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

nordisk/facett

facett-map3d/src/camera.rs

//! **OrbitCamera** — a state-of-the-art, fully navigable 3D camera for the OSM
//! viewer. The camera orbits a **target** point on spherical coordinates
//! (azimuth + elevation + distance), like three.js `OrbitControls` /
//! `camera-controls` and Blender's turntable: drag to orbit, wheel to **dolly
//! toward the cursor**, middle/shift-drag to **pan**, and snap to canonical
//! **view-angle presets** (top-down / iso / oblique / front).
//!
//! ## Math (Y-up, right-handed)
//! The eye sits on a sphere of radius `distance` around `target`:
//!
//! ```text
//! offset = distance * ( cos(elev) sin(azim),  sin(elev),  cos(elev) cos(azim) )
//! eye    = target + offset
//! ```
//!
//! `azimuth` is the turntable yaw about +Y; `elevation` is the polar lift
//! (clamped just shy of the poles so the view never gimbal-flips). Projection is
//! a real **perspective** transform (look-at → view space → divide by depth), so
//! the city reads with true depth and parallax from any angle — not an
//! orthographic fake.
//!
//! ## Damping (smooth, inertial)
//! Every interactive control writes a **goal** (`goal_azimuth`, `goal_distance`,
//! …); [`OrbitCamera::update`] eases the live value toward the goal with an
//! exponential, frame-rate-independent smoothing factor (`1 - exp(-k·dt)`), the
//! same inertial feel `inertial-turntable-camera` / `camera-controls` use. A
//! deterministic [`OrbitCamera::settle`] jumps straight to the goal so headless
//! tests are exact.
//!
//! Pure math, no egui, no GPU — so the inject-assert tests check the projection
//! and preset angles directly.

/// A canonical view angle the user can snap to from a hotkey. "View from
/// different angles" in one keystroke.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewPreset {
    /// Straight down (plan / map view).
    Top,
    /// Classic 3/4 isometric.
    Isometric,
    /// A low, dramatic oblique (street-level-ish).
    Oblique,
    /// Front elevation (looking along -Z, horizon level).
    Front,
}

impl ViewPreset {
    /// `(azimuth, elevation)` in radians for this preset. Distance is kept.
    pub fn angles(self) -> (f32, f32) {
        use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, FRAC_PI_8};
        match self {
            // Top-down: look straight down. Elevation just shy of +90° so the
            // azimuth still has a defined heading (north-up).
            ViewPreset::Top => (0.0, FRAC_PI_2 - 0.001),
            // Isometric: 45° around, ~35.264° up (the true iso angle,
            // atan(1/sqrt(2))), rounded to a clean 30°-ish lift that reads well.
            ViewPreset::Isometric => (FRAC_PI_4, 0.6155), // atan(1/√2)
            // Oblique: a shallower, more dramatic 3/4.
            ViewPreset::Oblique => (FRAC_PI_4 + FRAC_PI_8, FRAC_PI_8),
            // Front: looking along -Z, eye level (small lift to avoid z-fight).
            ViewPreset::Front => (0.0, 0.08),
        }
    }
    pub fn name(self) -> &'static str {
        match self {
            ViewPreset::Top => "top",
            ViewPreset::Isometric => "isometric",
            ViewPreset::Oblique => "oblique",
            ViewPreset::Front => "front",
        }
    }
}

/// How the camera should feel for this OS / input device. **Moved to
/// `facett_core::render::camera`** (CONS-CORE Phase A — the shared FEEL); re-exported
/// here so map3d's public API + tests are unchanged.
pub use facett_core::render::camera::InputFeel;

/// The camera's 3-vector — **`facett_core::render::camera::V3`, re-exported.** Until
/// 2026-08-22 this file carried its own verbatim copy of that struct (the same seven
/// methods, line for line) and facett-core's comment said "mirrors facett-map3d" while
/// this one said nothing: two writers of one vector type, the LAW #5 twin GFX_V2 item 3
/// left standing when it converted `sky.rs` to glam. The unified view core
/// (`facett_core::render::View`, item 6) already speaks core's `V3` at every seam
/// (`eye`, `basis`, `view_space`, `project_px`), so the local copy was converted to and
/// from it on every call; it is now the same type and the conversions are gone.
///
/// `V3` stays a plain `{x, y, z}` struct rather than a `glam::Vec3` alias on purpose —
/// see the `From` impls beside it in facett-core: the maths that matters runs in glam
/// inside `View`, and `V3` is the crate-API name the 40-odd call sites and tests hold.
/// Its `dot` / `cross` / `scale` / `len` are bit-identical to glam's scalar `Vec3`
/// (same association order, `math::sqrt` = `f32::sqrt`); only `normalized` differs, on
/// the degenerate `|v| < 1e-9` input, where glam's `normalize` yields a non-finite
/// vector and this returns `v · 1e9` — so a type-level swap would not be numerics-free.
pub use facett_core::render::camera::{Projected, V3};

/// The orbit camera. All angles in radians.
#[derive(Clone, Debug)]
pub struct OrbitCamera {
    /// Point the camera orbits / looks at.
    pub target: V3,
    /// Turntable yaw about +Y.
    pub azimuth: f32,
    /// Polar lift; `+PI/2` looks straight down, `0` is level.
    pub elevation: f32,
    /// Eye-to-target distance (the dolly radius).
    pub distance: f32,
    /// Vertical field of view (radians).
    pub fov_y: f32,
    /// **Bank about the forward axis**, radians, positive = right wing down. `0` for every
    /// pose but a flight-stick one; see [`facett_core::render::View::roll`], which is where
    /// it reaches the projection. Not damped — there is deliberately no `goal_roll`, because
    /// the rig that writes it damps its own stick and a second easing would fight it: a bank
    /// that lags the aileron by a frame reads as a sloppy control, not as smoothing.
    pub roll: f32,

    // --- damping goals (the live values ease toward these) ---
    pub goal_target: V3,
    pub goal_azimuth: f32,
    pub goal_elevation: f32,
    /// **The 2D plan mode** (GFX_V2 item 7). `true` makes [`view`](Self::view) return
    /// a fully constrained [`View::ortho_2d`] — exact top-down, zero azimuth,
    /// orthographic, flat terrain — which is what
    /// [`SceneFlags::constrain_to_view`](crate::backend::SceneFlags::constrain_to_view)
    /// recognises to bypass the entire screen-space post chain.
    ///
    /// A MODE, deliberately, and not an extreme of the orbit. See [`view`](Self::view).
    pub plan_2d: bool,
    /// **6DOF mode: the elevation limits belong to the ORBIT, not to this pose** (A-2b).
    ///
    /// `clamp_elev` holds the polar lift inside `[MIN_ELEV, ELEV_LIMIT]` so a turntable
    /// drag cannot dive under the ground plane or flip through the pole. That is right
    /// for a turntable. It is wrong for a flight rig, which owns its own attitude and
    /// commands `elevation = −pitch` directly: `MIN_ELEV` is 0.02 rad, so **every level
    /// or climbing pose was silently pitched 1.15° nose-down on the very next frame**,
    /// moving the derived eye by `distance·sin(0.02)` — 25 m on a 625 m/du scene, for a
    /// pose nobody changed.
    ///
    /// Set by [`crate::Map3D::look_from`] and cleared by every interactive orbit writer
    /// (drag, preset, keyboard nudge, tilt) — taking the turntable back is what ends
    /// free-look, and it is the same shape as `plan_2d`: a mode, not an extreme.
    pub free_look: bool,
    pub goal_distance: f32,

    /// Input feel (OS-derived).
    pub feel: InputFeel,

    /// Min/max dolly clamp (keeps the model on screen).
    pub min_distance: f32,
    pub max_distance: f32,

    /// **Near-plane override**, design units — `None` = the stock band
    /// ([`Self::NEAR_PLANE`] on the CPU clip, the tight `(distance − 2).max(0.05)`
    /// band on the GPU matrix). Written by [`crate::Map3D`] when a per-frame
    /// overlay rides CLOSE to the eye (the korp rider's seat is metres away —
    /// `~1e-3` design units on a city scene, far inside both stock nears, so the
    /// avatar was clipped invisible while every count stayed green). Flows through
    /// [`Self::view`] into the one projection writer.
    pub near_override: Option<f32>,
}

