From 1973361244ea5d7228f47a076d18f689db75c44e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:31:33 +0200 Subject: [PATCH] feat(ui-lvgl): vector map spike (own VT2 format from OSM) tools/maps/osm_vector.py turns Overpass JSON into VT2 tiles (data zooms 10/12/14, per-feature bbox, simplified); VectorTileProvider draws them into 256 px tiles (scanline polygons, thick lines with round joins) with a small PSRAM cache of data files. Behind Map tools > Vector map (test), raster where there is no vector data. Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-roadmap.md | 10 +- examples/companion_radio/ui-lvgl/LvglPort.h | 6 + examples/companion_radio/ui-lvgl/MapScreen.h | 35 +- examples/companion_radio/ui-lvgl/NavMap.h | 6 + examples/companion_radio/ui-lvgl/UITask.cpp | 2 + examples/companion_radio/ui-lvgl/UITask.h | 4 +- .../ui-lvgl/map/TileProvider.h | 24 +- .../ui-lvgl/map/VectorTileProvider.h | 360 ++++++++++++++++++ tools/maps/osm_vector.py | 355 +++++++++++++++++ 9 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/map/VectorTileProvider.h create mode 100644 tools/maps/osm_vector.py diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index 7b98e119..98aeee2c 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -254,8 +254,14 @@ Ideas to come back to (2026-09-26): - [x] A grid under the map (and the minimap), its step a round distance in the metric / imperial unit, with a scale bar: position and scale where no tile is loaded. -- [ ] Vector maps: spike first (own compact format from OSM, roads / trails - / water / forest, no labels; measure render time). +- [x] Vector maps spike: own VT2 format from OSM (tools/maps/osm_vector.py, + Overpass JSON -> /sdcard/vmap, data zooms 10/12/14, bbox per feature), + scanline polygons + thick lines drawn into the 256 px tile; behind Map + tools > Vector map (test), raster where no vector data. Measured on the + L2: 8-60 ms drawing, SD read ~30 ms per new data file (cached after). +- [ ] Vector maps next: delta / varint coordinates (smaller files, faster + read), labels and POIs (peaks, shelters, springs), contours, a PBF + pipeline for regions + download on the device, styling. User list, second batch (2026-09-26): diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index 75549636..edcd65ea 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -188,6 +188,9 @@ static void setLiveTiles(bool on) { nvs::putBool("mc_wifi", "live", on); } // Map tools > Hiking trails: the Waymarked Trails overlay over the map. static bool trailsOn() { return nvs::getBool("mc_ui", "trails", false); } static void setTrailsOn(bool on) { nvs::putBool("mc_ui", "trails", on); } +// Map tools > Vector map (test). +static bool vectorOn() { return nvs::getBool("mc_ui", "vector", false); } +static void setVectorOn(bool on) { nvs::putBool("mc_ui", "vector", on); } // Screen-lock PIN (Settings > Display & power > Screen PIN): digits, "" = none. static void loadPin(char* out, size_t n) { nvs::getStr("mc_lock", "pin", out, n); } @@ -522,6 +525,9 @@ static void setLiveTiles(bool on) { s_live_tiles = on; } static bool s_trails = false; static bool trailsOn() { return s_trails; } static void setTrailsOn(bool on) { s_trails = on; } +static bool s_vector = false; +static bool vectorOn() { return s_vector; } +static void setVectorOn(bool on) { s_vector = on; } static bool s_net_on = false; static void netBegin(const char*, const char*) { s_net_on = true; } static int netState() { return NET_UP; } diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index ed039302..ceb3823e 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -16,6 +16,7 @@ #include #include "map/TileProvider.h" +#include "map/VectorTileProvider.h" #include "map/TileCache.h" #include "map/LiveCache.h" #include "map/TileDownloader.h" @@ -24,7 +25,8 @@ namespace mapview { static RasterTileProvider s_raster("/sdcard/maps", LIVE_ROOT); -static TileProvider* s_provider = &s_raster; // the one place to swap in a vector renderer +static VectorTileProvider s_vector(s_raster); // spike: vector data where there is some, raster elsewhere +static TileProvider* s_provider = &s_raster; // Map tools > Vector map (test) swaps it static TileCache& s_cache = *new (psramBuf(1)) TileCache(); // decoded tiles, in PSRAM static TileDownloader s_dl("/sdcard/maps"); static AreaStore s_areas("/sdcard/maps"); @@ -267,6 +269,7 @@ void UITask::openMap(bool nav) { void UITask::showMap() { _screen = SCR_MAP; mapview::s_trails_on = lvport::trailsOn(); + mapview::s_provider = lvport::vectorOn() ? (mapview::TileProvider*)&mapview::s_vector : &mapview::s_raster; mapLiveBegin(); mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); if (_map_z == 0) { // first open: own position, else the node's own advert position, else Poland @@ -485,6 +488,13 @@ void UITask::layoutMap() { if (_map_nav) layoutNav(); if (over) lv_label_set_text_fmt(_map_zoom_lbl, "z%d (map z%d)", _map_z, _map_z - over); // magnified + else if (mapview::s_provider == &mapview::s_vector) // spike: how long a tile takes + { + uint32_t ld, fl, ln; + mapview::s_vector.lastSplit(ld, fl, ln); + lv_label_set_text_fmt(_map_zoom_lbl, "z%d vt %lu ms (r%lu a%lu l%lu)", _map_z, (unsigned long)mapview::s_vector.lastMs(), + (unsigned long)ld, (unsigned long)fl, (unsigned long)ln); + } else lv_label_set_text_fmt(_map_zoom_lbl, "z%d", _map_z); const char* hint = !have_provider ? "No map on the SD card.\nPut tiles in /maps (tools/maps)." : mapview::s_dl.liveQueued() > 0 ? nullptr // being fetched @@ -910,6 +920,16 @@ void UITask::setTrails(bool on) { else showToast("Download an area again to add its trails", 3000); } +// Map tools > Vector map (test): /sdcard/vmap drawn on the device where it has data. +void UITask::setVectorMap(bool on) { + lvport::setVectorOn(on); + mapview::s_provider = on ? (mapview::TileProvider*)&mapview::s_vector : &mapview::s_raster; + mapview::s_cache.invalidate(); + mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); + if (_screen == SCR_MAP) layoutMap(); + showToast(on ? "Vector map on (where /vmap has data)" : "Vector map off"); +} + void UITask::setLiveTiles(bool on) { lvport::setLiveTiles(on); if (on) { @@ -998,6 +1018,19 @@ void UITask::mapDownloadTick() { if (dl.active() && _screen == SCR_MAP && dl.downloaded() > 0) { mapview::s_cache.forgetMissing(); layoutMap(); } } +// Sim / tests: the Navigation map at "lat,lon,z", not following the GPS. +void UITask::simMapAt(const char* spec) { + double lat = 0, lon = 0; + int z = 14; + if (sscanf(spec, "%lf,%lf,%d", &lat, &lon, &z) < 2) return; + openMap(true); + _map_z = z; + _map_cx = mapview::lonToTileX(lon, z); + _map_cy = mapview::latToTileY(lat, z); + _map_follow = false; + layoutMap(); +} + #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) // Sim page / tests: a saved WiFi network without going through Settings > WiFi. extern "C" EMSCRIPTEN_KEEPALIVE void sim_wifi_save(const char* ssid, const char* pass) { lvport::saveWifi(ssid, pass); } diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 4861316b..fc040617 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -876,6 +876,9 @@ static void onNavTools(lv_event_t* e) { (void)e; s_ui->navToolsPopup(); } static void onTrails(lv_event_t* e) { s_ui->setTrails(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); } +static void onVectorMap(lv_event_t* e) { + s_ui->setVectorMap(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} static void onLiveTiles(lv_event_t* e) { s_ui->setLiveTiles(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); } @@ -973,6 +976,9 @@ void UITask::navToolsPopup() { lv_obj_t* tsw = switchRow(g, "Hiking trails", "Marked routes in their colours", nullptr); if (lvport::trailsOn()) lv_obj_add_state(tsw, LV_STATE_CHECKED); lv_obj_add_event_cb(tsw, onTrails, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_t* vsw = switchRow(g, "Vector map (test)", "Drawn on the device from /vmap", nullptr); + if (lvport::vectorOn()) lv_obj_add_state(vsw, LV_STATE_CHECKED); + lv_obj_add_event_cb(vsw, onVectorMap, LV_EVENT_VALUE_CHANGED, NULL); listRow(g, "Download an area", "Pick it with a frame, for use offline", onNavTool, (void*)(uintptr_t)navmap::TL_DOWNLOAD); char ar[40]; int na = mapview::s_areas.count(); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index d1037cd4..b77d0129 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -3100,6 +3100,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; } if (!strncmp(name, "page", 4)) { s_ui->showSchemaSettings(atoi(name + 4)); return; } if (!strcmp(name, "map")) { s_ui->openMap(true); return; } + if (!strncmp(name, "map@", 4)) { s_ui->simMapAt(name + 4); return; } // "map@lat,lon,z" + if (!strcmp(name, "vector")) { s_ui->setVectorMap(true); return; } if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; } if (!strcmp(name, "areasel")) { s_ui->areaSelectBegin(); return; } if (!strcmp(name, "areas")) { s_ui->mapAreasPopup(); return; } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index ca630c3b..facc7ee0 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -91,7 +91,9 @@ public: void mapDownloadDiscard(); void mapDownloadZmax(int delta); void setLiveTiles(bool on); - void setTrails(bool on); // Map tools > Live tiles + void setTrails(bool on); + void setVectorMap(bool on); + void simMapAt(const char* spec); // Map tools > Live tiles // Navigation map (NavMap.h) void navTargetsPopup(); void navClosePopup(); diff --git a/examples/companion_radio/ui-lvgl/map/TileProvider.h b/examples/companion_radio/ui-lvgl/map/TileProvider.h index 4d3fff60..e255ae72 100644 --- a/examples/companion_radio/ui-lvgl/map/TileProvider.h +++ b/examples/companion_radio/ui-lvgl/map/TileProvider.h @@ -42,6 +42,28 @@ static const char* const TRAILS_ATTR = "Trails \xC2\xA9 waymarkedtrails.org (CC- static const int TRAILS_MAX_Z = 18; static bool s_trails_on = false; // Map tools > Hiking trails (NVS), set when the map opens +// fread into a PSRAM buffer: the SD card can't DMA there, so the driver +// falls back to one 512-byte sector per transfer. Read in chunks through +// internal (DMA-capable) memory instead -- several times faster. +static bool readFast(FILE* f, uint8_t* dst, size_t len) { +#if defined(ESP32) + const size_t CHUNK = 16 * 1024; + uint8_t* bounce = (uint8_t*)heap_caps_malloc(len < CHUNK ? len : CHUNK, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (bounce) { + size_t done = 0; + while (done < len) { + size_t n = len - done < CHUNK ? len - done : CHUNK; + if (fread(bounce, 1, n, f) != n) break; + memcpy(dst + done, bounce, n); + done += n; + } + heap_caps_free(bounce); + return done == len; + } +#endif + return fread(dst, 1, len, f) == len; +} + // PNG -> RGBA (LVGL's lodepng: the result is an lv_draw_buf_t*, destroy it). static lv_draw_buf_t* decodePng(const uint8_t* png, size_t len) { unsigned char* res = nullptr; @@ -175,7 +197,7 @@ private: if (len == 0 || len > 512 * 1024) return nullptr; uint8_t* buf = (uint8_t*)lv_malloc(len); if (!buf) return nullptr; - if (fseek(f, off, SEEK_SET) != 0 || fread(buf, 1, len, f) != len) { lv_free(buf); return nullptr; } + if (fseek(f, off, SEEK_SET) != 0 || !readFast(f, buf, len)) { lv_free(buf); return nullptr; } return buf; } diff --git a/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h b/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h new file mode 100644 index 00000000..b020d349 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h @@ -0,0 +1,360 @@ +#pragma once +// Vector map (spike): tiles from tools/maps/osm_vector.py under VECTOR_ROOT, +// {dz}/{x}/{y}.vt at data zooms 10, 12 and 14, drawn here into the 256x256 RGB565 +// buffer the map asks for -- polygons by scanline (even-odd), lines as quads +// with round joins. No anti-aliasing, no labels yet. Where there is no vector +// data (or below zoom 10) the raster provider draws instead, so the two mix. +// +// renderTile() times itself (lastMs()); the map shows it while this provider +// is on, to judge whether the approach holds on the ESP32-S3. +// +// Single-TU fragment: included by ui-lvgl/MapScreen.h after TileProvider.h. + +namespace mapview { + +static inline int32_t vmin(int32_t a, int32_t b) { return a < b ? a : b; } +static inline int32_t vmax(int32_t a, int32_t b) { return a > b ? a : b; } + +static const char* const VECTOR_ROOT = "/sdcard/vmap"; + +class VectorTileProvider : public TileProvider { +public: + explicit VectorTileProvider(TileProvider& fallback) : _fb(fallback) {} + + bool available() override { + struct stat st; + _have = stat(VECTOR_ROOT, &st) == 0 && S_ISDIR(st.st_mode); + bool fb = _fb.available(); + return _have || fb; + } + + bool renderTile(int z, int x, int y, uint16_t* out) override { + if (!_have || z < 10) return _fb.renderTile(z, x, y, out); + uint32_t t0 = micros(); + int dz = z >= 14 ? 14 : z >= 12 ? 12 : 10, k = z - dz; + _t_fill = _t_line = 0; + if (!loadData(dz, x >> k, y >> k)) return _fb.renderTile(z, x, y, out); + _t_load = micros() - t0; + int span = EXTENT >> k; + _ox = (x & ((1 << k) - 1)) * span; + _oy = (y & ((1 << k) - 1)) * span; + _k = k; + _z = z; + _out = out; + for (int i = 0; i < TILE_PX * TILE_PX; i++) out[i] = PAPER; + for (int pass = 0; pass < 4; pass++) drawPass(pass); + _last_ms = (micros() - t0 + 500) / 1000; + return true; + } + + const char* attribution() const override { return _have ? "\xC2\xA9 OpenStreetMap contributors (ODbL)" : _fb.attribution(); } + uint32_t lastMs() const { return _last_ms; } + // The last tile's time split: reading the data, areas, lines (ms). + void lastSplit(uint32_t& load, uint32_t& fill, uint32_t& line) const { load = _t_load / 1000; fill = _t_fill / 1000; line = _t_line / 1000; } + +private: + static const int EXTENT = 4096; + static const uint16_t PAPER = 0xF77C; // #F2EFE9-ish + + TileProvider& _fb; + bool _have = false; + const uint8_t* _data = nullptr; // the data tile being drawn + size_t _data_len = 0; + int _ox = 0, _oy = 0, _k = 0, _z = 0; + uint16_t* _out = nullptr; + uint32_t _last_ms = 0; + + // Scratch for the polygon filler: edges (in 1/16 px), crossings of a row. + struct Edge { int16_t r0, r1; int64_t x, dx; }; // rows [r0, r1), x / step 16.16 in 1/16 px + static const int ACTIVE = 512; + Edge* _edges = nullptr; + int _edge_cap = 0, _ne = 0; + Edge* _act[ACTIVE]; + int32_t _xs[ACTIVE]; + uint32_t _t_load = 0, _t_fill = 0, _t_line = 0; // us, last tile + + static uint16_t rgb(uint32_t c) { return (uint16_t)(((c >> 8) & 0xF800) | ((c >> 5) & 0x07E0) | ((c >> 3) & 0x1F)); } + + // The data tiles last read (PSRAM), least recently used replaced: the map + // draws several tiles out of each. + struct Data { uint8_t* buf = nullptr; size_t len = 0, cap = 0; int dz = -1, x = -1, y = -1; bool ok = false; uint32_t used = 0; }; + static const int DATA_SLOTS = 4; + Data _slot[DATA_SLOTS]; + uint32_t _tick = 0; + + bool loadData(int dz, int x, int y) { + Data* d = nullptr; + for (Data& s : _slot) if (s.dz == dz && s.x == x && s.y == y) d = &s; + if (d) { d->used = ++_tick; _t_load = 0; _data = d->buf; _data_len = d->len; return d->ok; } + d = &_slot[0]; + for (Data& s : _slot) if (s.used < d->used) d = &s; + d->dz = dz; d->x = x; d->y = y; d->ok = false; d->used = ++_tick; + char path[64]; + snprintf(path, sizeof(path), "%s/%d/%d/%d.vt", VECTOR_ROOT, dz, x, y); + FILE* f = fopen(path, "rb"); + if (!f) return false; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz < 6 || sz > 2 * 1024 * 1024) { fclose(f); return false; } + if ((size_t)sz > d->cap) { + if (d->buf) free(d->buf); +#if defined(ESP32) + d->buf = (uint8_t*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); +#else + d->buf = (uint8_t*)malloc(sz); +#endif + d->cap = d->buf ? sz : 0; + if (!d->buf) { fclose(f); return false; } + } + d->len = readFast(f, d->buf, sz) ? sz : 0; + fclose(f); + d->ok = d->len == (size_t)sz && memcmp(d->buf, "VT2", 3) == 0; + _data = d->buf; + _data_len = d->len; + return d->ok; + } + + // Styles. Widths in px at zoom 14, scaled with the zoom. + enum : uint8_t { L_STREAM = 20, L_RIVER = 21, L_PATH = 30, L_TRACK = 31, L_SERVICE = 32, L_TRUNK = 37, L_ROUTE = 50 }; + static uint16_t polyColour(uint8_t c) { + switch (c) { + case 1: return rgb(0xE6DED6); // residential + case 2: return rgb(0xE1ECC6); // meadow + case 3: return rgb(0xCFE2B2); // scrub + case 4: return rgb(0xB6D59A); // forest + case 5: return rgb(0xE2DED8); // rock / scree + case 6: return rgb(0xA6CBE0); // water + case 7: return rgb(0xD4C8BC); // building + } + return PAPER; + } + static bool lineStyle(uint8_t c, uint16_t& col, float& w) { + switch (c) { + case L_STREAM: col = rgb(0x86B6D8); w = 1.0f; return true; + case L_RIVER: col = rgb(0x86B6D8); w = 3.0f; return true; + case L_PATH: col = rgb(0xA8502A); w = 1.2f; return true; + case L_TRACK: col = rgb(0x94683A); w = 1.6f; return true; + case 32: col = 0xFFFF; w = 2.2f; return true; // service + case 33: col = 0xFFFF; w = 3.2f; return true; // minor + case 34: col = rgb(0xFFFBB0); w = 3.8f; return true; // tertiary + case 35: col = rgb(0xF7D38A); w = 4.2f; return true; // secondary + case 36: col = rgb(0xF2A860); w = 4.8f; return true; // primary + case 37: col = rgb(0xE8808A); w = 5.2f; return true; // trunk + case L_ROUTE: col = 0; w = 3.0f; return true; + } + return false; + } + float zoomScale() const { + static const float S[] = { 0.4f, 0.45f, 0.6f, 0.8f, 1.0f, 1.3f, 1.7f, 2.2f, 2.8f }; // z10..z18 + int i = _z - 10; + return S[i < 0 ? 0 : i > 8 ? 8 : i]; + } + + // Passes: 0 areas + water / paths / tracks, 1 road casings, 2 road fills, + // 3 hiking routes -- so roads join cleanly and routes stay on top. + void drawPass(int pass) { + const uint8_t* p = _data + 6; + const uint8_t* end = _data + _data_len; + uint16_t count; + memcpy(&count, _data + 4, 2); + for (int f = 0; f < count && p + 12 <= end; f++) { + uint8_t cls = p[0], nparts = p[1]; + uint16_t col; + int16_t bb[4]; + memcpy(&col, p + 2, 2); + memcpy(bb, p + 4, 8); + p += 12; + const uint8_t* parts = p; + for (int i = 0; i < nparts && p + 2 <= end; i++) { // skip to the next feature + uint16_t n; + memcpy(&n, p, 2); + p += 2 + 4 * n; + } + if (p > end) return; + bool road = cls >= L_SERVICE && cls <= L_TRUNK; + bool want = pass == 0 ? (cls < L_SERVICE) : pass == 3 ? cls == L_ROUTE : road; + if (!want) continue; + if (_z < 13 && (cls == L_PATH || cls == 7)) continue; // paths from z13, buildings from z14 (data) + // Off the drawn tile (with a margin for the widest line): not even read. + const int32_t M = 12 * 16, S = TILE_PX * 16; + if (sx(bb[2]) < -M || sx(bb[0]) > S + M || sy(bb[3]) < -M || sy(bb[1]) > S + M) continue; + uint32_t t0 = micros(); + if (cls < 10) { fillFeature(parts, nparts, polyColour(cls)); _t_fill += micros() - t0; continue; } + uint16_t c; + float w; + if (!lineStyle(cls, c, w)) continue; + w *= zoomScale(); + if (cls == L_ROUTE) { c = col ? col : rgb(0xD04040); w = w < 2.5f ? 2.5f : w; } + if (pass == 1) { c = rgb(0xB4ACA2); w += 1.6f; } // casing + strokeFeature(parts, nparts, c, w); + _t_line += micros() - t0; + } + } + + // Tile units -> 1/16 px of the drawn tile. + inline int32_t sx(int16_t u) const { return (int32_t)(u - _ox) << _k; } + inline int32_t sy(int16_t u) const { return (int32_t)(u - _oy) << _k; } + + bool reserveEdges(int n) { + if (n <= _edge_cap) return true; + int cap = n + 256; + Edge* e = (Edge*)realloc(_edges, sizeof(Edge) * cap); + if (!e) return false; + _edges = e; + _edge_cap = cap; + return true; + } + // An edge, top to bottom, with its x at the first row centre it crosses and + // its slope per row (both 16.16 px). + void addEdge(int32_t x0, int32_t y0, int32_t x1, int32_t y1) { + if (y0 == y1) return; + if (y0 > y1) { int32_t t = x0; x0 = x1; x1 = t; t = y0; y0 = y1; y1 = t; } + int r0 = (y0 - 8 + 15) >> 4, r1 = (y1 - 8 + 15) >> 4; // rows r0 .. r1-1 (centre in [y0, y1)) + if (r1 <= r0 || r1 <= 0 || r0 >= TILE_PX) return; + if (_ne >= _edge_cap && !reserveEdges(_ne + 1)) return; + int64_t slope = ((int64_t)(x1 - x0) << 16) / (y1 - y0); // 1/16 px of x per 1/16 px of y, 16.16 + int32_t yc = r0 * 16 + 8; + Edge& e = _edges[_ne++]; + e.r0 = r0; e.r1 = r1; + e.x = ((int64_t)x0 << 16) + slope * (yc - y0); + e.dx = slope * 16; + } + + static int cmpEdge(const void* a, const void* b) { return ((const Edge*)a)->r0 - ((const Edge*)b)->r0; } + + // Even-odd scanline fill of the edges collected (an active edge list). + void fillEdges(uint16_t col) { + if (!_ne) return; + if (_ne > 4) qsort(_edges, _ne, sizeof(Edge), cmpEdge); + else for (int i = 1; i < _ne; i++) { Edge v = _edges[i]; int j = i - 1; while (j >= 0 && _edges[j].r0 > v.r0) { _edges[j + 1] = _edges[j]; j--; } _edges[j + 1] = v; } + int next = 0, na = 0; + int row = _edges[0].r0 < 0 ? 0 : _edges[0].r0; + int rmax = 0; + for (int i = 0; i < _ne; i++) if (_edges[i].r1 > rmax) rmax = _edges[i].r1; + if (rmax > TILE_PX) rmax = TILE_PX; + for (; row < rmax; row++) { + while (next < _ne && _edges[next].r0 <= row) { // edges starting by this row join + Edge& e = _edges[next++]; + if (e.r1 <= row) continue; + if (e.r0 < row) e.x += e.dx * (row - e.r0); // started above the tile + if (na < ACTIVE) _act[na++] = &e; + } + int n = 0; + for (int i = 0; i < na;) { // drop finished edges, collect crossings + Edge* e = _act[i]; + if (e->r1 <= row) { _act[i] = _act[--na]; continue; } + if (n < ACTIVE) _xs[n++] = (int32_t)(e->x >> 16); + e->x += e->dx; + i++; + } + if (n < 2) { if (!na && next >= _ne) break; continue; } + for (int i = 1; i < n; i++) { // insertion sort: a handful per row + int32_t v = _xs[i]; + int j = i - 1; + while (j >= 0 && _xs[j] > v) { _xs[j + 1] = _xs[j]; j--; } + _xs[j + 1] = v; + } + uint16_t* line = _out + row * TILE_PX; + for (int i = 0; i + 1 < n; i += 2) { + int a = (_xs[i] - 8 + 15) >> 4, b = (_xs[i + 1] - 8) >> 4; // centres inside + if (a < 0) a = 0; + if (b > TILE_PX - 1) b = TILE_PX - 1; + for (int x = a; x <= b; x++) line[x] = col; + } + } + } + + // Points of a part, in 1/16 px, closer than half a pixel to the last one + // dropped (the data is detailed enough for zoom 18). + template void forPoints(const uint8_t*& p, bool keep_last, F fn) { + uint16_t n; + memcpy(&n, p, 2); + p += 2; + int32_t lx = INT32_MIN, ly = 0; + for (int j = 0; j < n; j++) { + int16_t ux, uy; + memcpy(&ux, p + 4 * j, 2); memcpy(&uy, p + 4 * j + 2, 2); + int32_t x = sx(ux), y = sy(uy); + if (lx != INT32_MIN && abs(x - lx) + abs(y - ly) < 8 && !(keep_last && j == n - 1)) continue; + fn(x, y, lx == INT32_MIN); + lx = x; ly = y; + } + p += 4 * n; + } + + void fillFeature(const uint8_t* p, int nparts, uint16_t col) { + _ne = 0; + for (int i = 0; i < nparts; i++) { + int32_t fx = 0, fy = 0, px = 0, py = 0; + forPoints(p, false, [&](int32_t x, int32_t y, bool first) { + if (first) { fx = px = x; fy = py = y; return; } + addEdge(px, py, x, y); + px = x; py = y; + }); + addEdge(px, py, fx, fy); // closed + } + fillEdges(col); + } + + void disc(int32_t cx, int32_t cy, int32_t r16, uint16_t col) { // radius in 1/16 px + int x0 = (cx - r16) >> 4, x1 = (cx + r16) >> 4, y0 = (cy - r16) >> 4, y1 = (cy + r16) >> 4; + if (x1 < 0 || y1 < 0 || x0 >= TILE_PX || y0 >= TILE_PX) return; + int32_t r2 = r16 * r16; + for (int y = vmax(0, y0); y <= vmin(TILE_PX - 1, y1); y++) { + int32_t dy = y * 16 + 8 - cy; + uint16_t* line = _out + y * TILE_PX; + for (int x = vmax(0, x0); x <= vmin(TILE_PX - 1, x1); x++) { + int32_t dx = x * 16 + 8 - cx; + if (dx * dx + dy * dy <= r2) line[x] = col; + } + } + } + + // A thin line: a DDA with a 1 or 2 px pen. + void thinSegment(int32_t ax, int32_t ay, int32_t bx, int32_t by, int pen, uint16_t col) { + int32_t dx = bx - ax, dy = by - ay; + int steps = (vmax(abs(dx), abs(dy)) >> 4) + 1; + int32_t x = ax << 8, y = ay << 8, ix = (dx << 8) / steps, iy = (dy << 8) / steps; // 1/16 px << 8 + for (int i = 0; i <= steps; i++, x += ix, y += iy) { + int px = x >> 12, py = y >> 12; + for (int oy = 0; oy < pen; oy++) + for (int ox = 0; ox < pen; ox++) { + int qx = px + ox, qy = py + oy; + if ((unsigned)qx < (unsigned)TILE_PX && (unsigned)qy < (unsigned)TILE_PX) _out[qy * TILE_PX + qx] = col; + } + } + } + + void strokeFeature(const uint8_t* p, int nparts, uint16_t col, float w) { + int32_t h = (int32_t)(w * 8); // half width, 1/16 px + const int32_t S = TILE_PX * 16; + bool thin = w < 2.2f; + int pen = w < 1.8f ? 1 : 2; + for (int i = 0; i < nparts; i++) { + int32_t ax = 0, ay = 0; + forPoints(p, true, [&](int32_t bx, int32_t by, bool first) { + if (!first && !(vmax(ax, bx) < -h || vmin(ax, bx) > S + h || vmax(ay, by) < -h || vmin(ay, by) > S + h)) { + if (thin) { + thinSegment(ax, ay, bx, by, pen, col); + } else { + float dx = bx - ax, dy = by - ay, len = sqrtf(dx * dx + dy * dy); + if (len > 0) { + int32_t nx = (int32_t)(-dy / len * h), ny = (int32_t)(dx / len * h); + _ne = 0; + addEdge(ax + nx, ay + ny, bx + nx, by + ny); + addEdge(bx + nx, by + ny, bx - nx, by - ny); + addEdge(bx - nx, by - ny, ax - nx, ay - ny); + addEdge(ax - nx, ay - ny, ax + nx, ay + ny); + fillEdges(col); + } + if (w >= 2.8f) disc(bx, by, h, col); // round join + } + } + ax = bx; ay = by; + }); + } + } +}; + +} // namespace mapview diff --git a/tools/maps/osm_vector.py b/tools/maps/osm_vector.py new file mode 100644 index 00000000..ed364d2a --- /dev/null +++ b/tools/maps/osm_vector.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""OSM data -> vector map tiles for the Wio Tracker L2 (spike). + +Reads an Overpass JSON export (`out geom;`) and writes small binary tiles the +device rasterises itself (ui-lvgl/map/VectorTileProvider.h): roads, paths, +marked hiking routes in their waymark colours, water, forest, meadows, rock, +buildings. No labels yet. + + tools/maps/osm_vector.py area.json --out vmap/ + +Three data zooms: 10 (drawn at z10-11), 12 (z12-13), 14 (z14-18); the device picks the +data tile covering the tile it draws and scales it. Copy the output folder to +the card as /sdcard/vmap. + +Tile format 'VT2' (little-endian): + 'V' 'T' '2' dz:u8 count:u16 + count x feature, in drawing order: + cls:u8 nparts:u8 colour:u16 (RGB565, 0 = the class's own) + bbox: x0 y0 x1 y1 (i16) -- the device skips what's off the tile unread + nparts x (npts:u16, npts x (x:i16, y:i16)) + Coordinates: 0..4096 across the data tile, a little past its edges. + Polygons: rings, even-odd. Lines: polylines. + +Get the data for a box (south, west, north, east) from Overpass, e.g.: + [out:json][timeout:120]; + ( way["highway"](S,W,N,E); way["waterway"~"river|stream|canal"](S,W,N,E); + way["natural"~"water|wood|scrub|grassland|heath|scree|bare_rock"](S,W,N,E); + way["landuse"~"forest|meadow|grass|residential|farmland"](S,W,N,E); + way["building"](S,W,N,E); + relation["natural"~"water|wood|scrub"](S,W,N,E); + relation["landuse"~"forest|meadow"](S,W,N,E); + relation["route"="hiking"](S,W,N,E); ); + out geom; +Map data (c) OpenStreetMap contributors, ODbL. +""" +import argparse, json, math, os, struct, sys +from collections import defaultdict + +EXTENT = 4096 +BUFFER = 128 +DATA_ZOOMS = (10, 12, 14) # drawn at z10-11, z12-13, z14-18 + +# Classes: the draw order is the number's order (the device styles them). +P_RESIDENTIAL, P_MEADOW, P_SCRUB, P_FOREST, P_ROCK, P_WATER, P_BUILDING = 1, 2, 3, 4, 5, 6, 7 +L_STREAM, L_RIVER = 20, 21 +L_PATH, L_TRACK, L_SERVICE, L_MINOR, L_TERTIARY, L_SECONDARY, L_PRIMARY, L_TRUNK = 30, 31, 32, 33, 34, 35, 36, 37 +L_ROUTE = 50 + +HIGHWAY = { + 'path': L_PATH, 'footway': L_PATH, 'steps': L_PATH, 'bridleway': L_PATH, 'cycleway': L_PATH, + 'track': L_TRACK, + 'service': L_SERVICE, 'living_street': L_SERVICE, 'pedestrian': L_SERVICE, + 'residential': L_MINOR, 'unclassified': L_MINOR, 'road': L_MINOR, + 'tertiary': L_TERTIARY, 'tertiary_link': L_TERTIARY, + 'secondary': L_SECONDARY, 'secondary_link': L_SECONDARY, + 'primary': L_PRIMARY, 'primary_link': L_PRIMARY, + 'trunk': L_TRUNK, 'trunk_link': L_TRUNK, 'motorway': L_TRUNK, 'motorway_link': L_TRUNK, +} +# Lowest data zoom a class goes in (smaller ones would be clutter / weight). +MIN_DZ = {P_BUILDING: 14, L_SERVICE: 14, L_PATH: 12, L_TRACK: 12, L_STREAM: 12, L_MINOR: 12} + +WAYMARK = { # osmc:symbol / colour -> RGB888 + 'red': 0xE0302A, 'blue': 0x2A5FE0, 'green': 0x2EA043, 'yellow': 0xE8C20E, 'black': 0x202020, + 'orange': 0xF08A1C, 'purple': 0x9040C0, 'white': 0xF0F0F0, 'brown': 0x8B5A2B, +} + + +def rgb565(c): + return ((c >> 8) & 0xF800) | ((c >> 5) & 0x07E0) | ((c >> 3) & 0x001F) + + +def poly_class(t): + n, lu = t.get('natural'), t.get('landuse') + if t.get('building'): + return P_BUILDING + if n == 'water' or t.get('water'): + return P_WATER + if n == 'wood' or lu == 'forest': + return P_FOREST + if n == 'scrub': + return P_SCRUB + if n in ('scree', 'bare_rock'): + return P_ROCK + if n in ('grassland', 'heath') or lu in ('meadow', 'grass', 'farmland'): + return P_MEADOW + if lu == 'residential': + return P_RESIDENTIAL + return None + + +def line_class(t): + if 'highway' in t: + return HIGHWAY.get(t['highway']) + w = t.get('waterway') + if w in ('river', 'canal'): + return L_RIVER + if w == 'stream': + return L_STREAM + return None + + +def route_colour(t): + c = t.get('colour', '').lower() + if c in WAYMARK: + return WAYMARK[c] + sym = t.get('osmc:symbol', '') + if sym: + first = sym.split(':')[0].lower() + if first in WAYMARK: + return WAYMARK[first] + # "white:red_bar" style: the bar's colour + for part in sym.split(':')[1:]: + k = part.split('_')[0].lower() + if k in WAYMARK and k != 'white': + return WAYMARK[k] + return 0xD04040 + + +def world(lon, lat): + """Web Mercator 0..1.""" + s = math.sin(math.radians(lat)) + return (lon + 180.0) / 360.0, 0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi) + + +def geom_pts(g): + return [world(p['lon'], p['lat']) for p in g if p] + + +def join_rings(segments): + """Member ways of a multipolygon -> closed rings (by shared end points).""" + segs = [list(s) for s in segments if len(s) >= 2] + rings = [] + while segs: + ring = segs.pop() + changed = True + while ring[0] != ring[-1] and changed: + changed = False + for i, s in enumerate(segs): + if s[0] == ring[-1]: + ring += s[1:] + elif s[-1] == ring[-1]: + ring += s[-2::-1] + elif s[-1] == ring[0]: + ring = s[:-1] + ring + elif s[0] == ring[0]: + ring = s[:0:-1] + ring + else: + continue + segs.pop(i) + changed = True + break + if len(ring) >= 4 and ring[0] == ring[-1]: + rings.append(ring) + return rings + + +def simplify(pts, tol): + """Douglas-Peucker on tile units.""" + if len(pts) < 3 or tol <= 0: + return pts + keep = [False] * len(pts) + keep[0] = keep[-1] = True + stack = [(0, len(pts) - 1)] + t2 = tol * tol + while stack: + a, b = stack.pop() + ax, ay = pts[a] + bx, by = pts[b] + dx, dy = bx - ax, by - ay + L = dx * dx + dy * dy + best, bi = -1, -1 + for i in range(a + 1, b): + px, py = pts[i] + if L == 0: + d = (px - ax) ** 2 + (py - ay) ** 2 + else: + t = max(0, min(1, ((px - ax) * dx + (py - ay) * dy) / L)) + d = (ax + t * dx - px) ** 2 + (ay + t * dy - py) ** 2 + if d > best: + best, bi = d, i + if best > t2: + keep[bi] = True + stack += [(a, bi), (bi, b)] + return [p for p, k in zip(pts, keep) if k] + + +def clip_line(pts, lo, hi): + """Polyline -> pieces inside the box (Liang-Barsky per segment).""" + out, cur = [], [] + for (x0, y0), (x1, y1) in zip(pts, pts[1:]): + t0, t1, dx, dy = 0.0, 1.0, x1 - x0, y1 - y0 + ok = True + for p, q in ((-dx, x0 - lo), (dx, hi - x0), (-dy, y0 - lo), (dy, hi - y0)): + if p == 0: + if q < 0: + ok = False + break + else: + r = q / p + if p < 0: + t0 = max(t0, r) + else: + t1 = min(t1, r) + if not ok or t0 > t1: + if len(cur) >= 2: + out.append(cur) + cur = [] + continue + a = (x0 + t0 * dx, y0 + t0 * dy) + b = (x0 + t1 * dx, y0 + t1 * dy) + if not cur or cur[-1] != a: + if len(cur) >= 2: + out.append(cur) + cur = [a] + cur.append(b) + if t1 < 1.0: + out.append(cur) + cur = [] + if len(cur) >= 2: + out.append(cur) + return out + + +def clip_ring(pts, lo, hi): + """Sutherland-Hodgman against the box.""" + def edge(pts, inside, cross): + out = [] + for i in range(len(pts)): + cur, prev = pts[i], pts[i - 1] + if inside(cur): + if not inside(prev): + out.append(cross(prev, cur)) + out.append(cur) + elif inside(prev): + out.append(cross(prev, cur)) + return out + + def cx(v): + return lambda a, b: (v, a[1] + (b[1] - a[1]) * (v - a[0]) / (b[0] - a[0])) + + def cy(v): + return lambda a, b: (a[0] + (b[0] - a[0]) * (v - a[1]) / (b[1] - a[1]), v) + + for inside, cross in ((lambda p: p[0] >= lo, cx(lo)), (lambda p: p[0] <= hi, cx(hi)), + (lambda p: p[1] >= lo, cy(lo)), (lambda p: p[1] <= hi, cy(hi))): + pts = edge(pts, inside, cross) + if not pts: + return [] + return pts + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('json') + ap.add_argument('--out', default='vmap') + a = ap.parse_args() + data = json.load(open(a.json)) + + feats = [] # (cls, colour, 'poly'|'line', [parts in world coords]) + for e in data['elements']: + t = e.get('tags', {}) + if e['type'] == 'way' and 'geometry' in e: + pts = geom_pts(e['geometry']) + closed = len(pts) >= 4 and pts[0] == pts[-1] + pc = poly_class(t) if closed and t.get('area') != 'no' and 'highway' not in t else None + if pc: + feats.append((pc, 0, 'poly', [pts])) + lc = line_class(t) + if lc and not pc: + feats.append((lc, 0, 'line', [pts])) + elif e['type'] == 'relation': + members = e.get('members', []) + if t.get('route') == 'hiking': + col = rgb565(route_colour(t)) + parts = [geom_pts(m['geometry']) for m in members if m.get('type') == 'way' and 'geometry' in m] + feats.append((L_ROUTE, col, 'line', [p for p in parts if len(p) >= 2])) + else: + pc = poly_class(t) + if not pc: + continue + outer = [geom_pts(m['geometry']) for m in members if m.get('role') in ('outer', '') and 'geometry' in m] + inner = [geom_pts(m['geometry']) for m in members if m.get('role') == 'inner' and 'geometry' in m] + rings = join_rings(outer) + join_rings(inner) + if rings: + feats.append((pc, 0, 'poly', rings)) + print(f'{len(feats)} features', file=sys.stderr) + + total_bytes = total_tiles = 0 + for dz in DATA_ZOOMS: + n = 1 << dz + tol = 1.0 if dz == DATA_ZOOMS[-1] else 4.0 # half a pixel at the deepest zoom drawn from it + tiles = defaultdict(list) + for cls, col, kind, parts in feats: + if MIN_DZ.get(cls, 0) > dz: + continue + allp = [p for part in parts for p in part] + if not allp: + continue + x0 = int(min(p[0] for p in allp) * n); x1 = int(max(p[0] for p in allp) * n) + y0 = int(min(p[1] for p in allp) * n); y1 = int(max(p[1] for p in allp) * n) + for tx in range(x0, x1 + 1): + for ty in range(y0, y1 + 1): + out = [] + for part in parts: + loc = [((p[0] * n - tx) * EXTENT, (p[1] * n - ty) * EXTENT) for p in part] + if kind == 'poly': + r = clip_ring(loc, -BUFFER, EXTENT + BUFFER) + r = simplify(r, tol) + if len(r) >= 3 and (max(p[0] for p in r) - min(p[0] for p in r) > 4 * tol or + max(p[1] for p in r) - min(p[1] for p in r) > 4 * tol): # not a speck + out.append(r) + else: + for piece in clip_line(loc, -BUFFER, EXTENT + BUFFER): + piece = simplify(piece, tol) + if len(piece) >= 2: + out.append(piece) + if out: + tiles[(tx, ty)].append((cls, col, out)) + for (tx, ty), fl in tiles.items(): + fl.sort(key=lambda f: f[0]) + buf = bytearray(b'VT2' + bytes([dz]) + struct.pack('