facett-map3d/tests/flight_pose_reaches_the_renderer.rs
//! **A 6DOF FLIGHT POSE IS AN ORBIT POSE — proven on the renderer's own camera, not on
//! what the setter intended.**
//!
//! # Why this seam exists
//!
//! `facett-demo/src/osm_fly.rs` grew a **degraded twin renderer**: 929 lines with its own
//! projection, its own flat shading, its own fog, its own sky and its own painter's-algorithm
//! depth sort, referencing `facett_map3d_flight` (the engine) three times and
//! `facett_map3d` (the RENDERER) zero times — while the file's own doc claimed reuse. Its
//! sister `osm_tabs.rs` does it right through `Map3D::from_geometry_relief_roofed`. Per LAW 5
//! the fix is by construction: draw the flight world THROUGH a `Map3D`, and inherit the
//! z-buffer, tonemap, roofs, colours, haze and sun instead of keeping a worse copy of each.
//!
//! The one thing standing in the way was the camera. `Map3D` renders through an
//! `OrbitCamera`; a flight rig has an eye position and a heading. This file is the proof
//! that the second is expressible as the first, exactly — because if it is not, the twin
//! renderer had a reason to exist and deleting it would be a regression rather than a fix.
//!
//! # What is asserted
//!
//! Not "the setter stored what I passed it". Every assertion reads
//! `Map3D::render_camera()` — the camera the frame is actually projected with, after the
//! tour / flythrough / spin overrides have had their say — and asks where its EYE is and
//! which way its BASIS points. A setter that dropped the value on the floor reports the same
//! fields back and fails here.
use facett_map3d::Map3D;
mod common;
/// Central Stockholm, the centre `common::town` is built around.
const STOCKHOLM: (f64, f64) = (18.0686, 59.3293);
/// A scene a kilometre across — big enough that a hundred metres of camera travel is a
/// visible fraction of it and small enough to stay one town.
const SPAN_M: f64 = 1_000.0;
fn scene() -> Map3D {
let ways = common::town(SPAN_M);
let m = Map3D::from_geometry("flight-pose", &ways);
assert!(
m.geo_bbox().is_some(),
"precondition: the scene has a geographic span"
);
m
}
/// **The eye lands where the geography says it does.**
///
/// `design_from_lonlat` is the placement rule and `render_camera().eye()` is where the
/// renderer actually puts the camera. They are computed by different code — one is the
/// `to_design` footprint mapping, the other is `target − distance · forward` out of the view
/// core — so agreeing is a fact rather than a tautology.
///
/// RED directions: drop the `+ f[i] * d` from `look_from`'s target and the eye lands at the
/// target instead of a dolly length behind it; use `du_per_m` for the ground axes as well as
/// the height axis and the eye lands metres from where the city was built.
#[test]
fn a_geographic_eye_lands_at_its_own_design_position() {
let mut m = scene();
let alt_m = 400.0;
assert!(
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, alt_m, 0.0, -0.4, 0.0),
"a scene with a geo bbox must accept a geographic pose",
);
let want = m
.design_from_lonlat(STOCKHOLM.0, STOCKHOLM.1)
.expect("the centre is placeable");
let eye = m.render_camera().eye();
assert!(
(eye.x - want[0]).abs() < 1e-4 && (eye.z - want[1]).abs() < 1e-4,
"the rendered eye must sit at the design position of its own lon/lat: \
got ({}, {}), want ({}, {})",
eye.x,
eye.z,
want[0],
want[1],
);
// And the height axis is the scene's own metres-per-design-unit, not the ground scale.
let du_per_m = m
.design_units_per_metre()
.expect("a geo scene has a calibrated scale");
assert!(
(eye.y - alt_m as f32 * du_per_m).abs() < 1e-4,
"{alt_m} m up is {} design units on this scene, eye.y is {}",
alt_m as f32 * du_per_m,
eye.y,
);
}
/// **The centre of the scene is the centre of the design plane** — the precondition the test
/// above rests on, stated separately so a `design_from_lonlat` that returned a constant would
/// not satisfy both by accident.
///
/// RED direction: return `Some([0.0, 0.0])` unconditionally and this passes while
/// `a_geographic_eye_lands_at_its_own_design_position` still passes too — which is why the
/// offset half below is here.
#[test]
fn the_design_mapping_moves_when_the_geography_does() {
let m = scene();
let (lon0, lat0, lon1, lat1) = m.geo_bbox().expect("a geo scene has a bbox");
// The design origin is the centre of the MERCATOR box, so LONGITUDE — which mercator x
// is linear in — lands on it exactly.
let centre = m.geo_centre().expect("a geo scene has a centre");
let at_centre = m.design_from_lonlat(centre.0, centre.1).expect("placeable");
assert!(
at_centre[0].abs() < 1e-5,
"the mid longitude IS the design x origin, got {at_centre:?}",
);
// LATITUDE does not, and the residual is the projection's own curvature rather than a
// bug: `geo_centre` averages degrees of latitude while `to_design` centres the mercator
// box, and mercator y is nonlinear in latitude. MEASURED on this 1 km scene: 9.7e-4
// design units against a 1.6-unit span, i.e. 0.06 %. Bounded here so a future projection
// change that made it large could not pass unnoticed.
assert!(
at_centre[1].abs() < 5e-3,
"the lat-averaged centre is off the design z origin only by Mercator curvature, \
got {at_centre:?}",
);
let east = m.design_from_lonlat(lon1, centre.1).expect("placeable");
let west = m.design_from_lonlat(lon0, centre.1).expect("placeable");
let north = m.design_from_lonlat(centre.0, lat1).expect("placeable");
let south = m.design_from_lonlat(centre.0, lat0).expect("placeable");
assert!(east[0] > 0.05, "east of centre is +x, got {east:?}");
assert!(west[0] < -0.05, "west of centre is −x, got {west:?}");
// North (small Mercator y) maps to FAR (−z) — `to_design`'s documented orientation, and
// getting it backwards is the class of bug that mirrors a city without changing a count.
assert!(
north[1] < -0.05,
"north of centre is −z (far), got {north:?}"
);
assert!(
south[1] > 0.05,
"south of centre is +z (near), got {south:?}"
);
}
/// **Heading and pitch reach the renderer's BASIS**, which is what actually decides what the
/// frame looks like.
///
/// RED directions: swap the `azimuth` sign in `look_from` and every heading mirrors east for
/// west (a change no triangle count can see); drop the `[v0, v2, v1]` frame swap in
/// `look_from_lonlat` and north/up are exchanged, so nosing down looks sideways.
#[test]
fn heading_and_pitch_reach_the_rendered_basis() {
let mut m = scene();
// yaw 0 is toward +easting, which is the render frame's +x.
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 300.0, 0.0, 0.0, 0.0);
let (fwd, _, _) = m.render_camera().basis();
assert!(
fwd.x > 0.99 && fwd.y.abs() < 1e-3 && fwd.z.abs() < 1e-3,
"yaw 0, pitch 0 must look due EAST and level, got ({}, {}, {})",
fwd.x,
fwd.y,
fwd.z,
);
// yaw +90° is toward +northing — and in THIS scene's frame north is **−z**:
// `extrude::to_design` maps north (small Mercator y) to far/−z, and the
// geometry, the eye (`design_from_lonlat`) and the forward must live in ONE
// frame. This assertion used to demand `+z` — the terrain-demo frame — which
// pinned a camera that mirrored every heading north-for-south against the
// extruded city: told "look at the body to the north" it looked exactly away.
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 300.0, std::f64::consts::FRAC_PI_2, 0.0, 0.0);
let (fwd, _, _) = m.render_camera().basis();
assert!(
fwd.z < -0.99 && fwd.x.abs() < 1e-3,
"yaw +90° must look due NORTH (−z in the design frame), got ({}, {}, {})",
fwd.x,
fwd.y,
fwd.z,
);
// Nose DOWN is a negative y component, and it must be the pitch that was asked for.
for pitch in [-0.2f64, -0.45, -0.9] {
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 300.0, 0.0, pitch, 0.0);
let (fwd, _, _) = m.render_camera().basis();
assert!(
(fwd.y - pitch.sin() as f32).abs() < 1e-3,
"pitch {pitch} rad must give a forward y of {}, got {}",
pitch.sin(),
fwd.y,
);
}
}
/// **A whole flight path round-trips** — every pose the rig can be in reaches the renderer,
/// not just the three the assertions above happen to name.
///
/// A sweep rather than three cases because an inverse with a quadrant error passes at
/// azimuth 0 and 90 and fails at 135, and a `.asin()` that lost a sign passes level and fails
/// nosed down.
///
/// RED direction: replace `atan2(-f.x, -f.z)` with `atan2(f.x, f.z)` and this fails at every
/// sample while the two cardinal assertions above still pass.
#[test]
fn every_pose_on_a_flight_path_round_trips_through_the_orbit_camera() {
let mut m = scene();
let mut worst = 0.0f32;
for i in 0..24 {
let yaw = std::f64::consts::TAU * f64::from(i) / 24.0;
let pitch = -0.9 + 1.8 * f64::from(i % 7) / 6.0;
let alt = 120.0 + 60.0 * f64::from(i % 5);
// Off-centre, so the eye assertion is not satisfied by the design origin.
let lon = STOCKHOLM.0 + 0.002 * (f64::from(i) / 24.0 - 0.5);
let lat = STOCKHOLM.1 + 0.001 * (f64::from(i) / 24.0 - 0.5);
assert!(
m.look_from_lonlat(lon, lat, alt, yaw, pitch, 0.0),
"pose {i} must be accepted"
);
let want_eye = m.design_from_lonlat(lon, lat).expect("placeable");
let du_per_m = m.design_units_per_metre().expect("calibrated");
let cam = m.render_camera();
let eye = cam.eye();
let (fwd, _, _) = cam.basis();
let (cp, sp) = (pitch.cos(), pitch.sin());
// Northing is −z in the design frame (see the cardinal test above).
let want_fwd = [(yaw.cos() * cp) as f32, sp as f32, -(yaw.sin() * cp) as f32];
for (name, got, want) in [
("eye.x", eye.x, want_eye[0]),
("eye.y", eye.y, alt as f32 * du_per_m),
("eye.z", eye.z, want_eye[1]),
("fwd.x", fwd.x, want_fwd[0]),
("fwd.y", fwd.y, want_fwd[1]),
("fwd.z", fwd.z, want_fwd[2]),
] {
let e = (got - want).abs();
worst = worst.max(e);
assert!(
e < 2e-3,
"pose {i} (yaw {yaw:.2}, pitch {pitch:.2}, alt {alt}): {name} is {got}, \
want {want}",
);
}
}
eprintln!(" 24 flight poses round-tripped through OrbitCamera, worst error {worst:.2e}");
}
/// **A scene with no geography refuses rather than parking the camera at the origin.**
///
/// A synthetic fixture has no lon/lat span, so there is no honest place to put a geographic
/// eye. Silently using `(0, 0)` would point the camera at the design origin and look like a
/// working flight over a city that is not there.
///
/// RED direction: `unwrap_or([0.0, 0.0])` in `look_from_lonlat` and this fails.
#[test]
fn a_synthetic_scene_refuses_a_geographic_pose_instead_of_faking_one() {
// An EMPTY ways set — `Map3D::synthetic` is a fixture with a real geographic span, so it
// is not the no-geography case despite the name.
let mut m = Map3D::from_ways("no-geography", &[], 1.0);
assert!(
m.geo_bbox().is_none(),
"precondition: a scene with no ways has no geo span"
);
let before = m.render_camera();
assert!(
!m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 500.0, 0.3, -0.2, 0.0),
"a scene with no geographic span must REFUSE a geographic pose",
);
let after = m.render_camera();
assert!(
(before.eye().x - after.eye().x).abs() < 1e-9
&& (before.eye().y - after.eye().y).abs() < 1e-9
&& (before.eye().z - after.eye().z).abs() < 1e-9,
"a refused pose must leave the camera exactly where it was",
);
}
/// **The dolly length keeps the scene inside the far plane.**
///
/// `look_from` parks the orbit target a dolly length down the view ray, and the view's far
/// plane is `distance + FAR_PAD`. Too short a dolly clips the far side of the city out of
/// frame — a regression that looks like missing geometry, not like a camera bug.
///
/// RED direction: set `DOLLY_DU` to 0.05 and this fails.
#[test]
fn the_dolly_length_clears_the_scene_span() {
let mut m = scene();
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 300.0, 0.0, -0.3, 0.0);
let cam = m.render_camera();
assert!(
cam.distance >= facett_map3d::extrude::DESIGN_FILL,
"the dolly ({}) must clear the scene's own {} design-unit span, or the far side of \
the city falls outside the frustum",
cam.distance,
facett_map3d::extrude::DESIGN_FILL,
);
}
/// **ROLL reaches the rendered basis — the horizon actually tilts.**
///
/// # The degree of freedom that used to be dropped
///
/// When the flight tab was moved onto the shared renderer, `OrbitCamera` had `azimuth` and
/// `elevation` and nothing else: `View::basis` built `right` with `y == 0`, a horizon level
/// by construction. `FlightCamera` has had `roll` since it was written (Q / E, the aileron),
/// so a stick-mode bank changed the rig's state and NOTHING on screen. That was documented
/// rather than hidden, which is better than silence and still a control that acks and does
/// nothing.
///
/// `View` now carries a third angle. This asserts the whole chain — rig roll → `OrbitCamera`
/// → `View` → the basis the frame projects with.
///
/// RED directions: drop `.with_roll(self.roll)` from `OrbitCamera::view` and the horizon is
/// level at every bank; flip the sign in `View::basis` and the wings swap, which no count can
/// see; give `look_from` a `goal_roll` the damper eases toward and the bank lags a frame.
#[test]
fn a_banked_flight_pose_tilts_the_rendered_horizon() {
let mut m = scene();
// Level first — the reference the bank is measured against.
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 400.0, 0.0, -0.3, 0.0);
let (_, right0, up0) = m.render_camera().basis();
assert!(
right0.y.abs() < 1e-6,
"an unbanked camera has a level horizon: right.y = {}",
right0.y,
);
for roll in [0.2f64, -0.35, 0.9] {
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 400.0, 0.0, -0.3, roll);
let (fwd, right, up) = m.render_camera().basis();
// The horizon TILTS, by the angle that was asked for. `right` stays a unit vector in
// the (right, up) plane, so its component along the level `up0` is `sin(roll)`.
let got = right.x * up0.x + right.y * up0.y + right.z * up0.z;
assert!(
(got - roll.sin() as f32).abs() < 1e-4,
"a bank of {roll} rad must lean `right` onto the level up by sin({roll}) = {}, got {got}",
roll.sin(),
);
// A roll is a rotation ABOUT the look direction — it must not re-aim the camera.
let (fwd0, _, _) = {
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 400.0, 0.0, -0.3, 0.0);
m.render_camera().basis()
};
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 400.0, 0.0, -0.3, roll);
let (fwd, _, _) = (fwd, right, up);
assert!(
(fwd.x - fwd0.x).abs() < 1e-6
&& (fwd.y - fwd0.y).abs() < 1e-6
&& (fwd.z - fwd0.z).abs() < 1e-6,
"banking must not change where the camera LOOKS: ({}, {}, {}) vs ({}, {}, {})",
fwd.x, fwd.y, fwd.z, fwd0.x, fwd0.y, fwd0.z,
);
// And the frame stays orthonormal, or the projection is shearing rather than banking.
let dot = right.x * up.x + right.y * up.y + right.z * up.z;
let len = (right.x * right.x + right.y * right.y + right.z * right.z).sqrt();
assert!(dot.abs() < 1e-5, "right ⟂ up must survive the bank, got dot {dot}");
assert!((len - 1.0).abs() < 1e-5, "right stays a unit vector, got {len}");
}
}
/// **A zero roll is the IDENTITY on the basis** — bit-for-bit what the core returned before
/// the axis existed, so every camera that is not flying a stick projects exactly as it did.
///
/// This is the guard that makes the change additive rather than a rewrite of the shared
/// projection. RED direction: run the Rodrigues arm unconditionally (drop the `roll == 0.0`
/// early return) and the values move in the last bits at some poses.
#[test]
fn a_zero_roll_is_the_identity_on_the_basis() {
use facett_core::render::View;
use facett_core::render::camera::V3;
for (az, el) in [(0.0f32, 0.3f32), (1.1, 0.0), (-2.4, 1.2), (3.0, -0.5)] {
let v = View::from_orbit(V3::new(0.1, 0.2, 0.3), az, el, 2.0, 1.0);
assert_eq!(v.roll, 0.0, "the constructors default the bank to level");
let (f, r, u) = v.basis();
let (f2, r2, u2) = v.with_roll(0.0).basis();
assert_eq!(
(f.x, f.y, f.z, r.x, r.y, r.z, u.x, u.y, u.z),
(f2.x, f2.y, f2.z, r2.x, r2.y, r2.z, u2.x, u2.y, u2.z),
"an explicit zero roll must be indistinguishable from no roll at all",
);
}
}
/// **A screen click unprojects to the ground the camera is actually looking at**
/// (V2.5 D.2 fix — the waypoint's one honest mapping).
///
/// korp's first cut turned a click into ground metres through the top-down RAIL
/// camera's `span/width` — a different camera from the perspective on screen — so a
/// horizon click landed under a span ahead, usually inside the 200 m arrival radius:
/// "left-click does nothing" because the flight arrived instantly.
/// `ground_lonlat_at_px` shoots the pixel ray of the RENDER camera at the terrain
/// plane instead. The proof is a round-trip through independent code: the unprojected
/// `(lon, lat)` goes back through `design_from_lonlat` + the camera's own projector
/// and must land on the pixel that was clicked.
///
/// RED directions: mirror the ray's northing (the `look_from_lonlat` bug this round
/// fixed) and the round-trip misses by hundreds of pixels; intersect `target.y`
/// instead of the ground (the flight target hangs mid-air one dolly-length out) and
/// every click lands short of where it points.
#[test]
fn a_click_ray_meets_the_ground_where_the_pixel_points() {
let mut m = scene();
let (w, h) = (800.0f32, 600.0f32);
assert!(
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 250.0, 0.7, -0.35, 0.0),
"pose accepted"
);
// Three pixels: centre, low-left, low-right — all rays that reach the ground.
for px in [(0.0f32, 0.0f32), (-180.0, 140.0), (220.0, 90.0)] {
let (lon, lat) = m
.ground_lonlat_at_px(px, (w, h))
.unwrap_or_else(|| panic!("pixel {px:?} looks at the ground and must resolve"));
let [dx, dz] = m.design_from_lonlat(lon, lat).expect("scene has a span");
let cam = m.render_camera();
let pr = cam.project(facett_map3d::V3::new(dx, 0.0, dz), (w * 0.5, h * 0.5), h * 0.5);
assert!(pr.visible, "the ground point must be inside the frustum for {px:?}");
let miss =
((pr.x - (w * 0.5 + px.0)).powi(2) + (pr.y - (h * 0.5 + px.1)).powi(2)).sqrt();
assert!(
miss < 1.5,
"unproject∘project must return to the clicked pixel: {px:?} came back \
{miss:.1} px off (lon {lon:.6}, lat {lat:.6})",
);
}
// A sky pixel refuses rather than fabricating a waypoint: nose LEVEL, a pixel
// well above the horizon.
m.look_from_lonlat(STOCKHOLM.0, STOCKHOLM.1, 250.0, 0.7, 0.0, 0.0);
assert!(
m.ground_lonlat_at_px((0.0, -200.0), (w, h)).is_none(),
"a click above the horizon must plant NO waypoint",
);
}