impl Default for OrbitCamera {
    fn default() -> Self {
        let (az, el) = ViewPreset::Isometric.angles();
        Self {
            target: V3::new(0.0, 0.1, 0.0),
            azimuth: az,
            roll: 0.0,
            elevation: el,
            distance: 3.2,
            fov_y: Self::DEFAULT_FOV_Y_DEG.to_radians(),
            goal_target: V3::new(0.0, 0.1, 0.0),
            goal_azimuth: az,
            goal_elevation: el,
            plan_2d: false,
            free_look: false,
            goal_distance: 3.2,
            feel: InputFeel::default(),
            min_distance: Self::FRAME_MIN_DISTANCE,
            max_distance: 20.0,
            near_override: None,
        }
    }
}

/// Polar lift is clamped to just shy of the poles so the look-at `up` never
/// degenerates (no gimbal flip looking straight up/down).
const ELEV_LIMIT: f32 = std::f32::consts::FRAC_PI_2 - 0.01;

/// **Ground floor for the polar lift.** A map is always viewed from ABOVE: the eye
/// must never dip to or below the terrain plane (`y = 0`). When it does, the camera
/// looks at the ground edge-on / from underneath, and the terrain quad projects as
/// a viewport-filling wedge (the green smear) while buildings hang upside-down.
/// Clamp elevation to a hair above level so the lowest reachable view is a true
/// street-level oblique looking slightly down — never below the horizon. (≈1.1°.)
const MIN_ELEV: f32 = 0.02;

/// Clamp a polar lift into the legal `[MIN_ELEV, ELEV_LIMIT]` band — above the
/// ground plane, below the pole. The single source of truth for the elevation
/// limits so orbit / preset / damping / settle all agree.
#[inline]
fn clamp_elev(e: f32) -> f32 {
    e.clamp(MIN_ELEV, ELEV_LIMIT)
}

/// **Soft ease-out toward the dolly floor** (FIX 1 — the no-snap final approach).
///
/// A hard `clamp(min_distance, …)` makes the last stretch of a zoom-in collapse
/// onto the floor in a single tick: above the floor the step is full-size, at the
/// floor it's zero — a visible **snap**. Instead we bend any requested distance
/// `raw` smoothly as it nears [`OrbitCamera::MIN_DISTANCE`] so consecutive steps
/// shrink continuously (asymptotic, never quite reaching the floor):
///
/// ```text
/// out = MIN + SOFT · softplus((raw − MIN) / SOFT)
///     = MIN + SOFT · ln(1 + exp((raw − MIN)/SOFT))
/// ```
///
/// Far from the floor (`raw ≫ MIN`) this is ≈ `raw` (the zoom is unchanged — same
/// far-out speed). As `raw → MIN` it eases off; even for `raw < MIN` the output
/// stays a hair above `MIN` and keeps decreasing, so there is never a flat snap.
/// `SOFT` sets how many design-units before the floor the easing kicks in.
#[inline]
pub fn soft_min_distance(raw: f32) -> f32 {
    /// Easing scale: the approach goes "soft" only within ≈this many design units of
    /// the floor — kept tiny so (a) far-out dollying is bit-for-bit the requested
    /// distance (speed unchanged) and (b) the asymptotic stall still lands close to
    /// the floor (house level is reachable), it just decelerates into it instead of
    /// snapping. (A large SOFT would stall the zoom well short of the ground.)
    const SOFT: f32 = 0.015;
    soft_floor(raw, OrbitCamera::MIN_DISTANCE, SOFT)
}

/// **The one-sided soft floor** — [`soft_min_distance`]'s rule, generalised so the
/// ground clamp can use the same one (A-2a), and sharpened so it costs nothing above
/// the bend.
///
/// ```text
/// out = raw                                for raw >= floor + soft
/// out = floor + soft / (2 − (raw−floor)/soft)   below that
/// ```
///
/// Four properties, and each one is a thing a hard `if x < floor { x = floor }` gets
/// wrong:
///
/// * **EXACTLY identity above the bend.** Not "approximately": `raw` is returned
///   unchanged. A softplus is only asymptotically identity, and the residual it leaves
///   AT the floor is `soft·ln 2` — MEASURED as 6.8 m of unrequested altitude on a
///   625 m/du scene, which is the whole defect back in a smaller size.
/// * **C¹ at the join.** At `t = 1` the value is `floor + soft = raw` and the slope is
///   `1/(2−t)² = 1`, so there is no kink where the bend starts — which is what a
///   "visible snap" is.
/// * **strictly increasing everywhere**, including far below the floor. A hard clamp is
///   FLAT below it: the output stops responding to the input, which is how a camera
///   comes to ignore the altitude it was given.
/// * **always `> floor`**, since `soft/(2−t) > 0` for every `t < 1`. The ground is
///   approached and never crossed; there is no case to special-case.
///
/// `soft` is the width of the bend, in the same units as `raw`.
#[inline]
pub fn soft_floor(raw: f32, floor: f32, soft: f32) -> f32 {
    if soft.is_nan() || soft <= 0.0 {
        return raw.max(floor);
    }
    let t = (raw - floor) / soft;
    if t >= 1.0 {
        return raw;
    }
    floor + soft / (2.0 - t)
}

impl OrbitCamera {
    /// **The unified view this camera IS** (GFX_V2 item 6) — a
    /// [`facett_core::render::View`] in perspective mode with the heightfield and
    /// the full post stack on. `tilt = PI/2 − elevation`, `azimuth` verbatim.
    ///
    /// [`eye`](Self::eye), [`basis`](Self::basis), [`view_space`](Self::view_space),
    /// [`project_view`](Self::project_view) and [`view_proj`](Self::view_proj) all
    /// delegate to it, so the 3D lane has no projection writer of its own and the
    /// 2D lane (`facett-map`) is provably the same code under constraints (LAW #5).
    ///
    /// **In [`plan_2d`](Self::plan_2d) mode it returns [`View::ortho_2d`] instead** —
    /// the fully constrained 2D view, which is the one thing `is_constrained_2d()`
    /// accepts and therefore the only way the post-FX bypass is reachable.
    ///
    /// WHY A MODE AND NOT A RELAXED TILT CLAMP. `ELEV_LIMIT` keeps `tilt` away from
    /// exactly 0, and it would be safe to relax: the reason in its doc — "so the
    /// look-at `up` never degenerates" — is stale, because the closed-form basis this
    /// now delegates to is well-conditioned at the pole (asserted in
    /// `tests/postfx_bypass.rs`, on orthonormality AND on east staying east). Relaxing
    /// it is still the WRONG lever, for a reason that has nothing to do with the pole:
    /// `is_constrained_2d()` tests `tilt == 0.0` EXACTLY, so a bypass reached by
    /// dragging the orbit would switch the entire post chain off at one float value and
    /// back on at the next — a visible pop mid-drag between a bloomed, ambient-occluded
    /// frame and a flat one. An exact-equality constraint is only coherent for a mode
    /// you ENTER, so entering it is what this is. The clamp also still earns `MIN_ELEV`,
    /// a separate and entirely live concern (the eye dipping below the ground plane).
    #[must_use]
    pub fn view(&self) -> facett_core::render::View {
        if self.plan_2d {
            // Scale chosen so a mode switch PRESERVES FRAMING rather than jumping. A
            // perspective camera at `distance` with `fov_y` shows a vertical half-extent
            // of `distance · tan(fov_y/2)` world units; `View::view_proj` treats an
            // Ortho `px_per_unit` as the NDC scale against a canonical half-height of 1,
            // so the matching ortho scale is its reciprocal.
            let half_extent = (self.distance * (self.fov_y * 0.5).tan()).max(1e-4);
            let s = 1.0 / half_extent;
            return facett_core::render::View::ortho_2d_map([self.target.x, self.target.z], [s, s]);
        }
        facett_core::render::View::from_orbit(
            self.target,
            self.azimuth,
            self.elevation,
            self.distance,
            self.fov_y,
        )
        .with_roll(self.roll)
        .with_near(self.near_override)
    }

