1use std::collections::HashSet;
2
3use crate::LandmassSource;
4use crate::dynamics::get_segment_land_metres;
5use crate::route_bounds::RouteBounds;
6use crate::spherical::{LatLon, TangentMetres, delta_to_tangent_metres, haversine};
7
8#[derive(Clone, Debug)]
17pub struct PathBaseline<const N: usize> {
18 pub positions: [LatLon; N],
19 pub perpendiculars: [TangentMetres; N],
22 pub perp_scale_pos: [f64; N],
24 pub perp_scale_neg: [f64; N],
26}
27
28impl<const N: usize> PathBaseline<N> {
29 pub fn straight_line(bounds: &RouteBounds) -> Self {
32 let mut positions = [LatLon::default(); N];
33 for (i, slot) in positions.iter_mut().enumerate() {
34 let t = i as f64 / (N - 1) as f64;
35 *slot = bounds.lerp_between_endpoints(t);
36 }
37 positions[0] = bounds.origin;
40 positions[N - 1] = bounds.destination;
41
42 let perpendiculars = compute_perpendiculars(&positions);
43 let line_metres = haversine(positions[0], positions[N - 1]);
44 let symmetric = [line_metres * 0.5; N];
45 Self {
46 positions,
47 perpendiculars,
48 perp_scale_pos: symmetric,
49 perp_scale_neg: symmetric,
50 }
51 }
52
53 pub fn from_polyline<LS: LandmassSource>(
59 polyline: &[LatLon],
60 bounds: &RouteBounds,
61 landmass: &LS,
62 ) -> Self {
63 let positions = sample_uniform_arclength::<N>(polyline, bounds);
64 let perpendiculars = compute_perpendiculars(&positions);
65 let (perp_scale_pos, perp_scale_neg) =
66 compute_perpendicular_clearance(&positions, &perpendiculars, landmass);
67 Self {
68 positions,
69 perpendiculars,
70 perp_scale_pos,
71 perp_scale_neg,
72 }
73 }
74
75 pub fn from_polyline_land_respecting<LS: LandmassSource>(
81 polyline: &[LatLon],
82 bounds: &RouteBounds,
83 landmass: &LS,
84 ) -> Self {
85 let positions = sample_land_respecting::<N, LS>(polyline, bounds, landmass);
86 let perpendiculars = compute_perpendiculars(&positions);
87 let (perp_scale_pos, perp_scale_neg) =
88 compute_perpendicular_clearance(&positions, &perpendiculars, landmass);
89 Self {
90 positions,
91 perpendiculars,
92 perp_scale_pos,
93 perp_scale_neg,
94 }
95 }
96}
97
98fn sample_land_respecting<const N: usize, LS: LandmassSource>(
108 polyline: &[LatLon],
109 bounds: &RouteBounds,
110 landmass: &LS,
111) -> [LatLon; N] {
112 let mut out = [LatLon::default(); N];
113 out[0] = bounds.origin;
114 out[N - 1] = bounds.destination;
115 if polyline.len() < 2 || N < 3 {
116 for (i, slot) in out.iter_mut().enumerate().skip(1).take(N.saturating_sub(2)) {
117 let t = i as f64 / (N - 1) as f64;
118 *slot = bounds.lerp_between_endpoints(t);
119 }
120 return out;
121 }
122
123 let cumulative = cumulative_arc_lengths(polyline);
124 let total = *cumulative.last().expect("polyline has >= 2 vertices");
125 if total <= 0.0 {
126 return sample_uniform_arclength::<N>(polyline, bounds);
127 }
128
129 let mut selected: Vec<(LatLon, f64)> =
131 vec![(polyline[0], 0.0), (polyline[polyline.len() - 1], total)];
132
133 let mut skip: HashSet<(u64, u64)> = HashSet::new();
137 let key_of = |a: f64, b: f64| (a.to_bits(), b.to_bits());
138
139 let step = bounds.step_distance_max;
140
141 while selected.len() < N {
142 let mut worst_land = 0.0_f64;
144 let mut worst_idx: Option<usize> = None;
145 for i in 0..selected.len() - 1 {
146 if skip.contains(&key_of(selected[i].1, selected[i + 1].1)) {
147 continue;
148 }
149 let land = get_segment_land_metres(landmass, selected[i].0, selected[i + 1].0, step);
150 if land > worst_land {
151 worst_land = land;
152 worst_idx = Some(i);
153 }
154 }
155 if let Some(i) = worst_idx {
156 let arc_a = selected[i].1;
157 let arc_b = selected[i + 1].1;
158 let pa = selected[i].0;
159 let pb = selected[i + 1].0;
160 if let Some((pos, arc, score)) =
161 best_break_candidate(polyline, &cumulative, arc_a, arc_b, pa, pb, landmass, step)
162 && score < worst_land
163 {
164 selected.insert(i + 1, (pos, arc));
165 continue;
166 }
167 let target = (arc_a + arc_b) * 0.5;
184 if let Some((pos, arc)) = polyline_at_arc_length(polyline, &cumulative, target)
185 && (arc - arc_a).abs() > 1e-9
186 && (arc_b - arc).abs() > 1e-9
187 {
188 selected.insert(i + 1, (pos, arc));
189 continue;
190 }
191 skip.insert(key_of(arc_a, arc_b));
195 continue;
196 }
197
198 let mut longest_gap = 0.0_f64;
200 let mut longest_idx: Option<usize> = None;
201 for i in 0..selected.len() - 1 {
202 if skip.contains(&key_of(selected[i].1, selected[i + 1].1)) {
203 continue;
204 }
205 let gap = selected[i + 1].1 - selected[i].1;
206 if gap > longest_gap {
207 longest_gap = gap;
208 longest_idx = Some(i);
209 }
210 }
211 let Some(i) = longest_idx else {
212 break;
213 };
214 let arc_a = selected[i].1;
215 let arc_b = selected[i + 1].1;
216 let pa = selected[i].0;
217 let pb = selected[i + 1].0;
218 let target = (arc_a + arc_b) * 0.5;
219 let Some((pos, arc)) = polyline_at_arc_length(polyline, &cumulative, target) else {
220 skip.insert(key_of(arc_a, arc_b));
221 continue;
222 };
223 if (arc - arc_a).abs() < 1e-9 || (arc_b - arc).abs() < 1e-9 {
224 skip.insert(key_of(arc_a, arc_b));
225 continue;
226 }
227 let l_left = get_segment_land_metres(landmass, pa, pos, step);
228 let l_right = get_segment_land_metres(landmass, pos, pb, step);
229 if l_left > 0.0 || l_right > 0.0 {
230 skip.insert(key_of(arc_a, arc_b));
236 continue;
237 }
238 selected.insert(i + 1, (pos, arc));
239 }
240
241 if selected.len() < N {
242 return sample_uniform_arclength::<N>(polyline, bounds);
243 }
244
245 for (i, slot) in out.iter_mut().enumerate().take(N - 1).skip(1) {
246 *slot = selected[i].0;
247 }
248 out
249}
250
251#[expect(
258 clippy::too_many_arguments,
259 reason = "candidate search needs full segment context (endpoints, arcs, polyline, landmass)"
260)]
261fn best_break_candidate<LS: LandmassSource>(
262 polyline: &[LatLon],
263 cumulative: &[f64],
264 arc_a: f64,
265 arc_b: f64,
266 pa: LatLon,
267 pb: LatLon,
268 landmass: &LS,
269 step: f64,
270) -> Option<(LatLon, f64, f64)> {
271 let mut best: Option<(LatLon, f64, f64)> = None;
272 let consider = |pos: LatLon, arc: f64, best: &mut Option<(LatLon, f64, f64)>| {
273 if (arc - arc_a).abs() < 1e-9 || (arc_b - arc).abs() < 1e-9 {
274 return;
275 }
276 let l_left = get_segment_land_metres(landmass, pa, pos, step);
277 let l_right = get_segment_land_metres(landmass, pos, pb, step);
278 let score = l_left.max(l_right);
279 match *best {
280 Some((_, _, s)) if s <= score => {}
281 _ => *best = Some((pos, arc, score)),
282 }
283 };
284 for k in 0..polyline.len() {
285 let v_arc = cumulative[k];
286 if v_arc > arc_a && v_arc < arc_b {
287 consider(polyline[k], v_arc, &mut best);
288 }
289 }
290 let target = (arc_a + arc_b) * 0.5;
291 if let Some((pos, arc)) = polyline_at_arc_length(polyline, cumulative, target) {
292 consider(pos, arc, &mut best);
293 }
294 best
295}
296
297fn cumulative_arc_lengths(polyline: &[LatLon]) -> Vec<f64> {
301 let mut cum = vec![0.0_f64; polyline.len()];
302 for k in 1..polyline.len() {
303 cum[k] = cum[k - 1] + haversine(polyline[k - 1], polyline[k]);
304 }
305 cum
306}
307
308fn polyline_at_arc_length(
313 polyline: &[LatLon],
314 cumulative: &[f64],
315 target: f64,
316) -> Option<(LatLon, f64)> {
317 if polyline.is_empty() {
318 return None;
319 }
320 let total = *cumulative.last()?;
321 let target = target.clamp(0.0, total);
322 let mut k = 0;
324 while k + 1 < cumulative.len() && cumulative[k + 1] < target {
325 k += 1;
326 }
327 let seg_start = cumulative[k];
328 let seg_end = cumulative.get(k + 1).copied().unwrap_or(total);
329 let span = (seg_end - seg_start).max(1e-12);
330 let alpha = ((target - seg_start) / span).clamp(0.0, 1.0);
331 let a = polyline[k];
332 let b = polyline.get(k + 1).copied().unwrap_or(a);
333 let pos = LatLon::new(
338 a.lon * (1.0 - alpha) + b.lon * alpha,
339 a.lat * (1.0 - alpha) + b.lat * alpha,
340 );
341 Some((pos, target))
342}
343
344fn sample_uniform_arclength<const N: usize>(
349 polyline: &[LatLon],
350 bounds: &RouteBounds,
351) -> [LatLon; N] {
352 let mut out = [LatLon::default(); N];
353 out[0] = bounds.origin;
354 out[N - 1] = bounds.destination;
355 if polyline.len() < 2 || N < 3 {
356 for (i, slot) in out.iter_mut().enumerate().skip(1).take(N.saturating_sub(2)) {
358 let t = i as f64 / (N - 1) as f64;
359 *slot = bounds.lerp_between_endpoints(t);
360 }
361 return out;
362 }
363
364 let mut cumulative = vec![0.0f64; polyline.len()];
367 for k in 1..polyline.len() {
368 cumulative[k] = cumulative[k - 1] + haversine(polyline[k - 1], polyline[k]);
369 }
370 let total = *cumulative.last().expect("polyline has >= 2 points");
371 if total <= 0.0 {
372 for slot in out.iter_mut().skip(1).take(N.saturating_sub(2)) {
373 *slot = polyline[0];
374 }
375 return out;
376 }
377
378 let mut k = 0;
381 for (i, slot) in out.iter_mut().enumerate().skip(1).take(N.saturating_sub(2)) {
382 let target = (i as f64 / (N - 1) as f64) * total;
383 while k + 1 < cumulative.len() && cumulative[k + 1] < target {
384 k += 1;
385 }
386 let seg_start = cumulative[k];
387 let seg_end = cumulative.get(k + 1).copied().unwrap_or(total);
388 let span = (seg_end - seg_start).max(1e-12);
389 let alpha = ((target - seg_start) / span).clamp(0.0, 1.0);
390 let a = polyline[k];
391 let b = polyline.get(k + 1).copied().unwrap_or(a);
392 *slot = LatLon::new(
396 a.lon * (1.0 - alpha) + b.lon * alpha,
397 a.lat * (1.0 - alpha) + b.lat * alpha,
398 );
399 }
400 out
401}
402
403fn compute_perpendicular_clearance<const N: usize, LS: LandmassSource>(
414 positions: &[LatLon; N],
415 perpendiculars: &[TangentMetres; N],
416 landmass: &LS,
417) -> ([f64; N], [f64; N]) {
418 const MIN_SCALE_M: f64 = 5_000.0;
421 const MAX_SCALE_FRACTION: f64 = 0.2;
425
426 let line_metres = haversine(positions[0], positions[N - 1]).max(MIN_SCALE_M * 4.0);
427 let max_scale = (line_metres * MAX_SCALE_FRACTION).max(MIN_SCALE_M * 2.0);
428 let mut pos_scale = [0.0; N];
429 let mut neg_scale = [0.0; N];
430 for i in 0..N {
431 let here = positions[i];
432 let perp = perpendiculars[i];
433 pos_scale[i] = probe_clearance(landmass, here, perp, max_scale).max(MIN_SCALE_M);
434 neg_scale[i] = probe_clearance(landmass, here, -perp, max_scale).max(MIN_SCALE_M);
435 }
436 (pos_scale, neg_scale)
437}
438
439fn probe_clearance<LS: LandmassSource>(
444 landmass: &LS,
445 pos: LatLon,
446 direction: TangentMetres,
447 max_distance_m: f64,
448) -> f64 {
449 let probes: [f64; 6] = {
453 let mut p = [
454 100_000.0,
455 300_000.0,
456 1_000_000.0,
457 3_000_000.0,
458 max_distance_m,
459 max_distance_m,
460 ];
461 for slot in p.iter_mut().take(5) {
464 if *slot > max_distance_m {
465 *slot = max_distance_m;
466 }
467 }
468 p
469 };
470 let mut last_good = 0.0;
471 let mut first_bad: Option<f64> = None;
472 for &d in &probes {
473 if landmass.signed_distance_m(pos.offset_by(direction * d)) >= 0.0 {
474 last_good = d;
475 } else {
476 first_bad = Some(d);
477 break;
478 }
479 }
480 let Some(bad) = first_bad else {
481 return max_distance_m;
482 };
483 let mut lo = last_good;
485 let mut hi = bad;
486 for _ in 0..6 {
487 let mid = 0.5 * (lo + hi);
488 if landmass.signed_distance_m(pos.offset_by(direction * mid)) >= 0.0 {
489 lo = mid;
490 } else {
491 hi = mid;
492 }
493 }
494 lo
495}
496
497fn compute_perpendiculars<const N: usize>(positions: &[LatLon; N]) -> [TangentMetres; N] {
502 let mut perps = [TangentMetres::zero(); N];
503 for i in 0..N {
504 let prev_i = if i == 0 { 0 } else { i - 1 };
505 let next_i = if i + 1 >= N { N - 1 } else { i + 1 };
506 let prev = positions[prev_i];
507 let next = positions[next_i];
508 let here = positions[i];
509 let tangent = delta_to_tangent_metres(here, prev.delta_to(next));
516 let mag = tangent.norm();
517 perps[i] = if mag > 1e-12 {
518 TangentMetres::new(-tangent.north / mag, tangent.east / mag)
519 } else {
520 TangentMetres::new(0.0, 1.0)
523 };
524 }
525 perps
526}
527
528#[cfg(test)]
529mod tests {
530 #![allow(
531 clippy::float_cmp,
532 reason = "tests rely on bit-exact comparisons of constant or stored f32/f64 values."
533 )]
534 use super::*;
535 use crate::LandmassSourceDummy;
536 use crate::spherical::LonLatBbox;
537
538 struct CoastSlab {
542 lon_min: f64,
543 lon_max: f64,
544 }
545
546 impl LandmassSource for CoastSlab {
547 fn signed_distance_m(&self, location: LatLon) -> f64 {
548 let dx = (self.lon_min - location.lon).max(location.lon - self.lon_max);
549 dx * 111_000.0
552 }
553 }
554
555 #[test]
556 fn perpendiculars_handle_antimeridian_crossing() {
557 let positions: [LatLon; 3] = [
564 LatLon::new(178.0, 0.0),
565 LatLon::new(180.0, 0.0),
566 LatLon::new(-178.0, 0.0),
567 ];
568 let perps = compute_perpendiculars(&positions);
569 let mid = perps[1];
570 assert!(
571 mid.east.abs() < 1e-9 && (mid.north - 1.0).abs() < 1e-9,
572 "expected ~(east=0, north=1) at antimeridian crossing, got {mid:?}",
573 );
574 }
575
576 #[test]
577 fn perpendicular_clearance_is_asymmetric_near_a_coast() {
578 let coast = CoastSlab {
584 lon_min: -2.0,
585 lon_max: 2.0,
586 };
587 let positions: [LatLon; 3] = [
588 LatLon::new(3.0, -45.0),
589 LatLon::new(3.0, 0.0),
590 LatLon::new(3.0, 45.0),
591 ];
592 let perpendiculars = compute_perpendiculars(&positions);
593 let (pos_scale, neg_scale) =
594 compute_perpendicular_clearance(&positions, &perpendiculars, &coast);
595 assert!(
599 (pos_scale[1] - 111_000.0).abs() < 30_000.0,
600 "expected ~111 km west clearance, got {} m",
601 pos_scale[1],
602 );
603 assert!(
607 neg_scale[1] > pos_scale[1] * 5.0,
608 "expected east clearance >> west, got pos={}, neg={}",
609 pos_scale[1],
610 neg_scale[1],
611 );
612 }
613
614 struct LandPatch {
619 lon_min: f64,
620 lon_max: f64,
621 lat_min: f64,
622 lat_max: f64,
623 }
624
625 impl LandmassSource for LandPatch {
626 fn signed_distance_m(&self, location: LatLon) -> f64 {
627 let dx = (self.lon_min - location.lon).max(location.lon - self.lon_max);
628 let dy = (self.lat_min - location.lat).max(location.lat - self.lat_max);
629 let deg_to_m = 111_000.0;
630 let outside = dx.max(0.0).hypot(dy.max(0.0)) * deg_to_m;
631 let inside = dx.max(dy).min(0.0) * deg_to_m;
632 if outside > 0.0 { outside } else { inside }
633 }
634 }
635
636 #[test]
637 fn land_respecting_sampler_produces_land_free_chords() {
638 let land = LandPatch {
642 lon_min: -2.0,
643 lon_max: 2.0,
644 lat_min: -9.0,
645 lat_max: 9.0,
646 };
647 let polyline = vec![
648 LatLon::new(-15.0, 0.0),
649 LatLon::new(-3.0, 9.5),
650 LatLon::new(0.0, 10.0),
651 LatLon::new(3.0, 9.5),
652 LatLon::new(15.0, 0.0),
653 ];
654 let bounds = RouteBounds::new(
655 (-15.0, 0.0),
656 (15.0, 0.0),
657 LonLatBbox::new(-20.0, 20.0, -12.0, 12.0),
658 );
659 let positions = sample_land_respecting::<8, _>(&polyline, &bounds, &land);
660
661 let step = bounds.step_distance_max;
666 for w in positions.windows(2) {
667 let land_m = get_segment_land_metres(&land, w[0], w[1], step);
668 assert_eq!(land_m, 0.0, "chord {:?} -> {:?} crossed land", w[0], w[1]);
669 }
670
671 assert_eq!(positions[0], LatLon::new(-15.0, 0.0));
673 assert_eq!(positions[7], LatLon::new(15.0, 0.0));
674 }
675
676 #[test]
677 fn land_respecting_sampler_falls_back_for_open_ocean() {
678 let dummy = LandmassSourceDummy;
682 let polyline = vec![
683 LatLon::new(0.0, 0.0),
684 LatLon::new(50.0, 0.0),
685 LatLon::new(100.0, 0.0),
686 ];
687 let bounds = RouteBounds::new(
688 (0.0, 0.0),
689 (100.0, 0.0),
690 LonLatBbox::new(-10.0, 110.0, -5.0, 5.0),
691 );
692 let positions = sample_land_respecting::<6, _>(&polyline, &bounds, &dummy);
693 assert_eq!(positions[0], LatLon::new(0.0, 0.0));
694 assert_eq!(positions[5], LatLon::new(100.0, 0.0));
695 for i in 1..6 {
697 assert!(
698 positions[i].lon >= positions[i - 1].lon,
699 "waypoint {i} ({:?}) is west of {} ({:?})",
700 positions[i],
701 i - 1,
702 positions[i - 1],
703 );
704 }
705 }
706
707 #[test]
708 fn perpendicular_clearance_caps_at_max_distance_in_open_ocean() {
709 let dummy = LandmassSourceDummy;
711 let positions: [LatLon; 3] = [
712 LatLon::new(0.0, 0.0),
713 LatLon::new(50.0, 0.0),
714 LatLon::new(100.0, 0.0),
715 ];
716 let perpendiculars = compute_perpendiculars(&positions);
717 let (pos_scale, neg_scale) =
718 compute_perpendicular_clearance(&positions, &perpendiculars, &dummy);
719 assert_eq!(pos_scale[1], neg_scale[1], "open ocean should be symmetric");
720 assert!(pos_scale[1] > 1_000_000.0);
724 }
725}