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

nordisk/facett

facett-map3d/src/lib.rs

//! **facett-map3d** — a **3D map viewer**: the 2D OSM vector basemap
//! ([`facett_map::Way`]) **extruded into 3D**. Building / area footprints become
//! **prisms** (a closed ring lifted to a height), line ways become flat **ribbons**
//! laid on the ground, and the whole map sits on a **terrain plane**. A camera
//! **orbits** (yaw about Y + a fixed tilt) like [`facett_graph3d::RabbitLogo3D`],
//! so the city reads as a solid, lit, slowly-turning model.
//!
//! Two render paths share one [`Facet::ui`] — the same contract as facett-map and
//! facett-graph3d:
//!
//! - **CPU painter** (default, always available, headless-testable) — every
//!   extruded triangle is projected (orbit yaw + tilt), **back-face culled**,
//!   **depth sorted** (painter's algorithm), and shaded by `normal·light`. egui
//!   tessellates them, so a host with no wgpu RenderState still gets a real lit
//!   3D map. Never panics.
//! - **wgpu fast path** (feature `wgpu`) — reuses facett-graph3d's depth-tested
//!   [`facett_graph3d::logo3d::gpu`] pipeline for the **znippy rabbit logo overlay**
//!   (the spinning mascot in the corner). The basemap prisms always draw on the
//!   CPU painter (the headless self-test path), so the GPU is never required.
//!
//! ## The synthetic 3D fixture is the **znippy rabbit** (not squares)
//! When the host has no real OSM data, [`Map3D::synthetic`] builds the basemap by
//! **extruding the znippy rabbit silhouette** ([`facett_core::rabbit`]) — each
//! rabbit loop (body / head / ears) becomes a prism — and pins a 3D
//! [`facett_graph3d::RabbitLogo3D`] as the logo overlay. So the fixture is a 3D
//! rabbit-shaped skyline, never a placeholder cube.

// The viewer's rich `state_json` (camera pose + mesh/LOD/water levers + the Garmin
// nav overlays) is one big `serde_json::json!` object; its key count pushes the
// macro past the default 128-deep expansion, so lift the crate recursion limit.
#![recursion_limit = "256"]

use std::cell::{Cell, RefCell};

use egui::{Color32, Pos2, Sense};
use facett_core::{Facet, FacetCaps, theme};
use facett_map::Way;
use serde::{Deserialize, Serialize};

pub mod camera;
pub mod extrude;
/// The atmospheric distance-fog ramp — ONE writer for the CPU painter and the GPU
/// uniform, closing GFX_V2 §V2.9b's "`FOG_MAX 0.55` exists in THREE copies".
pub mod fog;
/// Real terrain elevation (DEM / procedural) — the orbit map's relief-ground source,
/// ported from the flight engine so the flat `y = 0` plane can show real topography.
pub mod terrain;
/// Altitude / LOD lever + the `METRES_PER_DESIGN_UNIT` calibration (the headline
/// `< 300 m → reflective water` trigger). Pure CPU, inject-assert-testable.
pub mod lod;
/// Sun + sky lighting model (Phase 1) shared by the GPU PBR shader + CPU painter.
pub mod sky;
/// Cinematic auto-pilot flythrough over the framed city (the moving flight-sim).
pub mod flythrough;
/// The cinematic Sigtuna autopilot tour (air-circle → descend → drive the streets).
pub mod tour;
/// Car-navigator / flight-sim **minimap** inset — the pure top-down overview data
/// (focus marker + heading cone) painted as a HUD inset over the 3D city.
pub mod minimap;
/// Car-navigator **route overlay + turn-by-turn follow** — a "your route" polyline
/// projected onto the streets, drawn as a bright ribbon + waypoint markers, and
/// (follow mode) flown by the camera along the route. See [`route`].
pub mod route;
/// Garmin-nav **HUD overlays** — the pure north rose / compass ([`garmin::CompassRose`])
/// and dynamic scale bar ([`garmin::ScaleBar`]) painted over the 3D-VECTOR view.
pub mod garmin;
/// **Maneuver model** — the pure turn-by-turn instructions ([`maneuver::Maneuver`] /
/// [`maneuver::ManeuverPlan`]) derived from the route polyline: the next-turn banner
/// + on-ribbon turn arrows read this. See [`maneuver`].
pub mod maneuver;
/// **Nav pose model** — pure auto-zoom/tilt-by-speed ([`nav::chase_pose_for_speed`])
/// and the 2D↔3D [`nav::NavMode`] pose hand-off. See [`nav`].
pub mod nav;
/// GPU depth-tested 3D-map renderer (feature `wgpu`) — real Z-buffer occlusion.
#[cfg(feature = "wgpu")]
pub mod gpu;

pub use camera::{InputFeel, OrbitCamera, Projected, V3, ViewPreset};
pub use extrude::{
    GROUND_LIFT, GroundLayer, GroundTerrain, HeightStats, MAX_TERRAIN_RELIEF, Mesh3, StreetLabelAnchor, Surface, Tri,
    building_height_stats, cap_triangulation, extrude_prism_mesh, extrude_ways, layer_lift, lonlat_bbox, open_cap_ring,
    relief_terrain_into, street_label_anchors, surface_of, terrain_plane,
};
pub use lod::{LodTier, altitude_metres, lod_tier, metres_per_design_unit, resolve_tier};
pub use sky::SunSky;
pub use flythrough::{CityFraming, Flythrough};
pub use tour::{SigtunaTour, TourPhase};

/// A **backend-neutral atmospheric transmittance LUT** the GPU sky pass can sample
/// for the sun's physical tint (FLIGHT-SIM #7). `texels` is row-major
/// `h_size × mu_size` **RGBA** — exactly the readback layout of the flight crate's
/// GPU scattering-LUT compute pass (`facett_map3d_flight::GpuLut::texels`, padded to
/// RGBA), so that pass feeds this seam directly. The data is device-neutral (raw
/// texels, NOT a `wgpu::Texture` — a texture cannot cross devices): the renderer
/// (re)uploads it onto the render device (`gpu::Map3dRenderer::ensure_sky_lut`) and
/// the sky fragment multiplies the sun disc/glow by the sampled transmittance-to-sun,
/// so the sun reddens as it sets. `None` on [`Map3D`] keeps the analytic sun tint —
/// the embeddable / CPU-fallback default (L2, additive: nothing regresses).
#[derive(Clone, Debug, PartialEq)]
pub struct SkyTransmittanceLut {
    /// μ axis size (texture width) — the sun-elevation cosine axis, `μ ∈ [-1, 1]`.
    pub mu_size: u32,
    /// Altitude axis size (texture height) — viewer altitude, ground → atmosphere top.
    pub h_size: u32,
    /// Row-major `h_size × mu_size` RGBA transmittance (0..1 per channel).
    pub texels: Vec<[f32; 4]>,
    /// Viewer altitude normalised into the LUT's altitude axis `0..1` (the row the
    /// sky pass samples this frame). `0` = ground.
    pub alt_norm: f32,
}

impl SkyTransmittanceLut {
    /// Build a LUT from row-major **RGB** transmittance (the flight crate's
    /// `GpuLut::texels: Vec<[f32; 3]>`), padding each texel to RGBA (a=1). The seam
    /// that lets the flight crate's GPU scattering-LUT readback drive the sky pass
    /// without either crate depending on the other's texel type.
    #[must_use]
    pub fn from_rgb(mu_size: u32, h_size: u32, rgb: &[[f32; 3]], alt_norm: f32) -> Self {
        let texels = rgb.iter().map(|c| [c[0], c[1], c[2], 1.0]).collect();
        Self { mu_size, h_size, texels, alt_norm }
    }
}
pub use minimap::{MinimapView, WorldBounds, xz_bounds};
pub use garmin::{CompassRose, ScaleBar};
pub use extrude::{PoiAnchor, PoiNode};
pub use route::Route;
pub use maneuver::{Maneuver, ManeuverKind, ManeuverPlan, maneuvers, road_names_along};
pub use nav::{NavMode, NavPose, chase_pose_for_speed};
pub use terrain::{Dem, Elevation, HGT_VOID, ProceduralTerrain, TerrainField, parse_hgt_name};

/// The swappable render-backend seam (the `RenderBackend` trait + our wgpu/vello
/// impl and a headless null backend). See [`backend`].
pub mod backend;

/// The **korp connector seam** — where a korp connector would supply street +
/// feature layers (OSM is the fallback source today). See [`korp`].
pub mod korp;
pub use korp::{FeatureLayers, load_layers};

pub use backend::{
    BackendCaps, CpuPainterBackend, NullBackend, RenderBackend, RenderScene, RenderStats, SceneFlags, Viewport,
};
#[cfg(feature = "wgpu")]
pub use backend::WgpuVelloBackend;

/// Re-export so a host can build the rabbit logo overlay without depending on
/// facett-graph3d directly.
pub use facett_graph3d::RabbitLogo3D;

/// **The smallest `tile_count` that means "this mesh has real geometry in it"**.
///
/// The pane refuses to draw at `tile_count <= 1` — one "tile" is the bare terrain quad,
/// i.e. nothing was extruded — and paints the `facet-map3d-1` card instead. Named here
/// so a test hook or a fixture cannot write the refusal sentinel by accident and then be
/// read as a rendered scene; see [`Map3D::set_mesh_for_test`], which did exactly that.
pub(crate) const TILE_COUNT_HAS_GEOMETRY: usize = 2;

/// A 3D map viewer: extruded ways + a terrain plane, orbited by a camera.
pub struct Map3D {
    pub title: String,
    /// The extruded basemap (prisms + ribbons + terrain), built once from ways.
    mesh: Mesh3,
    /// How many *footprints* (extruded rings + ribbons + the terrain quad) the
    /// mesh was built from — the "tile" count the self-test asserts is non-zero.
    tile_count: usize,
    /// **Car-navigator street-name labels** (1e): one anchor per NAMED road in the
    /// ways this map was built from, in the SAME design plane the mesh occupies
    /// (see [`extrude::street_label_anchors`]). Painted as billboarded HUD text over
    /// the 3D scene in [`Self::view`], LOD-gated by the anchor's road class + the
    /// live camera distance so a dense town doesn't clutter (major roads label
    /// first / from farther out).
    street_labels: Vec<extrude::StreetLabelAnchor>,
    /// Optional znippy-rabbit logo overlay (the spinning mascot, top-right). When
    /// `Some`, drawn after the map; reuses facett-graph3d's renderer. **Not Model
    /// state** — the spinning mascot is an ephemeral overlay with its own animation
    /// clock, so it lives interior-mutable OUTSIDE the [`Map3dModel`] (FC-7) and the
    /// pure [`Self::view`] can paint it (`logo.ui` needs `&mut`) through a `&self` borrow.
    logo: RefCell<Option<RabbitLogo3D>>,
    /// Orbit yaw (radians) about Y + a fixed tilt about X for a 3/4 city view.
    /// **Legacy** view angles kept for the snapshot/example paths; the live
    /// navigation now flows through [`Self::cam`]. `yaw` mirrors the camera
    /// azimuth and `tilt` the camera elevation so old callers still read sane
    /// values.
    pub yaw: f32,
    pub tilt: f32,
    pub spin: bool,
    spin_rate: f32,
    /// Injected animation clock (seconds; FC-7) — `Some` ⇒ pure spin function so a
    /// snapshot at a fixed clock is bit-stable. Mirrors RabbitLogo3D / Graph3D.
    clock_s: Option<f64>,
    /// The **navigable orbit camera** — the state-of-the-art view: orbit, dolly
    /// (zoom-to-cursor), pan, presets, damped/inertial. Drives the perspective
    /// projection in [`Self::shade_faces`].
    pub cam: OrbitCamera,
    /// **The observable CONTROL Model (FC-1 / FC-3)** — a serializable, round-trippable
    /// snapshot of the camera pose + animation/mode flags, kept in sync with the live
    /// [`Self::cam`] engine (the live `OrbitCamera` carries damping goals + a non-serde
    /// `InputFeel`, so the Model is a scalar projection of it, not the engine itself).
    /// [`facett_core::Elm::state`] hands back `&self.model`; a headless driver snapshots
    /// it after feeding a `Vec<Msg>`.
    model: Map3dModel,
    /// Last measured frames-per-second (smoothed) — surfaced in `state_json`.
    fps: f32,
    /// Triangles that passed culling + projected last frame (visible count). An
    /// ephemeral paint cache (FC-7), recorded by the pure [`Self::view`] through a
    /// `&self` borrow — deliberately OUTSIDE the [`Map3dModel`].
    last_visible_tris: Cell<usize>,
    /// Host opt-in to the GPU depth-tested render path (feature `wgpu`). The host
    /// installs a [`gpu::Map3dRenderer`] into its `egui_wgpu::RenderState` (via
    /// [`gpu::install_renderer`]) and sets this; otherwise the CPU painter draws.
    #[cfg(feature = "wgpu")]
    use_gpu: bool,
    /// **The active render backend** (the swappable seam). Defaults to the CPU
    /// painter; `with_gpu(true)` swaps in [`backend::WgpuVelloBackend`]. Every frame
    /// [`Self::view`] builds a [`backend::RenderScene`] and paints through this, so
    /// dropping in a different Rust engine never touches the view code.
    backend: Box<dyn backend::RenderBackend>,
    /// **Which lane the last `ui()` actually painted through — `None` until a frame has
    /// been painted at all.**
    ///
    /// Ephemeral paint cache (FC-7), recorded by the pure [`Self::view`]. The `Option` is
    /// the whole point and it cost a day: this was a `Cell<bool>` seeded `false`, and a
    /// host that read it OUTSIDE a paint therefore got `false` — a value indistinguishable
    /// from "the CPU painter ran". korp's `rebuild_map3d` logs one line per 3D rebuild and
    /// that line runs *before the first frame*, so it printed `gpu=false` on a build with
    /// the GPU lane correctly selected AND the renderer correctly installed, and three
    /// people read it as the defect. A zero that cannot be anything else is not a
    /// measurement. `None` here is what makes `gpu=pending` sayable.
    ///
    /// See [`Self::used_gpu_observed`] for the reader, and [`Self::gpu_lane_selected`] for
    /// the question a host CAN answer before the first frame.
    last_used_gpu: Cell<Option<bool>>,
    /// **`METRES_PER_DESIGN_UNIT`** — derived once from the data's Mercator
    /// footprint span ([`lod::metres_per_design_unit`]) so the camera's design-unit
    /// altitude maps to real metres (the `< 300 m → reflective water` calibration).
    metres_per_du: f32,
    /// **The same scale the EXTRUDER states it** — design units per metre, `None` for
    /// synthetic data with no world span ([`extrude::design_units_per_metre`]). Held
    /// next to [`Self::metres_per_du`] because the two answer different questions:
    /// this one carries the `None` that tells every metric constant to keep its legacy
    /// absolute, which a plain `f32` cannot. Both come from the SAME
    /// `world_span_metres` through reciprocal definitions (`lod.rs` documents the
    /// identity; `scene_scale_is_one_number` asserts it), so there is one scale here,
    /// stated twice, not two scales.
    ///
    /// This is what reaches the fragment shader, so the height haze can be a real
    /// metric layer instead of an absolute `0.25 du` that is 156 m in the fixture and
    /// 469 m in Stockholm.
    du_per_m: Option<f32>,
    /// **WHERE ON EARTH THIS SCENE IS** — the WGS-84 `(min_lon, min_lat, max_lon,
    /// max_lat)` bbox of the ways the mesh was actually built from, or `None` for
    /// synthetic data carrying no lon/lat.
    ///
    /// Held because every other observable this viewer publishes is a COUNT, and a count
    /// cannot answer the only question the 3D pane was getting wrong: *is this the place
    /// the user asked for?* 340 905 building vertices is the same number whether they are
    /// Stockholm's or Sigtuna's, so a test written on counts passes on the wrong city —
    /// which is exactly what happened. Assert on geography.
    geo_bbox: Option<(f64, f64, f64, f64)>,
    /// **Which rule chose the town window**, and how big it was — see
    /// [`extrude::TownWindow`]. `None` when no subsetting was asked for (`from_ways` and
    /// friends extrude what they are given). Published in `state_json` so "the camera
    /// drove this" is assertable rather than assumed.
    town_window: Option<extrude::TownWindow>,
    /// **The camera asked and the data could not answer** — see
    /// [`extrude::TownChoice::TooCoarse`]. Mutually exclusive with `town_window`: the
    /// scene is EMPTY on purpose, and this is the reason the pane prints instead of
    /// letting the density vote name a town 66 km from where the user is looking.
    town_refused: Option<extrude::TownRefusal>,
    /// **What the continuous-viewport falloff walk did** — set only by
    /// [`Self::from_viewport_roofed`], `None` on every town-window path. See
    /// [`extrude::ViewportStats`].
    viewport: Option<extrude::ViewportStats>,
    /// **This scene's "risen building" threshold**, in design units — derived once, at
    /// extrusion time, from the SAME `du_per_m` that scaled the building heights
    /// ([`extrude::building_rise_eps_for`]). The single writer behind
    /// [`Self::building_rise_eps`]: every question about whether a vertex is a house
    /// reads this one number, so the camera fitter and the observables can never
    /// disagree about what a house is.
    rise_eps: f32,
    /// Whether the ground is a **real heightfielded relief terrain** (a DEM /
    /// procedural [`terrain::Elevation`] source, built via [`extrude::GroundTerrain`])
    /// rather than the flat `y = 0` quad. Set by the relief constructors; surfaced in
    /// `state_json` so the robot-UI matrix can assert the orbit map shows topography.
    terrain_relief: bool,
    /// Hysteresis memory: was the reflective-water (`Ground`) tier active last
    /// frame? Carries the dead-band so the water pass never flickers near 300 m.
    last_tier_ground: bool,
    /// The **sun + sky** lighting environment (Phase 1). Drives the GPU PBR shader
    /// and the CPU painter; injectable so the blob oracles sweep sun angles.
    sun: SunSky,
    /// **Cascaded shadow map** strength `0..1` (how dark the building blocks' cast
    /// shadows are). `0` disables the depth pass (the wasm/cheap + no-shadow control
    /// path). Only the GPU lane casts shadows; the CPU painter ignores it.
    #[cfg(feature = "wgpu")]
    shadow_strength: f32,
    /// The tessellated **water grid** (Phase 2 reflective water), built once from
    /// the data's `Surface::Water` polygons. Empty for data with no water.
    water: extrude::WaterMesh,
    /// Force the reflective water tier on/off regardless of camera altitude (test
    /// hook + an explicit user override). `None` ⇒ resolve from the LOD tier.
    water_override: Option<bool>,
    /// Whether the **planar reflection pass** runs (native). Set `false` for the
    /// weaker WebGL2/wasm path: water still renders (Gerstner + Fresnel + depth +
    /// foam) using the cheaper **sky-gradient** reflection, skipping the second
    /// scene render. The flag the wasm demo flips for graceful degradation.
    planar_reflection: bool,
    /// Whether the **skybox / atmosphere** background pass runs (T3.2). When `true`
    /// (default) the GPU lane paints an analytic gradient + HDR sun-disc sky behind
    /// the city (the bloom/tonemap catch the sun); `false` keeps the legacy flat fog
    /// clear (the no-sky control / a host that opts out). GPU lane only.
    #[cfg(feature = "wgpu")]
    sky: bool,
    /// **Physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring): when set, the GPU
    /// sky pass samples this precomputed atmospheric transmittance (the flight crate's
    /// GPU scattering-LUT readback) for the sun's tint, so the sun reddens physically
    /// at low elevation instead of using the analytic disc colour. `None` ⇒ the
    /// analytic sun tint (the embeddable / CPU-fallback default, L2). `Arc` so the
    /// per-frame `RenderScene` snapshot is a cheap refcount bump.
    sky_lut: Option<std::sync::Arc<SkyTransmittanceLut>>,
    /// **SSAO / GTAO** (T3.1): whether the screen-space ambient-occlusion pass runs.
    /// `true` (default) darkens creases + wall/ground contacts; `false` is the AO-off
    /// control. GPU lane only.
    #[cfg(feature = "wgpu")]
    ssao: bool,
    /// **PCSS soft shadows** (T3.3): `true` (default) runs the contact-hardening
    /// percentage-closer soft-shadow filter; `false` is a hard PCF control. GPU lane.
    #[cfg(feature = "wgpu")]
    soft_shadows: bool,
    /// **Bloom** (HDR highlight glow, T2.1): `true` (default) runs the bright-pass +
    /// separable-Gaussian bloom the ACES tonemap catches; `false` makes the composite
    /// add zero bloom (the flat/wasm fallback + a render-proof discriminator). GPU lane.
    #[cfg(feature = "wgpu")]
    bloom: bool,
    /// **Cinematic flythrough** auto-pilot: when `true`, [`Self::render_camera`]
    /// ignores the manual pose + auto-spin and instead FLIES the framed city on a
    /// smooth, deterministic path (orbit + dolly-breathing + elevation sweep + target
    /// tracking) — the "watch the city fly by on its own" flight-sim. Default off, so
    /// the interactive orbit map is unchanged (additive, API-safe).
    flythrough_on: bool,
    /// **Sigtuna tour** autopilot: when `Some`, [`Self::render_camera`] flies the
    /// cinematic three-phase path (high air-circle → descend → drive the streets) as a
    /// pure function of the clock — overriding the manual pose, auto-spin, flythrough,
    /// and route-follow. Default `None`, so the interactive orbit map is unchanged
    /// (additive, API-safe). Reuses the framed city + the projected route (L5).
    tour: Option<tour::SigtunaTour>,
    /// **Minimap inset** (the car-navigator / flight-sim top-down overview): when
    /// `true`, [`Self::view`] paints a corner inset showing the city footprint, a
    /// "you are here" focus marker, and a heading cone that swings with the camera
    /// bearing. Default OFF (additive, API-safe — keeps the existing render-proof
    /// snapshots byte-stable); a host opts in with [`Self::with_minimap`]. A plain
    /// field (like `flythrough_on`) — ephemeral view chrome, not orbit control state,
    /// so it stays OUT of the serde [`Map3dModel`].
    minimap: bool,
    /// The framed-city bounds the flythrough flies around, captured by
    /// [`Self::frame_densest_cluster`]. `None` until framing runs (all-flat data) —
    /// the flythrough then falls back to [`flythrough::CityFraming::unit`].
    framing: Option<flythrough::CityFraming>,
    /// Live animation clock (seconds) the flythrough advances by each `Frame` dt when
    /// the FC-7 clock is NOT pinned. A plain field (like `fps`) outside the serde
    /// [`Map3dModel`] — pure animation state, not control state.
    fly_clock: f64,
    /// **Route overlay** (car-nav "your route"): the projected polyline the map draws
    /// as a bright ground ribbon + waypoint markers and, in follow mode, flies the
    /// camera along. `None` until [`Self::set_route`] projects a waypoint chain onto
    /// the streets. The ribbon/markers are already baked into `mesh`; this holds the
    /// geometry the camera FOLLOWS (turn-by-turn).
    route: Option<route::Route>,
    /// **Turn-by-turn follow** mode: when `true` (and a route is set), the camera
    /// chases the route ribbon — target rides the route point at `route_progress`,
    /// bearing swings to the route tangent, tilt held at a chase pitch. Default off
    /// (additive; the interactive orbit is unchanged). Set via [`Self::set_route_follow`].
    route_follow: bool,
    /// Normalised progress `t ∈ [0, 1]` along the route the follow camera sits at
    /// (0 = start, 1 = destination). Advanced each `Frame` while `route_follow` and
    /// the FC-7 clock is unpinned; set directly (deterministically) by
    /// [`Msg::RouteProgress`]. A plain field (like `fly_clock`) — animation state.
    route_progress: f32,
    /// Follow speed: route progress per second (so a `Frame { dt }` advances
    /// `route_progress` by `dt * route_speed`, wrapping at the destination).
    route_speed: f32,
    /// Pitched-roof / coloured-building counts from the styled extrude (round 2).
    /// `{0, 0}` for the default flat-cap path; populated when the scene was built with
    /// [`extrude::RoofStyle::Pitched`]. Surfaced in `state_json` (`pitched_roofs` /
    /// `coloured_buildings`) — the "houses read as real buildings" oracle.
    roof_stats: extrude::RoofStats,
    /// The extruded skyline's **height + PROPORTION distribution** — computed once
    /// from the same ways the mesh was built from. Published in `state_json` so the
    /// "a wall must not swallow its own roof" property is observable data on every
    /// real scene, not only inside `tests/wall_roof_ratio.rs`.
    height_stats: extrude::HeightStats,
    /// **North rose / compass** HUD (Garmin overlay): when `true`, [`Self::view`] paints
    /// a corner compass whose N needle swings with the camera bearing. Default OFF
    /// (additive, API-safe — keeps the render-proof goldens byte-stable); opt in with
    /// [`Self::with_compass`]. Ephemeral view chrome, so it stays OUT of the `Map3dModel`.
    compass: bool,
    /// **Dynamic scale bar** HUD (Garmin overlay): when `true`, [`Self::view`] paints a
    /// metres/km scale bar sized to the live zoom. Default OFF (additive); opt in with
    /// [`Self::with_scale_bar`].
    scale_bar: bool,
    /// **POI markers** (the Google-Maps-style POI layer, projected into the design plane
    /// via [`extrude::poi_anchors`]). Empty until [`Self::set_pois`]; painted as labelled
    /// markers when [`Self::show_pois`] is on + the category is visible in [`Self::poi_set`].
    pois: Vec<extrude::PoiAnchor>,
    /// **Shared POI layer-visibility** ([`facett_map::LayerSet`]): the per-category
    /// on/off the *same* Google-Maps layer toggles drive. Consumed, not twinned — a host
    /// flips a POI category here (or reuses its `LayerToggles`) and the markers follow.
    poi_set: facett_map::LayerSet,
    /// Master **POI layer toggle** on the navig view. Default OFF (additive); the "show
    /// points of interest" checkbox. Gated by this AND the per-category [`Self::poi_set`].
    show_pois: bool,
    /// Last painted viewport **height in logical pixels** — an ephemeral paint cache
    /// (FC-7, like `last_visible_tris`), recorded by the pure [`Self::view`] so the
    /// [`ScaleBar`] reported in `state_json` matches the frame the user is looking at.
    /// `0` before the first paint (⇒ a nominal reference height is used).
    last_view_h: Cell<f32>,
    /// **The rect this pane last PAINTED into** — an ephemeral paint cache (FC-7, the
    /// same class as [`Self::last_view_h`], which it supersedes for anything needing the
    /// origin as well as the height), recorded by [`Self::view`] at the moment the rect
    /// is allocated, so it is right on every arm including the two early returns.
    ///
    /// It exists because a host cannot reconstruct this rect. korp tried twice: first
    /// with `ui.min_rect()`, which in its `Ui` is the view STRIP ∪ the map, so every
    /// pixel ray shot ~half a strip too high; then with `next_widget_position()` read
    /// before and after the paint, which is exact at the top and **`item_spacing.y` too
    /// low at the bottom**. MEASURED on korp's own pane at 1280×900: the host's rect
    /// came out `3.00 px` taller with its centre `1.50 px` below the rect actually
    /// painted. Both errors bias the ray the same way — it aims high, and the ground
    /// point lands long. Neither is reachable from outside, because only this function
    /// knows what it allocated. So the fix is not a better reconstruction: it is that
    /// this is the ONE writer (LAW 5) and picks go through
    /// [`Self::ground_lonlat_at_screen`].
    ///
    /// `None` before the first paint — a pick then has no frame to shoot through and
    /// says so rather than inventing one.
    last_view_rect: Cell<Option<egui::Rect>>,
    /// **Turn-by-turn maneuver plan** derived from the route polyline (Depart → turns
    /// → Arrive), rebuilt by [`Self::set_route`] from the projected [`Route`] + the
    /// street-label anchors (nearest-name join). `None` until a route is set. Feeds the
    /// next-turn banner + the on-ribbon turn arrows — see [`maneuver`].
    maneuvers: Option<maneuver::ManeuverPlan>,
    /// **Next-turn banner + on-ribbon turn arrows** (Garmin overlay): when `true`,
    /// [`Self::view`] paints the next maneuver as a top HUD banner ("↰ Turn left in
    /// 200 m onto …") + an arrow glyph on the ribbon at each turn point. Default OFF
    /// (additive); opt in with [`Self::with_maneuvers`].
    show_maneuvers: bool,
    /// **Vehicle chevron** in the main 3D view (Garmin overlay): when `true`,
    /// [`Self::view`] paints a heading-oriented chevron at the live route position
    /// (useful in the overview / non-follow mode where you watch the car move). Default
    /// OFF (additive); opt in with [`Self::with_vehicle`].
    show_vehicle: bool,
    /// **Night mode**: when `true` the scene uses the [`SunSky::night`] preset (dim,
    /// cool, low-ambient — headlight-lit night driving), mirroring the 2D
    /// `CartographicNight` palette. Default OFF; toggled with [`Self::set_night`].
    night: bool,
    /// **Auto-zoom/tilt by speed**: when `true`, the follow camera couples its chase
    /// distance + tilt to [`Self::route_speed`] via [`nav::chase_pose_for_speed`]
    /// (faster ⇒ dolly out + flatten). Default OFF so the existing follow goldens stay
    /// byte-stable; opt in with [`Self::with_auto_zoom`].
    auto_zoom: bool,
    /// **Predictive look-ahead chase** (design seam F1): when `true`, the follow camera
    /// aims a look-ahead point along the route (and takes its bearing from the tangent to
    /// it) via [`nav::compute_camera_goals`], so it *anticipates* a turn instead of
    /// snapping at the vertex. Default OFF so the existing follow goldens stay byte-stable
    /// (with follow ON but this OFF the camera rides the vertex tangent as before); opt in
    /// with [`Self::with_lookahead`].
    lookahead: bool,
    /// **Chevron-flow arrows** (design seam F2): when `true`, [`Self::set_route`] bakes a
    /// train of directional chevron glyphs marching along the route ribbon into the
    /// overlay mesh (via [`extrude::chevron_flow_into`]). Default OFF (additive); set it
    /// BEFORE the route (e.g. [`Self::with_chevrons`] then [`Self::with_route`]) so the
    /// glyphs bake with the ribbon.
    show_chevrons: bool,
    /// The along-route spacing (design units) of the chevron-flow arrows (see
    /// [`Self::show_chevrons`]). Defaults to [`extrude::chevron_cadence`] at THIS
    /// scene's scale — 236 m apart while it was the absolute `CHEVRON_CADENCE_DU`.
    chevron_cadence_du: f32,
    /// The chevron-flow **animation phase** `∈ [0, 1)` (a fraction of one cadence) —
    /// advanced each live `Frame` so the pure [`route::Route::chevron_flow`] train marches
    /// forward; surfaced in `state_json`. A plain animation field (like `route_progress`).
    chevron_phase: f32,
    /// **The per-frame overlay mesh** (V2.5 D.3 — the seam the korp avatar rides).
    ///
    /// A small caller-supplied body drawn at a caller-supplied pose EVERY FRAME,
    /// entirely outside the extruder bake: the basemap `mesh` is rebuilt only when the
    /// window moves, so a flying body baked into it would stand still between rebuilds
    /// — which is worse than absent (the D.1 report's named blocker). [`Self::set_overlay`]
    /// stores the mesh already transformed into WORLD design units, so both render lanes
    /// draw it with zero per-frame math of their own: the CPU painter merges its shaded
    /// faces into the z-buffered face list, the wgpu lane re-uploads it as a tiny
    /// dedicated vertex buffer (~100 verts — never a bump of the main mesh generation).
    /// `None` = no overlay, byte-identical to the pre-seam frame.
    overlay: Option<OverlayInstance>,
}

