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

//! **Route overlay geometry** — the car-navigator "your route" polyline the 3D map
//! draws as a bright ground ribbon over the streets and (turn-by-turn) flies the
//! camera along.
//!
//! Pure + headless-testable: a [`Route`] is a **design-plane polyline** (already
//! projected onto the extruded streets by [`crate::extrude::project_route`], so it
//! lands ON the roads it follows) plus its cumulative arc-length table. From that,
//! [`Route::point_at`] and [`Route::bearing_deg_at`] resolve a normalised progress
//! `t ∈ [0, 1]` (0 = start, 1 = destination) to a world point + a compass heading —
//! the two levers a turn-by-turn camera chases. No I/O, no RNG, no floats-of-state:
//! a pure function of the polyline, so a pinned-clock A/B snapshot is bit-stable.
//!
//! The **compass convention** matches both the [`crate::MinimapView`] heading and
//! the [`crate::OrbitCamera`] bearing (clockwise from +z, i.e. `atan2(dx, dz)`), so
//! `cam.set_bearing_deg(route.bearing_deg_at(t))` looks straight along the route.

/// A projected route the 3D map draws + follows: an ordered design-plane polyline
/// with an arc-length table for constant-speed traversal.
#[derive(Clone, Debug, PartialEq)]
pub struct Route {
    /// Design-plane route vertices `(x, y, z)` — projected onto the streets (see
    /// [`crate::extrude::project_route`]); `y` is the shared [`crate::extrude::ROUTE_LIFT`].
    pub points: Vec<[f32; 3]>,
    /// Cumulative **XZ** (ground-plane) arc length at each vertex (`cumulative[0] == 0`).
    cumulative: Vec<f32>,
    /// Total ground-plane length (design units).
    pub length: f32,
}

impl Route {
    /// Build a route from an ordered design-plane polyline (the output of
    /// [`crate::extrude::project_route`]). Computes the cumulative XZ arc length so
    /// [`Self::point_at`] traverses at constant ground speed.
    #[must_use]
    pub fn from_design_points(points: Vec<[f32; 3]>) -> Self {
        let mut cumulative = Vec::with_capacity(points.len());
        let mut acc = 0.0f32;
        for (i, p) in points.iter().enumerate() {
            if i > 0 {
                let q = points[i - 1];
                let (dx, dz) = (p[0] - q[0], p[2] - q[2]);
                acc += (dx * dx + dz * dz).sqrt();
            }
            cumulative.push(acc);
        }
        Self { points, length: acc, cumulative }
    }

    /// A route with fewer than two vertices (or zero length) is not followable.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.points.len() < 2 || self.length <= 0.0
    }

    /// How many waypoints (polyline vertices) the route carries.
    #[must_use]
    pub fn waypoints(&self) -> usize {
        self.points.len()
    }

    /// The **XZ index** of the segment `[i-1, i]` containing arc-length `d` (clamped
    /// into `1..=len-1`), for the shared point/bearing interpolation.
    fn seg_index(&self, d: f32) -> usize {
        self.cumulative.partition_point(|&c| c < d).clamp(1, self.points.len() - 1)
    }

    /// The world point at normalised progress `t ∈ [0, 1]` along the route
    /// (0 = start, 1 = destination), linearly interpolated by **arc length** so the
    /// camera advances at a constant ground pace regardless of segment lengths.
    /// `t` is clamped; a degenerate route returns its first vertex (or the origin).
    #[must_use]
    pub fn point_at(&self, t: f32) -> [f32; 3] {
        if self.points.is_empty() {
            return [0.0, 0.0, 0.0];
        }
        if self.points.len() == 1 || self.length <= 0.0 {
            return self.points[0];
        }
        let d = t.clamp(0.0, 1.0) * self.length;
        let i = self.seg_index(d);
        let (c0, c1) = (self.cumulative[i - 1], self.cumulative[i]);
        let seg = (c1 - c0).max(1e-9);
        let f = ((d - c0) / seg).clamp(0.0, 1.0);
        let a = self.points[i - 1];
        let b = self.points[i];
        [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f]
    }

    /// The **compass bearing** (degrees, `[0, 360)`) of the route tangent at progress
    /// `t` — the direction of travel a turn-by-turn camera faces. Clockwise from +z
    /// (`atan2(dx, dz)`), matching the [`crate::OrbitCamera`] bearing + the minimap
    /// heading, so `cam.set_bearing_deg(route.bearing_deg_at(t))` looks along the route.
    #[must_use]
    pub fn bearing_deg_at(&self, t: f32) -> f32 {
        if self.points.len() < 2 || self.length <= 0.0 {
            return 0.0;
        }
        let d = t.clamp(0.0, 1.0) * self.length;
        let i = self.seg_index(d);
        let a = self.points[i - 1];
        let b = self.points[i];
        let (dx, dz) = (b[0] - a[0], b[2] - a[2]);
        dx.atan2(dz).to_degrees().rem_euclid(360.0)
    }

    /// **Chevron-flow placements** — the directional chevron glyphs a Garmin marches
    /// along the route ribbon to show the way (design seam F2). One chevron every
    /// `cadence_du` design units of arc, its start offset by the animation `phase01`
    /// (a fraction of one cadence, so the whole train slides forward as `phase01`
    /// advances `0 → 1`, then repeats — the marching motion). Each placement **rides the
    /// route** ([`Self::point_at`]) and **faces the tangent** ([`Self::bearing_deg_at`]),
    /// so the arrowheads point the way to drive.
    ///
    /// Pure: same route + cadence + phase ⇒ the same chevrons (a pinned A/B snapshot is
    /// bit-stable). The **count** is the number of `k ≥ 0` with `(k + frac(phase01)) ·
    /// cadence_du < length` — at `phase01 == 0` that is exactly the chevrons at arc
    /// `0, cadence, 2·cadence, …` below the route length (`⌊length/cadence⌋` for a length
    /// that is a whole multiple of the cadence), the red-when-broken oracle. Empty for a
    /// degenerate route or a non-positive cadence.
    #[must_use]
    pub fn chevron_flow(&self, cadence_du: f32, phase01: f32) -> Vec<FlowChevron> {
        if self.is_empty() || cadence_du <= 0.0 {
            return Vec::new();
        }
        let phase = phase01.rem_euclid(1.0);
        let mut out = Vec::new();
        let mut k = 0u32;
        loop {
            let s = (k as f32 + phase) * cadence_du;
            if s >= self.length {
                break;
            }
            let t = s / self.length;
            out.push(FlowChevron { pos: self.point_at(t), bearing_deg: self.bearing_deg_at(t) });
            k += 1;
        }
        out
    }
}

