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

nordisk/facett

facett-map3d/src/tour.rs

//! **Sigtuna tour** — the ONE cinematic autopilot that realises the "Google-Maps-3D
//! quality" demo brief: start HIGH in the air **circling** the extruded town so you
//! SEE THE HOUSES from a tilted bird view, then **descend** (bird → street) and
//! **drive DOWN THE STREETS** at ground level like a car-navigator, following a real
//! OSM road ribbon. One deterministic path, three phases, a pure function of an
//! injected clock (FC-7) so a pinned-clock snapshot is bit-reproducible and a headless
//! test flies the whole route without a window.
//!
//! This rides ON TOP of the existing beautiful [`Map3D`](crate::Map3D) render (GPU
//! PBR + IBL + sky + soft shadows + SSAO + bloom + reflective water) exactly like the
//! [`Flythrough`](crate::flythrough::Flythrough): it only *derives a moving pose* each
//! frame, so every "expensive look" pass the renderer already has is what you watch
//! the tour through. **Reuse, not reinvention** (L5): the air phase reuses the framed
//! city + the orbit convention, the drive phase reuses the projected [`Route`] +
//! the car-nav chase constants; the tour just sequences and blends them.
//!
//! ## The three phases (deterministic in the clock `t`, seconds; the tour LOOPS)
//! Given the framed city ([`CityFraming`] — centroid, extent, roof, base framing) and
//! an optional projected street [`Route`]:
//! - **Circle** (`0 .. CIRCLE_SECS`): a slow, high orbit around the skyline at a
//!   tilted bird elevation — the houses read as 3D prisms from above.
//! - **Descend** (`.. + DESCEND_SECS`): a smooth swoop that blends the high air pose
//!   into the street-level chase pose (distance + elevation drop, target slides from
//!   the town centre onto the route start) — the bird→street reveal.
//! - **Drive** (`.. + DRIVE_SECS`): a low, forward-leaning car-nav chase that rides
//!   the [`Route`] tangent down the streets (target on the road point, bearing on the
//!   route heading, the same close chase tilt/distance the turn-by-turn follow uses).
//!   With no route it degrades to a slow ground-level creep around the town.
//!
//! Every output is clamped to the camera's legal ranges by the caller
//! ([`OrbitCamera::settle`]'s elevation/ground/dolly clamps), so the tour can never
//! sink through the ground or gimbal-flip.

use crate::camera::{OrbitCamera, V3};
use crate::flythrough::CityFraming;
use crate::route::Route;

/// Which leg of the tour a given clock lands in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TourPhase {
    /// High, tilted orbit around the skyline (SEE THE HOUSES from the air).
    Circle,
    /// Smooth swoop from the air pose down to the street pose (bird → street).
    Descend,
    /// Low car-nav chase riding the street route (drive DOWN THE STREETS).
    Drive,
}

impl TourPhase {
    /// A short, stable tag (for HUD / `state_json` / test detail lines).
    #[must_use]
    pub fn tag(self) -> &'static str {
        match self {
            TourPhase::Circle => "circle",
            TourPhase::Descend => "descend",
            TourPhase::Drive => "drive",
        }
    }
}

/// The cinematic Sigtuna autopilot. Cheap to clone, deterministic — hold one on the
/// map and ask [`Self::pose`] for the camera pose at any clock.
#[derive(Clone, Debug)]
pub struct SigtunaTour {
    /// The framed city the air phase circles.
    pub framing: CityFraming,
    /// The projected street route the drive phase rides (design-plane polyline). When
    /// `None` (or empty) the drive phase falls back to a low ground-level creep.
    pub route: Option<Route>,
    /// Duration of the high air-circle phase (seconds).
    pub circle_secs: f64,
    /// Duration of the bird→street descent (seconds).
    pub descend_secs: f64,
    /// Duration of the street drive (seconds); the route is traversed 0→1 across it.
    pub drive_secs: f64,
    /// Air-circle orbit rate (radians/second) — a stately sweep, not a spin.
    pub orbit_rate: f32,
    /// **Metres per design unit** for this scene (`Map3D::metres_per_design_unit`).
    /// When `> 0` the drive phase sits a REAL street distance from the road
    /// ([`STREET_CHASE_M`] metres) — so houses loom at street level whatever the
    /// scene's real-world span (a regional clip normalises to a box where the
    /// design-unit [`OrbitCamera::CHASE_DIST`] would be hundreds of metres out). `0`
    /// ⇒ the design-unit fallback (scale-free scenes / tests).
    pub metres_per_unit: f32,
}

