Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/map-design.md

The geo/map subsystem — design

Class: design · Function axis: f0 (the geo subsystem) + f1 MapView · f2 GeoMap/OsmView · f3 osm ingest · f4 wgpu render path. Strict: this file states only the shape we run. Every why, every alternative, lives in map-reasoning.md.

f0 — the geo subsystem (the whole)

Three crates, one Web-Mercator vocabulary:

crate gives the subsystem
facett-osm ingest — decodes GeoParquet's WKB geometry column into (lon,lat) points and typed facett_map::Ways
facett-map the vocabulary + a static viewer — mercator/Way/WayStyle/MapIndex/tessellate, and MapView (fit-to-rect, no pan/zoom)
facett-geomap the live viewers — GeoMap (pan/zoom + hotspots) and OsmView (a full slippy map), both built by re-using facett-map's Way/MapPalette/tessellate/MapIndex rather than re-deriving them

All three agree on one projection: Web Mercator normalised to [0,1]² (facett_map::mercator, facett-map/src/lib.rs:48; facett_geomap::mercator, facett-geomap/src/lib.rs:49 — bit-identical formulas). A facett_map::Way (facett-map/src/lib.rs:95) is the one geometry type that crosses all three crates: facett-osm::read_ways (facett-osm/src/lib.rs:161) produces it, MapView/GeoMap/OsmView all consume it, and facett-geomap::boundary::AdminBoundaries::to_ways (facett-geomap/src/boundary.rs:190) produces it from a bundled asset instead of a file. Every viewer is a canonical facett_core::Elm component (Model/Msg/Effect/view/update) built with impl_facet_via_elm!, so all three are drivable headlessly through facett_core::harness with no window and no GPU — that seam is what the wgpu fast-path (f4) sits underneath, never instead of.

Reuse graph: facett-osm → facett-map (produces its Way); facett-geomap → facett-map (Way, MapPalette, tessellate, index::MapIndex, style::classify, gpu::GpuMapRenderer); both facett-map and facett-geomap's GPU lanes → facett-core::render::gpu (the shared L0 chunking/upload/SDF plumbing).

f1 — MapView (facett-map)

A static, fit-to-rect viewer: a point layer plus an optional vector-basemap ways layer, drawn under the points (facett-map/src/lib.rs:1).