/// **A caller-supplied pose for the per-frame overlay mesh** (V2.5 D.3).
///
/// The local frame the overlay mesh is authored in matches korp's avatar: **`+Z` is the
/// nose** (forward along the heading), **`+Y` is up**, **`+X` is the right wing**. The
/// pose plants that frame in the scene's design-unit world:
///
/// * `position` — design units, y-up (the same plane [`Map3D::design_from_lonlat`]
///   answers in, so a geographic caller goes lon/lat → `[dx, y, dz]` through it);
/// * `heading_deg` — course over ground, degrees **clockwise from north** (the map
///   convention every korp bearing speaks). Internally north is design `-z` and east
///   `+x` (see [`extrude::to_design`]), and the seam owns that conversion — a caller
///   never learns which way `z` points;
/// * `pitch_deg` — nose up positive;
/// * `bank_deg` — right wing down positive (the coordinated-turn sign);
/// * `scale` — design units per local unit (uniform).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OverlayPose {
    /// Where the body sits, design units, y-up.
    pub position: [f32; 3],
    /// Course over ground, degrees clockwise from north.
    pub heading_deg: f32,
    /// Nose up positive, degrees.
    pub pitch_deg: f32,
    /// Right wing down positive, degrees.
    pub bank_deg: f32,
    /// Design units per local unit (uniform scale).
    pub scale: f32,
}

impl Default for OverlayPose {
    /// The identity pose: origin, north, level, unit scale — the decoy the pose test
    /// sees red against.
    fn default() -> Self {
        OverlayPose { position: [0.0, 0.0, 0.0], heading_deg: 0.0, pitch_deg: 0.0, bank_deg: 0.0, scale: 1.0 }
    }
}

/// The stored overlay: the mesh already in world design units + the pose it was
/// planted at (kept for `state_json`) + the per-triangle emissive channel.
struct OverlayInstance {
    /// World-space overlay mesh — `set_overlay` transformed it once, so neither lane
    /// re-derives the pose (LAW 5: one writer of the transform).
    world: Mesh3,
    /// **Emissive intensity per triangle**, parallel to `world.colors`. `0` = lit like
    /// any scene triangle; `> 0` = unlit, the triangle's own colour scaled by this
    /// factor into HDR so the EXISTING bloom bright-pass catches it (a channel, not a
    /// new post pass). Empty = all zero.
    emissive: Vec<f32>,
    /// The pose as given — the observable `state_json` publishes.
    pose: OverlayPose,
}

/// Design-unit height above which a mesh vertex counts as **risen building
/// geometry** (a wall / roof), as opposed to the ground plane, the flat ground-cover
/// caps (landuse / parks, lifted only a sub-pixel hair ≈`GROUND_LIFT`), and the
/// lifted road / route ribbons (top out near `1.6·GROUND_LIFT` ≈ `1.9e-3`). Set well
/// clear of that whole ground band so a road-only scene reports **zero** buildings,
/// while a real house (≥ ~`7.5e-3` design units even for a low Sigtuna building)
/// counts.
///
/// **This absolute value is now only the SYNTHETIC / scale-free fallback.** It is an
/// absolute design-unit constant while a building's rise is metric, so on real data it
/// answered "is this a building?" on a different scale from the one that built the
/// building — and on a korp region viewport (tens of km) it read **zero** buildings in
/// a scene of nothing but buildings. Real scenes take
/// [`extrude::BUILDING_RISE_METRES`] through their own `du_per_m`; see
/// [`extrude::building_rise_eps`] for the mechanism, the measurement and the honest
/// bound. Every risen-geometry observable — [`Map3D::building_vertices`],
/// [`Map3D::buildings_visible`], [`Map3D::flanking_houses`], the roof accent AND
/// [`Map3D::frame_densest_cluster`] — asks [`Map3D::building_rise_eps`], so there is
/// exactly **one** definition of "risen" per scene (there used to be two: this
/// constant, and a hardcoded `1e-3` inside the camera fitter, which is how the fitter
/// and every observable could disagree about whether a town had houses in it).
pub const BUILDING_RISE_EPS: f32 = 4.0e-3;

impl Map3D {
    /// Build a 3D map from real OSM [`Way`]s + an extrusion height scale. Closed
    /// area/building rings are extruded to prisms; line ways become ground ribbons;
    /// a terrain plane is fitted under the data. `height` is the design-space lift
    /// applied to area rings (≈0.18 looks like a chunky city).
    pub fn from_ways(title: impl Into<String>, ways: &[Way], height: f32) -> Self {
        Self::from_ways_terrain(title, ways, height, extrude::GroundTerrain::Flat)
    }

    /// Build a 3D map from the **visible subset** of `ways` under a shared
    /// [`LayerSet`](facett_map::LayerSet) — the Google-Maps-style layer toggles.
    /// This is the 3D engine's consumption of the *same* `LayerSet` the 2D engine
    /// uses: it calls the one shared [`facett_map::LayerSet::filter_ways`] seam
    /// (no per-engine layer logic — L5), so turning *Buildings* off here drops the
    /// building prisms exactly as it drops the building fills in 2D. A host
    /// re-invokes this on a toggle to rebuild the visible city.
    pub fn from_ways_layerset(
        title: impl Into<String>,
        ways: &[Way],
        height: f32,
        layer_set: &facett_map::LayerSet,
    ) -> Self {
        Self::from_ways(title, &layer_set.filter_ways(ways), height)
    }

    /// Like [`Self::from_ways`], but the caller chooses the [`extrude::GroundTerrain`]:
    /// the flat quad (identical to `from_ways`) or a **real heightfielded relief
    /// terrain** sampled from a DEM / procedural elevation source. This is the
    /// constructor the orbit viewer paths use to break the flat plane and show real
    /// topography (see [`Self::from_geometry_relief`]).
    pub fn from_ways_terrain(title: impl Into<String>, ways: &[Way], height: f32, terrain: extrude::GroundTerrain<'_>) -> Self {
        Self::from_ways_terrain_styled(title, ways, height, terrain, extrude::RoofStyle::Flat)
    }

    /// Like [`Self::from_ways_terrain`], but with an [`extrude::RoofStyle`]:
    /// [`extrude::RoofStyle::Flat`] (the default) reproduces the historical flat-cap
    /// scene **byte-for-byte** (the pixel goldens do not move), while
    /// [`extrude::RoofStyle::Pitched`] grows real gabled/hipped roof geometry + honours
    /// OSM colours (`building:colour` facades, `roof:colour`/material roofs, tasteful
    /// defaults otherwise) so the town reads as buildings. The pitched/coloured counts
    /// land in `state_json` (`pitched_roofs` / `coloured_buildings`).
    pub fn from_ways_terrain_styled(
        title: impl Into<String>,
        ways: &[Way],
        height: f32,
        terrain: extrude::GroundTerrain<'_>,
        roof_style: extrude::RoofStyle,
    ) -> Self {
        let terrain_relief = matches!(terrain, extrude::GroundTerrain::Relief { .. });
        let (mesh, tile_count, water, roof_stats) =
            extrude::build_with_water_terrain_styled(ways, height, terrain, roof_style);
        // Same `ways` slice `build_with_water_terrain` just projected → the label
        // anchors land in the IDENTICAL design plane as the ribbons (see the
        // function doc: it recomputes the same deterministic footprint bbox).
        let street_labels = extrude::street_label_anchors(ways);
        let height_stats = extrude::building_height_stats(ways, height);
        let metres_per_du = lod::metres_per_design_unit(ways);
        // The scene's own definition of "risen", from the same span the heights used.
        let rise_eps = extrude::building_rise_eps_for(ways);
        // …and the same span again, as the extruder states it, so the renderer's
        // metric constants (the fragment shader's height haze, the SSAO radius) read
        // the one scale the geometry was built at.
        let du_per_m = extrude::design_units_per_metre(ways);
        let mut cam = OrbitCamera::default();
        cam.settle();
        let mut s = Self {
            title: title.into(),
            mesh,
            tile_count,
            street_labels,
            logo: RefCell::new(None),
            yaw: 0.6,
            tilt: 0.55,
            spin: true,
            spin_rate: 0.3,
            clock_s: None,
            cam,
            model: Map3dModel::default(),
            fps: 0.0,
            last_visible_tris: Cell::new(0),
            #[cfg(feature = "wgpu")]
            use_gpu: false,
            backend: Box::new(backend::CpuPainterBackend),
            last_used_gpu: Cell::new(None),
            metres_per_du,
            du_per_m,
            // The SAME `ways` the mesh was just built from — so "where is this scene?"
            // and "what is in this scene?" can never be answers about two different
            // sets. The subsetting constructors pass the already-subsetted slice.
            geo_bbox: extrude::lonlat_bbox(ways),
            town_window: None,
            town_refused: None,
            viewport: None,
            rise_eps,
            terrain_relief,
            last_tier_ground: false,
            sun: SunSky::noon(),
            #[cfg(feature = "wgpu")]
            shadow_strength: 0.85,
            water,
            water_override: None,
            planar_reflection: true,
            #[cfg(feature = "wgpu")]
            sky: true,
            sky_lut: None,
            #[cfg(feature = "wgpu")]
            ssao: true,
            #[cfg(feature = "wgpu")]
            soft_shadows: true,
            #[cfg(feature = "wgpu")]
            bloom: true,
            flythrough_on: false,
            tour: None,
            minimap: false,
            framing: None,
            fly_clock: 0.0,
            route: None,
            route_follow: false,
            route_progress: 0.0,
            route_speed: 0.08,
            roof_stats,
            height_stats,
            compass: false,
            scale_bar: false,
            pois: Vec::new(),
            poi_set: facett_map::LayerSet::new(),
            show_pois: false,
            last_view_h: Cell::new(0.0),
            last_view_rect: Cell::new(None),
            maneuvers: None,
            show_maneuvers: false,
            show_vehicle: false,
            night: false,
            auto_zoom: false,
            lookahead: false,
            show_chevrons: false,
            chevron_cadence_du: extrude::chevron_cadence(du_per_m),
            chevron_phase: 0.0,
            overlay: None,
        };
        s.sync_model();
        s
    }

    // ── the per-frame overlay seam (V2.5 D.3) ─────────────────────────────────

    /// **Set the per-frame overlay mesh** — a small caller-supplied body (`local` in the
    /// avatar frame: `+Z` nose, `+Y` up, `+X` right wing) drawn at `pose` from this frame
    /// on, without touching the extruder bake. Call it every frame with the live pose;
    /// the transform into world design units happens ONCE, here, so the CPU painter and
    /// the wgpu lane both consume finished world geometry (LAW 5).
    ///
    /// `emissive_by_tri` is the per-triangle emissive channel, parallel to
    /// `local.colors`: `0` (or an empty slice) = lit normally; `> 0` = unlit, the
    /// triangle's colour scaled by that factor into HDR — the EXISTING bloom
    /// bright-pass is the consumer, no new post pass. The CPU painter renders an
    /// emissive triangle at its plain max-brightness colour (no shade, no fog); the
    /// halo itself is the GPU bloom's job.
    ///
    /// The overlay is deliberately small (an avatar, ~100 verts): it re-uploads per
    /// frame on the GPU lane through a tiny dedicated buffer and never bumps the main
    /// mesh generation, so the 600k-triangle basemap upload stays a compare.
    pub fn set_overlay(&mut self, local: &Mesh3, emissive_by_tri: &[f32], pose: OverlayPose) {
        let world = Self::overlay_world_mesh(local, &pose);
        let mut emissive = emissive_by_tri.to_vec();
        emissive.resize(world.indices.len() / 3, 0.0);
        self.overlay = Some(OverlayInstance { world, emissive, pose });
        self.refresh_overlay_near();
    }

    /// **Append a second body to the frame's overlay** — the waypoint marker's seam
    /// (V2.5 fix round): the avatar goes in through [`Self::set_overlay`] (which
    /// REPLACES the frame's set), any further piece rides the same instance through
    /// here. The pieces merge into the ONE world mesh both lanes already consume, so
    /// neither backend learns that "overlay" became plural — no second buffer, no
    /// second draw. With no overlay set yet this IS `set_overlay` (the first piece's
    /// pose becomes the primary — the one `state_json` and the near-override read).
    pub fn add_overlay(&mut self, local: &Mesh3, emissive_by_tri: &[f32], pose: OverlayPose) {
        let Some(o) = self.overlay.as_mut() else {
            self.set_overlay(local, emissive_by_tri, pose);
            return;
        };
        let piece = Self::overlay_world_mesh(local, &pose);
        // `emissive` is per-triangle and was already padded to the standing tri count
        // by `set_overlay`, so the append stays parallel by construction.
        let base = o.world.positions.len() as u32;
        o.world.indices.extend(piece.indices.iter().map(|i| i + base));
        o.world.positions.extend(piece.positions);
        o.world.normals.extend(piece.normals);
        o.world.colors.extend(piece.colors);
        o.world.building.extend(piece.building);
        let mut em = emissive_by_tri.to_vec();
        em.resize(piece.indices.len() / 3, 0.0);
        o.emissive.extend(em);
        self.refresh_overlay_near();
    }

    /// **The one overlay pose transform** (LAW 5): `local` (avatar frame: `+Z` nose,
    /// `+Y` up, `+X` right wing) → world design units at `pose`. Both
    /// [`Self::set_overlay`] and [`Self::add_overlay`] route through here.
    fn overlay_world_mesh(local: &Mesh3, pose: &OverlayPose) -> Mesh3 {
        // Rotation basis: world = Yaw(heading) · Pitch · Roll, applied to scale·local.
        // Heading is compass (clockwise from north); design north is -z, east +x
        // (extrude::to_design), and local +Z is the nose — so the yaw that points the
        // nose along heading h is a = 180° - h about +Y (checked by the pose test
        // against an independent hand transform).
        let yaw = (180.0 - pose.heading_deg).to_radians();
        let (sy, cy) = yaw.sin_cos();
        let p = pose.pitch_deg.to_radians();
        let (sp, cp) = p.sin_cos();
        let b = pose.bank_deg.to_radians();
        let (sb, cb) = b.sin_cos();
        // Roll about +Z, right wing (+X) DOWN for positive bank: [x,y,z] →
        // [x·cb + y·sb, -x·sb + y·cb, z].
        let roll = |v: [f32; 3]| [v[0] * cb + v[1] * sb, -v[0] * sb + v[1] * cb, v[2]];
        // Pitch about +X, nose (+Z) UP for positive pitch: [x,y,z] →
        // [x, y·cp + z·sp, -y·sp + z·cp].
        let pitch = |v: [f32; 3]| [v[0], v[1] * cp + v[2] * sp, -v[1] * sp + v[2] * cp];
        // Yaw about +Y: [x,y,z] → [x·cy + z·sy, y, -x·sy + z·cy] (maps +Z to
        // [sy, 0, cy] = the heading direction after a = 180° - h).
        let yawr = |v: [f32; 3]| [v[0] * cy + v[2] * sy, v[1], -v[0] * sy + v[2] * cy];
        let rot = |v: [f32; 3]| yawr(pitch(roll(v)));
        Mesh3 {
            positions: local
                .positions
                .iter()
                .map(|&q| {
                    let r = rot([q[0] * pose.scale, q[1] * pose.scale, q[2] * pose.scale]);
                    [r[0] + pose.position[0], r[1] + pose.position[1], r[2] + pose.position[2]]
                })
                .collect(),
            // Normals rotate with the body (uniform scale ⇒ the same rotation, no inverse
            // transpose needed).
            normals: local.normals.iter().map(|&n| rot(n)).collect(),
            colors: local.colors.clone(),
            indices: local.indices.clone(),
            // The avatar is not a house: the risen-vertex census must not count it.
            building: vec![false; local.positions.len()],
        }
    }

    /// **The near-plane override an overlay earns** — the one writer (called by every
    /// overlay mutation and by [`Self::look_from`], so eye moves refresh it too).
    ///
    /// While a body rides close to the eye, both stock nears are wrong: the CPU clip
    /// sits at 0.02 du and the GPU matrix's tight band bottoms out at 0.05 du — on a
    /// 3 km scene those are ~37 m and ~94 m of real ground, and the rider's-seat
    /// avatar (metres from the eye) was near-clipped INVISIBLE on every frame while
    /// the pose, the counts and the buffer tests all stayed green. A quarter of the
    /// eye→body distance keeps the whole body (its own extent is about half that
    /// distance) in front of the plane with margin; the floor keeps the depth band
    /// sane if a pose ever lands on the eye. No overlay ⇒ `None` ⇒ stock behaviour,
    /// byte-identical.
    fn refresh_overlay_near(&mut self) {
        self.cam.near_override = self.overlay.as_ref().map(|o| {
            // Measured from the camera that actually PROJECTS this frame
            // (`render_camera` folds tour/spin into the pose; `self.cam` alone can
            // face another way entirely under an auto-spin, and a near computed off
            // the wrong eye is the same class of bug this fixes).
            let e = self.render_camera().eye();
            let p = o.pose.position;
            let d = ((e.x - p[0]).powi(2) + (e.y - p[1]).powi(2) + (e.z - p[2]).powi(2)).sqrt();
            (d * 0.25).clamp(Self::OVERLAY_NEAR_MIN_DU, OrbitCamera::NEAR_PLANE)
        });
    }

    /// Floor for the overlay near-override, design units — `1e-4` du is ~2 m even on
    /// a 40 km scene, and far/near stays ≤ ~6e4: comfortably inside a 24-bit depth
    /// buffer's precision, so the shrunken near cannot re-introduce z-fighting.
    const OVERLAY_NEAR_MIN_DU: f32 = 1.0e-4;

    /// Remove the overlay — the next frame is byte-identical to a pre-seam frame.
    pub fn clear_overlay(&mut self) {
        self.overlay = None;
        self.refresh_overlay_near();
    }

    /// Whether an overlay is set this frame.
    #[must_use]
    pub fn overlay_active(&self) -> bool {
        self.overlay.is_some()
    }

    /// Test hook: the overlay mesh in WORLD design units — the applied output of
    /// [`Self::set_overlay`]'s one transform, which the pose test projects through the
    /// camera to pin where it lands on screen.
    #[doc(hidden)]
    #[must_use]
    pub fn overlay_world(&self) -> Option<&Mesh3> {
        self.overlay.as_ref().map(|o| &o.world)
    }

