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

nordisk/facett

facett-graph3d/src/travels.rs

//! **TRAVELS — the guided constellation tour** (the owner's "list of interesting
//! travels"). A **glowing, ghostlike spline** drawn in 3-D space that takes a camera +
//! highlight on a TOUR through an existing graph: a [`TravelRoute`] names an ordered
//! set of node ids ("latest changes", "reported bugs — start at the top, travel down
//! into the bug", "spotlight this one node"), and a [`Travel`] flies a **glowing ghost
//! marker + the camera** along a smooth Catmull–Rom spline through those node centres,
//! easing between stops and pausing/pulsing at each visited node.
//!
//! ## A reusable PRIMITIVE (host-drives-data contract)
//! This crate renders the tour; it does **not** fetch data. The HOST (dwarves'
//! navigator page, korp) supplies the ordered node sets from nornir's warehouse
//! (recency / bug anchors / a single node) via the [`TravelRoute`] constructors
//! ([`TravelRoute::latest_changes`], [`TravelRoute::bugs_top_down`],
//! [`TravelRoute::spotlight`]); [`Travel`] turns the route + the graph's laid-out node
//! centres into the animated ghost. It is a plain animator (no egui, no I/O) plus a
//! pure [`Travel::paint`] overlay, so it drops onto a [`Graph3D`](crate::Graph3D)
//! (default OFF — no travel until a route is set, so existing renders/goldens are
//! byte-stable) and is unit-testable headless.
//!
//! ## Determinism (the crate-wide LAW)
//! The tour timeline is an explicit `elapsed` in seconds advanced ONLY through
//! [`Travel::advance`] / [`Travel::seek`] (the [`Graph3D`](crate::Graph3D) `Tick` /
//! `SetClock` seam) — never `Instant::now()`. So a fixed clock ⇒ a byte-stable frame,
//! and the geometry/animation goldens are reproducible.

use egui::{Color32, Painter, Pos2, Stroke};
use serde::{Deserialize, Serialize};

use crate::neon;
use facett_core::render::decor;

/// A node's stable id in the toured [`Graph3D`](crate::Graph3D) — its node **index**
/// (invariant across camera spin and relayout, exactly like the selection key).
pub type NodeId = usize;

/// Which built-in KIND of tour a [`TravelRoute`] is — a small enum so the host / a
/// robot can assert "this is the recency tour" vs "the bug tour" from `state_json`.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteKind {
    /// A single-node spotlight ([`TravelRoute::spotlight`]).
    Spotlight,
    /// The most-recently-changed units, newest → oldest ([`TravelRoute::latest_changes`]).
    LatestChanges,
    /// Reported bugs, top of the graph down into the bug ([`TravelRoute::bugs_top_down`]).
    BugsTopDown,
    /// A host-authored custom route ([`TravelRoute::new`]).
    Custom,
}

impl RouteKind {
    pub fn label(self) -> &'static str {
        match self {
            RouteKind::Spotlight => "spotlight",
            RouteKind::LatestChanges => "latest_changes",
            RouteKind::BugsTopDown => "bugs_top_down",
            RouteKind::Custom => "custom",
        }
    }
}

/// **A tour route** — a `name`, its [`RouteKind`], and the ordered `stops` (node ids)
/// the camera visits in order. Pure data the host builds from warehouse metadata; the
/// built-in constructors ([`latest_changes`](Self::latest_changes) /
/// [`bugs_top_down`](Self::bugs_top_down) / [`spotlight`](Self::spotlight)) are the
/// "list of interesting travels".
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TravelRoute {
    pub name: String,
    pub kind: RouteKind,
    pub stops: Vec<NodeId>,
}

impl TravelRoute {
    /// A host-authored custom route over an explicit ordered stop list.
    pub fn new(name: impl Into<String>, stops: Vec<NodeId>) -> Self {
        Self { name: name.into(), kind: RouteKind::Custom, stops }
    }