    /// The **effective CPU-clip near plane**: the override, else [`Self::NEAR_PLANE`].
    /// The CPU painter's Sutherland–Hodgman clip reads this (never the constant), so
    /// the clip and the projector's own reject cannot disagree about "behind the eye".
    #[must_use]
    pub fn near_clip(&self) -> f32 {
        self.near_override.unwrap_or(Self::NEAR_PLANE)
    }

    /// The eye position, derived from `target` + spherical offset. Delegates to the
    /// unified view core.
    pub fn eye(&self) -> V3 {
        let e = self.view().eye();
        e.into()
    }

    /// Forward (eye → target), right, and up basis vectors of the view.
    ///
    /// **Delegates to the unified core's CLOSED-FORM frame**, which is an exact
    /// algebraic identity with the `normalize(fwd × world_up)` look-at this used to
    /// compute — but is also well-conditioned when the camera looks straight down,
    /// where the cross product vanishes. That mattered more than it looks: at exact
    /// top-down the old form returned a unit `right` whose sign was the rounding
    /// residue of `cos(PI/2)`, i.e. it silently MIRRORED east and west. See
    /// `facett_core::render::view` and `tests/gfx_v2_item6_parity.rs`.
    pub fn basis(&self) -> (V3, V3, V3) {
        let (f, r, u) = self.view().basis();
        (f.into(), r.into(), u.into())
    }

    // ── interactive controls (write goals; `update` eases toward them) ────────

    /// Orbit by a screen-pixel drag delta (dx right, dy down).
    pub fn orbit(&mut self, dx: f32, dy: f32) {
        self.free_look = false; // an orbit drag takes the turntable back (A-2b)
        self.goal_azimuth -= dx * self.feel.orbit_per_px;
        self.goal_elevation =
            clamp_elev(self.goal_elevation + dy * self.feel.orbit_per_px);
    }

    /// Snap the live orbit **angles** (azimuth + elevation) straight to their goals,
    /// leaving distance/target to the damper. Called on every frame of an **active
    /// drag** so rotation tracks the cursor **1:1** — no easing lag, i.e. true "gyro"
    /// feel. (The exponential damper still smooths dolly/pan/keyboard/reset and the
    /// flick-release settle; it just must not sit *between* the cursor and the
    /// rotation while the user is directly holding-and-dragging it.)
    pub fn snap_angles_to_goal(&mut self) {
        self.azimuth = self.goal_azimuth;
        self.elevation = self.clamp_elev_unless_free(self.goal_elevation);
    }

    /// Pan by a screen-pixel drag delta — slides the **target** in the camera's
    /// right/up plane, scaled by distance so the world tracks the cursor at any
    /// zoom.
    ///
    /// **A grab, whatever `feel.natural_scroll` says.** The only caller is a pointer
    /// DRAG (`Map3D::ui`: middle button, or shift + primary), and a drag is a grab on
    /// every OS: the world follows the hand. "Natural" scrolling is a convention of
    /// SCROLL gestures, and the OS has already applied it to the scroll deltas egui
    /// reports (which dolly here, they never reach `pan`); flipping a drag on a Mac
    /// would make the world run away from the cursor. This doc used to say "Honors
    /// natural-scroll sign" over an `if natural_scroll { 1.0 } else { 1.0 }` that never
    /// did — the code was right and the sentence was not.
    /// `tests::pan_is_a_grab_whatever_the_natural_scroll_feel` pins it.
    pub fn pan(&mut self, dx: f32, dy: f32) {
        let (_f, right, up) = self.basis();
        let s = self.feel.pan_per_px * self.distance;
        // Dragging right moves the world right (target left), dragging down moves
        // the world down (target up).
        let delta = right.scale(-dx * s).add(up.scale(dy * s));
        self.goal_target = self.goal_target.add(delta);
    }

    /// **Hard floor** the camera distance may approach but never reach. The dolly
    /// eases asymptotically toward this; it is NOT a `clamp()` target (that was the
    /// "brutal snap" near house level — see [`soft_min_distance`]). Kept well below
    /// [`Self::FRAME_MIN_DISTANCE`] so a street-level approach has room to breathe.
    ///
    /// **This is the floor a HUMAN reaches.** Its twin below is the floor an automatic
    /// FIT settles on, and the two are different quantities that happened to share one
    /// name — see [`Self::FRAME_MIN_DISTANCE`].
    pub const MIN_DISTANCE: f32 = 0.06;

    /// **The floor an automatic FIT parks at** — the default of the [`Self::min_distance`]
    /// field, the value [`crate::Map3D::frame_extent`] clamps a computed framing distance
    /// against.
    ///
    /// It is deliberately **6.7×** [`Self::MIN_DISTANCE`], and that gap is the whole
    /// point: a fitter's job is to hold a whole town on screen, a dolly's job is to let
    /// you walk into it. Two floors, seven times apart, and which one binds depends on
    /// whether a human or the camera fitter moved the camera (`hus3d-5`). Neither number
    /// is wrong; what was wrong is that only one of them had a name, so the pair read as
    /// a duplicated constant rather than as two answers to two questions, and no test
    /// asserted which bound where. `tests/no_town_still_bounds_the_camera.rs`
    /// (`the_two_dolly_floors_are_two_questions`) now pins both ends.
    ///
    /// `Map3D::frame_viewport` is the one path that lowers the field to
    /// [`Self::MIN_DISTANCE`], because a viewport-sized scene is derived from exactly
    /// that constant (`extrude::scene_reach_half_m` solves for it) and clamping the
    /// result 6.7× higher voids the derivation underneath it.
    pub const FRAME_MIN_DISTANCE: f32 = 0.4;

    /// **The default vertical field of view, in degrees.** Named because the scene
    /// scaling now has to solve for it: [`crate::extrude::scene_reach_half_m`] asks
    /// *how much world can the design box hold before the eye can no longer get close
    /// enough to a house to see it stand up*, and the answer is a ratio of
    /// [`Self::MIN_DISTANCE`] to `tan(fov_y/2)`. That question cannot be answered from a
    /// literal buried in `Default::default()` (L5).
    pub const DEFAULT_FOV_Y_DEG: f32 = 50.0;

    /// Dolly (zoom) by a scroll amount, optionally **toward a cursor ray** so the
    /// world point under the cursor stays put (the modern "dolly to cursor"
    /// feel). `cursor_ndc` is the cursor in `[-1,1]` NDC (None ⇒ zoom to centre).
    ///
    /// ## Smooth near-ground approach (FIX 1)
    /// The step is **distance-proportional** — `factor = exp(-scroll·k)` multiplies
    /// the current goal, so the SAME wheel tick that moves you 5 m at altitude moves
    /// you only centimetres near the ground. That keeps the far-out speed and makes
    /// the final approach fine-grained. The old hard `clamp(min_distance, …)` is
    /// replaced by [`soft_min_distance`]: an asymptotic ease-out toward
    /// [`MIN_DISTANCE`] so the last ~metres compress smoothly instead of slamming
    /// onto a floor in a single brutal tick (the "snap").
    pub fn dolly(&mut self, scroll: f32, cursor_ndc: Option<(f32, f32)>) {
        let factor = (-scroll * self.feel.dolly_per_scroll).exp();
        let raw = self.goal_distance * factor;
        // Soft ease-out near the floor (no snap), hard clamp only at the far max.
        let new_dist = soft_min_distance(raw).min(self.max_distance);
        if let Some((nx, ny)) = cursor_ndc {
            // Shift the target toward the cursor ray proportionally to how much we
            // zoomed in, so the point under the cursor is preserved.
            let (fwd, right, up) = self.basis();
            let aspect_tan = (self.fov_y * 0.5).tan();
            // Cursor direction in world space at the focal plane.
            let dir = fwd
                .add(right.scale(nx * aspect_tan))
                .add(up.scale(ny * aspect_tan))
                .normalized();
            // Move target a fraction of the distance change along the cursor ray.
            let moved = self.goal_distance - new_dist;
            self.goal_target = self.goal_target.add(dir.scale(moved));
            let _ = (fwd,);
        }
        self.goal_distance = new_dist;
    }

