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]>
215 lines
6.9 KiB
Rust
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());
|
|
}
|
|
}
|