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

nordisk/facett

.nornir/map3d-reasoning.md

map3d — the 3D spatial subsystem — reasoning

Class: reasoning. This file holds the why, the trade-offs, and every design rejected for facett-map3d, facett-map3d-flight, and facett-model3d. The descriptive shape actually built is in map3d-design.md, which by rule mentions none of this. If you want to know "why not X?" — X is here.

Why a swappable RenderBackend trait at all

The 3D map already had two things that draw the same mesh: a CPU painter (always available) and a wgpu fast path. The trait (RenderBackend, facett-map3d/src/backend.rs:155) turns that ad-hoc pair into a named seam — the "Renderer/Canvas backend-seam trait" pattern this repo already committed to for the 2D map/graph render engine (.nornir/render-engine.md), applied here to the 3D map. The payoff is that a future Rust engine (a different wgpu abstraction, a software rasteriser, whatever comes next) implements one object-safe trait and the Map3D view code — the widget, the input handling, the Elm Msg/update loop — never changes. Object-safety (Box<dyn RenderBackend> on Map3D, lib.rs:125) was the point of keeping the trait small: caps, analyze, paint, reset_cache — no generics leaking into Map3D's public surface.

Why analyze is separate from paint

analyze(&scene) -> RenderStats is pure and device-free; paint(&scene, ui) draws and also returns RenderStats. Splitting them buys a conformance test that never touches a GPU or an egui::Ui: tests/backend_conformance.rs calls analyze on CpuPainterBackend and NullBackend (and, under --features wgpu, WgpuVelloBackend) with the same RenderScene and asserts the geometry-derived fields agree. If analyze and paint were one method, that comparison would need a live paint surface for every backend, which the GPU backend can't cheaply give a headless test.

Why paint has a default implementation

The default paint falls back to paint_cpu (backend.rs:168). A backend author implementing a brand-new engine can ship with only caps() and analyze() written and still draw something correct on day one (the CPU fallback), then override paint once their device pipeline is ready. The alternative — requiring every backend to hand-write its own paint immediately — was rejected because it would block a partial/in-progress backend from being usable at all.

Why NullBackend exists separately from CpuPainterBackend

Both report identical RenderStats (backend.rs:339 calls the same analyze_scene CpuPainterBackend uses), but NullBackend::paint draws nothing. Rejected: reusing CpuPainterBackend itself as "the headless backend" — its paint really does build an egui::Mesh and add it to the painter, which is fine in a live egui::Ui but is the wrong thing to reach for when the point of a test is "prove two different backend identities produce the same stats with zero paint side effects." A distinct, explicitly-named NullBackend makes that intent visible at the call site instead of relying on a convention ("don't look at what CpuPainterBackend::paint actually drew").

Why vello is reserved for the 2D HUD only, not the whole 3D pass

WgpuVelloBackend's doc comment is explicit: "all 3D pipelines run on wgpu … vello is reserved for the 2D HUD/labels the view draws over the top" per the framing law in .nornir/flightsim-render.md. The 3D geometry (shadow → reflection → PBR mesh → water → blit) needs a real depth-tested rasteriser; vello's strength is 2D vector compositing, which is what the HUD/label overlay needs, not what a shadow-mapped PBR mesh pass needs. Splitting the two engines by what each is good at was chosen over forcing one engine to do both jobs.

Why the korp seam is one function today, not a trait

facett-map3d/src/korp.rs states its own status: "korp/.nornir is empty today, so this is a clearly-marked SEAM + TODO, not a live connector." Building a full connector trait now, before korp's contract exists, would mean guessing at an API that would likely be wrong once korp lands. Instead load_layers(ways) (korp.rs:132) is the single function body that will be swapped for a real korp::connect(region) call — a one-line change point instead of a speculative abstraction. FeatureLayers (korp.rs:27) is still introduced now (rather than passing &[Way] straight through) because it is a connector-agnostic boundary type — grouping by cartographic layer is useful today (it drives GroundLayer z-ordering) independent of whether the data source is OSM or korp, so it doesn't need to wait for korp to be worth having.

The clip-pipeline fix: near-plane clip over the old heuristic pair

Map3D::shade_faces_clip(..., near_clip: bool) (facett-map3d/src/lib.rs:683) keeps the old, rejected projection path alive as shade_faces_legacy (lib.rs:703) — on purpose, as a diagnostic fixture. The old pipeline projected all three raw vertices of a triangle, dropped the whole triangle if any vertex was behind the eye, and then applied a "screen-extent guard" that rejected a face whose projected extent dwarfed the viewport. Both heuristics were rejected as the production path:

