aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
//! Route corridors: a buffered polygon around a centerline, plus the
//! leg-wise spatial predicates weather/TFR/NOTAM association uses.
//!
//! The display polygon is built by offsetting each centerline vertex
//! perpendicular to the local bearing (miter-clamped joins, semicircular
//! end caps). Spatial truth is deliberately **leg-wise**, not
//! polygon-wise: [`Corridor::contains`] and
//! [`Corridor::distance_to_centerline_nm`] evaluate cross-track/along-
//! track per leg, which stays correct even where a hairpin makes the
//! display ring self-intersect.

use aerocontext_core::{Area, GeoPoint, geo};

use crate::route::ExpandedRoute;

/// Segments per semicircular end cap.
const CAP_SEGMENTS: u32 = 8;
/// Miter length clamp, in multiples of the half-width.
const MITER_LIMIT: f64 = 2.5;

/// Failure constructing a corridor.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CorridorError {
    /// A corridor needs at least two centerline points.
    #[error("corridor needs at least 2 centerline points, got {got}")]
    TooFewPoints {
        /// Points provided.
        got: usize,
    },
    /// The half-width must be positive and finite.
    #[error("invalid corridor half-width {half_width_nm} NM")]
    InvalidWidth {
        /// The offending width.
        half_width_nm: f64,
    },
}

/// A buffered route corridor.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Corridor {
    /// The route centerline.
    pub centerline: Vec<GeoPoint>,
    /// Buffer half-width in nautical miles.
    pub half_width_nm: f64,
    /// Display ring (implicitly closed), built left side forward, end
    /// cap, right side reversed, start cap.
    pub polygon: Vec<GeoPoint>,
}

impl Corridor {
    /// Corridor around an expanded route's points.
    pub fn around_route(route: &ExpandedRoute, half_width_nm: f64) -> Result<Self, CorridorError> {
        let centerline: Vec<GeoPoint> = route.points.iter().map(|point| point.position).collect();
        Self::around_points(&centerline, half_width_nm)
    }

    /// Corridor around an explicit polyline.
    pub fn around_points(
        centerline: &[GeoPoint],
        half_width_nm: f64,
    ) -> Result<Self, CorridorError> {
        if centerline.len() < 2 {
            return Err(CorridorError::TooFewPoints {
                got: centerline.len(),
            });
        }
        if !(half_width_nm.is_finite() && half_width_nm > 0.0) {
            return Err(CorridorError::InvalidWidth { half_width_nm });
        }
        let polygon = build_ring(centerline, half_width_nm);
        Ok(Self {
            centerline: centerline.to_vec(),
            half_width_nm,
            polygon,
        })
    }

    /// Whether `point` lies within the corridor — leg-wise truth.
    pub fn contains(&self, point: GeoPoint) -> bool {
        self.distance_to_centerline_nm(point) <= self.half_width_nm
    }

    /// Great-circle distance from `point` to the nearest piece of the
    /// centerline, nautical miles.
    pub fn distance_to_centerline_nm(&self, point: GeoPoint) -> f64 {
        let mut best = f64::INFINITY;
        for leg in self.centerline.windows(2) {
            best = best.min(leg_distance_nm(point, leg[0], leg[1]));
        }
        best
    }

    /// The corridor as a core [`Area::Polygon`] — the hand-off to modules
    /// that filter geo-referenced products (TFRs, NOTAM shapes, weather)
    /// or derive a provider bbox.
    pub fn area(&self) -> Area {
        Area::Polygon {
            vertices: self.polygon.clone(),
        }
    }
}

/// Distance from a point to one leg: |cross-track| when the projection
/// falls within the leg, else the nearer endpoint distance.
fn leg_distance_nm(point: GeoPoint, start: GeoPoint, end: GeoPoint) -> f64 {
    let leg_length = geo::distance_nm(start, end);
    if leg_length < 1e-9 {
        return geo::distance_nm(point, start);
    }
    let along = geo::along_track_nm(point, start, end);
    if along <= 0.0 {
        geo::distance_nm(point, start)
    } else if along >= leg_length {
        geo::distance_nm(point, end)
    } else {
        geo::cross_track_nm(point, start, end).abs()
    }
}

fn build_ring(centerline: &[GeoPoint], half_width_nm: f64) -> Vec<GeoPoint> {
    let mut left = Vec::with_capacity(centerline.len());
    let mut right = Vec::with_capacity(centerline.len());
    let last = centerline.len() - 1;
    for (index, vertex) in centerline.iter().enumerate() {
        let inbound = if index > 0 {
            Some(geo::initial_bearing_deg(centerline[index - 1], *vertex))
        } else {
            None
        };
        let outbound = if index < last {
            Some(geo::initial_bearing_deg(*vertex, centerline[index + 1]))
        } else {
            None
        };
        let (offset_bearing, scale) = join_offset(inbound, outbound);
        let distance = (half_width_nm * scale).min(half_width_nm * MITER_LIMIT);
        left.push(geo::destination(*vertex, offset_bearing - 90.0, distance));
        right.push(geo::destination(*vertex, offset_bearing + 90.0, distance));
    }

    let mut ring = left;
    // End cap: sweep from the left offset around the terminus to the
    // right offset.
    let end_heading = geo::initial_bearing_deg(centerline[last - 1], centerline[last]);
    ring.extend(cap(centerline[last], end_heading - 90.0, half_width_nm));
    ring.extend(right.into_iter().rev());
    // Start cap: sweep around the origin back toward the left side.
    let start_heading = geo::initial_bearing_deg(centerline[0], centerline[1]);
    ring.extend(cap(centerline[0], start_heading + 90.0, half_width_nm));
    ring
}

/// The mean tangent bearing at a vertex plus the miter scale factor
/// 1/cos(turn/2) for the join.
fn join_offset(inbound: Option<f64>, outbound: Option<f64>) -> (f64, f64) {
    match (inbound, outbound) {
        (Some(inbound), Some(outbound)) => {
            let mut delta = outbound - inbound;
            while delta > 180.0 {
                delta -= 360.0;
            }
            while delta < -180.0 {
                delta += 360.0;
            }
            let mean = inbound + delta / 2.0;
            let scale = 1.0
                / (delta.to_radians() / 2.0)
                    .cos()
                    .abs()
                    .max(1.0 / MITER_LIMIT);
            (mean, scale)
        }
        (Some(bearing), None) | (None, Some(bearing)) => (bearing, 1.0),
        (None, None) => (0.0, 1.0),
    }
}

/// A semicircular cap around `center`, sweeping 180° clockwise from
/// `from_bearing`.
fn cap(center: GeoPoint, from_bearing: f64, radius_nm: f64) -> Vec<GeoPoint> {
    (1..CAP_SEGMENTS)
        .map(|step| {
            let bearing = from_bearing + 180.0 * f64::from(step) / f64::from(CAP_SEGMENTS);
            geo::destination(center, bearing, radius_nm)
        })
        .collect()
}

#[cfg(test)]
mod tests;