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.