    /// Toggle **SSAO / GTAO** (T3.1, chainable). `true` (default) runs the
    /// screen-space ambient-occlusion pass (creases + contacts come back darker);
    /// `false` is the AO-off control. GPU lane only.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn with_ssao(mut self, on: bool) -> Self {
        self.ssao = on;
        self
    }

    /// **Enter (or leave) the 2D PLAN mode** — GFX_V2 item 7, the reachable end of
    /// item 6's post-FX bypass.
    ///
    /// A plan view is a 3D view under constraints, exactly as GFX_V2 §4 says: exact
    /// top-down, zero azimuth, orthographic, flat terrain. Setting it makes
    /// [`OrbitCamera::view`](camera::OrbitCamera::view) return the constrained
    /// [`View::ortho_2d`](facett_core::render::View::ortho_2d), which
    /// [`SceneFlags::constrain_to_view`](backend::SceneFlags::constrain_to_view)
    /// recognises — and the GPU lane then records **zero** screen-space post passes
    /// (asserted on the device in `tests/postfx_bypass.rs`).
    ///
    /// Before this existed, `flat_2d` was a flag nothing could set: `ELEV_LIMIT` kept
    /// tilt off exactly 0 and `OrbitCamera` had no orthographic mode, so item 6
    /// deliberately pinned the gap rather than wire dead code. This closes it.
    #[must_use]
    pub fn with_plan_2d(mut self, on: bool) -> Self {
        self.cam.plan_2d = on;
        self.cam.settle();
        self
    }

    /// Whether the 2D plan mode is active — i.e. whether this frame takes the post-FX
    /// bypass. See [`with_plan_2d`](Self::with_plan_2d).
    #[must_use]
    pub fn is_plan_2d(&self) -> bool {
        self.cam.plan_2d
    }

    /// Toggle **bloom** (HDR highlight glow, chainable). `true` (default) runs the
    /// bright-pass + separable-Gaussian bloom the ACES tonemap catches; `false` makes
    /// the composite add zero bloom (the flat/wasm fallback). GPU lane only.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn with_bloom(mut self, on: bool) -> Self {
        self.bloom = on;
        self
    }

    /// **Toggle bloom on a LIVE view** — the `&mut` twin of
    /// [`with_bloom`](Self::with_bloom), which consumes `self` and so can only be used
    /// at construction.
    ///
    /// Added 2026-08-24 for two reasons, and the second is the product one. The first is
    /// that `tests/bloom_off_costs_nothing.rs` has to flip bloom on ONE renderer between
    /// frames — the only way to leave real glow in `bloom_a` and then prove that skipping
    /// the glow chain still cannot leak it. The second is that a host with a bloom
    /// checkbox had no way to honour it: every other knob here is chainable-only too, so
    /// the setting could be *built* and never *changed*, and the only way to turn glow off
    /// at runtime was to rebuild the whole `Map3D` — which discards the camera pose.
    ///
    /// GPU lane only; the CPU painter has no bloom to switch.
    #[cfg(feature = "wgpu")]
    pub fn set_bloom(&mut self, on: bool) {
        self.bloom = on;
    }

    /// Whether **bloom** is currently on — the readback for [`set_bloom`](Self::set_bloom),
    /// so a host's checkbox and a robot oracle read the same field the renderer does
    /// rather than each keeping their own copy of the answer.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn bloom_on(&self) -> bool {
        self.bloom
    }

    /// Toggle **PCSS soft shadows** (T3.3, chainable). `true` (default) runs the
    /// contact-hardening percentage-closer soft-shadow filter; `false` is a hard PCF
    /// control (a render-proof discriminator). GPU lane only.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn with_soft_shadows(mut self, on: bool) -> Self {
        self.soft_shadows = on;
        self
    }

    /// Toggle the **planar reflection pass** (chainable). `false` = the weaker
    /// WebGL2/wasm path: water keeps Gerstner + Fresnel + depth + foam with a
    /// **sky-gradient** reflection (no second scene render). Graceful degradation.
    #[must_use]
    pub fn with_planar_reflection(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetPlanarReflection(on));
        self
    }

    /// Toggle the **skybox / atmosphere** background (T3.2, chainable). `true`
    /// (default) paints the analytic gradient + HDR sun-disc sky behind the city on
    /// the GPU lane; `false` keeps the legacy flat fog clear (the no-sky control).
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn with_sky(mut self, on: bool) -> Self {
        self.sky = on;
        self
    }

    /// Supply a **physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring, chainable).
    /// The GPU sky pass then samples it for the sun's atmospheric tint (the sun reddens
    /// physically at low elevation) in place of the analytic disc colour. The natural
    /// source is the flight crate's GPU scattering-LUT readback
    /// (`SkyTransmittanceLut::from_rgb(gpu_lut.mu_size, gpu_lut.h_size, &gpu_lut.texels,
    /// alt_norm)`). Clearing it (see [`Self::clear_sky_transmittance_lut`]) restores the
    /// analytic fallback (L2).
    #[must_use]
    pub fn with_sky_transmittance_lut(mut self, lut: SkyTransmittanceLut) -> Self {
        self.sky_lut = Some(std::sync::Arc::new(lut));
        self
    }

    /// Set / replace the physical-sky transmittance LUT at runtime (the mutable twin of
    /// [`Self::with_sky_transmittance_lut`]). The next GPU frame uploads it and the sky
    /// pass samples it for the sun tint.
    pub fn set_sky_transmittance_lut(&mut self, lut: SkyTransmittanceLut) {
        self.sky_lut = Some(std::sync::Arc::new(lut));
    }

    /// Drop the physical-sky transmittance LUT → the sky pass falls back to the analytic
    /// sun tint (the embeddable / CPU-fallback default, L2).
    pub fn clear_sky_transmittance_lut(&mut self) {
        self.sky_lut = None;
    }

    /// Whether a physical-sky transmittance LUT is currently wired into the sky pass —
    /// the `state_json`-style oracle the robot-UI matrix asserts.
    #[must_use]
    pub fn sky_transmittance_lut_active(&self) -> bool {
        self.sky_lut.is_some()
    }

    /// Force the **reflective water tier** on/off, overriding the camera-altitude
    /// LOD trigger (test hook + user override). `None` restores altitude-driven.
    #[must_use]
    pub fn with_water_override(mut self, on: Option<bool>) -> Self {
        let _ = self.update(Msg::SetWaterOverride(on));
        self
    }

    /// Whether the **water surface is drawn** this frame — i.e. the data has water
    /// geometry at all (or an explicit override says otherwise).
    ///
    /// # The defect this used to be
    ///
    /// This read `self.water_override.unwrap_or_else(|| self.lod_tier().reflective_water())`
    /// — and `LodTier::reflective_water()` is `matches!(self, LodTier::Ground)`, i.e.
    /// camera altitude **under 300 m**. `SceneFlags::water_on` is what
    /// `gpu.rs`'s `let do_water = self.water_on && r.water_index_count > 0` gates the
    /// water DRAW on, so above 300 m the lakes were not degraded, they were **absent**.
    ///
    /// MEASURED on the shipped korp-ui against the live korp-server (2026-08-04, RTX
    /// 4090 discrete, `map3d-gpu`, Stockholm z12), straight out of `$KORP_STATE`:
    ///
    /// ```text
    /// map.map3d.water_triangles   = 1084
    /// map.map3d.water_active      = false
    /// map.map3d.reflective_water  = false
    /// map.map3d.metres_per_design_unit = 1875   (camera.distance 0.856 → ~1.6 km up)
    /// ```
    ///
    /// 1084 water triangles built, tessellated, uploaded — and not drawn, because the
    /// camera was 1.6 km up instead of 300 m. Measured off the resulting screenshot's
    /// ground band: **0.00 % green and 0.00 % warm pixels**, every coloured pixel blue
    /// and all of it the sky's tint in the shading. The same place in 2D is 16.89 %
    /// green and 24.95 % warm.
    ///
    /// And [`lod::LodTier`]'s own doc comments say what was meant, tier by tier:
    /// `Low` is *"water = animated normal-map (no planar reflection pass)"*, `Mid` is
    /// *"water = flat Fresnel tint"*, `High` is *"water = flat colour"*. Three of the
    /// four tiers describe water that is DRAWN and shaded more cheaply. The tier is a
    /// quality ladder; it was being read as an on/off switch.
    ///
    /// So the two questions are two functions now: this one answers *"is there water
    /// to draw"*, and [`Self::reflective_water_active`] answers *"does the expensive
    /// planar-reflection pass run"* — which is still `Ground` only, unchanged.
    #[must_use]
    pub fn water_active(&self) -> bool {
        if self.water.is_empty() {
            return false;
        }
        self.water_override.unwrap_or(true)
    }

    /// Whether the **planar reflection pass** runs this frame: water is drawn AND the
    /// camera is close enough to earn it (`LodTier::Ground`, altitude < 300 m). This is
    /// the expensive half — a second scene render into a reflection target — and it is
    /// the half the altitude tier was always meant to gate.
    #[must_use]
    pub fn reflective_water_active(&self) -> bool {
        self.water_active() && self.lod_tier().reflective_water()
    }

    /// Set the **cascaded shadow map** strength `0..1` (chainable). `0` turns the
    /// shadow pass off (the cheap / wasm / no-shadow control path). GPU lane only.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn with_shadows(mut self, strength: f32) -> Self {
        self.shadow_strength = strength.clamp(0.0, 1.0);
        self
    }

    /// Inject a **sun + sky** environment (chainable) — the blob oracles sweep this
    /// to prove the sun angle changes which faces are lit.
    #[must_use]
    pub fn with_sun(mut self, sun: SunSky) -> Self {
        self.sun = sun;
        self
    }

    /// **Set the sun/sky environment at runtime** — the mutable twin of
    /// [`Self::with_sun`], exactly as [`Self::set_sky_transmittance_lut`] is the mutable
    /// twin of [`Self::with_sky_transmittance_lut`].
    ///
    /// `with_sun` consumes `self`, which is right for a builder and wrong for a *retained*
    /// `Map3D`: a host that owns one and re-extrudes it only when the view window moves
    /// (korp's risen view does exactly that) cannot re-run the builder every frame to move
    /// the sun. Without this, a day-cycle driver had two options — rebuild the mesh to
    /// change the light, or take the sun out of `Map3D` and light the city a second time
    /// somewhere else. The first throws away the extrusion cache the retained view exists
    /// for; the second is a second lighting model.
    ///
    /// One writer either way: the field this sets is the one `sun()` reads, the CPU
    /// painter shades from and the GPU sky pass uploads, so a runtime sun and a built-in
    /// one cannot disagree.
    pub fn set_sun(&mut self, sun: SunSky) {
        self.sun = sun;
    }

    /// The live sun/sky environment (introspection + the CPU painter shade).
    #[must_use]
    pub fn sun(&self) -> SunSky {
        self.sun
    }

    /// **This scene's "risen building" threshold**, in design units — the ONE answer to
    /// "how high must a mesh vertex be before it is a wall or a roof?" that every
    /// risen-geometry query in this type uses ([`Self::building_vertices`],
    /// [`Self::buildings_visible`], [`Self::flanking_houses`], the roof accent, and the
    /// [`Self::frame_densest_cluster`] camera fitter).
    ///
    /// [`extrude::BUILDING_RISE_METRES`] taken through the scene's own design-units-per-
    /// metre, so it means the same **physical** thing whether the scene spans a village
    /// or a region; synthetic data with no world scale keeps the absolute
    /// [`BUILDING_RISE_EPS`]. See [`extrude::building_rise_eps`].
    #[must_use]
    pub fn building_rise_eps(&self) -> f32 {
        self.rise_eps
    }

    /// **The scene's scale as every metric constant reads it** — design units per real
    /// metre, `None` for synthetic data with no world span. The reciprocal view of
    /// [`Self::metres_per_design_unit`], carrying the `None` an `f32` cannot: that is
    /// the signal the haze layer, the SSAO radius and the water plane read to keep
    /// their legacy absolutes on the fixture scenes.
    #[must_use]
    pub fn design_units_per_metre(&self) -> Option<f32> {
        self.du_per_m
    }

    /// **Where on Earth this scene is** — the WGS-84 `(min_lon, min_lat, max_lon,
    /// max_lat)` bbox of the ways the mesh was built from, `None` for synthetic data.
    ///
    /// The geographic observable. Every other reading this viewer publishes is a count,
    /// and counts cannot tell Stockholm from Sigtuna — see [`Self::geo_centre`].
    #[must_use]
    pub fn geo_bbox(&self) -> Option<(f64, f64, f64, f64)> {
        self.geo_bbox
    }

    /// The scene's **`(lon, lat)` centre** — the one number that answers "did the 3D
    /// pane extrude the place the user was looking at?".
    ///
    /// A test written on triangle counts, building vertices or `buildings_visible` goes
    /// green on the wrong city, because a suburb has just as many houses as an inner
    /// city. This does not: hold it against the map camera's own centre and the answer
    /// is a distance in metres ([`geo_distance_m`]).
    #[must_use]
    pub fn geo_centre(&self) -> Option<(f64, f64)> {
        self.geo_bbox.map(|(lon0, lat0, lon1, lat1)| ((lon0 + lon1) * 0.5, (lat0 + lat1) * 0.5))
    }

    /// Which rule chose the extruded town window ([`extrude::town_window`]) — `None` for
    /// the constructors that do no subsetting.
    #[must_use]
    pub fn town_window(&self) -> Option<extrude::TownWindow> {
        self.town_window
    }

    /// **Why nothing was extruded**, when the reason is that the camera's window could
    /// not be answered at this zoom ([`extrude::TownChoice::TooCoarse`]). `None` on
    /// every scene that HAS a town — an empty pane with `None` here is the old
    /// "no ways at all" state, which is a different thing.
    #[must_use]
    pub fn town_refused(&self) -> Option<extrude::TownRefusal> {
        self.town_refused
    }

    /// **The dolly length a free-flight pose parks its orbit target at**, in design units.
    ///
    /// [`Self::look_from`] expresses a 6DOF pose as `target = eye + distance · forward`, so
    /// this number is arbitrary in the pose and NOT arbitrary in the frustum: the view's
    /// far plane is `distance + FAR_PAD`. The scene's own footprint spans
    /// [`extrude::DESIGN_FILL`] = 1.6 design units, so 2.0 puts the target beyond the far
    /// side of the city and the far plane well beyond that — a whole scene stays in frame
    /// from anywhere inside it.
    pub const DOLLY_DU: f32 = 2.0;

    /// **Where a geographic point lands in this scene's design plane** — `(dx, dz)` on
    /// the ground plane, `None` for synthetic data with no world span.
    ///
    /// The inverse question to [`Self::geo_centre`], and the seam a FLIGHT rig needs: a
    /// 6DOF camera knows where it is on Earth and has to be told where that is in the
    /// frame the mesh was built in. It goes through [`extrude::to_design`], the same one
    /// writer every footprint vertex went through, against the Mercator box of
    /// [`Self::geo_bbox`] — so the camera and the city cannot end up in two coordinate
    /// systems that agree until one is edited.
    ///
    /// `facett_map::mercator` is the projection because it is the one `Way::points` are
    /// already in ([`facett_map::Way::points`] is documented as "Mercator unit space"),
    /// and `to_design`'s box is the box of those points. Mercator is monotone per axis,
    /// so the box of the projected bbox corners IS the box of the projected points.
    #[must_use]
    pub fn design_from_lonlat(&self, lon: f64, lat: f64) -> Option<[f32; 2]> {
        let (lon0, lat0, lon1, lat1) = self.geo_bbox?;
        let (ax, ay) = facett_map::mercator(lon0, lat0);
        let (bx, by) = facett_map::mercator(lon1, lat1);
        // **f64 all the way to the subtraction** — see `extrude::to_design_f64`. This
        // used to cast the Mercator coordinate to f32 first, and one ULP there is 0.61 m
        // of ground at this latitude. A building vertex is quantised once and sits still;
        // the eye and the raven come back through here EVERY frame from moving f64
        // lon/lat, so that ULP was a staircase they walked up and down out of step with
        // each other — the flicker, measured at 9.29 px of per-frame centroid jitter.
        let bb = (ax.min(bx), ay.min(by), ax.max(bx), ay.max(by));
        let (mx, my) = facett_map::mercator(lon, lat);
        let (dx, dz) = extrude::to_design_f64(mx, my, bb);
        Some([dx as f32, dz as f32])
    }

    /// **The inverse of [`Self::design_from_lonlat`]** — a design-plane `(dx, dz)`
    /// back to `(lon, lat)`. The same bbox, the same uniform scale, mirrored exactly
    /// (undo `to_design`, then [`facett_map::unmercator`]), so
    /// `lonlat_from_design ∘ design_from_lonlat` round-trips to f32 precision.
    /// `None` for synthetic data with no world span — same refusal as the forward map.
    #[must_use]
    pub fn lonlat_from_design(&self, dx: f32, dz: f32) -> Option<(f64, f64)> {
        let (lon0, lat0, lon1, lat1) = self.geo_bbox?;
        let (ax, ay) = facett_map::mercator(lon0, lat0);
        let (bx, by) = facett_map::mercator(lon1, lat1);
        let (x0, y0, x1, y1) = (ax.min(bx), ay.min(by), ax.max(bx), ay.max(by));
        let s = (x1 - x0).max(y1 - y0).max(1e-9);
        let cx = (x0 + x1) * 0.5;
        let cy = (y0 + y1) * 0.5;
        let mx = f64::from(dx) / f64::from(extrude::DESIGN_FILL) * s + cx;
        let my = f64::from(dz) / f64::from(extrude::DESIGN_FILL) * s + cy;
        Some(facett_map::unmercator(mx, my))
    }

    /// **Where on the GROUND a screen pixel points** (V2.5 D.2 fix): the view ray
    /// through the pixel of the camera that actually painted this pane, intersected
    /// with the terrain plane `y = 0`, answered as `(lon, lat)`.
    ///
    /// This is the click's ONE honest mapping while the pane flies a rider's-eye
    /// perspective. korp's first cut mapped the click through the top-down RAIL
    /// camera's metres-per-pixel instead — a different camera from the one on screen —
    /// so a click on the horizon landed less than a span ahead of the body, usually
    /// inside the waypoint's own 200 m arrival radius: the waypoint "did nothing"
    /// because it arrived instantly and handed straight over to the loiter.
    ///
    /// `px_from_centre` is the click offset from the pane rect's centre (egui pixels);
    /// `viewport_px` the pane rect's size. `None` for a click at or above the horizon
    /// (the ray never meets the ground), for an ortho/plan view, or for a scene with
    /// no world span — a sky click plants no waypoint rather than a fabricated one.
    /// **Where on the GROUND an ABSOLUTE SCREEN POSITION points** — the click seam a
    /// host should use, and the reason [`Self::last_view_rect`] exists.
    ///
    /// [`Self::ground_lonlat_at_px`] takes the offset from the pane centre and the pane
    /// size, which means the CALLER has to know the rect this pane painted into. korp
    /// did not, and could not: only [`Self::view`] knows what it allocated. Its two
    /// attempts at reconstructing it were both wrong (`ui.min_rect()` is the view strip
    /// ∪ the map; `next_widget_position()` after the paint overshoots the bottom by
    /// `item_spacing.y`), and the second was MEASURED at `3.00 px` of excess height and
    /// `1.50 px` of centre offset on korp's own 1280×900 pane. A pixel ray is a lever:
    /// near the horizon a pixel of aim error is tens of metres of ground, and the error
    /// biases one way — the ray aims high, the waypoint lands LONG.
    ///
    /// So the host now hands over the raw pointer position and this answers it against
    /// the rect that actually painted. There is nothing left for a caller to get wrong,
    /// which is the point (LAW 5: fix by construction, not by a guard watching two
    /// copies of a rect agree).
    ///
    /// `None` before the first paint (no frame to shoot through), for a click at or
    /// above the horizon, for an ortho/plan view, or for a scene with no world span —
    /// the same refusals [`Self::ground_lonlat_at_px`] makes, plus the un-painted one.
    #[must_use]
    pub fn ground_lonlat_at_screen(&self, pos: egui::Pos2) -> Option<(f64, f64)> {
        let rect = self.last_view_rect.get()?;
        let c = rect.center();
        self.ground_lonlat_at_px((pos.x - c.x, pos.y - c.y), (rect.width(), rect.height()))
    }

    /// **The rect this pane last painted into**, or `None` before the first paint — the
    /// applied output behind [`Self::ground_lonlat_at_screen`], exposed so a test can
    /// assert the pick against the frame rather than against a rect it reconstructed
    /// the same wrong way the code under test would.
    #[must_use]
    pub fn painted_rect(&self) -> Option<egui::Rect> {
        self.last_view_rect.get()
    }

    /// **Carry the paint cache across a MESH SWAP** — the rect belongs to the PANE, not
    /// to the mesh hanging in it.
    ///
    /// A host that re-extrudes off-thread (korp does) replaces the whole `Map3D` when the
    /// worker lands. The new instance has never painted, so [`Self::painted_rect`] is
    /// `None` and [`Self::ground_lonlat_at_screen`] correctly refuses — which would turn
    /// a click that used to land in the wrong place into a click that lands nowhere. That
    /// is a better failure and still a failure: the pane did not move, only its contents
    /// did, so the outgoing rect is exactly right for the incoming mesh.
    ///
    /// Seeding it is therefore not a cache-warming trick, it is the host stating a fact
    /// it alone knows: these two meshes are the same pane. The next paint overwrites it
    /// with a freshly measured rect either way, so a wrong seed cannot outlive one frame.
    pub fn seed_painted_rect(&self, rect: egui::Rect) {
        self.last_view_rect.set(Some(rect));
    }

    #[must_use]
    pub fn ground_lonlat_at_px(
        &self,
        px_from_centre: (f32, f32),
        viewport_px: (f32, f32),
    ) -> Option<(f64, f64)> {
        let half_h = (viewport_px.1 * 0.5).max(1.0);
        let centre = (0.0, 0.0);
        // The SAME view the frame projects through (render_camera folds tour/spin in).
        let w = self.render_camera().view().unproject_plane_px(px_from_centre, centre, half_h, 0.0)?;
        self.lonlat_from_design(w.x, w.z)
    }

    /// **Point the camera FROM a design-space eye ALONG a design-space direction** — the
    /// free-fly pose, expressed in the orbit camera the renderer already has.
    ///
    /// # Why an orbit camera can express a free flight exactly
    ///
    /// [`OrbitCamera`] is `target + azimuth + elevation + distance`, and the view core
    /// derives `eye = target − distance · forward` with
    /// `forward = (−cos e · sin az, −sin e, −cos e · cos az)`
    /// (`facett_core::render::View::basis`, with `tilt = π/2 − elevation`). Invert it:
    /// any unit direction gives `e = asin(−F.y)` and `az = atan2(−F.x, −F.z)`, and the
    /// target is then just `eye + distance · F`. So a 6DOF pose IS an orbit pose with the
    /// target parked one dolly-length down the view ray — there is no expressiveness gap
    /// to justify a second renderer, which is exactly what `facett-demo`'s flight tab had
    /// grown (LAW 5).
    ///
    /// **Roll survives too, now.** It did not when this seam was written — an orbit camera
    /// had no bank axis, so a flight-stick roll was silently levelled, and that was said out
    /// loud here rather than hidden. It is no longer true: `facett_core::render::View` grew a
    /// `roll` that banks the basis about the forward axis, `OrbitCamera` carries it, and this
    /// takes it. All three degrees of freedom the rig has now reach the frame.
    ///
    /// `distance` is the dolly length, and it is not free: the view's far plane is
    /// `distance + FAR_PAD`. [`Self::DOLLY_DU`] is a design-unit length comfortably past
    /// the scene's own span so nothing clips out from behind the camera.
    ///
    /// Both the live pose and the damping GOALS are written, or `OrbitCamera::update`
    /// would ease every frame's pose back toward the last goal and the flight would drag.
    pub fn look_from(&mut self, eye: [f32; 3], forward: [f32; 3], roll: f32) {
        let n = (forward[0] * forward[0] + forward[1] * forward[1] + forward[2] * forward[2])
            .sqrt()
            .max(1e-9);
        let f = [forward[0] / n, forward[1] / n, forward[2] / n];
        let elevation = (-f[1]).clamp(-1.0, 1.0).asin();
        let azimuth = (-f[0]).atan2(-f[2]);
        // ── A-2a, PART 2: THE DOLLY IS GROUND-AWARE. ──
        //
        // `DOLLY_DU` is scene-INDEPENDENT, and that is the seat of the defect. The
        // target is parked one dolly-length down the view ray, so nose-down flight puts
        // it `DOLLY_DU·sin(pitch)` BELOW the eye — and `clamp_above_ground` then has to
        // deal with a target underground for every commanded altitude below
        // `(SURFACE_EPS − DOLLY_DU·sin(pitch))·m_per_du`, which on a 625 m/du scene at
        // pitch −0.25 is everything under 310 m. Softening that clamp (part 1) stops the
        // teleport; shortening the dolly stops the clamp from ever being asked.
        //
        // Looking down, the ray hits the ground at `(eye.y − surface)/(−f.y)`. Park the
        // target THERE at the latest — which is where a pilot looking down is looking —
        // and it can never be under the ground at any altitude.
        //
        // THE RECORDED OBJECTION IS ALREADY NEUTRALISED: "the far plane is
        // `distance + FAR_PAD`, so low-and-steep shortens the view distance".
        // `OrbitCamera::far_plane` reads `max(distance, DOLLY_DU) + FAR_PAD`
        // (mirrored in `facett_core::render::View::far_plane`), so a shortened dolly
        // does not shorten the frustum.
        let d = {
            let full = Self::DOLLY_DU;
            if f[1] < 0.0 {
                // Stop the target at the height where `clamp_above_ground`'s ease is
                // EXACTLY the identity — `surface + SURFACE_EPS + GROUND_SOFT` — not on
                // the ground itself. Aimed at the ground, the target lands at the bottom
                // of the bend, the ease lifts it, and the derived eye rises with it:
                // MEASURED 6.8 m of unrequested altitude at a commanded 17 m. Stopped at
                // the identity point, the clamp finds nothing to do and the eye lands
                // where the rig put it, to the bit. The stand-off is 3·SURFACE_EPS =
                // 1.9 m on a 625 m/du scene.
                let stop = self.cam.surface_height_at(eye[0], eye[2])
                    + camera::OrbitCamera::SURFACE_EPS
                    + camera::OrbitCamera::GROUND_SOFT;
                let to_stop = (eye[1] - stop) / -f[1];
                // A pose already inside the stand-off band has no dolly length that puts
                // the target in the identity region — every point down the ray is below
                // the eye, and the eye is already there. Collapse the dolly instead, so
                // the target rides with the eye and the residual lift is a fraction of
                // the bend rather than a fraction of `DOLLY_DU`. Floored just off zero:
                // a zero-length dolly is a degenerate orbit, and `SURFACE_EPS` of it is
                // 0.6 m on this scene.
                to_stop.clamp(camera::OrbitCamera::SURFACE_EPS, full)
            } else {
                full
            }
        };
        let target = camera::V3::new(eye[0] + f[0] * d, eye[1] + f[1] * d, eye[2] + f[2] * d);
        self.cam.target = target;
        self.cam.azimuth = azimuth;
        self.cam.elevation = elevation;
        self.cam.distance = d;
        self.cam.goal_target = target;
        self.cam.goal_azimuth = azimuth;
        self.cam.goal_elevation = elevation;
        self.cam.goal_distance = d;
        // Not damped and deliberately given no goal: the rig damps its own stick, and a
        // second easing here would fight it.
        self.cam.roll = roll;
        // ── A-2b: this pose owns its own attitude. The orbit band's `MIN_ELEV` (0.02 rad)
        // was pitching every level or climbing flight pose 1.15° nose-down on the next
        // frame — 25 m of derived eye movement on a 625 m/du scene, for a pose nobody
        // changed. An interactive orbit gesture (drag, preset, key nudge, tilt, reset)
        // clears the mode again.
        self.cam.free_look = true;
        // The eye just moved; if an overlay rides close, the near band follows it.
        self.refresh_overlay_near();
    }

    /// **Point the camera from a GEOGRAPHIC eye** — the whole flight-rig seam in one call.
    ///
    /// `yaw` is heading about the world up axis with 0 toward +easting, `pitch` is nose
    /// lift; both radians, both exactly `FlightCamera`'s own fields. `altitude_m` is metres
    /// above the scene's ground plane.
    ///
    /// The frame swap is here and nowhere else. `FlightCamera` speaks the world frame
    /// `(easting, northing, altitude)`; the renderer speaks Y-up `(easting, altitude,
    /// northing)`. Writing that swap at the call site is how `facett-demo`'s painter came
    /// to hold its own copy of it — see the module history on `build_terrain_mesh`.
    ///
    /// Returns `false` when the scene has no geographic span (synthetic data), leaving the
    /// camera untouched rather than parking it at the origin.
    pub fn look_from_lonlat(
        &mut self,
        lon: f64,
        lat: f64,
        altitude_m: f64,
        yaw: f64,
        pitch: f64,
        roll: f64,
    ) -> bool {
        let Some([dx, dz]) = self.design_from_lonlat(lon, lat) else {
            return false;
        };
        let du_per_m = self.du_per_m.unwrap_or(1.0);
        let eye = [dx, altitude_m as f32 * du_per_m, dz];
        // World forward is `(cos y · cos p, sin y · cos p, sin p)` (`FlightCamera::forward`),
        // i.e. `(easting, northing, altitude)`. The render frame this scene's GEOMETRY
        // lives in is `x = east, y = up, z = SOUTH` — [`extrude::to_design`] maps north
        // (small Mercator y) to −z, and `design_from_lonlat` placed the eye through that
        // same writer — so northing lands on **−z**, not +z.
        //
        // The old `+z` here was the `[v0, v2, v1]` swap `build_terrain_mesh` applies to
        // facett-demo's own terrain, carried over verbatim — a frame from a DIFFERENT
        // mesh builder. Against a map3d scene it mirrored every heading north-for-south:
        // told "chase the korp from behind" the camera looked exactly AWAY from the body
        // on a north heading. The flight-pose test pinned the wrong convention with it
        // (asserting "+z is north" against geometry whose north is −z); both are fixed
        // together and the compass/heading suite holds the new sign red-capable.
        let (cp, sp) = (pitch.cos(), pitch.sin());
        let forward = [
            (yaw.cos() * cp) as f32,
            sp as f32,
            -(yaw.sin() * cp) as f32,
        ];
        self.look_from(eye, forward, roll as f32);
        true
    }

    /// The calibrated **metres-per-design-unit** for this scene (derived from the
    /// data's Mercator footprint span). The scalar that turns the camera's
    /// design-unit altitude into the metres the LOD tiers gate on.
    #[must_use]
    pub fn metres_per_design_unit(&self) -> f32 {
        self.metres_per_du
    }

    /// The camera's current **altitude in metres** (the calibrated dolly height).
    #[must_use]
    pub fn altitude_m(&self) -> f32 {
        lod::altitude_metres(self.render_camera().distance_to_surface(), self.metres_per_du)
    }

    /// The current **LOD tier**, resolved with hysteresis from [`Self::altitude_m`].
    /// `Ground` (`< 300 m`) is where the reflective animated water switches on.
    #[must_use]
    pub fn lod_tier(&self) -> LodTier {
        lod::resolve_tier(self.altitude_m(), self.last_tier_ground)
    }

    /// **wgpu feature.** Opt the 3D map into the GPU **depth-tested** render path
    /// (real Z-buffer occlusion). The host must have installed a
    /// [`gpu::Map3dRenderer`] into its `RenderState` ([`gpu::install_renderer`]);
    /// if it hasn't, the callback no-ops and the CPU painter still draws. Chainable.
    #[cfg(feature = "wgpu")]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self.backend =
            if on { Box::new(backend::WgpuVelloBackend::new()) } else { Box::new(backend::CpuPainterBackend) };
        self
    }

    /// Whether the most recent `ui()` rendered through the GPU depth path (vs the
    /// CPU painter). Surfaced in `state_json` so the matrix can assert which lane ran.
    ///
    /// **`false` also means "no frame has been painted yet"** — the two are not
    /// distinguishable through this signature, which is exactly the trap described on
    /// [`Self::last_used_gpu`]. A host that reads the lane anywhere other than inside its
    /// own paint (korp's per-rebuild log line, a robot snapshot taken before the first
    /// frame) must ask [`Self::used_gpu_observed`] instead and say `pending` for `None`.
    pub fn used_gpu(&self) -> bool {
        self.last_used_gpu.get().unwrap_or(false)
    }

    /// **Which lane painted, or `None` because nothing has painted yet.** The honest
    /// spelling of [`Self::used_gpu`], and the one every out-of-paint reader wants.
    ///
    /// `None` ⇒ *no measurement exists*: this scene has not been through [`Self::view`]
    /// since it was built. `Some(false)` ⇒ the CPU painter really did draw the last frame
    /// (far→near sort, no depth test, overdraw — the "flat with lines" signature).
    /// `Some(true)` ⇒ the wgpu depth path drew it.
    #[must_use]
    pub fn used_gpu_observed(&self) -> Option<bool> {
        self.last_used_gpu.get()
    }

    /// **WHICH LANE DREW, as the three-state on-screen flag** — the answer the owner asked
    /// for on 2026-08-23 ("a flag saying GPU or CPU ... so i know whats being used").
    ///
    /// A thin wrapper on [`Self::used_gpu_observed`] on purpose: that is the ONE honest
    /// reader, and it is the same one korp's `map3d_lane_line` / `Risen3D::lane_line`
    /// print from — so the pane's chip and the log sentence cannot disagree (LAW 5).
    ///
    /// Deliberately NOT built on [`Self::used_gpu`]: that one collapses "nobody has
    /// measured" into `false`, which is how this pane came to report the CPU painter on a
    /// build with the GPU lane compiled in, the renderer installed and a discrete card
    /// selected (2026-08-06, read as the genuine defect by three people).
    #[must_use]
    pub fn lane_flag(&self) -> facett_core::render::lane::LaneFlag {
        facett_core::render::lane::LaneFlag::from_observed(self.used_gpu_observed())
    }

    /// **Which lane is SELECTED — answerable before the first frame.**
    ///
    /// The backend swap ([`Self::with_gpu`]) happens at construction, so this is known the
    /// instant the scene exists, while [`Self::used_gpu_observed`] cannot be known until a
    /// frame has been painted. The two answer different questions and a flat pane needs
    /// both: `selected && observed == Some(false)` is the real failure (the lane was
    /// chosen and something dropped back to the CPU painter), whereas
    /// `selected && observed == None` is merely *early*.
    #[must_use]
    pub fn gpu_lane_selected(&self) -> bool {
        self.backend.caps().gpu
    }

    /// The **name** of the selected render backend (`"cpu-painter"`, `"wgpu-vello"`, …) —
    /// the string a log line prints beside [`Self::used_gpu_observed`] so a `pending`
    /// still says which lane is *about* to run.
    #[must_use]
    pub fn backend_name(&self) -> &'static str {
        self.backend.caps().name
    }

    /// The **synthetic 3D fixture**: extrude the **znippy rabbit** silhouette into
    /// a rabbit-shaped 3D skyline (each rabbit loop → a prism) and pin a spinning
    /// [`RabbitLogo3D`] logo overlay. This is the canonical test fixture — never
    /// squares, always the rabbit.
    pub fn synthetic(title: impl Into<String>) -> Self {
        // The rabbit silhouette loops, mapped from the `[-1,1]` design box into a
        // small lon/lat patch, become real `Way`s we then extrude to prisms.
        let ways = extrude::rabbit_ways();
        let m = Self::from_ways(title, &ways, 0.18);
        *m.logo.borrow_mut() = Some(RabbitLogo3D::with_depth("znippy", 0.30));
        m
    }

    /// **Discovery-contract `local()`** — the canonical no-arg constructor the
    /// test matrix calls to enumerate this facet. Builds the synthetic extruded
    /// rabbit-skyline so the view renders real 3D geometry with no fixture on
    /// disk. Mirrors `OsmView::local()` / the rest of the facett discovery law.
    pub fn local() -> Self {
        Self::synthetic("OSM 3D")
    }

    /// **Discovery-contract `remote()`** — same surface, the variant a host builds
    /// from a server-loaded dataset. Identical wiring here (no transport in
    /// facett); kept so the matrix sees both ctors.
    pub fn remote() -> Self {
        let mut m = Self::local();
        m.title = "OSM 3D (remote)".into();
        m
    }

    /// **Real-data constructor** — build a 3D city from pre-loaded OSM
    /// [`Way`]s (the host reads `ways.parquet` via `facett-osm`). A convenience
    /// alias for [`Self::from_ways`] with the standard city extrusion height,
    /// named for intent at the demo's OSM 3D tab call site. Empty ways → just the
    /// terrain plane (graceful).
    pub fn from_geometry(title: impl Into<String>, ways: &[Way]) -> Self {
        Self::from_geometry_in_view(title, ways, None)
    }

    /// **The real-data constructor a host with a map camera must use** —
    /// [`Self::from_geometry`], but the town that gets extruded is the one under
    /// `view` instead of whichever one has the most houses in it.
    ///
    /// # The defect this exists to close
    ///
    /// The 3D pane subsets a region load to one town before extruding, because a 55 km
    /// scene draws an 8 m house 0.66 px tall — a flat white pancake, every count green.
    /// The subset is right; picking the town by **density** was not. The owner panned the
    /// 2D map to Stockholm, pressed 3D, and got **Sigtuna**: a suburb's dormitory grid
    /// out-votes an inner city, and the viewport centre was never consulted because it
    /// was never passed in.
    ///
    /// So `view` is the answer: the user already aimed the map. Density survives as the
    /// fallback for the case where the camera genuinely cannot answer — a country
    /// overview whose centre is open water — see [`extrude::town_window`].
    ///
    /// Pass `None` only when there is honestly no camera (fixtures, standalone demos).
    pub fn from_geometry_in_view(title: impl Into<String>, ways: &[Way], view: Option<extrude::ViewWindow>) -> Self {
        Self::from_geometry_in_view_styled(title, ways, view, extrude::RoofStyle::Flat)
    }

    /// **[`Self::from_geometry_in_view`] with pitched, COLOURED roofs** — the
    /// constructor a host with a map camera should use, and the one korp's 3D pane now
    /// calls.
    ///
    /// # The defect this closes: the town was built out of the 2D map's own near-white
    ///
    /// `from_geometry_in_view` extrudes with [`extrude::RoofStyle::Flat`], which means
    /// every building is a flat-capped prism wearing the **cartographic 2D fill** —
    /// `building` `#E2DBCF` on `land` `#F2EFE9`, with walls at 0.82x — and the OSM
    /// `building:colour` / `roof:colour` tags and the default roof palette in
    /// [`extrude`] are never reached. Those three greys are within a few LSB of each
    /// other and of white. Flat on a 2D map that is correct and deliberate; extruded
    /// into 3D it is a **white plain you cannot see a house on**, which is the report
    /// this constructor exists to answer.
    ///
    /// MEASURED 2026-08-05 (RTX 4090 [discrete/Vulkan], 320 000-way Stockholm extract,
    /// `ground_pixels_probe`, the CPU-painter control — i.e. the MESH's own colours with
    /// no shading chain at all): the whole warm family (land+building+road+path) reads
    /// **1.84 %** of the pane and the `building` class keeps **40 %** of its source
    /// chroma. The pane is not losing the houses' colour in the shader; the houses were
    /// never given a colour to lose.
    ///
    /// The pitched path was already written, tested and shipped —
    /// [`Self::from_geometry_roofed`] — but it picks its town by DENSITY, which is the
    /// Sigtuna defect ([`Self::from_geometry_in_view`]'s own doc). A host had to choose
    /// between the right town and the right colours. This constructor is both, and it is
    /// the same body as the flat one with the style passed through (L5), not a copy.
    pub fn from_geometry_in_view_roofed(
        title: impl Into<String>,
        ways: &[Way],
        view: Option<extrude::ViewWindow>,
    ) -> Self {
        Self::from_geometry_in_view_styled(title, ways, view, extrude::RoofStyle::Pitched)
    }

    /// The ONE body behind [`Self::from_geometry_in_view`] and
    /// [`Self::from_geometry_in_view_roofed`] — camera-first town choice, then extrude
    /// at `roof_style`. Reuse rather than twin (L5): the two public spellings differ by
    /// exactly the style they pass.
    fn from_geometry_in_view_styled(
        title: impl Into<String>,
        ways: &[Way],
        view: Option<extrude::ViewWindow>,
        roof_style: extrude::RoofStyle,
    ) -> Self {
        // **Subset to one town first**, THEN extrude. A country-/region-scale load
        // (e.g. all of liechtenstein, ~30 km) has sub-pixel footprints and, once
        // dollied into, a giant terrain plane whose far edges starburst. Extruding
        // only one ~3 km town makes the mesh city-scale: building heights scale to the
        // town span (a readable skyline), the terrain is town-sized (no far spokes),
        // and a normal camera frames it. A load that is already a single town (or the
        // synthetic fixture) is returned unchanged.
        let town = extrude::view_town_subset(ways, view);
        let mut m =
            Self::from_ways_terrain_styled(title, &town, 0.22, extrude::GroundTerrain::Flat, roof_style);
        // ONE decision, read once (L5): `town_window` and the refusal are the same
        // question, and asking `extrude` twice let the pane and the subset disagree.
        match extrude::choose_town(ways, view) {
            extrude::TownChoice::Chosen(w) => m.town_window = Some(w),
            extrude::TownChoice::TooCoarse(r) => m.town_refused = Some(r),
            extrude::TownChoice::Nothing => {}
        }
        // Still aim at the densest sub-cluster + dolly so the town fills the view.
        m.frame_densest_cluster();
        m
    }

    /// **The CONTINUOUS constructor: extrude the whole viewport, with distance falloff,
    /// under a triangle budget** — the answer to *"why is 3d only a tile / rectangle
    /// rotating?"*.
    ///
    /// # What it is, against [`Self::from_geometry_in_view_roofed`]
    ///
    /// That one asks *which one town do I extrude?* and answers with a box of at most
    /// `2·`[`extrude::TOWN_HALF_MAX_M`] = **1.16 km**. On a z12 viewport (37 km) that is
    /// 3 % of the width and 0.1 % of the area: a small rectangle of city hanging in an
    /// empty pane, which is what the operator sees rotating. And when the camera's centre
    /// holds no houses it answers with an EMPTY ways set, so the pane paints a sentence
    /// over a viewport that has roads, water and coastline in it.
    ///
    /// This one asks *what is in the viewport, and how much of it can this frame
    /// afford?*:
    ///
    /// * the window is the camera's own `span_m / 2` — the box on the 2D pane, not a
    ///   legibility cap;
    /// * geometry is taken **nearest-first** and degrades with distance
    ///   ([`extrude::Detail`]): real footprints with real roofs near the camera,
    ///   bounding-box blocks further out, then ground cover and the road network alone,
    ///   all the way to the viewport border. The scene fades; it does not end at an edge;
    /// * the depth of each band is set by a **triangle budget**
    ///   ([`extrude::SceneBudget`]), not by a metre constant, so it is self-limiting and
    ///   a weaker GPU gets a shallower scene rather than a dropped frame;
    /// * it **never returns an empty scene**. A viewport with no buildings at all still
    ///   extrudes its ground.
    ///
    /// # The camera
    ///
    /// The data window and the camera framing are now two different quantities, and this
    /// is the constructor that separates them. The camera frames
    /// [`extrude::legible_frame_extent_m`] metres of ground — derived from
    /// [`extrude::town_half_for_span`], so the median house stands exactly as many pixels
    /// as it does on the town path at every zoom — aimed at
    /// [`extrude::ViewportStats::aim`], the centroid of the full-detail band. An empty
    /// viewport centre therefore slides the camera to the nearest houses instead of
    /// framing a field, and a viewport with no houses at all frames its own centre.
    ///
    /// # What it does NOT change
    ///
    /// [`extrude::town_window`] and [`extrude::choose_town`] still run and their answers
    /// are still published ([`Self::town_window`], [`Self::town_refused`]) — they are
    /// information about where the houses are, and korp logs them. They no longer decide
    /// what is drawn. Every existing constructor, and [`extrude::town_half_for_span`]
    /// itself, is untouched.
    pub fn from_viewport_roofed(
        title: impl Into<String>,
        ways: &[Way],
        view: extrude::ViewWindow,
        budget: extrude::SceneBudget,
    ) -> Self {
        let scene = extrude::viewport_scene(ways, view, budget);
        let mut m = Self::from_ways_terrain_styled(
            title,
            &scene.ways,
            0.22,
            extrude::GroundTerrain::Flat,
            extrude::RoofStyle::Pitched,
        );
        // The town decision is still ASKED and still published — korp's log and the
        // pane's hint read it — but it no longer gates the geometry. One writer, still
        // (L5): `extrude::choose_town` is the only thing that answers "where are the
        // houses", exactly as on the town path.
        match extrude::choose_town(ways, Some(view)) {
            extrude::TownChoice::Chosen(w) => m.town_window = Some(w),
            extrude::TownChoice::TooCoarse(r) => m.town_refused = Some(r),
            extrude::TownChoice::Nothing => {}
        }
        let mut stats = scene.stats;
        m.frame_viewport(&mut stats, view);
        m.viewport = Some(stats);
        m
    }

    /// Aim + dolly the camera for a [`Self::from_viewport_roofed`] scene: the framed
    /// ground extent is METRIC ([`extrude::legible_frame_extent_m`]) rather than a
    /// fraction of the design box, because the design box is now the whole viewport.
    /// See that function for the measurement that forces it.
    fn frame_viewport(&mut self, stats: &mut extrude::ViewportStats, view: extrude::ViewWindow) {
        // **Let the eye reach the floor the dolly already reaches.** `min_distance` is a
        // FIELD defaulting to 0.4 while the interactive dolly eases to the CONST
        // `MIN_DISTANCE` = 0.06 — two floors, and the framing clamp used the higher one.
        // On a town-sized box the wanted distance is above both, so the disagreement never
        // showed; on a scene sized by `extrude::scene_reach_half_m` (which solves for
        // exactly that constant) 0.4 is a 6.7x dolly-out and the whole reach derivation
        // is voided by the clamp underneath it. Only this path moves it; every existing
        // constructor keeps 0.4.
        self.cam.min_distance = camera::OrbitCamera::MIN_DISTANCE;
        let du_per_m = self.du_per_m.unwrap_or(extrude::DEFAULT_DESIGN_UNITS_PER_METRE);
        let extent = (extrude::legible_frame_extent_m(stats.span_m) as f32) * du_per_m;
        let (lon, lat) = stats.aim.unwrap_or((view.lon, view.lat));
        let Some([tx, tz]) = self.design_from_lonlat(lon, lat) else {
            return; // no geo bbox → no scene to aim at; keep the default whole-box framing
        };
        // The skyline the scene actually has, from the SAME stats the heights came from —
        // so the camera's eye height tracks the town instead of a constant.
        let roof = self.height_stats.p95;
        self.frame_extent(V3::new(tx, roof * 0.4, tz), extent, roof);
        // **The APPLIED distance, read back out of the camera** — after the dolly clamp,
        // in metres. This is the observable that moves when the operator zooms on this
        // path, and it is stamped from the camera rather than recomputed so it cannot
        // report a distance the camera is not actually at (L5). See
        // `extrude::ViewportStats::camera_distance_m` for the measurement that made it
        // necessary.
        stats.camera_distance_m = f64::from(self.cam.distance) / f64::from(du_per_m.max(1e-12));
    }

    /// **What the continuous-viewport falloff walk did** — `None` on every path except
    /// [`Self::from_viewport_roofed`]. See [`extrude::ViewportStats`].
    #[must_use]
    pub fn viewport_scene(&self) -> Option<extrude::ViewportStats> {
        self.viewport
    }

    /// **Real-data constructor with real DEM/relief terrain** — the [`Self::from_geometry`]
    /// city, but its ground is a **heightfielded relief mesh** (topography sampled from
    /// `elev`, an SRTM DEM or the [`terrain::ProceduralTerrain`] fallback) instead of the
    /// flat plane. This is what the orbit viewer (`osm3d_demo` bin + the demo's OSM 3D
    /// tab) uses so "the non-flight orbit map3d" shows real terrain, not a billiard table.
    /// `subdiv` sets the terrain grid resolution; `exaggeration` scales the metric relief
    /// (auto-capped in-box, see [`extrude::MAX_TERRAIN_RELIEF`]).
    pub fn from_geometry_relief(title: impl Into<String>, ways: &[Way], elev: &dyn terrain::Elevation, subdiv: usize, exaggeration: f32) -> Self {
        let town = extrude::densest_cluster_subset(ways);
        let mut m = Self::from_ways_terrain(title, &town, 0.22, extrude::GroundTerrain::Relief { elev, subdiv, exaggeration });
        m.frame_densest_cluster();
        m
    }

    /// [`Self::from_geometry`] but with **pitched roofs + OSM colours** — the round-2
    /// "houses read as real buildings" path: buildings grow gabled/hipped roof geometry
    /// (OSM `roof:shape` else a default gabled for small footprints) and carry OSM
    /// colours (`building:colour`/`roof:colour`/`roof:material`, tasteful defaults
    /// otherwise). See [`extrude::RoofStyle::Pitched`]. Opt-in (the `sigtuna_demo` uses
    /// it); `from_geometry` stays flat-capped + byte-stable.
    pub fn from_geometry_roofed(title: impl Into<String>, ways: &[Way]) -> Self {
        let town = extrude::densest_cluster_subset(ways);
        let mut m = Self::from_ways_terrain_styled(title, &town, 0.22, extrude::GroundTerrain::Flat, extrude::RoofStyle::Pitched);
        m.frame_densest_cluster();
        m
    }

    /// [`Self::from_geometry_relief`] but with **pitched roofs + OSM colours** (real DEM
    /// terrain AND real roof geometry) — see [`Self::from_geometry_roofed`].
    pub fn from_geometry_relief_roofed(
        title: impl Into<String>,
        ways: &[Way],
        elev: &dyn terrain::Elevation,
        subdiv: usize,
        exaggeration: f32,
    ) -> Self {
        let town = extrude::densest_cluster_subset(ways);
        let mut m = Self::from_ways_terrain_styled(
            title,
            &town,
            0.22,
            extrude::GroundTerrain::Relief { elev, subdiv, exaggeration },
            extrude::RoofStyle::Pitched,
        );
        m.frame_densest_cluster();
        m
    }

    /// The number of buildings that grew **real pitched** (gabled / hipped) roof
    /// geometry (0 on the flat-cap default path). Surfaced as `state_json.pitched_roofs`.
    #[must_use]
    pub fn pitched_roofs(&self) -> usize {
        self.roof_stats.pitched
    }

    /// The number of buildings whose roof tint reads distinct from their facade tint —
    /// an OSM colour or a tasteful class default (0 on the monochrome flat-cap default
    /// path). Surfaced as `state_json.coloured_buildings`.
    #[must_use]
    pub fn coloured_buildings(&self) -> usize {
        self.roof_stats.coloured
    }

    /// Whether this map's ground is a **real relief terrain** (a DEM / procedural
    /// heightfield) rather than the flat `y = 0` plane.
    #[must_use]
    pub fn terrain_relief(&self) -> bool {
        self.terrain_relief
    }

    /// Find the **densest cluster of risen (building) geometry** in the extruded
    /// mesh and point the camera at it, dollied in so the cluster fills the view.
    /// Buildings are the `y > 0` (lifted) vertices; ground cover / terrain lie flat
    /// at `y = 0`. We bin the risen vertices into a coarse grid, pick the heaviest
    /// cell, then set the camera target to that cell's centroid and the distance to
    /// the cluster's extent — so a country-scale dataset still shows a real town.
    pub fn frame_densest_cluster(&mut self) {
        // Collect risen (building) vertices in the ground (x,z) plane. "Risen" is the
        // scene's OWN threshold ([`Self::building_rise_eps`]) — the same one every
        // observable uses. This used to be a hardcoded `1e-3` while the observables
        // used `BUILDING_RISE_EPS`, i.e. two definitions of "risen" in one file, and on
        // a korp region viewport BOTH were above the metric rise of a real house: the
        // fitter took the `risen.len() < 3` return below and left `camera.distance` on
        // its `3.2` default over 2.1 M triangles of Stockholm.
        //
        // **Class-BLIND on purpose** ([`Self::risen_vertices`], not the house census). The
        // fitter's job is to frame whatever stands up in this scene, and a legitimate
        // scene can stand up without holding a single OSM `building`: a synthetic fixture
        // (the rabbit's `body`/`ear` prisms), or a viewport of nothing but raised
        // industrial rings. Narrowing this to houses would leave those scenes on the
        // default whole-box framing — the exact `risen.len() < 3` early return this
        // function's own history is about. The HOUSE observables are the ones that must
        // not conflate the two ([`Self::building_vertices`]); the camera is not.
        let eps = self.rise_eps;
        let risen: Vec<[f32; 3]> = self.mesh.positions.iter().copied().filter(|p| p[1] > eps).collect();
        if risen.len() < 3 {
            return; // nothing risen (all flat) → keep the default whole-box framing
        }
        // Bin into a GRID×GRID grid over the design box [-1,1]² and find the
        // heaviest cell (the densest town).
        const GRID: usize = 24;
        let cell = |v: f32| (((v + 1.0) * 0.5 * GRID as f32) as usize).min(GRID - 1);
        let mut bins = vec![0u32; GRID * GRID];
        for p in &risen {
            bins[cell(p[2]) * GRID + cell(p[0])] += 1;
        }
        let (best, _) = bins.iter().enumerate().max_by_key(|(_, c)| **c).unwrap();
        let (bz, bx) = (best / GRID, best % GRID);
        // Cluster = the best cell + its 8 neighbours. Centroid + extent of the
        // risen verts that fall inside that 3×3 neighbourhood.
        let in_cluster = |p: &[f32; 3]| {
            let (cx, cz) = (cell(p[0]) as isize, cell(p[2]) as isize);
            (cx - bx as isize).abs() <= 1 && (cz - bz as isize).abs() <= 1
        };
        let mut cnt = 0.0f32;
        let (mut sx, mut sz) = (0.0f32, 0.0f32);
        let (mut x0, mut z0, mut x1, mut z1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
        let mut ymax = 0.0f32;
        for p in risen.iter().filter(|p| in_cluster(p)) {
            sx += p[0];
            sz += p[2];
            cnt += 1.0;
            x0 = x0.min(p[0]);
            z0 = z0.min(p[2]);
            x1 = x1.max(p[0]);
            z1 = z1.max(p[2]);
            ymax = ymax.max(p[1]);
        }
        if cnt < 1.0 {
            return;
        }
        let (tx, tz) = (sx / cnt, sz / cnt);
        let extent = (x1 - x0).max(z1 - z0).max(0.05);
        self.frame_extent(V3::new(tx, ymax * 0.4, tz), extent, ymax);
    }

    /// **Park the camera on `target`, dollied so `extent` design units of ground fill
    /// roughly the vertical fov, in the oblique 3/4 preset** — the tail
    /// [`Self::frame_densest_cluster`] always was, lifted out so the continuous-viewport
    /// path ([`Self::from_viewport_roofed`]) can frame by a METRIC extent without a
    /// second copy of the dolly arithmetic (L5). `roof` is the scene's skyline height,
    /// recorded for the cinematic flythrough.
    ///
    /// The `1.6` is the historical fill factor and the clamp is the camera's own dolly
    /// range, so a caller cannot ask for a distance that clips through the city or flies
    /// out of the box.
    pub fn frame_extent(&mut self, target: V3, extent: f32, roof: f32) {
        let extent = extent.max(1e-4);
        let dist = (extent / (self.cam.fov_y * 0.5).tan() * 1.6).clamp(self.cam.min_distance, self.cam.max_distance);
        self.cam.target = target;
        self.cam.goal_target = self.cam.target;
        self.cam.distance = dist;
        self.cam.goal_distance = dist;
        // A pleasant oblique 3/4 view of the town.
        self.cam.apply_preset(ViewPreset::Oblique);
        self.cam.settle();
        // Record the framing so the cinematic flythrough knows the town it flies.
        self.framing = Some(flythrough::CityFraming {
            center: self.cam.target,
            extent,
            roof,
            base_distance: dist,
            base_azimuth: self.cam.azimuth,
        });
        self.sync_model();
    }

    /// Enable the **cinematic flythrough** auto-pilot (chainable): the camera flies
    /// the framed city on its own — a smooth orbit + dolly-breathing + elevation sweep
    /// over the GPU depth-tested skyline (lit prisms, SSAO, shadows, water, HDR sky +
    /// bloom). Deterministic in the clock (FC-7). Default off; additive.
    #[must_use]
    pub fn with_flythrough(mut self, on: bool) -> Self {
        self.flythrough_on = on;
        self
    }

    /// Toggle the cinematic flythrough at runtime (the demo's "auto-fly" checkbox).
    pub fn set_flythrough(&mut self, on: bool) {
        self.flythrough_on = on;
    }

    /// Whether the cinematic flythrough auto-pilot is flying the camera right now.
    #[must_use]
    pub fn flythrough_active(&self) -> bool {
        self.flythrough_on
    }

    /// The framed-city bounds the flythrough flies around (once framing has run).
    #[must_use]
    pub fn city_framing(&self) -> Option<flythrough::CityFraming> {
        self.framing
    }

    // ── the Sigtuna tour (air-circle → descend → drive the streets) ─────────────

    /// Attach the cinematic **Sigtuna tour** autopilot (chainable): high air-circle →
    /// descend → drive the streets, riding this map's framed city + (optionally) its
    /// projected [`route`](Self::with_route). Overrides manual/spin/flythrough/route-
    /// follow while active; default off. Set the route first (`with_route`) so the
    /// drive phase has streets to follow, else it degrades to a low ground-level creep.
    #[must_use]
    pub fn with_tour(mut self, on: bool) -> Self {
        self.set_tour(on);
        self
    }

    /// Toggle the Sigtuna tour autopilot at runtime (the demo's "auto-tour" key). When
    /// turned on, builds the tour from the current framing + route; when off, drops it
    /// and hands control back to the manual orbit camera.
    pub fn set_tour(&mut self, on: bool) {
        if on {
            let framing = self.framing.unwrap_or_else(flythrough::CityFraming::unit);
            let route = self.route.clone();
            // Pass the scene scale so the drive phase sits a real street distance from
            // the road (houses loom at street level whatever the town's real span) —
            // else the design-unit chase constant is hundreds of metres on a metric clip.
            self.tour = Some(tour::SigtunaTour::new(framing, route).with_scale(self.metres_per_du));
            // A fresh tour starts its clock at 0 so the demo opens on the air-circle.
            self.fly_clock = 0.0;
        } else {
            self.tour = None;
        }
    }

    /// **Seize the stick** — hand control from the autopilot tour back to the user
    /// without a jump. Freezes the live camera exactly where the tour has it *this
    /// frame* (the settled tour pose off [`render_camera`](Self::render_camera)), then
    /// drops the tour so the user's own orbit / pan / dolly / keyboard input drives from
    /// there. A no-op when no tour is flying (manual is already in control). This is the
    /// "the tour plays but I can't grab the flight" fix: every manual-input [`Msg`]
    /// (orbit/pan/dolly/key/preset/reset) calls this first in [`update`](Self::update),
    /// so any drag/scroll/keypress instantly takes over; press the tour key (T) to
    /// re-arm ([`Msg::SetTour`]).
    pub fn take_manual_control(&mut self) {
        if self.tour.is_some() {
            // Copy the tour's current (settled) pose into the live camera so control is
            // seized from exactly where the autopilot left it — no snap/jump.
            let pose = self.render_camera();
            self.cam = pose;
            self.tour = None;
            self.spin = false;
        }
    }

    /// Whether the Sigtuna tour autopilot is flying the camera right now.
    #[must_use]
    pub fn tour_active(&self) -> bool {
        self.tour.is_some()
    }

    /// The tour phase (circle / descend / drive) at the camera's current clock — the
    /// oracle a robot-UI / headless matrix asserts the tour walks through. `None` when
    /// the tour is not active.
    #[must_use]
    pub fn tour_phase(&self) -> Option<tour::TourPhase> {
        self.tour.as_ref().map(|t| t.phase(self.clock_s.unwrap_or(self.fly_clock)))
    }

    /// Enable the **minimap inset** (chainable): a corner top-down overview of the
    /// city with a "you are here" focus marker + a heading cone that swings with the
    /// camera bearing — the car-navigator / flight-sim inset. Default off; additive.
    #[must_use]
    pub fn with_minimap(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetMinimap(on));
        self
    }

    /// Whether the minimap inset is being painted this frame.
    #[must_use]
    pub fn minimap_on(&self) -> bool {
        self.minimap
    }

    /// The scene **ground extent** `[min_x, min_z, max_x, max_z]` (design-plane world
    /// units) — the box the [`MinimapView`] projects into. `None` for an empty mesh.
    #[must_use]
    pub fn world_bounds(&self) -> Option<minimap::WorldBounds> {
        minimap::xz_bounds(&self.mesh.positions)
    }

    /// The live [`MinimapView`] for the current camera pose — the pure overview data
    /// (focus marker + heading cone) the inset paints. `None` for an empty mesh.
    /// The headless overview proof reads this directly (no framebuffer).
    #[must_use]
    pub fn minimap_view(&self) -> Option<MinimapView> {
        Some(MinimapView::compute(self.world_bounds()?, &self.render_camera()))
    }

    // ── Garmin HUD overlays: north rose / compass + dynamic scale bar ──────────

    /// The nominal viewport height (logical px) the `state_json` scale bar falls back
    /// to before the first real paint has recorded one.
    const NOMINAL_VIEW_H: f32 = 480.0;
    /// The scale bar aims for ≈ this fraction-derived pixel length before snapping to a
    /// nice round distance (≈ a fifth of a nominal viewport — a readable Garmin bar).
    const SCALE_TARGET_PX: f32 = 96.0;

    /// Enable the **north rose / compass** HUD (chainable): a corner compass whose N
    /// needle swings with the camera bearing — a Garmin staple. Default off; additive.
    #[must_use]
    pub fn with_compass(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetCompass(on));
        self
    }

    /// Toggle the compass HUD at runtime.
    pub fn set_compass(&mut self, on: bool) {
        let _ = self.update(Msg::SetCompass(on));
    }

    /// Whether the compass HUD is painting this frame.
    #[must_use]
    pub fn compass_on(&self) -> bool {
        self.compass
    }

    /// The live [`CompassRose`] for the current camera pose — the pure north-rose data
    /// (bearing + the cardinal screen angles) the HUD paints. The headless compass proof
    /// reads this directly (no framebuffer).
    #[must_use]
    pub fn compass_rose(&self) -> CompassRose {
        CompassRose::from_camera(&self.render_camera())
    }

    /// Enable the **dynamic scale bar** HUD (chainable): a metres/km bar sized to the
    /// live zoom. Default off; additive.
    #[must_use]
    pub fn with_scale_bar(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetScaleBar(on));
        self
    }

    /// Toggle the scale bar HUD at runtime.
    pub fn set_scale_bar(&mut self, on: bool) {
        let _ = self.update(Msg::SetScaleBar(on));
    }

    /// Whether the scale bar HUD is painting this frame.
    #[must_use]
    pub fn scale_bar_on(&self) -> bool {
        self.scale_bar
    }

    /// The live [`ScaleBar`] for the current camera + a given viewport height (logical
    /// px). Pure — the headless scale proof reads this directly (dolly out ⇒ the round
    /// distance grows). Reuses the map's [`Self::metres_per_design_unit`] calibration.
    #[must_use]
    pub fn scale_bar_view(&self, viewport_h_px: f32) -> ScaleBar {
        ScaleBar::from_camera(&self.render_camera(), viewport_h_px, self.metres_per_du, Self::SCALE_TARGET_PX)
    }

    /// The viewport height the `state_json` scale bar uses: the last painted frame's
    /// height (FC-7 cache) if the view has drawn, else a nominal reference.
    fn scale_view_h(&self) -> f32 {
        let h = self.last_view_h.get();
        if h > 1.0 { h } else { Self::NOMINAL_VIEW_H }
    }

    // ── POI layer (the Google-Maps-style POI markers, projected for the 3D view) ─

    /// **Attach POI markers** (chainable): classify + project a POI node feed onto the
    /// design plane via the *shared* [`extrude::poi_anchors`] seam (POIs classified by
    /// the same [`facett_map::LayerCategory::from_osm_tags`] the 2D layer toggles use —
    /// consumed, not twinned). Pass the SAME `ways` this map was built from so the
    /// markers land on the city. Turn them on with [`Self::show_pois`].
    #[must_use]
    pub fn with_pois(mut self, ways: &[Way], pois: &[extrude::PoiNode]) -> Self {
        self.set_pois(ways, pois);
        self
    }

    /// Set the POI marker feed (classify + project through [`extrude::poi_anchors`]).
    /// Pass the SAME `ways` slice the map extruded so the markers align with the streets.
    pub fn set_pois(&mut self, ways: &[Way], pois: &[extrude::PoiNode]) {
        self.pois = extrude::poi_anchors(ways, pois);
    }

    /// Enable the master **POI layer** (chainable): show the projected POI markers on the
    /// navig view. Default off; additive. Individual categories still gate via
    /// [`Self::set_poi_layer`].
    #[must_use]
    pub fn show_pois(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetShowPois(on));
        self
    }

    /// Toggle the master POI layer at runtime.
    pub fn set_show_pois(&mut self, on: bool) {
        let _ = self.update(Msg::SetShowPois(on));
    }

    /// Whether the POI layer is on (the master toggle).
    #[must_use]
    pub fn pois_shown(&self) -> bool {
        self.show_pois
    }

    /// Set a POI **category**'s visibility on/off (`cat` is a [`facett_map::LayerCategory`]).
    pub fn set_poi_layer(&mut self, cat: facett_map::LayerCategory, on: bool) {
        let _ = self.update(Msg::SetPoiLayer { category: cat.as_str().to_string(), visible: on });
    }

    /// The shared POI [`facett_map::LayerSet`] (per-category visibility) — a host reads
    /// this to sync a `LayerToggles` panel with the map, or mutate it directly.
    #[must_use]
    pub fn poi_set(&self) -> &facett_map::LayerSet {
        &self.poi_set
    }

    /// All projected POI anchors (regardless of visibility) — the full feed.
    #[must_use]
    pub fn pois(&self) -> &[extrude::PoiAnchor] {
        &self.pois
    }

    /// The total number of projected POI markers attached (visible or not).
    #[must_use]
    pub fn poi_total(&self) -> usize {
        self.pois.len()
    }

    /// The number of POI markers that would **draw** this frame: the master toggle is on
    /// AND the marker's category is visible in [`Self::poi_set`]. The RED-when-broken
    /// oracle (`0` when the layer is off / all categories hidden). Surfaced in `state_json`.
    #[must_use]
    pub fn poi_count(&self) -> usize {
        if !self.show_pois {
            return 0;
        }
        self.pois.iter().filter(|p| self.poi_set.is_visible(p.category)).count()
    }

    /// **Attach a route overlay** (chainable): project the car-nav waypoint chain
    /// `waypoints` (ordered stops in the SAME Mercator-unit space as [`Way::points`] —
    /// pass the SAME `ways` slice this map was built from) onto the streets, bake a
    /// bright "your route" ribbon + waypoint markers into the mesh, and keep the
    /// projected polyline so the camera can follow it. See [`Self::set_route`].
    #[must_use]
    pub fn with_route(mut self, ways: &[Way], waypoints: &[(f32, f32)]) -> Self {
        self.set_route(ways, waypoints);
        self
    }

    /// **Set the route overlay.** Projects `waypoints` (Mercator-unit stops, same
    /// space as [`Way::points`] — pass the SAME `ways` this map extruded so the route
    /// lands ON the streets) into the design plane via [`extrude::project_route`],
    /// appends the bright ribbon + waypoint-marker geometry to the basemap mesh
    /// ([`extrude::route_overlay_into`]), and stores the projected [`Route`] for
    /// turn-by-turn camera follow. A route of fewer than two waypoints (or ways with
    /// no footprint) clears the overlay. **Call once** (constructor time) — a second
    /// call appends a second ribbon rather than replacing the first.
    pub fn set_route(&mut self, ways: &[Way], waypoints: &[(f32, f32)]) {
        let pts = extrude::project_route(ways, waypoints);
        if pts.len() < 2 {
            self.route = None;
            self.maneuvers = None;
            return;
        }
        // Bright accent ribbon; a contrasting warm marker so the stops pop. The route
        // colour is stable chrome (not theme-reactive geometry), so the default theme
        // accent is used — no `Ui` is in scope at construction time.
        let ribbon = theme::Theme::default().accent;
        let marker = Color32::from_rgb(255, 210, 90);
        // Scalars hoisted so the `&mut self` borrow `mesh_mut` takes does not overlap
        // the reads of `self.du_per_m`.
        let half_w = extrude::route_half_width(self.du_per_m);
        let marker_r = extrude::route_marker_r(self.du_per_m);
        // `mesh_mut`, not `&mut self.mesh` — THE cache invalidation. Without it this whole
        // ribbon was built, counted, published in `state_json` and never drawn by the GPU
        // lane; see `Map3D::mesh_mut`.
        extrude::route_overlay_into(self.mesh_mut(), &pts, half_w, ribbon, marker, marker_r);
        self.tile_count += 1;
        let route = route::Route::from_design_points(pts);
        // Chevron-flow arrows (opt-in, seam F2): a train of directional glyphs marching
        // along the route, placed by the PURE route::Route::chevron_flow model and injected
        // into the SAME overlay ground mesh (reusing extrude::push_tri3 — no new renderer).
        // Baked with the ribbon at construction; the live phase animates the pure model
        // surfaced in state_json. Bright arrowheads read over the accent ribbon.
        if self.show_chevrons {
            let chevrons = route.chevron_flow(self.chevron_cadence_du, self.chevron_phase);
            let half_w = extrude::route_half_width(self.du_per_m);
            extrude::chevron_flow_into(self.mesh_mut(), &chevrons, half_w, Color32::WHITE);
        }
        // Derive the turn-by-turn maneuver plan from the projected polyline, naming each
        // turn by the nearest street-label anchor (the shared, already-built label set) —
        // the "join to street_label_anchors" seam. Pure + rebuilt in lock-step with the
        // route so the banner/arrows always match the ribbon.
        let names = maneuver::road_names_along(&route, &self.street_labels);
        self.maneuvers = Some(maneuver::maneuvers(&route, &names, self.metres_per_du));
        self.route = Some(route);
    }

    /// Whether a route overlay is attached + followable (≥ 2 waypoints, non-zero length).
    #[must_use]
    pub fn has_route(&self) -> bool {
        self.route.as_ref().is_some_and(|r| !r.is_empty())
    }

    /// The attached route (the projected polyline the camera follows), if any.
    #[must_use]
    pub fn route(&self) -> Option<&route::Route> {
        self.route.as_ref()
    }

    /// Enable **turn-by-turn route-follow** (chainable): the camera chases the route
    /// ribbon (target rides the route, bearing swings to the tangent, tilt held at a
    /// chase pitch) and, each `Frame`, advances along it. No-op without a route set.
    /// Default off; additive (the interactive orbit is unchanged).
    #[must_use]
    pub fn with_route_follow(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetRouteFollow(on));
        self
    }

    /// Toggle turn-by-turn route-follow at runtime (the demo's "follow route" checkbox).
    pub fn set_route_follow(&mut self, on: bool) {
        let _ = self.update(Msg::SetRouteFollow(on));
    }

    /// Whether the camera is following the route (follow mode on AND a route set).
    #[must_use]
    pub fn route_follow_active(&self) -> bool {
        self.route_follow && self.has_route()
    }

    /// The camera's current progress `t ∈ [0, 1]` along the route (0 = start,
    /// 1 = destination) — surfaced so the robot-UI matrix can assert the follow
    /// camera advances.
    #[must_use]
    pub fn route_progress(&self) -> f32 {
        self.route_progress
    }

    /// The **chase tilt** (degrees from top-down) the follow camera holds — a low,
    /// forward-leaning car-nav pitch so the driver sees the road ahead.
    const ROUTE_CHASE_TILT_DEG: f32 = OrbitCamera::CHASE_TILT_DEG;
    /// The **chase dolly distance** (design units) the follow camera holds — close in,
    /// like a bonnet/hood cam, so the ribbon fills the view.
    ///
    /// [`tour::STREET_CHASE_M`] metres through THIS scene's scale, not a raw design-unit
    /// constant. It was `OrbitCamera::CHASE_DIST` (0.55 du) verbatim, and a design unit
    /// is whatever the data's span makes it: that "bonnet cam" sat ~1 km behind the car
    /// in `korp`'s 3 km town window and ~16 km back on a region clip. `tour.rs:80` had
    /// already walked around exactly this constant with a metric distance; the route
    /// follow had not, so the crate held two opinions about where a chase camera goes.
    /// One writer now: [`tour::street_chase_dist`].
    fn route_chase_dist(&self) -> f32 {
        tour::street_chase_dist(self.metres_per_du)
    }
    /// The chevron-flow **march rate** — fractions of a cadence per second the animation
    /// phase advances (seam F2), so the arrow train visibly flows toward the destination.
    const CHEVRON_PHASE_RATE: f32 = 0.6;

    /// Snap the camera to the route pose at the current [`Self::route_progress`]:
    /// target on the route point, bearing along the route tangent, tilt + distance at
    /// the chase constants. Snaps live == goal so a pinned-clock A/B snapshot is
    /// deterministic (no damper trail). No-op without a route.
    fn apply_route_camera(&mut self) {
        let Some(r) = self.route.as_ref() else {
            return;
        };
        if r.is_empty() {
            return;
        }
        let t = self.route_progress.clamp(0.0, 1.0);
        // Resolve the chase goals `(target, bearing, distance, tilt)`:
        //   • lookahead ON (seam F1): PREDICTIVE chase — aim a look-ahead point ahead of
        //     the vehicle and take the bearing from the tangent to it, so the camera leads
        //     into the turn (inherently speed-coupled pose via chase_pose_for_speed).
        //   • lookahead OFF: ride the vertex — target on the route point, bearing the
        //     on-route tangent, pose either the speed-coupled auto-zoom or the fixed close
        //     chase (both preserve the existing follow goldens byte-for-byte).
        let (p, brg, dist, tilt) = if self.lookahead {
            nav::compute_camera_goals(
                self.route_speed,
                t,
                r,
                self.route_chase_dist(),
                Self::ROUTE_CHASE_TILT_DEG,
            )
        } else {
            let p = r.point_at(t);
            let brg = r.bearing_deg_at(t);
            let (dist, tilt) = if self.auto_zoom {
                nav::chase_pose_for_speed(self.route_speed, self.route_chase_dist(), Self::ROUTE_CHASE_TILT_DEG)
            } else {
                (self.route_chase_dist(), Self::ROUTE_CHASE_TILT_DEG)
            };
            (p, brg, dist, tilt)
        };
        let tgt = V3::new(p[0], p[1], p[2]);
        self.cam.target = tgt;
        self.cam.goal_target = tgt;
        self.cam.set_bearing_deg(brg);
        self.cam.set_tilt_deg(tilt);
        self.cam.distance = dist;
        self.cam.goal_distance = dist;
        self.cam.snap_angles_to_goal();
        // Keep the legacy readouts + spin state consistent with a driven camera.
        self.spin = false;
        self.yaw = self.cam.azimuth;
        self.tilt = self.cam.elevation;
    }

    // ── Maneuver model + next-turn banner / on-ribbon arrows (Garmin turn-by-turn) ─

    /// The **turn-by-turn maneuver plan** derived from the route polyline (Depart →
    /// turns → Arrive), if a route is set. Pure data the headless proofs read directly.
    #[must_use]
    pub fn maneuver_plan(&self) -> Option<&maneuver::ManeuverPlan> {
        self.maneuvers.as_ref()
    }

    /// The **next maneuver** at the live [`Self::route_progress`] + its remaining
    /// distance (metres), if any is ahead — the banner's data source.
    #[must_use]
    pub fn next_maneuver(&self) -> Option<(&maneuver::Maneuver, f32)> {
        self.maneuvers.as_ref()?.next_maneuver_at_t(self.route_progress)
    }

    /// The **next-turn banner line** ("↰ Turn left in 200 m onto …") for the live
    /// progress, if a maneuver is ahead. Surfaced in `state_json` so a robot test can
    /// assert the banner text without a framebuffer.
    #[must_use]
    pub fn maneuver_banner(&self) -> Option<String> {
        self.next_maneuver().map(|(m, rem)| m.banner(rem))
    }

    /// Enable the **next-turn banner + on-ribbon turn arrows** (chainable). Default off.
    #[must_use]
    pub fn with_maneuvers(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetShowManeuvers(on));
        self
    }

    /// Toggle the maneuver banner / arrows at runtime.
    pub fn set_maneuvers(&mut self, on: bool) {
        let _ = self.update(Msg::SetShowManeuvers(on));
    }

    /// Whether the maneuver banner / arrows are painting this frame (toggle on AND a
    /// maneuver plan exists).
    #[must_use]
    pub fn maneuvers_on(&self) -> bool {
        self.show_maneuvers && self.maneuvers.is_some()
    }

    // ── Vehicle chevron in the main 3D view ───────────────────────────────────────

    /// Enable the **vehicle chevron** at the live route position (chainable). Default off.
    #[must_use]
    pub fn with_vehicle(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetShowVehicle(on));
        self
    }

    /// Toggle the vehicle chevron at runtime.
    pub fn set_vehicle(&mut self, on: bool) {
        let _ = self.update(Msg::SetShowVehicle(on));
    }

    /// Whether the vehicle chevron paints this frame (toggle on AND a route is set).
    #[must_use]
    pub fn vehicle_on(&self) -> bool {
        self.show_vehicle && self.has_route()
    }

    /// The live vehicle **heading** (compass degrees) — the route tangent at the current
    /// progress; the direction the chevron points. `None` without a route.
    #[must_use]
    pub fn vehicle_bearing_deg(&self) -> Option<f32> {
        self.route.as_ref().filter(|r| !r.is_empty()).map(|r| r.bearing_deg_at(self.route_progress))
    }

    // ── Night mode + auto-zoom-by-speed ───────────────────────────────────────────

    /// Enable **night mode** (chainable): the [`SunSky::night`] preset (dim, cool,
    /// low-ambient — headlight-lit night driving), mirroring the 2D `CartographicNight`
    /// palette. Default off.
    #[must_use]
    pub fn with_night(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetNight(on));
        self
    }

    /// Toggle night mode at runtime (swaps the sun/sky preset).
    pub fn set_night(&mut self, on: bool) {
        let _ = self.update(Msg::SetNight(on));
    }

    /// Whether night mode is on.
    #[must_use]
    pub fn night_on(&self) -> bool {
        self.night
    }

    /// Enable **auto-zoom/tilt by speed** on the follow camera (chainable): faster ⇒
    /// dolly out + flatten (see [`nav::chase_pose_for_speed`]). Default off (keeps the
    /// existing follow goldens byte-stable).
    #[must_use]
    pub fn with_auto_zoom(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetAutoZoom(on));
        self
    }

    /// Toggle auto-zoom-by-speed at runtime.
    pub fn set_auto_zoom(&mut self, on: bool) {
        let _ = self.update(Msg::SetAutoZoom(on));
    }

    /// Whether auto-zoom-by-speed is active.
    #[must_use]
    pub fn auto_zoom_on(&self) -> bool {
        self.auto_zoom
    }

    // ── Predictive look-ahead chase (seam F1) ─────────────────────────────────────

    /// Enable the **predictive look-ahead chase** (chainable): the follow camera aims a
    /// point ahead along the route and takes its bearing from the tangent to it (see
    /// [`nav::compute_camera_goals`]), so it *anticipates* a turn instead of snapping at
    /// the vertex. Default off (keeps the vertex-tangent follow goldens byte-stable).
    #[must_use]
    pub fn with_lookahead(mut self, on: bool) -> Self {
        let _ = self.update(Msg::SetLookahead(on));
        self
    }

    /// Toggle the predictive look-ahead chase at runtime.
    pub fn set_lookahead(&mut self, on: bool) {
        let _ = self.update(Msg::SetLookahead(on));
    }

    /// Whether the predictive look-ahead chase is active.
    #[must_use]
    pub fn lookahead_on(&self) -> bool {
        self.lookahead
    }

    /// The **anticipatory bearing** (compass degrees) the look-ahead chase aims — the
    /// tangent to the look-ahead point at the live speed + progress. Surfaced in
    /// `state_json` so a robot test can assert the camera leads the turn without a
    /// framebuffer. `None` without a route.
    #[must_use]
    pub fn lookahead_bearing_deg(&self) -> Option<f32> {
        let r = self.route.as_ref().filter(|r| !r.is_empty())?;
        let (_, brg, _, _) = nav::compute_camera_goals(
            self.route_speed,
            self.route_progress,
            r,
            self.route_chase_dist(),
            Self::ROUTE_CHASE_TILT_DEG,
        );
        Some(brg)
    }

    // ── Chevron-flow arrows (seam F2) ─────────────────────────────────────────────

    /// Enable the **chevron-flow arrows** baked into the route mesh (chainable): a train
    /// of directional glyphs marching along the ribbon. Default off. Set this BEFORE the
    /// route (`with_chevrons(true).with_route(..)`) so the glyphs bake with the ribbon —
    /// like the ribbon itself, the overlay is laid once at route-set time.
    #[must_use]
    pub fn with_chevrons(mut self, on: bool) -> Self {
        self.show_chevrons = on;
        self
    }

    /// Whether the chevron-flow arrows are enabled AND a route is attached.
    #[must_use]
    pub fn chevrons_on(&self) -> bool {
        self.show_chevrons && self.has_route()
    }

    /// The chevron-flow **placements** at the live phase — the pure marching train (each
    /// a position on the ribbon + the bearing it points). Empty without a route / when
    /// off. The robot-drivable oracle behind `state_json`'s `chevron_count`.
    #[must_use]
    pub fn chevron_flow(&self) -> Vec<route::FlowChevron> {
        if !self.show_chevrons {
            return Vec::new();
        }
        match self.route.as_ref() {
            Some(r) if !r.is_empty() => r.chevron_flow(self.chevron_cadence_du, self.chevron_phase),
            _ => Vec::new(),
        }
    }

    /// How many chevron-flow arrows the route carries at the live phase (`0` when off /
    /// no route) — the red-when-broken `state_json` count.
    #[must_use]
    pub fn chevron_count(&self) -> usize {
        self.chevron_flow().len()
    }

    /// Attach (or replace) the znippy-rabbit logo overlay (chainable).
    pub fn with_logo(self, logo: RabbitLogo3D) -> Self {
        *self.logo.borrow_mut() = Some(logo);
        self
    }

    /// Pin the animation clock (seconds; FC-7) — the orbit becomes a pure function.
    /// Routes through the FC-2 [`update`](Self::update) mutation path.
    pub fn set_clock(&mut self, seconds: f64) {
        let _ = self.update(Msg::SetClock(seconds));
    }
    /// Builder form of [`set_clock`](Self::set_clock).
    pub fn with_clock(mut self, seconds: f64) -> Self {
        self.set_clock(seconds);
        self
    }
    /// Advance the injected clock by `dt` seconds (deterministic stepping).
    pub fn tick(&mut self, dt: f64) {
        self.set_clock(self.clock_s.unwrap_or(0.0) + dt);
    }

    /// Adopt the input feel for an OS (orbit/pan/zoom sensitivity + damping +
    /// natural-scroll). Reads facett-core's look presets so a macOS trackpad and
    /// a Windows wheel each feel native. Chainable.
    pub fn with_os_feel(mut self, os: egui::os::OperatingSystem) -> Self {
        self.cam.feel = match os {
            egui::os::OperatingSystem::Mac | egui::os::OperatingSystem::IOS => InputFeel::macos(),
            _ => InputFeel::windows(),
        };
        self
    }

    /// Snap the camera to a canonical view-angle preset (top / iso / oblique /
    /// front). Stops the auto-spin so the chosen angle holds. Routes through the
    /// FC-2 [`update`](Self::update) mutation path.
    pub fn view_preset(&mut self, p: ViewPreset) {
        let _ = self.update(Msg::Preset(p));
    }

    /// Reset the camera to the default framing. Routes through [`update`](Self::update).
    pub fn reset_view(&mut self) {
        let _ = self.update(Msg::Reset);
    }

    /// The frame the camera projected last (visible triangle count after cull). On
    /// the GPU backend this count is SAMPLED — at most a few frames stale during
    /// continuous camera motion; see `RenderStats::visible_triangles`.
    pub fn visible_triangles(&self) -> usize {
        self.last_visible_tris.get()
    }

    /// Count the **building mesh vertices currently inside the view frustum** — the
    /// "are the houses in this shot?" oracle the Sigtuna circle is all about. Reuses
    /// the live [`render_camera`](Self::render_camera) + the shared perspective
    /// projection: a risen (wall / roof) vertex counts when it projects in FRONT of
    /// the near plane AND inside the square viewport bound. `> 0` ⇒ extruded houses
    /// are on screen at this pose. Surfaced in `state_json` as `buildings_visible`,
    /// so a headless / robot-UI matrix can assert "at the air-circle altitude the
    /// houses are visible" as DATA (RED-when-broken: neutralise the extrusion → 0).
    #[must_use]
    pub fn buildings_visible(&self) -> usize {
        let cam = self.render_camera();
        let center = (0.0f32, 0.0f32);
        let half_h = 1.0f32;
        self.risen_house_positions()
            .filter(|p| {
                let pr = cam.project(V3::new(p[0], p[1], p[2]), center, half_h);
                pr.visible && pr.x.abs() <= half_h && pr.y.abs() <= half_h
            })
            .count()
    }

    /// **How tall the houses actually are ON SCREEN, in pixels** — the median apparent
    /// height of the extruded walls at the live [`render_camera`](Self::render_camera)
    /// pose, for a viewport `viewport_h_px` tall.
    ///
    /// This exists because [`buildings_visible`](Self::buildings_visible) cannot answer
    /// the question a user actually asks. MEASURED on the shipped korp path, a real
    /// focused Xvfb window against the live korp-server (2026-08-03, RTX 4090,
    /// Stockholm z12, 2 802 173 triangles): `buildings_visible` reported **340 581**
    /// houses in shot while the pane drew a flat white pancake, because at that scene
    /// span each house stands **0.66 px** high. Both numbers are true. Only one of them
    /// is what "the buildings are 3D" means, and it is not the count — a vertex inside
    /// the frustum is not a building you can see.
    ///
    /// Applied output, not a knob: each risen vertex is projected through the same
    /// camera the renderer uses, together with its own ground shadow `(x, 0, z)`, and
    /// the pixel distance between them IS the wall the viewer sees. The median over the
    /// visible ones, so one freak skyscraper cannot speak for a town. `0.0` when nothing
    /// risen is on screen.
    #[must_use]
    pub fn skyline_screen_height_px(&self, viewport_h_px: f32) -> f32 {
        self.skyline_percentile_px(viewport_h_px, 0.5)
    }

    /// **The `q`-quantile of the same on-screen wall heights** [`skyline_screen_height_px`]
    /// takes the median of — the ONE writer both read (L5), so the median cannot drift
    /// from the tail.
    ///
    /// # The survivorship bias this used to have, and why it made the oracle lie
    ///
    /// The sample used to be restricted to walls whose **top vertex lands inside the
    /// viewport**. That is the sentence that broke it: dolly a perspective camera down
    /// toward street level and the nearby roofs — the only ones that got big — leave the
    /// frame through the TOP and are *deleted from the sample*, so the median
    /// re-converges on the distant houses at the horizon and barely moves. The
    /// measurement selected against exactly the houses whose growth it was supposed to
    /// report. MEASURED on the synthetic 2.4 km town, `distance` 3.2 → 0.06 design units
    /// (a 53x dolly), OLD rule: median **2.99 px → 5.04 px** (1.7x for a 53x move) while
    /// **max** went 8.00 px → 98.79 px. A number that moves 1.7x when the thing it
    /// measures moves 53x is not a slow oracle, it is a wrong one, and it is what let
    /// `skyline_px_per_1000 = 8.80` be published for a pane with no visible house on it.
    ///
    /// **The rule now is: a wall is in the sample when its BASE — the footprint corner
    /// standing on the ground — is inside the viewport.** That is the honest reading of
    /// "how tall are the houses I am looking at": the house is in frame if its footprint
    /// is in frame, and its apparent height is the full base→top distance whether or not
    /// the roof has left the top of the screen. Both projections must still be in FRONT
    /// of the near plane (a wall behind the camera has no apparent height), and the top
    /// is no longer required to be *inside* the frame — only to project.
    ///
    /// The consequence is intended and worth stating: near street level this reports
    /// heights far larger than the viewport, because a house one block away really does
    /// stand taller than the screen. That is the signal. Clamping it back to the viewport
    /// is how the bias got in.
    ///
    /// `q` is clamped to `[0, 1]`; `0.0` when nothing risen is on screen.
    #[must_use]
    pub fn skyline_percentile_px(&self, viewport_h_px: f32, q: f32) -> f32 {
        let cam = self.render_camera();
        // `project` normalises y to ±half_h over the FULL viewport, so one unit of
        // projected y is half the viewport in pixels.
        let to_px = viewport_h_px * 0.5;
        let mut px: Vec<f32> = self
            .risen_house_positions()
            .filter_map(|p| Self::apparent_wall_px(&cam, p, to_px))
            .collect();
        if px.is_empty() {
            return 0.0;
        }
        px.sort_by(f32::total_cmp);
        let i = ((px.len() - 1) as f32 * q.clamp(0.0, 1.0)).round() as usize;
        px[i]
    }

    /// Total count of **risen HOUSE vertices** in the scene, regardless of the camera —
    /// the extruded-building geometry budget. `0` ⇒ no OSM `building` ring stood up (a
    /// flat field, a viewport with no houses in it, or the extrusion was neutralised).
    /// Surfaced as `building_vertices`.
    ///
    /// # It used to count everything that stood up, and that is a different number
    ///
    /// This was `positions.iter().filter(|p| p[1] > rise_eps).count()`, and
    /// [`extrude::prism_height_for`] raises **every** closed ring that is not
    /// `water`/`park`/`coastline` — a `landuse=industrial` block, a `leisure` pitch, an
    /// untagged closed way — as well as a relief ground mesh, whose vertices stand real
    /// metres above zero. So it answered "how much of this scene stands up", under a name
    /// that promised houses. The owner's 2026-08-06 log printed `risen = 36 672` beside
    /// `building_ways = 0`: the second number was right and this one was the lie.
    ///
    /// It now reads [`extrude::Mesh3::building`], stamped once per way by the extruder,
    /// which is the only code that knows. [`Self::risen_vertices`] is the old, class-blind
    /// quantity, kept under a name that says what it is.
    #[must_use]
    pub fn building_vertices(&self) -> usize {
        self.risen_house_positions().count()
    }

    /// **Everything in the mesh that stands up, house or not** — the class-blind reading
    /// [`Self::building_vertices`] used to give. Terrain relief, raised untagged rings and
    /// houses all count.
    ///
    /// It is a real quantity with a real user — the camera fitter has to frame whatever is
    /// there, including a scene of nothing but raised industrial blocks — but it is not a
    /// house census, and the two must never again share one name.
    #[must_use]
    pub fn risen_vertices(&self) -> usize {
        let eps = self.rise_eps;
        self.mesh.positions.iter().filter(|p| p[1] > eps).count()
    }

    /// **The ONE definition of "a risen house vertex"** (LAW 5): above this scene's own
    /// [`Self::building_rise_eps`] *and* stamped as building geometry by the extruder.
    /// Every house observable on this type goes through it.
    fn risen_house_positions(&self) -> impl Iterator<Item = &[f32; 3]> + '_ {
        let eps = self.rise_eps;
        let cls = &self.mesh.building;
        self.mesh
            .positions
            .iter()
            .enumerate()
            .filter(move |(i, p)| p[1] > eps && cls.get(*i).copied().unwrap_or(false))
            .map(|(_, p)| p)
    }

    /// **Houses flanking the street** at the current camera pose — `(left, right)`
    /// counts of risen (building) mesh vertices sitting on the LEFT and RIGHT of the
    /// camera's forward axis, within a band around the look-at target. The car-nav
    /// "am I driving DOWN THE STREET, between the houses?" oracle: on a real street-level
    /// drive pose BOTH sides are `> 0` (buildings line the road on either hand); a camera
    /// that drifted OUT of the town, or one facing backwards into open ground, loses one
    /// or both sides. Reuses the live [`render_camera`](Self::render_camera) basis, so it
    /// reads the actual flown pose. Surfaced in `state_json` as `houses_left`/`houses_right`.
    ///
    /// The band is scaled by the camera distance (a street-chase dolly), so it adapts to
    /// the scene scale: vertices within `± LATERAL·distance` sideways and `[−distance,
    /// AHEAD·distance]` along the forward axis (beside + ahead of the car) are counted.
    /// **How many pixels tall this one risen vertex's wall is, at this camera** — or
    /// `None` when it is not a house in view at all.
    ///
    /// THE SAMPLE RULE (L5, ONE writer): the house is in the sample when its FOOTPRINT
    /// is on screen. Selecting on the roof is what deleted every house that grew — see
    /// [`Self::skyline_percentile_px`], which is the reason this predicate exists and
    /// the reason it may not be copied.
    ///
    /// `to_px` is `viewport_h_px * 0.5`, because [`OrbitCamera::project`] normalises `y`
    /// to `±1` over the FULL viewport.
    fn apparent_wall_px(cam: &OrbitCamera, p: &[f32; 3], to_px: f32) -> Option<f32> {
        let (center, half_h) = ((0.0f32, 0.0f32), 1.0f32);
        let base = cam.project(V3::new(p[0], 0.0, p[2]), center, half_h);
        if !base.visible || base.x.abs() > half_h || base.y.abs() > half_h {
            return None; // its footprint is not in frame — not a house in view
        }
        let top = cam.project(V3::new(p[0], p[1], p[2]), center, half_h);
        if !top.visible {
            return None; // behind the near plane: no apparent height at all
        }
        Some((top.y - base.y).abs() * to_px)
    }

    /// The apparent wall height below which a "house" is not a house anybody can see.
    /// One pixel: at less than that the wall and its own shadow land on the same row of
    /// the framebuffer and the pane is a flat plan view, whatever the counters say.
    pub const RESOLVABLE_WALL_PX: f32 = 1.0;

    /// **[`Self::flanking_houses`], but only the houses you can actually SEE** —
    /// `(left, right)` counts restricted to walls at least [`Self::RESOLVABLE_WALL_PX`]
    /// tall on a `viewport_h_px`-tall viewport.
    ///
    /// `flanking_houses` is the car-nav "am I driving between the houses?" claim, and it
    /// is a count of geometry that EXISTS inside a world-space band. That is `hus3d-1`'s
    /// shape exactly: on the drive where `buildings_visible` reported 340 581 houses over
    /// a flat white pancake, every one of those houses stood 0.66 px and this oracle
    /// would have answered "yes, both sides" just as confidently. The band test cannot
    /// tell "beside the car" from "resolvable", so it is asked here together with the
    /// projection that can — the same [`Self::apparent_wall_px`] the skyline reads.
    ///
    /// Surfaced in `state_json` as `houses_left_px`/`houses_right_px` at the same
    /// 1000-pixel reference viewport as `skyline_px_per_1000`, so a robot drive can
    /// assert the claim instead of the geometry budget.
    #[must_use]
    pub fn flanking_houses_resolvable(&self, viewport_h_px: f32) -> (usize, usize) {
        self.flanking_houses_filtered(Some(viewport_h_px))
    }

    #[must_use]
    pub fn flanking_houses(&self) -> (usize, usize) {
        self.flanking_houses_filtered(None)
    }

    /// The ONE writer behind [`Self::flanking_houses`] and
    /// [`Self::flanking_houses_resolvable`]: the street-band split, with the
    /// resolvability test applied when a viewport is supplied. Two copies of the band
    /// arithmetic is how the two answers would drift.
    fn flanking_houses_filtered(&self, viewport_h_px: Option<f32>) -> (usize, usize) {
        let cam = self.render_camera();
        let (fwd, right, _up) = cam.basis();
        // Flatten to the ground plane so a tilted chase pitch doesn't bias the split.
        let flat = |v: V3| {
            let f = V3::new(v.x, 0.0, v.z);
            if f.len() > 1e-6 { f.normalized() } else { v }
        };
        let (fwd, right) = (flat(fwd), flat(right));
        let t = cam.target;
        let d = cam.distance.max(1e-4);
        const LATERAL: f32 = 4.0; // sideways reach, in camera distances
        const AHEAD: f32 = 6.0; // forward reach ahead of the car
        let (mut left, mut rgt) = (0usize, 0usize);
        for p in self.risen_house_positions() {
            let rel = V3::new(p[0] - t.x, 0.0, p[2] - t.z);
            let along = rel.dot(fwd);
            let side = rel.dot(right);
            if along < -d || along > AHEAD * d || side.abs() > LATERAL * d {
                continue; // outside the street band around the car
            }
            if let Some(h_px) = viewport_h_px {
                // …and it has to be a wall you can SEE, not merely one that is there.
                match Self::apparent_wall_px(&cam, p, h_px * 0.5) {
                    Some(px) if px >= Self::RESOLVABLE_WALL_PX => {}
                    _ => continue,
                }
            }
            if side < 0.0 {
                left += 1;
            } else {
                rgt += 1;
            }
        }
        (left, rgt)
    }

    /// **Roof accent** (chainable, opt-in, additive) — lighten the up-facing top-cap
    /// triangles of the extruded prisms toward a warm highlight, in proportion to
    /// their height, so the ROOFS read distinct from the shaded walls: the
    /// Google-Maps-3D massing look where a town reads as lit rooftops over darker
    /// facades. OFF by default, so the shared extrusion path and every render-proof
    /// golden stay byte-stable (L2); a host opts in per scene (the `sigtuna_demo`
    /// does). `strength` ∈ `[0, 1]` is the peak lerp toward the highlight, applied to
    /// the tallest roofs (ground-cover caps at `y ≈ 0` are left untouched).
    #[must_use]
    pub fn with_roof_accent(mut self, strength: f32) -> Self {
        self.apply_roof_accent(strength);
        self
    }

    /// Recolour the extruded roof caps in place — see [`Self::with_roof_accent`].
    pub fn apply_roof_accent(&mut self, strength: f32) {
        let strength = strength.clamp(0.0, 1.0);
        if strength <= 0.0 {
            return;
        }
        // Normalise the accent against the tallest risen vertex, so the peak highlight
        // lands on the tallest roofs and shorter houses brighten proportionally.
        let eps = self.rise_eps;
        // Read BEFORE the door: a flat scene changes nothing, so it must not spend a
        // cache invalidation (and a full payload rebuild) to find that out.
        let ymax = self.mesh.positions.iter().map(|p| p[1]).fold(0.0f32, f32::max);
        if ymax <= eps {
            return; // nothing risen → nothing to accent (flat scene)
        }
        // A warm off-white roof highlight (Google-Maps-3D rooftops read lighter +
        // slightly warm against the cooler lit walls).
        const HL: (f32, f32, f32) = (250.0, 244.0, 230.0);
        // `mesh_mut`, not `&mut self.mesh` — THE cache invalidation. Without it this
        // recolour reached every count and no pixel on the GPU lane; see `Map3D::mesh_mut`.
        let mesh = self.mesh_mut();
        let n_tris = mesh.indices.len() / 3;
        for t in 0..n_tris {
            let i0 = mesh.indices[t * 3] as usize;
            let i1 = mesh.indices[t * 3 + 1] as usize;
            let i2 = mesh.indices[t * 3 + 2] as usize;
            // Up-facing cap? All three normals point +Y. Walls carry horizontal
            // normals, bottom caps point −Y — both left untouched.
            let up = |i: usize| mesh.normals[i][1] > 0.9;
            if !(up(i0) && up(i1) && up(i2)) {
                continue;
            }
            // Cap height (avg of the three vertices). Flat ground-cover caps sit at
            // ~0 ⇒ f ≈ 0 (unchanged); real risen roofs brighten, tallest the most.
            let h = (mesh.positions[i0][1] + mesh.positions[i1][1] + mesh.positions[i2][1]) / 3.0;
            if h <= eps {
                continue;
            }
            let f = (h / ymax).clamp(0.0, 1.0) * strength;
            let c = mesh.colors[t];
            let lerp = |a: f32, b: f32| (a + (b - a) * f) as u8;
            mesh.colors[t] = Color32::from_rgb(
                lerp(c.r() as f32, HL.0),
                lerp(c.g() as f32, HL.1),
                lerp(c.b() as f32, HL.2),
            );
        }
    }
    /// Smoothed FPS measured by the live UI path.
    pub fn fps(&self) -> f32 {
        self.fps
    }

    /// **Pure** read of the held keyboard navigation as a `Vec<Msg>`: WASD/arrows
    /// orbit, +/- zoom, R reset, 1-4 view presets. Reads egui key state only — the
    /// returned [`Msg`]s are applied by [`update`](Self::update) (FC-2 / FC-9).
    fn read_keys(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let step = 0.06_f32; // orbit radians per held frame
        let mut msgs: Vec<Msg> = Vec::new();
        ui.input(|i| {
            use egui::Key;
            // Orbit (WASD + arrows).
            if i.key_down(Key::A) || i.key_down(Key::ArrowLeft) {
                msgs.push(Msg::KeyOrbit { daz: step, del: 0.0 });
            }
            if i.key_down(Key::D) || i.key_down(Key::ArrowRight) {
                msgs.push(Msg::KeyOrbit { daz: -step, del: 0.0 });
            }
            if i.key_down(Key::W) || i.key_down(Key::ArrowUp) {
                msgs.push(Msg::KeyOrbit { daz: 0.0, del: step });
            }
            if i.key_down(Key::S) || i.key_down(Key::ArrowDown) {
                msgs.push(Msg::KeyOrbit { daz: 0.0, del: -step });
            }
            // Zoom.
            if i.key_down(Key::Plus) || i.key_down(Key::Equals) {
                msgs.push(Msg::Dolly { scroll: 40.0, cursor: None });
            }
            if i.key_down(Key::Minus) {
                msgs.push(Msg::Dolly { scroll: -40.0, cursor: None });
            }
            // Reset + presets (edge-triggered).
            if i.key_pressed(Key::R) {
                msgs.push(Msg::Reset);
            }
            // Re-arm the cinematic tour autopilot (hand the stick back). Manual input
            // above seizes control; T flies the Sigtuna tour again.
            if i.key_pressed(Key::T) {
                msgs.push(Msg::SetTour(true));
            }
            if i.key_pressed(Key::Num1) {
                msgs.push(Msg::Preset(ViewPreset::Top));
            }
            if i.key_pressed(Key::Num2) {
                msgs.push(Msg::Preset(ViewPreset::Isometric));
            }
            if i.key_pressed(Key::Num3) {
                msgs.push(Msg::Preset(ViewPreset::Oblique));
            }
            if i.key_pressed(Key::Num4) {
                msgs.push(Msg::Preset(ViewPreset::Front));
            }
        });
        msgs
    }

    /// Whether the live camera is still easing toward its damping goals (the
    /// pre-update twin of [`OrbitCamera::update`]'s "moving" return). Read by the
    /// pure [`view`](Self::view) to decide whether to request another repaint.
    fn cam_is_settling(&self) -> bool {
        let c = &self.cam;
        (c.azimuth - c.goal_azimuth).abs() > 1e-5
            || (c.elevation - c.goal_elevation).abs() > 1e-5
            || (c.distance - c.goal_distance).abs() > 1e-5
            || c.target.sub(c.goal_target).len() > 1e-5
    }

    /// Refresh the serializable [`Map3dModel`] snapshot from the live camera engine +
    /// the animation/mode flags. Called at the end of every [`update`](Self::update)
    /// (and each finalizing constructor) so [`state`](facett_core::Elm::state) always
    /// hands back a current observable snapshot.
    fn sync_model(&mut self) {
        let c = self.render_camera();
        self.model = Map3dModel {
            target: [c.target.x, c.target.y, c.target.z],
            azimuth: c.azimuth,
            elevation: c.elevation,
            distance: c.distance,
            fov_y: c.fov_y,
            spin: self.spin,
            spin_rate: self.spin_rate,
            clock_s: self.clock_s,
            water_override: self.water_override,
            planar_reflection: self.planar_reflection,
        };
    }

    /// The extruded basemap mesh (positions + normals + indices).
    pub fn mesh(&self) -> &Mesh3 {
        &self.mesh
    }

    /// **Test hook** — swap in a hand-built mesh (e.g. the COPLANAR z-fight negative
    /// control: a green cap forced onto the terrain plane). Resets the cached GPU
    /// payload so the new mesh re-uploads. NOT for production use.
    ///
    /// # Why `tile_count` is [`TILE_COUNT_HAS_GEOMETRY`] and not 1
    ///
    /// The pane refuses to draw at `tile_count <= 1` and paints `"no ways to extrude"`
    /// instead ([`Self::ui`], the `facet-map3d-1` error frame; the gate landed
    /// 2026-07-31 in `c92ce2ab`). A hook that left the count at `1` therefore handed the
    /// caller's mesh straight into the refusal branch: the framebuffer contained ERROR
    /// TEXT, not the scene, and every pixel measured off it was measured off a sentence.
    ///
    /// MEASURED 2026-08-22, `tests/zfight.rs` on the adapter `egui_kittest` picks
    /// (`llvmpipe (LLVM 21.1.8) [Cpu/Vulkan]` — it prefers a CPU adapter, so NOT this
    /// box's 4090): the coplanar control rendered **407** ground
    /// pixels against the fixed scene's **97 994** — 0.4 % — so it could not flicker, the
    /// discriminator read `0.0000` on both arms, and `cargo test -p facett-map3d
    /// --features wgpu` was red on `main` from 2026-07-31 to 2026-08-22 for a reason that
    /// had nothing to do with z-fighting. The screenshot is the whole diagnosis: it says
    /// "no ways to extrude" across the middle of the frame.
    ///
    /// The count is NAMED rather than written as a literal `2` (L5): a hook that has to
    /// spell the refusal's other side by hand is one edit away from spelling the sentinel
    /// again, which is the whole defect above.
    #[doc(hidden)]
    pub fn set_mesh_for_test(&mut self, mesh: Mesh3) {
        self.tile_count = TILE_COUNT_HAS_GEOMETRY;
        *self.mesh_mut() = mesh;
    }

    /// **THE ONE DOOR TO A MESH MUTATION** — hands out `&mut Mesh3` and drops the
    /// backend's per-scene upload cache, because those are one act and not two.
    ///
    /// # The defect this closes
    ///
    /// `WgpuVelloBackend::paint` builds its GPU payload behind `if
    /// self.cache.borrow().is_none()` (backend.rs:683) — **once**, and again only after
    /// [`backend::RenderBackend::reset_cache`]. So a mutation of `self.mesh` that does not
    /// invalidate that cache never reaches the device at all: the CPU-side mesh grows,
    /// every count in `state_json` grows with it, and the 4090 goes on drawing the payload
    /// it built on the first frame.
    ///
    /// Three writers touched `self.mesh`; **one** of them invalidated
    /// ([`Self::set_mesh_for_test`]) and two did not:
    ///
    /// * [`Self::set_route`] — the route ribbon, the stop markers and the chevron train.
    ///   A route set after the pane's first paint was invisible on the GPU lane. Its
    ///   `state_json` reported the triangles, `triangle_count()` reported them, and
    ///   nothing was on screen. That is the shape of defect this tree keeps catching:
    ///   every cheaper signal green, only the device wrong.
    /// * [`Self::apply_roof_accent`] — same, for the roof highlight.
    ///
    /// Fixing it by construction rather than by adding two `reset_cache()` calls is the
    /// point (LAW 5): a fourth mutator added later gets the invalidation because there is
    /// nowhere else to get a `&mut Mesh3` from, not because its author remembered.
    ///
    /// The `&self` `reset_cache` runs BEFORE the `&mut` borrow is handed out, so the
    /// cache is already gone by the time a caller can touch a triangle — there is no
    /// window in which a paint could see a mutated mesh behind a live cache.
    fn mesh_mut(&mut self) -> &mut Mesh3 {
        self.backend.reset_cache();
        &mut self.mesh
    }
    /// Footprint/tile count the mesh was extruded from.
    pub fn tile_count(&self) -> usize {
        self.tile_count
    }
    /// Triangle count of the extruded basemap mesh.
    pub fn triangle_count(&self) -> usize {
        self.mesh.triangle_count()
    }

    /// Rotate a point by yaw (Y) then tilt (X). Returns `(x, y, z)` view-space.
    /// **Legacy** helper kept for callers that did fixed-orbit projection before
    /// the [`OrbitCamera`]; the live render path now uses the camera. Identical
    /// convention to [`RabbitLogo3D`] so the logo + map agree.
    pub fn rotate(p: [f32; 3], yaw: f32, tilt: f32) -> [f32; 3] {
        let (sy, cy) = yaw.sin_cos();
        let x1 = p[0] * cy + p[2] * sy;
        let z1 = -p[0] * sy + p[2] * cy;
        let (st, ct) = tilt.sin_cos();
        let y2 = p[1] * ct - z1 * st;
        let z2 = p[1] * st + z1 * ct;
        [x1, y2, z2]
    }

    /// The camera actually projected this frame: the live [`Self::cam`], but with
    /// the auto-spin (and any injected clock) folded into its azimuth so a fixed
    /// clock yields a pure, reproducible orbit. Pan/zoom/preset state is preserved.
    pub fn render_camera(&self) -> OrbitCamera {
        let mut c = self.cam.clone();
        if let Some(tour) = self.tour.as_ref() {
            // The cinematic Sigtuna tour: FLY the three-phase path as a pure function
            // of the clock (FC-7). Overrides manual pose / spin / flythrough /
            // route-follow. Uses the pinned clock when present (deterministic tests),
            // else the live `fly_clock` the Frame tick advances. `settle` applies the
            // elevation / ground / dolly clamps so the tour can't sink or gimbal-flip.
            let t = self.clock_s.unwrap_or(self.fly_clock);
            tour.drive(&mut c, t);
            c.settle();
            return c;
        }
        if self.flythrough_on {
            // Cinematic auto-pilot: FLY the framed city as a pure function of the clock
            // (FC-7). Overrides the manual pose + auto-spin. Uses the pinned clock when
            // present (deterministic tests), else the live `fly_clock` the Frame tick
            // advances. `settle` applies the elevation / ground clamps.
            let framing = self.framing.unwrap_or_else(flythrough::CityFraming::unit);
            let t = self.clock_s.unwrap_or(self.fly_clock);
            flythrough::Flythrough::new(framing).drive(&mut c, t);
            c.settle();
            return c;
        }
        if self.spin {
            let spin = self.clock_s.map(|t| t as f32 * self.spin_rate).unwrap_or(0.0);
            c.azimuth += spin;
            c.goal_azimuth = c.azimuth;
        }
        c
    }

    /// Build the **backend-neutral** [`backend::RenderScene`] for this frame — the
    /// input every [`backend::RenderBackend`] draws from. Borrows the mesh + water
    /// grid (zero-copy) and snapshots the resolved camera + sun + feature flags.
    fn build_scene(&self, rect: egui::Rect, bg: Color32) -> backend::RenderScene<'_> {
        // `mut` is consumed only by the `wgpu` block below — without the feature the
        // binding is final, so the attribute keeps the featureless build warning-free.
        #[cfg_attr(not(feature = "wgpu"), allow(unused_mut))]
        let mut flags = backend::SceneFlags {
            water_on: self.water_active(),
            // The costly pass is gated by ALTITUDE (`LodTier::Ground`), the surface
            // itself is not — see `water_active`. Both used to hang off the same
            // altitude test, so 1084 built water triangles went undrawn from 1.6 km up.
            planar_reflection: self.planar_reflection && self.reflective_water_active(),
            ..Default::default()
        };
        #[cfg(feature = "wgpu")]
        {
            flags.sky = self.sky;
            flags.ssao = self.ssao;
            flags.bloom = self.bloom;
            flags.soft_shadows = self.soft_shadows;
            flags.shadow_strength = self.shadow_strength;
        }
        let camera = self.render_camera();
        // GFX_V2 item 6: the post stack is a property of the VIEW, not a separate
        // switch. A view that satisfies every 2D constraint bypasses the whole
        // screen-space chain here, in one place, rather than each pass checking for
        // itself — which is why 2D costs nothing extra.
        let flags = flags.constrain_to_view(&camera.view());
        backend::RenderScene {
            mesh: &self.mesh,
            water: &self.water,
            camera,
            sun: self.sun,
            viewport: backend::Viewport::from_rect(rect),
            bg,
            flags,
            sky_lut: self.sky_lut.clone(),
            clock_s: self.clock_s.unwrap_or(0.0),
            du_per_m: self.du_per_m,
            // The per-frame overlay (V2.5 D.3) — already world-space, both lanes draw
            // it as-is. Borrowed, like the mesh: zero per-frame copies here.
            overlay: self.overlay.as_ref().map(|o| &o.world),
            overlay_emissive: self.overlay.as_ref().map_or(&[][..], |o| o.emissive.as_slice()),
        }
    }

    /// Project + frustum/back-face cull + depth-sort + light the extruded mesh
    /// into screen-space shaded faces via the **perspective orbit camera**. Pure
    /// (no egui) so the self-test can count *visible lit triangles* without a UI.
    /// Returns `(camera_depth, [3 px], shaded_color)` per visible tri, far→near.
    ///
    /// `center` is the viewport centre in pixels; `scale` is the projection
    /// half-height in pixels (≈ half the viewport height) — the perspective focal
    /// scale. `base` is the **atmospheric fog colour** (the background): distant
    /// faces are blended toward it so the city recedes into haze — the subtle depth
    /// cue that, with the directional light, makes the model read as a 3D city
    /// rather than a flat field of prisms. The per-triangle source colour drives the
    /// lit tint; fog only desaturates it with distance.
    pub fn shade_faces(&self, center: Pos2, scale: f32, base: Color32) -> Vec<(f32, [Pos2; 3], Color32)> {
        self.shade_faces_clip(center, scale, base, true)
    }

    /// **Diagnostic / regression variant** of [`Self::shade_faces`] with the step-1
    /// view-space near clip togglable. `near_clip = true` is the production path
    /// (the FIX); `near_clip = false` reproduces the *pre-fix* pipeline — the
    /// brittle "drop the whole triangle if any vertex is behind the eye" +
    /// "screen-extent guard" heuristics — so a fail-on-bug / pass-on-fix test can
    /// inject the same camera pose into both and compare the escaping geometry the
    /// rect-scissor is then handed. Not for production callers; the bug oracle test
    /// uses it to prove the fix changes the geometry that reaches the rasterizer.
    pub fn shade_faces_clip(
        &self,
        center: Pos2,
        scale: f32,
        base: Color32,
        near_clip: bool,
    ) -> Vec<(f32, [Pos2; 3], Color32)> {
        if !near_clip {
            return self.shade_faces_legacy(center, scale, base);
        }
        shade_mesh_faces(&self.mesh, &self.render_camera(), center, scale, base)
    }

    /// The **pre-fix** projection pipeline (diagnostic only) — kept so the bug
    /// oracle test can reproduce the geometry the brittle heuristics produced. It
    /// projects all three raw vertices, drops the triangle whole if any vertex is
    /// behind the eye, and applies the old "screen-extent guard" (reject a face
    /// whose projected extent dwarfs the viewport). A face *straddling* the near
    /// plane that survives the guard still smears far off the rect — exactly the
    /// escaping geometry the step-1 near clip eliminates.
    fn shade_faces_legacy(&self, center: Pos2, scale: f32, base: Color32) -> Vec<(f32, [Pos2; 3], Color32)> {
        let cam = self.render_camera();
        let half_h = scale.max(1.0);
        let centre = (center.x, center.y);
        let proj: Vec<Projected> = self
            .mesh
            .positions
            .iter()
            .map(|&p| cam.project(V3::new(p[0], p[1], p[2]), centre, half_h))
            .collect();
        let light = {
            let l = [-0.4f32, 0.7, 0.6];
            let n = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
            [l[0] / n, l[1] / n, l[2] / n]
        };
        let fog = [base.r() as f32, base.g() as f32, base.b() as f32];
        // ONE writer (`crate::fog`) — the GPU uniform is built from the SAME ramp, so a
        // tune here cannot leave the 4090 and the software painter drawing different haze.
        let ramp = crate::fog::FogRamp::from_camera_distance(cam.distance);
        let n_tris = self.mesh.indices.len() / 3;
        let mut faces: Vec<(f32, [Pos2; 3], Color32)> = Vec::with_capacity(n_tris);
        for t in 0..n_tris {
            let i0 = self.mesh.indices[t * 3] as usize;
            let i1 = self.mesh.indices[t * 3 + 1] as usize;
            let i2 = self.mesh.indices[t * 3 + 2] as usize;
            let (pa, pb, pc) = (proj[i0], proj[i1], proj[i2]);
            if !pa.visible || !pb.visible || !pc.visible {
                continue;
            }
            // Old screen-extent guard (the heuristic the fix replaces).
            let ext_x = pa.x.max(pb.x).max(pc.x) - pa.x.min(pb.x).min(pc.x);
            let ext_y = pa.y.max(pb.y).max(pc.y) - pa.y.min(pb.y).min(pc.y);
            if ext_x > half_h * 6.0 || ext_y > half_h * 6.0 {
                continue;
            }
            let cross_z = (pb.x - pa.x) * (pc.y - pa.y) - (pb.y - pa.y) * (pc.x - pa.x);
            if cross_z <= 0.0 {
                continue;
            }
            let nv = {
                let n0 = self.mesh.normals[i0];
                let n1 = self.mesh.normals[i1];
                let n2 = self.mesh.normals[i2];
                let mut nx = n0[0] + n1[0] + n2[0];
                let mut ny = n0[1] + n1[1] + n2[1];
                let mut nz = n0[2] + n1[2] + n2[2];
                let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
                nx /= len;
                ny /= len;
                nz /= len;
                [nx, ny, nz]
            };
            let diff = (nv[0] * light[0] + nv[1] * light[1] + nv[2] * light[2]).abs();
            let shade = (0.30 + 0.70 * diff).clamp(0.0, 1.0);
            let src = self.mesh.colors[t];
            let depth = (pa.depth + pb.depth + pc.depth) / 3.0;
            let (lr, lg, lb) = (src.r() as f32 * shade, src.g() as f32 * shade, src.b() as f32 * shade);
            let fog_t = ramp.t(depth);
            let col = Color32::from_rgb(
                (lr + (fog[0] - lr) * fog_t) as u8,
                (lg + (fog[1] - lg) * fog_t) as u8,
                (lb + (fog[2] - lb) * fog_t) as u8,
            );
            faces.push((depth, [Pos2::new(pa.x, pa.y), Pos2::new(pb.x, pb.y), Pos2::new(pc.x, pc.y)], col));
        }
        faces.sort_by(|l, r| r.0.partial_cmp(&l.0).unwrap_or(std::cmp::Ordering::Equal));
        faces
    }
}

