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

nordisk/facett

.nornir/maptrait-design.md

maptrait — the selectable Map trait — design

Class: design · Function axis: f0 (one trait, all maps) · f1 MapLayer + MapView camera · f2 MapFacet + shared renderer · f3 reusable 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

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

Deferred (plan)