use crate::dynamics::get_travel_time_range;
use crate::route_bounds::RouteBounds;
use crate::spherical::{LatLon, Segment, TangentMetres};
use crate::units::{Floats, Path, Time};
use crate::{LandmassSource, Sailboat, WindSource};
use swarmkit::{Boundary, Contextful};
pub(crate) struct SailingPathBoundary<
'a,
const N: usize,
SB: Sailboat,
WS: WindSource,
LS: LandmassSource,
> {
bounds: &'a RouteBounds,
boat: &'a SB,
wind_source: &'a WS,
landmass: &'a LS,
}
impl<'a, const N: usize, SB: Sailboat, WS: WindSource, LS: LandmassSource>
SailingPathBoundary<'a, N, SB, WS, LS>
{
pub fn new(
bounds: &'a RouteBounds,
boat: &'a SB,
wind_source: &'a WS,
landmass: &'a LS,
) -> Self {
SailingPathBoundary {
bounds,
boat,
wind_source,
landmass,
}
}
}
impl<const N: usize, SB: Sailboat, WS: WindSource, LS: LandmassSource> Contextful
for SailingPathBoundary<'_, N, SB, WS, LS>
{
type TContext = Path<N>;
}
impl<const N: usize, SB: Sailboat, WS: WindSource, LS: LandmassSource> Boundary
for SailingPathBoundary<'_, N, SB, WS, LS>
{
type T = Path<N>;
fn handle(&self, pos: Self::T) -> Self::T {
#[cfg(feature = "profile-timers")]
let __profile_start = std::time::Instant::now();
let mut clamped = self.bounds.constrain_xy(&pos);
for i in 1..N - 1 {
let projected = project_off_land(self.landmass, clamped.xy.lat_lon(i));
clamped.xy.set_lat_lon(i, self.bounds.clamp(projected));
}
let mut times = [0.0f64; N];
let mut acc_time = 0.0;
for (i, seg) in clamped.iter_segments().enumerate() {
let (a, b) = get_travel_time_range(
self.boat,
self.wind_source,
Segment {
origin: seg.origin,
destination: seg.destination,
origin_time: acc_time,
step_distance_max: self.bounds.step_distance_max,
},
);
times[i + 1] = f64::clamp(pos.t[i + 1], a, b);
acc_time += times[i + 1];
}
clamped.t = Time(Floats(times));
#[cfg(feature = "profile-timers")]
crate::profile_timers::SAILING_BOUNDARY.record(__profile_start.elapsed().as_nanos() as u64);
clamped
}
}
const PROJECTION_MAX_ITER: usize = 8;
const SAFETY_BUFFER_M: f64 = 50_000.0;
const MAX_PROJECTION_STEP_M: f64 = 100_000.0;
const FINITE_DIFFERENCE_PROBE_M: f64 = 50_000.0;
fn project_off_land<LS: LandmassSource>(landmass: &LS, waypoint: LatLon) -> LatLon {
let mut p = waypoint;
let mut sd = landmass.signed_distance_m(p);
if sd >= SAFETY_BUFFER_M {
return p;
}
for _ in 0..PROJECTION_MAX_ITER {
let g = landmass.gradient(p);
let dir = if g.norm_squared() > 1e-12 {
g.normalize()
} else if let Some(d) = finite_difference_gradient(landmass, p, sd) {
d
} else {
break;
};
let step_m = (SAFETY_BUFFER_M - sd).clamp(0.0, MAX_PROJECTION_STEP_M);
p = p.offset_by(dir * step_m);
sd = landmass.signed_distance_m(p);
if sd >= SAFETY_BUFFER_M {
break;
}
}
p
}
fn finite_difference_gradient<LS: LandmassSource>(
landmass: &LS,
p: LatLon,
centre_sd: f64,
) -> Option<TangentMetres> {
const PROBES: [TangentMetres; 4] = [
TangentMetres::new(FINITE_DIFFERENCE_PROBE_M, 0.0),
TangentMetres::new(-FINITE_DIFFERENCE_PROBE_M, 0.0),
TangentMetres::new(0.0, FINITE_DIFFERENCE_PROBE_M),
TangentMetres::new(0.0, -FINITE_DIFFERENCE_PROBE_M),
];
let mut best_dir: Option<TangentMetres> = None;
let mut best_diff = 0.0;
for probe_dir in PROBES {
let probe_pos = p.offset_by(probe_dir);
let probe_sd = landmass.signed_distance_m(probe_pos);
let diff = probe_sd - centre_sd;
if diff > best_diff {
best_diff = diff;
best_dir = Some(probe_dir.normalize());
}
}
best_dir
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LandmassSourceDummy;
struct BboxLand {
lon_min: f64,
lon_max: f64,
lat_min: f64,
lat_max: f64,
}
impl LandmassSource for BboxLand {
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 project_off_land_pushes_waypoint_out_of_synthetic_island() {
let land = BboxLand {
lon_min: -2.0,
lon_max: 2.0,
lat_min: -2.0,
lat_max: 2.0,
};
let inside = LatLon::new(0.0, 0.0);
assert!(land.is_land(inside));
let projected = project_off_land(&land, inside);
let sd = land.signed_distance_m(projected);
assert!(
sd >= 0.0,
"expected projected waypoint to be over water, got SDF = {sd} m at {projected:?}",
);
}
#[test]
fn project_off_land_is_noop_for_dummy_source() {
let land = LandmassSourceDummy;
let p = LatLon::new(3.0, 4.0);
let projected = project_off_land(&land, p);
assert_eq!(projected, p, "dummy source must short-circuit projection");
}
#[test]
fn project_off_land_is_noop_when_already_safe() {
let land = BboxLand {
lon_min: -2.0,
lon_max: 2.0,
lat_min: -2.0,
lat_max: 2.0,
};
let p = LatLon::new(20.0, 20.0);
let projected = project_off_land(&land, p);
assert_eq!(projected, p, "safe waypoint must not move");
}
}