    /// Snap to a canonical view-angle preset (keeps distance + target).
    pub fn apply_preset(&mut self, p: ViewPreset) {
        self.free_look = false; // a canonical orbit angle is an orbit pose (A-2b)
        let (az, el) = p.angles();
        self.goal_azimuth = az;
        self.goal_elevation = clamp_elev(el);
    }

    /// Reset to the default framing (iso, recentred).
    pub fn reset(&mut self) {
        self.free_look = false; // a reset is a return to the default ORBIT framing
        let d = OrbitCamera::default();
        self.goal_target = d.goal_target;
        self.goal_azimuth = d.goal_azimuth;
        self.goal_elevation = d.goal_elevation;
        self.goal_distance = d.goal_distance;
    }

    /// Keyboard nudge (WASD / arrows): orbit a fixed step. `+`/`-` use [`dolly`].
    pub fn key_orbit(&mut self, daz: f32, del: f32) {
        self.free_look = false; // a keyboard orbit nudge is an orbit gesture (A-2b)
        self.goal_azimuth += daz;
        self.goal_elevation = clamp_elev(self.goal_elevation + del);
    }

    // ── damping ───────────────────────────────────────────────────────────────

    /// Ease the live values toward the goals over `dt` seconds. Frame-rate
    /// independent: the smoothing factor is `1 - exp(-k·dt)`. Returns `true` while
    /// still moving (so the host can keep requesting repaints).
    pub fn update(&mut self, dt: f32) -> bool {
        let t = 1.0 - (-self.feel.damping * dt.max(0.0)).exp();
        let lerp = |a: f32, b: f32| a + (b - a) * t;
        let before = (self.azimuth, self.elevation, self.distance, self.target);

        self.azimuth = lerp(self.azimuth, self.goal_azimuth);
        self.elevation = self.clamp_elev_unless_free(lerp(self.elevation, self.goal_elevation));
        self.distance = lerp(self.distance, self.goal_distance);
        self.target = V3::new(
            lerp(self.target.x, self.goal_target.x),
            lerp(self.target.y, self.goal_target.y),
            lerp(self.target.z, self.goal_target.z),
        );

        // Never let the eased eye sink through the ground (FIX 2).
        self.clamp_above_ground();

        (self.azimuth - before.0).abs() > 1e-5
            || (self.elevation - before.1).abs() > 1e-5
            || (self.distance - before.2).abs() > 1e-5
            || self.target.sub(before.3).len() > 1e-5
    }

    /// Jump live values straight to the goals (deterministic; for headless tests
    /// + the very first frame).
    pub fn settle(&mut self) {
        self.azimuth = self.goal_azimuth;
        self.elevation = self.clamp_elev_unless_free(self.goal_elevation);
        self.distance = self.goal_distance;
        self.target = self.goal_target;
        // A settled pose must also respect the ground (FIX 2).
        self.clamp_above_ground();
    }

    // ── projection ──────────────────────────────────────────────────────────

    /// The **bounded far plane** (camera-space depth, design units). The scene
    /// sits within a few units of the target (the design box is `[-1,1]³` and the
    /// camera orbits at `distance`), so anything past `distance + FAR_PAD` is
    /// beyond the horizon and must be culled — without a far bound, a terrain-quad
    /// far edge at a low elevation skims `cz → 0⁺` off-axis and the perspective
    /// divide smears it to eternity (the "clips to infinity" bug). Mirrors the GPU
    /// [`Self::view_proj`] far (`distance + 4.0`) so both render paths agree.
    pub fn far_plane(&self) -> f32 {
        /// How far past the orbit distance the frustum still keeps geometry — the
        /// design box's far corner from the eye is ≈ `distance + √3`, so 4 units of
        /// pad comfortably contains the whole city while still bounding the horizon.
        const FAR_PAD: f32 = 4.0;
        // The distance is FLOORED before the pad — see
        // `facett_core::render::View::FAR_MIN_DISTANCE`: the ground-aware flight dolly
        // (A-2a) can be centimetres long, and the frustum must not shrink with it.
        self.distance.max(facett_core::render::View::FAR_MIN_DISTANCE) + FAR_PAD
    }

    /// The **view-space near plane** (camera-space depth, design units). Geometry
    /// with `cz <= NEAR_PLANE` sits on or behind the eye and must be clipped
    /// **before** the perspective divide — dividing by a `cz` at or below this
    /// blows the projected coord up toward infinity (the off-screen "smear"). The
    /// step-1 [`super::Map3D::shade_faces`] view-space Sutherland–Hodgman clip
    /// splits triangles against exactly this plane so no surviving vertex ever
    /// divides by a near-zero (or negative) depth.
    pub const NEAR_PLANE: f32 = 0.02;

    /// A world point transformed into **view space** (`x=right, y=up, z=forward`,
    /// relative to the eye). The near-plane clip operates here, before projection.
    /// Delegates to the unified view core.
    pub fn view_space(&self, p: V3) -> V3 {
        let c = self.view().view_space(p.into());
        c.into()
    }

    /// Project an already-**view-space** point (`c.x=right, c.y=up, c.z=forward`)
    /// to screen pixels with **perspective**. Used by the step-1 near-plane clip,
    /// which has already guaranteed `c.z > NEAR_PLANE`, so the divide is safe and
    /// the only frustum bound left to apply here is the far plane.
    ///
    /// Delegates to the unified view core, which applies exactly this near/far
    /// reject and the same `focal = half_h / tan(fov_y/2)` divide.
    pub fn project_view(&self, c: V3, center: (f32, f32), half_h: f32) -> Projected {
        self.view().project_px(c.into(), center, half_h)
    }

    /// Project a world point to screen pixels with **perspective**. `center` is
    /// the viewport centre in pixels; `half_h` is half the viewport height in
    /// pixels (the projection scale). Returns pixel position + camera-space depth
    /// + a near/far-plane visibility flag.
    pub fn project(&self, p: V3, center: (f32, f32), half_h: f32) -> Projected {
        self.project_view(self.view_space(p), center, half_h)
    }

    /// The **view-projection matrix** for the GPU depth-tested path — the same
    /// look-at + perspective the CPU [`Self::project`] does, as a column-major
    /// `[f32; 16]` ready for a wgpu uniform. Camera space is `x=right, y=up,
    /// z=forward` (matching `project`); the projection maps depth into wgpu's
    /// `[0, 1]` clip-z so the GPU **depth test** orders fragments correctly.
    ///
    /// `aspect` is viewport width / height. Returns `M` such that
    /// `clip = M · [world, 1]` (column-major, i.e. `clip_i = Σ_j M[j*4+i]·v[j]`).
    pub fn view_proj(&self, aspect: f32) -> [f32; 16] {
        // GFX_V2 item 6: this was 45 lines of hand-assembled `P * V`, duplicated
        // VERBATIM in `facett_core::render::camera::Camera::view_proj`. Both copies
        // are gone; the unified view core is the one writer, and `facett-map`'s 2D
        // ortho path is now provably the same code under constraints (LAW #5).
        self.view().view_proj(aspect).to_cols_array()
    }

    // ── ground collision / distance-to-surface (FIX 2) ────────────────────────

    /// The terrain surface clearance the eye must keep above the ground — a small
    /// design-unit lift so the camera never sits *exactly* on the plane (which would
    /// look through it / z-fight) and the asymptotic dolly always has headroom.
    pub const SURFACE_EPS: f32 = 1.0e-3;

    /// **How wide the ground floor's softplus bend is**, design units (A-2a).
    ///
    /// Expressed in [`Self::SURFACE_EPS`] rather than picked: the epsilon is already
    /// this camera's declaration of "close enough to the ground to be touching it", so
    /// the width over which the approach decelerates is two of them. Above
    /// `floor + GROUND_SOFT` [`soft_floor`] is EXACTLY the identity, so an ordinary
    /// orbit, and every flight pose whose dolly stops at that height (see
    /// [`crate::Map3D::look_from`]), is bit-for-bit unchanged by the clamp.
    ///
    /// It is deliberately tight — on a 625 m/du scene it is 1.25 m. The orbit case it
    /// eases was a HARD clamp before this change, so any finite bend is strictly better
    /// there; the flight case does not rely on the bend at all, because the ground-aware
    /// dolly means the clamp is never asked.
    pub const GROUND_SOFT: f32 = 2.0 * Self::SURFACE_EPS;