/// One **flow chevron** — a single directional glyph in the marching chevron train: its
/// design-plane position on the route ribbon and the compass bearing it points (the
/// route tangent there, clockwise from +z, matching [`Route::bearing_deg_at`]). Consumed
/// by [`crate::extrude::chevron_flow_into`] to lay the arrowhead into the ground mesh.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FlowChevron {
    /// The design-plane point `(x, y, z)` the chevron sits at (on the route).
    pub pos: [f32; 3],
    /// The compass bearing (degrees, `[0, 360)`) the chevron points — the route tangent.
    pub bearing_deg: f32,
}

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

    /// An L-shaped 3-waypoint route: east along +x for 2 units, then north (−z) for
    /// 2 units. Total length 4; the corner sits at half progress.
    fn l_route() -> Route {
        Route::from_design_points(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 0.0, -2.0]])
    }

    #[test]
    fn length_is_the_summed_xz_arc() {
        assert!((l_route().length - 4.0).abs() < 1e-5, "L route is 2 + 2 = 4 units long");
    }

    #[test]
    fn point_at_traverses_by_arc_length() {
        let r = l_route();
        // t=0 → start, t=1 → destination.
        assert_eq!(r.point_at(0.0), [0.0, 0.0, 0.0]);
        assert_eq!(r.point_at(1.0), [2.0, 0.0, -2.0]);
        // t=0.5 → exactly the corner (2 of 4 units in).
        let mid = r.point_at(0.5);
        assert!((mid[0] - 2.0).abs() < 1e-4 && (mid[2] - 0.0).abs() < 1e-4, "half progress lands on the corner: {mid:?}");
        // t=0.25 → half way down the first (east) leg.
        let q = r.point_at(0.25);
        assert!((q[0] - 1.0).abs() < 1e-4 && q[2].abs() < 1e-4, "quarter progress is mid-first-leg: {q:?}");
        // Monotone advance in x then in −z: consecutive samples never regress along path.
        let mut prev = -1.0;
        for k in 0..=20 {
            let d = {
                let p = r.point_at(k as f32 / 20.0);
                // path distance ≈ x on first leg, 2+(−z) on second.
                if p[0] < 2.0 { p[0] } else { 2.0 + (-p[2]) }
            };
            assert!(d >= prev - 1e-4, "arc-progress is monotone (got {d} after {prev})");
            prev = d;
        }
    }

    #[test]
    fn bearing_tracks_the_tangent_and_turns_at_the_corner() {
        let r = l_route();
        // First leg heads +x → compass atan2(1,0) = 90°.
        assert!((r.bearing_deg_at(0.1) - 90.0).abs() < 1e-3, "first leg bears due-east 90°");
        // Second leg heads −z → compass atan2(0,-1) = 180°.
        assert!((r.bearing_deg_at(0.9) - 180.0).abs() < 1e-3, "second leg bears due-'north' 180°");
        // The bearing actually CHANGES across the turn (the whole point of turn-by-turn).
        assert!((r.bearing_deg_at(0.9) - r.bearing_deg_at(0.1)).abs() > 45.0, "the route turns");
    }

    /// A straight east run of total length 4 (two colinear 2-unit segments) — a clean
    /// canvas for the chevron cadence arithmetic (no corners to complicate the tangent).
    fn straight_route() -> Route {
        Route::from_design_points(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]])
    }

    #[test]
    fn chevron_flow_count_spacing_and_heading() {
        let r = straight_route();
        assert!((r.length - 4.0).abs() < 1e-5, "the straight route is 4 units long");
        // cadence 1, phase 0 ⇒ chevrons at arc 0, 1, 2, 3 (arc 4 is the end, excluded).
        let ch = r.chevron_flow(1.0, 0.0);
        assert_eq!(ch.len(), (r.length / 1.0).floor() as usize, "count == ⌊length / cadence⌋ = 4");
        assert_eq!(ch.len(), 4, "arc 0,1,2,3 sit below the length-4 route");
        // Every chevron faces the route tangent — due-east 90° on this straight run.
        for c in &ch {
            assert!((c.bearing_deg - 90.0).abs() < 1e-3, "a chevron faces the route tangent (got {})", c.bearing_deg);
        }
        // Consecutive chevrons are exactly one cadence apart along the ground.
        for w in ch.windows(2) {
            let (dx, dz) = (w[1].pos[0] - w[0].pos[0], w[1].pos[2] - w[0].pos[2]);
            let gap = (dx * dx + dz * dz).sqrt();
            assert!((gap - 1.0).abs() < 1e-4, "chevrons are one cadence (1 unit) apart (got {gap})");
        }
        // First chevron at phase 0 sits at the route start.
        assert!(ch[0].pos[0].abs() < 1e-4, "phase-0 train starts at the route origin");
    }

    #[test]
    fn chevron_flow_phase_marches_the_train_forward() {
        let r = straight_route();
        let a = r.chevron_flow(1.0, 0.0);
        let b = r.chevron_flow(1.0, 0.5);
        // Half a cadence of phase slides the leading chevron forward by half a cadence.
        assert!(b[0].pos[0] > a[0].pos[0] + 0.4, "advancing the phase marches the chevrons forward");
        assert!((b[0].pos[0] - 0.5).abs() < 1e-4, "phase 0.5 puts the first chevron half a cadence in");
        // The phase wraps: phase 1.0 ≡ phase 0.0 (a seamless loop).
        let wrapped = r.chevron_flow(1.0, 1.0);
        assert_eq!(wrapped.len(), a.len(), "phase wraps at 1.0 back to the phase-0 train");
        assert!((wrapped[0].pos[0] - a[0].pos[0]).abs() < 1e-4, "phase 1.0 ≡ phase 0.0");
    }

    #[test]
    fn chevron_flow_degenerate_is_empty_and_never_panics() {
        let r = straight_route();
        assert!(r.chevron_flow(0.0, 0.0).is_empty(), "a non-positive cadence yields no chevrons");
        assert!(r.chevron_flow(-1.0, 0.0).is_empty(), "a negative cadence yields no chevrons");
        let empty = Route::from_design_points(vec![]);
        assert!(empty.chevron_flow(1.0, 0.0).is_empty(), "an empty route has no chevrons");
        // A cadence longer than the whole route still yields the leading (start) chevron.
        assert_eq!(r.chevron_flow(10.0, 0.0).len(), 1, "one chevron when the cadence exceeds the route");
    }

    #[test]
    fn clamps_and_degenerate_routes_never_panic() {
        let r = l_route();
        assert_eq!(r.point_at(-5.0), r.point_at(0.0), "under-range t clamps to start");
        assert_eq!(r.point_at(5.0), r.point_at(1.0), "over-range t clamps to destination");
        let empty = Route::from_design_points(vec![]);
        assert!(empty.is_empty());
        assert_eq!(empty.point_at(0.5), [0.0, 0.0, 0.0]);
        assert_eq!(empty.bearing_deg_at(0.5), 0.0);
        let single = Route::from_design_points(vec![[1.0, 0.0, 2.0]]);
        assert!(single.is_empty(), "one point is not a followable route");
        assert_eq!(single.point_at(0.7), [1.0, 0.0, 2.0]);
    }
}