maptrait — the selectable Map trait — design
Class:
design· Function axis:f0(one trait, all maps) ·f1MapLayer+MapViewcamera ·f2MapFacet+ shared renderer ·f3reusable sources (WaysLayer/PointsLayer) +MapTheme. Strict: this file states only the shape that is built. Workflow →maptrait-guide.md; zero-knowledge walkthrough →maptrait-for-idiots.md.
f0 — one trait, all maps (the whole)
Every map surface in the constellation draws through one source-agnostic seam so that data-to-draw is selectable and stackable: OSM streets + a Trafikverket roads overlay + korp collectors' own layers, each toggled at runtime. The renderer never knows a source; it paints DrawFeatures. This lives in facett-map/src/layer.rs, additive to the legacy point/ways MapView facet (unchanged). It reuses this crate's Web-Mercator projection, the OSM style palette and the polygon tessellate — nothing is re-implemented.
f1 — MapLayer + the rotatable MapView camera
pub trait MapLayer {
fn id(&self) -> &str; // "osm-streets", "trafikverket-roads", "korp-…"
fn kind(&self) -> LayerKind; // Line | Polygon | Point | Raster | Label
fn features(&self, view: &MapView) -> Vec<DrawFeature>; // culled to the viewport
fn style(&self) -> &LayerStyle; // the layer's identity colour/width/z
fn enabled(&self) -> bool; // user toggle — SELECTABLE data-to-draw
fn set_enabled(&mut self, on: bool);
}
features returns renderer-agnostic primitives (DrawFeature { id, geom, color, width, fill, z } with DrawGeom = Point | Line | Polygon | Label) — never source structs, so the renderer stays generic. The camera is the MapView:
pub struct MapView { pub center: LatLon, pub zoom: f32,
pub bearing_deg: f32, pub tilt_deg: f32, pub size_px: (f32,f32) }
bearing_deg rotates the flat map (compass heading, clockwise — the map turns under a fixed screen so the driver's heading points up); tilt_deg pitches it back toward the horizon (vertical foreshorten by cos(tilt) + a far-fade the renderer shades). zoom is a slippy-map level (world = 256·2^zoom px) so it composes with real OSM/PMTiles tiles. MapView::project is the one shared projection — every layer and every test maps (lon,lat)→pixel through it, so all layers register to the pixel.
f2 — MapFacet + the shared renderer
pub struct MapFacet { layers: Vec<Box<dyn MapLayer>>, view: MapView, theme: MapTheme, .. }
Toggling a layer's enabled is selecting which data draws. enabled_features() culls each enabled layer to view.merc_bbox(), collects the DrawFeatures and z-sorts them (polygons < lines < points < labels) — the pure render input. ui() paints the themed land, then those features projected through the rotated+tilted MapView, reusing tessellate::filled_mesh for fills. Nothing renderer-side is source-aware.
f3 — reusable sources + the "better than Garmin" look
WaysLayerwraps pre-decoded [Way]s (fromfacett-osm's GeoParquet reader, or any source producingWays). Source-agnostic:WaysLayer::new("osm-streets", …)andWaysLayer::new("trafikverket-roads", …)are the same type, different id — both selectable at runtime, no re-parse.facett-osm::ways_layer(id, path, max, theme)builds one straight from a ways GeoParquet through the existingread_waysdecode.PointsLayeroverlays a korp collector's(lon,lat)output.LabelLayer(facett-map/src/layer.rs) — the name/ref text pass, the single biggest Garmin-feel lever. AMapLayer(kind = Label, paints on top at z40) that renders road refs + place/streetnames from the OSMname/ref/placetags the tiles carry. The tag →(text, Lod)rule isstyle::classify_label(facett-map/src/style.rs), a sibling ofclassifysharing the same tag vocabulary: arefshield (E4) wins over a streetname; settlements tier country → region → city byplace=rank.LabelLayer::featuresLOD-gates each label (lod <= view_lod_tier(zoom), the same contractWaysLayeruses) so labels declutter by zoom — country labels everywhere, street names only zoomed into a city. Two constructors:from_tagged(id, [(id, anchor, tags)])(the primary path, consumes raw tags) andfrom_ways(id, &[Way])(harvests the roadnames an already-decodedWaysLayercarries, no re-parse — the korp path). Painted throughMapFacet::ui's existingDrawGeom::Labelarm (themedMapTheme::labelcolour).LandmassLayer(facett-map/src/layer.rs) — the base land fill. AMapLayerthat fills a country's landmass polygon(s) UNDER every other layer so land-vs-sea reads instantly instead of a bare-paper pane between features.LANDMASS_Zispaint_order::LANDMASS_BAND—-900 000since 2026-08-31, derived asWAY_RANK_FLOOR - LAYER_STEPand held there by a const assertion. It was written here as-100and was-1 000in code; both numbers were wrong for the job, because a way's rank is dominated byeffective_layer * LAYER_STEP(100 000) and atunnel=yesway therefore ranks near -98 800 — under a landmass at -1 000. Seemap-reasoning.md"vita marken tar över allt" for what that cost.from_rings(id, rings, fill)takes(lon,lat)outer rings (degenerate <3-vertex rings dropped), viewport-culls by precomputed Mercator bbox, and shows at every zoom (it is the ground, not LOD-gated). Geometry source:facett_geomap::AdminBoundaries::to_landmass_layer(id, fill)/landmass_rings()(facett-geomap/src/boundary.rs), built from the bundled Swedish län rings.MapThemeis the one place the look lives — a day/nightMapPalette+ label + tilt-shade knobs, derived from a facettThemeso the map re-themes with the deck: crisp street hierarchy (motorway over residential by width/colour), smooth labels, glowing night streets, pitched- horizon fade.
f4 — the per-frame memos: what a STILL frame costs
MapFacet::ui re-derives its whole frame from &self every pass, and three of those derivations only change when the layer roster or the camera changes. All three hang off the same key, MapFacet::layer_epoch — bumped by every &mut self op that can change the roster (push_layer, toggle_layer, set_layer_enabled), and safe because the Ways inside a layer never mutate (korp replaces the layer on data change).
| memo | what it stopped re-deriving | key |
lane_plan_memo |
way_lane_plan's two .any() scans of every way |
(layer_epoch, renderer_installed) |
gpu_sig_memo / tile_key_memo |
the O(ways) VRAM signature fold |
(layer_epoch, gpu_layers, origin, per-layer geometry_generation) |
draw_list_memo |
the whole CPU draw list — the walk of every enabled layer, a PaintKey per feature, and the sort_by_key(|f| f.order) over the result |
DrawListKey, below |
The draw-list memo (phase 4 of korp's map-v2-design.md)
MapFacet::features_for_cpu allocated a fresh Vec<DrawFeature>, walked every enabled layer through MapLayer::features_into, and re-sorted the lot on the declared PaintKey — every frame. Nothing invalidated it, so a still screen paid exactly what a pan paid.
The key is the closure of the build's inputs, said explicitly:
struct DrawListKey {
epoch: u64, // MapFacet::layer_epoch — the layer roster
view: MapView, // the WHOLE camera, by value (Copy + PartialEq)
gpu_layers: Vec<usize>, // which layers the GPU lane took this frame
gpu_owns: GpuOwns, // …and which KINDS of their geometry (lines / fills)
}
The camera is keyed as the whole struct, not as the three fields today's four in-crate layers read (merc_bbox ← center/zoom/size_px; lod_visible ← zoom). MapLayer is a public trait: a korp collector's own layer may read bearing_deg/tilt_deg and would be served a stale list by a narrower key. MapView compares floats, so a NaN camera never equals itself and the memo rebuilds — it fails toward correctness. MapTheme is deliberately not in the key: no layer's features_into reads it (a Way carries its own resolved style), so set_theme cannot change this answer.
The result, stated as WORK ELIMINATED
Not as a wall-clock delta, deliberately. Absolute frame times on oden are contention-dominated: lane BENCH measured korp's map_speed on the same binary and the same fixture at 15.91 ms and 36.78 ms four minutes apart, box load ranging 39–262 on 32 cores, while the vertex count came back at 383 708 on every run — only the clock moved. A millisecond figure from this box measures the queue, not the code. (Lane BENCH's corrected medians, release, n=7–8: 2D MapFacet::ui frame 16.55 ms [14.28–36.78]; paint-order sort at the 20 000-way cap 7.28 ms [6.74–12.91]; enabled_features 2.95 ms [2.59–9.28]. The earlier 39.3 ms / 21.9 ms / 236 % figures do not reproduce and are withdrawn.)
So the result is a COUNT, which is deterministic. Over 501 identical asks of the 20 000-way fixture in facett-map/tests/draw_list_memo.rs:
| before | after | |
layer walks (features_into passes) |
501 | 1 |
features materialised = way_paint_key computations = sort comparands |
10 020 000 | 20 000 |
sort_by_key passes over the whole list |
501 | 1 |
One build per distinct frame instead of one per frame. Both counters are public measurement seams — draw_list_build_count() (the frequency) and draw_list_feature_count() (the volume) — for the same reason way_lane_scan_count() exists: the painted pixels are byte-identical whether the memo hit or not, so the output can never be the oracle.
The memo hands back Arc<Vec<DrawFeature>> and ui paints straight out of it — a memo that cloned its answer would re-allocate 20 000 DrawFeatures on every hit to copy a list nothing mutates.
Hit ratio, honestly: over a scripted pan/zoom (6 gestures × 15 moving frames, each followed by a 60-frame dwell) it is 80.0 % — exactly the 90 moving frames rebuild and every dwell frame is a hit. A pan still pays the full build, because the camera really is a key input; what the memo buys is the still screen, which is what phase 4's gate asks for. The same follows for idle auto-rotate: while the camera spins, bearing_deg moves every frame and the memo misses every frame — no regression, but no win either. The percentage is a property of the gesture script, not of the memo; what the test pins is the mechanism under it (every dwell frame a hit, every gesture frame a rebuild).
What is NOT on this key, and why
MapFacet::place_map_labels runs immediately after, over the same &feats, and is now the dominant remaining per-frame CPU cost — profiled through a real egui_kittest frame (4 000 named Stockholm ways, 8 000 features, 1600×1000, zoom 13, still camera, eight frames):
| frame | draw list | label placement |
| first (cold: ear clips + font atlas) | 67.1 ms | 79.2 ms |
| every still frame after | 150 ns – 1.1 µs | 0.96 – 9.45 ms |
Those absolutes carry the same contention caveat as everything else timed on this box; they are quoted only for the ORDER between the two columns, which held on every frame of the run and is the only thing decided from them.
It is not memoised here because its answer is computed against egui's font state: painter.layout_no_wrap shapes every candidate through Context::fonts, and while rect, origin and pixels_per_point are all readable at that seam, the font definitions and the atlas carry no version a MapFacet can read. A key written today would be incomplete by construction — and a stale answer there is not a slow frame, it is names painted into rects the painter no longer measures, published as painted_label_boxes(), which is the applied-output oracle label_collision.rs asserts on. Memoising it is its own change and needs a galley generation first.
Bench + test contract
- Bench (native frame time):
facett.map.layer_frame(inbench/examples/nornir-bench.rs) timesMapFacet::enabled_features()over a heavy rotated+tilted stack (OSM streets + Trafikverket overlay + a 5000-point collector layer) —ms_per_frame+frames_per_sec. - Test: the projection / cull / toggle / z-stacking /
state_json/ day-night unit tests infacett-map+ thefacett-osmreuse proof (ways_layer_wraps_decoded_ways_as_a_selectable_source). The Garmin levers add:classify_label_extracts_name_ref_and_tiers_lod(style),label_layer_emits_labels_from_name_ref_tags,label_layer_lod_gates_labels_by_zoom,label_layer_from_ways_harvests_road_names,landmass_fill_renders_under_all_other_layers,landmass_culls_offscreen_and_skips_degenerate_rings(layer), andto_landmass_layer_covers_every_ring_as_the_base_fill(facett-geomap boundary). - Test (f4, the memos):
facett-map/tests/frame_churn_memo.rs(the lane plan + the VRAM signature) andfacett-map/tests/draw_list_memo.rs(the draw list). The oracle in both is a counter on the work —way_lane_scan_count()/gpu_sig_walk_count()/draw_list_build_count()+draw_list_feature_count()— never the output, because the painted frame is byte-identical whether the memo hit or not, and never a wall-clock delta, because absolute ms on oden are contention-dominated (load 39–262 on 32 cores). The draw-list file's seven arms:a_still_frame_never_rebuilds_the_draw_list(501 asks, ONE walk, ONE sort, 20 000 paint keys, and the sameArceach time — not merely an equal list),an_invalidated_frame_rebuilds_every_time(the red arm: a real one-pixel pan per frame, both counters climb 1:1; the ms pair it prints is labelled as NOT a speed-up figure),the_key_covers_the_camera/..._the_layer_roster/..._the_lane/..._bearing_and_tilt(one per keyed input, asserting the LIST changed — bearing/tilt through a testMapLayerthat reads them, since no in-crate layer does), andhit_ratio_over_a_pan_zoom_sequence. Seen red (LAW 2), field by field: each key input was dropped in turn and the matching arm fired —epoch→..._the_layer_roster;view.center/view.zoom/view.size_px→..._the_camera;view.bearing_deg+tilt_deg→..._bearing_and_tilt;gpu_layers/gpu_owns→..._the_lane. Thesize_pxsweep is what caught a vacuous arm: it stayed green on the first pass because the preceding ask had already changed the key, so the test now primes the memo at the baseline camera before resizing.
Deferred (plan)
- Live Trafikverket/NVDB fetch → belongs in
korp-collectors(it already has a Trafikverket collector); the map consumes its GeoParquet export viaways_layer("trafikverket-roads", …). No network in tests keeps the layer path pure. - Full
Facet/Elmwiring ofMapFacet(pan/zoom/rotate gestures →Msg) rides the korp panel extraction (consolidation §2 / §5). -
KORP WIRING (follow-up — the levers are built + tested in facett; korp is mid-build elsewhere, so NOT edited here). korp's
rebuild_map_facetmust surface the two new layers on its map pane. One line each:-
Labels: after building the OSM
WaysLayer, pushMapFacet::push_layer(Box::new(facett_map::layer::LabelLayer::from_ways("street-names", ways)))(orfrom_tagged(...)if the pane keeps the raw tile(id, anchor, tags)— richer, gets place- ref labels, not just road names). It stacks on top automatically (z
40).
- ref labels, not just road names). It stacks on top automatically (z
-
Landmass: push the base fill first (it self-sorts to the bottom via
LANDMASS_Z):MapFacet::push_layer(Box::new(sweden_admin.to_landmass_layer("landmass", theme.palette.land)))wheresweden_admin = facett_geomap::load_sweden_admin()?.
-