open-standards/map/map-rs/crates/map-route/src/directions.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:

- aac/    — communication cargo class: portable boards + a ten-level
            offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/    — geospatial cargo class: range-requestable signed tilesets +
            an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
            compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.

Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 17:04:36 -04:00

215 lines
6.9 KiB
Rust

//! Turn-by-turn directions — derive human maneuvers from a [`RouteResult`].
//!
//! Each consecutive pair of route segments has a *turn angle* (the signed
//! difference of their compass bearings); that angle classifies into a
//! [`Maneuver`]. Straight runs are merged so a long road is one step, not dozens.
//! The first step is a `Depart` (with a cardinal heading) and the last is
//! `Arrive`. Pure geometry over the segments the router already produced.
use serde::{Deserialize, Serialize};
use crate::{bearing, Coordinate, RouteResult};
/// A driving/walking maneuver at a step boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Maneuver {
Depart,
Continue,
SlightLeft,
SlightRight,
Left,
Right,
SharpLeft,
SharpRight,
UTurn,
Arrive,
}
impl Maneuver {
/// The base instruction verb for this maneuver.
pub fn verb(self) -> &'static str {
match self {
Maneuver::Depart => "Head",
Maneuver::Continue => "Continue straight",
Maneuver::SlightLeft => "Keep left",
Maneuver::SlightRight => "Keep right",
Maneuver::Left => "Turn left",
Maneuver::Right => "Turn right",
Maneuver::SharpLeft => "Take a sharp left",
Maneuver::SharpRight => "Take a sharp right",
Maneuver::UTurn => "Make a U-turn",
Maneuver::Arrive => "Arrive at destination",
}
}
}
/// One step of turn-by-turn guidance: the maneuver at its start, the heading
/// taken, and how far/long to travel until the next step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DirectionStep {
pub maneuver: Maneuver,
pub instruction: String,
pub coord: Coordinate,
pub bearing_deg: f64,
pub distance_m: f64,
pub duration_secs: f64,
}
/// Normalize a bearing delta to the signed turn in `(-180, 180]`
/// (positive = right/clockwise, negative = left).
fn turn_angle(from_bearing: f64, to_bearing: f64) -> f64 {
let mut a = to_bearing - from_bearing;
while a <= -180.0 {
a += 360.0;
}
while a > 180.0 {
a -= 360.0;
}
a
}
/// Classify a signed turn angle into a maneuver.
fn classify(angle: f64) -> Maneuver {
let m = angle.abs();
let right = angle > 0.0;
if m < 20.0 {
Maneuver::Continue
} else if m < 45.0 {
if right { Maneuver::SlightRight } else { Maneuver::SlightLeft }
} else if m <= 135.0 {
if right { Maneuver::Right } else { Maneuver::Left }
} else if m <= 160.0 {
if right { Maneuver::SharpRight } else { Maneuver::SharpLeft }
} else {
Maneuver::UTurn
}
}
/// The 8-wind compass name for a bearing in degrees.
pub fn cardinal(bearing_deg: f64) -> &'static str {
const DIRS: [&str; 8] = ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"];
let idx = (((bearing_deg % 360.0 + 360.0) % 360.0 + 22.5) / 45.0) as usize % 8;
DIRS[idx]
}
/// Build turn-by-turn directions from a route, merging straight runs into the
/// preceding step. Returns an empty list for an empty route.
pub fn turn_by_turn(route: &RouteResult) -> Vec<DirectionStep> {
let segs = &route.segments;
if segs.is_empty() {
return Vec::new();
}
let seg_bearing = |i: usize| bearing(&segs[i].from, &segs[i].to);
let b0 = seg_bearing(0);
let mut steps = Vec::new();
let mut cur = DirectionStep {
maneuver: Maneuver::Depart,
instruction: format!("Head {}", cardinal(b0)),
coord: segs[0].from,
bearing_deg: b0,
distance_m: 0.0,
duration_secs: 0.0,
};
for (i, seg) in segs.iter().enumerate() {
if i > 0 {
let m = classify(turn_angle(seg_bearing(i - 1), seg_bearing(i)));
if m != Maneuver::Continue {
steps.push(cur);
cur = DirectionStep {
maneuver: m,
instruction: m.verb().to_string(),
coord: seg.from,
bearing_deg: seg_bearing(i),
distance_m: 0.0,
duration_secs: 0.0,
};
}
}
cur.distance_m += seg.distance_m;
cur.duration_secs += seg.duration_secs;
}
steps.push(cur);
let last = segs.last().unwrap();
steps.push(DirectionStep {
maneuver: Maneuver::Arrive,
instruction: Maneuver::Arrive.verb().to_string(),
coord: last.to,
bearing_deg: seg_bearing(segs.len() - 1),
distance_m: 0.0,
duration_secs: 0.0,
});
steps
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RouteResult, RouteSegment};
fn seg(from: (f64, f64), to: (f64, f64)) -> RouteSegment {
let from = Coordinate::new(from.0, from.1);
let to = Coordinate::new(to.0, to.1);
RouteSegment {
from,
to,
distance_m: crate::haversine_distance(&from, &to),
duration_secs: 10.0,
instruction: String::new(),
}
}
fn route(segments: Vec<RouteSegment>) -> RouteResult {
RouteResult {
waypoints: Vec::new(),
total_distance_m: segments.iter().map(|s| s.distance_m).sum(),
total_duration_secs: segments.iter().map(|s| s.duration_secs).sum(),
segments,
}
}
#[test]
fn cardinals_round_correctly() {
assert_eq!(cardinal(0.0), "north");
assert_eq!(cardinal(90.0), "east");
assert_eq!(cardinal(359.0), "north");
assert_eq!(cardinal(45.0), "northeast");
assert_eq!(cardinal(225.0), "southwest");
}
#[test]
fn straight_run_merges_into_one_step() {
// Three colinear segments heading east → Depart + Arrive only.
let r = route(vec![
seg((0.0, 0.0), (0.0, 0.01)),
seg((0.0, 0.01), (0.0, 0.02)),
seg((0.0, 0.02), (0.0, 0.03)),
]);
let steps = turn_by_turn(&r);
assert_eq!(steps.len(), 2, "merged straight run: depart + arrive");
assert_eq!(steps[0].maneuver, Maneuver::Depart);
assert_eq!(steps[1].maneuver, Maneuver::Arrive);
// Depart accumulates all three segments' distance.
assert!((steps[0].distance_m - r.total_distance_m).abs() < 1e-6);
}
#[test]
fn detects_a_right_turn() {
// East, then north → a left turn (bearing 90 → 0 is -90 = left).
let east = route(vec![seg((0.0, 0.0), (0.0, 0.01)), seg((0.0, 0.01), (0.01, 0.01))]);
let steps = turn_by_turn(&east);
assert_eq!(steps.len(), 3); // depart, turn, arrive
assert_eq!(steps[1].maneuver, Maneuver::Left);
// East, then south → a right turn.
let s = route(vec![seg((0.0, 0.0), (0.0, 0.01)), seg((0.0, 0.01), (-0.01, 0.01))]);
assert_eq!(turn_by_turn(&s)[1].maneuver, Maneuver::Right);
}
#[test]
fn empty_route_yields_no_steps() {
assert!(turn_by_turn(&route(Vec::new())).is_empty());
}
}