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