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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-27 10:31:33 +02:00
co-authored by Claude Opus 5.5
parent c90cf474fe
commit 1973361244
9 changed files with 797 additions and 5 deletions
+8 -2
View File
@@ -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):
@@ -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; }
+34 -1
View File
@@ -16,6 +16,7 @@
#include <math.h>
#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<TileCache>(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); }
@@ -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();
@@ -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; }
+3 -1
View File
@@ -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();
@@ -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;
}
@@ -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 <typename F> 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
+355
View File
@@ -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('<H', 0))
count = 0
for cls, col, parts in fl:
for i in range(0, len(parts), 255): # nparts is a byte
chunk = parts[i:i + 255]
qs = []
for part in chunk:
q = []
for x, y in part:
p = (int(round(x)), int(round(y)))
if not q or q[-1] != p:
q.append(p)
qs.append(q)
allq = [p for q in qs for p in q]
buf += struct.pack('<BBHhhhh', cls, len(chunk), col, min(p[0] for p in allq), min(p[1] for p in allq),
max(p[0] for p in allq), max(p[1] for p in allq))
for q in qs:
buf += struct.pack('<H', len(q))
for x, y in q:
buf += struct.pack('<hh', x, y)
count += 1
struct.pack_into('<H', buf, 4, min(count, 65535))
d = os.path.join(a.out, str(dz), str(tx))
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, f'{ty}.vt'), 'wb') as f:
f.write(buf)
total_bytes += len(buf)
total_tiles += 1
with open(os.path.join(a.out, 'attribution.txt'), 'w') as f:
f.write('© OpenStreetMap contributors (ODbL)\n')
print(f'{total_tiles} tiles, {total_bytes / 1024:.0f} KB', file=sys.stderr)
if __name__ == '__main__':
main()