/// **The shared 3D projection kernel** — project + view-space frustum-clip +
/// back-face cull + depth-sort + light the extruded `mesh` through `cam` into
/// screen-space shaded faces. Pure (no egui, no device); the single source of
/// truth every render backend and [`Map3D::shade_faces`] share. Returns
/// `(camera_depth, [3 px], shaded_color)` per visible tri, far→near.
/// World-normal `y` above which a face counts as an **up-facing ground-plane**
/// surface (roads · route ribbon · terrain cover · roof caps) that the always-above
/// map camera views from its front and so must NEVER be back-face-culled by screen
/// winding — the fix for the invisible-roads gap (`.nornir/osm-3d-gaps.md` §F). A
/// flat `+Y` ribbon has `ny = 1`; town walls have `ny ≈ 0`; down-caps `ny = -1`.
pub const GROUND_FACE_UP_NY: f32 = 0.5;

/// Paint a vertical **sky gradient** (zenith `sky` tint at the top → `horizon` haze
/// at the bottom) as the 3D background for the **CPU render lane**. The wasm / WebGL
/// demo has no analytic sky pass (that lives in the wgpu backend), so without this the
/// extruded city floats over a flat void; a cheap 2-triangle vertical-gradient egui
/// mesh gives the web 3D a real sky. `sky` is the linear-0..1 [`SunSky::sky_color`]
/// zenith tint. The native wgpu backend never calls this — its sky pass draws the sky.
fn paint_sky_gradient(painter: &egui::Painter, rect: egui::Rect, sky: [f32; 3], horizon: Color32) {
    let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0) as u8;
    // Lift the zenith a touch toward white so it reads as daylight sky, not a flat wash.
    let zenith = Color32::from_rgb(
        to_u8(sky[0] * 0.8 + 0.15),
        to_u8(sky[1] * 0.8 + 0.18),
        to_u8(sky[2] * 0.8 + 0.20),
    );
    let mut mesh = egui::Mesh::default();
    mesh.colored_vertex(rect.left_top(), zenith);
    mesh.colored_vertex(rect.right_top(), zenith);
    mesh.colored_vertex(rect.left_bottom(), horizon);
    mesh.colored_vertex(rect.right_bottom(), horizon);
    mesh.add_triangle(0, 1, 2);
    mesh.add_triangle(2, 1, 3);
    painter.add(egui::Shape::mesh(mesh));
}