/// The real-world street chase distance (metres) a car-navigator chase camera holds
/// when the scene scale is known — a bonnet-cam distance so buildings read at their
/// true size. Converted to design units via [`street_chase_dist`].
///
/// **The one writer** (L5) for "how far behind the car does a chase camera sit". The
/// tour's drive phase and `Map3D`'s turn-by-turn route follow are the two readers;
/// they used to disagree, because the follow camera kept the raw design-unit
/// [`OrbitCamera::CHASE_DIST`].
pub const STREET_CHASE_M: f32 = 30.0;

/// **The chase distance in design units for a scene of `metres_per_unit` m/du** — the
/// single conversion both chase cameras go through.
///
/// The floor is [`OrbitCamera::SURFACE_EPS`], the ground clearance it exists to clear,
/// and NOT `OrbitCamera::MIN_DISTANCE`: that is 0.06 design units, sixty times larger
/// than the thing it protects against and absolute in a scene whose design unit is
/// whatever the data's span makes it (112 m in `korp`'s 3 km town window, 2 km on a
/// region clip). `SURFACE_EPS` is genuinely absolute — an `f32` depth epsilon in the
/// `[-1,1]` design box, not a length in the world.
///
/// `metres_per_unit <= 0` (scale-free scenes / tests) ⇒ the design-unit fallback.
#[must_use]
pub fn street_chase_dist(metres_per_unit: f32) -> f32 {
    if metres_per_unit > 0.0 {
        (STREET_CHASE_M / metres_per_unit).max(OrbitCamera::SURFACE_EPS)
    } else {
        OrbitCamera::CHASE_DIST
    }
}

/// Air-circle elevation (polar lift, radians) — a tilted bird view (not top-down) so
/// the extruded houses read as 3D prisms. ~30° above the horizon.
const CIRCLE_ELEV: f32 = 0.52;
/// Air-circle dolly multiplier over the framed base distance — pulled out a touch so
/// the whole town sits in frame while circling.
const CIRCLE_DIST_MUL: f32 = 1.25;
/// Fraction of the roof height the air-circle look-at sits at (frames the skyline).
const CIRCLE_TARGET_ROOF_FRAC: f32 = 0.5;

impl SigtunaTour {
    /// Default cinematic tuning around a framed city + optional street route.
    #[must_use]
    pub fn new(framing: CityFraming, route: Option<Route>) -> Self {
        // Drop an empty route (fewer than two followable points) to the creep fallback.
        let route = route.filter(|r| !r.is_empty());
        Self {
            framing,
            route,
            circle_secs: 18.0,
            descend_secs: 8.0,
            drive_secs: 26.0,
            // ~0.14 rad/s ⇒ a full skyline loop in ~45 s (only the circle phase runs it).
            orbit_rate: 0.14,
            metres_per_unit: 0.0,
        }
    }

    /// Set the scene scale (metres per design unit) so the drive phase sits a real
    /// [`STREET_CHASE_M`]-metre street distance from the road (houses loom at street
    /// level). Chainable. `0` keeps the scale-free design-unit fallback.
    #[must_use]
    pub fn with_scale(mut self, metres_per_unit: f32) -> Self {
        self.metres_per_unit = metres_per_unit;
        self
    }

    /// The drive-phase chase **distance** (design units) — [`street_chase_dist`] at
    /// this tour's scale. One writer, shared with `Map3D`'s route follow.
    fn chase_dist(&self) -> f32 {
        street_chase_dist(self.metres_per_unit)
    }