The fix is a real near-plane frustum clip (clip_tri_near, clip_tri_frustum, lib.rs:1177,:1262) applied before projection, which geometrically re-shapes a straddling triangle into the polygon that's actually in front of the camera — no whole-triangle drops, no extent-guard heuristics, and ink_outside_rect == 0 by construction. shade_faces_legacy was kept (not deleted) specifically so a fail-on-bug/pass-on-fix regression test can feed the same camera pose into both pipelines and diff the escaping geometry, proving the fix actually changes what reaches the rasteriser.

Cap triangulation: ear-clip over centroid-fan (the OSM "starburst" fix)

Building/area caps used to be centroid-fanned: a fabricated top_centre / bot_centre vertex was added per ring, and every cap triangle fanned out from it. That is correct for a small convex building, but real OSM data has large, concave closed rings — admin boundaries, landuse/forest, coastline, water. A centroid-fan of a big concave ring draws spokes from the map centre to every boundary vertex across the whole design box (the starburst artefact seen on a Liechtenstein render), and is geometrically wrong regardless of appearance: concave polygons cannot be correctly fan-triangulated from a single centroid.

The fix is ear-clip triangulation (ear_clip, facett-map3d/src/extrude.rs) over only the ring's own vertices — no fabricated centroid vertex at all. Rings are opened (the OSM closing-duplicate point dropped) and normalised to CCW (signed_area) before clipping; the top cap winds CCW, the bottom cap reuses the same triangulation reversed. A standard O(n²) ear-clip was chosen over pulling in an external triangulation crate or an RNG-based method — the ring counts here are small (building/landuse footprints, not million-vertex meshes), so the simpler, dependency-free, deterministic algorithm was sufficient and keeps the extrusion path free of a new crate dependency. The centroid is still computed, but only to orient wall normals outward — never to build a cap.

Land/water lie flat; only buildings rise

prism_height_for (the height-choice logic backing extrude::build) was written so the scene reads as a city, not an accident of OSM tagging: a building ring always rises to the full extrusion height, while water/park/coastline rings — and any ring whose footprint exceeds FLAT_AREA_FRAC = 0.18 (18%) of the design box, e.g. an admin mega-ring — lie flat (height = 0, a single ground cap, no walls). The alternative (extrude every closed ring uniformly) was rejected because an admin boundary or a lake is not a building, and extruding it to the same height as a building turns a country outline into a skyscraper.

Altitude/LOD: hysteresis over a hard threshold

The reflective-water tier is gated by altitude (< 300 m → Ground, lod::WATER_ON_ALTITUDE_M), but the tier resolution (lod::resolve_tier) is not a bare threshold check — it carries a was_ground memory and a WATER_OFF_ALTITUDE_M = 360.0 dead-band above the trigger. A hard single threshold was rejected because a camera hovering near 300 m would flicker the expensive reflective-water pass on and off every frame the altitude estimate crossed the line by a hair; the 300–360 m dead-band means the tier only flips out of Ground once the camera has clearly climbed away, and only flips into Ground once it has clearly descended below 300 m — stable in both directions.

map3d-flight: f64 world frame + floating origin over raw f32 world coordinates

facett-map3d-flight works in Web-Mercator metres at planetary scale (WorldPos is f64, origin.rs:38), then rebases to the camera every frame (FloatingOrigin::rebase, origin.rs:135) before anything reaches the f32 GPU pipeline. Feeding f32 world coordinates directly into the renderer at planetary scale was rejected: far from the Mercator origin, f32's precision is too coarse relative to a building-scale feature, producing visible vertex jitter and Z-fighting as the camera moves. Keeping the authoritative position in f64 and only ever converting a relative-to-camera delta to f32 keeps every rendered coordinate small, regardless of where on the planet the camera sits.

Why atmosphere reuses a precomputed LUT instead of a live analytic raymarch per pixel

TransmittanceLut (atmos.rs:138) precomputes light transmittance to the top of the atmosphere over a small 2D (altitude, μ) grid with bilinear sampling, rather than ray-marching the full Bruneton/Hillaire optical-depth integral on every lookup. The precomputed table keeps the model deterministic and cheap enough to inject-assert directly (feed an altitude + sun angle, assert a specific tint), which a live per-call raymarch would make expensive to test exhaustively; the .nornir/flightsim-engine-roadmap.md roadmap explicitly plans to move the same lookup onto a GPU-precomputed texture later, with the CPU version as the correctness oracle it must match.

