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

nordisk/facett

.nornir/map3d-design.md

map3d — the 3D spatial subsystem — design

Class: design · Function axis: f0 (the 3D subsystem as one) · f1 map3d + RenderBackend · f2 map3d-flight (camera/atmosphere/streaming) · f3 model3d. Strict: this file states only the shape that is built. Every why, every rejected shape, lives in map3d-reasoning.md. Workflow → map3d-guide.md; zero-knowledge walkthrough → map3d-for-idiots.md.

f0 — the 3D subsystem (the whole)

Three crates make up facett's 3D spatial subsystem, each a Facet/Elm component (the same FC-1..FC-9 contract as every other facett view, see elm-design.md):

crate job Elm view reuses
facett-map3d embeddable 3D map: extrude facett_map::Ways → prisms/ribbons/terrain/water, orbit-navigate, draw through a swappable [RenderBackend] Map3D (facett-map3d/src/lib.rs:74) facett_graph3d::RabbitLogo3D (logo overlay), facett_core::rabbit (synthetic fixture)
facett-map3d-flight planetary flight-sim engine layer: f64 floating origin, 6DOF camera rig, atmospheric scattering, tile streaming none yet (pure CPU library; no Facet impl in this crate today) facett_map3d::{Mesh3, SunSky, LodTier} — re-exported, not forked (facett-map3d-flight/src/lib.rs:49)
facett-model3d reusable glTF model viewer, CPU-lit, status-tinted sub-mesh Model3D (facett-model3d/src/lib.rs:161) facett's CPU painter path (project → cull → depth-sort → Lambert shade), independent of facett-map3d's mesh types

facett-map3d is the hub: facett-map3d-flight extends its shared mesh/sky/LOD types instead of forking them, and facett-model3d is a sibling viewer built on the same CPU-painter convention but with its own Mesh3d (glTF-shaped: named groups + per-vertex normals + glTF base colour), independent of facett_map3d::extrude::Mesh3 (OSM-shaped: positions + indices + per-triangle source colour).

Every component in the subsystem follows the canonical Elm split (elm-design.md f0): a serializable Model (Map3dModel / Model3DState), a Msg enum as the sole mutation alphabet, an uninhabited Effect (no I/O — enum Effect {} in both facett-map3d/src/lib.rs:1380 and facett-model3d/src/lib.rs:158), and a pure view(&self).

f1 — map3d + the RenderBackend trait

The extrusion pipeline

facett_map::Ways become 3D geometry via facett_map3d::extrude:

input becomes function
closed ring, surface_of(kind) != Water and footprint ≤ FLAT_AREA_FRAC (18%) of the design box, building tag a risen prism to height build (extrude.rs:386), Surface/surface_of (extrude.rs:30,:41)
closed ring otherwise (water / large landuse) a flat ground cap (height = 0) same build path
line way a flat ground ribbon build
water polygons a separate tessellated grid WaterMesh (extrude.rs:98), WATER_HEIGHT = 5.0e-3 (extrude.rs:90)
(always) one terrain plane terrain_plane (extrude.rs:636)

build_with_water(ways, height) -> (Mesh3, usize, WaterMesh) (extrude.rs:436) is the entry Map3D::from_ways calls. Mesh3 (extrude.rs:53) holds positions + Tri indices (extrude.rs:24); vertex_count/triangle_count are on Mesh3/WaterMesh (extrude.rs:64-68,:105-113). Every footprint is recentred into the normalised [-1,1] design box with a uniform scale, DESIGN_FILL = 1.6 (extrude.rs:206) of it filled by the data's long axis. Ground-layer z-ordering (landuse / water / streets / buildings) is GroundLayer + layer_lift (extrude.rs:658,:684), lifted by GROUND_LIFT = 1.2e-3 (extrude.rs:649) per layer so coplanar ground geometry doesn't z-fight. densest_cluster_subset (extrude.rs:220) and world_span_metres (extrude.rs:311) subset a country-scale load to one town and report its real-world metric span.

The synthetic fixture is extrude::rabbit_ways() (extrude.rs:981): each facett_core::rabbit::Rabbit::silhouette() loop becomes a filled Way through the same Mercator path real OSM data uses, so it extrudes into a rabbit-shaped skyline.

Map3D (facett-map3d/src/lib.rs:74)

