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

nordisk/facett

facett-map3d-flight/src/facet.rs

//! **The FLIGHT viewer's `Facet`** — the host-facing pane for the flight-sim
//! layer. Until now `facett-map3d-flight` was an *engine only*: a floating
//! origin, an atmosphere, a 6DOF rig, a day cycle, a tile planner — all
//! headless-testable but with **no `Facet` impl**, so no host could mount it, no
//! `FacetDeck` could compose it, and (the sharp end) no robot could drive it and
//! no testmatrix cell could go RED when it broke. `.nornir/osm-3d-gaps.md` row
//! C:69 named exactly that: *"6DOF flight camera — PARTIAL: engine exists; no
//! `Facet` yet."* This module closes it.
//!
//! ## Reuse, not a fork (L5)
//! The pane does **not** own a renderer. It owns the FLIGHT-side state — the
//! [`FlightCamera`] rig, the [`DayCycle`] sun, the locomotion mode — and drives
//! `facett_map3d::Map3D` as its **shared render core**, exactly as the
//! two-viewer partition in `.nornir/osm-3d-gaps.md` prescribes: extrusion, roof
//! shell, ground-plane roads and the CPU/wgpu painter are fixed ONCE in
//! `facett-map3d` and both viewers benefit. Nothing from the NAVIGATOR side
//! (turn-by-turn, route overlay, next-turn HUD, ETA) is duplicated here.
//!
//! ## What this pane owns — the FLIGHT half of the partition
//! * **6DOF free-fly / flight-stick / cinematic-orbit** — the existing rig,
//!   finally reachable from a host and a robot script.
//! * **`Walk` — first-person pedestrian locomotion at eye height.** This was
//!   `.nornir/osm-3d-gaps.md` row C:72, the last open FLIGHT-owned camera gap
//!   ("no pedestrian eye-height locomotion yet — the tour drive is a car-nav
//!   *chase*, not a first-person walk"), and it is explicitly a FLIGHT feature,
//!   NOT a navigator one. In `Walk` the eye is pinned to [`WALK_EYE_HEIGHT_M`]
//!   above the ground, the pitch is clamped to a human neck range, movement is
//!   horizontal only (you cannot fly by looking up and pressing forward) and it
//!   is capped at a walking pace. Every one of those is a separate, individually
//!   RED-when-broken assertion in `tests/facet_robot.rs`.
//!
//! ## The oracles
//! `state_json` publishes the full pose (lon/lat/altitude, yaw/pitch/roll, mode,
//! sun hour + elevation) so the state oracle is exact, and the pane renders
//! through the shared `Map3D` so the pixel oracle (`RenderReport.vertices` +
//! geometry count) applies unchanged. `update_json` is the robot drive seam.

use facett_core::Facet;
use facett_map3d::Map3D;

use crate::camera_fly::{FlightCamera, FlyInput, FlyMode};
use crate::daycycle::{DayCycle, Weather};
use crate::origin::WorldPos;

/// Pedestrian eye height above the ground, metres. The standing eye height of an
/// adult (~1.70 m) — the number that makes a street-level walk read as *walking*
/// rather than *hovering*. `Walk` pins the camera here.
pub const WALK_EYE_HEIGHT_M: f64 = 1.70;

/// Comfortable walking pace, metres/second (≈ 5 km/h). `Walk` caps translation at
/// this so the pedestrian mode cannot be flown like the free-fly rig.
pub const WALK_SPEED_MPS: f64 = 1.40;

/// **The closest the walk dolly may come to what it looks at — METRES.**
///
/// A floor belongs here so a degenerate scene scale can never collapse the render
/// core's dolly to zero. It is a *metre*, and it is converted through the same
/// `metres_per_du` as [`WALK_EYE_HEIGHT_M`], because the floor it replaced was an
/// ABSOLUTE design-unit number (`OrbitCamera::min_distance`, 0.4 du) that won at
/// every scale that occurs: 250 m on the 625 m/du fixture, **750 m** in the 3 km
/// town window `korp` actually flies, 11.9 km on a region load. A floor that always
/// wins makes the metric conversion above it pure decoration — and `state_json`
/// went on publishing `walk_eye_height_m: 1.70` while the walker stood a quarter of
/// a kilometre up.
///
/// Half a metre: under a pedestrian eye height by a factor of 3.4, so it never binds
/// for a walker and only catches a pathological scale.
pub const WALK_MIN_DOLLY_M: f64 = 0.5;

