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