/// A **shaded, projected sub-triangle** — the rich output of the CPU projection
/// kernel [`shade_mesh_tris`]. Carries the screen-space (logical-point) vertices
/// (`pts`), each vertex's **view-space depth** (`vz`) for the software z-buffer, the
/// centroid `depth` (the painter key + the shared depth-order oracle), and the final
/// tonemapped `col`. The lean [`shade_mesh_faces`] oracle tuple simply drops `vz`.
#[derive(Clone, Copy, Debug)]
pub struct ShadedTri {
    /// Screen-space (logical-point) vertices, already frustum + screen-bound clipped.
    pub pts: [Pos2; 3],
    /// Per-vertex **view-space depth** (camera-forward distance) for the z-buffer test.
    pub vz: [f32; 3],
    /// Centroid camera-space depth — the painter's-order key (far→near) + depth oracle.
    pub depth: f32,
    /// The final shaded + tonemapped + fogged colour.
    pub col: Color32,
}

/// **Narkowicz ACES** filmic tonemap (single channel) — the SAME curve the wgpu
/// post-composite (`bloom.wgsl::aces`) runs. Rolls highlights off and lifts midtones,
/// so the CPU painter's colours land in the display-referred range the desktop GPU
/// look uses, instead of the un-tonemapped muddy-brown raw `albedo·shade` produced.
#[inline]
#[must_use]
pub fn aces_tonemap(x: f32) -> f32 {
    let (a, b, c, d, e) = (2.51f32, 0.03, 2.43, 0.59, 0.14);
    ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
}

