facett-core/src/render/view.rs
//! **THE unified view core — a 2D map IS a 3D view under constraints.**
//!
//! GFX_V2 item 6. Before this module `facett-map` (2D ortho Mercator) and
//! `facett-map3d` (3D orbit terrain) each carried their own projection writer, so
//! every AA / miter / typography / precision fix had to be made **twice** and the
//! two copies drifted. This is the ONE writer both skins route through (LAW #5):
//!
//! ```text
//! [ View — this module ]
//! ┌──────────────┴──────────────┐
//! [ 3D Terrain Mode ] [ 2D Ortho Mode ]
//! • Terrain::Heightfield • Terrain::Flat (Z(x,y) ≡ 0)
//! • tilt / azimuth free • tilt = azimuth = 0
//! • Projection::Perspective • Projection::Ortho
//! • PostFx::ALL (SSAO + CSM) • PostFx::NONE (bypassed, so 2D is free)
//! └──────────────┬──────────────┘
//! [ WGSL compute culling & draw ]
//! ```
//!
//! ## World axes — `X east, Y up, Z south`
//! Deliberately chosen so **Web-Mercator drops straight in**: Mercator `x` grows
//! east and Mercator `y` grows *south*, exactly matching `(X, Z)`, and screen `y`
//! also grows downward. The 2D reduction therefore needs **no axis flip and no
//! fudge factor** — see [`View::project_px`], which is *bit-exact* against
//! `facett-map`'s `MapTransform::project` (`gfx_v2_item6_parity.rs` proves it).
//!
//! ## Why the frame is closed-form and not a look-at
//! `facett-map3d::camera::OrbitCamera::basis` builds the view frame as
//! `right = normalize(fwd × world_up)`. That cross product **vanishes when the
//! camera looks straight down** — precisely the 2D constraint — which is why
//! `ViewPreset::Top` has to fudge its elevation to `PI/2 − 0.001`. A 2D mode built
//! on a look-at would be built on a gimbal lock.
//!
//! [`View::basis`] instead evaluates the frame in closed form from
//! `(azimuth, tilt)`. It is an **exact algebraic identity** with the orbit
//! cross-product basis wherever that basis is defined (`sin(tilt) > 0`), and it
//! stays perfectly conditioned *at* `tilt = 0`. Both facts are asserted in
//! `tests` below, and the identity is re-asserted cross-crate from `facett-map3d`
//! (where both types are visible) by `unified_view_matches_orbit_camera_math`.
use glam::{Mat4, Vec3};
use super::camera::{Projected, V3};
/// How the view maps view-space onto the screen after the view transform.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Projection {
/// **Orthographic** — no foreshortening at all; depth does not divide.
/// `px_per_unit` is pixels per world unit **per axis** (a uniform map camera
/// passes the same value twice; a non-uniform "fit this bbox" camera does not).
/// This is the 2D map's projection: it is what makes `screen = world·zoom + c`.
Ortho { px_per_unit: [f32; 2] },
/// **Perspective** — vertical field of view in radians. The 3D terrain mode.
/// A very long focal length (tiny `fov_y`) approaches `Ortho`, which is the
/// other half of GFX_V2's "orthographic (or long focal length)".
Perspective { fov_y: f32 },
}
impl Projection {
/// Whether this is the orthographic (2D-mode) projection.
#[inline]
pub fn is_ortho(&self) -> bool {
matches!(self, Projection::Ortho { .. })
}
}
/// Whether the view reads the terrain heightfield or pins the ground perfectly flat.
///
/// `Flat` is GFX_V2's `Z(x,y) = 0` constraint, and it is enforced **by
/// construction** in [`View::terrain_height`] rather than by a caller remembering
/// to zero it — so "let Z through" is not something a 2D caller can do by accident.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Terrain {
/// `Z(x,y) ≡ 0`. The 2D map plane.
Flat,
/// Sample the heightfield. The 3D terrain.
Heightfield,
}
/// Which screen-space post passes the view runs.
///
/// 2D mode sets [`PostFx::NONE`], which is *why* GFX_V2 can say "2D costs nothing
/// extra": the unified pipeline is the same, the post passes are simply skipped.
///
/// The field names track what the renderer **actually has** rather than GFX_V2's
/// prose: the ambient-occlusion pass in `facett-map3d` is **SSAO**
/// (`ssao.wgsl`), not GTAO — there is no GTAO anywhere in the workspace. The
/// shadow pass is a real cascaded shadow map, so `csm` is accurate.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct PostFx {
/// Screen-space ambient occlusion (GFX_V2 calls this "GTAO"; the pass is SSAO).
pub ssao: bool,
/// Cascaded shadow maps + the soft-shadow (PCSS) filter.
pub csm: bool,
/// HDR bloom (bright-pass → separable blur → composite).
pub bloom: bool,
/// Post-process antialiasing (the present/resolve FXAA).
pub fxaa: bool,
}
impl PostFx {
/// Every pass on — the 3D terrain default.
pub const ALL: Self = Self { ssao: true, csm: true, bloom: true, fxaa: true };
/// Every pass off — the 2D bypass.
pub const NONE: Self = Self { ssao: false, csm: false, bloom: false, fxaa: false };
/// Whether any post pass is enabled (i.e. whether an offscreen HDR target and
/// the resolve chain are needed at all).
#[inline]
pub fn any(&self) -> bool {
self.ssao || self.csm || self.bloom || self.fxaa
}
}
/// **The ground-plane frame of an orthographic view**, at `f64` precision — the
/// rotate-then-foreshorten the 2D map skins apply to a Mercator pixel offset.
///
/// It exists because the 2D map does its Mercator arithmetic in `f64` while
/// [`View`] stores its angles as `f32` (matching `OrbitCamera`). Rather than force
/// the 2D path down to `f32`, or let it keep a second hand-written rotation, the
/// **trigonometry is evaluated once** in [`GroundFrame2d::from_azimuth_tilt_rad`]
/// and [`apply`](Self::apply) is the one writer that applies it.
///
/// ## Why `foreshorten` is separate and not folded into `rot`
/// Folding `cos(tilt)` into the matrix rows would mean computing
/// `(ct·s)·dx + (ct·c)·dz` instead of `(s·dx + c·dz)·ct` — distributing a multiply
/// over an addition, which is **not** exact in floating point. Keeping it a
/// separate post-multiply preserves the exact operation order the hand-rolled 2D
/// path used, so the unification is bit-exact rather than merely close.
///
/// Measured over 400k pseudo-random `(bearing, tilt, dx, dz)` draws, the folded
/// form differs from the separated one in **52.5%** of cases — always by a single
/// ULP, never visibly. That is precisely what makes it dangerous: it would break
/// the 2D skins' bit-equality guards for a reason no screenshot could explain.
/// `the_ground_frame_must_not_fold_the_foreshorten_into_the_rotation` pins the
/// ordering, and it exists because a mutation that folded the rows **passed**
/// `facett-map`'s six-pose `layer.rs` sweep (half those poses had `tilt = 0`, where
/// `ct = 1` makes folding exact).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GroundFrame2d {
/// The pure rotation `[a, b, c, d]`, applied as
/// `(a·dx + b·dz, c·dx + d·dz)`. No tilt in here.
pub rot: [f64; 4],
/// The vertical foreshortening `cos(tilt)`, applied to the rotated `y`
/// **after** the rotation. `1.0` at tilt 0.
pub foreshorten: f64,
}
impl GroundFrame2d {
/// Build from a turntable `azimuth` and `tilt`, both in **radians**. This is the
/// one place the 2D frame's trigonometry is evaluated.
pub fn from_azimuth_tilt_rad(azimuth: f64, tilt: f64) -> Self {
let (sa, ca) = azimuth.sin_cos();
Self { rot: [ca, -sa, sa, ca], foreshorten: tilt.cos() }
}
/// Apply the frame to a ground offset `(dx, dz)` in pixels (east, south),
/// yielding the screen offset from the pane centre. **The one writer** — a
/// caller must not spell this arithmetic out itself.
#[inline]
pub fn apply(&self, dx: f64, dz: f64) -> (f64, f64) {
let rx = self.rot[0] * dx + self.rot[1] * dz;
let ry = self.rot[2] * dx + self.rot[3] * dz;
(rx, ry * self.foreshorten)
}
/// **The exact inverse of [`apply`](Self::apply)** — a screen offset from the pane
/// centre back to the ground offset `(dx, dz)` in pixels (east, south).
///
/// This is what a *gesture* needs: a drag is measured in screen pixels and has to
/// become a movement across the ground, and on a rotated or tilted map those are not
/// the same vector. It lives here, beside `apply`, because the only other way to get
/// it is to spell the trigonometry out a second time at the call site — which is the
/// drift LAW #5 exists to stop, and `project`'s own doc already records what that
/// cost once.
///
/// `rot` is a pure rotation (orthonormal, `det = 1`), so its inverse is its
/// transpose; `foreshorten` is `cos(tilt)` with tilt clamped well below 90°, so it is
/// never zero — the guard against a hand-built degenerate frame just passes the
/// vertical component through rather than producing an infinity.
#[inline]
pub fn unapply(&self, rx: f64, ry: f64) -> (f64, f64) {
let ry = if self.foreshorten.abs() < f64::EPSILON { ry } else { ry / self.foreshorten };
let dx = self.rot[0] * rx + self.rot[2] * ry;
let dz = self.rot[1] * rx + self.rot[3] * ry;
(dx, dz)
}
}
/// **The unified view.** One description; two modes.
///
/// See the module docs. Build the 2D mode with [`View::ortho_2d`] and the 3D mode
/// with [`View::perspective_3d`] / [`View::from_orbit`]; both are the same type, so
/// a fix to [`View::basis`] / [`View::project_px`] / [`View::view_proj`] lands in
/// both at once.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
/// The world point the view centres on (the orbit target / the 2D camera
/// centre). World axes are `X east, Y up, Z south`.
pub target: Vec3,
/// **Turntable yaw about +Y**, radians. This is `OrbitCamera::azimuth`'s exact
/// sense: increasing it swings the *eye* counter-clockwise (toward the east),
/// so the direction the camera *faces* rotates the opposite way.
///
/// It is deliberately NOT called `bearing`. A compass bearing is a clockwise
/// heading, and `azimuth` is its negation — see
/// [`compass_bearing_deg`](View::compass_bearing_deg) and the
/// `the_two_skins_disagree_about_which_way_bearing_turns` test, which measures
/// a live disagreement between the two skins on exactly this point.
pub azimuth: f32,
/// **Tilt from top-down** in radians; `0` = plan / map view (straight down),
/// `PI/2` = horizon. Matches `OrbitCamera::tilt_deg` (`= 90° − elevation`).
pub tilt: f32,
/// **Bank about the FORWARD axis**, radians, positive = right wing down. `0` for
/// every camera that is not flying a stick, which is every camera but one.
///
/// # Why the projection core grew a third angle
///
/// `azimuth` and `tilt` fix where the camera LOOKS; they cannot express how it is
/// rotated about that look. The basis below therefore always returned a `right` with
/// `y == 0` — a horizon level by construction — and a rig with a bank axis had nowhere
/// to put it. `facett-map3d-flight`'s `FlightCamera` has had `roll` since it was written
/// (Q / E, the flight-stick aileron), and when `facett-demo`'s flight tab was moved onto
/// the shared renderer that roll was silently LEVELLED: the stick moved, the state
/// changed, and the horizon did not. A degree of freedom dropped without a word is the
/// same defect class as a control that acks and does nothing.
///
/// Additive by construction: it defaults to `0` in both constructors, and at `0` the
/// rotation in [`View::basis`] is skipped outright, so every existing view projects
/// bit-for-bit as it did (`a_zero_roll_is_the_identity_on_the_basis` is the guard).
pub roll: f32,
/// Eye-to-target distance. In `Ortho` mode it only sets where the near/far
/// band sits; it cannot change the on-screen scale (that is the definition of
/// orthographic, and the 2D parity test pins it).
pub distance: f32,
/// Ortho or perspective.
pub projection: Projection,
/// Flat (`Z ≡ 0`) or heightfield.
pub terrain: Terrain,
/// Which post passes run.
pub post: PostFx,
/// **Near-plane override**, view-space design units (V2.5 D.3 fix). `None` = the
/// stock behaviour: [`View::NEAR_PLANE`] for the CPU projector and the tight
/// `(distance − 2).max(0.05)` band for the GPU matrix.
///
/// Why it exists: the korp ride parks a rider's-eye a few METRES behind an
/// overlay body, and a few metres is `~1e-3` design units on a city scene —
/// far inside both stock nears, so the avatar was clipped out of existence on
/// every frame while every count stayed green. A host that knows it has close
/// geometry (`Map3D` when an overlay rides) writes the one number both lanes
/// then honour; nobody else ever sets it, so every existing view projects
/// bit-for-bit as before ([`View::near_clip`] is the one reader).
pub near_override: Option<f32>,
}
impl View {
/// The **view-space near plane** (camera-space depth). Identical to
/// `OrbitCamera::NEAR_PLANE` / `Camera::NEAR_PLANE` — geometry at or behind it
/// sits on the eye and must be rejected before a perspective divide.
pub const NEAR_PLANE: f32 = 0.02;
/// The far-plane pad added to `distance` (mirrors `OrbitCamera::far_plane`).
const FAR_PAD: f32 = 4.0;
/// **The shortest orbit distance the far plane is budgeted for** (A-2a).
///
/// The far plane used to be `distance + FAR_PAD` outright, which silently made the
/// FRUSTUM a function of the DOLLY LENGTH. That was harmless while every pose used
/// the same scene-independent dolly. It stopped being harmless when
/// `Map3D::look_from` was made ground-aware — parking a nose-down flight target on
/// the ground along the view ray instead of a fixed two units out, so it can never
/// be dragged under the terrain — because a low, steep pose then has a dolly of
/// centimetres and would have had a frustum to match.
///
/// The floor is derived, not picked: the scene lives in the design box `[-1,1]³`,
/// whose body diagonal is `2√3 ≈ 3.47`, so an eye anywhere inside it must be able
/// to see the opposite corner however short its dolly is. `2 + 4 = 6` clears that
/// and is exactly the budget every non-flight pose already had.
pub const FAR_MIN_DISTANCE: f32 = 2.0;
// ── the two constructors ─────────────────────────────────────────────────
/// **2D ortho mode**, by construction. `target` is the ground point under the
/// screen centre (`y` is ignored — the plane is at `Y = 0`), `px_per_unit` is
/// the per-axis zoom.
///
/// Every 2D constraint is applied here rather than left to the caller:
/// `tilt = azimuth = 0`, [`Projection::Ortho`], [`Terrain::Flat`],
/// [`PostFx::NONE`]. There is no way to construct a *nearly*-2D view by
/// forgetting one of them.
pub fn ortho_2d(target: Vec3, px_per_unit: [f32; 2]) -> Self {
Self {
target: Vec3::new(target.x, 0.0, target.z),
azimuth: 0.0,
// A plan view has no bank: a rolled map is not a mode, it is a mistake.
roll: 0.0,
tilt: 0.0,
distance: 1.0,
projection: Projection::Ortho { px_per_unit },
terrain: Terrain::Flat,
post: PostFx::NONE,
near_override: None,
}
}
/// **3D terrain mode.** `tilt`/`azimuth` in radians, heightfield on, full post
/// stack on.
pub fn perspective_3d(target: Vec3, azimuth: f32, tilt: f32, distance: f32, fov_y: f32) -> Self {
Self {
target,
azimuth,
tilt,
roll: 0.0,
distance,
projection: Projection::Perspective { fov_y },
terrain: Terrain::Heightfield,
post: PostFx::ALL,
near_override: None,
}
}
/// **Bank this view about its forward axis.** Chainable, and additive: the default is
/// `0`, at which [`View::basis`] returns exactly what it returned before roll existed.
#[must_use]
pub fn with_roll(mut self, roll: f32) -> Self {
self.roll = roll;
self
}
/// **Override the near plane** (chainable, additive — `None` is the stock band).
/// See [`Self::near_override`] for why a rider's-eye pose needs one.
#[must_use]
pub fn with_near(mut self, near: Option<f32>) -> Self {
self.near_override = near;
self
}
/// The **effective CPU-projector near plane**: the override, else
/// [`Self::NEAR_PLANE`]. The one reader every near-clip goes through, so the
/// CPU clip and the reject in [`Self::project_px`] cannot disagree.
#[must_use]
pub fn near_clip(&self) -> f32 {
self.near_override.unwrap_or(Self::NEAR_PLANE)
}
/// Build from `OrbitCamera`'s **spherical** parameters (`azimuth`, `elevation`)
/// instead of nav (`compass bearing`, `tilt`). This is the delegation seam
/// `facett-map3d` calls, so the orbit camera has no projection writer of its
/// own: `tilt = PI/2 − elevation`, `azimuth = azimuth`.
pub fn from_orbit(target: V3, azimuth: f32, elevation: f32, distance: f32, fov_y: f32) -> Self {
Self::perspective_3d(
Vec3::new(target.x, target.y, target.z),
azimuth,
std::f32::consts::FRAC_PI_2 - elevation,
distance,
fov_y,
)
}
// ── the 2D-facing façade (no glam needed at the call site) ───────────────
/// [`View::ortho_2d`] for a caller that thinks in **2D map coordinates**:
/// `ref_xy` is the Mercator point under the pane centre and `px_per_unit` the
/// per-axis zoom. Lifts `(x, y)` onto the ground plane as `(x, 0, y)` — the
/// axis choice that makes the reduction exact (see the module docs).
///
/// This exists so `facett-map` and the other 2D skins need no `glam`
/// dependency of their own to route through the unified core.
pub fn ortho_2d_map(ref_xy: [f32; 2], px_per_unit: [f32; 2]) -> Self {
Self::ortho_2d(Vec3::new(ref_xy[0], 0.0, ref_xy[1]), px_per_unit)
}
/// Project a **ground-plane** `(x, y)` (2D map space) to screen pixels.
/// `half_h` is only read in perspective mode.
#[inline]
pub fn project_ground(&self, xy: [f32; 2], centre: (f32, f32), half_h: f32) -> Projected {
self.project_world(Vec3::new(xy[0], 0.0, xy[1]), centre, half_h)
}
/// The inverse of [`View::project_ground`]: screen pixel → ground `(x, y)` in
/// 2D map space. `None` when the ray cannot reach the plane.
#[inline]
pub fn unproject_ground(&self, px: (f32, f32), centre: (f32, f32), half_h: f32) -> Option<[f32; 2]> {
self.unproject_ground_px(px, centre, half_h).map(|w| [w.x, w.z])
}
/// **The ground-plane frame this view applies, in `f64`** — what the 2D skins
/// need. See [`GroundFrame2d`].
///
/// The angles come from this `View`'s `f32` fields, so this is the right call
/// when the caller already holds a `View`. A 2D caller that holds its bearing
/// and tilt in **degrees** should use
/// [`ground_frame_2d_compass_deg`](Self::ground_frame_2d_compass_deg) instead:
/// converting degrees → radians in `f32` and back up to `f64` costs an ULP,
/// which is invisible on screen but enough to break a bit-equality guard.
///
/// Orthographic only — with a perspective divide there is no ground-plane
/// affine.
pub fn ground_frame_2d(&self) -> GroundFrame2d {
debug_assert!(self.projection.is_ortho(), "a ground frame needs an orthographic view");
GroundFrame2d::from_azimuth_tilt_rad(self.azimuth as f64, self.tilt as f64)
}
/// [`View::ground_frame_2d`] from a **true compass bearing and tilt in
/// degrees**, both `f64` — the full-precision entry point for the 2D map, whose
/// Mercator arithmetic is `f64` (at slippy zoom 22 the world is 2^30 px wide, so
/// an `f32` pixel offset there is coarse).
pub fn ground_frame_2d_compass_deg(compass_bearing_deg: f64, tilt_deg: f64) -> GroundFrame2d {
GroundFrame2d::from_azimuth_tilt_rad(-compass_bearing_deg.to_radians(), tilt_deg.to_radians())
}
// ── the constraint ───────────────────────────────────────────────────────
/// Whether this view satisfies **every** 2D constraint GFX_V2 names: tilt 0,
/// azimuth 0, orthographic, flat terrain. (Post-FX is a cost choice, not a
/// correctness constraint, so it is not part of the predicate.)
pub fn is_constrained_2d(&self) -> bool {
self.tilt == 0.0
&& self.azimuth == 0.0
&& self.projection.is_ortho()
&& self.terrain == Terrain::Flat
}
/// Collapse a 3D view onto the 2D constraint surface, keeping the ground point
/// and converting the perspective scale at the target into the equivalent
/// ortho `px_per_unit` for `half_h` pixels of half-viewport-height — so
/// "flatten this 3D view" lands on the same on-screen scale it had.
pub fn constrain_2d(&mut self, half_h: f32) {
let px_per_unit = match self.projection {
Projection::Ortho { px_per_unit } => px_per_unit,
// The perspective scale AT the target plane: focal / depth.
Projection::Perspective { fov_y } => {
let s = (half_h / (fov_y * 0.5).tan()) / self.distance.max(1e-6);
[s, s]
}
};
self.tilt = 0.0;
self.azimuth = 0.0;
self.target.y = 0.0;
self.projection = Projection::Ortho { px_per_unit };
self.terrain = Terrain::Flat;
self.post = PostFx::NONE;
}
/// **The `Z(x,y) = 0` constraint, enforced by construction.** Callers hand in
/// whatever the heightfield sampled; `Flat` throws it away. A 2D caller cannot
/// leak terrain relief into the plane by forgetting to zero it, and the item-6
/// mutation test drives exactly this seam.
#[inline]
pub fn terrain_height(&self, sampled: f32) -> f32 {
match self.terrain {
Terrain::Flat => 0.0,
Terrain::Heightfield => sampled,
}
}
// ── the frame (ONE writer, both modes) ───────────────────────────────────
/// `(forward, right, up)` — the orthonormal view frame, in **closed form** from
/// `(azimuth, tilt)`.
///
/// At `azimuth = tilt = 0` this is exactly `fwd = -Y` (straight down),
/// `right = +X` (east → screen right), `up = -Z` (north → screen up): the 2D
/// map frame, with no degeneracy. See the module docs for why a look-at cannot
/// do that.
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
let (sb, cb) = self.azimuth.sin_cos();
let (st, ct) = self.tilt.sin_cos();
// Frame at azimuth 0: right=(1,0,0), up=(0,st,-ct), fwd=(0,-ct,-st);
// then rotated about +Y by `azimuth` (Ry(a)·v), which matches
// OrbitCamera's azimuth sense exactly.
let right = Vec3::new(cb, 0.0, -sb);
let up = Vec3::new(-ct * sb, st, -ct * cb);
let fwd = Vec3::new(-st * sb, -ct, -st * cb);
if self.roll == 0.0 {
return (fwd, right, up);
}
// **BANK about the forward axis.** `fwd`, `right` and `up` are orthonormal by
// construction above, so a Rodrigues rotation about `fwd` reduces exactly to a
// rotation within the `(right, up)` plane — no cross products, no renormalisation,
// and `fwd` is untouched, which is what makes this a roll rather than a re-aim.
//
// The sign matches `facett_map3d_flight::FlightCamera::right`, which banks its own
// right vector about forward by `+roll` (Rodrigues). Two spellings of a rotation are
// free to disagree about handedness, and a mirrored horizon is a defect no count can
// see — so the flight rig's convention is adopted here rather than chosen afresh.
let (sr, cr) = self.roll.sin_cos();
(
fwd,
Vec3::new(
right.x * cr + up.x * sr,
right.y * cr + up.y * sr,
right.z * cr + up.z * sr,
),
Vec3::new(
up.x * cr - right.x * sr,
up.y * cr - right.y * sr,
up.z * cr - right.z * sr,
),
)
}
/// The eye position: `target − distance · forward`.
pub fn eye(&self) -> Vec3 {
let (fwd, _, _) = self.basis();
self.target - fwd * self.distance
}
/// The bounded far plane (`max(distance, FAR_MIN_DISTANCE) + FAR_PAD`), mirroring
/// `OrbitCamera::far_plane`. See [`Self::FAR_MIN_DISTANCE`] for why the distance has
/// a floor rather than going straight into the sum.
pub fn far_plane(&self) -> f32 {
self.distance.max(Self::FAR_MIN_DISTANCE) + Self::FAR_PAD
}
/// A world point in **view space** (`x = right, y = up, z = forward`, relative
/// to the eye). The near-plane clip happens here, before any divide.
pub fn view_space(&self, p: Vec3) -> Vec3 {
let (fwd, right, up) = self.basis();
let rel = p - self.eye();
Vec3::new(rel.dot(right), rel.dot(up), rel.dot(fwd))
}
/// **The one projection writer.** An already-view-space point → screen pixels.
///
/// * `Ortho` — `px = centre + (c.x·sx, −c.y·sy)`. No divide, so nothing is
/// near-plane-sensitive in x/y; only the depth band clips.
/// * `Perspective` — `px = centre + (c.x/c.z, −c.y/c.z)·focal`, `focal =
/// half_h / tan(fov_y/2)`.
///
/// `centre` is where [`View::target`] lands in pixels (the pane centre, which
/// is *not* necessarily the render-target centre). `half_h` is half the
/// viewport height in pixels and is only read in perspective mode.
pub fn project_px(&self, c: Vec3, centre: (f32, f32), half_h: f32) -> Projected {
match self.projection {
Projection::Ortho { px_per_unit } => {
// Orthographic: x/y do not depend on depth, so a point behind the
// eye still has a well-defined screen position; only the depth
// band decides visibility.
let visible = c.z > 0.0;
Projected {
x: centre.0 + c.x * px_per_unit[0],
y: centre.1 - c.y * px_per_unit[1],
depth: c.z,
visible,
}
}
Projection::Perspective { fov_y } => {
if c.z <= self.near_clip() || c.z >= self.far_plane() {
return Projected { x: centre.0, y: centre.1, depth: c.z, visible: false };
}
let focal = half_h / (fov_y * 0.5).tan();
Projected {
x: centre.0 + (c.x / c.z) * focal,
y: centre.1 - (c.y / c.z) * focal,
depth: c.z,
visible: true,
}
}
}
}
/// A **world** point → screen pixels (the `view_space` + `project_px` pair).
pub fn project_world(&self, p: Vec3, centre: (f32, f32), half_h: f32) -> Projected {
self.project_px(self.view_space(p), centre, half_h)
}
/// **The inverse** of [`View::project_world`] onto the ground plane — a screen
/// pixel → the world `(x, z)` where its view ray meets `Y = target.y`.
///
/// This matters far more than a convenience: the GPU **compute cull** works in
/// this unprojected space while the **draw** works in the projected one, so if
/// the two are not exact inverses the cull silently drops ways that are on
/// screen. Routing both through this module is what makes them inverses by
/// construction.
///
/// In `Ortho` mode this is the exact affine inverse (`unproject∘project` is
/// bit-stable). In `Perspective` mode it is a ray/plane intersection, and it
/// returns `None` for a ray parallel to or pointing away from the plane.
///
/// ## Why the Ortho arm goes through [`GroundFrame2d::unapply`]
///
/// It used to spell the inverse out as `target + right·cx + up·cy` — which lands
/// on the **image plane**, not the ground, whenever the view is tilted (`up` has a
/// `+sin(tilt)` component and `unproject_ground` then just *drops* the `y`).
/// Descending that point along `fwd` to `Y = target.y` contributes a second
/// `sin²(tilt)/cos(tilt)` term, and the two together turn the forward's
/// `·cos(tilt)` into an inverse that also **multiplies** by `cos(tilt)` where it
/// must divide. The unprojected offset therefore came out `cos²(tilt)` too short:
/// **2.42× at tilt 50°, 14.9× at `MAX_TILT` 75°**.
///
/// That is not cosmetic. `facett_map::gpu::MapTransform::viewport_bbox` builds the
/// **compute cull's** frustum box out of exactly this call while the draw projects
/// through `GroundFrame2d::apply`, so a too-short box culls ways that are plainly
/// on screen — and the CPU fills, which cull against the generous
/// `MapView::merc_bbox` instead, stay put, so half the map vanishes and the other
/// half does not. It was unreachable while the GPU lane refused every rotated
/// camera; it went live the moment the lane grew its ground frame.
///
/// `GroundFrame2d::unapply` is the ONE writer of this inverse (LAW #5) and it
/// already divides. Dividing each pixel axis by its own `px_per_unit` first is
/// exact for an anisotropic ortho too, because the forward scales each axis
/// independently *after* the frame.
pub fn unproject_ground_px(&self, px: (f32, f32), centre: (f32, f32), half_h: f32) -> Option<Vec3> {
match self.projection {
Projection::Ortho { px_per_unit } => {
// Pixel offset → the frame's own output space (one divide per axis),
// then the one inverse writer, which undoes the foreshorten and the
// rotation in that order.
let (dx, dz) = self.ground_frame_2d().unapply(
f64::from((px.0 - centre.0) / px_per_unit[0]),
f64::from((px.1 - centre.1) / px_per_unit[1]),
);
Some(self.target + Vec3::new(dx as f32, 0.0, dz as f32))
}
Projection::Perspective { .. } => self.unproject_plane_px(px, centre, half_h, self.target.y),
}
}
/// [`Self::unproject_ground_px`]'s perspective ray, intersected with an
/// **arbitrary horizontal plane** `Y = plane_y` — the flight case, where the
/// target is parked one dolly-length down the view ray (`target.y` is mid-air)
/// and the ground the click means is the terrain plane at `Y = 0`. One ray
/// writer for both (LAW 5). `None` for an ortho view (no eye to shoot from
/// that this seam should invent), a ray parallel to the plane, or a plane
/// behind the eye.
pub fn unproject_plane_px(
&self,
px: (f32, f32),
centre: (f32, f32),
half_h: f32,
plane_y: f32,
) -> Option<Vec3> {
let Projection::Perspective { fov_y } = self.projection else {
return None;
};
let (fwd, right, up) = self.basis();
let focal = half_h / (fov_y * 0.5).tan();
// The view ray through the pixel, in world space.
let dir = fwd + right * ((px.0 - centre.0) / focal) + up * (-(px.1 - centre.1) / focal);
let eye = self.eye();
let denom = dir.y;
if denom.abs() < 1e-9 {
return None; // parallel to the plane
}
let t = (plane_y - eye.y) / denom;
if t <= 0.0 {
return None; // the plane is behind the eye
}
Some(eye + dir * t)
}
/// The **view-projection matrix** for the GPU depth-tested path, as a `glam`
/// [`Mat4`] (column-major; `to_cols_array()` is the wgpu uniform layout).
///
/// `clip = M · [world, 1]`. Perspective mode reproduces
/// `OrbitCamera::view_proj` (tight near/far around the design box so the depth
/// test keeps real precision); ortho mode emits the affine the 2D draw shader
/// already applies, with `w ≡ 1`.
///
/// `aspect` is viewport width / height; it is only read in perspective mode
/// (ortho carries its own per-axis pixel scale, so it needs the viewport in
/// pixels instead — see [`View::view_proj_px`]).
pub fn view_proj(&self, aspect: f32) -> Mat4 {
match self.projection {
Projection::Perspective { fov_y } => {
let f = 1.0 / (fov_y * 0.5).tan();
self.compose(f / aspect.max(1e-6), f, true, (0.0, 0.0))
}
// Without a viewport we cannot turn px_per_unit into NDC; assume the
// canonical half-height of 1 unit so the matrix is still well-formed.
Projection::Ortho { px_per_unit } => {
let sy = px_per_unit[1];
let sx = px_per_unit[0] / aspect.max(1e-6);
self.compose(sx, sy, false, (0.0, 0.0))
}
}
}
/// The view-projection matrix in **pixel-anchored** form: `centre_px` is where
/// [`View::target`] lands and `viewport` is the render target in pixels. This
/// is the form the 2D map needs, because its pane centre is not the target
/// centre; the perspective mode reduces to [`View::view_proj`] when
/// `centre_px == viewport/2`.
pub fn view_proj_px(&self, centre_px: (f32, f32), viewport: (f32, f32)) -> Mat4 {
let (vw, vh) = (viewport.0.max(1e-6), viewport.1.max(1e-6));
let half_w = vw * 0.5;
let half_h = vh * 0.5;
// Pixel offset of the projection centre from the NDC origin, in NDC.
let off = ((centre_px.0 - half_w) / half_w, -(centre_px.1 - half_h) / half_h);
match self.projection {
Projection::Perspective { fov_y } => {
let focal = half_h / (fov_y * 0.5).tan();
self.compose(focal / half_w, focal / half_h, true, off)
}
Projection::Ortho { px_per_unit } => {
self.compose(px_per_unit[0] / half_w, px_per_unit[1] / half_h, false, off)
}
}
}
/// Assemble `P · V`. `sx`/`sy` are the NDC scales applied to view-space x/y,
/// `divide` selects perspective (`clip.w = c.z`) vs ortho (`clip.w = 1`), and
/// `off` shifts the projection centre in NDC.
fn compose(&self, sx: f32, sy: f32, divide: bool, off: (f32, f32)) -> Mat4 {
let (fwd, right, up) = self.basis();
let eye = self.eye();
let (tx, ty, tz) = (-right.dot(eye), -up.dot(eye), -fwd.dot(eye));
// The GPU depth band. Stock: a tight near around the design box (the orbit
// dolly is ≥ MIN_DISTANCE, so `distance − 2` only goes tight when a flight
// pose parks the dolly at 2.0 — where the old `.max(0.05)` floor was 94 m of
// real ground on a 3 km scene and near-clipped the ride avatar wholesale).
// An override wins because the host measured its closest geometry.
// Ortho has no eye to be behind: the band is symmetric about the (nominal) eye so
// geometry taller than `distance` above the target is not near-clipped.
let far = self.far_plane();
let near = if divide {
self.near_override.unwrap_or_else(|| (self.distance - 2.0).max(0.05))
} else {
-far
};
// ── THE DEPTH MAP IS PROJECTION-SPECIFIC. ──
// Perspective writes `clip.z = a·z + b` and divides by `w = z`, so
// `a = f/(f−n), b = −f·n/(f−n)` puts `z = n → 0` and `z = f → 1`.
// Ortho does NOT divide (`w ≡ 1`), so the same `a, b` gave `ndc.z = z` — a depth
// band of exactly [0, 1] view units instead of [n, f]. The plan view's eye sits
// `distance = 1` above the ground, so the ground landed on `ndc.z = 1.0` (the
// clear value, so a `Less` test rejects it) and anything below it or above the eye fell
// outside the band.
// MEASURED by `facett-map3d/tests/postfx_bypass.rs` on an RTX 4090 (6.62) and a
// Radeon 890M (6.65) alike — the plan frame's luma spread, calibrated at 14.70 —
// a vendor-independent defect, present since the unified core (`21f411b`,
// 2026-08-01). The linear map is `ndc.z = (z − n) / (f − n)`.
let (a, b) = if divide {
(far / (far - near), -far * near / (far - near))
} else {
(1.0 / (far - near), -near / (far - near))
};
// clip.x = sx·(right·p + tx) + off.x·w
// clip.y = sy·(up·p + ty) + off.y·w
// clip.z = a ·(fwd·p + tz) + b
// clip.w = divide ? (fwd·p + tz) : 1
let (wx, wy, wz, ww) = if divide { (fwd.x, fwd.y, fwd.z, tz) } else { (0.0, 0.0, 0.0, 1.0) };
Mat4::from_cols_array(&[
// column 0 (coefficients of p.x)
sx * right.x + off.0 * wx,
sy * up.x + off.1 * wx,
a * fwd.x,
wx,
// column 1 (p.y)
sx * right.y + off.0 * wy,
sy * up.y + off.1 * wy,
a * fwd.y,
wy,
// column 2 (p.z)
sx * right.z + off.0 * wz,
sy * up.z + off.1 * wz,
a * fwd.z,
wz,
// column 3 (the w=1 translation column)
sx * tx + off.0 * ww,
sy * ty + off.1 * ww,
a * tz + b,
ww,
])
}
// ── nav-facing helpers (so callers need no trigonometry) ─────────────────
/// Tilt from top-down in degrees (`0` = plan view).
pub fn tilt_deg(&self) -> f32 {
self.tilt.to_degrees()
}
/// The turntable yaw in degrees, `[0, 360)`. This is what
/// `OrbitCamera::bearing_deg` returns today — see
/// [`compass_bearing_deg`](Self::compass_bearing_deg) for why that name is a
/// misnomer.
pub fn azimuth_deg(&self) -> f32 {
Self::wrap360(self.azimuth.to_degrees())
}
/// **The true compass heading the camera faces**, degrees clockwise from north,
/// `[0, 360)`. This is `−azimuth`, and the sign is not a detail:
///
/// at `azimuth = 90°` the eye sits due **east** of the target, so the camera is
/// looking **west** — compass bearing 270°, not 90°. `OrbitCamera::bearing_deg`
/// returns the azimuth unchanged and therefore reports the *opposite* heading
/// from `facett-map::layer::MapView::bearing_deg`, which applies the correct
/// clockwise sense. That divergence is MEASURED (a due-north point moves 576 px
/// left in 2D and 198 px right in 3D at the same nominal `bearing_deg = 90`) by
/// `facett-map3d/tests/gfx_v2_item6_parity.rs`.
///
/// The unified core keeps `azimuth` as the storage field so the 3D delegation is
/// bit-exact, and exposes the compass sense here so a 2D caller gets the
/// heading it means. Which sense should become canonical across both skins is a
/// product decision — flipping `OrbitCamera` would rotate a shipping car-nav
/// compass overlay — so this records and pins it rather than silently picking.
pub fn compass_bearing_deg(&self) -> f32 {
Self::wrap360(-self.azimuth.to_degrees())
}
/// Set the turntable yaw from a **true compass heading** (clockwise from north).
pub fn set_compass_bearing_deg(&mut self, deg: f32) {
self.azimuth = -deg.to_radians();
}
/// [`View::perspective_3d`] from a **true compass heading** in degrees rather
/// than a turntable azimuth in radians.
pub fn perspective_3d_compass(
target: Vec3,
compass_bearing_deg: f32,
tilt_deg: f32,
distance: f32,
fov_y: f32,
) -> Self {
Self::perspective_3d(
target,
-compass_bearing_deg.to_radians(),
tilt_deg.to_radians(),
distance,
fov_y,
)
}
/// **2D ortho mode with a true compass bearing and a tilt** — the mode
/// `facett-map::layer::MapView` is in. A *tilted orthographic* view is a real
/// projection, not a fake: its vertical foreshortening is exactly
/// `cos(tilt)` with no perspective convergence, which is precisely what
/// `MapView::project` computes by hand today. Routing it here makes that a
/// consequence of the shared frame instead of a second writer.
pub fn ortho_2d_compass(
target_xy: [f32; 2],
px_per_unit: [f32; 2],
compass_bearing_deg: f32,
tilt_deg: f32,
) -> Self {
let mut v = Self::ortho_2d_map(target_xy, px_per_unit);
v.set_compass_bearing_deg(compass_bearing_deg);
v.tilt = tilt_deg.to_radians();
v
}
#[inline]
fn wrap360(d: f32) -> f32 {
let d = d % 360.0;
if d < 0.0 { d + 360.0 } else { d }
}
/// The polar `elevation` an `OrbitCamera` would hold for this tilt.
pub fn elevation(&self) -> f32 {
std::f32::consts::FRAC_PI_2 - self.tilt
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The orbit camera's cross-product frame, written out verbatim as the
/// REFERENCE the closed form must reproduce (`facett-map3d::camera::
/// OrbitCamera::{eye,basis}`, spherical `azimuth`/`elevation`).
fn orbit_reference_basis(azimuth: f32, elevation: f32) -> (Vec3, Vec3, Vec3) {
let (se, ce) = elevation.sin_cos();
let (sa, ca) = azimuth.sin_cos();
// eye = target + distance·(ce·sa, se, ce·ca); fwd = normalize(target − eye)
let fwd = -Vec3::new(ce * sa, se, ce * ca).normalize();
let world_up = Vec3::new(0.0, 1.0, 0.0);
let right = fwd.cross(world_up).normalize();
let up = right.cross(fwd).normalize();
(fwd, right, up)
}
/// **The identity.** The closed-form frame reproduces the orbit cross-product
/// frame at a spread of NON-trivial poses. Deliberately excludes the poles and
/// bearing/tilt 0 — an identity test that only ever sees `(0, 0)` would pass
/// with a frame that is right for one pose and wrong everywhere else.
#[test]
fn closed_form_frame_is_the_orbit_cross_product_frame() {
let poses = [
(37.0f32, 12.0f32),
(-115.0, 62.0),
(223.0, 85.0),
(91.0, 3.5),
(180.0, 45.0),
(-44.0, 89.0),
];
let mut worst = 0.0f32;
for (bearing_deg, tilt_deg) in poses {
let b = bearing_deg.to_radians();
let t = tilt_deg.to_radians();
let v = View::perspective_3d(Vec3::ZERO, b, t, 5.0, 50f32.to_radians());
let (fwd, right, up) = v.basis();
// OrbitCamera's elevation = 90° − tilt.
let (rf, rr, ru) = orbit_reference_basis(b, std::f32::consts::FRAC_PI_2 - t);
for (got, want, name) in
[(fwd, rf, "fwd"), (right, rr, "right"), (up, ru, "up")]
{
let d = (got - want).length();
worst = worst.max(d);
assert!(
d < 1e-6,
"{name} drifted at bearing {bearing_deg}° tilt {tilt_deg}°: \
closed form {got:?} vs orbit reference {want:?} (|Δ| = {d:e})"
);
}
// And the frame really is orthonormal (not just equal to a broken ref).
assert!((fwd.length() - 1.0).abs() < 1e-6, "fwd unit");
assert!(fwd.dot(right).abs() < 1e-6 && fwd.dot(up).abs() < 1e-6, "orthogonal");
assert!(right.dot(up).abs() < 1e-6, "orthogonal");
}
// NON-VACUITY: the poses must actually have moved the frame around. If the
// basis were a constant the loop above would still pass against a constant
// reference, so pin that the frame genuinely varies.
let a = View::perspective_3d(Vec3::ZERO, 0.6, 0.3, 5.0, 1.0).basis().0;
let c = View::perspective_3d(Vec3::ZERO, 2.4, 1.1, 5.0, 1.0).basis().0;
assert!((a - c).length() > 0.5, "the test poses must not all give one frame");
assert!(worst > 0.0, "reference and closed form are separate computations");
}
/// **The gimbal lock the closed form escapes — and it is worse than a NaN.**
///
/// At tilt 0 (the 2D constraint) `right = normalize(fwd × world_up)` is
/// `normalize` of a vector whose every component is zero *in exact arithmetic*.
/// In `f32` it is not zero, it is the rounding residue of `cos(PI/2)`
/// (`-4.37e-8`), so the look-at basis does **not** blow up visibly — it returns
/// a perfectly unit `right` whose SIGN is decided entirely by that residue, and
/// at elevation exactly `PI/2` the residue is negative, so it hands back
/// `(-1, 0, 0)`: **east and west are silently swapped and the map renders
/// mirrored.**
///
/// That is why `ViewPreset::Top` must fudge `PI/2 − 0.001` — the fudge is
/// load-bearing, not cosmetic — and it is why a 2D mode cannot be built on a
/// look-at. The closed form is exact and stable at the constraint.
#[test]
fn top_down_lookat_basis_silently_mirrors_east_and_the_closed_form_does_not() {
const EAST: Vec3 = Vec3::new(1.0, 0.0, 0.0);
let quarter = std::f32::consts::FRAC_PI_2;
// 1. AT the constraint the look-at basis is unit-length (so no NaN guard
// would ever catch it) but points the WRONG WAY along east.
let (_, r_at, _) = orbit_reference_basis(0.0, quarter);
assert!(r_at.is_finite(), "it does not NaN — that is the whole problem");
assert!((r_at.length() - 1.0).abs() < 1e-6, "and it is unit length: {r_at:?}");
assert!(
r_at.dot(EAST) < -0.5,
"the look-at basis must be MIRRORED at exact top-down, got {r_at:?}"
);
// 2. It is ill-conditioned, not merely wrong at one point: perturbing the
// elevation by a ten-millionth of a radian FLIPS the east axis.
let (_, r_below, _) = orbit_reference_basis(0.0, quarter - 1e-7);
let (_, r_above, _) = orbit_reference_basis(0.0, quarter + 1e-7);
assert!(
r_below.dot(r_above) < -0.5,
"1e-7 rad across top-down must flip the look-at east axis: {r_below:?} vs {r_above:?}"
);
// 3. The closed form is exact AT the constraint...
let v = View::ortho_2d(Vec3::ZERO, [1.0, 1.0]);
let (fwd, right, up) = v.basis();
assert_eq!(fwd, Vec3::new(0.0, -1.0, 0.0), "straight down");
assert_eq!(right, EAST, "east → screen +x");
assert_eq!(up, Vec3::new(0.0, 0.0, -1.0), "north → screen up");
// 4. ...and STABLE across it: the same ±1e-7 sweep moves it by nothing.
let lo = View::perspective_3d(Vec3::ZERO, 0.0, -1e-7, 5.0, 1.0).basis().1;
let hi = View::perspective_3d(Vec3::ZERO, 0.0, 1e-7, 5.0, 1.0).basis().1;
assert!(
lo.dot(hi) > 0.999_999 && lo.dot(EAST) > 0.999_999,
"the closed form must not flip across the constraint: {lo:?} vs {hi:?}"
);
}
/// The 2D ortho reduction: `screen = (world − target)·zoom + centre`, the
/// affine `facett-map`'s `MapTransform::project` applies — asserted at
/// NON-identity zoom, NON-zero target and an off-centre pane, and demanded
/// **bit-exact** (the axis choice makes it a genuine identity, not an
/// approximation).
#[test]
fn ortho_2d_is_bit_exactly_the_map_affine() {
let (zx, zy) = (4096.0f32, 4096.0f32);
let (rx, ry) = (0.361_25f32, 0.284_75f32);
let centre = (517.5f32, 388.25f32);
let v = View::ortho_2d(Vec3::new(rx, 0.0, ry), [zx, zy]);
for pos in [[0.361_5f32, 0.284_2], [0.0, 0.0], [1.0, -0.5], [rx, ry]] {
let p = v.project_world(Vec3::new(pos[0], 0.0, pos[1]), centre, 300.0);
let want_x = (pos[0] - rx) * zx + centre.0;
let want_y = (pos[1] - ry) * zy + centre.1;
assert_eq!(p.x.to_bits(), want_x.to_bits(), "x bit-exact for {pos:?}");
assert_eq!(p.y.to_bits(), want_y.to_bits(), "y bit-exact for {pos:?}");
}
// NON-IDENTITY GUARD: the zoom must actually be doing work. A dead
// transform (scale 1, offset 0) would satisfy the loop above only if the
// reference were equally dead, so pin the applied magnitude.
let far = v.project_world(Vec3::new(rx + 0.01, 0.0, ry), centre, 300.0);
assert!(
(far.x - centre.0 - 40.96).abs() < 1e-3,
"0.01 Mercator at zoom 4096 must move 40.96 px, moved {}",
far.x - centre.0
);
}
/// Orthographic really is orthographic: `distance` cannot change the on-screen
/// scale, but in perspective mode it must.
#[test]
fn ortho_ignores_distance_and_perspective_does_not() {
let p = Vec3::new(0.25, 0.0, 0.1);
let mut a = View::ortho_2d(Vec3::ZERO, [1000.0, 1000.0]);
let near = a.project_world(p, (0.0, 0.0), 300.0).x;
a.distance = 97.0;
let far = a.project_world(p, (0.0, 0.0), 300.0).x;
assert_eq!(near.to_bits(), far.to_bits(), "ortho scale is distance-invariant");
let mut q = View::perspective_3d(Vec3::ZERO, 0.0, 0.9, 3.0, 50f32.to_radians());
let s1 = q.project_world(p, (0.0, 0.0), 300.0);
q.distance = 6.0;
let s2 = q.project_world(p, (0.0, 0.0), 300.0);
assert!(s1.visible && s2.visible, "both poses see the point");
assert!(
(s1.x - s2.x).abs() > 1.0,
"perspective MUST foreshorten with distance ({} vs {})",
s1.x,
s2.x
);
}
/// `Terrain::Flat` is the `Z(x,y) = 0` constraint and it *discards* the sample;
/// `Heightfield` passes it through. Driven with a NON-zero sample so a
/// pass-through bug cannot hide.
#[test]
fn flat_terrain_discards_the_heightfield_sample() {
let flat = View::ortho_2d(Vec3::ZERO, [1.0, 1.0]);
let hf = View::perspective_3d(Vec3::ZERO, 0.0, 0.7, 3.0, 1.0);
assert_eq!(flat.terrain_height(37.5), 0.0, "Flat pins Z to 0");
assert_eq!(hf.terrain_height(37.5), 37.5, "Heightfield passes Z through");
assert_eq!(flat.terrain_height(-9.25), 0.0);
}
/// The constraint predicate must reject a view that misses ANY one constraint —
/// four separate reds, so it cannot be a `true` in disguise.
#[test]
fn the_2d_constraint_predicate_rejects_each_single_violation() {
let base = View::ortho_2d(Vec3::new(0.3, 0.0, 0.2), [512.0, 512.0]);
assert!(base.is_constrained_2d(), "the constructor is constrained");
let mut a = base;
a.tilt = 0.001;
assert!(!a.is_constrained_2d(), "non-zero tilt must fail");
let mut b = base;
b.azimuth = 0.001;
assert!(!b.is_constrained_2d(), "non-zero azimuth must fail");
let mut c = base;
c.projection = Projection::Perspective { fov_y: 0.9 };
assert!(!c.is_constrained_2d(), "perspective must fail");
let mut d = base;
d.terrain = Terrain::Heightfield;
assert!(!d.is_constrained_2d(), "heightfield must fail");
}
/// `constrain_2d` collapses a genuinely tilted, rotated, perspective view onto
/// the constraint surface AND keeps the on-screen scale at the target plane.
#[test]
fn constrain_2d_flattens_a_non_trivial_3d_view() {
let mut v = View::perspective_3d(
Vec3::new(1.5, 3.0, -2.5),
127f32.to_radians(),
58f32.to_radians(),
4.0,
50f32.to_radians(),
);
assert!(!v.is_constrained_2d(), "starts unconstrained");
v.constrain_2d(300.0);
assert!(v.is_constrained_2d(), "collapsed onto the constraint surface");
assert_eq!(v.target.y, 0.0, "ground plane");
assert!(!v.post.any(), "post-FX bypassed in 2D");
// The scale is the perspective scale at the target plane: focal/distance.
let want = (300.0 / (50f32.to_radians() * 0.5).tan()) / 4.0;
match v.projection {
Projection::Ortho { px_per_unit } => {
assert!((px_per_unit[0] - want).abs() < 1e-3, "{:?} vs {want}", px_per_unit)
}
_ => panic!("must be ortho"),
}
}
/// The **matrix** agrees with the CPU projection writer in BOTH modes — the
/// GPU and CPU lanes cannot drift because they are the same frame. Checked at
/// non-identity poses and with an off-centre pane.
#[test]
fn view_proj_px_agrees_with_the_cpu_projection_in_both_modes() {
let viewport = (800.0f32, 600.0f32);
let cases = [
View::ortho_2d(Vec3::new(0.36, 0.0, 0.28), [1024.0, 1024.0]),
View::perspective_3d(
Vec3::new(0.2, 0.0, -0.4),
71f32.to_radians(),
49f32.to_radians(),
3.0,
50f32.to_radians(),
),
];
let centre = (417.0f32, 271.0f32);
for v in cases {
let m = v.view_proj_px(centre, viewport);
let probes = [
Vec3::new(0.30, 0.0, 0.25),
Vec3::new(0.40, 0.0, 0.31),
Vec3::new(0.36, 0.0, 0.28),
];
let mut moved = 0;
for p in probes {
let cpu = v.project_px(v.view_space(p), centre, viewport.1 * 0.5);
if !cpu.visible {
continue;
}
let clip = m * p.extend(1.0);
assert!(clip.w.abs() > 1e-9, "w must be non-degenerate");
let ndc = (clip.x / clip.w, clip.y / clip.w);
let px = (
(ndc.0 * 0.5 + 0.5) * viewport.0,
(0.5 - ndc.1 * 0.5) * viewport.1,
);
assert!(
(px.0 - cpu.x).abs() < 0.01 && (px.1 - cpu.y).abs() < 0.01,
"matrix {px:?} vs cpu ({}, {}) for {p:?} in {:?}",
cpu.x,
cpu.y,
v.projection
);
if (px.0 - centre.0).abs() > 1.0 {
moved += 1;
}
}
assert!(moved > 0, "at least one probe must land off the pane centre");
}
}
/// **`GroundFrame2d::apply` must use the SEPARATED operation order**, i.e.
/// rotate first and multiply by `cos(tilt)` afterwards — never fold the
/// foreshortening into the rotation rows.
///
/// The two are algebraically equal and numerically are not: folding computes
/// `(ct·sa)·dx + (ct·ca)·dz`, which distributes a multiply over an addition.
/// Measured over 400k pseudo-random `(bearing, tilt, dx, dz)` draws, the folded
/// form differs from the separated one in **52.5%** of cases — always by an ULP,
/// never visibly, which is exactly what makes it dangerous: the 2D skins'
/// bit-equality guards would start failing for a reason no screenshot could
/// explain.
///
/// This test exists because a source mutation that folded the rows **passed** the
/// six-pose sweep in `facett-map`'s `layer.rs` guard. That sweep's poses were too
/// kind (half of them had `tilt = 0`, where `ct = 1` makes folding exact). So
/// this pins the ordering directly and asserts the folded form is *reachably*
/// different, which makes the mutation impossible to sneak past.
#[test]
fn the_ground_frame_must_not_fold_the_foreshorten_into_the_rotation() {
// A deterministic LCG — no rand dep, and the same draws every run.
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state >> 12;
state ^= state << 25;
state ^= state >> 27;
((state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64) / ((1u64 << 53) as f64)
};
let mut checked = 0u32;
let mut folded_differs = 0u32;
for _ in 0..20_000 {
let bearing = next() * 720.0 - 360.0;
let tilt = next() * 89.0;
let dx = next() * 10_000.0 - 5_000.0;
let dz = next() * 10_000.0 - 5_000.0;
let f = View::ground_frame_2d_compass_deg(bearing, tilt);
let (_, got_y) = f.apply(dx, dz);
// The canonical (reference) order: rotate, THEN foreshorten.
let separated = (f.rot[2] * dx + f.rot[3] * dz) * f.foreshorten;
assert_eq!(
got_y.to_bits(),
separated.to_bits(),
"apply must rotate then foreshorten, at bearing {bearing} tilt {tilt}"
);
// The folded order, which `apply` must NOT be using.
let ct = f.foreshorten;
let folded = (ct * f.rot[2]) * dx + (ct * f.rot[3]) * dz;
if folded.to_bits() != separated.to_bits() {
folded_differs += 1;
}
checked += 1;
}
let pct = 100.0 * f64::from(folded_differs) / f64::from(checked);
println!("[item6] folded != separated in {folded_differs}/{checked} = {pct:.1}% of draws");
assert_eq!(checked, 20_000, "the sweep must have run");
// NON-VACUITY: if the two orders never differed, the assertion above would be
// comparing a value to itself and this guard would be decoration.
assert!(
folded_differs > 1_000,
"the folded form must be REACHABLY different or this guard is hollow \
(differed in only {folded_differs} of {checked})"
);
}
/// **Cull and draw must be exact inverses** or the compute cull drops ways that
/// are on screen. In ortho mode the round-trip is bit-stable; in perspective
/// mode (a real ray/plane intersect at a non-trivial tilt) it closes to well
/// under a hundredth of a pixel.
#[test]
fn unproject_is_the_inverse_of_project_in_both_modes() {
let centre = (417.0f32, 271.0f32);
let half_h = 300.0f32;
// ORTHO: bit-stable round-trip at zoom 4096 and a non-zero ref.
let o = View::ortho_2d(Vec3::new(0.361_25, 0.0, 0.284_75), [4096.0, 4096.0]);
for px in [(300.0f32, 200.0f32), (700.0, 512.0), (centre.0, centre.1)] {
let w = o.unproject_ground_px(px, centre, half_h).expect("ortho always hits");
let back = o.project_world(w, centre, half_h);
assert_eq!(back.x.to_bits(), px.0.to_bits(), "ortho x round-trip for {px:?}");
assert_eq!(back.y.to_bits(), px.1.to_bits(), "ortho y round-trip for {px:?}");
}
// ORTHO AT A REAL BEARING AND A REAL TILT — the arm the guard above could
// never reach. `ortho_2d` is azimuth 0 / tilt 0, where `cos(tilt) == 1` and
// the whole frame is the identity, so it cannot tell a working inverse from
// one that multiplies by `cos(tilt)` where it should divide (LAW 2: the
// identity value). That defect made the compute cull's box 2.42× too short at
// tilt 50° and 14.9× at MAX_TILT 75°; the sweep below is red on it at every
// non-zero tilt and stays bit-stable at tilt 0.
for &(bearing, tilt) in &[(0.0f32, 50.0f32), (31.0, 50.0), (137.0, 75.0), (-44.0, 12.5), (73.0, 0.0)] {
let o = View::ortho_2d_compass([0.361_25, 0.284_75], [4096.0, 2731.0], bearing, tilt);
for px in [(300.0f32, 200.0f32), (700.0, 512.0), (120.0, 890.0), (centre.0, centre.1)] {
let w = o.unproject_ground_px(px, centre, half_h).expect("ortho always hits");
assert!(w.y.abs() < 1e-6, "a tilted ortho hit must be ON the ground plane, got y={}", w.y);
let back = o.project_world(w, centre, half_h);
assert!(
(back.x - px.0).abs() < 2e-3 && (back.y - px.1).abs() < 2e-3,
"ortho round-trip at bearing {bearing} tilt {tilt} for {px:?}: got ({}, {})",
back.x,
back.y
);
}
}
// …and the defect must be REACHABLE, or the sweep above is a green nobody has
// seen red: the old expression (`target + right·cx + up·cy`, y dropped) is
// written out here and must miss by pixels, not ULPs.
let o = View::ortho_2d_compass([0.361_25, 0.284_75], [4096.0, 2731.0], 31.0, 50.0);
let (_, right, up) = o.basis();
let px = (700.0f32, 512.0f32);
let old = o.target
+ right * ((px.0 - centre.0) / 4096.0)
+ up * (-(px.1 - centre.1) / 2731.0);
let old_back = o.project_world(Vec3::new(old.x, 0.0, old.z), centre, half_h);
let miss = ((old_back.x - px.0).powi(2) + (old_back.y - px.1).powi(2)).sqrt();
assert!(miss > 10.0, "the pre-fix ortho inverse must be reachably wrong, missed by only {miss} px");
// PERSPECTIVE at a real tilt/azimuth: ray/plane, so approximate but tight.
let p = View::perspective_3d(
Vec3::new(0.2, 0.0, -0.4),
71f32.to_radians(),
49f32.to_radians(),
3.0,
50f32.to_radians(),
);
let mut hits = 0;
for px in [(417.0f32, 271.0f32), (500.0, 350.0), (330.0, 300.0)] {
let Some(w) = p.unproject_ground_px(px, centre, half_h) else { continue };
assert!(w.y.abs() < 1e-4, "the hit must be ON the ground plane, got y={}", w.y);
let back = p.project_world(w, centre, half_h);
if !back.visible {
continue;
}
assert!(
(back.x - px.0).abs() < 0.01 && (back.y - px.1).abs() < 0.01,
"perspective round-trip {px:?} -> {w:?} -> ({}, {})",
back.x,
back.y
);
hits += 1;
}
assert!(hits >= 2, "the perspective sweep must actually hit the plane, hit {hits}");
// NON-VACUITY: a ray that cannot reach the plane must say so, not hand back
// a bogus point. At an 80-degree tilt the eye is genuinely LIFTED above the
// plane (`eye.y = distance*cos(tilt) > 0`), so a ray through the top of the
// screen points above the horizon and must miss.
//
// (Tilt exactly 90 degrees is NOT a valid probe here and it is worth
// recording why: there the eye sits *on* the ground plane, so every ray
// trivially "hits" it at t ~ 0. The first version of this assertion used
// 90 degrees and failed for that reason — the miss case, not the code.)
let low = View::perspective_3d(Vec3::ZERO, 0.0, 80f32.to_radians(), 3.0, 50f32.to_radians());
assert!(low.eye().y > 0.4, "the probe camera must really be above the plane");
assert!(
low.unproject_ground_px((centre.0, 0.0), centre, half_h).is_none(),
"a ray above the horizon must miss the ground plane"
);
// ...and the BOTTOM of the same screen must still hit, or the `None` above
// would prove nothing (a function that always returns None would pass).
assert!(
low.unproject_ground_px((centre.0, 600.0), centre, half_h).is_some(),
"the downward ray of the same camera must hit"
);
}
/// The depth row must ORDER fragments in `[0,1]` in perspective mode (the wgpu
/// convention the depth test needs), at a non-trivial pose.
#[test]
fn perspective_depth_row_orders_and_stays_normalised() {
let v = View::perspective_3d(
Vec3::ZERO,
0.7,
0.8,
5.0,
50f32.to_radians(),
);
let m = v.view_proj(800.0 / 600.0);
let (fwd, _, _) = v.basis();
let near_p = v.eye() + fwd * 3.5;
let far_p = v.eye() + fwd * 6.5;
let (a, b) = (m * near_p.extend(1.0), m * far_p.extend(1.0));
assert!(a.w > 0.0 && b.w > 0.0, "both in front of the eye");
let (za, zb) = (a.z / a.w, b.z / b.w);
assert!(za < zb, "nearer must be smaller depth ({za} vs {zb})");
assert!((0.0..=1.0).contains(&za) && (0.0..=1.0).contains(&zb), "{za}, {zb} in [0,1]");
}
/// **The ortho twin** (2026-09-29): the plan view's depth row. Ortho does not divide
/// (`w ≡ 1`), so the depth map must be linear over the view's `[near, far]` band —
/// before `14b1c6e` it reused perspective's `a, b` and gave `ndc.z = z`, which put the
/// ground of a plan view (eye `distance = 1` above it) on exactly `1.0`, the depth
/// clear value, so a `Less` test drew no ground (measured by the GPU-only
/// `postfx_bypass` plan frame; this is the CPU guard for the same row). Every ortho
/// constructor shares the row (`compose(.., divide = false, ..)`), and so does
/// `view_proj_px`; each is checked.
#[test]
fn ortho_depth_row_puts_the_plan_ground_inside_the_band_and_orders_height() {
let target = [0.361_25_f32, 0.284_75_f32];
let views = [
("ortho_2d", View::ortho_2d(Vec3::new(target[0], 0.0, target[1]), [4096.0, 4096.0])),
("ortho_2d_map", View::ortho_2d_map(target, [4096.0, 2731.0])),
("ortho_2d_compass", View::ortho_2d_compass(target, [4096.0, 2731.0], 31.0, 0.0)),
("ortho_2d_compass tilted", View::ortho_2d_compass(target, [4096.0, 2731.0], 31.0, 50.0)),
];
for (name, v) in views {
assert!(v.projection.is_ortho(), "{name} is ortho");
let ground = Vec3::new(target[0], 0.0, target[1]);
let (fwd, _, _) = v.basis();
for (how, m) in [("view_proj", v.view_proj(800.0 / 600.0)), ("view_proj_px", v.view_proj_px((417.0, 271.0), (800.0, 600.0)))] {
let depth = |p: Vec3| {
let c = m * p.extend(1.0);
assert!((c.w - 1.0).abs() < 1e-6, "{name}/{how}: ortho does not divide (w = {})", c.w);
c.z / c.w
};
let z_ground = depth(ground);
// THE PLAN-VIEW CASE: the eye is `distance` (= 1 here) above the ground.
assert!(
z_ground > 0.0 && z_ground < 1.0,
"{name}/{how}: the ground at the target must land strictly inside (0,1), got {z_ground} \
(1.0 is the depth clear value — a Less test would draw no ground)"
);
// Along the view ray: toward the eye is nearer (smaller), away is farther.
let above = depth(ground - fwd * 0.5);
let below = depth(ground + fwd * 0.5);
assert!(above < z_ground && z_ground < below, "{name}/{how}: {above} < {z_ground} < {below}");
// Geometry taller than `distance` above the target is not near-clipped, and
// geometry below the ground still lies inside the band.
let tall = depth(ground - fwd * (v.distance + 0.5));
for (what, z) in [("above", above), ("below", below), ("taller than the eye", tall)] {
assert!((0.0..=1.0).contains(&z), "{name}/{how}: {what} at {z} is outside [0,1]");
}
}
}
}
}