Constructors: from_ways(title, ways, height) (:177), from_geometry(title, ways) (:374, subsets to the densest town via densest_cluster_subset then frame_densest_cluster :395), synthetic(title) (:343, the znippy-rabbit fixture + a pinned RabbitLogo3D overlay), and the discovery-contract pair local()/remote() (:356,:363).

Camera navigation is OrbitCamera (facett-map3d/src/camera.rs:137): spherical azimuth/elevation/distance around a target, true perspective projection (project_view/project, :427,:443; view_proj, :455). Inputs: orbit (:256), pan (:276), dolly (zoom-to-cursor, :304), key_orbit (:344), apply_preset/ViewPreset (:328,camera.rs:36), reset (:335). Damping is goal-tracking: update(dt) (:354) eases live angles toward goals; settle() (:380) jumps straight there for deterministic headless tests. distance_to_surface() (:537) and clamp_above_ground() (:556) keep the eye above the terrain. Map3D::render_camera() (lib.rs:621) folds any auto-spin / injected clock into the live camera's azimuth so a fixed clock yields a reproducible frame.

The RenderBackend trait — the swappable render-engine seam

facett-map3d/src/backend.rs defines the seam: [crate::Map3D] draws every frame through a Box<dyn RenderBackend> (backend.rs:125, field Map3D::backend), so a different Rust engine implements RenderBackend without the view code changing.

piece what symbol
RenderScene<'a> backend-neutral per-frame input: &Mesh3, &WaterMesh, resolved OrbitCamera, SunSky, Viewport, background, SceneFlags, clock_s — no egui, no wgpu types backend.rs:86
Viewport widget rect + centre + perspective focal half_h backend.rs:46, from_rect :58
SceneFlags per-frame feature toggles: sky/ssao/soft_shadows/shadow_strength/water_on/planar_reflection backend.rs:67
BackendCaps what a backend supports: gpu/shadows/reflective_water/post_fx backend.rs:108
RenderStats the conformance oracle every backend reports identically: visible_triangles, lit_fraction, ink_outside_rect, raw_overshoot_px, depth_monotone, max_extent_px, plus backend/used_gpu backend.rs:126
RenderBackend trait caps(), analyze(&scene) -> RenderStats (pure, device-free), paint(&scene, ui) -> RenderStats (default = paint_cpu), reset_cache() (default no-op) backend.rs:155
CpuPainterBackend impl #1a: always-available headless CPU projection backend.rs:316, RenderBackend impl :318
NullBackend headless conformance backend: same analyze oracle, draws nothing backend.rs:333, paint :342
WgpuVelloBackend (feature wgpu) impl #1b, the production backend: wgpu shadow → reflection → PBR mesh → water → blit pass chain; vello reserved for the 2D HUD drawn over the top backend.rs:361, RenderBackend impl :377

The shared geometry math lives once: analyze_scene (backend.rs:226) and paint_cpu (backend.rs:240) both call crate::shade_mesh_faces (lib.rs:779) and face_stats (backend.rs:181), so every backend's RenderStats is computed from the identical projection. Map3D::build_scene (lib.rs:634) assembles the RenderScene each frame from the live camera, sun, mesh, water, and flags.

CPU depth = a software z-buffer, not a painter's sort. paint_cpu now rasterizes through crate::raster_zbuffer (lib.rs), a true 1/z per-pixel depth test carrying each vertex's view-space depth vz — the fix for the depth-tearing the old painter's-order centroid sort showed where a small near face overlapped a large far one (so overlapping building / terrain / ground-cover faces resolve by TRUE depth on llvmpipe / wasm, no GPU). Shaded colour is ACES-tonemapped (aces_tonemap / tonemap_shaded, Narkowicz ACES) so the CPU look matches the wgpu path's curve. The centroid-order depth_monotone in RenderStats remains the device-free conformance oracle.

The korp connector seam

facett-map3d/src/korp.rs defines FeatureLayers (korp.rs:27): OSM-style Ways grouped into buildings/streets/landuse/water. classify(ways) (korp.rs:44) groups by the same tag rules the extruder uses; flatten() (korp.rs:79) re-linearises in cartographic draw order (landuse → water → streets → buildings) for GroundLayer z-ordering; into_map3d(title) (korp.rs:93) builds a Map3D from the layers. load_layers(ways) (korp.rs:132) is the one function a korp connector plugs into — it classifies OSM ways today.

Altitude / LOD (facett-map3d/src/lod.rs)