    /// **Spotlight** — a single-node tour: the camera flies to `node` and pulses there.
    /// (the owner's single-node spotlight.)
    pub fn spotlight(node: NodeId) -> Self {
        Self { name: "spotlight".into(), kind: RouteKind::Spotlight, stops: vec![node] }
    }

    /// **Latest changes** — visit the recently-changed units in the order the host
    /// supplies them (newest first). The HOST passes the node set (from nornir's
    /// warehouse recency); facett just renders the tour.
    pub fn latest_changes(nodes_by_recency: &[NodeId]) -> Self {
        Self { name: "latest changes".into(), kind: RouteKind::LatestChanges, stops: nodes_by_recency.to_vec() }
    }

    /// **Bugs, top-down** — "start at the top, travel down into the bug": visit the
    /// reported-bug anchors in the host-supplied top→down order. The HOST passes the
    /// bug anchors (from nornir's warehouse); facett renders the descent.
    pub fn bugs_top_down(bug_anchors: &[NodeId]) -> Self {
        Self { name: "bugs top-down".into(), kind: RouteKind::BugsTopDown, stops: bug_anchors.to_vec() }
    }

    /// Whether this route has any stops to tour.
    pub fn is_empty(&self) -> bool {
        self.stops.is_empty()
    }
}

/// **A uniform Catmull–Rom spline** through control points — the smooth path the ghost
/// follows. Catmull–Rom **passes through every control point**, so the spline visits
/// each stop's node centre exactly (`point(seg, 0.0) == pts[seg]`,
/// `point(seg, 1.0) == pts[seg+1]`) — the geometry proof the tests assert.
#[derive(Clone, Debug)]
pub struct Spline {
    pts: Vec<[f32; 3]>,
}

impl Spline {
    pub fn new(pts: Vec<[f32; 3]>) -> Self {
        Self { pts }
    }

    /// The number of segments (`pts.len() - 1`, saturating at 0).
    pub fn segments(&self) -> usize {
        self.pts.len().saturating_sub(1)
    }

    /// Evaluate segment `seg` (from `pts[seg]` to `pts[seg+1]`) at local `u ∈ [0,1]`.
    /// Endpoints are clamped (the ends repeat their neighbour), so the curve stays put
    /// at the first/last control point.
    pub fn point(&self, seg: usize, u: f32) -> [f32; 3] {
        let n = self.pts.len();
        if n == 0 {
            return [0.0; 3];
        }
        if n == 1 {
            return self.pts[0];
        }
        let seg = seg.min(n - 2);
        let i1 = seg;
        let i2 = seg + 1;
        let i0 = i1.saturating_sub(1);
        let i3 = (i2 + 1).min(n - 1);
        let (p0, p1, p2, p3) = (self.pts[i0], self.pts[i1], self.pts[i2], self.pts[i3]);
        let u = u.clamp(0.0, 1.0);
        let u2 = u * u;
        let u3 = u2 * u;
        let mut out = [0.0f32; 3];
        for c in 0..3 {
            // Uniform Catmull–Rom basis (passes through p1 at u=0, p2 at u=1).
            out[c] = 0.5
                * ((2.0 * p1[c])
                    + (-p0[c] + p2[c]) * u
                    + (2.0 * p0[c] - 5.0 * p1[c] + 4.0 * p2[c] - p3[c]) * u2
                    + (-p0[c] + 3.0 * p1[c] - 3.0 * p2[c] + p3[c]) * u3);
        }
        out
    }

    /// Sample the whole spline at `t ∈ [0,1]` (across all segments).
    pub fn sample(&self, t: f32) -> [f32; 3] {
        let segs = self.segments();
        if segs == 0 {
            return self.pts.first().copied().unwrap_or([0.0; 3]);
        }
        let t = t.clamp(0.0, 1.0) * segs as f32;
        let seg = (t.floor() as usize).min(segs - 1);
        self.point(seg, t - seg as f32)
    }
}