/// How far a walker may look up/down, radians (±70°). A human neck does not hinge
/// to the vertical, and unclamping it is what turns a walk into a flight.
pub const WALK_PITCH_LIMIT: f64 = 70.0 * std::f64::consts::PI / 180.0;

/// How the viewer moves. A superset of [`FlyMode`] with the pedestrian lane the
/// engine had no vocabulary for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Locomotion {
    /// 6DOF free flight (the engine's `FlyMode::FreeFly`).
    #[default]
    Fly,
    /// Banked flight-stick (`FlyMode::FlightStick`).
    Stick,
    /// Cinematic orbit about a target (`FlyMode::Orbit`).
    Orbit,
    /// **First-person pedestrian walk** at [`WALK_EYE_HEIGHT_M`] — the FLIGHT
    /// viewer's street-level mode (osm-3d-gaps C:72).
    Walk,
}

impl Locomotion {
    /// The engine mode this locomotion drives. `Walk` rides the free-fly
    /// integrator with the pedestrian constraints applied on top.
    #[must_use]
    pub fn fly_mode(self) -> FlyMode {
        match self {
            Locomotion::Fly | Locomotion::Walk => FlyMode::FreeFly,
            Locomotion::Stick => FlyMode::FlightStick,
            Locomotion::Orbit => FlyMode::Orbit,
        }
    }

    /// The stable wire name published in `state_json` / accepted by `update_json`.
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Locomotion::Fly => "fly",
            Locomotion::Stick => "stick",
            Locomotion::Orbit => "orbit",
            Locomotion::Walk => "walk",
        }
    }

    /// Parse the wire name. Unknown names are rejected (`None`) so a robot script
    /// with a typo cannot silently leave the pane in the previous mode while
    /// looking like it switched.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "fly" => Some(Locomotion::Fly),
            "stick" => Some(Locomotion::Stick),
            "orbit" => Some(Locomotion::Orbit),
            "walk" => Some(Locomotion::Walk),
            _ => None,
        }
    }
}

/// The FLIGHT viewer pane: the 6DOF rig + day cycle over the shared
/// `facett_map3d::Map3D` render core.
pub struct FlightFacet {
    title: String,
    /// The shared render core (extrusion / roofs / roads / painter) — REUSED from
    /// `facett-map3d`, never forked.
    map: Map3D,
    /// The 6DOF rig (floating-origin `f64` world frame).
    cam: FlightCamera,
    /// Which locomotion lane is active.
    locomotion: Locomotion,
    /// Time-of-day sun driver.
    day: DayCycle,
    /// Hour of day the sun is evaluated at (0..24).
    hour: f32,
    /// Ground elevation under the walker, metres. The pane is a viewer, not a
    /// physics engine: hosts that have a DEM push the sampled ground height here
    /// and `Walk` pins the eye to `ground + WALK_EYE_HEIGHT_M`.
    ground_m: f64,
}

impl FlightFacet {
    /// A pane over an existing render core at a world position.
    #[must_use]
    pub fn new(title: impl Into<String>, map: Map3D, position: WorldPos) -> Self {
        Self {
            title: title.into(),
            map,
            cam: FlightCamera::free_fly(position),
            locomotion: Locomotion::Fly,
            day: DayCycle::default(),
            hour: 12.0,
            ground_m: 0.0,
        }
    }

    /// **Metres of world travel per design unit of the render core** — the calibration
    /// that keeps flight motion and the map's extruded geometry in the same scale.
    ///
    /// Read from the map EVERY time rather than cached at construction. It was a field
    /// written once in `new()`, in a type that exposes [`Self::map_mut`] precisely so a
    /// host can load real OSM ways into the shared core: doing that changed the map's
    /// real scale while the pane went on dividing by `Map3D::synthetic`'s, and both
    /// readers of the stale copy are user-facing — the walk eye height and
    /// `state_json`'s published `metres_per_design_unit`. The map is the one writer of
    /// its own scale; this just asks it.
    #[must_use]
    pub fn metres_per_du(&self) -> f64 {
        f64::from(self.map.metres_per_design_unit()).max(1e-6)
    }

