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/flythrough.rs

//! **Cinematic flythrough** — the auto-pilot camera path that turns the static,
//! densest-cluster-framed 3D city into a MOVING flight-sim you just WATCH: a smooth,
//! deterministic camera that continuously orbits the skyline, cruises in toward
//! street level and pulls back out to a dramatic oblique, and gently tracks across
//! the town — all as a pure function of an injected clock (FC-7), so a headless test
//! flies a bit-reproducible path and a snapshot at a fixed clock is exact.
//!
//! This rides ON TOP of the existing [`OrbitCamera`](crate::OrbitCamera) + GPU
//! depth-tested render (lit prisms, SSAO, soft shadows, reflective water, HDR sky +
//! bloom): the flythrough only *derives a moving pose* each frame, so every "cooler
//! graphics" pass the renderer already has is what you watch the flight through. It
//! is the missing "the camera actually FLIES the city on its own" piece — the
//! [`Map3D`](crate::Map3D) `spin` auto-orbit only sweeps azimuth; a flythrough also
//! breathes the dolly + elevation + target so it reads as flight, not a turntable.
//!
//! ## The path (deterministic in the clock `t`, seconds)
//! Given the framed city ([`CityFraming`] — target centroid, horizontal extent, roof
//! height, and the framed base distance/azimuth):
//! - **orbit**: `azimuth = az0 + ORBIT_RATE * t` — a slow continuous swing around the
//!   skyline.
//! - **dolly breathing**: `distance = base * (1 + DOLLY_AMP * sin(2π t / DOLLY_PERIOD))`
//!   — cruise in toward the streets, then pull back out to frame the whole town.
//! - **elevation**: `elev = EL_MID + EL_AMP * sin(2π t / EL_PERIOD)` — dip to a low,
//!   dramatic oblique then lift toward a high 3/4, out of phase with the dolly so the
//!   two never peak together (more cinematic, less mechanical).
//! - **target tracking**: a small Lissajous of the look-at point within the cluster
//!   extent so the flight glides across different quarters of the city without ever
//!   letting it leave frame.
//!
//! All outputs are clamped to the camera's legal ranges by the caller (the
//! [`OrbitCamera`](crate::OrbitCamera) elevation/ground/dolly clamps), so the
//! flythrough can never sink through the ground or gimbal-flip.

use crate::camera::{OrbitCamera, V3};

/// The framed city the flythrough flies around — captured by
/// `Map3D::frame_densest_cluster` so the auto-pilot knows the town's centre, size,
/// and a sensible base framing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CityFraming {
    /// Look-at centroid of the densest building cluster (design units).
    pub center: V3,
    /// Horizontal extent (max of the cluster's x/z span, design units) — the orbit
    /// radius scale + the target-tracking amplitude.
    pub extent: f32,
    /// Tallest roof in the cluster (design units) — keeps the look-at near skyline
    /// height and bounds how low the dolly should dip.
    pub roof: f32,
    /// The base orbit distance the static framing chose (design units).
    pub base_distance: f32,
    /// The base azimuth the static framing chose (radians) — the orbit starts here.
    pub base_azimuth: f32,
}

impl CityFraming {
    /// A framing centred at the origin with a unit extent — the graceful fallback
    /// when nothing rose (all-flat data): the flythrough still orbits the box.
    #[must_use]
    pub fn unit() -> Self {
        Self { center: V3::new(0.0, 0.1, 0.0), extent: 1.0, roof: 0.2, base_distance: 3.2, base_azimuth: 0.7 }
    }
}

/// The cinematic auto-pilot. Cheap, `Copy`, deterministic — hold one on the map and
/// ask [`Self::pose`] for the camera at any clock.
#[derive(Clone, Copy, Debug)]
pub struct Flythrough {
    /// The city being flown.
    pub framing: CityFraming,
    /// Continuous orbit rate (radians/second). Slow — a stately sweep, not a spin.
    pub orbit_rate: f32,
    /// Dolly breathing amplitude (fraction of the base distance).
    pub dolly_amp: f32,
    /// Dolly breathing period (seconds).
    pub dolly_period: f32,
    /// Elevation sweep midpoint (radians).
    pub elev_mid: f32,
    /// Elevation sweep amplitude (radians).
    pub elev_amp: f32,
    /// Elevation sweep period (seconds).
    pub elev_period: f32,
}

impl Flythrough {
    /// Default cinematic tuning around a framed city.
    #[must_use]
    pub fn new(framing: CityFraming) -> Self {
        Self {
            framing,
            // ~0.12 rad/s => a full loop of the skyline in ~52 s.
            orbit_rate: 0.12,
            // Cruise from ~0.68x (in among the streets) to ~1.32x (whole-town) the
            // framed distance.
            dolly_amp: 0.32,
            dolly_period: 21.0,
            // Sweep from a low, dramatic ~14deg oblique up to a ~46deg 3/4 skyline.
            elev_mid: 0.52,
            elev_amp: 0.28,
            elev_period: 17.0,
        }
    }

