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)");
}
}