Why Mesh3 / SunSky / LodTier are re-exported, not forked

facett-map3d-flight re-exports these three types from facett-map3d (facett-map3d-flight/src/lib.rs:49) instead of defining its own mesh/lighting/LOD types. Forking them was rejected because the flight-sim layer is explicitly framed as extending the lightweight map (facett-map3d/src/lib.rs doc: "carries the heavy, GPU-required, full-screen 'bells and whistles'; facett-map3d stays lean") — duplicating the mesh or lighting model would mean two shading pipelines to keep in sync, and atmospheric_sunsky (lib.rs:62) would have nothing to bridge onto.

Why tile loading is a pure planner over a pluggable TileSource

plan_tiles (tiles.rs:164) takes resident/in_flight/the view query/a budget and returns a TilePlan — it does no I/O itself. TileSource (tiles.rs:55) is a separate trait with two impls: InMemoryTileSource (deterministic test fixture) and DirTileSource (on-disk GeoParquet). Folding loading and planning into one component was rejected: keeping the eviction/priority policy pure means a test can assert "given this resident set and this camera position, exactly these tiles load and these evict" without touching a filesystem, while swapping InMemoryTileSource for DirTileSource in production requires no change to the planner at all.

model3d: a separate Mesh3d, not facett_map3d::extrude::Mesh3

facett-model3d::mesh::Mesh3d (named groups, per-vertex normals, a glTF base colour per group) is a distinct type from facett_map3d::extrude::Mesh3 (positions + flat triangle indices + a per-triangle source colour, built by extruding 2D map ways). Unifying them was rejected: the two meshes come from fundamentally different sources (an OSM extrusion vs. a loaded glTF asset) and need different per-triangle data (a map triangle's colour comes from the source way; a model triangle's colour comes from its glTF group), so a single shared type would need optional/unused fields for one side or the other with no real benefit — the two viewers don't share geometry, only the shading convention (project → cull → depth-sort → Lambert-shade), which is duplicated as a small function in each crate rather than factored into a shared crate.

Why model3d has no wgpu render pass

The doc comment on facett-model3d is direct about the trade taken: it draws through facett's existing CPU painter path so "it lights up identically native and in the wasm demo and stays headless-snapshot-testable," rather than adding a three-d/rend3/wgpu pass with PBR/textures/animation. The rejected alternative — a real wgpu-based glTF renderer — would buy PBR materials and textures but would reintroduce the "GPU render can't be checked from a return value" problem this codebase already solved once for facett-map3d, for a component whose whole job is a small embeddable status-tinted viewer, not a high-fidelity asset previewer.

Why status is a scalar + a named ramp, not a raw colour input

Model3D::with_status(f32) takes a [0,1] scalar resolved through STATUS_RAMP (lib.rs:65) via facett_core::render::decor::gradient_by_scalar, rather than accepting an arbitrary Color32 directly. A raw-colour API was rejected because the point of the feature is a domain readout ("how healthy is this thing") that a host drives with a single number (e.g. straight from a health score), not a UI styling knob; the gradient is the same status-ramp pattern used elsewhere in facett, so a consumer reads "0.7" the same way regardless of which component renders it.

How we got here (the road, briefly)

facett-map3d started as a fixed yaw+tilt orbit CPU painter over extruded OSM ways (the T4 wgpu/3D-holdout wave noted in elm-reasoning.md's migration history). It grew a full navigable OrbitCamera, then a GPU fast path reusing facett-graph3d's pipeline, then — as the map3d-flight roadmap's planetary ambitions arrived — the swappable RenderBackend trait, so the wgpu/vello production pipeline and any future engine sit behind the same seam the CPU painter already proved out. facett-map3d-flight was split into its own crate rather than grown inside facett-map3d specifically so the lightweight, embeddable map (CPU fallback, deck-pane-embeddable) never has to carry the heavy, GPU-required, full-screen flight-sim machinery as a hard dependency. facett-model3d was built independently as a general-purpose glTF viewer once the CPU-painter shading convention (project → cull → sort → Lambert) had already proven itself on the rabbit logo and the 3D map, reusing the convention, not the map's mesh type.