/// sRGB → linear decode (so lighting multiplies in **linear** space — the fix for the
/// muddy sRGB-space multiply that darkened + dulled the CPU roofs).
#[inline]
fn srgb_to_linear(c: f32) -> f32 {
    if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}

/// Linear → sRGB gamma encode (the display encode the wgpu present pass applies on the
/// non-sRGB web canvas — see `gpu.rs::present_encode_srgb`).
#[inline]
fn srgb_encode(c: f32) -> f32 {
    let c = c.clamp(0.0, 1.0);
    if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
}

/// **CPU exposure** — the linear pre-tonemap gain that lifts the sunlit city out of the
/// mud (a stand-in for the wgpu path's physically-based exposure + IBL fill). Tuned so
/// a lit terracotta roof tonemaps to a punchy sunlit terracotta, without clipping the
/// bright roads/facades (the ACES shoulder rolls those off).
pub const CPU_EXPOSURE: f32 = 2.2;

/// Tonemap a source [`Color32`] under a scalar `shade` to a **display-referred** RGB in
/// `0..1`, with the wgpu-matched transfer: sRGB-decode → light in linear → exposure →
/// Narkowicz **ACES** → sRGB-encode. This is the muddy-brown fix: the raw
/// `albedo·shade` in sRGB space read dark + dull; this lands the CPU lane on the
/// desktop's filmic look. Pure; called once per face (not per pixel).
#[must_use]
pub fn tonemap_shaded(src: Color32, shade: f32) -> [f32; 3] {
    let ch = |c: u8| {
        let lin = srgb_to_linear(f32::from(c) / 255.0) * shade * CPU_EXPOSURE;
        srgb_encode(aces_tonemap(lin))
    };
    [ch(src.r()), ch(src.g()), ch(src.b())]
}