    /// The **shared demo fixture** — the synthetic town from the render core,
    /// with the camera parked over it at 400 m. Consumed by the robot/matrix
    /// tests and by any host that wants a populated flight pane without inventing
    /// data (L5: one fixture, not one per test file).
    #[must_use]
    pub fn local() -> Self {
        // Sigtuna-ish: the FLIGHT viewer's home fixture.
        let pos = WorldPos::from_lonlat(17.72, 59.62, 400.0);
        Self::new("flight", Map3D::synthetic("flight-core"), pos)
    }

    /// The live rig (read-only).
    #[must_use]
    pub fn camera(&self) -> &FlightCamera {
        &self.cam
    }

    /// The shared render core (read-only) — for hosts that want to feed it ways.
    #[must_use]
    pub fn map(&self) -> &Map3D {
        &self.map
    }

    /// The shared render core (mutable) — a host loads real OSM ways through this
    /// rather than the pane growing its own loader (L5).
    pub fn map_mut(&mut self) -> &mut Map3D {
        &mut self.map
    }

    /// The active locomotion lane.
    #[must_use]
    pub fn locomotion(&self) -> Locomotion {
        self.locomotion
    }

    /// Switch locomotion. Entering [`Locomotion::Walk`] **immediately** plants the
    /// walker: the eye snaps to `ground + WALK_EYE_HEIGHT_M` and the pitch is
    /// clamped to the human neck range, so there is no frame in which a
    /// "pedestrian" is still hovering at cruise altitude.
    pub fn set_locomotion(&mut self, l: Locomotion) {
        self.locomotion = l;
        self.cam.set_mode(l.fly_mode());
        if l == Locomotion::Walk {
            self.plant_walker();
        }
    }

    /// Tell the pane the ground height under the camera (metres). A host with a
    /// DEM samples `terrain::elevation_at` and pushes it here each frame; the
    /// default flat ground is 0 m.
    pub fn set_ground_m(&mut self, ground_m: f64) {
        self.ground_m = ground_m;
        if self.locomotion == Locomotion::Walk {
            self.plant_walker();
        }
    }

    /// Ground height currently assumed under the camera, metres.
    #[must_use]
    pub fn ground_m(&self) -> f64 {
        self.ground_m
    }

    /// Eye height above the ground, metres — the number that says whether you are
    /// walking (≈ 1.7) or flying.
    #[must_use]
    pub fn eye_height_m(&self) -> f64 {
        self.cam.position.z - self.ground_m
    }

    /// Hour of day the sun is evaluated at.
    #[must_use]
    pub fn hour(&self) -> f32 {
        self.hour
    }

    /// Set the hour of day (wrapped into `[0, 24)`), driving the day cycle.
    pub fn set_hour(&mut self, hour: f32) {
        self.hour = hour.rem_euclid(24.0);
    }

    /// Set the weather (overcast/cloudy/clear) on the day cycle.
    pub fn set_weather(&mut self, weather: Weather) {
        self.day.weather = weather;
    }

    /// **The walk dolly in design units** — a pedestrian eye height, converted ONCE
    /// through this scene's `metres_per_du`, with the floor on the METRE side of the
    /// conversion ([`WALK_MIN_DOLLY_M`]) so it cannot swallow the metre it floors.
    ///
    /// KNOWN, and not this function's to fix: `OrbitCamera::NEAR_PLANE` is still an
    /// absolute `0.02 du` (37.5 m in the 3 km town window, and declared three times —
    /// `facett-map3d/src/camera.rs:472`, `facett-core/src/render/camera.rs:202`,
    /// `facett-core/src/render/view.rs:230`), so geometry nearer than that is clipped
    /// whatever the dolly says. Same family, wider blast radius.
    fn walk_dolly_du(&self) -> f32 {
        (WALK_EYE_HEIGHT_M.max(WALK_MIN_DOLLY_M) / self.metres_per_du()) as f32
    }