    /// The total loop length (seconds). The tour repeats forever so the demo never ends.
    #[must_use]
    pub fn total_secs(&self) -> f64 {
        self.circle_secs + self.descend_secs + self.drive_secs
    }

    /// Which phase the clock `t` (seconds) lands in (after looping).
    #[must_use]
    pub fn phase(&self, t: f64) -> TourPhase {
        let tl = t.rem_euclid(self.total_secs().max(1e-3));
        if tl < self.circle_secs {
            TourPhase::Circle
        } else if tl < self.circle_secs + self.descend_secs {
            TourPhase::Descend
        } else {
            TourPhase::Drive
        }
    }

    /// The **cinematic pose** at clock `t` (seconds): `(target, azimuth, elevation,
    /// distance)` — the same 4-tuple [`Flythrough`](crate::flythrough::Flythrough)
    /// produces, so the caller writes it onto an [`OrbitCamera`] identically. A pure
    /// function of `t` (same clock ⇒ same pose, FC-7).
    #[must_use]
    pub fn pose(&self, t: f64) -> (V3, f32, f32, f32) {
        let tl = t.rem_euclid(self.total_secs().max(1e-3));
        if tl < self.circle_secs {
            self.circle_pose(tl)
        } else if tl < self.circle_secs + self.descend_secs {
            // Smooth swoop: blend the END of the circle into the START of the drive.
            let p = smoothstep(((tl - self.circle_secs) / self.descend_secs.max(1e-3)) as f32);
            let a = self.circle_pose(self.circle_secs);
            let b = self.drive_pose(0.0);
            blend_pose(a, b, p)
        } else {
            self.drive_pose(tl - self.circle_secs - self.descend_secs)
        }
    }

    /// Air-circle pose at local time `lt` (seconds into the circle phase).
    fn circle_pose(&self, lt: f64) -> (V3, f32, f32, f32) {
        let f = &self.framing;
        let azimuth = f.base_azimuth + self.orbit_rate * lt as f32;
        let target = V3::new(f.center.x, f.center.y + f.roof * CIRCLE_TARGET_ROOF_FRAC, f.center.z);
        // The air circle frames the whole town from a BIRD height — the framed base
        // distance pulled out a touch, but ALWAYS floored well above the street chase
        // distance so the descent is a real bird→street drop for any scene scale (a
        // tiny densest-cluster framing can otherwise sit closer than the chase cam).
        let distance = (f.base_distance * CIRCLE_DIST_MUL).max(self.chase_dist() * 8.0);
        (target, azimuth, CIRCLE_ELEV, distance)
    }

    /// Street-drive pose at local time `lt` (seconds into the drive phase).
    fn drive_pose(&self, lt: f64) -> (V3, f32, f32, f32) {
        // Car-nav chase feel — the turn-by-turn tilt (reuse, L5) at a real street
        // distance so houses loom at street level whatever the scene scale.
        let chase_elev = (90.0 - OrbitCamera::CHASE_TILT_DEG).to_radians();
        let chase_dist = self.chase_dist();
        match &self.route {
            Some(r) => {
                // Traverse the route 0→1 across the drive phase (constant arc pace).
                let prog = (lt / self.drive_secs.max(1e-3)).clamp(0.0, 1.0) as f32;
                let p = r.point_at(prog);
                let brg = r.bearing_deg_at(prog);
                // **Face FORWARD down the street** (the reported "it drives backwards"
                // fix). The orbit eye sits at `target + dist·dir(azimuth)`, so setting
                // `azimuth = brg` would place the eye AHEAD of the target down-route and
                // look BACK up the street — a reversing chase. The chase camera must sit
                // BEHIND the car (up-route) and look forward, so the eye azimuth is the
                // travel bearing turned 180°: `eye = target − dist·dir(brg)` ⇒ the eye is
                // behind, the view runs down the road ahead with the houses flanking it.
                let eye_az = (brg + 180.0).rem_euclid(360.0);
                (V3::new(p[0], p[1], p[2]), eye_az.to_radians(), chase_elev, chase_dist)
            }
            None => {
                // No street route: a slow ground-level creep around the town centre so
                // the drive phase still reads as "down among the buildings".
                let f = &self.framing;
                let azimuth = f.base_azimuth + self.orbit_rate * 0.5 * lt as f32;
                let target = V3::new(f.center.x, f.center.y, f.center.z);
                (target, azimuth, chase_elev, chase_dist.max(f.extent * 0.6))
            }
        }
    }