/// Smooth ease-in-out (Hermite `smoothstep`) so the ghost accelerates out of a stop
/// and decelerates into the next — a rest-to-rest glide, not a constant crawl.
#[inline]
fn ease(u: f32) -> f32 {
    let u = u.clamp(0.0, 1.0);
    u * u * (3.0 - 2.0 * u)
}

/// The ghost's pale, luminous body colour (a cyan-white spectre).
const GHOST: Color32 = Color32::from_rgb(185, 240, 255);

/// Where the ghost is on the timeline right now — the source `stop`, whether it is
/// **dwelling** at that stop (else travelling toward `stop + 1`), and its 3-D `pos`.
#[derive(Clone, Copy, Debug)]
struct Located {
    stop: usize,
    in_dwell: bool,
    pos: [f32; 3],
}

/// **The tour animator** — flies a glowing ghost marker + the camera along a
/// [`TravelRoute`]'s spline. Holds the route, the stop node centres (the spline
/// control points, supplied by the host from the graph layout), and the deterministic
/// `elapsed` timeline. Advanced through [`advance`](Self::advance) / [`seek`](Self::seek)
/// (the `Tick`/`SetClock` seam), controlled through play/pause/next/prev/speed.
#[derive(Clone, Debug)]
pub struct Travel {
    route: TravelRoute,
    /// One 3-D centre per stop (the spline control points).
    centers: Vec<[f32; 3]>,
    spline: Spline,
    /// Seconds along the tour timeline (deterministic; advanced only via the seam).
    elapsed: f64,
    playing: bool,
    /// Timeline speed multiplier (`advance(dt)` adds `dt * speed`).
    speed: f32,
    /// Travel time per segment (tour seconds).
    seg_secs: f32,
    /// Dwell (pause + pulse) at each stop (tour seconds).
    dwell_secs: f32,
    /// Whether the camera flies to face the ghost (the "camera fly" seam).
    camera_follow: bool,
    /// Highest stop index whose arrival event has fired (for edge-triggered events).
    last_fired: i64,
}

impl Travel {
    /// Build a tour for `route` over `graph_centers` — the toured graph's laid-out node
    /// positions (`graph.nodes[i].pos`). The host slices the stops' centres out for the
    /// spline; the tour starts **paused at the first stop** (the arrival of stop 0 is
    /// pending until the first advance, so a robot sees the visit sequence).
    pub fn new(route: TravelRoute, graph_centers: &[[f32; 3]]) -> Self {
        let centers: Vec<[f32; 3]> = route.stops.iter().map(|&i| graph_centers.get(i).copied().unwrap_or([0.0; 3])).collect();
        let spline = Spline::new(centers.clone());
        Self {
            route,
            centers,
            spline,
            elapsed: 0.0,
            playing: true,
            speed: 1.0,
            seg_secs: 1.4,
            dwell_secs: 0.6,
            camera_follow: true,
            last_fired: -1,
        }
    }

    /// Rebuild the spline control points from a fresh graph layout (after a relayout /
    /// absorb repositions nodes) without disturbing the timeline.
    pub fn refresh_centers(&mut self, graph_centers: &[[f32; 3]]) {
        self.centers = self.route.stops.iter().map(|&i| graph_centers.get(i).copied().unwrap_or([0.0; 3])).collect();
        self.spline = Spline::new(self.centers.clone());
    }

    /// Builder: start paused.
    pub fn paused(mut self) -> Self {
        self.playing = false;
        self
    }
    /// Builder: set the timeline speed multiplier.
    pub fn with_speed(mut self, speed: f32) -> Self {
        self.speed = speed.max(0.0);
        self
    }
    /// Builder: enable/disable the camera fly-to-ghost.
    pub fn with_camera_follow(mut self, follow: bool) -> Self {
        self.camera_follow = follow;
        self
    }