    /// Pin the eye to walking height and clamp the pitch to the neck range.
    fn plant_walker(&mut self) {
        self.cam.position.z = self.ground_m + WALK_EYE_HEIGHT_M;
        self.cam.pitch = self.cam.pitch.clamp(-WALK_PITCH_LIMIT, WALK_PITCH_LIMIT);
        self.cam.roll = 0.0; // a pedestrian does not bank
    }

    /// Integrate one frame of input over `dt` seconds.
    ///
    /// In every lane but [`Locomotion::Walk`] this is the engine's rig verbatim.
    /// In `Walk` three pedestrian constraints are applied — each one an
    /// independent, separately-assertable contract:
    ///   1. **speed cap** — translation is limited to [`WALK_SPEED_MPS`];
    ///   2. **horizontal-only travel** — forward motion is projected onto the
    ///      ground plane and vertical input is dropped, so looking up and walking
    ///      forward does NOT lift you off the street;
    ///   3. **replant** — the eye is snapped back to `ground + eye height` and the
    ///      pitch re-clamped after the step.
    pub fn step(&mut self, input: &FlyInput, dt: f64) {
        if self.locomotion != Locomotion::Walk {
            self.cam.integrate(input, dt);
            return;
        }
        let clamp_speed = |v: f64| v.clamp(-WALK_SPEED_MPS, WALK_SPEED_MPS);
        // Look first, with the neck clamp, so the walk direction below uses the
        // pitch the walker actually ended up with.
        self.cam.yaw += input.yaw_rate * dt;
        self.cam.pitch =
            (self.cam.pitch + input.pitch_rate * dt).clamp(-WALK_PITCH_LIMIT, WALK_PITCH_LIMIT);
        self.cam.roll = 0.0;

        // Horizontal-only basis: forward flattened onto the ground plane.
        let yaw = self.cam.yaw;
        let (fx, fz) = (yaw.cos(), yaw.sin());
        // Right = forward × world-up, flattened (same handedness as the rig).
        let (rx, rz) = (fz, -fx);

        let fwd = clamp_speed(input.move_forward) * dt;
        let strafe = clamp_speed(input.move_right) * dt;
        self.cam.position.x += fx * fwd + rx * strafe;
        self.cam.position.z += fz * fwd + rz * strafe;
        // NB: `move_up` is deliberately ignored — a walker has no vertical thruster.

        self.plant_walker();
    }

    /// Push the flight pose into the shared render core's orbit camera so the map
    /// draws what the rig is looking at. Bearing comes from the rig's yaw and the
    /// tilt from its pitch, both in the render core's own **Garmin vocabulary**
    /// (`set_bearing_deg` / `set_tilt_deg`) — that vocabulary is the single source
    /// of truth for both viewers (L5), so this is a translation, not a second
    /// camera model. In `Walk` the dolly is pulled to the closest legal radius so
    /// the eye sits among the buildings rather than above them.
    fn sync_render_camera(&mut self) {
        let bearing = self.cam.yaw.to_degrees() as f32;
        // rig pitch: 0 = level, +up. Map tilt: 0 = straight down, 90 = horizon.
        let tilt = 90.0 + self.cam.pitch.to_degrees() as f32;
        self.map.cam.set_bearing_deg(bearing);
        self.map.cam.set_tilt_deg(tilt);
        if self.locomotion == Locomotion::Walk {
            // Eye height in design units — the walker stands INSIDE the city.
            self.map.cam.goal_distance = self.walk_dolly_du();
        }
        self.map.cam.settle();
    }

    /// The pose as JSON — the exact state oracle.
    fn pose_json(&self) -> serde_json::Value {
        let (lon, lat) = self.cam.position.to_lonlat();
        let sun = self.day.solar_angles(self.hour);
        serde_json::json!({
            "locomotion": self.locomotion.name(),
            "fly_mode": format!("{:?}", self.cam.mode),
            "lon": lon,
            "lat": lat,
            "altitude_m": self.cam.position.z,
            "ground_m": self.ground_m,
            "eye_height_m": self.eye_height_m(),
            "yaw_deg": self.cam.yaw.to_degrees(),
            "pitch_deg": self.cam.pitch.to_degrees(),
            "roll_deg": self.cam.roll.to_degrees(),
            "hour": self.hour,
            "sun_elevation_deg": sun.elevation_rad.to_degrees(),
            "sun_azimuth_deg": sun.azimuth_rad.to_degrees(),
            "cloudiness": self.day.weather.cloudiness,
            "walk_eye_height_m": WALK_EYE_HEIGHT_M,
            // The dolly the render camera is actually given in `Walk`, in metres —
            // the number `walk_eye_height_m` merely ASPIRES to. They were 440× apart
            // while the floor was absolute, and no oracle could see it because only
            // the aspiration was published.
            "walk_dolly_m": f64::from(self.walk_dolly_du()) * self.metres_per_du(),
            "metres_per_design_unit": self.metres_per_du(),
        })
    }
}

