1use geo::{Coord, LineString, Polygon};
15use nav_math::{Position, angle, great_circle, rhumb};
16
17pub(crate) const SEGMENT_NM: f64 = 0.25;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Sailing {
34 #[default]
37 Rhumb,
38 GreatCircle,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Leg {
54 pub from: Position,
56 pub to: Position,
58 pub port_xtd_nm: f64,
60 pub starboard_xtd_nm: f64,
62 pub sailing: Sailing,
64}
65
66#[derive(Debug, Clone)]
68pub struct Segment {
69 pub start_nm: f64,
71 pub start: Position,
73 pub band: Polygon<f64>,
75}
76
77impl Leg {
78 #[must_use]
82 pub fn symmetric(from: Position, to: Position, half_width_nm: f64) -> Self {
83 Self {
84 from,
85 to,
86 port_xtd_nm: half_width_nm,
87 starboard_xtd_nm: half_width_nm,
88 sailing: Sailing::default(),
89 }
90 }
91
92 #[must_use]
94 pub fn length_nm(&self) -> f64 {
95 match self.sailing {
96 Sailing::Rhumb => rhumb::distance_nm(self.from, self.to),
97 Sailing::GreatCircle => great_circle::distance_nm(self.from, self.to),
98 }
99 }
100
101 #[must_use]
105 pub fn narrowest_xtd_nm(&self) -> f64 {
106 self.port_xtd_nm.min(self.starboard_xtd_nm)
107 }
108
109 #[must_use]
111 pub fn point_at(&self, along_nm: f64) -> Position {
112 match self.sailing {
113 Sailing::Rhumb => {
114 rhumb::destination(self.from, rhumb::bearing_deg(self.from, self.to), along_nm)
115 }
116 Sailing::GreatCircle => great_circle::destination(
120 self.from,
121 great_circle::initial_bearing_deg(self.from, self.to),
122 along_nm,
123 ),
124 }
125 }
126
127 #[must_use]
133 pub fn course_at(&self, position: Position, along_nm: f64) -> f64 {
134 match self.sailing {
135 Sailing::Rhumb => rhumb::bearing_deg(self.from, self.to),
136 Sailing::GreatCircle => {
137 if along_nm >= self.length_nm() - f64::EPSILON {
140 great_circle::final_bearing_deg(self.from, self.to)
141 } else {
142 great_circle::initial_bearing_deg(position, self.to)
143 }
144 }
145 }
146 }
147
148 #[must_use]
154 pub fn band(&self) -> Option<Polygon<f64>> {
155 let length_nm = self.length_nm();
156 if length_nm <= 0.0 || self.port_xtd_nm <= 0.0 || self.starboard_xtd_nm <= 0.0 {
157 return None;
158 }
159 Some(self.band_between(0.0, length_nm))
160 }
161
162 #[must_use]
168 pub fn segments(&self) -> Vec<Segment> {
169 let length_nm = self.length_nm();
170 if length_nm <= 0.0 || self.port_xtd_nm <= 0.0 || self.starboard_xtd_nm <= 0.0 {
171 return Vec::new();
172 }
173
174 let mut segments = Vec::new();
175 let mut start_nm = 0.0;
176 while start_nm < length_nm {
177 let end_nm = (start_nm + SEGMENT_NM).min(length_nm);
178 segments.push(Segment {
179 start_nm,
180 start: self.point_at(start_nm),
181 band: self.band_between(start_nm, end_nm),
182 });
183 start_nm = end_nm;
184 }
185 segments
186 }
187
188 fn band_between(&self, start_nm: f64, end_nm: f64) -> Polygon<f64> {
196 let mut centre = Vec::new();
199 let mut along_nm = start_nm;
200 loop {
201 let point = self.point_at(along_nm);
202 centre.push((point, self.course_at(point, along_nm)));
203 if along_nm >= end_nm {
204 break;
205 }
206 along_nm = (along_nm + SEGMENT_NM).min(end_nm);
207 }
208
209 let mut ring: Vec<Coord<f64>> = centre
211 .iter()
212 .map(|(point, course)| {
213 offset(*point, angle::norm_360(course - 90.0), self.port_xtd_nm)
214 })
215 .collect();
216 ring.extend(centre.iter().rev().map(|(point, course)| {
217 offset(*point, angle::norm_360(course + 90.0), self.starboard_xtd_nm)
218 }));
219
220 Polygon::new(LineString::new(ring), Vec::new())
221 }
222}
223
224fn offset(from: Position, bearing_deg: f64, distance_nm: f64) -> Coord<f64> {
227 let p = rhumb::destination(from, bearing_deg, distance_nm);
228 Coord {
229 x: p.lon_deg,
230 y: p.lat_deg,
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use geo::{Contains, Intersects, Point};
238
239 fn leg() -> Leg {
240 Leg::symmetric(
241 Position::new(45.50, 13.60),
242 Position::new(45.50, 13.80),
243 0.1,
244 )
245 }
246
247 #[test]
248 fn a_leg_of_no_length_has_no_band() {
249 let point = Position::new(45.5, 13.6);
250 let leg = Leg::symmetric(point, point, 0.1);
251 assert!(leg.band().is_none());
252 assert!(leg.segments().is_empty());
253 }
254
255 #[test]
256 fn a_leg_with_no_width_has_no_band() {
257 let mut leg = leg();
261 leg.starboard_xtd_nm = 0.0;
262 assert!(leg.band().is_none());
263 }
264
265 #[test]
266 fn the_band_holds_the_track_it_was_built_around() {
267 let leg = leg();
274 let band = leg.band().expect("a band");
275 for step in 0..=10 {
276 let along_nm = leg.length_nm() * f64::from(step) / 10.0;
277 let on_track = leg.point_at(along_nm);
278 assert!(
279 band.intersects(&Point::new(on_track.lon_deg, on_track.lat_deg)),
280 "the track itself fell outside its own band at {along_nm} NM"
281 );
282 }
283 }
284
285 #[test]
286 fn the_band_reaches_the_tolerance_and_not_much_further() {
287 let leg = leg();
288 let band = leg.band().expect("a band");
289 let middle = leg.point_at(leg.length_nm() / 2.0);
290 let course = leg.course_at(middle, leg.length_nm() / 2.0);
291
292 let inside = rhumb::destination(middle, course + 90.0, 0.09);
293 let outside = rhumb::destination(middle, course + 90.0, 0.15);
294 assert!(band.contains(&Point::new(inside.lon_deg, inside.lat_deg)));
295 assert!(!band.intersects(&Point::new(outside.lon_deg, outside.lat_deg)));
296 }
297
298 #[test]
299 fn the_two_sides_are_measured_separately() {
300 let leg = Leg {
304 port_xtd_nm: 0.02,
305 starboard_xtd_nm: 0.20,
306 ..leg()
307 };
308 let band = leg.band().expect("a band");
309 let middle = leg.point_at(leg.length_nm() / 2.0);
310 let course = leg.course_at(middle, leg.length_nm() / 2.0);
311
312 let wide_side = rhumb::destination(middle, course + 90.0, 0.15);
313 let narrow_side = rhumb::destination(middle, course - 90.0, 0.15);
314 assert!(
315 band.contains(&Point::new(wide_side.lon_deg, wide_side.lat_deg)),
316 "starboard tolerance was not honoured"
317 );
318 assert!(
319 !band.intersects(&Point::new(narrow_side.lon_deg, narrow_side.lat_deg)),
320 "port tolerance was widened to match starboard"
321 );
322 }
323
324 #[test]
325 fn the_narrower_side_is_the_one_the_chart_has_to_answer_for() {
326 let leg = Leg {
327 port_xtd_nm: 0.02,
328 starboard_xtd_nm: 0.20,
329 ..leg()
330 };
331 assert_eq!(leg.narrowest_xtd_nm(), 0.02);
332 }
333
334 #[test]
335 fn a_great_circle_leg_is_shorter_than_the_rhumb_line_it_replaces() {
336 let from = Position::new(50.0, -5.0);
337 let to = Position::new(40.0, -60.0);
338 let rhumb_leg = Leg::symmetric(from, to, 1.0);
339 let gc_leg = Leg {
340 sailing: Sailing::GreatCircle,
341 ..rhumb_leg
342 };
343 assert!(gc_leg.length_nm() < rhumb_leg.length_nm() - 10.0);
344 }
345
346 #[test]
347 fn a_great_circle_band_follows_its_own_track_and_not_the_rhumb_line() {
348 let from = Position::new(50.0, -5.0);
352 let to = Position::new(40.0, -60.0);
353 let gc_leg = Leg {
354 sailing: Sailing::GreatCircle,
355 ..Leg::symmetric(from, to, 5.0)
356 };
357 let band = gc_leg.band().expect("a band");
358
359 let half_way_gc = gc_leg.point_at(gc_leg.length_nm() / 2.0);
360 assert!(
361 band.intersects(&Point::new(half_way_gc.lon_deg, half_way_gc.lat_deg)),
362 "the great circle track left its own band"
363 );
364
365 let rhumb_leg = Leg::symmetric(from, to, 5.0);
366 let half_way_rhumb = rhumb_leg.point_at(rhumb_leg.length_nm() / 2.0);
367 assert!(
368 !band.intersects(&Point::new(half_way_rhumb.lon_deg, half_way_rhumb.lat_deg)),
369 "the rhumb line should be nowhere near this band"
370 );
371 }
372
373 #[test]
374 fn the_pieces_cover_the_leg_end_to_end_in_order() {
375 let leg = leg();
376 let segments = leg.segments();
377 assert!(segments.len() > 1, "a leg longer than a segment splits");
378
379 assert_eq!(segments[0].start_nm, 0.0);
380 for pair in segments.windows(2) {
381 assert!(pair[0].start_nm < pair[1].start_nm, "pieces out of order");
382 }
383 let last = segments.last().expect("pieces");
384 assert!(
385 last.start_nm < leg.length_nm(),
386 "a piece started past the end of the leg"
387 );
388 }
389
390 #[test]
391 fn a_piece_only_covers_its_own_stretch() {
392 let leg = leg();
396 let first = &leg.segments()[0];
397 let far_end = Point::new(leg.to.lon_deg, leg.to.lat_deg);
398 assert!(!first.band.intersects(&far_end));
399 }
400}