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

nordisk/facett

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