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

nordisk/facett

facett-map3d-flight/src/cinematic.rs

//! **Cinematic autopilot** — the hands-off FLIGHT-SIM path: a deterministic camera
//! that FLIES the clipped OSM city on its own so you just WATCH it, no keyboard. It is
//! the flight-layer twin of `facett_map3d`'s `Flythrough` (which flies the small
//! embeddable orbit map); this one drives the 6DOF [`FlightCamera`] over the real DEM +
//! draped city, in the `f64` **z-up world frame** (`x = easting`, `y = northing`,
//! `z = altitude`) that [`WorldPos`] defines.
//!
//! The path: a slow ORBIT around the town centre at a breathing radius + altitude,
//! always looking back at the centre — a helicopter-style establishing shot of the
//! skyline. Pure math in an injected clock `t` (seconds), so a headless test flies a
//! bit-reproducible path (FC-7) and the live demo just feeds it the frame time.
//!
//! It hands control back the instant a human touches the stick: the demo runs the
//! autopilot only while there is no manual input, so "watch it fly" and "grab the
//! controls" are the same view.

use crate::camera_fly::{FlightCamera, FlyMode};
use crate::origin::WorldPos;

/// A cinematic auto-pilot orbiting a city centre. Cheap, `Copy`, deterministic.
///
/// Frame convention is the **`WorldPos` world frame** (`x = easting`,
/// `y = northing`, `z = altitude`) — the frame `WorldPos::from_lonlat` /
/// `to_lonlat` / `ground_distance` all speak, and the one the [`FlightCamera`]
/// rig integrates in.
///
/// FIXED (was a real bug): this module used to declare and use the **render**
/// frame layout (`y = altitude`, `z = northing`) inside a `WorldPos`, directly
/// contradicting `WorldPos`'s own definition. Two conflicting readings of the
/// same struct lived in one crate: an autopilot "altitude" was silently a
/// Mercator northing, so `to_lonlat()` on a cinematic pose reported a wildly
/// wrong latitude and the floating-origin rebase snapped on the wrong axis. The
/// world frame (origin.rs) is the authority; this module now follows it, and
/// `camera_fly::forward()` was corrected to match at the same time. The
/// world→render swap stays where it belongs — in `terrain.rs`, at the render
/// boundary (`[r[0], r[2], r[1]]`).
#[derive(Clone, Copy, Debug)]
pub struct CinematicFlight {
    /// The town centre the camera orbits + looks at (Y-up world frame).
    pub center: WorldPos,
    /// Base orbit radius (metres).
    pub radius: f64,
    /// Base camera altitude above the centre (metres).
    pub altitude: f64,
    /// Orbit rate (radians/second). Slow — a stately establishing swing.
    pub orbit_rate: f64,
    /// Radius breathing amplitude (fraction of `radius`) — cruise in and out.
    pub radius_amp: f64,
    /// Radius breathing period (seconds).
    pub radius_period: f64,
    /// Altitude breathing amplitude (fraction of `altitude`).
    pub alt_amp: f64,
    /// Altitude breathing period (seconds).
    pub alt_period: f64,
}

impl CinematicFlight {
    /// A cinematic orbit over `center` (Y-up) at a base `radius` (metres). Altitude and
    /// breathing are derived from the radius so one number frames any town.
    #[must_use]
    pub fn over(center: WorldPos, radius: f64) -> Self {
        let radius = radius.max(50.0);
        Self {
            center,
            radius,
            altitude: radius * 0.55,
            // ~0.10 rad/s => a full loop in ~63 s.
            orbit_rate: 0.10,
            radius_amp: 0.28,
            radius_period: 23.0,
            alt_amp: 0.35,
            alt_period: 19.0,
        }
    }

