navcore_routes/plan.rs
1//! What a route is, before any question of where it lives.
2//!
3//! A route is a named, ordered list of waypoints, nothing more. Signal
4//! K's own Route resource carries none of IEC
5//! 61174 (RTZ)'s per-leg richness -- no per-leg cross-track corridor,
6//! planned speed, safety-contour override or rhumb/great-circle choice,
7//! only `name`/`description`/`distance`/a line -- and no client needs
8//! that richness either, recomputing a whole route from one global
9//! vessel-settings struct rather than per leg. See [`crate::signalk`]'s
10//! own doc for the rest of that reasoning.
11
12use nav_math::Position;
13
14/// A route, in the order its waypoints are sailed.
15#[derive(Debug, Clone, PartialEq)]
16pub struct Route {
17 /// Signal K's UUID, and the identity everywhere else too -- the same
18 /// choice `waypoints::Waypoint::uuid`'s own doc explains, for the
19 /// same reason: one id, whatever server holds it.
20 pub uuid: String,
21 /// What the mariner calls it.
22 pub name: Option<String>,
23 /// Anything they wrote about it.
24 pub description: Option<String>,
25 /// In the order they are sailed.
26 pub waypoints: Vec<Waypoint>,
27}
28
29impl Route {
30 /// A fresh, unnamed route through these waypoints. The caller
31 /// supplies the id: minting one needs randomness, and this layer
32 /// deliberately has no source of it, the same reasoning
33 /// `waypoints::Waypoint::at`'s own doc gives.
34 #[must_use]
35 pub fn new(uuid: impl Into<String>, waypoints: Vec<Waypoint>) -> Self {
36 Self { uuid: uuid.into(), name: None, description: None, waypoints }
37 }
38
39 /// The legs, in order: each is the pair of waypoints it runs
40 /// between. Empty for a route of fewer than two waypoints, which is
41 /// a position rather than a plan.
42 pub fn legs(&self) -> impl Iterator<Item = (&Waypoint, &Waypoint)> {
43 self.waypoints.windows(2).map(|pair| (&pair[0], &pair[1]))
44 }
45
46 /// Total distance in nautical miles, each leg a rhumb line -- the
47 /// same convention `nav_math::rhumb` and this whole codebase's own
48 /// charts already draw a route leg as.
49 #[must_use]
50 pub fn distance_nm(&self) -> f64 {
51 self.legs().map(|(from, to)| nav_math::rhumb::distance_nm(from.position, to.position)).sum()
52 }
53}
54
55/// One waypoint of a route -- a position and, optionally, what the
56/// mariner calls it, the same pair Signal K's own `coordinatesMeta`
57/// carries per point.
58#[derive(Debug, Clone, PartialEq)]
59pub struct Waypoint {
60 /// Where it is.
61 pub position: Position,
62 /// What the mariner calls it, if anything.
63 pub name: Option<String>,
64}
65
66impl Waypoint {
67 /// A plain, unnamed waypoint at a position.
68 #[must_use]
69 pub const fn at(position: Position) -> Self {
70 Self { position, name: None }
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 fn route() -> Route {
79 Route::new(
80 "94052456-65fa-48ce-a85d-41b78a9d2111",
81 vec![
82 Waypoint::at(Position::new(45.5150, 13.5680)),
83 Waypoint::at(Position::new(45.5500, 13.6200)),
84 Waypoint::at(Position::new(45.5480, 13.7300)),
85 ],
86 )
87 }
88
89 #[test]
90 fn a_new_route_has_no_name_or_description() {
91 let route = route();
92 assert_eq!(route.name, None);
93 assert_eq!(route.description, None);
94 assert_eq!(route.waypoints.len(), 3);
95 }
96
97 #[test]
98 fn a_single_waypoint_has_no_distance() {
99 let lone = Route::new("id", vec![Waypoint::at(Position::new(45.0, 13.0))]);
100 assert_eq!(lone.distance_nm(), 0.0);
101 assert_eq!(lone.legs().count(), 0);
102 }
103
104 #[test]
105 fn a_route_of_no_waypoints_has_no_distance_either() {
106 // Not just the single-waypoint case's neighbour: a route just
107 // cleared, or loaded from a malformed empty `LineString`, is
108 // still a valid (if useless) `Route` to hold -- nothing here
109 // should panic or divide by zero.
110 let empty = Route::new("id", Vec::new());
111 assert_eq!(empty.distance_nm(), 0.0);
112 assert_eq!(empty.legs().count(), 0);
113 }
114
115 #[test]
116 fn a_route_of_three_waypoints_has_two_legs() {
117 assert_eq!(route().legs().count(), 2);
118 }
119
120 #[test]
121 fn distance_sums_every_leg() {
122 // Loose bound: the three points here span roughly ten nautical
123 // miles end to end, and the point of this test is that both legs
124 // were added, not the exact rhumb-line arithmetic nav_math's own
125 // tests already cover.
126 let nm = route().distance_nm();
127 assert!(nm > 1.0, "{nm}");
128 }
129
130 #[test]
131 fn at_makes_a_plain_unnamed_waypoint() {
132 let waypoint = Waypoint::at(Position::new(1.0, 2.0));
133 assert_eq!(waypoint.name, None);
134 assert_eq!(waypoint.position, Position::new(1.0, 2.0));
135 }
136}