    /// **Surface height under a world `(x, z)`** — the ground the camera must stay
    /// above. Today this is the flat terrain plane at `y = 0` (the city sits on it;
    /// see `extrude::GROUND_LIFT`). This is the **seam for house collision**: when
    /// building AABBs/heights are wired in, this returns `max(terrain, building_top)`
    /// under `(x, z)` and the same clamp keeps the eye above rooftops. For now it is
    /// constant, taking the args so callers (and the future raycast) need no change.
    ///
    /// See `.nornir/camera-feel-and-ground.md` for the house-collision plan
    /// (raycast the camera ray vs building prisms, clamp distance at the first hit).
    #[inline]
    pub fn surface_height_at(&self, _world_x: f32, _world_z: f32) -> f32 {
        // Flat terrain plane (extrude.rs draws the ground quad at y = 0).
        0.0
    }

    /// **Distance from the eye down to the ground surface** beneath it (design
    /// units), i.e. how much air is under the camera. Always `>= 0` once the eye is
    /// clamped above ground ([`Self::clamp_above_ground`]). Surfaced in `state_json`
    /// so the matrix / robot-UI can assert the camera never sinks through terrain.
    pub fn distance_to_surface(&self) -> f32 {
        let e = self.eye();
        (e.y - self.surface_height_at(e.x, e.z)).max(0.0)
    }

    /// [`clamp_elev`], unless this pose is a 6DOF one — see [`Self::free_look`].
    #[inline]
    fn clamp_elev_unless_free(&self, e: f32) -> f32 {
        if self.free_look { e } else { clamp_elev(e) }
    }

    /// **Clamp the eye to stay above the terrain surface** — the camera must never
    /// fly through the ground. Because the eye is derived from `target + distance ·
    /// dir(az, el)`, lowering the eye means shrinking `distance` (we keep the orbit
    /// angles + target, only pull the eye in along the view ray until it clears the
    /// surface by [`Self::SURFACE_EPS`]).
    ///
    /// Geometry: `eye.y = target.y + distance · sin(elev)`. With `elev ≥ MIN_ELEV`
    /// the eye is always *above* the target, so an over-long distance only lifts it
    /// — the collision case is a **low target near the ground** combined with a
    /// distance that would still push the eye below `surface + EPS`. We solve for
    /// the max distance that keeps `eye.y ≥ surface + EPS` and shrink to it. (When
    /// `sin(elev) > 0` the eye rises with distance, so the binding constraint is the
    /// target itself; we then also lift the target a hair above ground.) Mutates
    /// both the live `distance`/`target` and their goals so damping can't re-sink.
    pub fn clamp_above_ground(&mut self) {
        // 1) The target must not be under the ground (pan/dolly can drag it down).
        //
        // ── A-2a: THIS WAS A TELEPORT, AND THEN A PIN. ──
        // It read `if target.y < floor { target.y = floor }`. The eye is DERIVED —
        // `eye = target − fwd·distance`, `fwd.y = −sin(elevation)` — so raising the
        // target by Δ raises the eye by exactly Δ. `Map3D::look_from` parks the target
        // one dolly-length down the view ray, so a nose-down pose puts it BELOW the eye
        // and the hard clamp fired for every commanded altitude under
        // `(SURFACE_EPS − DOLLY_DU·sin(pitch))·m_per_du`. Below that the clamp is FLAT:
        // it wrote the goal too, so `eye.y` came out the same number whatever altitude
        // was asked for, and the camera stopped following the rig. MEASURED on a 625 m/du
        // scene at pitch −0.25: the eye pinned at 309.9 m and 308 of 1999 one-metre
        // descent commands moved it by nothing at all.
        //
        // Softplus instead. It is the rule `soft_min_distance` already applies to the
        // dolly floor one screen above, for the same reason stated there ("a hard clamp
        // makes the last stretch collapse in a single tick — a visible snap"), and its
        // three properties are exactly the three things wanted here: ≈ identity far
        // above the floor (an ordinary orbit is untouched), strictly increasing
        // everywhere (the eye never stops following the command), and never at or below
        // the floor (the ground is approached, not crossed).
        let ground = self.surface_height_at(self.target.x, self.target.z);
        let floor = ground + Self::SURFACE_EPS;
        self.target.y = soft_floor(self.target.y, floor, Self::GROUND_SOFT);
        self.goal_target.y = soft_floor(self.goal_target.y, floor, Self::GROUND_SOFT);
        // 2) The eye must clear the surface under IT. Pull `distance` in if needed.
        let clear = |c: &Self, dist: f32| -> bool {
            let (se, ce) = c.elevation.sin_cos();
            let (sa, ca) = c.azimuth.sin_cos();
            let ex = c.target.x + ce * sa * dist;
            let ez = c.target.z + ce * ca * dist;
            let ey = c.target.y + se * dist;
            ey >= c.surface_height_at(ex, ez) + Self::SURFACE_EPS
        };
        // Eye y rises with distance when sin(elev) > 0, so if the full distance
        // already clears, nothing to do; otherwise shrink to the largest clearing
        // distance (binary search — robust to a non-flat future surface_height_at).
        if !clear(self, self.distance) {
            let mut lo = 0.0f32;
            let mut hi = self.distance;
            for _ in 0..40 {
                let mid = 0.5 * (lo + hi);
                if clear(self, mid) {
                    lo = mid;
                } else {
                    hi = mid;
                }
            }
            self.distance = lo;
            if self.goal_distance > lo {
                self.goal_distance = lo;
            }
        }
    }

    /// The **mirror-camera view-projection** for the planar water reflection
    /// (Phase 2): the camera reflected about the water plane `y = water_h`. A clone
    /// whose `target`/`goal_target` are mirrored and whose elevation is flipped
    /// (looking up from below the plane), so re-rendering the scene through it
    /// yields the reflected skyline the water samples. Column-major `[f32;16]`.
    #[must_use]
    pub fn view_proj_mirrored(&self, aspect: f32, water_h: f32) -> [f32; 16] {
        let mut m = self.clone();
        // Reflect the target about the plane y = water_h.
        m.target = V3::new(self.target.x, 2.0 * water_h - self.target.y, self.target.z);
        m.goal_target = m.target;
        // Flip elevation so the eye sits on the OPPOSITE side of the plane (the
        // mirror eye). eye.y = target.y + distance·sin(elev); negating elevation
        // reflects the eye height about the target — and the target is already
        // mirrored — giving the eye reflected about the water plane.
        m.elevation = -self.elevation;
        m.view_proj(aspect)
    }

    // ── garmin-nav vocabulary: bearing + tilt ──────────────────────────────────
    //
    // A car-navigator / Garmin expresses the camera as a **bearing** (compass
    // heading it looks along) and a **tilt** (how far the view is pitched down from
    // straight-above). These are just the orbit `azimuth`/`elevation` renamed into
    // nav terms — additive, no new state, so the whole orbit machinery (damping,
    // ground-clamp, projection) is reused unchanged.

    /// The compass **bearing** the camera looks along, degrees in `[0, 360)` — the
    /// turntable `azimuth` in nav terms. Rotating the bearing swings the horizontal
    /// heading of the view (the "which way am I facing" of a car-nav).
    pub fn bearing_deg(&self) -> f32 {
        let d = self.azimuth.to_degrees() % 360.0;
        if d < 0.0 { d + 360.0 } else { d }
    }

    /// Set the compass bearing (degrees). Writes the orbit goal, so it eases with
    /// the same inertial damper as a drag (deterministic under [`settle`]).
    pub fn set_bearing_deg(&mut self, deg: f32) {
        self.goal_azimuth = deg.to_radians();
    }

    /// **Tilt** from top-down, degrees: `0` = plan / map view (looking straight
    /// down), `90` = horizon (level). The garmin-nav pitch. Maps onto the polar
    /// `elevation` as `tilt = 90° − elevation`.
    pub fn tilt_deg(&self) -> f32 {
        90.0 - self.elevation.to_degrees()
    }