impl Facet for FlightFacet {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        self.sync_render_camera();
        // The shared render core paints. The FLIGHT pane adds no second painter.
        Facet::ui(&mut self.map, ui);
    }

    fn state_json(&self) -> serde_json::Value {
        let mut v = self.pose_json();
        // Fold the render core's own state in under a namespace so the pixel/geometry
        // oracle reads through unchanged and the pane's cardinality is non-zero.
        v["map"] = self.map.state_json();
        v
    }

    /// The **robot drive seam**.
    ///
    /// * `{"locomotion":"walk"}` — switch lane (`fly`/`stick`/`orbit`/`walk`).
    /// * `{"step":{"forward":1.4,"right":0.0,"up":0.0,"yaw":0.0,"pitch":0.0,"roll":0.0,"dt":1.0}}`
    ///   — integrate one frame of stick/keyboard input.
    /// * `{"hour":18.5}` — set the time of day.
    /// * `{"ground_m":42.0}` — the host's sampled DEM height under the camera.
    /// * `{"cloudiness":0.8}` — weather.
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        if let Some(name) = v.get("locomotion").and_then(|s| s.as_str()) {
            if let Some(l) = Locomotion::parse(name) {
                self.set_locomotion(l);
            }
            return;
        }
        if let Some(h) = v.get("hour").and_then(|n| n.as_f64()) {
            return self.set_hour(h as f32);
        }
        if let Some(g) = v.get("ground_m").and_then(|n| n.as_f64()) {
            return self.set_ground_m(g);
        }
        if let Some(c) = v.get("cloudiness").and_then(|n| n.as_f64()) {
            return self.set_weather(Weather::cloudy(c as f32));
        }
        if let Some(s) = v.get("step") {
            let f = |k: &str| s.get(k).and_then(|n| n.as_f64()).unwrap_or(0.0);
            let input = FlyInput {
                move_right: f("right"),
                move_up: f("up"),
                move_forward: f("forward"),
                yaw_rate: f("yaw"),
                pitch_rate: f("pitch"),
                roll_rate: f("roll"),
                orbit_dolly: f("dolly"),
            };
            let dt = s.get("dt").and_then(|n| n.as_f64()).unwrap_or(1.0);
            self.step(&input, dt);
        }
    }

    fn caps(&self) -> facett_core::FacetCaps {
        Facet::caps(&self.map)
    }

    fn kind(&self) -> &'static str {
        "map3d-flight"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn walk_input(forward: f64) -> FlyInput {
        FlyInput {
            move_right: 0.0,
            move_up: 0.0,
            move_forward: forward,
            yaw_rate: 0.0,
            pitch_rate: 0.0,
            roll_rate: 0.0,
            orbit_dolly: 0.0,
        }
    }

    #[test]
    fn locomotion_names_round_trip_and_reject_garbage() {
        for l in [Locomotion::Fly, Locomotion::Stick, Locomotion::Orbit, Locomotion::Walk] {
            assert_eq!(Locomotion::parse(l.name()), Some(l));
        }
        assert_eq!(Locomotion::parse("teleport"), None);
        assert_eq!(Locomotion::parse(""), None);
    }

    #[test]
    fn entering_walk_plants_the_eye_at_pedestrian_height() {
        let mut f = FlightFacet::local();
        assert!(f.eye_height_m() > 100.0, "starts at cruise altitude");
        f.set_locomotion(Locomotion::Walk);
        assert!(
            (f.eye_height_m() - WALK_EYE_HEIGHT_M).abs() < 1e-9,
            "walk plants the eye at {WALK_EYE_HEIGHT_M} m, got {}",
            f.eye_height_m()
        );
    }

    /// RED-when-broken: this is the whole point of a WALK mode. Looking up and
    /// walking forward must not lift the walker off the street — if the vertical
    /// component of the view direction leaks into translation, `Walk` is just
    /// slow flying.
    #[test]
    fn walking_forward_while_looking_up_does_not_take_off() {
        let mut f = FlightFacet::local();
        f.set_locomotion(Locomotion::Walk);
        // Look as far up as the neck allows.
        f.step(
            &FlyInput { pitch_rate: 10.0, ..walk_input(0.0) },
            1.0,
        );
        assert!((f.camera().pitch - WALK_PITCH_LIMIT).abs() < 1e-9, "pitch clamps at the neck limit");
        for _ in 0..100 {
            f.step(&walk_input(WALK_SPEED_MPS), 1.0);
        }
        assert!(
            (f.eye_height_m() - WALK_EYE_HEIGHT_M).abs() < 1e-9,
            "100 s of walking uphill-of-gaze must not gain altitude (eye {} m)",
            f.eye_height_m()
        );
    }

    /// RED-when-broken: a walker moves at a walking pace. Feeding the free-fly
    /// cruise speed must NOT translate at cruise speed.
    #[test]
    fn walk_caps_translation_at_a_walking_pace() {
        let mut f = FlightFacet::local();
        f.set_locomotion(Locomotion::Walk);
        let (x0, z0) = (f.camera().position.x, f.camera().position.z);
        f.step(&walk_input(250.0), 1.0); // jet speed request
        let d = ((f.camera().position.x - x0).powi(2) + (f.camera().position.z - z0).powi(2)).sqrt();
        assert!(
            d <= WALK_SPEED_MPS + 1e-9,
            "one second of walking covers at most {WALK_SPEED_MPS} m, covered {d}"
        );
        assert!(d > 0.0, "and it does move");
    }

    /// The free-fly lane is untouched by the pedestrian constraints (L2: no
    /// regression to the existing rig).
    #[test]
    fn fly_mode_keeps_the_engines_unconstrained_rig() {
        let mut f = FlightFacet::local();
        let z0 = f.camera().position.z;
        f.step(
            &FlyInput { move_up: 200.0, ..walk_input(0.0) },
            1.0,
        );
        assert!((f.camera().position.z - z0 - 200.0).abs() < 1e-6, "free-fly climbs at the commanded rate");
    }

    #[test]
    fn ground_height_moves_the_walker_with_the_terrain() {
        let mut f = FlightFacet::local();
        f.set_locomotion(Locomotion::Walk);
        f.set_ground_m(137.0);
        assert!((f.camera().position.z - (137.0 + WALK_EYE_HEIGHT_M)).abs() < 1e-9);
        assert!((f.eye_height_m() - WALK_EYE_HEIGHT_M).abs() < 1e-9);
    }

    /// **RED-when-broken: the walk dolly is 1.70 m at EVERY scene scale.**
    ///
    /// The eye height was converted to design units and then thrown at an ABSOLUTE
    /// floor (`min_distance`, 0.4 du), which wins at every scale that occurs — so the
    /// conversion was decoration and the walker stood a quarter of a kilometre up
    /// while `state_json` published `walk_eye_height_m: 1.70`.
    ///
    /// Three REAL scenes, decades apart, so a floor cannot hide as a metre: a 1 km
    /// hamlet (≈625 m/du), korp's 3 km town window (≈1875) and a 48 km region load
    /// (≈29 855). The scale is whatever the pushed geometry makes it — read back from
    /// the map, never assigned.
    #[test]
    fn the_walk_dolly_is_a_metric_eye_height_at_every_scene_scale() {
        for &span_deg in &[0.008_983_f64, 0.026_950, 0.429_067] {
            let mut f = FlightFacet::new("scaled", scene(span_deg), WorldPos::from_lonlat(18.0, 59.33, 400.0));
            let mpu = f.metres_per_du();
            f.set_locomotion(Locomotion::Walk);
            f.sync_render_camera();
            let dolly_m = f64::from(f.map.cam.goal_distance) * mpu;
            println!(
                "  {mpu:>9.0} m/du   dolly {:.3e} du = {dolly_m:>10.2} m   (want {WALK_EYE_HEIGHT_M} m)",
                f.map.cam.goal_distance
            );
            assert!(
                (dolly_m - WALK_EYE_HEIGHT_M).abs() < 0.5,
                "a walker at {mpu} m/du stands {dolly_m:.1} m from what it looks at, \
                 not {WALK_EYE_HEIGHT_M} m — the floor ate the metric conversion"
            );
        }
    }

    /// The published pose must not claim a walking eye height the render camera does
    /// not hold: `walk_dolly_m` is the dolly the map actually got, in metres.
    #[test]
    fn state_json_publishes_the_dolly_the_camera_actually_holds() {
        let mut f = FlightFacet::new("scaled", scene(0.026_950), WorldPos::from_lonlat(18.0, 59.33, 400.0));
        f.set_locomotion(Locomotion::Walk);
        f.sync_render_camera();
        let published = f.pose_json()["walk_dolly_m"].as_f64().expect("walk_dolly_m");
        let held = f64::from(f.map.cam.goal_distance) * f.metres_per_du();
        assert!(
            (published - held).abs() < 1e-6,
            "the pane publishes {published:.3} m while the camera holds {held:.3} m"
        );
        assert!((published - WALK_EYE_HEIGHT_M).abs() < 0.5, "and it is a walking height");
    }

    /// A one-way scene spanning `span_deg` of latitude at Stockholm's latitude — a
    /// render core whose `metres_per_design_unit` is `span / DESIGN_FILL`, so a pane
    /// built over it has a REAL, known scale rather than the fixture's default.
    fn scene(span_deg: f64) -> Map3D {
        let tags = [("highway".to_string(), "residential".to_string())];
        let w = facett_map::Way::from_lonlat(
            1,
            &[(18.0, 59.33), (18.0, 59.33 + span_deg)],
            &tags,
            &facett_core::Theme::default(),
        )
        .expect("a line way");
        Map3D::from_ways("scene", &[w], 0.22)
    }

    /// **RED-when-broken: the pane's scale follows the geometry a host pushes into it.**
    ///
    /// `metres_per_du` was read ONCE in `new()` and cached in a field, in a type that
    /// exposes `map_mut()` precisely so a host can load real OSM ways into the shared
    /// render core. A host that did so changed the map's real scale while the pane kept
    /// `Map3D::synthetic`'s — and BOTH readers of the stale copy are user-facing: the
    /// walk eye height and `state_json`'s published `metres_per_design_unit`. Two
    /// readers of a value with one writer, and the writer was the constructor.
    #[test]
    fn geometry_pushed_through_map_mut_moves_the_published_scale() {
        let mut f = FlightFacet::local();
        let fixture = f.pose_json()["metres_per_design_unit"].as_f64().unwrap();
        *f.map_mut() = scene(0.0270); // ≈3 km of latitude — korp's town window
        let real = f64::from(f.map().metres_per_design_unit());
        let published = f.pose_json()["metres_per_design_unit"].as_f64().unwrap();
        println!("  fixture {fixture:.1} m/du → pushed geometry {real:.1} m/du, pane publishes {published:.1}");
        assert!(
            (real - fixture).abs() / fixture > 0.5,
            "the pushed scene must differ from the fixture's scale or this proves nothing \
             ({real:.1} vs {fixture:.1})"
        );
        assert!(
            (published - real).abs() / real < 1e-6,
            "the pane publishes {published:.1} m/du while its own render core is at {real:.1}"
        );
        // …and the walk dolly follows too, since it divides by the same number.
        f.set_locomotion(Locomotion::Walk);
        f.sync_render_camera();
        let dolly_m = f64::from(f.map.cam.goal_distance) * real;
        assert!(
            (dolly_m - WALK_EYE_HEIGHT_M).abs() < 0.5,
            "the walker stands {dolly_m:.1} m from what it looks at after the push"
        );
    }

    #[test]
    fn hour_wraps_and_drives_the_sun() {
        let mut f = FlightFacet::local();
        f.set_hour(25.5);
        assert!((f.hour() - 1.5).abs() < 1e-5, "hours wrap into [0,24)");
        f.set_hour(-1.0);
        assert!((f.hour() - 23.0).abs() < 1e-5);
    }
}