metres_per_design_unit(ways) (lod.rs:47) derives METRES_PER_DESIGN_UNIT from the extruder's real-world footprint span (world_span_metres / DESIGN_FILL), falling back to DEFAULT_METRES_PER_DESIGN_UNIT = 625.0 (lod.rs:30) for synthetic data. altitude_metres (lod.rs:57) converts a camera design-unit distance to real metres. LodTier (lod.rs:64: Ground/Low/Mid/High) is resolved by lod_tier(altitude_m) (lod.rs:115), with hysteresis (WATER_ON_ALTITUDE_M = 300.0, WATER_OFF_ALTITUDE_M = 360.0, lod.rs:35,:40) via resolve_tier(altitude_m, was_ground) (lod.rs:134). LodTier::reflective_water() (lod.rs:90) is true only for Ground; LodTier::feature_budget(base) (lod.rs:101) shrinks a streamer's per-tile way budget monotonically with distance, floored at 1. Map3D::altitude_m()/lod_tier() (lib.rs:310,:317) expose the live reading; Map3D::metres_per_du and last_tier_ground (lib.rs:132,:135) hold the calibration and hysteresis memory.

Sun + sky (facett-map3d/src/sky.rs)

SunSky (sky.rs:14) is a directional sun (dir, sun_color, sun_intensity) plus sky ambient (sky_color, ambient), shared by the GPU PBR shader and the CPU painter. Presets: noon() (sky.rs:36, the default), golden_hour() (:49), from_angles(azimuth, elevation) (:62). Uniform packings for the shader: light_uniform/sun_uniform/sky_uniform (:72,:77,:82). light_view_proj (:94) builds the orthographic shadow-pass light matrix; lambert_shade(n) (:145) is the CPU painter's twin of the shader's diffuse term. invert (:187) inverts a column-major [f32;16] (the skybox fragment shader's ndc → world ray reconstruction).

Elm surface

Map3dModel (lib.rs:1306) is the observable control state: orbit target, azimuth, elevation, distance, fov_y, spin/spin_rate, clock_s, water_override, planar_reflection. Msg (lib.rs:1335) covers every input: Orbit/Pan/Dolly/KeyOrbit/Preset/Reset/Settle/SetCameraPose/ SetSpin/SetClock/Frame/SetWaterOverride/SetPlanarReflection. Effect (lib.rs:1380) is uninhabited. Map3D::update (lib.rs:1388) is the sole mutation path; view (lib.rs:1488) is pure. impl Elm for Map3D is at lib.rs:1604; the bridge macro's form-3 custom_state_json (lib.rs:1631) publishes tiles/vertices/triangles/visible_triangles/fps/ used_gpu/camera (OrbitCamera::pose_json, camera.rs:615)/ metres_per_design_unit/altitude_m/lod_tier/reflective_water/ water_triangles/water_active; caps() (lib.rs:1666) reports FacetCaps::NONE.themeable().resizable().navigable(). portable_state/load_state (lib.rs:1682,:1695) clone the camera pose between two Map3D instances.

f2 — map3d-flight (camera / atmosphere / streaming)

facett-map3d-flight is a pure-CPU engine layer (no GPU, no egui at this layer) over facett-map3d's shared types, re-exported unmodified: Mesh3, SunSky, LodTier (facett-map3d-flight/src/lib.rs:49).

piece what symbol
WorldPos an f64 Web-Mercator world position (metres east/north + altitude) — the canonical sim-space origin.rs:38, from_lonlat :59, EARTH_RADIUS_M = 6_378_137.0 :28
FloatingOrigin the per-frame f64→f32 rebase point origin.rs:101, snapped :121, rebase/rebase_all :135/:144
FlyMode which control scheme drives the pose: free-fly / cinematic-orbit / flight-stick camera_fly.rs:21
FlightCamera the 6DOF pose + active mode camera_fly.rs:63, free_fly :86, forward/right/up :101/:109/:122, integrate(input, dt) :145, eye_relative(origin) :201, make_origin(grid_m) :208
ControlFrame one frame of raw injectable control state (keys/mouse/scroll) control.rs:29, requested_mode :64, from_egui (feature egui) :168
FlightController maps ControlFrame → FlyInput, pilots a FlightCamera control.rs:80, to_fly_input :113, apply :149
TransmittanceLut / Atmosphere precomputed Bruneton/Hillaire-style atmospheric scattering atmos.rs:138 (precompute :151, standard :171, sample :177), atmos.rs:202 (earth() :214, sky_radiance :225, aerial_perspective :283)
SpatialKey / SwedenExtent a slippy/Web-Mercator tile key; Sweden's WGS-84 bounding extent sweden.rs:32 (from_world :57), sweden.rs:106, WORLD_HALF_M = π·R :25
tiles_in_view(origin, z, radius_m) nearest-first tile selection within radius_m sweden.rs:173
TileSource trait / InMemoryTileSource / DirTileSource pluggable tile geometry source: an in-memory fixture and an on-disk per-tile GeoParquet reader ({root}/{z}_{x}_{y}.parquet via facett_osm::read_ways) tiles.rs:55, :64, :104
plan_tiles pure per-frame load/evict decision from resident + in-flight + view + budget tiles.rs:164, TilePlan :142

atmospheric_sunsky(base, atmos, altitude_m, exposure) (facett-map3d-flight/src/lib.rs:62) is the bridge: it returns a SunSky whose sky_color is the atmosphere-derived zenith tint at the given altitude/sun, keeping dir (the lighting) unchanged, so facett-map3d's existing GPU sky pass and CPU-painter fog become altitude/sun-driven.

f3 — model3d

facett-model3d loads an embedded glTF/.glb mesh and draws it through facett's CPU painter path (project → back-face cull → painter's-algorithm depth sort → Lambert shade), independent of any wgpu render pass.