    /// The **cinematic pose** at clock `t` (seconds): `(target, azimuth, elevation,
    /// distance)`. A pure function of `t` — same clock => same pose (FC-7). The caller
    /// writes these onto an [`OrbitCamera`](crate::OrbitCamera) (whose clamps keep the
    /// eye above ground and shy of the poles).
    #[must_use]
    pub fn pose(&self, t: f64) -> (V3, f32, f32, f32) {
        use std::f32::consts::TAU;
        let t = t as f32;
        let f = &self.framing;

        // Continuous orbit around the skyline.
        let azimuth = f.base_azimuth + self.orbit_rate * t;

        // Dolly breathing — cruise in to the streets, pull back to the whole town.
        let dolly = 1.0 + self.dolly_amp * (TAU * t / self.dolly_period).sin();
        let distance = (f.base_distance * dolly).max(0.05);

        // Elevation sweep, phase-offset from the dolly so the peaks never coincide.
        let elevation = self.elev_mid + self.elev_amp * (TAU * t / self.elev_period + 1.7).sin();

        // Gentle target tracking: a small Lissajous within the cluster so the flight
        // glides across the town, never letting it drift out of frame. Amplitude is a
        // modest fraction of the extent.
        let amp = f.extent * 0.28;
        let tx = f.center.x + amp * (TAU * t / 29.0).sin();
        let tz = f.center.z + amp * (TAU * t / 37.0 + 0.9).cos();
        // Keep the look-at near skyline height (a hair of vertical bob for life).
        let ty = f.center.y + f.roof * 0.08 * (TAU * t / 13.0).sin();

        (V3::new(tx, ty, tz), azimuth, elevation, distance)
    }

    /// Apply the pose at clock `t` onto `cam` (both the 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;
    }
}

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

    fn framing() -> CityFraming {
        CityFraming { center: V3::new(0.2, 0.15, -0.1), extent: 0.6, roof: 0.3, base_distance: 1.4, base_azimuth: 0.5 }
    }

    /// **INJECT-ASSERT (the flythrough MOVES — it is flight, not a still):** over a
    /// clock sweep the eye position visibly changes (orbit + dolly + elevation
    /// together), and the same clock always gives the same pose (FC-7 determinism).
    #[test]
    fn flythrough_moves_the_camera_and_is_deterministic() {
        let fly = Flythrough::new(framing());
        let mut cam = OrbitCamera::default();

        fly.drive(&mut cam, 0.0);
        cam.settle();
        let eye0 = cam.eye();

        fly.drive(&mut cam, 4.0);
        cam.settle();
        let eye1 = cam.eye();

        // A real flight: the eye moved a meaningful distance in 4 s.
        let moved = eye1.sub(eye0).len();
        assert!(moved > 0.05, "the flythrough flies the camera (moved {moved} in 4s)");

        // Determinism: the same clock replays the identical pose.
        let a = fly.pose(4.0);
        let b = fly.pose(4.0);
        assert_eq!((a.1, a.2, a.3), (b.1, b.2, b.3), "pose is pure in the clock");
        // And azimuth genuinely advances (continuous orbit).
        assert!(fly.pose(10.0).1 > fly.pose(0.0).1, "azimuth orbits forward over time");
    }

    /// **INJECT-ASSERT (the city stays framed — the flight never loses the town):**
    /// across a long clock sweep the look-at target stays within the cluster extent of
    /// its centre, the distance stays within the breathing band, and every pose is
    /// finite. So you always watch the city, never empty sky or the ground.
    #[test]
    fn flythrough_keeps_the_city_framed() {
        let f = framing();
        let fly = Flythrough::new(f);
        let mut cam = OrbitCamera::default();
        for i in 0..400 {
            let t = i as f64 * 0.25; // 100 s at 4 Hz
            let (target, az, el, dist) = fly.pose(t);
            assert!(target.x.is_finite() && target.y.is_finite() && target.z.is_finite(), "finite target");
            assert!(az.is_finite() && el.is_finite() && dist.is_finite(), "finite pose");
            // Target stays near the town centroid (within ~half the extent + margin).
            let off = (target.sub(f.center)).len();
            assert!(off <= f.extent * 0.5 + 1e-3, "look-at stays over the city (off {off} <= {})", f.extent * 0.5);
            // Distance breathes within the band, never collapses or flees.
            let lo = f.base_distance * (1.0 - fly.dolly_amp) - 1e-3;
            let hi = f.base_distance * (1.0 + fly.dolly_amp) + 1e-3;
            assert!((lo..=hi).contains(&dist), "dolly stays in the cruise band ({lo}..{hi}), got {dist}");

            // Driving the real camera keeps the eye above the ground (the clamp holds).
            fly.drive(&mut cam, t);
            cam.settle();
            assert!(cam.eye().y >= cam.surface_height_at(cam.eye().x, cam.eye().z), "eye stays above ground");
        }
    }
}