    /// Apply the pose at clock `t` onto `cam` (both live values AND their damping
    /// goals, so nothing eases *away* from the flown pose). The caller settles/clamps.
    pub fn drive(&self, cam: &mut OrbitCamera, t: f64) {
        let (target, azimuth, elevation, distance) = self.pose(t);
        cam.target = target;
        cam.goal_target = target;
        cam.azimuth = azimuth;
        cam.goal_azimuth = azimuth;
        cam.elevation = elevation;
        cam.goal_elevation = elevation;
        cam.distance = distance;
        cam.goal_distance = distance;
    }
}

/// Smoothstep (`3t² − 2t³`) on `[0,1]` — the ease-in-out that makes the descent swoop
/// read as a fluid camera move, not a linear ramp.
#[inline]
fn smoothstep(t: f32) -> f32 {
    let t = t.clamp(0.0, 1.0);
    t * t * (3.0 - 2.0 * t)
}

/// Linear blend of two `(target, azimuth, elevation, distance)` poses by `p ∈ [0,1]`.
fn blend_pose(
    a: (V3, f32, f32, f32),
    b: (V3, f32, f32, f32),
    p: f32,
) -> (V3, f32, f32, f32) {
    let lerp = |x: f32, y: f32| x + (y - x) * p;
    let target = V3::new(lerp(a.0.x, b.0.x), lerp(a.0.y, b.0.y), lerp(a.0.z, b.0.z));
    (target, lerp(a.1, b.1), lerp(a.2, b.2), lerp(a.3, b.3))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn framing() -> CityFraming {
        CityFraming { center: V3::new(0.1, 0.15, -0.1), extent: 0.8, roof: 0.3, base_distance: 2.0, base_azimuth: 0.4 }
    }

    /// An L-shaped street route in the design plane (reuses the real `Route` math).
    fn route() -> Route {
        Route::from_design_points(vec![
            [-0.5, crate::extrude::ROUTE_LIFT, 0.4],
            [0.3, crate::extrude::ROUTE_LIFT, 0.4],
            [0.3, crate::extrude::ROUTE_LIFT, -0.5],
        ])
    }

    /// **INJECT-ASSERT (the three phases sequence in order):** the clock lands in
    /// Circle → Descend → Drive across the loop, and the loop repeats.
    #[test]
    fn phases_sequence_and_loop() {
        let tour = SigtunaTour::new(framing(), Some(route()));
        assert_eq!(tour.phase(0.0), TourPhase::Circle);
        assert_eq!(tour.phase(tour.circle_secs - 0.1), TourPhase::Circle);
        assert_eq!(tour.phase(tour.circle_secs + 0.1), TourPhase::Descend);
        assert_eq!(tour.phase(tour.circle_secs + tour.descend_secs + 0.1), TourPhase::Drive);
        // Loops: one full period later we're back in the circle.
        assert_eq!(tour.phase(tour.total_secs() + 0.05), TourPhase::Circle);
    }

    /// **INJECT-ASSERT (the tour DESCENDS — bird → street):** the air-circle pose sits
    /// FAR out and looks steeply down; the street-drive pose is pulled CLOSE in and
    /// levels toward the road. So distance and the top-down tilt both drop from the
    /// circle to the drive — the whole point of the reveal. RED if the phases stop
    /// differing (e.g. a wiring bug leaves the tour stuck in the air).
    #[test]
    fn tour_descends_from_air_to_street() {
        let tour = SigtunaTour::new(framing(), Some(route()));
        let (_at, _aaz, a_elev, a_dist) = tour.pose(1.0); // mid-circle
        let drive_t = tour.circle_secs + tour.descend_secs + 1.0;
        let (_dt, _daz, d_elev, d_dist) = tour.pose(drive_t); // early drive
        assert!(a_dist > d_dist + 0.5, "the tour pulls CLOSE in for the drive ({a_dist} → {d_dist})");
        // Air circle elevation is the bird tilt; the drive chase is a much lower,
        // forward-leaning car-nav pitch → smaller elevation (closer to the horizon).
        assert!(a_elev > d_elev, "the drive levels toward the road (elev {a_elev} → {d_elev})");
        assert!(a_dist >= tour.framing.base_distance, "air circle frames the whole town");
    }

    /// **INJECT-ASSERT (the air phase actually ORBITS):** azimuth advances across the
    /// circle phase, so the camera sweeps around the skyline (not a static shot).
    #[test]
    fn air_circle_orbits_the_skyline() {
        let tour = SigtunaTour::new(framing(), Some(route()));
        let az0 = tour.pose(0.0).1;
        let az1 = tour.pose(tour.circle_secs * 0.8).1;
        assert!(az1 > az0 + 0.1, "azimuth orbits forward in the air phase ({az0} → {az1})");
    }

    /// **INJECT-ASSERT (the drive rides the street route, FACING FORWARD):** across the
    /// drive phase the look-at target walks along the projected route (matches
    /// `route.point_at`), and the eye sits BEHIND the car looking DOWN the road ahead —
    /// i.e. it drives forward down the street, it does not hover and it does not reverse.
    /// Also deterministic: same clock ⇒ identical pose. RED if the drive stops tracking
    /// the route, OR if it regresses to the eye-ahead "looks backwards" wiring.
    #[test]
    fn drive_follows_the_street_route() {
        let tour = SigtunaTour::new(framing(), Some(route()));
        let r = route();
        let base = tour.circle_secs + tour.descend_secs;
        // Sample strictly WITHIN the drive phase — `frac == 1.0` lands exactly on the
        // loop boundary (`t == total_secs`), which `rem_euclid` wraps back to the
        // circle (the tour loops), so the route end is reached just before the wrap.
        for &frac in &[0.0f32, 0.25, 0.5, 0.75, 0.95] {
            let lt = frac as f64 * tour.drive_secs;
            let (tgt, az, elev, dist) = tour.pose(base + lt);
            let want = r.point_at(frac);
            let off = ((tgt.x - want[0]).powi(2) + (tgt.z - want[2]).powi(2)).sqrt();
            assert!(off < 1e-4, "drive target rides the route at {frac} (off {off})");
            // The EYE azimuth is the travel bearing turned 180° (eye behind the car).
            let want_brg = r.bearing_deg_at(frac);
            let want_eye_az = (want_brg + 180.0).rem_euclid(360.0).to_radians();
            assert!((az - want_eye_az).abs() < 1e-4, "eye sits behind the car at {frac} (az {az} vs {want_eye_az})");
            // And the resulting camera actually LOOKS FORWARD down the street: build the
            // pose on a real OrbitCamera and check the view forward (target − eye) points
            // along the travel bearing (compass atan2(dx, dz)), NOT 180° away.
            let mut cam = OrbitCamera::default();
            cam.target = V3::new(tgt.x, tgt.y, tgt.z);
            cam.goal_target = cam.target;
            cam.azimuth = az;
            cam.goal_azimuth = az;
            cam.elevation = elev;
            cam.goal_elevation = elev;
            cam.distance = dist;
            cam.goal_distance = dist;
            cam.settle();
            let fwd = cam.target.sub(cam.eye());
            let look_brg = fwd.x.atan2(fwd.z).to_degrees().rem_euclid(360.0);
            let dbrg = (look_brg - want_brg).rem_euclid(360.0);
            let dbrg = dbrg.min(360.0 - dbrg); // angular distance
            assert!(dbrg < 1.0, "camera looks FORWARD down the street at {frac} (look {look_brg} vs travel {want_brg})");
        }
        // Determinism (FC-7).
        assert_eq!(tour.pose(base + 3.0).1, tour.pose(base + 3.0).1);
    }

    /// **INJECT-ASSERT (driving the real camera stays legal):** stepping a clock sweep
    /// onto an [`OrbitCamera`] + settling never sinks the eye through the ground and
    /// keeps every pose finite — the tour is safe to run live.
    #[test]
    fn driving_the_camera_stays_above_ground_and_finite() {
        let tour = SigtunaTour::new(framing(), Some(route()));
        let mut cam = OrbitCamera::default();
        let steps = 200;
        for i in 0..steps {
            let t = i as f64 * tour.total_secs() / steps as f64;
            tour.drive(&mut cam, t);
            cam.settle();
            let e = cam.eye();
            assert!(e.x.is_finite() && e.y.is_finite() && e.z.is_finite(), "finite eye at t={t}");
            assert!(
                e.y >= cam.surface_height_at(e.x, e.z) - 1e-4,
                "eye stays above ground at t={t} (eye.y {})",
                e.y
            );
        }
    }

    /// **INJECT-ASSERT (graceful no-route creep):** with no street route the drive
    /// phase still produces a low, close, finite ground-level pose (never panics, never
    /// flies away) — the honest fallback for data with no highways.
    #[test]
    fn no_route_falls_back_to_a_ground_creep() {
        let tour = SigtunaTour::new(framing(), None);
        assert!(tour.route.is_none(), "empty/absent route → creep fallback");
        let base = tour.circle_secs + tour.descend_secs;
        let (tgt, az, elev, dist) = tour.pose(base + 5.0);
        assert!(tgt.x.is_finite() && az.is_finite() && elev.is_finite() && dist.is_finite());
        assert!(dist > 0.0, "creep keeps a positive dolly");
    }

    /// **INJECT-ASSERT (the drive is a REAL street distance when the scale is known):**
    /// on a metric scene the drive chase must be `STREET_CHASE_M` metres from the road,
    /// NOT the design-unit constant (which is hundreds of metres out on a big clip →
    /// houses invisible). We give the tour a scale and check the drive-phase distance in
    /// METRES is street-level, and that a WIDER scene (more metres/unit) still drives at
    /// the same real metre distance (the chase shrinks in design units to compensate).
    /// RED if the drive stops reading the scene scale (regresses to design-unit chase).
    #[test]
    fn metric_scale_puts_the_drive_at_street_level() {
        let base = |mpu: f32| {
            let t = SigtunaTour::new(framing(), Some(route())).with_scale(mpu);
            let drive_dist = t.pose(t.circle_secs + t.descend_secs + 1.0).3;
            drive_dist * mpu // → metres from the road
        };
        // 1875 is korp's own scale (the 3 km densest-cluster window); 29855 a region
        // load. Two decades of span, so a floor cannot pass itself off as a metre.
        for &mpu in &[200.0f32, 500.0, 972.0, 1875.0, 3000.0, 29_855.0] {
            let street_m = base(mpu);
            println!("  mpu {mpu:>7.0}  drive sits {street_m:>8.1} m from the road (want {STREET_CHASE_M})");
            // Street level means STREET_CHASE_M metres. The bound used to read
            // `STREET_CHASE_M.max(OrbitCamera::MIN_DISTANCE * mpu)`, i.e. it granted
            // the floor as an exemption from the very rule it was checking — at its
            // own mpu = 3000 that let 180 m pass a 30 m assertion. A guard that writes
            // the exception into its own condition can never see the defect.
            assert!(
                street_m <= STREET_CHASE_M + 1.0,
                "drive sits at street level for mpu={mpu} ({street_m:.0} m from the road, \
                 want {STREET_CHASE_M} m)"
            );
        }
        // Scale-free (mpu = 0) keeps the design-unit fallback (unchanged behaviour).
        let free = SigtunaTour::new(framing(), Some(route()));
        assert_eq!(free.pose(free.circle_secs + free.descend_secs + 1.0).3, OrbitCamera::CHASE_DIST);
    }
}