Model (FC-1/3) — MapCamera (facett-map/src/lib.rs:256): pan: [f32;2], zoom: f32 (clamped 0.25..8.0, MIN_ZOOM/MAX_ZOOM at :242), selected: Option<i64> (a way's stable osm_id, FC-5), show_points/show_ways. points: Vec<(f64,f64)> and ways: Vec<Way> are held on MapView itself, outside the Model — they are render input, not navigation state (facett-map/src/lib.rs:325).

Msg (FC-2) — Msg (facett-map/src/lib.rs:296): PanBy/SetPan, ZoomBy/SetZoom, Fit, Select/ClearSelection, SetPoints/SetWays. All applied by the single MapView::update (:461). Effect is uninhabited (:322, FC-8) — the viewer does no I/O.

View (FC-9) — MapView::view (:512) is &self-pure: paints a toolbar (counts, zoom readout, ⊙ fit, layer checkboxes), fits the Mercator bbox of points ∪ ways into the pane, applies the live camera on top, draws in fixed order — land background → filled areas (mesh + outline) → line ways → the live-camera selection outline → points on top — and returns the Msgs produced by drag/scroll/click. Concave area fills go through tessellate::filled_mesh (facett-map/src/tessellate.rs:107, ear-clipping, facett-map/src/tessellate.rs:44), never egui::Shape::convex_polygon.

A Way (facett-map/src/lib.rs:95) carries both a projected points: Vec<(f32,f32)> (Mercator unit, for the GPU/CPU cull) and the source lonlat: Vec<(f64,f64)> (for CPU fills). Way::from_lonlat (:187) is the real-OSM constructor: style::classify (facett-map/src/style.rs:107) resolves a WayStyle from tags, and a way only gets a fill when style.is_area and its geometry is a closed ring (Way::is_closed_ring, :172) — an open landuse way still strokes. Way::line/Way::filled (:218, :226) build synthetic ways directly (no tag classification) for hosts with no real basemap. Building height (Way::height_m) is parsed by parse_building_height_m (facett-map/src/height.rs, re-exported at the crate root): height=<m|ft> wins, else (building:levels + roof:levels)×METRES_PER_LEVEL (3.2 m, measured — see .nornir/building-height-design.md), else None and the 3D extruder's DEFAULT_BUILDING_HEIGHT_M (6.0 m) box. None is also what a junk tag yields — height=99999, 1;12, 102,5, 25' and building:levels=3-4 all used to render silently wrong. Only WayKind::Building ways carry it.

Styling (facett-map/src/style.rs) — Lod (:32, Country/Region/City), WayStyle (:42, colour/width/lod/kind/is_area/fill), MapPalette::from_theme (:61/:85, derives land/water/green/building/coast from the active Theme by blending, mix at :75) — a way layer re-themes with the deck. classify (:107) branches on highway (line, width by class), natural=coastline (line), natural=water|lake|bay|wetland/water/waterway (area, blue), natural=wood|forest|…/leisure=park|…/landuse=* (area, green), building/amenity (area, grey); untagged → a faint thin line.

Spatial index (facett-map/src/index.rs) — MapIndex::build (:20) partitions ways into three LOD tiers; gpu_ways() (:50) walks all tiers for the GPU upload; query_visible (:60) is a brute-force per-tier bbox test for the CPU fallback (the GPU compute shader does the real per-frame cull).

Projection (facett-map/src/project.rs) — to_mercator_f32 (:10, f32, GPU vertex space, bit-identical in shape to crate::mercator's f64), to_screen/from_screen (:19/:25), viewport_bbox (:30).

state_json (facett-map/src/lib.rs:741, form-3 override): points, ways, way_kinds (histogram), way_vertices, filled_ways, bbox, plus the observable selected/camera/show_points/show_ways. portable_state/load_state (:789/:799) round-trip the camera/selection/layer-toggle state between two MapViews for copy/paste. caps() (:771) advertises themeable().resizable().selectable().navigable().

L0 SDF points (facett-map/src/l0_points.rs, feature wgpu) — an opt-in alternative point lane: lower_points (:24) turns screen-pixel point centres into facett_core::render::CircleInstances; render_points_l0 (:38) renders them through facett_core::render::gpu::offscreen_render, returning None on a CPU-only box. The default MapView::view point painter (CPU circle_filled) is unchanged; this is an additional path a host may drive.

f2 — GeoMap / OsmView (facett-geomap)

Two live (pannable/zoomable) viewers in one crate, both reusing facett-map's Way/MapPalette/tessellate/index::MapIndex.

GeoMap — fit-scatter + hotspot overlay

GeoMap (facett-geomap/src/lib.rs:124): a point layer, a hotspot layer (Hotspot { lon, lat, count, label }, :58, circles sized sqrt(count)-scaled with a count badge + label), and an optional basemap Vec<Way>.

Model — GeoViewport (:80): Mercator center: (f64,f64), zoom: f64 (clamped 0.25..=5_000_000.0), selected: Option<String> (a hotspot's stable key — its label, or "{lon},{lat}" when unlabelled, hotspot_key at :246, FC-5). points/hotspots/ways live outside the Model on GeoMap itself.

Msg — Msg (:99): PanUnit/SetCenter, ZoomBy/SetZoom, Fit, Select/ClearSelection, applied by GeoMap::update (:268). Effect uninhabited (:120). GeoMap::new (:142) and with_ways (:150) both call fit() (:192→fit_viewport at :197), which centres on the bbox of points ∪ hotspots ∪ ways and zooms so the span (with a 0.9 margin) fills the viewport.

View — GeoMap::view (:296) paints under a live drag-to-pan / scroll-to-zoom pane; draw order is basemap fills+outlines → basemap lines → points → hotspots (sized circles, a selection ring on the selected one, a count badge, a label). Each hotspot gets an AccessKit node (facett_core::a11y::node, :478) keyed on its stable id.

state_json (:531, form-3): points, hotspots (full list), hotspot_count, top_hotspot, ways/way_kinds/way_vertices/filled_ways, bbox, zoom, center (reported in lon/lat via inv_mercator, :496), selected. caps() (:588): themeable().resizable().scalable().selectable() — zoom rides the uniform scale()/set_scale() knob (:579).

Boundary loader (facett-geomap/src/boundary.rs) — load_sweden_admin (:212) parses a bundled include_str!'d assets/sweden_admin.json (compact custom JSON, not GeoJSON) into AdminBoundaries (:108) of AdminUnits (:75, name/iso/population/center/rings). AdminBoundaries::parse (:124) is strict: malformed JSON → BoundaryError::Json, a missing/mistyped field or empty units → BoundaryError::Schema (:41). to_ways (:190) turns every ≥3-vertex ring into a filled-silhouette Way::filled (tagged "admin"), feeding GeoMap::with_ways / OsmView::new directly.

OsmView — the slippy map + Garmin car-navigator basemap

OsmView (facett-geomap/src/osm2d.rs:528): a real slippy map — mouse-wheel zoom-to-cursor, click-drag pan with inertial glide, keyboard pan (arrows/WASD) + zoom (+/-/PageUp/PageDown), trackpad pinch + two-finger pan, all through facett_core::look::keymap so the feel matches the active per-OS preset. In cartographic mode (cartographic(day), :726) it draws a turn-by-turn Garmin car-navigator basemap — road-class hierarchy, casing-then-fill passes, one-way chevrons (draw_direction_arrows, :489), a cased route ribbon (with_route/set_route, :660/:667), and collision-avoiding priority road-name labels (MAX_LABELS = 48). Full algorithm write-up: osm-rendering-techniques.md.

Model — MercatorCamera (:90): center: (f64,f64), zoom: f64 (pixels-per-Mercator-unit, clamped ZOOM_MIN..ZOOM_MAX = 64.0..80_000_000.0), target_zoom: f64 (the wheel/keyboard goal zoom eases toward via tick, factor ZOOM_DAMP = 0.35). zoom_to (:210) is the anchor-preserving wheel zoom (the point under the cursor stays put); pan_px/pan_frac/pan_velocity move center, clamped to [0,1] (clamp_center).

Spatial indices — PointGrid (:285): a dependency-free uniform grid over Mercator-unit point positions (~8 points/cell, side clamped 1..=256); visible_into viewport-culls and LOD-decimates by a stride; visible_count is the honest (undecimated) count. Ways reuse facett_map::index::MapIndex (query_visible culls by bbox + LOD tier, way_lod() at osm2d.rs:861 picks the tier from camera.zoom).

Point-draw budget — point_stride (:832) is a zoom-driven LOD floor; budget_stride raises that floor so the number of circles actually painted never exceeds MAX_DRAWN_POINTS (:71, 40 000) regardless of absolute zoom or dataset size — the fix for drawing hundreds of thousands of circles when a small, dense country is whole-country-fit.

Clipping (facett-geomap/src/osm2d/clip.rs) — every bbox-culled way's geometry is clipped to a margin-expanded pane rect (CLIP_MARGIN_PX = 64.0, osm2d.rs:71) before it reaches the painter: clip::clip_polyline (clip.rs:82, Liang–Barsky per segment, emitting one or more on-screen sub-polylines) for line ways, clip::clip_polygon (clip.rs:138, Sutherland–Hodgman) for filled rings.

Msg / Effect — Msg (:784): DragPan/ReleaseDrag/CancelMomentum, PanPx/PanFrac, ZoomTo/ZoomBy/SetZoom, Fit, TickMomentum/TickZoom. Effect uninhabited (:814). OsmView::update (:825) is the sole mutation path; OsmView::view (:876) is &self-pure — every camera transition is applied to a local copy cam (so the frame paints the moved map with no one-frame lag) and also pushed as a Msg for update to persist.

Inertial pan — pan_x/pan_y: SmoothScroll (facett-core's momentum model) are seeded on drag-release (ReleaseDrag, clamped MAX_FLICK = 6000.0) and decayed each frame by tick_pan_momentum (:729), deterministic under an injected dt.

state_json (:1131, form-3): points/ways totals, visible_points/visible_ways, point_stride/zoom_lod_stride/drawn_points/max_drawn_points, zoom/target_zoom/center/center_merc, fps, lod_sizes, pan_momentum, gpu_lines/gpu_enabled. caps() (:1173): themeable().resizable().scalable().navigable(). local()/remote() (:495/:503) build a deterministic synthetic Liechtenstein-ish dataset via sample_dataset (:1189) for the discovery-contract test matrix.

f3 — osm ingest (facett-osm)

Reads OSM GeoParquet (the osm2geoparquet/katana-osm output) into facett geometry (facett-osm/src/lib.rs:1).

WKB decode — decode_wkb (:16) decodes a WKB blob's vertices into an output buffer, handling Point (type 1), LineString (2), Polygon (3, exterior + inner rings), tolerant of truncated input (bounds-checked, never panics, decode_truncated_blob_does_not_panic). decode_wkb_coords (:126) decodes one geometry's vertices as its own Vec (what read_ways needs to keep a way's polyline intact, vs decode_wkb's buffer-flattening).

Points — read_points (:78, from a file path) / read_points_bytes (:87, from in-memory Bytes — the filesystem-free path a wasm build uses) both funnel through read_points_from (:95): a ParquetRecordBatchReaderBuilder over the geometry (Binary/WKB) column, capped at max vertices (0 skipped by neither — max is a hard cap here, not "unlimited").

Ways — read_ways (:161) / read_ways_bytes (:168) funnel through read_ways_from (:174): per row, decode_wkb_coords the geometry column, skip rows with <2 vertices, parse the tags column (JSON object, parse_tags at :134, malformed → empty), and build a facett_map::Way via Way::from_lonlat(0, &coords, &tags, theme) — so the area-vs-line classification and Mercator projection happen at ingest, not in the viewer. max == 0 means unlimited (cap = usize::MAX).

Convenience — map_view (:216) reads points straight into a facett_map::MapView.

f4 — the wgpu render path

Shared between facett-map's own ways layer (feature wgpu) and facett-geomap::OsmView's ways layer (feature map-wgpu, which pulls in facett-map/wgpu): both draw the line ways layer through the same facett_map::gpu::GpuMapRenderer (facett-map/src/gpu/mod.rs:79) via an egui_wgpu::Callback (MapPaintCallback, :337); filled silhouettes always draw on the CPU on top in both viewers.

Upload-once, cull-per-frame — build_gpu_geometry (:479) walks all LOD tiers once into a flat Vec<Vertex> + Vec<GpuWayMeta> (GpuGeometry, :50); GpuMapRenderer::upload (:256) is called on the first paint where ways exist (an AtomicBool upload latch on each viewer: MapView::geom_uploaded at facett-map/src/lib.rs:339, OsmView::gpu_uploaded at osm2d.rs:365). Every later frame only writes the DrawUniforms (zoom/offset/viewport size, :372) and the cull's ViewportCull uniform (:400), then re-dispatches cull.wgsl and draw_indexed_indirects off geom_buf through the cull's index list — no per-frame vertex re-upload.

Chunking — upload (:256) partitions ways via facett_core::render::gpu::partition_chunks so each chunk's geom_buf stays ≤ device.limits().max_buffer_size; no way straddles a chunk boundary (vert_start is chunk-relative). One cull dispatch and one draw_indexed_indirect run per chunk per stream (chunks: Vec<GeomChunk>, :61/:90). chunk_count()/chunk_buffer_bytes() (:218/:225) surface the split as data for the headless self-test.

Compute cull (cull.wgsl, driven by GpuMapRenderer::new's cull_pipeline, :184) — one invocation per way: a frustum+LOD test against ViewportCull; a visible way's vertex INDICES are written, in bake order (an ordered compaction: count, scan, copy), into index_buf — 4 B per vertex, so a chunk's VRAM is its geometry plus a sixth rather than twice it (GG audit row 21; tests/vram_ratio.rs holds it at ≤ 1.2×). indirect_buf is the control block: the DrawIndexedIndirectArgs the draw consumes plus the scan scratch. A road chunk is drawn with instance_count = 2 — casing and fill as two instances of one command (tests/draw_count.rs holds one draw per chunk per stream).

Installation — install_renderer (:518) delegates to facett_core::render::gpu::install_renderer, a get-or-insert into the host's egui_wgpu::RenderState callback resources, called once from the host's eframe creation closure. Without it, draw_ways_gpu (facett-map/src/lib.rs:848, osm2d.rs:455) returns false and the CPU line painter draws the basemap instead — both features default off.

f5 — shared way slabs + the baked-vertex store hook (GFX_V2 client backlog, 2026-08-21)

WaySlab / WaysLayer::shared (facett-map/src/layer.rs). The layer boundary reads &[Way], and WaysLayer::new(id, Vec<Way>) was the only door — so a host that retains its classified ways and rebuilds the MapFacet on every apply deep-copied the whole set into the layers once per class. WaySlab is Arc<[Way]> + Range with Deref<Target = [Way]>; WaysLayer::shared(id, slab, range) is a view (a pointer bump and two indices), the tessellation cache and the shared-geometry slots sized to the range as before. WaysLayer::new is unchanged in behaviour (it owns its slab). Guard: tests/ways_layer_shared.rs (the slice IS the slab's memory at its range; a shared layer draws what an owned copy draws).

The baked-vertex store (facett-map/src/gpu/bake.rs). The tile lane keys VRAM by CONTENT (layer::tile_content_key, 128-bit FNV over identity + geometry + style + bake origin). On a VRAM miss add_gpu_tiled_callback now asks an installed BakeStore (install_bake_store) under the SAME key through baked_or_build(key, bake): a hit decodes the blob straight into a GpuGeometry — no ear clip, no reprojection, no quad/fill expansion; a miss bakes and offers encode_baked(&geo). The fills' ear clips are resolved INSIDE the bake closure (gpu_lane_fill_refs yields way + cache + slot, no tris), and the lane's origin/tile-lane pre-checks no longer tessellate — a served group never ear-clips. The blob: FCTBAKE1 magic, BAKE_FORMAT_GEN, eight 8 B-aligned POD streams + line_lod_end; decode_baked answers None on ANY inconsistency (corrupt is a miss, never a wrong picture). facett-map does no disk IO — the store is the host's (korp: ~/.korp/bakecache, KORP_BAKE_CACHE_MB, nuked with the tile cache on a data-version move). Measured (release, 4 000 roads + 400 lakes, 3.05 MiB blob): decode 0.32 ms vs cold bake 3.32 ms (10.5×) / memo-warm bake 2.09 ms (6.6×). Guards: gpu::bake::tests (round trip, six corruptions, the door hits/misses/heals, the measurement) and tests/bake_store_tile_lane.rs (real device: facet A misses + offers + ear-clips 16 lakes, facet B over the same ways hits and ear-clips zero).

The rung nothing stood on (2026-08-31, label-district-1)

Rickard, on a working map: "det blir ingen garmin upplevelse av detta … hastigheten är ok men fan".

MEASURED on the live client (korp-ui b937078 against oden), pane at camera 11.3:

labels     fed 5319   drawn 1

The pane's own budget is about 65 names (LABEL_MIN_PX2_EACH = 14 000 px² each over a 1258×728 canvas). The placer was not overloaded — it was starved by a factor of sixty-five.

What it was not

The obvious reading is that the label machinery is too aggressive. It is not: the decluttering is already the professional shape — an areal budget, a real galley measured at the real font, LABEL_REPEAT_PX against one street printing its name once per OSM segment, and a greedy grid that sorts by tier ascending then rank descending before truncating to MAX_LABELS_CONSIDERED. Its own doc anticipates "a viewport holding 80 000 named ways".

The first fix attempted here was to delete the lod_visible gate in LabelLayer::features and let that budget decide everything. Two existing guards refused it, correctly:

They are right, and the reason is worth stating: an areal budget answers how many names fit, never whether this name is important enough for this zoom. Two different questions. Remove the ladder and a whole-Europe pane spends its 65 slots on Swedish city names.

What it was

LOD_TIER_ZOOMS is [0, 6, 9, 12, 15] — Country · Region · District · City · Detail. classify_label resolved country/state to Country, city/town to Region, and every remaining settlement class straight to City. So District was occupied by no settlement at all, and the ladder skipped from camera 6 to camera 12 without naming anything new — six zoom levels, roughly 300 km down to 40 km of pane. That is the band a person navigates in, and it carried town names and nothing else.

Which class earns the rung — measured, not chosen

Named place=* nodes over all nineteen z6 tiles of the 2026-08-31 Sweden cut:

class named nodes carries population
isolated_dwelling 25 221 0 %
hamlet 14 348 2 %
locality 3 687 0 %
village 996 27 %
municipality 132 4 %
town 67 97 %

996 villages over a whole country is regional density — a handful on a 100 km pane, which the areal budget then ranks. 14 348 hamlets and 25 221 isolated dwellings are not, and they stay at City where they were: promoting them is the clutter defect from the other side.

Why not population, which would be the obvious refinement: the data does not carry it. It is present on 97 % of towns — which already draw at Region and gain nothing — and on 27 % of villages, 2 % of hamlets and 0 % of isolated dwellings. A tier computed from a field absent for three quarters of the class it decides is a tier decided by its own fallback. Measured before it was designed on, which is the only reason it was not built.

village → District(9), rank 0.80. municipality/borough → District(9), rank 0.85 (a kommun name is a regional fact by definition). suburb deliberately stays at City: a suburb sits inside a city whose name is already drawn two rungs earlier.

Both endpoints the old guards pin still hold — village paints nothing at camera 6 and paints at camera 12 — because District sits between them. The new guard (a_village_stands_on_the_district_rung_and_a_hamlet_does_not) was seen RED at 0 px on camera 9 with village => Lod::City restored.

Still open

Named natural features (a lake, a park) fall through to named(Lod::City, …), so a lake the size of Vänern is named at the same camera as a village pond. The honest fix is an extent-derived floor — LabelPlacement::merc_len already carries the subject's measured extent and rule 3 already uses it for "does the name fit along the thing it names" — but classify_label sees tags only, so the extent has to reach the tier decision first. Not done here.

A country view with no names (2026-08-31, label-city-country-1)

Measured immediately after the District rung landed, same client, camera 5.6 — a whole-Sweden pane:

labels     fed 15797   drawn 0

Not one name on the country view. place=city and place=town shared Lod::Region, whose rung opens at camera 6, and a whole-Sweden pane sits at about 5. So the pane opened no rung but Country — and the only Country-tier node in the entire cut is the single place=country for Sverige.

Splitting the pair is safe because the counts are not close. Named place=* over all nineteen z6 tiles of the 2026-08-31 cut:

class named nodes
country 1
region 5
city 6
town 67
municipality 132

Six city names on a pane showing a whole country is the minimum a map carries, and the areal budget is ~65 — nowhere near reached. city → Lod::Country, rank 0.97 (just under the country itself). town stays at Region: 67 is right for a regional pane and wrong for a continental one.

Two existing guards used place=city as their REGION instrument and both went red. Their property is unchanged and correct — the Country rung must be distinguishable, and "everything lifted to Country" must remain detectable — so the instrument moved to town (the class Region is now for) rather than the property being weakened. The new city tier is pinned explicitly in both the pixel test and the unit test, so it cannot move again silently. Seen RED with "city" | "town" => (Lod::Region, 0.95) restored.

The floor that was only a band (2026-08-31, white-stacking)

facett_map::paint_order declares the rank every lane sorts on:

rank = effective_layer * LAYER_STEP + band + class + tier      LAYER_STEP = 100_000

The band constants are LANDMASS_BAND < AREA_BAND 0 < LINE_BAND 1_000 < POINT_BAND 2_000 < LABEL_BAND 3_000. LANDMASS_BAND is now WAY_RANK_FLOOR - LAYER_STEP = -900 000, where

pub const WAY_RANK_FLOOR: i32 = -LAYER_CLAMP * LAYER_STEP + AREA_BAND;   // -800_000
const _: () = assert!(LANDMASS_BAND < WAY_RANK_FLOOR);

It was -1 000, a band among the other bands — which is not the same thing as a floor, because the layer term dominates the rank by 100 000 per step. A tunnel=yes way ranks near -98 800, i.e. under the land silhouette that is supposed to be beneath everything.

That matters at the CPU/GPU seam, not just inside a sort: MapFacet::ui places the GPU callback as ONE opaque slot at gpu_lane_rank_floor (the minimum way_rank over every way the lane draws, no viewport cull) and paints only lower-ranked CPU features before it. One tunnel in the loaded set pushed the slot below the landmass and the white land covered the whole GPU-rendered map — "vita marken tar över allt", varying with what had been loaded.

Guards, both seen RED:

Two texts of one taxonomy — the cross-repo guard (2026-08-31, lane WIRE)

facett_map::style::classify_with (a way's WayKind + Lod) and classify_label (the place=* settlement backbone) are, together, the constellation's ONE semantic OSM tag → class map. On 2026-08-31 osm-katana needed the same decision at WRITE time, to fill a min_zoom column on the GeoParquet it cuts, and it could not depend on facett-map — an egui component crate is not a dependency a headless PBF converter takes on to learn that a lake is a lake. So it transcribed the two functions, arm for arm, into osm_katana::shared::class::RULES, and said so in its own module doc.

A transcription is not a construction. Nothing made the two texts stay in step, they live in two repos, and the drift is silent in both directions: facett paints a feature at a rung the server does not send it at, or the server sends a rung facett will not paint. This constellation has paid for that exact shape twice — facett-geomap's osm2d.rs was a complete SECOND 2D OSM painter over the same Way type with its own band ladder and took days to find, and korp b937078 fixed a bug whose whole cause was points and ways going through different tag → class maps (a sub-pixel lake became an off-by-default grey dot in a layer that was off by default).

What was built, and what was deliberately not

The by-construction fix is to lift the kind+tier half of classify_with into edda and have both sides call one function. That spans three repos and a crates.io release cascade; it is recorded as the migration target in znippy-zoomies/.nornir/osm-katana-v2-design.md and is NOT this. What is here is the detector that makes the drift impossible to miss until then:

The corpus is real and weighted: the 110 most frequent distinct tag signatures of the live Sweden cut (~/.korp/work/sweden, 2026-08-31 — 55 from ways.parquet / 9 325 142 rows, 55 from nodes.parquet / 103 488 583 rows), over the keys either classifier reads, plus 32 hand-added tiering-backbone tags the frequency census does not reach in volume, plus (2026-09-01) the seven boundary / admin_level way signatures with more than a handful of rows — 4 601 of Sweden's 9 325 142 ways carry either key at all. Each carries its row count, so a divergence is reported in ROWS: "they disagree" is an argument, "they disagree about 31 112 islets" is a fact.

The seven live divergences, all one defect

Recorded in KNOWN_DIVERGENCES, which is not a mute suppression list — the guard fails when a listed pair stops diverging, or diverges by different rungs, exactly as it fails on an unlisted one. Closing a hole therefore forces its entry out.

Six: classify_with has no place arm. A place=city NODE goes through classify_label and earns Lod::Country; a place=city closed WAY goes through classify_with, matches nothing, and lands on the _ => Lod::City fallthrough. Same tags, two geometries, two rungs — korp-map-pt-1 again. osm-katana closed it by putting the place=* block in one table both geometries read; facett still has the hole, at country / region / city / town (12 vs 6) and municipality / village (12 vs 9). What it costs today is close to nothing and that is worth writing down rather than assuming: Swedish settlements are mapped as nodes and as boundary relations, and over the 400 most frequent way signatures of the Sweden cut there is not one place=city, place=town or place=village way. The hole is real, it is up to six rungs deep, and Sweden happens not to walk into it.

One, and this is the one with rows in it: osm-katana was the side that was wrong — CLOSED 2026-09-01. RULES put its whole place=* block first, ending in a place? catch-all at City. An islet's shoreline carries both natural=coastline and place=islet, so the catch-all fired before the coastline arm was reached and the row was written min_zoom = 12, while facett paints coastline at Lod::Country — rung 6. MEASURED on ~/.korp/work/sweden/ways.parquet (2026-08-31): 31 112 ways carry exactly {natural: coastline, place: islet}, plus 260 more with place=island. osm-katana islet-1 moved natural=coastline to the head of the ladder; the fixture was regenerated (osm-katana 0.2.11, zoomies 4c3d4d6) and the guard reported the closed hole by name — "listed in KNOWN_DIVERGENCES as 6 vs 12 but the two sides now AGREE on 6. The hole is closed — delete the entry" — which is exactly the sentence the list's doc promised. The entry is gone.

What the reorder exposed is the same rows' LABEL half: classify_label places every place=islet name at City (rung 12) while the feature is now served from 6. A camera at 6–11 gets the shoreline and no name — the harmless direction. Recorded as a label divergence; where an islet's name appears is a placement decision with pixels behind it.

The boundary block (2026-09-01) — five more, all one defect

osm-katana 0ac5dfa gave boundary=* a rung of its own: admin_level 1–2 and 3–4 at 6, 5–7 at 9, any other admin_level at 12; boundary=national_park|protected_area at 6, any other boundary at 12. classify_with has no boundary arm at all — a boundary way is the "untagged-for-us generic line" of its own byte-for-byte test and lands on _ => Lod::City. Recorded, with rows from the 2026-09-01 census of ~/.korp/work/sweden/unpacked-backup/ways.parquet:

signature rows facett osm-katana
boundary=administrative admin_level=7 1 893 12 9
boundary=protected_area 464 12 6
boundary=administrative admin_level=4 353 12 6
boundary=administrative admin_level=2 113 12 6
boundary=national_park 9 12 6

Two agree by accident and are in the corpus unlisted: admin_level=9 (12 on both — the ladder's admin_level? catch-all) and boundary=protected_area + leisure=nature_reserve (1 384 rows, 6 on both — facett's leisure arm reaches it first). The direction is the dangerous one (the server serves a national border at 6, the client paints it at 12), the volume is 2 832 way rows, and it is small because Swedish boundaries live in RELATIONS (717 019 rows per osm-katana's census), which this corpus does not sample. Reported, not fixed: painting a border at the country tier changes pixels.

Seen RED (LAW 2)

arm what it produced
"tertiary" | "tertiary_link" => Lod::City in classify_with "way: highway=tertiary (92 622 Swedish rows) — facett paints it at rung 12, the osm-katana ladder puts it on 9"
the place=city entry deleted from KNOWN_DIVERGENCES the live place hole reported by name
a listed divergence given the wrong rungs "listed as diverging 12 vs 9 but actually diverges 12 vs 6 — a rung moved underneath a known hole" + "suppresses nothing and must go"
rung_of made to ignore its rules "rung_of gives the same answer with NO rules as with all of them — every comparison in this module is vacuous"
the fixture's generated-file header removed "osm-katana-ladder.txt has lost its generated-file header"
place=town moved from Region to District in osm-katana's RULES "rule 2 is now place∈[town]>9, the copy facett holds says place∈[town]>6" — red in the OTHER repo, with the regeneration command
(2026-09-01) the regenerated fixture dropped in with the OLD KNOWN_DIVERGENCES 8 of 162: the islet way entry "now AGREE on 6 — delete the entry", the islet label 12 vs 6, and five boundary signatures "facett paints it at rung 12, the osm-katana ladder puts it on 6"
(2026-09-01) admin_level∈[1|2]>6 deleted from the fixture with the new entries in place "boundary=administrative admin_level=2 is listed as 12 vs 6 but the two sides now AGREE on 12" — a removed rule cannot hide behind its own entry

The guard reads text and calls two pure functions; nothing in it renders, so none of the above depended on which GPU adapter the box handed out.

The lane was carrying it all along (2026-08-31, lane LANE)

facett-map/tests/gpu_lane_share.rs, real device (RTX 4090, Vulkan, DiscreteGpu — printed by every test, which refuses to report a lane figure from a software rasteriser).

The complaint was "not smooth, kind of fast then slow" and "the lines must be PUT in the GPU and THEN not manipulated from egui", and the evidence offered was korp's upload 25 us (1280 bytes). That number is one hand-over GROUP's upload event, not a frame's — note_upload fires per group, and a korp viewport is hundreds of them. The lane was never idle:

What was true is the other half of his sentence. The payload was uploaded once and the grouping around it was rebuilt every frame — every way of every taken layer walked twice into a fresh HashMap, keys collected and sorted, only to hand over geometry: None. TileGroupPlan memoises it per apply: 2 grouped way walks over 7 panned frames and 2 over 23 (RED, a layer toggled per frame: 9 and 25). Two rather than one because a key is not known until its payload has landed, so the first memo-hit frame can still owe bytes.

A third O(ways) walk was in the caller: add_gpu_ways_callback folded the whole-lane gpu::ways_origin before asking gpu_lane_needs_grouping, and the grouped path — which is korp's, always — discards that origin for each group's own. Asking the short-circuiting question first: 0 folds over 11 panned frames on a stamped scene, 13 on the unstamped legacy one that genuinely needs it.

What is left is the group census, and it is not a lane-selection problem: 900 groups over 150 tiles (6.00/tile) for 1 200 ways ⇒ ≥5 400 queue.write_buffer calls per frame, panning or still. In Rickard's own log the lane's in-GPU pass clock reads median 48 524 µs over 22 samples. Details, and the two costs outside facett-map (korp's 54 UI-thread facet rebuilds, median 143 116 ways / 50.8 ms; and a 3D view with map3d-gpu not compiled in at all), are in osm-2d-perf-design.md §f6.

has_nothing_to_draw — a way whose tags say nothing is not a road (2026-09-03)

classify_with ends on a fallback: "Generic / untagged way → a faint thin line (no fill)" — road_generic #DED8CB, width 0.6, Lod::City. That arm is a guess, and on the live Swedish cut the guess is painted across open sea.

Measured, on korp's live cut

Over one z14 central-Stockholm viewport of ~/.korp/work/sweden/ways.parquet:

ways
intersecting the viewport 16 565
carrying no tags at all 496
barrier and nothing else 1 017
whose drawn line crosses OPEN WATER 31

The untagged ones are the member geometry of boundary and multipolygon relations, which OSM leaves untagged on purpose. The rest of the 31 carry only metadata: source=historical + source_ref=ekonomiska kartan (kommungräns segments through Riddarfjärden), maritime=yes, seamark:type=pipeline_submarine, seamark:cable_submarine:category=power.

korp's v2.1 sweep oracle E (ink on open water, tools/mapdrive/sweep_oracles.py) read RED(2275…4405) at deep Stockholm because of them: 90 % of its foreign pixels lie within 3 px of one of these ways, the ink is road_generic exactly, and the oracle turns red at z12 — which is Lod::City, this arm's own tier.

It is the route=ferry defect from the other end. There the tags said ferry and the classifier answered trail; here the tags say nothing and the classifier answers road.

The shape of the fix

An allowlist, not "has no tags". Measured on the same frame: dropping only the zero-tag ways leaves 82–95 % of the sea ink standing, because half the chords carry a source or a maritime key and nothing else. The allowlist is not a taste call either — it is classify_with's own reading list, guarded against drift.

The RUNG was not touched. osm_katana::shared::class::UNCLASSIFIED_MIN_ZOOM is 12 because this arm is Lod::City, deliberately, so the two ladders agree and the client never wants what the server withholds. The server is right to hand the geometry over.

What now draws nothing, and is not fixed here

boundary, place and man_made are keys classify_with has no arm for (the boundary block above says so already). They used to reach the generic hairline; they now reach nothing. A kommungräns, a pier and a bridge outline each deserve their own arm and their own ink, and inventing one here would be the unargued taste call the ferry arm's doc warns about. Named, not fixed.

Two fixtures moved with the gate, both because they leaned on the old behaviour: paint_order::no_way_can_sink_below_the_landmass used boundary=administrative as its catch-all class (now railway=rail, which reaches WayKind::Other through a key the classifier does read), and facett-osm's allocation bench decoded UNTAGGED fixture rows (now highway=residential; the tag path is a constant that cancels out of the measured slope).

A TRANSLUCENT stroke gets no casing (2026-09-03)

MapFacet's CPU paint draws a road casing — an opaque near-black outline, up to CASING_MAX_PX = 2 px each side — under every line way, in one pass before any fill (so a later road's casing cannot paint over an earlier road's bright core).

That pass now skips any feature whose colour carries alpha < 255.

Why. Every road is opaque. A way that arrives translucent is an overlay — a glow halo, a tracer, a highlight — and an opaque dark outline under a translucent wash is the one thing that can never look like light. MEASURED 2026-09-03 (korp's tests/golden_trail_pixels.rs): korp's case trail draws a 5-pass glow ladder whose widest pass is 22 px at α 0.09; each pass was getting a 26 px OPAQUE casing under it, so the "glow" rendered as a solid grey band with a coloured thread down the middle — on the day map and the night map alike, whatever ladder was used. Skipping the casing is what lets a glow ladder read as a glow on this renderer at all: after it, the same frame goes from 7 471 lit pixels to 17 511 and the halo's cross-section from 13 px to 30 px.

The rule is a property of the ink, not a new API: nothing had to grow a "glow" way kind, and no caller opts in.


A tagless SOURCE is not an untagged way (2026-09-03, lane TL)

has_nothing_to_draw above is right and is not weakened by anything in this section. But it answers a question about one way's tags, and two different things look the same from inside it. Only one of them deserves the answer.

what it is verdict
an untagged way inside a tagged OSM cut a boundary relation's member geometry — OSM leaves it untagged on purpose draw nothing. Lane SI, unchanged.
every way in a source with no tags column a plain WKB table an operator explicitly pointed the host at — Trafikverket, NVDB, a survey export draw. It is not OSM and was never going to have OSM tags.

The regression, measured

korp's Trafikverket overlay (src/main.rs:2947) loads through facett_osm::read_ways_with, which builds every way through Way::from_lonlat_owned_with — a CLASSIFYING constructor. Real Trafikverket road data is not OSM and carries no OSM tags, so after lane SI it read 0 of 20 001 real WKB LineStrings and the overlay drew nothing, silently. korp's own the_trafikverket_overlay_read_is_bounded_like_the_osm_one went red on left: 0, right: 20000 — a test about the read CAP failing on classification.

The seam: the SCHEMA, computed once per batch

read_ways_reported_from already holds the answer and always did:

let tags_col = batch.column_by_name("tags").and_then(|c| c.as_any().downcast_ref::<StringArray>());
let forced   = tags_col.is_none().then(|| facett_map::untagged_source_style(pal));

tags_col is None exactly when the file has no readable tags column at all. That is a property of the SOURCE, not of a row — which is why it is read there and not in the row loop. No row-level "is this one untagged?" test exists in this change, because that test is the one lane SI correctly closed.

A tagless source then routes through the NON-classifying door lane SI deliberately left ungated. SI's own doc: the tag rule sits on the four classifying constructors and never on from_lonlat_styled, "because a caller that hands in its own WayStyle has already decided". A tagless source is such a caller. The only thing that had to change on the facett-map side is that the door became public — Way::from_lonlat_owned_styled. facett-osm's three build sites route through one private build_way(lonlat, tags, pal, forced), so the classifying and non-classifying paths cannot drift (LAW 5).

What was considered and lost

alternative why it lost
relax has_nothing_to_draw to admit empty tag lists that IS the weakening. It readmits the 496 untagged boundary-member ways lane SI measured in one z14 Stockholm frame, 31 of them across open water.
a per-row "this row has no tags" branch same defect, same rows, one indirection further away.
a new read_ways_untagged_source() entry point, or a caller flag the operator does not know whether their parquet has a tags column, so korp would have to guess — a second reader of a fact the schema already states exactly once.
paint it road_generic that is the ink lane SI just took off the water. Doing it to a whole file is the fix undoing itself at scale.
paint it as a road tier (motorway) the reader knows this geometry's SOURCE and nothing about its class. Painting an unknown class as a motorway is the same species of guess the generic arm was.

Known limit, stated: a tags column of a type this reader cannot downcast to StringArray counts as tagless and paints as an overlay. That is the loud direction — the operator sees that something is off — and the alternative is the silent one.

The ink: an OVERPRINT

MapPalette::overlay, new field. Magenta — OVERPRINT_DAY #B5197A, OVERPRINT_NIGHT #FF5FC8 — because that is what an overprint is in cartography: the revision colour a survey adds on top of a printed base sheet, chosen precisely because no base-map feature owns it. Nothing else in either preset is in the magenta family.

Two metrics, because there are two questions, and the first version of the guard used one metric for both and was wrong for it:

question metric held measured
legible on the ground WCAG contrast ≥ 3.0 day: paper 5.41, water 3.79, green 4.61
tellable apart from another ink per-channel distance (0…765) ≥ 120 nearest day ink path #B99873 at 138; nearest night ink road_secondary at 200

Contrast ratio cannot answer the second: #B5197A and the motorway orange #E9A23B sit at 2.86:1 because they have nearly the same luminance, while being about as confusable as two colours get, i.e. not at all. And the number to keep in view for the first: road_generic against day paper is 1.24:1 — the ink lane SI removed was never visible on land at all; it only ever showed because it was being drawn across blue.

The rung: Lod::Region, and why

untagged_source_style is Lod::Region, width 1.6 (the secondary weight, twice road_generic's 0.6). The failure being fixed is invisibility, and a national roads overlay that first appears at camera 12 is invisible at every camera an operator loads one for. MEASURED (korp map_render_truth::a_tagless_source_draws_over_the_water_and_a_tagged_cut_still_refuses, offscreen wgpu over open Baltic water):

camera px added overprint px road_generic px
z8 481 228 0
z13 5 221 2 526 0

Move the rung to Lod::City and z8 goes to 0 px added while z13 is unchanged. That is the rung argument, in pixels.

Not Lod::Country either: that rung carries the classified trunk skeleton, and a whole untagged table smeared over it at continent zoom is the clutter the ferry arm was written to stop.

WayKind::Overprint — why the ink needed a KIND

The overprint style was WayKind::Other first, and that was wrong in a way only a palette swap shows: MapPalette::recolor_way reskins by kind, and Other reskins to road_generic. So OsmView::cartographic(true) — which calls recolor on the way in — repainted a correctly read operator overlay into the exact ink lane SI took off the water, before anything drew. Found by facett-demo's deck guard, which read the source correctly and then reported 0 of 8 overprint ways on the deck. A kind of its own is what makes the ink survive a reskin, and it is also what the deck census keys on (OsmView::state_json → overprint_ways) — an overprint way in the way_kinds histogram would otherwise sit in the same Other bucket a railway lands in, so a deck could not tell "the operator's overlay is on screen" from "it silently read nothing".


The three classes lane SI named and did not decide (2026-09-03, lane TL)

SI's section above ends: "A kommungräns, a pier and a bridge outline each deserve their own arm and their own ink, and inventing one here would be the unargued taste call the ferry arm's doc warns about. Named, not fixed." This is the call, made with frames. Guarded by style::tests::the_three_undecided_classes_are_decided and korp's map_render_truth::the_three_undecided_classes_get_their_verdicts_in_pixels, which renders each one over open Baltic water and counts what it adds to the frame.

class verdict px added at z13 why
boundary=administrative correctly nothing 0 it IS the defect — the kommungräns through Riddarfjärden. And facett already draws administrative units, from facett-geomap's sweden_admin relation geometry; a second boundary painter in the way path, fed by the member ways of the very relations that path already owns, is a twin (LAW 5).
boundary=maritime correctly nothing 0 same, and it is by definition over water.
place=islet / place=locality correctly nothing 0 place on a way is a LABEL anchor, not a stroke. place=islet has no outline anyone wants; it has a name. The label/POI path (LayerCategory::from_osm_tags, facett_osm::read_pois) is where a place belongs and already reads the key.
man_made=pipeline / cable correctly nothing 0 not on the surface at all. Two of SI's five measured sea-crossing chords were exactly these.
man_made=pier / breakwater / groyne / embankment / causeway its own arm and ink 3 177 (352 in MapPalette::land) ground you stand on — land pushed out into water, and inked as the paper so it reads as a cream finger off the quay and vanishes over land, which is correct: a pier over land is not information. Lod::City, width 1.2, WayKind::Path.

CLASSIFIED_PAIRS — why man_made could not just join the key roster

CLASSIFIED_KEYS admits a way on its key alone, and an admitted way whose value matches no arm falls to the generic hairline at the bottom of classify_with. Putting man_made in the key roster would therefore have put man_made=pipeline straight back on the water in road_generic — re-opening the defect through the door built to close it.

So CLASSIFIED_PAIRS: five exact (key, value) pairs, admitted in addition to the key roster, never instead of it. The pair half only ever ADMITS — a way refused before it existed is refused still unless it carries one of the five.

Held by the same source scan that guards the key roster, extended three ways:

All three driven red.