    // ── read model ─────────────────────────────────────────────────────────────
    pub fn route(&self) -> &TravelRoute {
        &self.route
    }
    /// A tour is **active** iff its route has stops (no travel until a route is set).
    pub fn is_active(&self) -> bool {
        !self.route.stops.is_empty()
    }
    pub fn is_playing(&self) -> bool {
        self.playing
    }
    pub fn speed(&self) -> f32 {
        self.speed
    }
    pub fn camera_follow(&self) -> bool {
        self.camera_follow
    }
    pub fn elapsed(&self) -> f64 {
        self.elapsed
    }
    /// The full tour duration in tour seconds (`m·dwell + (m-1)·seg`; a lone stop is
    /// just its dwell).
    pub fn total(&self) -> f64 {
        let m = self.route.stops.len();
        if m == 0 {
            0.0
        } else if m == 1 {
            self.dwell_secs as f64
        } else {
            m as f64 * self.dwell_secs as f64 + (m - 1) as f64 * self.seg_secs as f64
        }
    }
    /// Overall progress `∈ [0,1]` along the tour.
    pub fn progress(&self) -> f32 {
        let total = self.total();
        if total <= 0.0 {
            0.0
        } else {
            (self.elapsed / total).clamp(0.0, 1.0) as f32
        }
    }
    /// The index into `route.stops` of the current (source) stop.
    pub fn stop_index(&self) -> usize {
        self.locate(self.elapsed).stop
    }
    /// The current node id (`route.stops[stop_index]`), if the route has stops.
    pub fn current_node(&self) -> Option<NodeId> {
        self.route.stops.get(self.stop_index()).copied()
    }
    /// The ghost marker's current 3-D position (on the spline, eased).
    pub fn marker_pos(&self) -> [f32; 3] {
        self.locate(self.elapsed).pos
    }
    /// Whether the ghost is currently **dwelling** (paused + pulsing) at a stop, versus
    /// travelling between two stops along the spline.
    pub fn is_dwelling(&self) -> bool {
        self.locate(self.elapsed).in_dwell
    }
    /// The camera yaw (radians) that brings the ghost to the FRONT of the orbit — the
    /// "camera fly" target the host applies while `camera_follow` is on.
    pub fn camera_yaw(&self) -> f32 {
        let p = self.marker_pos();
        // Depth after the Y-rotation is maximised (nearest camera) at this yaw.
        p[0].atan2(p[2])
    }

    // ── timeline seam (deterministic) ────────────────────────────────────────────
    /// The wall-clock time (tour seconds) at which stop `i`'s dwell begins.
    fn arrival_time(&self, i: usize) -> f64 {
        i as f64 * (self.dwell_secs as f64 + self.seg_secs as f64)
    }

    /// Fire the arrival events for every stop reached at the current `elapsed` that has
    /// not fired yet (monotonic-forward, in stop order) — returns the newly-visited
    /// node ids. This is the stop-visited event.
    fn fire_arrivals(&mut self) -> Vec<NodeId> {
        let m = self.route.stops.len();
        let mut out = Vec::new();
        while (self.last_fired + 1) < m as i64 {
            let i = (self.last_fired + 1) as usize;
            if self.arrival_time(i) <= self.elapsed + 1e-9 {
                out.push(self.route.stops[i]);
                self.last_fired = i as i64;
            } else {
                break;
            }
        }
        out
    }

    /// Sync `last_fired` to the arrivals already passed at the current `elapsed` (used
    /// after a backward seek / prev-stop so events re-fire correctly going forward).
    fn resync_fired(&mut self) {
        let m = self.route.stops.len();
        self.last_fired = (0..m).rev().find(|&i| self.arrival_time(i) <= self.elapsed + 1e-9).map(|i| i as i64).unwrap_or(-1);
    }

    /// **Advance the tour** by `dt` real seconds (scaled by `speed`) when playing —
    /// returns the node ids of any stops **newly visited** this step (the stop-visited
    /// event). Deterministic: a fixed `dt` sequence yields a fixed timeline.
    pub fn advance(&mut self, dt: f64) -> Vec<NodeId> {
        if !(self.playing && self.is_active()) {
            return Vec::new();
        }
        self.elapsed = (self.elapsed + dt * self.speed as f64).clamp(0.0, self.total());
        self.fire_arrivals()
    }

