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