use std::rc::Rc;
use geo::{
Contains, Distance, Geodesic, Intersects, LineIntersection, LineLocatePoint, LineString, Point,
};
use log::trace;
use rstar::RTreeObject;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::fp::ClimbDescentPerformance;
use crate::measurements::{Length, LengthUnit, Speed};
use crate::nd::{Airspace, Fix, NavAid, NavigationData};
use crate::VerticalDistance;
use super::{Leg, Route};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AirspaceIntersection {
airspace: Rc<Airspace>,
entry_distance: Length,
exit_distance: Length,
entry_point: Point<f64>,
exit_point: Point<f64>,
}
impl AirspaceIntersection {
pub fn airspace(&self) -> &Airspace {
&self.airspace
}
pub fn entry_distance(&self) -> &Length {
&self.entry_distance
}
pub fn exit_distance(&self) -> &Length {
&self.exit_distance
}
pub fn entry_point(&self) -> &Point<f64> {
&self.entry_point
}
pub fn exit_point(&self) -> &Point<f64> {
&self.exit_point
}
pub fn floor(&self) -> &VerticalDistance {
&self.airspace.floor
}
pub fn ceiling(&self) -> &VerticalDistance {
&self.airspace.ceiling
}
pub fn length(&self) -> Length {
self.exit_distance - self.entry_distance
}
}
#[derive(Clone, PartialEq, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum VerticalPoint {
BeginOfClimb {
level: VerticalDistance,
distance: Length,
},
TopOfClimb {
level: VerticalDistance,
distance: Length,
},
NavAid {
level: Option<VerticalDistance>,
distance: Length,
navaid: NavAid,
overflow: bool,
},
TopOfDescent {
level: VerticalDistance,
distance: Length,
},
EndOfDescent {
level: VerticalDistance,
distance: Length,
},
}
impl VerticalPoint {
pub fn level(&self) -> Option<&VerticalDistance> {
match self {
Self::BeginOfClimb { level, .. } => Some(level),
Self::TopOfClimb { level, .. } => Some(level),
Self::NavAid { level, .. } => level.as_ref(),
Self::TopOfDescent { level, .. } => Some(level),
Self::EndOfDescent { level, .. } => Some(level),
}
}
pub fn distance(&self) -> &Length {
match self {
Self::BeginOfClimb { distance, .. } => distance,
Self::TopOfClimb { distance, .. } => distance,
Self::NavAid { distance, .. } => distance,
Self::TopOfDescent { distance, .. } => distance,
Self::EndOfDescent { distance, .. } => distance,
}
}
}
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct VerticalProfile {
intersections: Vec<AirspaceIntersection>,
profile: Vec<VerticalPoint>,
}
impl VerticalProfile {
pub fn new(
route: &Route,
nd: &NavigationData,
climb: Option<&ClimbDescentPerformance>,
descent: Option<&ClimbDescentPerformance>,
) -> Self {
let legs = route.legs();
if legs.is_empty() {
return Self::default();
}
let route_coords: Vec<geo::Coord<f64>> = std::iter::once(legs[0].from().coordinate())
.chain(legs.iter().map(|leg| leg.to().coordinate()))
.map(Into::into)
.collect();
let route_line = LineString::new(route_coords);
let segment_lengths: Vec<Length> = route_line
.lines()
.map(|line| {
Length::m(Geodesic.distance(Point::from(line.start), Point::from(line.end)) as f32)
})
.collect();
let total_length: Length = segment_lengths.iter().copied().sum();
let route_envelope = route_line.envelope();
let candidates = nd.candidate_airspaces_for_envelope(&route_envelope);
let mut intersections = Vec::new();
for airspace in &candidates {
if !route_line.intersects(&airspace.polygon) {
continue;
}
intersections.extend(Self::compute_intersections(
Rc::clone(airspace),
&route_line,
&segment_lengths,
total_length,
));
}
intersections.sort_by(|a, b| {
a.entry_distance()
.partial_cmp(b.entry_distance())
.unwrap_or(std::cmp::Ordering::Equal)
});
let profile = Self::compute_profile(route, climb, descent);
Self {
intersections,
profile,
}
}
fn compute_intersections(
airspace: Rc<Airspace>,
route_line: &LineString<f64>,
segment_lengths: &[Length],
total_length: Length,
) -> Vec<AirspaceIntersection> {
let geo_polygon = &airspace.polygon;
let coords: Vec<_> = route_line.coords().collect();
if coords.is_empty() {
return Vec::new();
}
let first_inside = geo_polygon.contains(&Point::new(coords[0].x, coords[0].y));
let last_inside = geo_polygon.contains(&Point::new(
coords[coords.len() - 1].x,
coords[coords.len() - 1].y,
));
let intersection_points = Self::compute_segment_intersections(route_line, geo_polygon);
let mut crossings: Vec<(Length, geo::Coord<f64>)> = intersection_points
.into_iter()
.map(|(seg_idx, coord)| {
let dist =
geodesic_distance_to_intersection(seg_idx, &coord, route_line, segment_lengths);
(dist, coord)
})
.collect();
crossings.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
crossings.dedup_by(|a, b| (a.0 - b.0).abs() < Length::m(1.0));
let mut transitions: Vec<(Length, geo::Coord<f64>)> = Vec::new();
if first_inside {
transitions.push((Length::m(0.0), *coords[0]));
}
transitions.extend(crossings);
if last_inside {
transitions.push((total_length, *coords[coords.len() - 1]));
}
let mut intersections = Vec::new();
let mut i = 0;
while i + 1 < transitions.len() {
let (entry_dist, entry_coord) = transitions[i];
let (exit_dist, exit_coord) = transitions[i + 1];
intersections.push(AirspaceIntersection {
airspace: Rc::clone(&airspace),
entry_distance: entry_dist.convert_to(LengthUnit::NauticalMiles),
exit_distance: exit_dist.convert_to(LengthUnit::NauticalMiles),
entry_point: Point::new(entry_coord.x, entry_coord.y),
exit_point: Point::new(exit_coord.x, exit_coord.y),
});
i += 2;
}
intersections
}
fn compute_segment_intersections(
route_line: &LineString<f64>,
polygon: &geo::Polygon<f64>,
) -> Vec<(usize, geo::Coord<f64>)> {
let mut intersections = Vec::new();
let boundary = polygon.exterior();
for (seg_idx, route_segment) in route_line.lines().enumerate() {
for boundary_segment in boundary.lines() {
if let Some(intersection) =
geo::line_intersection::line_intersection(route_segment, boundary_segment)
{
match intersection {
LineIntersection::SinglePoint { intersection, .. } => {
intersections.push((seg_idx, intersection));
}
LineIntersection::Collinear { intersection } => {
intersections.push((seg_idx, intersection.start));
intersections.push((seg_idx, intersection.end));
}
}
}
}
}
intersections
}
fn compute_profile(
route: &Route,
climb_perf: Option<&ClimbDescentPerformance>,
descent_perf: Option<&ClimbDescentPerformance>,
) -> Vec<VerticalPoint> {
let legs = route.legs();
if legs.is_empty() {
return Vec::new();
}
let mut profile = Vec::new();
let mut prev_level: Option<VerticalDistance> = route.origin().map(|o| o.elevation);
if let Some(origin) = route.origin() {
profile.push(VerticalPoint::NavAid {
level: Some(origin.elevation),
distance: Length::nm(0.0),
navaid: NavAid::Airport(origin),
overflow: false,
});
}
let accumulated: Vec<_> = route.accumulate_legs(None).collect();
let total_route_dist = accumulated
.last()
.map(|t| *t.dist())
.unwrap_or(Length::nm(0.0));
for (leg, totals) in legs.iter().zip(accumulated.iter()) {
let total_dist = *totals.dist();
let from_dist = total_dist - *leg.dist();
let cd_to = leg.climb_descent().to();
let cd_reach_at = leg.climb_descent().reach_at();
let cd_from = leg.climb_descent().from().copied().or(prev_level);
let mut overflow = false;
if let (Some(level), Some(prev)) = (cd_to, cd_from) {
let is_climb = *level > prev;
let perf = if is_climb { climb_perf } else { descent_perf };
if let Some(dist) = perf.and_then(|p| transition_distance(p, &prev, level, leg)) {
let level_of_dist = from_dist + dist;
profile.push(if is_climb {
VerticalPoint::TopOfClimb {
level: *level,
distance: level_of_dist,
}
} else {
VerticalPoint::EndOfDescent {
level: *level,
distance: level_of_dist,
}
});
overflow = level_of_dist > total_dist || level_of_dist >= total_route_dist;
}
prev_level = Some(*level);
}
if let (Some(level), Some(prev)) = (cd_reach_at, prev_level) {
trace!("change level to reach {} at {}", leg.to().ident(), level);
let is_climb = *level > prev;
let perf = if is_climb { climb_perf } else { descent_perf };
if let Some(dist) = perf.and_then(|p| transition_distance(p, &prev, level, leg)) {
let level_of_dist = total_dist - dist;
trace!(
"begin climb/descent from {prev} to {level} at {:.1} along the route",
level_of_dist
);
profile.push(if is_climb {
VerticalPoint::BeginOfClimb {
level: prev,
distance: level_of_dist,
}
} else {
VerticalPoint::TopOfDescent {
level: prev,
distance: level_of_dist,
}
});
overflow = level_of_dist < from_dist || *level_of_dist.value() <= 0.0;
}
prev_level = Some(*level);
}
if let Some(prev) = prev_level {
profile.push(VerticalPoint::NavAid {
level: if overflow { None } else { Some(prev) },
distance: total_dist,
navaid: leg.to().clone(),
overflow,
});
}
}
if let Some(dest) = route.destination() {
let already_emitted = profile.last().is_some_and(|p| {
matches!(p, VerticalPoint::NavAid { distance, .. } if *distance == total_route_dist)
});
if !already_emitted {
profile.push(VerticalPoint::NavAid {
level: Some(dest.elevation),
distance: total_route_dist,
navaid: NavAid::Airport(dest),
overflow: false,
});
}
}
profile
}
pub fn profile(&self) -> &[VerticalPoint] {
&self.profile
}
pub fn intersections(&self) -> &[AirspaceIntersection] {
&self.intersections
}
pub fn max_level(&self) -> Option<&VerticalDistance> {
self.profile
.iter()
.filter_map(|point| point.level())
.filter(|level| {
matches!(
level,
VerticalDistance::Fl(_)
| VerticalDistance::Msl(_)
| VerticalDistance::Altitude(_)
| VerticalDistance::Gnd
| VerticalDistance::Unlimited
)
})
.max_by(|a, b| a.cmp(b))
}
pub fn len(&self) -> usize {
self.intersections.len()
}
pub fn is_empty(&self) -> bool {
self.intersections.is_empty()
}
}
fn transition_distance(
perf: &ClimbDescentPerformance,
from: &VerticalDistance,
to: &VerticalDistance,
leg: &Leg,
) -> Option<Length> {
let (lo, hi) = if from < to { (from, to) } else { (to, from) };
let hw = leg.headwind().unwrap_or(Speed::kt(0.0));
perf.between(lo, hi)
.map(|r| r.with_wind(hw).horizontal_distance)
}
fn geodesic_distance_to_intersection(
seg_idx: usize,
coord: &geo::Coord<f64>,
route_line: &LineString<f64>,
segment_lengths: &[Length],
) -> Length {
let prior: Length = segment_lengths[..seg_idx].iter().copied().sum();
let segment = route_line
.lines()
.nth(seg_idx)
.expect("valid segment index");
let point = Point::new(coord.x, coord.y);
let fraction = segment.line_locate_point(&point).unwrap_or(0.0) as f32;
prior + segment_lengths[seg_idx] * fraction
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nd::{AirspaceClassification, AirspaceType};
fn test_airspace(name: &str, coords: &[(f64, f64)]) -> Rc<Airspace> {
let exterior: Vec<geo::Coord<f64>> = coords
.iter()
.map(|&(lat, lon)| geo::Coord { x: lon, y: lat })
.collect();
Rc::new(Airspace {
name: name.to_string(),
airspace_type: AirspaceType::CTA,
classification: Some(AirspaceClassification::D),
ceiling: VerticalDistance::Fl(65),
floor: VerticalDistance::Msl(1500),
polygon: geo::Polygon::new(geo::LineString::from(exterior), vec![]),
})
}
fn route_lengths(route_line: &LineString<f64>) -> (Vec<Length>, Length) {
let segment_lengths: Vec<Length> = route_line
.lines()
.map(|line| {
Length::m(Geodesic.distance(Point::from(line.start), Point::from(line.end)) as f32)
})
.collect();
let total_length: Length = segment_lengths.iter().copied().sum();
(segment_lengths, total_length)
}
#[test]
fn empty_route_produces_empty_profile() {
let nd = NavigationData::new();
let route = Route::new();
let profile = VerticalProfile::new(&route, &nd, None, None);
assert!(profile.is_empty());
}
#[test]
fn profile_finds_airspace_intersection() {
use crate::nd::NavigationDataBuilder;
let airspace = Airspace {
name: "Test TMA".to_string(),
airspace_type: AirspaceType::CTA,
classification: Some(AirspaceClassification::D),
ceiling: VerticalDistance::Fl(65),
floor: VerticalDistance::Msl(1500),
polygon: {
let coords: Vec<geo::Coord<f64>> = [
(53.0, 9.0),
(53.0, 10.0),
(54.0, 10.0),
(54.0, 9.0),
(53.0, 9.0),
]
.iter()
.map(|&(lat, lon)| geo::Coord { x: lon, y: lat })
.collect();
geo::Polygon::new(geo::LineString::from(coords), vec![])
},
};
let mut builder = NavigationDataBuilder::new();
builder.add_airspace(airspace);
let nd = builder.build();
let route = Route::new();
let profile = VerticalProfile::new(&route, &nd, None, None);
assert!(profile.is_empty());
}
#[test]
fn route_starting_inside_airspace_has_boundary_exit() {
let ctr_hamburg = test_airspace(
"CTR Hamburg",
&[
(53.5, 9.7),
(53.5, 10.2),
(53.8, 10.2),
(53.8, 9.7),
(53.5, 9.7),
],
);
let route_line = LineString::new(vec![
geo::Coord { x: 9.99, y: 53.63 }, geo::Coord { x: 10.70, y: 53.81 }, ]);
let (segment_lengths, total_length) = route_lengths(&route_line);
let intersections = VerticalProfile::compute_intersections(
ctr_hamburg,
&route_line,
&segment_lengths,
total_length,
);
assert_eq!(intersections.len(), 1, "Should find one intersection");
let intersection = &intersections[0];
assert!(
(intersection.entry_point().x() - 9.99).abs() < 0.01,
"Entry should be at EDDH lon, got {}",
intersection.entry_point().x()
);
assert!(
(intersection.entry_point().y() - 53.63).abs() < 0.01,
"Entry should be at EDDH lat, got {}",
intersection.entry_point().y()
);
assert!(
*intersection.entry_distance().value() < 0.01,
"Entry distance should be 0"
);
assert!(
(intersection.exit_point().x() - 10.2).abs() < 0.05,
"Exit should be near CTR east boundary lon 10.2, got {}",
intersection.exit_point().x()
);
assert!(
*intersection.exit_distance().value() > 1.0,
"Exit distance should be well past 0 nm, got {} nm",
intersection.exit_distance().value()
);
}
#[test]
fn route_ending_inside_airspace_has_boundary_entry() {
let ctr_luebeck = test_airspace(
"CTR Luebeck",
&[
(53.7, 10.5),
(53.7, 10.9),
(53.95, 10.9),
(53.95, 10.5),
(53.7, 10.5),
],
);
let route_line = LineString::new(vec![
geo::Coord { x: 9.99, y: 53.63 }, geo::Coord { x: 10.70, y: 53.81 }, ]);
let (segment_lengths, total_length) = route_lengths(&route_line);
let intersections = VerticalProfile::compute_intersections(
ctr_luebeck,
&route_line,
&segment_lengths,
total_length,
);
assert_eq!(intersections.len(), 1, "Should find one intersection");
let intersection = &intersections[0];
assert!(
(intersection.entry_point().x() - 10.5).abs() < 0.05,
"Entry should be near CTR west boundary lon 10.5, got {}",
intersection.entry_point().x()
);
assert!(
*intersection.entry_distance().value() > 1.0,
"Entry distance should be well past 0 nm, got {} nm",
intersection.entry_distance().value()
);
assert!(
(intersection.exit_point().x() - 10.70).abs() < 0.01,
"Exit should be at EDHL lon, got {}",
intersection.exit_point().x()
);
assert!(
(intersection.exit_point().y() - 53.81).abs() < 0.01,
"Exit should be at EDHL lat, got {}",
intersection.exit_point().y()
);
}
#[test]
fn cross_through_leg_finds_intersection() {
let airspace = test_airspace(
"Cross-Through Test",
&[
(53.0, 9.0),
(53.0, 10.0),
(54.0, 10.0),
(54.0, 9.0),
(53.0, 9.0),
],
);
let route_line = LineString::new(vec![
geo::Coord { x: 8.0, y: 53.5 },
geo::Coord { x: 11.0, y: 53.5 },
]);
let (segment_lengths, total_length) = route_lengths(&route_line);
let intersections = VerticalProfile::compute_intersections(
airspace.clone(),
&route_line,
&segment_lengths,
total_length,
);
assert_eq!(
intersections.len(),
1,
"Should detect cross-through intersection"
);
let intersection = &intersections[0];
let entry_fraction = 1.0 / 3.0;
let exit_fraction = 2.0 / 3.0;
assert!(
(intersection.entry_point().x() - 9.0).abs() < 0.01,
"Entry longitude should be ~9.0, got {}",
intersection.entry_point().x()
);
assert!(
(intersection.entry_point().y() - 53.5).abs() < 0.01,
"Entry latitude should be ~53.5, got {}",
intersection.entry_point().y()
);
assert!(
(intersection.exit_point().x() - 10.0).abs() < 0.01,
"Exit longitude should be ~10.0, got {}",
intersection.exit_point().x()
);
assert!(
(intersection.exit_point().y() - 53.5).abs() < 0.01,
"Exit latitude should be ~53.5, got {}",
intersection.exit_point().y()
);
let expected_entry = total_length * entry_fraction as f32;
let expected_exit = total_length * exit_fraction as f32;
let tolerance = Length::nm(1.0);
assert!(
(*intersection.entry_distance() - expected_entry).abs() < tolerance,
"Entry distance should be ~{}, got {}",
expected_entry,
intersection.entry_distance()
);
assert!(
(*intersection.exit_distance() - expected_exit).abs() < tolerance,
"Exit distance should be ~{}, got {}",
expected_exit,
intersection.exit_distance()
);
let expected_length = expected_exit - expected_entry;
assert!(
(intersection.length() - expected_length).abs() < tolerance,
"Intersection length should be ~{}, got {}",
expected_length,
intersection.length()
);
}
}