    /// Set the tilt-from-top-down (degrees). Clamped to the legal above-ground band
    /// (never below the horizon, never through the pole) — the single-source
    /// [`clamp_elev`], same as orbit/preset — so a car-nav tilt can't flip or dive
    /// underground.
    pub fn set_tilt_deg(&mut self, deg: f32) {
        self.free_look = false; // the car-nav tilt is an orbit tilt (A-2b)
        self.goal_elevation = clamp_elev((90.0 - deg).to_radians());
    }

    /// **Car-navigator chase tilt** (degrees from top-down) — the low, forward-leaning
    /// pitch a turn-by-turn follow / the [`crate::tour::SigtunaTour`] drive phase holds
    /// so the driver sees the road ahead. Single source of truth (L5): both the map's
    /// route-follow and the tour read this one value.
    pub const CHASE_TILT_DEG: f32 = 62.0;
    /// **Car-navigator chase dolly** (design units) — close in, like a bonnet/hood cam,
    /// so the street ribbon fills the view. Shared by route-follow + the tour drive.
    pub const CHASE_DIST: f32 = 0.55;

    /// State for `state_json` / introspection (camera pose).
    pub fn pose_json(&self) -> serde_json::Value {
        let e = self.eye();
        serde_json::json!({
            "azimuth_deg": self.azimuth.to_degrees(),
            "elevation_deg": self.elevation.to_degrees(),
            "bearing_deg": self.bearing_deg(),
            "tilt_deg": self.tilt_deg(),
            "distance": self.distance,
            "fov_deg": self.fov_y.to_degrees(),
            "target": [self.target.x, self.target.y, self.target.z],
            "eye": [e.x, e.y, e.z],
            "distance_to_surface": self.distance_to_surface(),
        })
    }
}

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

    /// INJECT-ASSERT: the eye sits exactly `distance` from the target, on the
    /// spherical offset for the given angles. Feed iso angles → check the radius
    /// and that elevation actually lifts the eye above the target.
    #[test]
    fn eye_is_on_a_sphere_of_radius_distance() {
        let c = OrbitCamera {
            azimuth: 0.7,
            elevation: 0.5,
            distance: 4.0,
            target: V3::new(1.0, 0.0, -2.0),
            ..Default::default()
        };
        let r = c.eye().sub(c.target).len();
        assert!((r - 4.0).abs() < 1e-4, "eye radius == distance, got {r}");
        // Positive elevation lifts the eye above the target.
        assert!(c.eye().y > c.target.y, "elevation>0 → eye above target");
    }

    /// INJECT-ASSERT: presets produce the documented angles, and applying a preset
    /// then settling actually moves the live azimuth/elevation to them.
    #[test]
    fn presets_set_known_angles() {
        let (az, el) = ViewPreset::Top.angles();
        assert!((el - (std::f32::consts::FRAC_PI_2 - 0.001)).abs() < 1e-4, "top looks down");
        assert_eq!(az, 0.0, "top is north-up");

        let mut c = OrbitCamera::default();
        c.apply_preset(ViewPreset::Front);
        c.settle();
        let (faz, fel) = ViewPreset::Front.angles();
        assert!((c.azimuth - faz).abs() < 1e-5 && (c.elevation - fel).abs() < 1e-5);
    }

    /// INJECT-ASSERT: orbiting by a drag changes azimuth/elevation by the expected
    /// pixel-scaled amount, and elevation is clamped at the pole.
    #[test]
    fn orbit_drag_rotates_and_clamps() {
        let mut c = OrbitCamera {
            goal_azimuth: 0.0,
            goal_elevation: 0.0,
            ..Default::default()
        };
        c.orbit(100.0, 50.0);
        assert!((c.goal_azimuth + 100.0 * c.feel.orbit_per_px).abs() < 1e-6);
        assert!((c.goal_elevation - 50.0 * c.feel.orbit_per_px).abs() < 1e-6);
        // Drag far past the pole → clamped, never flips.
        c.orbit(0.0, 100000.0);
        assert!(c.goal_elevation <= ELEV_LIMIT + 1e-6, "elevation clamped at pole");
    }

    /// **A pan drag is a GRAB under every feel** — `feel.natural_scroll` does not flip
    /// it (see [`OrbitCamera::pan`]: the only caller is a pointer drag, and natural
    /// scrolling is a scroll-gesture convention the OS has already applied). Dragging
    /// right/down must move the target along −right/+up — the world follows the hand —
    /// for the Windows AND the macOS feel, with the flag either way; and two cameras that
    /// differ ONLY in the flag must land on bit-identical targets. A sign flip keyed on
    /// the flag fails the second half, a global sign flip fails the first.
    #[test]
    fn pan_is_a_grab_whatever_the_natural_scroll_feel() {
        for base in [InputFeel::windows(), InputFeel::macos()] {
            for natural_scroll in [false, true] {
                let mut c = OrbitCamera { feel: InputFeel { natural_scroll, ..base }, ..Default::default() };
                c.settle();
                let (_f, right, up) = c.basis();
                let t0 = c.goal_target;
                c.pan(40.0, 0.0);
                let moved = c.goal_target.sub(t0);
                assert!(moved.dot(right) < 0.0, "drag right → target left (world follows the hand), natural_scroll={natural_scroll}");
                assert!(moved.dot(up).abs() < 1e-6, "a horizontal drag does not move the target vertically");
                let t1 = c.goal_target;
                c.pan(0.0, 25.0);
                let moved = c.goal_target.sub(t1);
                assert!(moved.dot(up) > 0.0, "drag down → target up (world moves down), natural_scroll={natural_scroll}");
            }
            let mut plain = OrbitCamera { feel: InputFeel { natural_scroll: false, ..base }, ..Default::default() };
            let mut natural = OrbitCamera { feel: InputFeel { natural_scroll: true, ..base }, ..Default::default() };
            plain.pan(37.0, -12.0);
            natural.pan(37.0, -12.0);
            assert_eq!(plain.goal_target, natural.goal_target, "natural_scroll must not change a drag pan");
        }
    }

    /// INJECT-ASSERT: a wheel dolly-in reduces distance (zoom in) and clamps to
    /// min; dolly-out increases it and clamps to max.
    #[test]
    fn dolly_zooms_and_clamps() {
        let mut c = OrbitCamera { goal_distance: 3.0, ..Default::default() };
        c.dolly(100.0, None); // positive scroll = zoom in
        assert!(c.goal_distance < 3.0, "zoom in shrinks distance, got {}", c.goal_distance);
        // Slam the wheel out → clamp at max.
        c.dolly(-100000.0, None);
        assert!((c.goal_distance - c.max_distance).abs() < 1e-3, "clamped to max");
    }

    /// **GYRO-DIRECT (the "not 100% gyro" rotation fix)** — while the user is
    /// actively dragging, the live azimuth/elevation must equal the goal at every
    /// frame (rotation tracks the cursor 1:1, zero damper lag). We replay a drag
    /// sweep two ways and assert: (a) with the gyro snap, the live angle is EXACTLY
    /// the cursor-driven goal after every drag step — no trailing; (b) the
    /// goal-only-then-damp path (the old feel) measurably LAGS the cursor mid-drag,
    /// proving the snap is what removes the softness. Multi-value over drag speeds.
    #[test]
    fn drag_rotation_is_gyro_direct_not_damped_lag() {
        for &dx_per_frame in &[2.0f32, 8.0, 25.0, 60.0] {
            let mut gyro = OrbitCamera::default();
            gyro.settle();
            let mut laggy = OrbitCamera::default();
            laggy.settle();
            let mut max_lag = 0.0f32;
            // 30 frames of a steady horizontal drag at ~60 fps.
            for _ in 0..30 {
                // (a) gyro-direct: orbit + snap (what the widget now does on drag).
                gyro.orbit(dx_per_frame, 0.0);
                gyro.snap_angles_to_goal();
                assert!(
                    (gyro.azimuth - gyro.goal_azimuth).abs() < 1e-6,
                    "gyro drag: live azimuth tracks the cursor goal 1:1 (no lag)"
                );
                // (b) old feel: orbit sets goal, the damper eases live toward it.
                laggy.orbit(dx_per_frame, 0.0);
                laggy.update(0.016);
                max_lag = max_lag.max((laggy.goal_azimuth - laggy.azimuth).abs());
            }
            // The cursor was driven the same amount in both; the gyro path ends ON
            // the cursor, the damped path ends BEHIND it by a real, visible angle.
            assert!(
                (gyro.azimuth - gyro.goal_azimuth).abs() < 1e-6,
                "gyro: live ends exactly on the goal"
            );
            assert!(
                max_lag > 1e-3,
                "damped path visibly trails the cursor mid-drag (lag {max_lag} rad) — \
                 this is the softness the snap removes"
            );
        }
    }

    /// **FIX 1 (the near-ground snap)** — a wheel zoom from high altitude down to
    /// house level must approach the ground SMOOTHLY: consecutive distances shrink
    /// by a **bounded, monotonically-shrinking** absolute step near the floor, never
    /// a single brutal jump. The old hard `clamp(min_distance, …)` collapsed the last
    /// ~metres onto the floor in one tick (the snap); the soft ease-out
    /// ([`soft_min_distance`]) makes each step a fraction of the last.
    ///
    /// Multi-value: sweep many start distances × tick sizes; for each, dolly IN tick
    /// by tick to the floor and assert (a) distance strictly decreases, (b) far-out
    /// the per-tick multiplicative factor is the requested constant (speed
    /// unchanged), and (c) near the ground NO single step's ABSOLUTE shrink exceeds
    /// the previous step's by more than 1.5× — i.e. the steps taper, never snap.
    #[test]
    fn dolly_to_ground_is_smooth_no_snap() {
        for &start in &[18.0f32, 12.0, 8.0, 4.0] {
            for &tick in &[20.0f32, 40.0, 80.0] {
                let mut c = OrbitCamera {
                    goal_distance: start,
                    distance: start,
                    ..Default::default()
                };
                let mut dists = vec![c.goal_distance];
                // Dolly IN until the approach converges onto the floor — small ticks
                // need many more steps to reach house level than big ones; stop when
                // a step no longer moves the distance (settled into the soft floor).
                for _ in 0..6000 {
                    let before = c.goal_distance;
                    c.dolly(tick, None);
                    dists.push(c.goal_distance);
                    if (before - c.goal_distance).abs() < 1e-6 {
                        break; // converged onto the asymptotic floor
                    }
                }
                // (a) Monotone decreasing (a zoom-in never grows the distance).
                for w in dists.windows(2) {
                    assert!(w[1] <= w[0] + 1e-6, "dolly-in never increases distance: {} -> {}", w[0], w[1]);
                }
                // (a') It actually got close to the floor (real approach, not stuck).
                let last = *dists.last().unwrap();
                assert!(
                    last < OrbitCamera::MIN_DISTANCE + 0.05,
                    "the zoom reaches house level (≈MIN_DISTANCE), got {last} (tick {tick}, start {start})"
                );
                assert!(last >= OrbitCamera::MIN_DISTANCE, "never sinks below the floor, got {last}");

                // (b) FAR-OUT speed unchanged: while distance ≫ floor, each tick is
                // the requested multiplicative factor exp(-tick·k) within a hair.
                let k = c.feel.dolly_per_scroll;
                let want = (-tick * k).exp();
                for w in dists.windows(2) {
                    if w[0] > OrbitCamera::MIN_DISTANCE + 1.0 {
                        let got = w[1] / w[0];
                        assert!(
                            (got - want).abs() < 1e-3,
                            "far-out dolly keeps its speed (factor {want:.4}), got {got:.4} at d={}",
                            w[0]
                        );
                    }
                }

                // (c) NO SNAP near the ground: the absolute per-tick shrink tapers —
                // no single step is more than 1.5× the previous step's shrink. (A
                // hard clamp would show one giant final step then zero — a snap.)
                let steps: Vec<f32> = dists.windows(2).map(|w| w[0] - w[1]).collect();
                let mut prev = f32::INFINITY;
                let mut max_ratio = 0.0f32;
                for &s in &steps {
                    if s <= 1e-7 {
                        continue; // converged tail — no movement to bound
                    }
                    if prev.is_finite() && prev > 1e-7 {
                        max_ratio = max_ratio.max(s / prev);
                    }
                    prev = s;
                }
                assert!(
                    max_ratio <= 1.5,
                    "near-ground steps taper (≤1.5× the prior step) — no snap; got max ratio {max_ratio:.3} (tick {tick}, start {start})"
                );
            }
        }
    }

    /// **FIX 1 unit** — [`soft_min_distance`] is ≈identity far from the floor (so
    /// far-out zoom is unchanged) and eases asymptotically as it nears MIN, always
    /// staying strictly above MIN and monotone in its input (no snap, no fold-back).
    #[test]
    fn soft_min_distance_is_smooth_and_asymptotic() {
        let min = OrbitCamera::MIN_DISTANCE;
        // Far from the floor → ≈ identity (zoom speed preserved).
        for &r in &[1.0f32, 3.0, 8.0] {
            assert!((soft_min_distance(r) - r).abs() < 1e-2, "≈identity far out at {r}: {}", soft_min_distance(r));
        }
        // At/above the floor it stays strictly above (the realistic dolly range —
        // distance is always positive); far-negative inputs asymptote down to MIN.
        for &r in &[0.0f32, min * 0.5, min, min * 2.0] {
            assert!(soft_min_distance(r) > min, "stays above the floor at raw={r}: {}", soft_min_distance(r));
        }
        for &r in &[-1.0f32, -5.0] {
            assert!(soft_min_distance(r) >= min, "asymptotes to the floor below it at raw={r}: {}", soft_min_distance(r));
        }
        // Monotone non-decreasing in the input (a smooth squash, never a fold).
        let mut prev = soft_min_distance(-2.0);
        let mut r = -2.0f32;
        while r < 10.0 {
            r += 0.05;
            let v = soft_min_distance(r);
            assert!(v >= prev - 1e-6, "monotone at raw={r}: {v} < {prev}");
            prev = v;
        }
    }

    /// **FIX 2 (no flying through the ground)** — across a POSE SWEEP, driving the
    /// dolly/target toward (and below) the ground must clamp the eye to stay above
    /// the terrain surface: camera world-Y ≥ surface + ε, and `distance_to_surface()`
    /// ≥ 0, always. We sweep azimuths × elevations × a target dragged below ground ×
    /// an over-long distance, settle (which clamps), and assert the invariant.
    #[test]
    fn camera_never_sinks_through_the_ground() {
        use std::f32::consts::PI;
        for ai in 0..8 {
            for &el in &[MIN_ELEV, 0.1, 0.3, 0.6, ELEV_LIMIT] {
                for &ty in &[-2.0f32, -0.5, 0.0, 0.05] {
                    for &dist in &[0.05f32, 0.5, 3.0, 12.0] {
                        let mut c = OrbitCamera {
                            goal_azimuth: ai as f32 / 8.0 * 2.0 * PI,
                            goal_elevation: el,
                            goal_target: V3::new(0.3, ty, -0.2), // target dragged below ground
                            goal_distance: dist,
                            ..Default::default()
                        };
                        c.settle();
                        let eye = c.eye();
                        let surf = c.surface_height_at(eye.x, eye.z);
                        assert!(
                            eye.y >= surf + OrbitCamera::SURFACE_EPS - 1e-5,
                            "eye stays above ground (az#{ai} el {el} ty {ty} d {dist}): eye.y {} < surf {}",
                            eye.y,
                            surf
                        );
                        assert!(c.distance_to_surface() >= 0.0, "distance_to_surface ≥ 0, got {}", c.distance_to_surface());
                        // And damping toward an underground goal can't re-sink it.
                        for _ in 0..200 {
                            c.update(0.016);
                        }
                        let e2 = c.eye();
                        assert!(
                            e2.y >= c.surface_height_at(e2.x, e2.z) + OrbitCamera::SURFACE_EPS - 1e-5,
                            "damped eye also stays above ground: {}",
                            e2.y
                        );
                        assert!(c.distance_to_surface() >= 0.0);
                    }
                }
            }
        }
    }

    /// **FIX 2 hook** — `surface_height_at` is the seam future house-collision plugs
    /// into; today it returns the flat terrain height (0) for any `(x,z)`, and
    /// `distance_to_surface` reads the eye's clearance off it.
    #[test]
    fn surface_height_and_distance_to_surface_are_exposed() {
        let mut c = OrbitCamera {
            goal_target: V3::new(0.0, 0.1, 0.0),
            goal_elevation: 0.6,
            goal_distance: 3.0,
            ..Default::default()
        };
        c.settle();
        // Flat terrain: the surface is 0 everywhere (the house-collision seam).
        for &(x, z) in &[(0.0f32, 0.0f32), (0.5, -0.3), (-0.9, 0.8)] {
            assert_eq!(c.surface_height_at(x, z), 0.0, "today the surface is the flat terrain plane");
        }
        // The eye is up at iso → a real positive clearance equal to eye.y here.
        let d = c.distance_to_surface();
        assert!(d > 0.5, "iso camera sits well above the ground, dts={d}");
        assert!((d - c.eye().y).abs() < 1e-5, "dts == eye.y above a flat y=0 surface");
    }

    /// INJECT-ASSERT: damping eases toward the goal (not instant), and many ticks
    /// converge. Two cameras at the same goal+dt+start are bit-identical (pure).
    #[test]
    fn damping_eases_and_converges_deterministically() {
        let mut a = OrbitCamera {
            goal_azimuth: 1.0,
            azimuth: 0.0,
            ..Default::default()
        };
        let mut b = a.clone();
        a.update(0.016);
        b.update(0.016);
        assert_eq!(a.azimuth, b.azimuth, "damping is a pure function");
        assert!(a.azimuth > 0.0 && a.azimuth < 1.0, "eased partway, got {}", a.azimuth);
        for _ in 0..600 {
            a.update(0.016);
        }
        assert!((a.azimuth - 1.0).abs() < 1e-3, "converges to goal");
    }

    /// INJECT-ASSERT: the GPU `view_proj` matrix agrees with the CPU `project`
    /// (same look-at + perspective) AND produces a monotone depth — a nearer point
    /// gets a SMALLER clip-z (after /w) than a farther one, which is exactly what
    /// the GPU depth test (LESS) needs to occlude correctly.
    #[test]
    fn view_proj_matches_project_and_orders_depth() {
        let mut c = OrbitCamera {
            goal_azimuth: 0.0,
            goal_elevation: 0.0,
            goal_distance: 5.0,
            goal_target: V3::new(0.0, 0.0, 0.0),
            ..Default::default()
        };
        c.settle();
        let aspect = 800.0 / 600.0;
        let m = c.view_proj(aspect);
        // Apply M (column-major) to a homogeneous world point.
        let apply = |p: V3| {
            let v = [p.x, p.y, p.z, 1.0];
            let mut o = [0.0f32; 4];
            for row in 0..4 {
                let mut s = 0.0;
                for col in 0..4 {
                    s += m[col * 4 + row] * v[col];
                }
                o[row] = s;
            }
            o
        };
        // The target projects to ~screen centre: clip.x/clip.w ≈ 0, clip.y/w ≈ 0.
        let mid = apply(V3::new(0.0, 0.0, 0.0));
        assert!(mid[3] > 0.0, "target is in front of the eye (w>0)");
        assert!((mid[0] / mid[3]).abs() < 1e-3 && (mid[1] / mid[3]).abs() < 1e-3, "target → screen centre");
        // A nearer point (toward +z, i.e. toward the eye line at z=+2) vs a farther
        // one (z=-2): the nearer must have the SMALLER normalised clip-z.
        let near = apply(V3::new(0.0, 0.0, 2.0));
        let far = apply(V3::new(0.0, 0.0, -2.0));
        assert!(near[3] > 0.0 && far[3] > 0.0, "both in front");
        let znear = near[2] / near[3];
        let zfar = far[2] / far[3];
        assert!(znear < zfar, "nearer point has smaller depth (znear {znear} < zfar {zfar})");
        // And both depths land inside wgpu's [0,1] clip-z range.
        assert!((0.0..=1.0).contains(&znear) && (0.0..=1.0).contains(&zfar), "depths in [0,1]");
    }

    /// **INJECT-ASSERT (planar reflection mirror matrix):** a point ABOVE the water
    /// plane projects, under the MIRROR view-projection, to (approximately) the same
    /// screen location as its mirror-image BELOW the plane does under the NORMAL
    /// view-projection — i.e. the reflection pass renders the world as seen mirrored
    /// in the water. We check the screen-X/Y of a building-top vs its reflected twin.
    #[test]
    fn mirror_matrix_reflects_about_the_water_plane() {
        let mut c = OrbitCamera {
            goal_azimuth: 0.3,
            goal_elevation: 0.5,
            goal_distance: 4.0,
            goal_target: V3::new(0.0, 0.0, 0.0),
            ..Default::default()
        };
        c.settle();
        let aspect = 1.0;
        let water_h = 0.0;
        let normal = c.view_proj(aspect);
        let mirror = c.view_proj_mirrored(aspect, water_h);
        let apply = |m: &[f32; 16], p: V3| {
            let v = [p.x, p.y, p.z, 1.0];
            let mut o = [0.0f32; 4];
            for row in 0..4 {
                let mut s = 0.0;
                for col in 0..4 {
                    s += m[col * 4 + row] * v[col];
                }
                o[row] = s;
            }
            (o[0] / o[3], o[1] / o[3])
        };
        // A point above the plane, and its reflection below (y negated about y=0).
        let above = V3::new(0.2, 0.3, -0.1);
        let below = V3::new(0.2, -0.3, -0.1);
        // The mirror camera projects `above` to the SAME horizontal screen position
        // (x) as the normal camera projects its reflected twin `below` — and to the
        // VERTICALLY-MIRRORED y (a water reflection is an up-down flip, which is
        // exactly why the reflection pass flips winding). So mx≈nx and my≈-ny.
        let (mx, my) = apply(&mirror, above);
        let (nx, ny) = apply(&normal, below);
        assert!((mx - nx).abs() < 0.05, "mirror screen-x matches the reflected twin ({mx} vs {nx})");
        assert!((my + ny).abs() < 0.05, "mirror screen-y is the vertical flip of the twin ({my} vs {ny})");
        // Sanity: the flip is real (non-degenerate) — the y is meaningfully non-zero.
        assert!(my.abs() > 0.02, "the reflected point has a real vertical offset");
    }

    /// INJECT-ASSERT: perspective projection puts a point in front of the camera
    /// on screen and behind it as not-visible; nearer points project larger
    /// (perspective foreshortening, not orthographic).
    #[test]
    fn perspective_projection_has_depth_and_culls_behind() {
        let mut c = OrbitCamera {
            goal_azimuth: 0.0,
            goal_elevation: 0.0,
            goal_distance: 5.0,
            goal_target: V3::new(0.0, 0.0, 0.0),
            ..Default::default()
        };
        c.settle(); // jump live values to the goals set above
        let center = (400.0, 300.0);
        // A point at the target projects near screen centre.
        let mid = c.project(V3::new(0.0, 0.0, 0.0), center, 300.0);
        assert!(mid.visible);
        assert!((mid.x - center.0).abs() < 1.0 && (mid.y - center.1).abs() < 1.0);
        // A point behind the eye is culled.
        let eye = c.eye();
        let behind = c.project(eye.add(c.basis().0.scale(-1.0)), center, 300.0);
        assert!(!behind.visible, "points behind the near plane are culled");
        // Perspective: a unit object nearer the camera spans more pixels than one
        // farther away (foreshortening).
        let near_pt = c.project(V3::new(0.5, 0.0, 2.0), center, 300.0);
        let far_pt = c.project(V3::new(0.5, 0.0, -2.0), center, 300.0);
        let near_off = (near_pt.x - center.0).abs();
        let far_off = (far_pt.x - center.0).abs();
        assert!(near_off > far_off, "nearer point projects wider (perspective)");
    }
}