piece what symbol
Mesh3d / MeshGroup loaded geometry: named groups, each with positions/normals/indices + a glTF base colour mesh.rs:37, mesh.rs:19
load_glb(bytes) parse a .glb buffer into a Mesh3d mesh.rs:156
normalise_unit_box rescale loaded geometry to fit the [-1,1] viewer box mesh.rs:59
to_glb(mesh) serialize a Mesh3d back to .glb bytes glb.rs:15
skull_mesh() the bundled procedural skull's raw mesh (baked to SKULL_GLB by the gen_skull bin) skull.rs:58
SKULL_GLB / AVOCADO_GLB embedded fixtures: the procedural skull ("eyes" status group) and a stripped CC0 Khronos "Avocado" sample lib.rs:55, :61
STATUS_RAMP the green→yellow→red gradient stops, sampled via facett_core::render::decor::gradient_by_scalar lib.rs:65

Model3D (lib.rs:161) constructors: new(title, mesh) (:173), from_glb(title, bytes) (:178), skull(title) (:184, status group "eyes"), avocado(title) (:191, status group "Avocado"). status_color() (:261) resolves the current ramp colour. Model3DState (lib.rs:88) holds yaw/tilt/spin/spin_rate/ clock_s/status/status_group/status_control — the complete observable control state; the mesh is held on the view, not the Model. Msg (lib.rs:128) covers Orbit/Spin/SetYaw/SetTilt/SetSpin/ResetView/SetStatus/ SetStatusGroup/SetStatusControl/Tick/SetClock. Effect (lib.rs:158) is uninhabited. Model3D::update (lib.rs:267) is the sole mutation path; view (lib.rs:297) paints the optional inline status slider then every group's triangles — collected across all groups into one list (lib.rs:386) so the painter's-algorithm depth sort resolves occlusion between groups (e.g. eyes vs. skull) — using the shared rotate(p, yaw, tilt) convention (lib.rs:451, the same convention as RabbitLogo3D). impl Elm for Model3D is at lib.rs:468; the form-3 custom_state_json (lib.rs:495) publishes vertices/triangles/ groups/pose/status/status_rgb; caps() (lib.rs:518) reports FacetCaps::NONE.themeable().resizable() (not navigable — orbit here is a drag gesture, not a full OrbitCamera).

Off-thread extrusion (GFX_V2 shit list #3, 2026-08-21)

backend::RenderBackend is now : Send, so a Map3D can be extruded on a worker thread and handed to the UI thread — Box<dyn RenderBackend> without the bound made the whole map !Send, which pinned every host's re-extrusion inside its paint (korp's risen view blocked mid-drag whenever the window moved past REBUILD_CENTRE_FRAC). Both shipped backends hold only Send state. Guard: a_map3d_can_cross_to_a_worker_thread (did not compile before). korp's Risen3D::spawn_build is the consumer.