facett-map3d/tests/camera_nav_proof.rs
//! **OSM-3D garmin-nav tilt/bearing proof** (headless, data-level A/B — no pixels).
//!
//! The car-navigator feel is two controls over the shared `OrbitCamera`:
//! **bearing** (compass heading the view looks along) and **tilt** (pitch down from
//! straight-above). This asserts, as DATA:
//! 1. changing BEARING rotates the horizontal heading by the same angle,
//! 2. increasing TILT lifts the view toward the horizon (forward pitch shallows),
//! 3. both round-trip through `bearing_deg()`/`tilt_deg()`, and
//! 4. the pose is deterministic under `settle()`.
//!
//! No GPU: reuses the pure `basis()`/`eye()` math, so it runs anywhere.
//!
//! Run: `cargo test -p facett-map3d --test camera_nav_proof [--features testmatrix]`.
use facett_map3d::{OrbitCamera, V3};
/// The camera's horizontal (ground-plane) heading unit vector for the current pose.
fn heading_xz(c: &OrbitCamera) -> (f32, f32) {
let (fwd, _r, _u) = c.basis();
let (x, z) = (fwd.x, fwd.z);
let l = (x * x + z * z).sqrt().max(1e-9);
(x / l, z / l)
}
/// Forward pitch: the downward component of the look direction (more negative =
/// steeper down toward the map; ~0 = level toward the horizon).
fn forward_pitch_y(c: &OrbitCamera) -> f32 {
c.basis().0.y
}
fn nav_cam(bearing_deg: f32, tilt_deg: f32) -> OrbitCamera {
let mut c = OrbitCamera::default();
c.target = V3::new(0.0, 0.1, 0.0);
c.goal_target = c.target;
c.goal_distance = 3.0;
c.set_bearing_deg(bearing_deg);
c.set_tilt_deg(tilt_deg);
c.settle();
c
}
#[test]
fn tilt_bearing_drive_the_garmin_nav_view() {
// (1) BEARING rotates the heading. For each base bearing, a +90° bearing change
// rotates the horizontal heading by ~90° (angle between the two unit vecs).
let mut bearing_ok = true;
let mut worst_bearing_err = 0.0f32;
for &base in &[0.0f32, 37.0, 118.0, 205.0, 300.0] {
let a = heading_xz(&nav_cam(base, 45.0));
let b = heading_xz(&nav_cam(base + 90.0, 45.0));
let dot = (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0);
let angle = dot.acos().to_degrees();
let err = (angle - 90.0).abs();
worst_bearing_err = worst_bearing_err.max(err);
bearing_ok &= err < 1.0;
}
assert!(bearing_ok, "a +90° bearing rotates the heading ~90° (worst err {worst_bearing_err:.3}°)");
// (2) TILT shallows the pitch. Near top-down (tilt 10°) the view looks steeply
// DOWN; near the horizon (tilt 80°) it's nearly level → the downward
// component rises toward 0. Monotone across a tilt sweep.
let pitches: Vec<f32> = [10.0f32, 30.0, 50.0, 70.0, 85.0]
.iter()
.map(|&t| forward_pitch_y(&nav_cam(0.0, t)))
.collect();
let mut tilt_monotone = true;
for w in pitches.windows(2) {
// higher tilt ⇒ shallower ⇒ pitch_y strictly greater (less negative).
tilt_monotone &= w[1] > w[0] + 1e-4;
}
assert!(tilt_monotone, "increasing tilt shallows the pitch (pitch_y: {pitches:?})");
assert!(pitches[0] < -0.5, "low tilt looks steeply down (pitch_y {})", pitches[0]);
assert!(pitches[4] > pitches[0], "level tilt is far shallower than top-down");
// (3) Round-trip: bearing/tilt read back what was set (within the clamp band).
let c = nav_cam(217.0, 35.0);
let bearing_rt = (c.bearing_deg() - 217.0).abs();
let tilt_rt = (c.tilt_deg() - 35.0).abs();
assert!(bearing_rt < 1e-3, "bearing round-trips, got {} (Δ {bearing_rt})", c.bearing_deg());
assert!(tilt_rt < 1e-3, "tilt round-trips, got {} (Δ {tilt_rt})", c.tilt_deg());
// (4) Determinism: same nav inputs ⇒ bit-identical pose.
let d1 = nav_cam(118.0, 55.0);
let d2 = nav_cam(118.0, 55.0);
let deterministic = d1.eye() == d2.eye() && d1.bearing_deg() == d2.bearing_deg() && d1.tilt_deg() == d2.tilt_deg();
assert!(deterministic, "nav pose is a pure function of (bearing, tilt)");
#[cfg(feature = "testmatrix")]
{
let ok = bearing_ok && tilt_monotone && pitches[0] < -0.5 && bearing_rt < 1e-3 && tilt_rt < 1e-3 && deterministic;
facett_core::testmatrix::emit(
"facett-map3d",
"camera_tilt_bearing_nav",
ok,
&format!(
"bearing_err<1deg={bearing_ok} worst={worst_bearing_err:.3} tilt_monotone={tilt_monotone} \
pitch_topdown={:.3} pitch_level={:.3} bearing_rt={bearing_rt:.1e} tilt_rt={tilt_rt:.1e} det={deterministic}",
pitches[0], pitches[4]
),
);
}
}