    /// **Seek** to an absolute timeline position `seconds` (the `SetClock` seam) —
    /// returns the stops newly visited by moving forward to it.
    pub fn seek(&mut self, seconds: f64) -> Vec<NodeId> {
        let back = seconds < self.elapsed;
        self.elapsed = seconds.clamp(0.0, self.total());
        if back {
            self.resync_fired();
            Vec::new()
        } else {
            self.fire_arrivals()
        }
    }

    // ── controls ────────────────────────────────────────────────────────────────
    pub fn play(&mut self) {
        self.playing = true;
    }
    pub fn pause(&mut self) {
        self.playing = false;
    }
    pub fn set_speed(&mut self, speed: f32) {
        self.speed = speed.max(0.0);
    }
    /// Jump to (and dwell at) the next stop.
    pub fn next_stop(&mut self) {
        let m = self.route.stops.len();
        if m == 0 {
            return;
        }
        let target = (self.stop_index() + 1).min(m - 1);
        self.elapsed = self.arrival_time(target).min(self.total());
        self.resync_fired();
    }
    /// Jump back to the previous stop.
    pub fn prev_stop(&mut self) {
        if self.route.stops.is_empty() {
            return;
        }
        let target = self.stop_index().saturating_sub(1);
        self.elapsed = self.arrival_time(target);
        self.resync_fired();
    }

    /// Locate the ghost on the timeline at `e` seconds.
    fn locate(&self, e: f64) -> Located {
        let m = self.route.stops.len();
        if m == 0 {
            return Located { stop: 0, in_dwell: true, pos: [0.0; 3] };
        }
        if m == 1 {
            return Located { stop: 0, in_dwell: true, pos: self.centers[0] };
        }
        let dwell = self.dwell_secs as f64;
        let seg = self.seg_secs as f64;
        let mut acc = 0.0;
        for i in 0..m {
            if e < acc + dwell {
                return Located { stop: i, in_dwell: true, pos: self.centers[i] };
            }
            acc += dwell;
            if i < m - 1 {
                if e < acc + seg {
                    let u = ((e - acc) / seg) as f32;
                    return Located { stop: i, in_dwell: false, pos: self.spline.point(i, ease(u)) };
                }
                acc += seg;
            }
        }
        Located { stop: m - 1, in_dwell: true, pos: *self.centers.last().unwrap() }
    }

