Skip to main content

aerocontext_planning/
corridor.rs

1//! Route corridors: a buffered polygon around a centerline, plus the
2//! leg-wise spatial predicates weather/TFR/NOTAM association uses.
3//!
4//! The display polygon is built by offsetting each centerline vertex
5//! perpendicular to the local bearing (miter-clamped joins, semicircular
6//! end caps). Spatial truth is deliberately **leg-wise**, not
7//! polygon-wise: [`Corridor::contains`] and
8//! [`Corridor::distance_to_centerline_nm`] evaluate cross-track/along-
9//! track per leg, which stays correct even where a hairpin makes the
10//! display ring self-intersect.
11
12use aerocontext_core::{Area, GeoPoint, geo};
13
14use crate::route::ExpandedRoute;
15
16/// Segments per semicircular end cap.
17const CAP_SEGMENTS: u32 = 8;
18/// Miter length clamp, in multiples of the half-width.
19const MITER_LIMIT: f64 = 2.5;
20
21/// Failure constructing a corridor.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum CorridorError {
25    /// A corridor needs at least two centerline points.
26    #[error("corridor needs at least 2 centerline points, got {got}")]
27    TooFewPoints {
28        /// Points provided.
29        got: usize,
30    },
31    /// The half-width must be positive and finite.
32    #[error("invalid corridor half-width {half_width_nm} NM")]
33    InvalidWidth {
34        /// The offending width.
35        half_width_nm: f64,
36    },
37}
38
39/// A buffered route corridor.
40#[derive(Debug, Clone, PartialEq)]
41#[non_exhaustive]
42pub struct Corridor {
43    /// The route centerline.
44    pub centerline: Vec<GeoPoint>,
45    /// Buffer half-width in nautical miles.
46    pub half_width_nm: f64,
47    /// Display ring (implicitly closed), built left side forward, end
48    /// cap, right side reversed, start cap.
49    pub polygon: Vec<GeoPoint>,
50}
51
52impl Corridor {
53    /// Corridor around an expanded route's points.
54    pub fn around_route(route: &ExpandedRoute, half_width_nm: f64) -> Result<Self, CorridorError> {
55        let centerline: Vec<GeoPoint> = route.points.iter().map(|point| point.position).collect();
56        Self::around_points(&centerline, half_width_nm)
57    }
58
59    /// Corridor around an explicit polyline.
60    pub fn around_points(
61        centerline: &[GeoPoint],
62        half_width_nm: f64,
63    ) -> Result<Self, CorridorError> {
64        if centerline.len() < 2 {
65            return Err(CorridorError::TooFewPoints {
66                got: centerline.len(),
67            });
68        }
69        if !(half_width_nm.is_finite() && half_width_nm > 0.0) {
70            return Err(CorridorError::InvalidWidth { half_width_nm });
71        }
72        let polygon = build_ring(centerline, half_width_nm);
73        Ok(Self {
74            centerline: centerline.to_vec(),
75            half_width_nm,
76            polygon,
77        })
78    }
79
80    /// Whether `point` lies within the corridor — leg-wise truth.
81    pub fn contains(&self, point: GeoPoint) -> bool {
82        self.distance_to_centerline_nm(point) <= self.half_width_nm
83    }
84
85    /// Great-circle distance from `point` to the nearest piece of the
86    /// centerline, nautical miles.
87    pub fn distance_to_centerline_nm(&self, point: GeoPoint) -> f64 {
88        let mut best = f64::INFINITY;
89        for leg in self.centerline.windows(2) {
90            best = best.min(leg_distance_nm(point, leg[0], leg[1]));
91        }
92        best
93    }
94
95    /// The corridor as a core [`Area::Polygon`] — the hand-off to modules
96    /// that filter geo-referenced products (TFRs, NOTAM shapes, weather)
97    /// or derive a provider bbox.
98    pub fn area(&self) -> Area {
99        Area::Polygon {
100            vertices: self.polygon.clone(),
101        }
102    }
103}
104
105/// Distance from a point to one leg: |cross-track| when the projection
106/// falls within the leg, else the nearer endpoint distance.
107fn leg_distance_nm(point: GeoPoint, start: GeoPoint, end: GeoPoint) -> f64 {
108    let leg_length = geo::distance_nm(start, end);
109    if leg_length < 1e-9 {
110        return geo::distance_nm(point, start);
111    }
112    let along = geo::along_track_nm(point, start, end);
113    if along <= 0.0 {
114        geo::distance_nm(point, start)
115    } else if along >= leg_length {
116        geo::distance_nm(point, end)
117    } else {
118        geo::cross_track_nm(point, start, end).abs()
119    }
120}
121
122fn build_ring(centerline: &[GeoPoint], half_width_nm: f64) -> Vec<GeoPoint> {
123    let mut left = Vec::with_capacity(centerline.len());
124    let mut right = Vec::with_capacity(centerline.len());
125    let last = centerline.len() - 1;
126    for (index, vertex) in centerline.iter().enumerate() {
127        let inbound = if index > 0 {
128            Some(geo::initial_bearing_deg(centerline[index - 1], *vertex))
129        } else {
130            None
131        };
132        let outbound = if index < last {
133            Some(geo::initial_bearing_deg(*vertex, centerline[index + 1]))
134        } else {
135            None
136        };
137        let (offset_bearing, scale) = join_offset(inbound, outbound);
138        let distance = (half_width_nm * scale).min(half_width_nm * MITER_LIMIT);
139        left.push(geo::destination(*vertex, offset_bearing - 90.0, distance));
140        right.push(geo::destination(*vertex, offset_bearing + 90.0, distance));
141    }
142
143    let mut ring = left;
144    // End cap: sweep from the left offset around the terminus to the
145    // right offset.
146    let end_heading = geo::initial_bearing_deg(centerline[last - 1], centerline[last]);
147    ring.extend(cap(centerline[last], end_heading - 90.0, half_width_nm));
148    ring.extend(right.into_iter().rev());
149    // Start cap: sweep around the origin back toward the left side.
150    let start_heading = geo::initial_bearing_deg(centerline[0], centerline[1]);
151    ring.extend(cap(centerline[0], start_heading + 90.0, half_width_nm));
152    ring
153}
154
155/// The mean tangent bearing at a vertex plus the miter scale factor
156/// 1/cos(turn/2) for the join.
157fn join_offset(inbound: Option<f64>, outbound: Option<f64>) -> (f64, f64) {
158    match (inbound, outbound) {
159        (Some(inbound), Some(outbound)) => {
160            let mut delta = outbound - inbound;
161            while delta > 180.0 {
162                delta -= 360.0;
163            }
164            while delta < -180.0 {
165                delta += 360.0;
166            }
167            let mean = inbound + delta / 2.0;
168            let scale = 1.0
169                / (delta.to_radians() / 2.0)
170                    .cos()
171                    .abs()
172                    .max(1.0 / MITER_LIMIT);
173            (mean, scale)
174        }
175        (Some(bearing), None) | (None, Some(bearing)) => (bearing, 1.0),
176        (None, None) => (0.0, 1.0),
177    }
178}
179
180/// A semicircular cap around `center`, sweeping 180° clockwise from
181/// `from_bearing`.
182fn cap(center: GeoPoint, from_bearing: f64, radius_nm: f64) -> Vec<GeoPoint> {
183    (1..CAP_SEGMENTS)
184        .map(|step| {
185            let bearing = from_bearing + 180.0 * f64::from(step) / f64::from(CAP_SEGMENTS);
186            geo::destination(center, bearing, radius_nm)
187        })
188        .collect()
189}
190
191#[cfg(test)]
192mod tests;