/// Barycentric interpolation of the three view-space depths of the projected triangle
/// `(a,b,c)` at screen point `p` — gives a **screen-bound-clipped** sub-vertex its view
/// depth so the software z-buffer can test it. Degenerate triangle → the centroid.
#[inline]
fn bary_depth(p: Pos2, a: Projected, b: Projected, c: Projected) -> f32 {
    let area = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
    if area.abs() < 1e-9 {
        return (a.depth + b.depth + c.depth) / 3.0;
    }
    let w0 = ((b.x - p.x) * (c.y - p.y) - (c.x - p.x) * (b.y - p.y)) / area;
    let w1 = ((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y)) / area;
    let w2 = 1.0 - w0 - w1;
    w0 * a.depth + w1 * b.depth + w2 * c.depth
}

/// **The software z-buffer rasterizer** — the CPU painter's fix for depth tearing.
/// Rasterizes the shaded sub-triangles into a `width × height` **physical-pixel** RGBA
/// buffer with a per-pixel, perspective-correct (`1/z`) depth test, so overlapping
/// building / terrain / ground-cover faces resolve by TRUE depth instead of the
/// painter's-order centroid sort (which tears where a small near face overlaps a large
/// far one, and on the `GROUND_LIFT`-separated coplanar ground layers). Uncovered
/// pixels stay **transparent** so the sky gradient painted behind shows through.
/// `origin` is the widget rect's top-left (logical points), `ppp` the pixels-per-point,
/// so a logical vertex maps to pixel `(v − origin)·ppp`. Row-parallel via znippy's
/// fork-join **gatling scanlines** (ROOT-LAW#0: rayon is forbidden — gatling is the
/// sanctioned engine; the "no thread spawn" note that used to sit here was about
/// rayon, not about parallelism): each worker owns disjoint color + invz rows, no
/// atomics in the pixel path, and the per-pixel test sequence per row is the serial
/// triangle order, so the frame is byte-identical to the serial raster at any worker
/// count (`tests/raster_fanout.rs`). Below [`RASTER_PARALLEL_PX_THRESHOLD`] it stays
/// on the plain serial rows — a minimap-sized frame never pays a pool spin-up.
#[must_use]
pub fn raster_zbuffer(tris: &[ShadedTri], width: usize, height: usize, origin: Pos2, ppp: f32) -> Vec<Color32> {
    raster_zbuffer_buffers(tris, width, height, origin, ppp, 0).0
}

