Skip to main content

swarmkit_sailing/
path_baseline.rs

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/// Reference path that the init families perturb.
9///
10/// Each particle is built by walking the baseline and adding
11/// `offset[i] * scale[i]` metres along `perpendiculars[i]` at every
12/// interior waypoint. Scale is `perp_scale_pos` for `offset ≥ 0`,
13/// `perp_scale_neg` otherwise — asymmetric so a polyline running
14/// closer to one coast can still wiggle freely on the other side.
15/// Decouples "where the path goes" from "how particles vary."
16#[derive(Clone, Debug)]
17pub struct PathBaseline<const N: usize> {
18    pub positions: [LatLon; N],
19    /// Outward perpendicular, unit east-north tangent (90° CCW of the
20    /// local tangent direction).
21    pub perpendiculars: [TangentMetres; N],
22    /// Perturbation scale (metres) for the +perpendicular side.
23    pub perp_scale_pos: [f64; N],
24    /// Perturbation scale (metres) for the −perpendicular side.
25    pub perp_scale_neg: [f64; N],
26}
27
28impl<const N: usize> PathBaseline<N> {
29    /// Straight-line lerp baseline. Symmetric scale = half the
30    /// haversine route length on both sides.
31    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        // Pin endpoints exactly (lerp at t=0 / t=1 should already match,
38        // but guard against floating-point drift).
39        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    /// Topology-aware baseline from a polyline (e.g. an A* sea path).
54    /// Samples N waypoints uniformly by arc length and probes the
55    /// signed-distance field for the perpendicular clearance on each
56    /// side, so a path running close to one coast still wiggles freely
57    /// on the open-sea side.
58    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    /// Sampler that prioritises land-free chords over uniform spacing.
76    /// Use for benchmark routes (unperturbed `xy`) so "Bench land"
77    /// reads 0 on a correctly-routed input. *Not* for PSO init seeds —
78    /// tighter sampling hugs the coastline, and perpendicular kicks
79    /// then push particles into land too often.
80    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
98/// Greedy sampler: prefer breaking the worst crossing first, fall
99/// back to longest-gap arc-midpoint splits. Tries every polyline
100/// vertex in the gap (not just the midpoint) so a single well-placed
101/// detour vertex can break a crossing that midpoint refinement
102/// wouldn't reach within the N budget. Skip-set tracks segments where
103/// no split improves things, so one un-breakable chord doesn't halt
104/// the loop. Falls back to plain arc-length sampling if `N` can't be
105/// reached. Endpoints are pinned to `bounds.origin` /
106/// `bounds.destination` regardless of where A* snapped its endpoints.
107fn 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    // Selected: (position, polyline_arc_length_from_polyline[0]).
130    let mut selected: Vec<(LatLon, f64)> =
131        vec![(polyline[0], 0.0), (polyline[polyline.len() - 1], total)];
132
133    // Skip set keyed on the segment's arc-length endpoints (as f64
134    // bits). `selected` is append-only so the key stays valid; stale
135    // entries pointing at later-split segments are harmless.
136    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        // Phase 1 work: break the worst remaining crossing.
143        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            // Smart-break couldn't strictly improve on this segment.
168            // Happens when the parent's great-circle traverses
169            // fundamentally different geography from the polyline
170            // detour: e.g. an around-continent route where the chord
171            // cuts across the continent and any single detour vertex
172            // still leaves one half on the continent's other side.
173            // `max(l_left, l_right) < parent_land` is unattainable,
174            // so smart-break alone would skip the segment and leave
175            // the crossing in place forever.
176            //
177            // Fall back to inserting the polyline arc-midpoint. The
178            // polyline is land-free by construction (it's an A* path
179            // through sea cells), so repeated halving subdivides the
180            // parent's polyline range until consecutive selections
181            // span a single polyline edge — at which point the chord
182            // tracks the sea route and lands no metres on land.
183            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            // Arc-midpoint coincides with one of the segment's
192            // endpoints (degenerate very-short range): nothing
193            // meaningful to insert.
194            skip.insert(key_of(arc_a, arc_b));
195            continue;
196        }
197
198        // Phase 2 work: fill the longest open-sea gap.
199        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            // Splitting at this gap's polyline midpoint would create
231            // a fresh crossing — happens when the polyline curves
232            // through coastline that the parent chord didn't clip
233            // (a previously-open-sea gap whose arc midpoint lands on
234            // a polyline detour). Skip and look at the next gap.
235            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/// Search for the split candidate that minimises `max(land_left,
252/// land_right)` after insertion, considering every polyline vertex
253/// strictly inside `(arc_a, arc_b)` plus the arc-length midpoint
254/// interpolation. Returns `None` if no candidate falls inside the gap
255/// (e.g. when the gap spans no polyline vertices and the arc midpoint
256/// is at a duplicate position).
257#[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
297/// Cumulative haversine arc length from `polyline[0]` to each subsequent
298/// vertex. `cumulative[k]` is the arc length from `polyline[0]` to
299/// `polyline[k]`.
300fn 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
308/// Position (and exact arc length) on the polyline at the given
309/// cumulative arc length. Linearly interpolates within the bracketing
310/// segment for sub-vertex precision. Returns `None` only for empty
311/// polylines.
312fn 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    // Find the segment k..k+1 containing `target`.
323    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    // Linear interpolation in (lon, lat) — acceptable here because the
334    // polyline carries enough vertices that sub-vertex distances are
335    // small enough for the arc-length-vs-great-circle error to be
336    // negligible for init seeding.
337    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
344/// Sample N points uniformly along the cumulative arc-length of a
345/// polyline. The first and last samples are pinned to `bounds.origin`
346/// and `bounds.destination` so init particles always start and end at
347/// the user-specified endpoints regardless of polyline drift.
348fn 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        // Fall back to lerp between the pinned endpoints.
357        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    // Cumulative haversine distances: cumulative[k] = arc length from
365    // polyline[0] to polyline[k].
366    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    // For interior i, walk the polyline to find the segment containing
379    // arc length `target`, then linearly interpolate between its endpoints.
380    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        // Interpolate in (lon, lat) — for sub-cell distances this is
393        // close enough to a great-circle interpolation that the small
394        // error doesn't matter for init seeding.
395        *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
403/// Per-waypoint perturbation scales for the +`perpendiculars[i]` and
404/// −`perpendiculars[i]` directions, derived by walking each side of
405/// the polyline until the SDF crosses zero (or a route-length cap is
406/// reached). Asymmetric so a polyline running 20 km off a coast can
407/// still let particles wiggle hundreds of km on the open-sea side.
408///
409/// Probing strategy: an exponential coarse probe to find the first
410/// land hit, then a short bisection to refine the crossover within ~1 %
411/// of the cap. ~11 SDF lookups per direction per waypoint — cheap
412/// enough that even N=60 init at startup costs <1 ms total.
413fn 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    /// Min perturbation amplitude (m). Always allow at least this much
419    /// so coast-hugging waypoints get *some* family-induced variation.
420    const MIN_SCALE_M: f64 = 5_000.0;
421    /// Cap as a fraction of the great-circle route length. Smaller than
422    /// the straight-line baseline's 0.5 because the polyline already
423    /// does the macro detour work — families just refine locally.
424    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
439/// Walk from `pos` along `direction` (unit east-north tangent metres)
440/// until the SDF crosses zero, returning the largest sea-side distance
441/// found. Capped at `max_distance_m`. Implementation: exponential
442/// probe to bracket the crossover, then 6-step bisection to refine.
443fn probe_clearance<LS: LandmassSource>(
444    landmass: &LS,
445    pos: LatLon,
446    direction: TangentMetres,
447    max_distance_m: f64,
448) -> f64 {
449    // Exponential probe distances; capped to `max_distance_m` so we
450    // never probe further than we'd ever use. The last entry is always
451    // the cap so we don't miss the case "all sea up to the cap."
452    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        // Replace the next-to-last with `max_distance_m` if we'd
462        // otherwise probe further than the cap.
463        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    // Bisect [last_good, bad] for sub-cell accuracy on the crossover.
484    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
497/// Local outward perpendiculars in the east-north tangent frame, one
498/// per waypoint. Uses the central-difference tangent (prev → next) at
499/// interior waypoints and one-sided differences at endpoints. Returns
500/// the 90° CCW rotation of the unit tangent.
501fn 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        // Tangent in tangent metres at `here`, computed from the
510        // wrap-aware delta between the neighbours converted via the
511        // east-north metric at `here`'s latitude. `LatLon::delta_to`
512        // takes the short way across the antimeridian, so a polyline
513        // segment from 179° to −179° gives a +2° east tangent rather
514        // than a −358° west one.
515        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            // Degenerate (consecutive duplicate positions) — pick a
521            // sensible default so the family kicks aren't zeroed out.
522            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    /// `LandmassSource` with land in the slab `lon ∈ [lon_min, lon_max]`,
539    /// sea everywhere else. Lets us reason about perpendicular clearance
540    /// against a known geometry.
541    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            // Treat 1° ≈ 111 km for the SDF magnitude. Sign flips inside
550            // the slab.
551            dx * 111_000.0
552        }
553    }
554
555    #[test]
556    fn perpendiculars_handle_antimeridian_crossing() {
557        // Polyline crossing the antimeridian eastward at the equator:
558        // [178°, 180°, −178°]. The short-way tangent at the middle
559        // waypoint is +2°/step due east, so the 90°-CCW perpendicular
560        // is due north `(east=0, north=1)`. With a raw `next.lon −
561        // prev.lon` delta the tangent comes out −356° (west) and the
562        // perpendicular points south — this test pins the wrap fix.
563        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        // Polyline running north–south at lon = 3 (1° east of a coast
579        // that runs from lon -2 to 2). Tangent at the middle waypoint
580        // is north, perpendicular is west (rotate 90° CCW). Long route
581        // so the route-length cap is well above the coast-side clearance
582        // and the asymmetry is visible.
583        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        // +perpendicular at index 1 should be west (toward coast). The
596        // coast edge is 1° = ~111 km away; clearance should be in that
597        // ballpark (within bisection tolerance, ~8 km on this scale).
598        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        // −perpendicular is east — open ocean for far longer than the
604        // coast distance. Clearance should saturate at the route-length
605        // cap (~0.2 × 10 000 km = 2000 km), much larger than coast-side.
606        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    /// Bounded land patch — `lon ∈ [lon_min, lon_max]`, `lat ∈ [lat_min,
615    /// lat_max]`. Mirrors `multi_baseline_init.rs`'s `SimulatedLandmass`
616    /// SDF formula so the test exercises the sampler against a real
617    /// rectangular obstacle the polyline can detour around.
618    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        // 4°-wide × 18°-tall land patch centred on the equator. A
639        // polyline detours over the top: cell-centred chain of 5
640        // vertices, all on the sea side of the patch.
641        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        // Every consecutive-pair chord must be land-free. With N=8 and a
662        // 5-vertex polyline, the uniform sampler's central chord cuts
663        // straight across the patch; the new sampler breaks that crossing
664        // by inserting polyline midpoints until each chord stays in sea.
665        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        // Sanity: endpoints pinned to bounds origin / destination.
672        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        // No land anywhere — sampler should still produce N waypoints,
679        // and the result should be a valid path (consecutive haversines
680        // monotonically increase along the route arc).
681        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        // Interior waypoints should march monotonically east.
696        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        // No land anywhere — both directions saturate at the cap.
710        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        // Cap = 0.2 × line_metres; line_metres ≈ haversine(100°, 0°) at
721        // the equator ≈ 11 130 km, so cap ≈ 2226 km. Just check it's
722        // big and matches both sides.
723        assert!(pos_scale[1] > 1_000_000.0);
724    }
725}