use std::collections::HashSet;
use crate::LandmassSource;
use crate::dynamics::get_segment_land_metres;
use crate::route_bounds::RouteBounds;
use crate::spherical::{LatLon, TangentMetres, delta_to_tangent_metres, haversine};
#[derive(Clone, Debug)]
pub struct PathBaseline<const N: usize> {
pub positions: [LatLon; N],
pub perpendiculars: [TangentMetres; N],
pub perp_scale_pos: [f64; N],
pub perp_scale_neg: [f64; N],
}
impl<const N: usize> PathBaseline<N> {
pub fn straight_line(bounds: &RouteBounds) -> Self {
let mut positions = [LatLon::default(); N];
for (i, slot) in positions.iter_mut().enumerate() {
let t = i as f64 / (N - 1) as f64;
*slot = bounds.lerp_between_endpoints(t);
}
positions[0] = bounds.origin;
positions[N - 1] = bounds.destination;
let perpendiculars = compute_perpendiculars(&positions);
let line_metres = haversine(positions[0], positions[N - 1]);
let symmetric = [line_metres * 0.5; N];
Self {
positions,
perpendiculars,
perp_scale_pos: symmetric,
perp_scale_neg: symmetric,
}
}
pub fn from_polyline<LS: LandmassSource>(
polyline: &[LatLon],
bounds: &RouteBounds,
landmass: &LS,
) -> Self {
let positions = sample_uniform_arclength::<N>(polyline, bounds);
let perpendiculars = compute_perpendiculars(&positions);
let (perp_scale_pos, perp_scale_neg) =
compute_perpendicular_clearance(&positions, &perpendiculars, landmass);
Self {
positions,
perpendiculars,
perp_scale_pos,
perp_scale_neg,
}
}
pub fn from_polyline_land_respecting<LS: LandmassSource>(
polyline: &[LatLon],
bounds: &RouteBounds,
landmass: &LS,
) -> Self {
let positions = sample_land_respecting::<N, LS>(polyline, bounds, landmass);
let perpendiculars = compute_perpendiculars(&positions);
let (perp_scale_pos, perp_scale_neg) =
compute_perpendicular_clearance(&positions, &perpendiculars, landmass);
Self {
positions,
perpendiculars,
perp_scale_pos,
perp_scale_neg,
}
}
}
fn sample_land_respecting<const N: usize, LS: LandmassSource>(
polyline: &[LatLon],
bounds: &RouteBounds,
landmass: &LS,
) -> [LatLon; N] {
let mut out = [LatLon::default(); N];
out[0] = bounds.origin;
out[N - 1] = bounds.destination;
if polyline.len() < 2 || N < 3 {
for (i, slot) in out.iter_mut().enumerate().skip(1).take(N.saturating_sub(2)) {
let t = i as f64 / (N - 1) as f64;
*slot = bounds.lerp_between_endpoints(t);
}
return out;
}
let cumulative = cumulative_arc_lengths(polyline);
let total = *cumulative.last().expect("polyline has >= 2 vertices");
if total <= 0.0 {
return sample_uniform_arclength::<N>(polyline, bounds);
}
let mut selected: Vec<(LatLon, f64)> =
vec![(polyline[0], 0.0), (polyline[polyline.len() - 1], total)];
let mut skip: HashSet<(u64, u64)> = HashSet::new();
let key_of = |a: f64, b: f64| (a.to_bits(), b.to_bits());
let step = bounds.step_distance_max;
while selected.len() < N {
let mut worst_land = 0.0_f64;
let mut worst_idx: Option<usize> = None;
for i in 0..selected.len() - 1 {
if skip.contains(&key_of(selected[i].1, selected[i + 1].1)) {
continue;
}
let land = get_segment_land_metres(landmass, selected[i].0, selected[i + 1].0, step);
if land > worst_land {
worst_land = land;
worst_idx = Some(i);
}
}
if let Some(i) = worst_idx {
let arc_a = selected[i].1;
let arc_b = selected[i + 1].1;
let pa = selected[i].0;
let pb = selected[i + 1].0;
if let Some((pos, arc, score)) =
best_break_candidate(polyline, &cumulative, arc_a, arc_b, pa, pb, landmass, step)
&& score < worst_land
{
selected.insert(i + 1, (pos, arc));
continue;
}
let target = (arc_a + arc_b) * 0.5;
if let Some((pos, arc)) = polyline_at_arc_length(polyline, &cumulative, target)
&& (arc - arc_a).abs() > 1e-9
&& (arc_b - arc).abs() > 1e-9
{
selected.insert(i + 1, (pos, arc));
continue;
}
skip.insert(key_of(arc_a, arc_b));
continue;
}
let mut longest_gap = 0.0_f64;
let mut longest_idx: Option<usize> = None;
for i in 0..selected.len() - 1 {
if skip.contains(&key_of(selected[i].1, selected[i + 1].1)) {
continue;
}
let gap = selected[i + 1].1 - selected[i].1;
if gap > longest_gap {
longest_gap = gap;
longest_idx = Some(i);
}
}
let Some(i) = longest_idx else {
break;
};
let arc_a = selected[i].1;
let arc_b = selected[i + 1].1;
let pa = selected[i].0;
let pb = selected[i + 1].0;
let target = (arc_a + arc_b) * 0.5;
let Some((pos, arc)) = polyline_at_arc_length(polyline, &cumulative, target) else {
skip.insert(key_of(arc_a, arc_b));
continue;
};
if (arc - arc_a).abs() < 1e-9 || (arc_b - arc).abs() < 1e-9 {
skip.insert(key_of(arc_a, arc_b));
continue;
}
let l_left = get_segment_land_metres(landmass, pa, pos, step);
let l_right = get_segment_land_metres(landmass, pos, pb, step);
if l_left > 0.0 || l_right > 0.0 {
skip.insert(key_of(arc_a, arc_b));
continue;
}
selected.insert(i + 1, (pos, arc));
}
if selected.len() < N {
return sample_uniform_arclength::<N>(polyline, bounds);
}
for (i, slot) in out.iter_mut().enumerate().take(N - 1).skip(1) {
*slot = selected[i].0;
}
out
}
#[expect(
clippy::too_many_arguments,
reason = "candidate search needs full segment context (endpoints, arcs, polyline, landmass)"
)]
fn best_break_candidate<LS: LandmassSource>(
polyline: &[LatLon],
cumulative: &[f64],
arc_a: f64,
arc_b: f64,
pa: LatLon,
pb: LatLon,
landmass: &LS,
step: f64,
) -> Option<(LatLon, f64, f64)> {
let mut best: Option<(LatLon, f64, f64)> = None;
let consider = |pos: LatLon, arc: f64, best: &mut Option<(LatLon, f64, f64)>| {
if (arc - arc_a).abs() < 1e-9 || (arc_b - arc).abs() < 1e-9 {
return;
}
let l_left = get_segment_land_metres(landmass, pa, pos, step);
let l_right = get_segment_land_metres(landmass, pos, pb, step);
let score = l_left.max(l_right);
match *best {
Some((_, _, s)) if s <= score => {}
_ => *best = Some((pos, arc, score)),
}
};
for k in 0..polyline.len() {
let v_arc = cumulative[k];
if v_arc > arc_a && v_arc < arc_b {
consider(polyline[k], v_arc, &mut best);
}
}
let target = (arc_a + arc_b) * 0.5;
if let Some((pos, arc)) = polyline_at_arc_length(polyline, cumulative, target) {
consider(pos, arc, &mut best);
}
best
}
fn cumulative_arc_lengths(polyline: &[LatLon]) -> Vec<f64> {
let mut cum = vec![0.0_f64; polyline.len()];
for k in 1..polyline.len() {
cum[k] = cum[k - 1] + haversine(polyline[k - 1], polyline[k]);
}
cum
}
fn polyline_at_arc_length(
polyline: &[LatLon],
cumulative: &[f64],
target: f64,
) -> Option<(LatLon, f64)> {
if polyline.is_empty() {
return None;
}
let total = *cumulative.last()?;
let target = target.clamp(0.0, total);
let mut k = 0;
while k + 1 < cumulative.len() && cumulative[k + 1] < target {
k += 1;
}
let seg_start = cumulative[k];
let seg_end = cumulative.get(k + 1).copied().unwrap_or(total);
let span = (seg_end - seg_start).max(1e-12);
let alpha = ((target - seg_start) / span).clamp(0.0, 1.0);
let a = polyline[k];
let b = polyline.get(k + 1).copied().unwrap_or(a);
let pos = LatLon::new(
a.lon * (1.0 - alpha) + b.lon * alpha,
a.lat * (1.0 - alpha) + b.lat * alpha,
);
Some((pos, target))
}
fn sample_uniform_arclength<const N: usize>(
polyline: &[LatLon],
bounds: &RouteBounds,
) -> [LatLon; N] {
let mut out = [LatLon::default(); N];
out[0] = bounds.origin;
out[N - 1] = bounds.destination;
if polyline.len() < 2 || N < 3 {
for (i, slot) in out.iter_mut().enumerate().skip(1).take(N.saturating_sub(2)) {
let t = i as f64 / (N - 1) as f64;
*slot = bounds.lerp_between_endpoints(t);
}
return out;
}
let mut cumulative = vec![0.0f64; polyline.len()];
for k in 1..polyline.len() {
cumulative[k] = cumulative[k - 1] + haversine(polyline[k - 1], polyline[k]);
}
let total = *cumulative.last().expect("polyline has >= 2 points");
if total <= 0.0 {
for slot in out.iter_mut().skip(1).take(N.saturating_sub(2)) {
*slot = polyline[0];
}
return out;
}
let mut k = 0;
for (i, slot) in out.iter_mut().enumerate().skip(1).take(N.saturating_sub(2)) {
let target = (i as f64 / (N - 1) as f64) * total;
while k + 1 < cumulative.len() && cumulative[k + 1] < target {
k += 1;
}
let seg_start = cumulative[k];
let seg_end = cumulative.get(k + 1).copied().unwrap_or(total);
let span = (seg_end - seg_start).max(1e-12);
let alpha = ((target - seg_start) / span).clamp(0.0, 1.0);
let a = polyline[k];
let b = polyline.get(k + 1).copied().unwrap_or(a);
*slot = LatLon::new(
a.lon * (1.0 - alpha) + b.lon * alpha,
a.lat * (1.0 - alpha) + b.lat * alpha,
);
}
out
}
fn compute_perpendicular_clearance<const N: usize, LS: LandmassSource>(
positions: &[LatLon; N],
perpendiculars: &[TangentMetres; N],
landmass: &LS,
) -> ([f64; N], [f64; N]) {
const MIN_SCALE_M: f64 = 5_000.0;
const MAX_SCALE_FRACTION: f64 = 0.2;
let line_metres = haversine(positions[0], positions[N - 1]).max(MIN_SCALE_M * 4.0);
let max_scale = (line_metres * MAX_SCALE_FRACTION).max(MIN_SCALE_M * 2.0);
let mut pos_scale = [0.0; N];
let mut neg_scale = [0.0; N];
for i in 0..N {
let here = positions[i];
let perp = perpendiculars[i];
pos_scale[i] = probe_clearance(landmass, here, perp, max_scale).max(MIN_SCALE_M);
neg_scale[i] = probe_clearance(landmass, here, -perp, max_scale).max(MIN_SCALE_M);
}
(pos_scale, neg_scale)
}
fn probe_clearance<LS: LandmassSource>(
landmass: &LS,
pos: LatLon,
direction: TangentMetres,
max_distance_m: f64,
) -> f64 {
let probes: [f64; 6] = {
let mut p = [
100_000.0,
300_000.0,
1_000_000.0,
3_000_000.0,
max_distance_m,
max_distance_m,
];
for slot in p.iter_mut().take(5) {
if *slot > max_distance_m {
*slot = max_distance_m;
}
}
p
};
let mut last_good = 0.0;
let mut first_bad: Option<f64> = None;
for &d in &probes {
if landmass.signed_distance_m(pos.offset_by(direction * d)) >= 0.0 {
last_good = d;
} else {
first_bad = Some(d);
break;
}
}
let Some(bad) = first_bad else {
return max_distance_m;
};
let mut lo = last_good;
let mut hi = bad;
for _ in 0..6 {
let mid = 0.5 * (lo + hi);
if landmass.signed_distance_m(pos.offset_by(direction * mid)) >= 0.0 {
lo = mid;
} else {
hi = mid;
}
}
lo
}
fn compute_perpendiculars<const N: usize>(positions: &[LatLon; N]) -> [TangentMetres; N] {
let mut perps = [TangentMetres::zero(); N];
for i in 0..N {
let prev_i = if i == 0 { 0 } else { i - 1 };
let next_i = if i + 1 >= N { N - 1 } else { i + 1 };
let prev = positions[prev_i];
let next = positions[next_i];
let here = positions[i];
let tangent = delta_to_tangent_metres(here, prev.delta_to(next));
let mag = tangent.norm();
perps[i] = if mag > 1e-12 {
TangentMetres::new(-tangent.north / mag, tangent.east / mag)
} else {
TangentMetres::new(0.0, 1.0)
};
}
perps
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
reason = "tests rely on bit-exact comparisons of constant or stored f32/f64 values."
)]
use super::*;
use crate::LandmassSourceDummy;
use crate::spherical::LonLatBbox;
struct CoastSlab {
lon_min: f64,
lon_max: f64,
}
impl LandmassSource for CoastSlab {
fn signed_distance_m(&self, location: LatLon) -> f64 {
let dx = (self.lon_min - location.lon).max(location.lon - self.lon_max);
dx * 111_000.0
}
}
#[test]
fn perpendiculars_handle_antimeridian_crossing() {
let positions: [LatLon; 3] = [
LatLon::new(178.0, 0.0),
LatLon::new(180.0, 0.0),
LatLon::new(-178.0, 0.0),
];
let perps = compute_perpendiculars(&positions);
let mid = perps[1];
assert!(
mid.east.abs() < 1e-9 && (mid.north - 1.0).abs() < 1e-9,
"expected ~(east=0, north=1) at antimeridian crossing, got {mid:?}",
);
}
#[test]
fn perpendicular_clearance_is_asymmetric_near_a_coast() {
let coast = CoastSlab {
lon_min: -2.0,
lon_max: 2.0,
};
let positions: [LatLon; 3] = [
LatLon::new(3.0, -45.0),
LatLon::new(3.0, 0.0),
LatLon::new(3.0, 45.0),
];
let perpendiculars = compute_perpendiculars(&positions);
let (pos_scale, neg_scale) =
compute_perpendicular_clearance(&positions, &perpendiculars, &coast);
assert!(
(pos_scale[1] - 111_000.0).abs() < 30_000.0,
"expected ~111 km west clearance, got {} m",
pos_scale[1],
);
assert!(
neg_scale[1] > pos_scale[1] * 5.0,
"expected east clearance >> west, got pos={}, neg={}",
pos_scale[1],
neg_scale[1],
);
}
struct LandPatch {
lon_min: f64,
lon_max: f64,
lat_min: f64,
lat_max: f64,
}
impl LandmassSource for LandPatch {
fn signed_distance_m(&self, location: LatLon) -> f64 {
let dx = (self.lon_min - location.lon).max(location.lon - self.lon_max);
let dy = (self.lat_min - location.lat).max(location.lat - self.lat_max);
let deg_to_m = 111_000.0;
let outside = dx.max(0.0).hypot(dy.max(0.0)) * deg_to_m;
let inside = dx.max(dy).min(0.0) * deg_to_m;
if outside > 0.0 { outside } else { inside }
}
}
#[test]
fn land_respecting_sampler_produces_land_free_chords() {
let land = LandPatch {
lon_min: -2.0,
lon_max: 2.0,
lat_min: -9.0,
lat_max: 9.0,
};
let polyline = vec![
LatLon::new(-15.0, 0.0),
LatLon::new(-3.0, 9.5),
LatLon::new(0.0, 10.0),
LatLon::new(3.0, 9.5),
LatLon::new(15.0, 0.0),
];
let bounds = RouteBounds::new(
(-15.0, 0.0),
(15.0, 0.0),
LonLatBbox::new(-20.0, 20.0, -12.0, 12.0),
);
let positions = sample_land_respecting::<8, _>(&polyline, &bounds, &land);
let step = bounds.step_distance_max;
for w in positions.windows(2) {
let land_m = get_segment_land_metres(&land, w[0], w[1], step);
assert_eq!(land_m, 0.0, "chord {:?} -> {:?} crossed land", w[0], w[1]);
}
assert_eq!(positions[0], LatLon::new(-15.0, 0.0));
assert_eq!(positions[7], LatLon::new(15.0, 0.0));
}
#[test]
fn land_respecting_sampler_falls_back_for_open_ocean() {
let dummy = LandmassSourceDummy;
let polyline = vec![
LatLon::new(0.0, 0.0),
LatLon::new(50.0, 0.0),
LatLon::new(100.0, 0.0),
];
let bounds = RouteBounds::new(
(0.0, 0.0),
(100.0, 0.0),
LonLatBbox::new(-10.0, 110.0, -5.0, 5.0),
);
let positions = sample_land_respecting::<6, _>(&polyline, &bounds, &dummy);
assert_eq!(positions[0], LatLon::new(0.0, 0.0));
assert_eq!(positions[5], LatLon::new(100.0, 0.0));
for i in 1..6 {
assert!(
positions[i].lon >= positions[i - 1].lon,
"waypoint {i} ({:?}) is west of {} ({:?})",
positions[i],
i - 1,
positions[i - 1],
);
}
}
#[test]
fn perpendicular_clearance_caps_at_max_distance_in_open_ocean() {
let dummy = LandmassSourceDummy;
let positions: [LatLon; 3] = [
LatLon::new(0.0, 0.0),
LatLon::new(50.0, 0.0),
LatLon::new(100.0, 0.0),
];
let perpendiculars = compute_perpendiculars(&positions);
let (pos_scale, neg_scale) =
compute_perpendicular_clearance(&positions, &perpendiculars, &dummy);
assert_eq!(pos_scale[1], neg_scale[1], "open ocean should be symmetric");
assert!(pos_scale[1] > 1_000_000.0);
}
}