Skip to main content

navcore_enc_store/
corridor.rs

1//! The band of water a leg actually occupies.
2//!
3//! A vessel is allowed to be off track by the leg's own cross-track
4//! limit, so checking only the drawn line would pass a route that
5//! threads between two rocks but cannot actually be steered. This
6//! builds the band of water either side of the track that must be
7//! clear -- the same check an ECDIS performs.
8//!
9//! Port and starboard are tracked separately: a buoyed channel can
10//! have, for example, fifty metres of clearance to starboard and
11//! twenty to port, which a single symmetric half-width cannot express.
12//! IEC 61174 tracks them separately for the same reason.
13
14use geo::{Coord, LineString, Polygon};
15use nav_math::{Position, angle, great_circle, rhumb};
16
17/// Longest a piece of the band may be before it is broken up, in nautical
18/// miles.
19///
20/// The band's edges are offsets of a curved track, which is not
21/// straight in longitude and latitude. Approximating them as straight
22/// segments between distant corners would cut the corner inward on one
23/// side, hiding dangers there. A quarter mile keeps that error well
24/// below the band's own width, and sets the resolution at which a
25/// check reports where a danger was first encountered.
26pub(crate) const SEGMENT_NM: f64 = 0.25;
27
28/// How a leg is drawn between its two ends.
29///
30/// This crate has no dependency on the route domain, so a caller with
31/// a route supplies which sailing each leg used.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Sailing {
34    /// Constant bearing: a straight line on a Mercator projection, the
35    /// default representation for a plotted leg.
36    #[default]
37    Rhumb,
38    /// The shortest path over the sphere; its bearing changes
39    /// continuously. The difference from a rhumb line is significant
40    /// only on legs of hundreds of miles, where checking the rhumb
41    /// line instead would check the wrong water.
42    GreatCircle,
43}
44
45/// One leg of a route, with the water it is allowed to wander into.
46///
47/// Known limitation: a leg crossing the antimeridian is not handled.
48/// The band is built in longitude and latitude, so a leg from 179°E to
49/// 179°W would be drawn the long way around the world, checking the
50/// wrong hemisphere. Splitting such a leg at 180° and checking the
51/// halves separately would fix this; not yet implemented.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Leg {
54    /// Where the leg starts.
55    pub from: Position,
56    /// Where it ends.
57    pub to: Position,
58    /// How far the vessel may stray to port of the track, in nautical miles.
59    pub port_xtd_nm: f64,
60    /// The same to starboard.
61    pub starboard_xtd_nm: f64,
62    /// How the leg is drawn.
63    pub sailing: Sailing,
64}
65
66/// A short piece of a leg, with the band around that piece.
67#[derive(Debug, Clone)]
68pub struct Segment {
69    /// Distance from the start of the leg to where this piece begins.
70    pub start_nm: f64,
71    /// Where this piece begins, on the leg itself.
72    pub start: Position,
73    /// The band of water around this piece.
74    pub band: Polygon<f64>,
75}
76
77impl Leg {
78    /// A leg with the same tolerance to port and starboard, for a
79    /// caller with a single corridor figure and no route to supply
80    /// per-side values.
81    #[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    /// The leg's length, measured the way the leg is drawn.
93    #[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    /// The tighter of the two tolerances -- the precision the chart
102    /// data must resolve to, regardless of how much room the wider
103    /// side allows.
104    #[must_use]
105    pub fn narrowest_xtd_nm(&self) -> f64 {
106        self.port_xtd_nm.min(self.starboard_xtd_nm)
107    }
108
109    /// The point `along_nm` from the start, on the track itself.
110    #[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 the departure bearing of a great circle *is* sailing
117            // the great circle; the bearing only reads differently further
118            // along, which is what course_at is for.
119            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    /// The course being steered at a point on the track, degrees true.
128    ///
129    /// Constant on a rhumb line; changes continuously on a great
130    /// circle, so the band is offset from the local course at each
131    /// point rather than from a single bearing for the whole leg.
132    #[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                // At the far end there is no remaining leg to take a
138                // bearing along, so the arrival bearing is the answer.
139                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    /// The whole band, as one polygon, for the R-Tree index lookup,
149    /// which requires a single bounding box.
150    ///
151    /// `None` for a leg of zero length (no direction, hence no sides)
152    /// or zero tolerance.
153    #[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    /// The leg cut into pieces, in sailing order.
163    ///
164    /// Checking piece by piece lets a check report the distance along
165    /// the leg where a hazard is first met, rather than only that one
166    /// exists.
167    #[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    /// The band around the stretch of leg between two distances along
189    /// it.
190    ///
191    /// Squared off at both ends rather than rounded: the water swept
192    /// while turning from one leg to the next depends on speed and
193    /// turn rate, which this function does not model, so it makes no
194    /// claim about turn areas.
195    fn band_between(&self, start_nm: f64, end_nm: f64) -> Polygon<f64> {
196        // Walk the centre line at the segment resolution, so a long stretch
197        // keeps its shape instead of becoming a single sagging quadrilateral.
198        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        // Up the port side and back down the starboard one.
210        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
224/// One position offset sideways, as a coordinate the geometry crate wants:
225/// longitude first, which is the opposite order to a [`Position`].
226fn 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        // Zero tolerance is not "check the line exactly", it is a setting
258        // that cannot be honoured. Better to have no band than a band of
259        // nothing that quietly passes every route.
260        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        // Tested with the same predicate the check uses. The two ends of the
268        // leg sit exactly on the band's boundary -- the band is squared off
269        // at the waypoints, it does not reach round them -- and `contains`
270        // excludes a boundary while `intersects` includes it. A rock at the
271        // waypoint has to be found, so `intersects` is the right question
272        // here and in the check.
273        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        // The channel case: room to starboard, none to port. A band that
301        // averaged the two would approve water on the wrong side and
302        // condemn water on the right one.
303        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        // The point of carrying the sailing at all. On an ocean leg the two
349        // tracks are far apart, so a band built on the wrong one checks
350        // water the vessel never crosses -- and misses the water it does.
351        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        // The whole point of the pieces is telling *where* something was
393        // met, so a piece that reached the far end of the leg would make
394        // every report say "at zero miles".
395        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}