The geo/map subsystem — reasoning
Class:
reasoning. This file holds the why, the trade-offs, and every design we rejected. The descriptive shape we actually run is inmap-design.md, which by rule mentions none of this. If you want to know "why not X?" — X is here.
f0 — why three crates, not one
The obvious shape is one facett-map crate that does everything: read GeoParquet, project, cull, paint, pan/zoom. We split it into facett-osm (ingest) / facett-map (vocabulary + a static viewer) / facett-geomap (live viewers) instead, for two reasons: (1) facett-map::MapView predates the live-viewer need — korp's geo demo wanted scroll/zoom + hotspots, and rather than bolt a live viewport onto a component whose whole contract was "fit to rect, no camera", a new Facet (GeoMap) was written that reuses MapView's Way/MapPalette/tessellate types instead of duplicating them. (2) facett-osm's only job is decoding WKB bytes into the shared Way/point vocabulary — it has no egui dependency at all, so it can be exercised (and reused, e.g. by a headless ETL step) without pulling in a UI toolkit. The alternative — one mega-crate — was rejected because it would force every consumer (including a pure-ingest batch job) to compile egui/wgpu.
f0 — why one Mercator formula duplicated, not shared via a dependency
facett_map::mercator (facett-map/src/lib.rs:48) and facett_geomap::mercator (facett-geomap/src/lib.rs:49) are the same formula, copy-pasted rather than factored into a shared tiny crate. This was a deliberate non-decision, not an oversight: facett-geomap already depends on facett-map for Way/MapPalette/tessellate, so the projection could be re-exported from there — the duplication survives mainly because both call sites are one-line pure functions and a cross-crate re-export would buy negligible DRY for a real coupling cost (any future divergence in latitude clamping, say, would need co-ordination instead of being a local decision). If the formula ever needs a third home (e.g. facett-map3d), that is the trigger to extract it; until then, two copies of an 8-line pure function are cheaper than the coupling.
f1 — why the GPU line-basemap was ported, not written from scratch
The facett-map/src/gpu pipeline (GpuMapRenderer, cull.wgsl, draw.wgsl) is ported nearly verbatim from katana-osm's osm-viewer/src/render/gpu/mod.rs rather than redesigned for facett. The source pipeline (upload-once VRAM geometry + per-frame compute-shader frustum/LOD cull + draw_indirect) was already proven at country scale; writing a new one from scratch would have re-earned bugs (buffer layout mismatches, cull correctness, LOD boundary math) that were already paid for once. The only changes made: egui_wgpu/wgpu are imported as direct crates (the source went through eframe's re-exports) and build_gpu_geometry reads the facett Way's style.color instead of osm-viewer's style.stroke (a field-name difference, not a design change). Version note: source eframe 0.34.1 → wgpu 24; facett's egui 0.34.3 → egui-wgpu 0.34 → wgpu 29. The pipeline descriptors that changed shape across that gap (immediate_size, multiview_mask, cache, CallbackTrait::{prepare,paint}) were updated to the wgpu-29/egui-wgpu-0.34 shapes; the cull/draw algorithm did not change.
f1 — why the R*-tree was dropped from the ported index
osm-viewer's index.rs used static_aabb2d_index (a packed Hilbert R*-tree) for the CPU-side spatial query. facett's MapIndex (facett-map/src/index.rs) keeps only the LOD-tier split and replaces the R*-tree with a dependency-free brute-force query_visible. This was a deliberate downgrade, not an oversight: on the fast (GPU) path the compute shader does the real per-frame frustum cull over all ways regardless of any CPU index — the CPU query only matters for the CPU-fallback line painter and for gpu_ways()'s upload-time walk, neither of which is a per-frame hot loop over an unindexed set at the scale facett currently targets. Pulling in a whole R-tree crate to speed up a cold or CPU-fallback path that isn't the bottleneck was judged not worth the dependency; if a future CPU-only deployment needs to cull tens of millions of ways without a GPU, static_aabb2d_index (or rstar) is the drop-in replacement for query_visible.
f1 — why concave fill uses hand-rolled ear-clipping, not a polygon crate or convex_polygon
egui::Shape::convex_polygon was tried first for area fills and rejected outright: OSM area rings (lakes, parks, building footprints) are routinely concave, and a convex fan from the first vertex spills triangles outside the ring — the very first version of the basemap "looked like shit" for exactly this reason. Two paths were open from there: pull in a full polygon-triangulation crate (lyon, earcutr, …), or write the minimal thing facett actually needs. tessellate::triangulate (facett-map/src/tessellate.rs:44) is the second choice: ear clipping, O(n²), dependency-free, no rayon. The rings a single OSM way carries are small (tens to low-hundreds of vertices), so O(n²) is not a real cost, and a dependency-free implementation means no extra crate for a build target (wasm) that is already careful about its dependency graph. The test triangulates_a_concave_polygon_without_spilling_outside (facett-map/src/tessellate.rs:140) exists specifically because this is the corner-case convex_polygon got wrong.
f1 — why the L0 SDF points lane is opt-in, not the default
facett-map/src/l0_points.rs lowers the point layer into the shared facett_core::render::gpu::GpuSdfRenderer — instanced screen-aligned quads with a fragment-shader SDF, the millions-of-points path the render-engine roadmap calls for. It is deliberately not wired into MapView::view's default point painter. The default CPU circle_filled path is the one every render-proof golden test and the headless CPU harness exercise; swapping it for a GPU-only path would make every point-layer test either GPU-dependent or require a CPU fallback duplicated inside the same call site. Keeping L0 points as a separate opt-in module means a host that wants the GPU points lane calls it explicitly, and the proven CPU path is never at risk of a silent behavioural change from an unrelated GPU refactor.
f4 — why the GPU upload was split into chunks (fix #12)
The first GPU upload wrote one geom buffer sized to the whole way set. A whole-country upload (Sweden ≈ 314 MB of Vertex) exceeds wgpu's default max_buffer_size (256 MiB), so Device::create_buffer failed a Validation Error and panicked the first time a country-scale dataset was tried. The fix was not to shrink the geometry (LOD already exists for a different reason — visual detail, not buffer size) but to partition the upload: GpuMapRenderer::upload splits ways into chunks, each chunk's geom/meta/compact buffer kept ≤ device.limits().max_buffer_size, with a cull dispatch and a draw_indirect per chunk. The alternative — capping the total way count so it always fits one buffer — was rejected because it silently drops data as a function of the device's buffer limit, which varies across GPUs; chunking scales the upload to whatever device it's running on instead of imposing an arbitrary content cap. Ways are never split across a chunk boundary (each way's vertices live wholly in one chunk) specifically so the existing, proven per-way cull shader needed no changes — only the CPU-side partition and re-basing of vert_start per chunk.
f2 — why OsmView re-derives navigation feel instead of reusing GeoMap's
GeoMap and OsmView both pan and zoom, and could in principle share one camera type. They don't, because they solve different problems: GeoMap's viewport exists to frame a known, bounded dataset (fit-and-explore a country of points + hotspots) — a simple (center, zoom) in Mercator-unit space is enough, and its zoom multiplier has no absolute meaning beyond "how much more than fit". OsmView targets arbitrary OSM scale (down to street level on a whole-planet-scale dataset), where zoom needs an absolute meaning (pixels-per-Mercator-unit) so LOD tiers and point decimation can key off a stable number, and it needs damped/eased motion + inertial pan + per-OS keymap input that GeoMap's simpler drag/scroll handling has no need for. Unifying them into one camera would have forced the simpler GeoMap to carry damping/momentum/keymap state it never uses, or forced OsmView to give up the absolute-zoom LOD keying GeoMap doesn't need. Two purpose-built cameras, sharing only the underlying Mercator projection, was judged the lower-friction shape.
f2 — why PointGrid is a uniform grid, not an R-tree
Same reasoning as MapIndex (f1, above), applied to points instead of ways: OsmView::PointGrid is a dependency-free uniform grid sized to ~8 points/cell rather than a general spatial tree. OSM node distributions are not adversarially skewed at the scale this viewer targets (a country, not a synthetic point cloud clustered into one cell), so a uniform grid's O(visible-cells) query cost is effectively the same as a tree's for the datasets actually fed in, at zero extra dependency weight. An R*-tree (rstar) remains the documented drop-in if a future dataset's spatial skew makes the uniform grid's worst case (many points landing in one cell) a real problem.
f2 — why per-frame drawing has an explicit point budget (MAX_DRAWN_POINTS)
Liechtenstein's whole-country fit has ~353k nodes, and the zoom-driven LOD stride (point_stride) alone was not enough to keep the frame cheap: because Liechtenstein is small, its whole-country-fit zoom (pixels-per-Mercator-unit) is still numerically high (a small country's Mercator span is tiny, so filling the viewport needs a large zoom number), which made point_stride return "draw everything" even while the whole dense point set was on screen — the CPU painter was emitting hundreds of thousands of tessellated circles a frame, and the map felt sluggish. Lowering the LOD thresholds further would have made sparse countries at real street-level zoom draw fewer points than they should (it can't distinguish "small dense country, whole view" from "large sparse country, close zoom" using zoom alone). budget_stride fixes this the right way: it uses the actual visible count this frame (a real, honest number) to raise the LOD floor only as much as needed to stay under MAX_DRAWN_POINTS — sparse, close-up views are untouched; only "everything visible at once" views get decimated harder.
f2 — why geometry is clipped (Liang–Barsky / Sutherland–Hodgman), not just bbox-culled
Bbox culling (MapIndex::query_visible) decides which ways are worth drawing, but a kept way's bbox merely crosses the viewport — individual vertices can still be millions of pixels off-screen once projected at high zoom. egui's own painter clips the final raster to painter_at(rect), but it still tessellates the whole shape first, and tessellating a vertex at ~1e8 px produces visible flicker / lines that shoot off in the wrong direction (a floating-point precision artifact, not a logic bug). The fix is to clip the geometry itself to a margin-expanded viewport before it reaches the painter: clip::clip_polyline (Liang–Barsky, per segment, emitting one or more on-screen sub-polylines — chosen because it preserves the true slope of a clipped segment rather than clamping endpoints, which would visibly bend a road at the pane edge) for lines, clip::clip_polygon (Sutherland–Hodgman) for filled rings — chosen because the clip rectangle is always convex, which is Sutherland–Hodgman's one precondition, even though the OSM polygon being clipped is not (Sutherland–Hodgman only requires the clip window be convex, not the subject). The margin (CLIP_MARGIN_PX = 64.0, a few multiples of the widest stroke, ~9px) exists so an edge stroke is never visibly truncated right at the rect boundary.
f2 — why damped zoom and per-OS input feel, not raw deltas
Feeding egui's raw scroll/zoom deltas straight into the camera was the first cut and felt wrong on every trackpad: pinch-to-zoom and ctrl-wheel arrive as noisy, bursty deltas, and applying them 1:1 makes the map jump rather than glide. MercatorCamera instead separates a target_zoom (what the input said) from an eased zoom (what's actually drawn), converging via 1 - exp-style damping (ZOOM_DAMP = 0.35 per tick) — smooth and, because the easing is a pure function of the previous state, deterministic under a driven clock (tick()/tick_pan_momentum(dt) can be stepped by a headless test with an injected dt, unlike "however long this wall-clock frame took"). Reading input through facett_core::look::keymap rather than hard-coding key bindings was chosen so macOS pinch/natural-scroll/momentum and Windows ctrl-wheel each map onto the same semantic Actions (ZoomIn, PanLeft, …) — the alternative (branching on cfg!(target_os) inside osm2d.rs) was rejected because every other facett navigable component already solved this once in the shared keymap; re-solving it locally would have produced a second, divergent set of platform quirks to maintain.
f2 — why the boundary asset is compact custom JSON, not GeoJSON
facett-geomap::boundary embeds assets/sweden_admin.json via include_str! rather than shipping (and parsing) standard GeoJSON. A real GeoJSON FeatureCollection for 21 Swedish län (Stockholm alone has 18 archipelago rings) carries a lot of structural overhead (type/properties/geometry/coordinates nesting, CRS boilerplate) that facett never reads back out — the loader only ever wants name/iso/population/center/rings. A flat, purpose-built schema keeps the include_str!'d asset smaller (kept the whole 21-län, 2422-vertex dataset to ~47 KB) and keeps AdminBoundaries::parse a direct field-by-field read instead of a generic GeoJSON walk. The trade-off is a schema no other GIS tool understands — acceptable here because the asset is generated once from Natural Earth data and never hand-edited or exchanged with another tool.
f2/f3 — why AdminBoundaries::parse is strict, not "best effort"
A missing field or malformed ring in a real GIS asset is easy to imagine handling gracefully — skip the bad unit, log a warning, carry on with 20 län instead of 21. AdminBoundaries::parse does the opposite: a missing/mistyped top-level field, an empty units array, or a malformed coordinate shape (e.g. a 3-tuple where [lon, lat] was expected) is a hard BoundaryError, not a silently-smaller dataset. This was chosen because the asset is include_str!'d at compile time from a fixed, checked-in file — there is no "partially corrupt input from the wild" scenario to be lenient toward, only "the asset regenerator produced something wrong", which should fail loudly and immediately rather than quietly ship a map missing a province. Degenerate geometry (a ring with <3 vertices) is handled differently — to_ways skips it rather than erroring — because that is a legitimate (if odd) shape a real boundary dataset can contain, not a schema violation.
f5 (region picker) — why the planet is never re-downloaded, and why clip runs in-process
Two choices in facett-demo::region_picker are easy to get wrong the "simple" way and were deliberately not: (1) planet_source() only ever looks at an already-cached .osm.bz2 on disk (/home first, T9 fallback) and never fetches one — a planet file is tens of gigabytes, and a picker that silently triggers that download on first click would make "click a button" an hours-long, network-dependent operation with no warning. The torrent/object-store hosting (znippy) is left as a documented future hook (planet_source's single swap point) rather than half-built now. (2) The clip itself calls osm-katana's convert() library API directly on a background thread, not a subprocess — an earlier iteration shelled out to an osm-katana binary (probed via clip_supported()/convert --help), which meant the feature could not exist at all until a separate binary shipped a compatible CLI flag. Calling the library in-process removes that cross-binary versioning dependency entirely: the feature is gated on a Cargo feature (region-clip) pulling in the osm-katana crate, not on what binary happens to be on $PATH.
f5 — why Sweden clips by a real polygon and not a bounding box
Skandinavien and Europa clip by a named bbox preset (nordics/europe) — a rectangle is an acceptable approximation at that scale. Sweden does not: a bounding box around Sweden also contains Oslo, Copenhagen, Helsinki, and a slice of northern Germany, which is a wrong answer for a button labelled "Sverige". Preset::Sverige instead clips with Clip::Poly against a bundled assets/sweden.poly (Osmosis .poly format) — a real, if simplified, country outline that contains Stockholm/Göteborg/Malmö/Kiruna and excludes the neighbouring capitals. The cost is a slightly more expensive polygon clip (vs. a rectangle test) and one more bundled asset; the alternative (accepting the bbox's foreign-capital bleed-in) was rejected as simply wrong for a per-country preset.
f5 — why the cache lives under ~/.cache, never on T9
region_cache_dir/region_cache_dir_named build ~/.cache/facett-osm/<name>-geo (honouring $XDG_CACHE_HOME) and this is asserted in tests to never resolve onto the T9 removable drive. T9 is the source disk for the large, slow, sometimes-flaky planet file — writing derived, frequently-read cache output back onto the same disk the slow read comes from would both contend for the same I/O and put the cache itself at risk of the drive's flakiness. $HOME/$XDG_CACHE_HOME is always a stable, fast, local disk regardless of which machine the demo runs on, which is the property a "does a repeat pick load instantly" cache actually needs.
paint order — "vita marken tar över allt": why a band could not be a floor (2026-08-31)
Rickard, 2026-08-30, on the live korp 2D map: "vita marken tar över allt" — the white land paints over everything, and it varies with what has been loaded.
paint_order::LANDMASS_BAND was -1 000, and its own doc line called it "the floor, under every other band". That was true when it was written and stopped being true the day layer/bridge/tunnel started being read (defect 4 of the same module). A way's rank is
rank = effective_layer * LAYER_STEP + band + class + tier (LAYER_STEP = 100 000)
so the layer term dominates everything else by two orders of magnitude: one tunnel=yes or layer=-1 way ranks near -98 800 — 97 800 units below a landmass at -1 000. The landmass was a band sitting inside the ground layer's neighbourhood, not a floor under the whole ladder.
Inside one painter's sorted list that would be a curiosity. It is not, because of the CPU/GPU seam: MapFacet::ui inserts the GPU callback as ONE opaque slot at gpu_lane_rank_floor — the minimum way_rank over every way the lane draws, scanned with no viewport cull — and paints only the CPU features below that rank before it. One tunnel anywhere in the served set therefore dropped the slot under the landmass, the landmass fell into the CPU tail, and the white country silhouette painted over the entire GPU-rendered map.
Both halves of the report fall out of that one constant: it covers everything (the silhouette is the country) and it varies with what has been loaded (whether a negative-layer way is in the set at all) — which is why it kept being mistaken for the already-closed defect 3.
Numbers, all measured on this tree (oden / RTX 4090, 2026-08-31):
| what | measured |
real Sweden pyramid tile z9/288/125.parquet |
11 of 1 493 ways (0.74 %) carry tunnel=*; 11 carry layer < 0 |
deepest rank a way can reach (way_rank, full clamp) |
-799 870 vs a landmass at -1 000 |
two renders differing by exactly one tunnel=yes tag, before |
91 212 of 208 000 px (43.9 %) differ; pane centre goes water [18,58,90] → land [242,239,233] |
| the same pair, after | 1 422 px (0.68 %) differ — the tunnel road's own ink sinking under the lake, which is the point |
The cure. LANDMASS_BAND = WAY_RANK_FLOOR - LAYER_STEP = -900 000, where WAY_RANK_FLOOR = -LAYER_CLAMP * LAYER_STEP + AREA_BAND is the lowest rank way_rank can produce. A const _: () = assert!(LANDMASS_BAND < WAY_RANK_FLOOR) makes breaking it a build failure instead of a sentence to trust — and because two constants agreeing with each other is exactly the shape LAW 2 warns about, paint_order::tests::no_way_can_sink_below_the_landmass walks real ways through the real way_rank (every class, both bands, at the negative clamp and as tunnels) and compares the landmass against the minimum those produce. Seen RED at -1 000: -1000 vs a deepest measured way of -799870.
What was NOT it (measured, so nobody re-runs them):
- Defect 3, the hardcoded
LayerKind::Line.default_z()GPU slot — fixed.uisplits ongpu_lane_rank_floor(layer.rs:2565). The device-lane pixel claims that were written RED on 2026-08-23 (gpu_lane_paint_order.rs: tunnel-under-lake, and the two tile arrangements) all pass on this tree. The module doc still read as if it were live; it now says CLOSED. gpu_fillable'slonlat.len() >= 3splitting fills across the two lanes — structurally impossible on every real path.Way::from_lonlat_styledonly assigns afillwhenis_closed_ring, which requireslonlat.len() >= 4(lib.rs:505). The>= 3clause can therefore never be the discriminator for a classified way; only the syntheticWay::filledconstructor could reach it, and a 2-vertex filled ring is degenerate anyway.style.rstestinghas("landuse")beforehas("building")— real, but 193 of 1 454 268 buildings (0.01 %) carry both tags.
The pure-CPU build was never affected, and that is structural, not luck: with no lane there is no split at all (gpu_floor.map_or(feats.len(), ..)), so the CPU list paints as one uninterrupted PaintKey-sorted run. Asserted in the same test rather than assumed.