    /// The **cinematic pose** at clock `t` (seconds): `(position, yaw, pitch)` — the
    /// eye on the breathing orbit, oriented to look back at the town centre. Pure in
    /// `t` (FC-7).
    #[must_use]
    pub fn pose(&self, t: f64) -> (WorldPos, f64, f64) {
        use std::f64::consts::TAU;
        let ang = self.orbit_rate * t;
        let r = self.radius * (1.0 + self.radius_amp * (TAU * t / self.radius_period).sin());
        let alt = self.altitude * (1.0 + self.alt_amp * (TAU * t / self.alt_period + 1.3).sin());

        // Eye on the orbit circle in the horizontal (easting=x, northing=y) plane,
        // lifted along the altitude axis (z).
        let position = WorldPos::new(
            self.center.x + r * ang.cos(),
            self.center.y + r * ang.sin(),
            self.center.z + alt,
        );

        // Look back at the centre: forward = normalize(center - position).
        let dx = self.center.x - position.x;
        let dy = self.center.y - position.y;
        let dz = self.center.z - position.z;
        let horiz = (dx * dx + dy * dy).sqrt().max(1e-9);
        // yaw is the ground-plane heading (+X toward +Y, matching
        // `FlightCamera::forward`); pitch lifts toward +Z. Looking down at the town
        // => negative pitch.
        let yaw = dy.atan2(dx);
        let pitch = dz.atan2(horiz);
        (position, yaw, pitch)
    }

    /// Drive `cam` to the cinematic pose at clock `t` (free-fly, level). The camera
    /// ends looking at the town centre from the breathing orbit.
    pub fn drive(&self, cam: &mut FlightCamera, t: f64) {
        let (position, yaw, pitch) = self.pose(t);
        if cam.mode != FlyMode::FreeFly {
            cam.set_mode(FlyMode::FreeFly);
        }
        cam.position = position;
        cam.yaw = yaw;
        cam.pitch = pitch;
        cam.roll = 0.0;
    }
}

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

    fn flight() -> CinematicFlight {
        CinematicFlight::over(WorldPos::new(1_000_000.0, 6_000_000.0, 0.0), 1_200.0)
    }

    /// **INJECT-ASSERT (the autopilot flies AND keeps looking at the town):** over a
    /// clock sweep the eye orbits (moves each step, stays near the breathing radius),
    /// the camera's forward always points at the centre, and the pose is finite +
    /// deterministic (FC-7).
    #[test]
    fn autopilot_orbits_and_frames_the_town() {
        let cf = flight();
        let mut cam = FlightCamera::free_fly(WorldPos::new(0.0, 0.0, 0.0));
        let mut prev: Option<WorldPos> = None;
        for i in 0..300 {
            let t = i as f64 * 0.3; // 90 s at ~3 Hz
            cf.drive(&mut cam, t);
            let p = cam.position;
            assert!(p.x.is_finite() && p.y.is_finite() && p.z.is_finite(), "finite pose");

            // Eye sits on the breathing orbit around the centre (horizontal distance
            // stays within the radius band; altitude stays positive above the town).
            let dx = p.x - cf.center.x;
            let dy = p.y - cf.center.y;
            let horiz = (dx * dx + dy * dy).sqrt();
            let lo = cf.radius * (1.0 - cf.radius_amp) - 1.0;
            let hi = cf.radius * (1.0 + cf.radius_amp) + 1.0;
            assert!((lo..=hi).contains(&horiz), "eye stays on the orbit band ({lo:.0}..{hi:.0}), got {horiz:.0}");
            assert!(p.z > cf.center.z, "camera stays above the town (z is the altitude axis)");

            // Forward points at the centre: the off-axis angle is tiny (it is genuinely
            // looking AT the town).
            let f = cam.forward();
            let to_c = [cf.center.x - p.x, cf.center.y - p.y, cf.center.z - p.z];
            let n = (to_c[0] * to_c[0] + to_c[1] * to_c[1] + to_c[2] * to_c[2]).sqrt().max(1e-9);
            let dot = (f[0] * to_c[0] + f[1] * to_c[1] + f[2] * to_c[2]) / n;
            assert!(dot > 0.999, "the autopilot looks straight at the town (cos={dot:.4})");

            // It actually moves between steps (it is flight, not a still).
            if let Some(q) = prev {
                let moved = ((p.x - q.x).powi(2) + (p.y - q.y).powi(2) + (p.z - q.z).powi(2)).sqrt();
                assert!(moved > 0.5, "the autopilot flies between frames (moved {moved:.2} m)");
            }
            prev = Some(p);
        }

        // Determinism: replay the same clock => identical pose.
        let a = flight().pose(12.5);
        let b = flight().pose(12.5);
        assert_eq!((a.1, a.2), (b.1, b.2), "pose is pure in the clock");
        assert_eq!((a.0.x, a.0.y, a.0.z), (b.0.x, b.0.y, b.0.z));
    }
}