    /// **Pure ghost overlay paint.** Draws the glowing ghostlike spline trail, a soft
    /// highlight at each stop node, and the pulsing emissive ghost marker at the head —
    /// projecting every 3-D point through the host's `project` closure (the SAME camera
    /// the graph paints with, L5). `phase` (cycles) drives the pulse (a pinned clock ⇒
    /// a byte-stable ghost). Returns the number of shapes drawn (for the render emit).
    pub fn paint(&self, painter: &Painter, project: impl Fn([f32; 3]) -> (Pos2, f32), phase: f32) -> usize {
        if !self.is_active() {
            return 0;
        }
        let mut drawn = 0usize;
        let pulse = decor::pulse(phase); // 0..1 breathing
        // ── the ghostly spline trail (multi-sample translucent poly-line) ──────────
        let segs = self.spline.segments();
        if segs > 0 {
            let samples = (segs * 10).max(12);
            let mut prev: Option<Pos2> = None;
            for s in 0..=samples {
                let t = s as f32 / samples as f32;
                let (p, z) = project(self.spline.sample(t));
                let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.35, 1.0);
                if let Some(pp) = prev {
                    // A faint, breathing spectre line.
                    let a = (70.0 + 60.0 * pulse) * bright;
                    painter.line_segment([pp, p], Stroke::new(2.0_f32, GHOST.gamma_multiply((a / 255.0).clamp(0.0, 1.0))));
                    drawn += 1;
                }
                prev = Some(p);
            }
        }
        // ── a soft glow at each visited stop node ──────────────────────────────────
        for &c in &self.centers {
            let (p, z) = project(c);
            let r = 4.0 + (z + 1.0) * 2.0;
            neon::paint_node_orb(painter, p, r, GHOST, 0.4, 0.7);
            drawn += 1;
        }
        // ── the ghost MARKER at the head of the tour (pulsing emissive orb) ────────
        let (mp, mz) = project(self.marker_pos());
        let base_r = 6.0 + (mz + 1.0) * 3.0;
        let r = base_r * (0.85 + 0.35 * pulse);
        // Wide ghost halo, then a bright translucent core.
        neon::paint_node_orb(painter, mp, r * 1.4, GHOST, 0.7, 0.9);
        painter.circle_filled(mp, r, GHOST.gamma_multiply(0.55 + 0.35 * pulse));
        painter.circle_filled(mp, r * 0.5, Color32::WHITE.gamma_multiply(0.6 + 0.3 * pulse));
        drawn += 3;
        drawn
    }

    /// The observable tour surface for `state_json` — merged into the host's object.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "travel_active": self.is_active(),
            "travel_playing": self.playing,
            "travel_name": self.route.name,
            "travel_kind": self.route.kind.label(),
            "travel_stops": self.route.stops,
            "travel_stop_index": self.stop_index(),
            "travel_progress": self.progress(),
            "travel_dwelling": self.is_dwelling(),
            "travel_node": self.current_node(),
            "travel_speed": self.speed,
            "travel_elapsed": self.elapsed,
            "travel_camera_follow": self.camera_follow,
        })
    }
}

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

    fn centers(n: usize) -> Vec<[f32; 3]> {
        // A deterministic spread so each stop centre is distinct.
        (0..n)
            .map(|i| {
                let a = i as f32 * 0.9;
                [a.cos(), (i as f32 / n as f32) * 2.0 - 1.0, a.sin()]
            })
            .collect()
    }

    #[test]
    fn spline_passes_through_every_control_point_in_order() {
        // GEOMETRY PROOF (RED-when-broken): Catmull–Rom must hit each control point at
        // the segment boundaries, in order. If the spline stopped interpolating the
        // stops (e.g. a bezier that misses the knots), the boundary points drift → RED.
        let pts = centers(5);
        let sp = Spline::new(pts.clone());
        assert_eq!(sp.segments(), 4);
        for seg in 0..sp.segments() {
            let a = sp.point(seg, 0.0);
            let b = sp.point(seg, 1.0);
            for c in 0..3 {
                assert!((a[c] - pts[seg][c]).abs() < 1e-5, "seg {seg} u=0 hits stop {seg}");
                assert!((b[c] - pts[seg + 1][c]).abs() < 1e-5, "seg {seg} u=1 hits stop {}", seg + 1);
            }
        }
    }

    #[test]
    fn travel_marker_sits_exactly_on_each_stop_at_its_dwell() {
        // The eased path plus the dwell means the marker is exactly on stop i during its
        // dwell window — the "pauses at each visited node" contract.
        let route = TravelRoute::new("t", vec![0, 2, 4]);
        let cs = centers(6);
        let mut tv = Travel::new(route, &cs);
        for stop in 0..3 {
            tv.seek(tv.arrival_time(stop) + 0.01);
            let m = tv.marker_pos();
            let want = cs[[0, 2, 4][stop]];
            for c in 0..3 {
                assert!((m[c] - want[c]).abs() < 1e-5, "dwell {stop} marker on the stop node");
            }
            assert_eq!(tv.current_node(), Some([0usize, 2, 4][stop]));
        }
    }

    #[test]
    fn advancing_the_tour_visits_every_stop_in_order_and_progress_grows() {
        // TICK PROOF (RED-when-broken): stepping the clock must fire the stop-visited
        // event for EVERY stop, in route order, and progress must climb 0→1. A frozen
        // tour (tick a no-op) fires nothing and progress stays 0 → RED.
        let stops = vec![3usize, 1, 4, 2];
        let route = TravelRoute::latest_changes(&stops);
        let cs = centers(6);
        let mut tv = Travel::new(route, &cs);

        let mut visited = Vec::new();
        let mut last_progress = tv.progress();
        let mut progressed = false;
        // Small deterministic steps across the whole timeline.
        for _ in 0..400 {
            let got = tv.advance(0.05);
            visited.extend(got);
            let p = tv.progress();
            if p > last_progress {
                progressed = true;
            }
            last_progress = p;
        }
        assert_eq!(visited, stops, "every stop visited exactly once, in order");
        assert!(progressed, "progress advanced as the clock ticked");
        assert!((tv.progress() - 1.0).abs() < 1e-4, "the tour reached the end");
    }

    #[test]
    fn paused_tour_does_not_move() {
        // RED-when-broken guard on the pause control: a paused tour ignores ticks.
        let route = TravelRoute::spotlight(2);
        let cs = centers(4);
        let mut tv = Travel::new(route, &cs).paused();
        let before = tv.progress();
        let visited = tv.advance(5.0);
        assert!(visited.is_empty(), "a paused tour fires no arrivals");
        assert_eq!(tv.progress(), before, "a paused tour does not advance");
        tv.play();
        let _ = tv.advance(0.001);
        // The spotlight is a single-stop tour: stop 0 arrives once play resumes.
        assert_eq!(tv.current_node(), Some(2));
    }

    #[test]
    fn next_and_prev_stop_jump_between_stops() {
        let route = TravelRoute::new("t", vec![0, 1, 2, 3]);
        let cs = centers(4);
        let mut tv = Travel::new(route, &cs).paused();
        assert_eq!(tv.stop_index(), 0);
        tv.next_stop();
        assert_eq!(tv.stop_index(), 1);
        tv.next_stop();
        tv.next_stop();
        assert_eq!(tv.stop_index(), 3);
        tv.next_stop();
        assert_eq!(tv.stop_index(), 3, "next clamps at the last stop");
        tv.prev_stop();
        assert_eq!(tv.stop_index(), 2);
    }

    #[test]
    fn camera_yaw_faces_the_current_marker() {
        // The camera-fly target: the yaw brings the marker to the front (max depth).
        let route = TravelRoute::spotlight(1);
        let cs = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
        let tv = Travel::new(route, &cs);
        // Marker at [1,0,0]: yaw = atan2(1,0) = π/2.
        assert!((tv.camera_yaw() - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
    }

    #[test]
    fn route_constructors_carry_kind_and_stops() {
        assert_eq!(TravelRoute::spotlight(7).kind, RouteKind::Spotlight);
        assert_eq!(TravelRoute::spotlight(7).stops, vec![7]);
        let r = TravelRoute::latest_changes(&[2, 5, 1]);
        assert_eq!(r.kind, RouteKind::LatestChanges);
        assert_eq!(r.stops, vec![2, 5, 1]);
        let b = TravelRoute::bugs_top_down(&[9, 4]);
        assert_eq!(b.kind, RouteKind::BugsTopDown);
        assert_eq!(b.kind.label(), "bugs_top_down");
    }

    #[test]
    fn state_json_exposes_the_tour_surface() {
        let route = TravelRoute::bugs_top_down(&[1, 3]);
        let cs = centers(4);
        let mut tv = Travel::new(route, &cs);
        let _ = tv.advance(0.1);
        let j = tv.state_json();
        assert_eq!(j["travel_active"], true);
        assert_eq!(j["travel_playing"], true);
        assert_eq!(j["travel_kind"], "bugs_top_down");
        assert_eq!(j["travel_stops"], serde_json::json!([1, 3]));
        assert_eq!(j["travel_stop_index"], 0);
        assert_eq!(j["travel_node"], 1);
        assert!(j["travel_progress"].as_f64().unwrap() >= 0.0);
    }
}