/// Fan across cores only once the frame carries at least this many pixels — the same
/// 256×256 waterline `facett-core`'s `CpuCanvas::PARALLEL_PX_THRESHOLD` uses for the
/// same reason (the 2D raster precedent this parallel raster is shaped after).
const RASTER_PARALLEL_PX_THRESHOLD: usize = 64 * 1024;

/// One triangle's **screen-space setup**, computed once per frame instead of once per
/// scanline visit: projected pixel corners, per-vertex `1/z`, the barycentric
/// `1/area`, the clamped pixel bbox and the shaded colour. Exactly the values the
/// serial loop derived at the top of its triangle iteration — same expressions, same
/// order — so the per-pixel arithmetic downstream is bit-identical.
struct RasterTri {
    px: [f32; 3],
    py: [f32; 3],
    iz: [f32; 3],
    inv_area: f32,
    minx: usize,
    maxx: usize,
    miny: usize,
    maxy: usize,
    col: Color32,
}

/// Row-base pointer for the SECOND framebuffer (`invz`) beside the `color` rows
/// `gatling_scanlines` hands out. SAFETY CONTRACT: each worker derives from it only
/// the row `y` it uniquely claimed from the gatling's row counter, and rows are
/// disjoint `width`-long slices — no two workers ever alias. (The engine's own
/// `RowBase` does the identical thing for the primary buffer.)
struct InvzRows(*mut f32);
unsafe impl Send for InvzRows {}
unsafe impl Sync for InvzRows {}

/// [`raster_zbuffer`] returning **both** buffers (color + interpolated `1/z`) and
/// taking an explicit gatling worker count — `0` = one per core, `1` = the serial
/// reference. The hook `tests/raster_fanout.rs` drives the byte-identity proof
/// through; not part of the supported API surface.
#[doc(hidden)]
pub fn raster_zbuffer_buffers(
    tris: &[ShadedTri],
    width: usize,
    height: usize,
    origin: Pos2,
    ppp: f32,
    workers: usize,
) -> (Vec<Color32>, Vec<f32>) {
    let mut color = vec![Color32::TRANSPARENT; width * height];
    // Depth buffer holds interpolated 1/z (larger = nearer); start at 0 (∞ far).
    let mut invz = vec![0.0f32; width * height];
    if width == 0 || height == 0 || tris.is_empty() {
        return (color, invz);
    }
    let (fw, fh) = (width as f32, height as f32);
    // ── Per-triangle setup, once (serial, O(tris)) ───────────────────────────────
    let pres: Vec<RasterTri> = tris
        .iter()
        .filter_map(|t| {
            let px = [
                (t.pts[0].x - origin.x) * ppp,
                (t.pts[1].x - origin.x) * ppp,
                (t.pts[2].x - origin.x) * ppp,
            ];
            let py = [
                (t.pts[0].y - origin.y) * ppp,
                (t.pts[1].y - origin.y) * ppp,
                (t.pts[2].y - origin.y) * ppp,
            ];
            // Per-vertex 1/z (vz > near > 0 by construction; guard anyway).
            let iz = [1.0 / t.vz[0].max(1e-4), 1.0 / t.vz[1].max(1e-4), 1.0 / t.vz[2].max(1e-4)];
            let area = (px[1] - px[0]) * (py[2] - py[0]) - (px[2] - px[0]) * (py[1] - py[0]);
            if area.abs() < 1e-6 {
                return None;
            }
            let inv_area = 1.0 / area;
            let minx = px.iter().copied().fold(f32::MAX, f32::min).floor().clamp(0.0, fw - 1.0) as usize;
            let maxx = px.iter().copied().fold(f32::MIN, f32::max).ceil().clamp(0.0, fw - 1.0) as usize;
            let miny = py.iter().copied().fold(f32::MAX, f32::min).floor().clamp(0.0, fh - 1.0) as usize;
            let maxy = py.iter().copied().fold(f32::MIN, f32::max).ceil().clamp(0.0, fh - 1.0) as usize;
            Some(RasterTri { px, py, iz, inv_area, minx, maxx, miny, maxy, col: t.col })
        })
        .collect();
    // ── Y-bucket index (CSR): which triangles touch each row, IN TRIANGLE ORDER ──
    // The same reject `facett-core`'s `YBuckets` does for its 2D raster: a row visits
    // `O(tris_touching_row)` instead of every triangle. The fill walks `pres` in
    // order, so each row's list preserves the serial triangle order — which is the
    // whole byte-identity argument: a pixel's depth-test sequence is the same
    // sequence the triangle-outer serial loop ran.
    let mut counts = vec![0u32; height + 1];
    for t in &pres {
        for y in t.miny..=t.maxy {
            counts[y + 1] += 1;
        }
    }
    for y in 0..height {
        counts[y + 1] += counts[y];
    }
    let mut bucket = vec![0u32; counts[height] as usize];
    let mut cursor: Vec<u32> = counts[..height].to_vec();
    for (ti, t) in pres.iter().enumerate() {
        for y in t.miny..=t.maxy {
            bucket[cursor[y] as usize] = ti as u32;
            cursor[y] += 1;
        }
    }
    // ── The row fan: disjoint color + invz rows per worker, no atomics ───────────
    let min_rows = if width * height >= RASTER_PARALLEL_PX_THRESHOLD { 1 } else { usize::MAX };
    let invz_base = InvzRows(invz.as_mut_ptr());
    let ib = &invz_base;
    znippy_zoomies::gatling_forkjoin::gatling_scanlines(
        &mut color,
        height,
        width,
        workers, // 0 ⇒ one worker per core
        min_rows,
        |y, color_row| {
            // SAFETY: row `y` is uniquely claimed by this worker (gatling's row
            // counter); `[y*width, (y+1)*width)` is disjoint from every other row, so
            // this slice never aliases a sibling's. The scope joins before `invz` is
            // read again.
            let invz_row = unsafe { std::slice::from_raw_parts_mut(ib.0.add(y * width), width) };
            let sy = y as f32 + 0.5;
            let (lo, hi) = (counts[y] as usize, counts[y + 1] as usize);
            for &ti in &bucket[lo..hi] {
                let t = &pres[ti as usize];
                let RasterTri { px, py, iz, inv_area, col, .. } = *t;
                for x in t.minx..=t.maxx {
                    let sx = x as f32 + 0.5;
                    let w0 = ((px[1] - sx) * (py[2] - sy) - (px[2] - sx) * (py[1] - sy)) * inv_area;
                    let w1 = ((px[2] - sx) * (py[0] - sy) - (px[0] - sx) * (py[2] - sy)) * inv_area;
                    let w2 = 1.0 - w0 - w1;
                    if w0 < -1e-4 || w1 < -1e-4 || w2 < -1e-4 {
                        continue;
                    }
                    let d = w0 * iz[0] + w1 * iz[1] + w2 * iz[2]; // interpolated 1/z
                    if d > invz_row[x] {
                        invz_row[x] = d;
                        color_row[x] = col;
                    }
                }
            }
        },
    );
    (color, invz)
}

/// The lean **oracle-tuple** view of the CPU projection kernel: `(camera_depth,
/// [3 px], shaded_color)` per visible tri, far→near — the byte-stable conformance
/// surface every backend's `analyze` shares. A thin adapter over [`shade_mesh_tris`]
/// (drops the per-vertex depth), so the oracle output is unchanged by the z-buffer
/// paint work; the colour is the tonemapped one both the oracle and the paint agree on.
pub fn shade_mesh_faces(
    mesh: &Mesh3,
    cam: &OrbitCamera,
    center: Pos2,
    scale: f32,
    base: Color32,
) -> Vec<(f32, [Pos2; 3], Color32)> {
    shade_mesh_tris(mesh, cam, center, scale, base).into_iter().map(|t| (t.depth, t.pts, t.col)).collect()
}

/// **The shared 3D projection kernel** — project + view-space frustum-clip + back-face
/// cull + shade + tonemap + depth-sort the extruded `mesh` through `cam` into
/// screen-space [`ShadedTri`]s (far→near). Pure (no egui paint, no device); the single
/// source of truth the CPU painter (via [`raster_zbuffer`]) and the [`shade_mesh_faces`]
/// oracle share.
pub fn shade_mesh_tris(
    mesh: &Mesh3,
    cam: &OrbitCamera,
    center: Pos2,
    scale: f32,
    base: Color32,
) -> Vec<ShadedTri> {
    shade_mesh_tris_emissive(mesh, &[], cam, center, scale, base)
}

/// [`shade_mesh_tris`] with the **per-triangle emissive channel** (V2.5 D.3 — the
/// overlay's eyes). `emissive` is parallel to `mesh.colors`; empty = all zero, and the
/// zero path is byte-identical to `shade_mesh_tris` (it IS `shade_mesh_tris`, which
/// delegates here — one kernel, LAW 5). An emissive face renders at its **plain source
/// colour** — no directional shade, no tonemap, no fog: the CPU painter's honest
/// "max brightness" reading of a self-lit surface. The HDR halo around it is the GPU
/// lane's job (`build_overlay_geometry` scales the colour past the bloom threshold);
/// this lane asserts only "unlit and undimmed".
pub fn shade_mesh_tris_emissive(
    mesh: &Mesh3,
    emissive: &[f32],
    cam: &OrbitCamera,
    center: Pos2,
    scale: f32,
    base: Color32,
) -> Vec<ShadedTri> {
        let half_h = scale.max(1.0);
        let centre = (center.x, center.y);
        // The EFFECTIVE near — the override a riding overlay installed, else the
        // stock constant. Reading the constant here while the projector honoured
        // the override would clip the avatar in one lane and draw it in the other.
        let near = cam.near_clip();
        // Frustum side-plane slope = tan(fov_y/2): `project_view` uses
        // `focal = half_h / tan(fov_y/2)`, so a vertex with `|cx/cz| <= tan(fov_y/2)`
        // projects to EXACTLY `center ± half_h` (the viewport edge) — the tight bound.
        // (sx == sy here = the square viewport at half_h; the rect-scissor trims any
        // non-square remainder.)
        let fslope = (cam.fov_y * 0.5).tan().max(1e-4);

        // ── STEP 1: view-space transform (once; reused index buffer) ────────────
        // Transform every vertex into VIEW space (x=right, y=up, z=forward). The
        // near-plane clip (step 1) operates here, BEFORE the perspective divide, so
        // a triangle straddling the eye is split — its in-front part kept, its
        // behind-the-eye part dropped — instead of either projecting to a wild
        // off-screen smear or being dropped whole (the bug). Projection (step 2)
        // then divides only safe `cz > near` depths.
        let view: Vec<V3> =
            mesh.positions.iter().map(|&p| cam.view_space(V3::new(p[0], p[1], p[2]))).collect();

        // Light from the upper-front-left, in WORLD space (camera-independent so
        // the model's faces keep their identity as the camera orbits).
        let light = {
            let l = [-0.4f32, 0.7, 0.6];
            let n = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
            [l[0] / n, l[1] / n, l[2] / n]
        };

        // ── atmospheric depth fog ──────────────────────────────────────────────
        // Faces far from the eye fade toward the background colour so the city
        // recedes into haze (a classic depth cue). The fog ramps across a band
        // centred on the camera distance: `fog_near` ≈ the foreground (no haze),
        // `fog_far` ≈ well behind the target (max haze). Tuned shallow so the
        // foreground stays crisp and only the far skyline softens.
        let fog = [base.r() as f32, base.g() as f32, base.b() as f32];
        // ONE writer (`crate::fog`) — the GPU uniform is built from the SAME ramp, so a
        // tune here cannot leave the 4090 and the software painter drawing different haze.
        let ramp = crate::fog::FogRamp::from_camera_distance(cam.distance);

        let n_tris = mesh.indices.len() / 3;
        let mut faces: Vec<ShadedTri> = Vec::with_capacity(n_tris);

        // ── STEP 2b: the **screen-extent frustum bound** (the SECOND half of the
        // fix). The view-space near clip (step 1) guarantees every surviving vertex
        // has `cz > near`, so projection is finite — but with a tiny near plane a
        // face grazing the near plane far off-axis still projects to a coordinate a
        // huge multiple of the viewport (cz≈near, cx large ⇒ cx/cz·focal ≈ tens of
        // thousands of px). The widget rect scissor confines that to the rect, but a
        // single 20 000-px terrain triangle clipped to the rect becomes a giant
        // wedge that FILLS the component — the green smear in the screenshot
        // (geometrically "inside the rect", visually the bug). Clip every projected
        // face to a generous screen box (the viewport expanded by `SCREEN_BOUND_K`)
        // BEFORE depth-sort so `max_projected_extent ≤ viewport · 2·K` holds by
        // construction: the off-screen excess that would smear is removed, while any
        // genuinely on-screen face is untouched. Same ceiling the `no_spokes` fence
        // asserts (half_h·8). Left/right use the full width (≈2·half_h ⇒ a square-ish
        // viewport); the bound is intentionally loose so it never clips real geometry.
        const SCREEN_BOUND_K: f32 = 1.0;
        let screen_bound = egui::Rect::from_min_max(
            Pos2::new(centre.0 - SCREEN_BOUND_K * half_h, centre.1 - SCREEN_BOUND_K * half_h),
            Pos2::new(centre.0 + SCREEN_BOUND_K * half_h, centre.1 + SCREEN_BOUND_K * half_h),
        );

        // Emit counters (cheap; stripped in release) for the part-step oracles.
        #[cfg(feature = "testmatrix")]
        let mut em = ShadeEmit::default();

        for t in 0..n_tris {
            let i0 = mesh.indices[t * 3] as usize;
            let i1 = mesh.indices[t * 3 + 1] as usize;
            let i2 = mesh.indices[t * 3 + 2] as usize;
            let vtri = [view[i0], view[i1], view[i2]];

            // ── STEP 1: view-space near-plane clip (the FIX) ────────────────────
            // Split the triangle against z = near in VIEW space. A triangle wholly
            // behind the eye clips to empty (dropped); one straddling the plane is
            // cut to a 3..4-gon whose every vertex is in front — so projection can
            // never divide by a near-zero depth and smear off the rect.
            // FULL frustum clip (near + 4 sides) — bounds lateral projection to the
            // viewport by construction, killing the off-axis-near spoke smear that a
            // near-only clip leaves (measured up to 84 000px before this). sx=sy=1.0 =
            // the square frustum at focal half_h (project_view's focal); the final
            // rect-scissor handles any non-square remainder.
            let (poly, crossed) = clip_tri_frustum(&vtri, near, fslope, fslope);
            #[cfg(feature = "testmatrix")]
            {
                em.near_in += 1;
                if poly.len() < 3 {
                    em.near_dropped += 1;
                }
                if crossed {
                    em.near_crossing_split += 1;
                }
                // Oracle: NO surviving clipped vertex sits on/behind the eye.
                if poly.iter().any(|p| p.z < near - 1e-4) {
                    em.near_behind_survived += 1;
                }
            }
            #[cfg(not(feature = "testmatrix"))]
            let _ = crossed;
            if poly.len() < 3 {
                continue;
            }

            // ── STEP 2: project the clipped view-space polygon to screen px ──────
            // Every point has z > near (step 1 guaranteed it), so the divide is
            // safe and bounded.
            let mut sp: Vec<Projected> = Vec::with_capacity(poly.len());
            for v in &poly {
                sp.push(cam.project_view(*v, centre, half_h));
            }
            #[cfg(feature = "testmatrix")]
            {
                em.project_in += poly.len();
                em.project_out += sp.len();
                if sp.iter().any(|p| p.x.is_nan() || p.y.is_nan()) {
                    em.project_nan += 1;
                }
            }

            // Per-face flat normal (averaged vertex normals) + source colour — the
            // same shading the unclipped face had; all sub-triangles share it.
            let nv = {
                let n0 = mesh.normals[i0];
                let n1 = mesh.normals[i1];
                let n2 = mesh.normals[i2];
                let mut nx = n0[0] + n1[0] + n2[0];
                let mut ny = n0[1] + n1[1] + n2[1];
                let mut nz = n0[2] + n1[2] + n2[2];
                let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
                nx /= len;
                ny /= len;
                nz /= len;
                [nx, ny, nz]
            };
            let diff = (nv[0] * light[0] + nv[1] * light[1] + nv[2] * light[2]).abs();
            let shade = (0.30 + 0.70 * diff).clamp(0.0, 1.0);
            let src = mesh.colors[t];
            // ── Tonemap ONCE per face (shade + src are constant across its sub-tris):
            // sRGB-decode → light in linear → exposure → ACES → sRGB-encode, the wgpu-
            // matched transfer that turns the muddy `albedo·shade` into filmic colour.
            // Display-referred 0..255 base; fog (below) blends it toward the bg per sub-tri.
            let tm = tonemap_shaded(src, shade);
            let base_rgb = [tm[0] * 255.0, tm[1] * 255.0, tm[2] * 255.0];

            // Fan-triangulate the clipped polygon (0→i→i+1) into sub-triangles.
            for k in 1..poly.len() - 1 {
                let (pa, pb, pc) = (sp[0], sp[k], sp[k + 1]);
                // ── STEP 3: far-plane frustum cull (near is the clip's job now) ──
                #[cfg(feature = "testmatrix")]
                {
                    em.cull_in += 1;
                }
                if !pa.visible || !pb.visible || !pc.visible {
                    #[cfg(feature = "testmatrix")]
                    {
                        em.cull_far_dropped += 1;
                    }
                    continue;
                }
                // ── STEP 4: screen-space back-face cull (CCW-front) ─────────────
                // A flat, UP-facing GROUND-PLANE face (roads · the route ribbon ·
                // terrain cover · roof caps — world normal ≈ +Y) has no meaningful
                // "back" from the map's always-above camera: it points at the eye. But
                // its screen-space winding sign FLIPS with the viewing hemisphere (a
                // +Y face reads CCW from one side, CW from the other), so the raw
                // `cross_z <= 0` test wrongly culled such ribbons from above — the
                // invisible-roads bug (osm-3d-gaps §F). Keep any strongly up-facing
                // face regardless of screen winding; only genuine back-faces (walls
                // seen edge-on-away, down-caps) are dropped. The camera is clamped to
                // sit above the ground plane, so an up-facing surface is always front.
                let up_facing = nv[1] > GROUND_FACE_UP_NY;
                let cross_z = (pb.x - pa.x) * (pc.y - pa.y) - (pb.y - pa.y) * (pc.x - pa.x);
                if cross_z <= 0.0 && !up_facing {
                    #[cfg(feature = "testmatrix")]
                    {
                        em.cull_backface_dropped += 1;
                    }
                    continue;
                }
                #[cfg(feature = "testmatrix")]
                {
                    em.cull_out += 1;
                }

                // ── STEP 5: shade (fog + directional light) ─────────────────────
                let depth = (pa.depth + pb.depth + pc.depth) / 3.0;
                let (lr, lg, lb) = (base_rgb[0], base_rgb[1], base_rgb[2]);
                let fog_t = ramp.t(depth);
                // An EMISSIVE face is self-lit: its plain source colour, no shade, no
                // fog — the one branch the emissive channel adds to this lane (see the
                // function doc; the HDR halo is the GPU bloom's).
                let col = if emissive.get(t).copied().unwrap_or(0.0) > 0.0 {
                    src
                } else {
                    Color32::from_rgb(
                        (lr + (fog[0] - lr) * fog_t) as u8,
                        (lg + (fog[1] - lg) * fog_t) as u8,
                        (lb + (fog[2] - lb) * fog_t) as u8,
                    )
                };
                #[cfg(feature = "testmatrix")]
                {
                    em.shade_out += 1;
                    if shade > 0.31 {
                        em.shade_lit += 1;
                    }
                }
                // ── STEP 2b applied: clip the projected face to the screen bound so
                // no emitted triangle can exceed the viewport·K extent (kills the
                // grazing-near-plane smear wedge). A face fully inside is returned
                // unchanged (3 verts); one poking past the bound is trimmed to a
                // convex polygon we fan back into triangles; one fully outside is
                // dropped. The widget rect scissor (step 7) confines further at paint.
                let tri = [Pos2::new(pa.x, pa.y), Pos2::new(pb.x, pb.y), Pos2::new(pc.x, pc.y)];
                let bounded = clip_poly_to_rect(&tri, screen_bound);
                if bounded.len() < 3 {
                    #[cfg(feature = "testmatrix")]
                    {
                        em.bound_dropped += 1;
                    }
                    continue;
                }
                #[cfg(feature = "testmatrix")]
                if bounded.len() != 3 || bounded != tri.to_vec() {
                    em.bound_trimmed += 1;
                }
                for j in 1..bounded.len() - 1 {
                    let f = [bounded[0], bounded[j], bounded[j + 1]];
                    #[cfg(feature = "testmatrix")]
                    {
                        let ex = f[0].x.max(f[1].x).max(f[2].x) - f[0].x.min(f[1].x).min(f[2].x);
                        let ey = f[0].y.max(f[1].y).max(f[2].y) - f[0].y.min(f[1].y).min(f[2].y);
                        em.max_extent_px = em.max_extent_px.max(ex.max(ey));
                    }
                    // Per-vertex view depth (barycentric over the projected tri) so the
                    // software z-buffer resolves overlaps by TRUE per-pixel depth. When
                    // the screen-bound clip left the tri untouched these equal the exact
                    // projected vertex depths.
                    let vz = [bary_depth(f[0], pa, pb, pc), bary_depth(f[1], pa, pb, pc), bary_depth(f[2], pa, pb, pc)];
                    faces.push(ShadedTri { pts: f, vz, depth, col });
                }
            }
        }
        // ── STEP 6: depth-sort (painter's key: far first, near last). The paint now
        // z-buffers per pixel, but the sorted far→near order is still the shared
        // depth-order oracle (`depth_monotone`) every backend's `analyze` reports on.
        faces.sort_by(|l, r| r.depth.partial_cmp(&l.depth).unwrap_or(std::cmp::Ordering::Equal));

        #[cfg(feature = "testmatrix")]
        {
            // is_monotone: the sorted depths are non-increasing (far→near).
            let mut monotone = true;
            for w in faces.windows(2) {
                if w[0].depth < w[1].depth - 1e-3 {
                    monotone = false;
                    break;
                }
            }
            em.depth_monotone = monotone;
            em.emit_steps("facett-map3d", half_h);
        }    faces
}

/// **Scissor oracle** (step 7) — measure how far the faces overshoot the widget
/// rect *before* the rect-scissor confines them, and confirm the clipped output
/// never lands outside. Returns `(raw_overshoot_px, clipped_escapes)`:
///
/// * `raw_overshoot_px` — the worst signed-distance any **raw** projected vertex
///   pokes past a rect edge. This is THE bug oracle: the pre-fix near-singularity
///   smear pushed it to thousands of px; the step-1 near clip drives it to ~0 (a
///   face may still legitimately extend past an edge, but never by a viewport).
/// * `clipped_escapes` — vertices of the *scissored* polygons still outside the
///   rect; the final scissor must leave this at 0 under any input.
///
/// Pure (no egui paint) so both the live `ui()` paint loop and the inject-assert
/// tests call it. `ink_outside_rect` (the emit detail) = `raw_overshoot_px`.
pub fn scissor_report(faces: &[(f32, [Pos2; 3], Color32)], rect: egui::Rect) -> (f32, usize) {
    let mut raw_overshoot = 0.0f32;
    let mut clipped_escapes = 0usize;
    let eps = 1e-2;
    for (_d, pts, _c) in faces {
        for p in pts {
            let over = (rect.left() - p.x)
                .max(p.x - rect.right())
                .max(rect.top() - p.y)
                .max(p.y - rect.bottom());
            if over > raw_overshoot {
                raw_overshoot = over;
            }
        }
        for p in &clip_poly_to_rect(pts, rect) {
            let over = (rect.left() - p.x)
                .max(p.x - rect.right())
                .max(rect.top() - p.y)
                .max(p.y - rect.bottom());
            if over > eps {
                clipped_escapes += 1;
            }
        }
    }
    (raw_overshoot.max(0.0), clipped_escapes)
}

/// Per-frame counters for the part-step **emit oracles** (`testmatrix` only). Each
/// part-step of `shade_faces` increments these; [`Self::emit_steps`] flushes them
/// as `functional_status` rows so the matrix carries one row per stage. Stripped
/// entirely in release (the whole type is feature-gated).
#[cfg(feature = "testmatrix")]
#[derive(Default)]
struct ShadeEmit {
    // step 1 — near-plane clip
    near_in: usize,
    near_dropped: usize,
    near_crossing_split: usize,
    near_behind_survived: usize,
    // step 2 — project
    project_in: usize,
    project_out: usize,
    project_nan: usize,
    // step 2b — screen-extent frustum bound (the smear gate)
    bound_dropped: usize,
    bound_trimmed: usize,
    /// The worst projected face extent (px) AFTER the screen bound — the
    /// `max_projected_extent ≤ viewport·K` oracle the spec calls for.
    max_extent_px: f32,
    // step 3/4 — frustum + back-face cull
    cull_in: usize,
    cull_out: usize,
    cull_far_dropped: usize,
    cull_backface_dropped: usize,
    // step 5 — shade
    shade_out: usize,
    shade_lit: usize,
    // step 6 — depth-sort
    depth_monotone: bool,
}

#[cfg(feature = "testmatrix")]
impl ShadeEmit {
    /// Flush each part-step's oracle as one `functional_status` row, in pipeline
    /// order: near_clip → project → cull → shade → depth_sort. (scissor + composite

The file goes on; the console shows all of it.