use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, VecDeque};
use std::sync::{Mutex, OnceLock};
use serde::Deserialize;
use swarmkit_sailing::spherical::{
LatLon, METRES_PER_DEGREE, TangentMetres, haversine, signed_lon_delta, wrap_lon_deg,
};
use swarmkit_sailing::{LandmassSource, RouteBounds, SeaPathBias};
#[expect(
clippy::large_include_file,
reason = "Natural Earth coastline data is embedded deliberately so the \
landmass-aware search works without an asset-loading step on \
every consumer."
)]
const LAND_GEOJSON: &[u8] = include_bytes!("../assets/ne_50m_land.geojson");
pub const SDF_RESOLUTION_DEG: f64 = 0.5;
#[derive(Debug, Clone)]
pub struct Polygon {
pub rings: Vec<Vec<(f64, f64)>>,
}
#[derive(Deserialize)]
struct FeatureCollection {
features: Vec<Feature>,
}
#[derive(Deserialize)]
struct Feature {
geometry: Geometry,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Geometry {
Polygon {
coordinates: Vec<Vec<[f64; 2]>>,
},
MultiPolygon {
coordinates: Vec<Vec<Vec<[f64; 2]>>>,
},
#[serde(other)]
Other,
}
impl Geometry {
fn into_polygons(self) -> Vec<Polygon> {
let convert_ring = |ring: Vec<[f64; 2]>| -> Vec<(f64, f64)> {
let mut out: Vec<(f64, f64)> = ring.into_iter().map(|p| (p[0], p[1])).collect();
if out.len() >= 2 && out.first() == out.last() {
out.pop();
}
out
};
match self {
Self::Polygon { coordinates } => {
vec![Polygon {
rings: coordinates.into_iter().map(convert_ring).collect(),
}]
}
Self::MultiPolygon { coordinates } => coordinates
.into_iter()
.map(|p| Polygon {
rings: p.into_iter().map(convert_ring).collect(),
})
.collect(),
Self::Other => Vec::new(),
}
}
}
pub fn raw_polygons() -> &'static [Polygon] {
static POLYGONS: OnceLock<Vec<Polygon>> = OnceLock::new();
POLYGONS.get_or_init(
|| match serde_json::from_slice::<FeatureCollection>(LAND_GEOJSON) {
Ok(fc) => fc
.features
.into_iter()
.flat_map(|f| f.geometry.into_polygons())
.collect(),
Err(e) => {
log::error!("failed to parse bundled landmasses: {e}");
Vec::new()
}
},
)
}
fn polygon_bbox(poly: &Polygon) -> (f64, f64, f64, f64) {
let mut lon_min = f64::INFINITY;
let mut lon_max = f64::NEG_INFINITY;
let mut lat_min = f64::INFINITY;
let mut lat_max = f64::NEG_INFINITY;
for ring in &poly.rings {
for &(lon, lat) in ring {
lon_min = lon_min.min(lon);
lon_max = lon_max.max(lon);
lat_min = lat_min.min(lat);
lat_max = lat_max.max(lat);
}
}
(lon_min, lon_max, lat_min, lat_max)
}
fn point_in_polygon(lon: f64, lat: f64, poly: &Polygon) -> bool {
let mut inside = false;
for ring in &poly.rings {
if point_in_ring(lon, lat, ring) {
inside = !inside;
}
}
inside
}
fn point_in_ring(lon: f64, lat: f64, ring: &[(f64, f64)]) -> bool {
let n = ring.len();
if n < 3 {
return false;
}
let mut inside = false;
for k in 0..n {
let (x1, y1) = ring[k];
let (x2, y2) = ring[(k + 1) % n];
if (y1 > lat) != (y2 > lat) {
let xint = x1 + (lat - y1) * (x2 - x1) / (y2 - y1);
if lon < xint {
inside = !inside;
}
}
}
inside
}
fn rasterise_mask(polygons: &[Polygon], width: usize, height: usize, cell_deg: f64) -> Vec<bool> {
let mut mask = vec![false; width * height];
for poly in polygons {
let (lon_min, lon_max, lat_min, lat_max) = polygon_bbox(poly);
let i_lo = (((lon_min + 180.0) / cell_deg - 0.5).ceil() as isize).max(0) as usize;
let i_hi = (((lon_max + 180.0) / cell_deg - 0.5).floor() as isize + 1).max(0) as usize;
let j_lo = (((lat_min + 90.0) / cell_deg - 0.5).ceil() as isize).max(0) as usize;
let j_hi = (((lat_max + 90.0) / cell_deg - 0.5).floor() as isize + 1).max(0) as usize;
let i_hi = i_hi.min(width);
let j_hi = j_hi.min(height);
for j in j_lo..j_hi {
let lat = -90.0 + (j as f64 + 0.5) * cell_deg;
for i in i_lo..i_hi {
let lon = -180.0 + (i as f64 + 0.5) * cell_deg;
if !mask[j * width + i] && point_in_polygon(lon, lat, poly) {
mask[j * width + i] = true;
}
}
}
}
mask
}
const INF: i32 = 1 << 24;
fn dist_sq(g: (i32, i32)) -> i64 {
i64::from(g.0) * i64::from(g.0) + i64::from(g.1) * i64::from(g.1)
}
fn better(p: (i32, i32), q: (i32, i32)) -> (i32, i32) {
if dist_sq(q) < dist_sq(p) { q } else { p }
}
fn distance_transform(
seed_mask: &[bool],
width: usize,
height: usize,
lon_wrap: bool,
) -> Vec<(i32, i32)> {
let mut g = vec![(INF, INF); width * height];
for (idx, &m) in seed_mask.iter().enumerate() {
if m {
g[idx] = (0, 0);
}
}
let lon_minus = |i: usize| -> Option<usize> {
if i > 0 {
Some(i - 1)
} else if lon_wrap {
Some(width - 1)
} else {
None
}
};
let lon_plus = |i: usize| -> Option<usize> {
if i + 1 < width {
Some(i + 1)
} else if lon_wrap {
Some(0)
} else {
None
}
};
for j in 0..height {
for i in 0..width {
let mut best = g[j * width + i];
if let Some(il) = lon_minus(i) {
let n = g[j * width + il];
best = better(best, (n.0 + 1, n.1));
}
if j > 0 {
if let Some(il) = lon_minus(i) {
let n = g[(j - 1) * width + il];
best = better(best, (n.0 + 1, n.1 + 1));
}
let n = g[(j - 1) * width + i];
best = better(best, (n.0, n.1 + 1));
if let Some(ir) = lon_plus(i) {
let n = g[(j - 1) * width + ir];
best = better(best, (n.0 - 1, n.1 + 1));
}
}
g[j * width + i] = best;
}
}
for j in (0..height).rev() {
for i in (0..width).rev() {
let mut best = g[j * width + i];
if let Some(ir) = lon_plus(i) {
let n = g[j * width + ir];
best = better(best, (n.0 - 1, n.1));
}
if j + 1 < height {
if let Some(ir) = lon_plus(i) {
let n = g[(j + 1) * width + ir];
best = better(best, (n.0 - 1, n.1 - 1));
}
let n = g[(j + 1) * width + i];
best = better(best, (n.0, n.1 - 1));
if let Some(il) = lon_minus(i) {
let n = g[(j + 1) * width + il];
best = better(best, (n.0 + 1, n.1 - 1));
}
}
g[j * width + i] = best;
}
}
g
}
pub struct LandmassGrid {
width: usize,
height: usize,
cell_deg: f64,
sdf_m: Vec<f32>,
grad_en: Vec<(f32, f32)>,
}
impl LandmassGrid {
pub fn build(polygons: &[Polygon], cell_deg: f64) -> Self {
assert!(cell_deg > 0.0, "cell_deg must be positive, got {cell_deg}");
let width = (360.0 / cell_deg).round() as usize;
let height = (180.0 / cell_deg).round() as usize;
let mask = rasterise_mask(polygons, width, height, cell_deg);
Self::from_mask(&mask, width, height, cell_deg)
}
pub fn from_mask(mask: &[bool], width: usize, height: usize, cell_deg: f64) -> Self {
assert_eq!(mask.len(), width * height, "mask length mismatch");
let to_land = distance_transform(mask, width, height, true);
let inverted: Vec<bool> = mask.iter().map(|&b| !b).collect();
let to_water = distance_transform(&inverted, width, height, true);
let cell_m = cell_deg * METRES_PER_DEGREE;
let mut sdf_m = vec![0.0f32; width * height];
for (idx, &m) in mask.iter().enumerate() {
let unsigned = if m {
(dist_sq(to_water[idx]) as f64).sqrt()
} else {
(dist_sq(to_land[idx]) as f64).sqrt()
};
let metres = unsigned * cell_m;
sdf_m[idx] = if m { -metres as f32 } else { metres as f32 };
}
let grad_en = compute_gradient(&sdf_m, width, height, cell_deg);
Self {
width,
height,
cell_deg,
sdf_m,
grad_en,
}
}
fn lon_to_cell(&self, lon: f64) -> f64 {
((lon + 180.0).rem_euclid(360.0)) / self.cell_deg - 0.5
}
fn lat_to_cell(&self, lat: f64) -> f64 {
(lat + 90.0) / self.cell_deg - 0.5
}
fn wrap_i(&self, i: isize) -> usize {
i.rem_euclid(self.width as isize) as usize
}
fn cell_centre(&self, i: usize, j: usize) -> LatLon {
let lon = -180.0 + (i as f64 + 0.5) * self.cell_deg;
let lat = -90.0 + (j as f64 + 0.5) * self.cell_deg;
LatLon::new(lon, lat)
}
fn cell_index_of(&self, location: LatLon) -> (usize, usize) {
let fi = self.lon_to_cell(location.lon) + 0.5;
let fj = (self.lat_to_cell(location.lat) + 0.5).clamp(0.0, (self.height - 1) as f64);
let i = self.wrap_i(fi.floor() as isize);
let j = (fj.floor() as usize).min(self.height - 1);
(i, j)
}
fn cell_idx(&self, i: usize, j: usize) -> usize {
j * self.width + i
}
fn is_sea(&self, i: usize, j: usize) -> bool {
self.sdf_m[self.cell_idx(i, j)] >= 0.0
}
fn bilinear<F: Fn(usize, usize) -> f32>(&self, lon: f64, lat: f64, sample: F) -> f32 {
let fi = self.lon_to_cell(lon);
let fj = self.lat_to_cell(lat).clamp(0.0, (self.height - 1) as f64);
let i0 = fi.floor() as isize;
let j0 = (fj.floor() as isize).clamp(0, self.height as isize - 1);
let alpha = (fi - fi.floor()) as f32;
let beta = (fj - fj.floor()) as f32;
let i1 = i0 + 1;
let j1 = (j0 + 1).min(self.height as isize - 1);
let i0w = self.wrap_i(i0);
let i1w = self.wrap_i(i1);
let j0u = j0 as usize;
let j1u = j1 as usize;
let s00 = sample(i0w, j0u);
let s10 = sample(i1w, j0u);
let s01 = sample(i0w, j1u);
let s11 = sample(i1w, j1u);
let s0 = s00 * (1.0 - alpha) + s10 * alpha;
let s1 = s01 * (1.0 - alpha) + s11 * alpha;
s0 * (1.0 - beta) + s1 * beta
}
}
impl LandmassSource for LandmassGrid {
fn signed_distance_m(&self, location: LatLon) -> f64 {
let lon = wrap_lon_deg(location.lon);
f64::from(self.bilinear(lon, location.lat, |i, j| self.sdf_m[j * self.width + i]))
}
fn gradient(&self, location: LatLon) -> TangentMetres {
let lon = wrap_lon_deg(location.lon);
let east = self.bilinear(lon, location.lat, |i, j| self.grad_en[j * self.width + i].0);
let north = self.bilinear(lon, location.lat, |i, j| self.grad_en[j * self.width + i].1);
TangentMetres::new(f64::from(east), f64::from(north))
}
fn find_sea_path(
&self,
origin: LatLon,
destination: LatLon,
bounds: &RouteBounds,
bias: SeaPathBias,
) -> Option<Vec<LatLon>> {
astar_sea_path(self, origin, destination, bounds, bias)
}
}
fn compute_gradient(sdf_m: &[f32], width: usize, height: usize, cell_deg: f64) -> Vec<(f32, f32)> {
let mut grad = vec![(0.0f32, 0.0f32); width * height];
let cell_m = cell_deg * METRES_PER_DEGREE;
for j in 0..height {
let lat = -90.0 + (j as f64 + 0.5) * cell_deg;
let cos_lat = lat.to_radians().cos().max(1e-9);
let inv_dx_m = 1.0 / (2.0 * cell_m * cos_lat);
let inv_dy_m = 1.0 / (2.0 * cell_m);
for i in 0..width {
let il = ((i as isize - 1).rem_euclid(width as isize)) as usize;
let ir = ((i as isize + 1).rem_euclid(width as isize)) as usize;
let jb = j.saturating_sub(1);
let jt = (j + 1).min(height - 1);
let dsdx = (sdf_m[j * width + ir] - sdf_m[j * width + il]) as f64 * inv_dx_m;
let dsdy = (sdf_m[jt * width + i] - sdf_m[jb * width + i]) as f64 * inv_dy_m;
let mag = dsdx.hypot(dsdy);
let g = if mag > 1e-12 {
(dsdx / mag, dsdy / mag)
} else {
(0.0, 0.0)
};
grad[j * width + i] = (g.0 as f32, g.1 as f32);
}
}
grad
}
const BIAS_BARRIER_SLACK_DEG: f64 = 0.5;
const SNAP_TO_SEA_MAX_RING: usize = 32;
const ASTAR_MAX_EXPANSIONS: usize = 1_000_000;
#[derive(Copy, Clone, Debug)]
struct AStarNode {
f_score: f64,
cell: u32,
}
impl PartialEq for AStarNode {
fn eq(&self, other: &Self) -> bool {
self.f_score == other.f_score
}
}
impl Eq for AStarNode {}
impl PartialOrd for AStarNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for AStarNode {
fn cmp(&self, other: &Self) -> Ordering {
other
.f_score
.partial_cmp(&self.f_score)
.unwrap_or(Ordering::Equal)
}
}
fn snap_to_sea_cell(grid: &LandmassGrid, location: LatLon) -> Option<(usize, usize)> {
let (i0, j0) = grid.cell_index_of(location);
if grid.is_sea(i0, j0) {
return Some((i0, j0));
}
let mut visited = vec![false; grid.width * grid.height];
let mut queue = VecDeque::new();
visited[grid.cell_idx(i0, j0)] = true;
queue.push_back((i0, j0, 0usize));
while let Some((i, j, ring)) = queue.pop_front() {
if grid.is_sea(i, j) {
return Some((i, j));
}
if ring >= SNAP_TO_SEA_MAX_RING {
continue;
}
for (ni, nj) in neighbours_8(grid, i, j) {
let idx = grid.cell_idx(ni, nj);
if !visited[idx] {
visited[idx] = true;
queue.push_back((ni, nj, ring + 1));
}
}
}
None
}
fn neighbours_8(
grid: &LandmassGrid,
i: usize,
j: usize,
) -> impl Iterator<Item = (usize, usize)> + '_ {
[-1isize, 0, 1].into_iter().flat_map(move |dj| {
[-1isize, 0, 1].into_iter().filter_map(move |di| {
if di == 0 && dj == 0 {
return None;
}
let nj_signed = j as isize + dj;
if nj_signed < 0 || nj_signed >= grid.height as isize {
return None;
}
let ni = grid.wrap_i(i as isize + di);
Some((ni, nj_signed as usize))
})
})
}
fn line_lat_at_lon(bounds: &RouteBounds, lon: f64) -> f64 {
let origin = bounds.origin;
let dest = bounds.destination;
let total_dlon = signed_lon_delta(origin.lon, dest.lon);
if total_dlon.abs() < 1e-12 {
return (origin.lat + dest.lat) * 0.5;
}
let dlon_to_query = signed_lon_delta(origin.lon, lon);
let t = (dlon_to_query / total_dlon).clamp(0.0, 1.0);
origin.lat + t * (dest.lat - origin.lat)
}
fn bias_allows(
grid: &LandmassGrid,
bounds: &RouteBounds,
start: (usize, usize),
goal: (usize, usize),
cell: (usize, usize),
bias: SeaPathBias,
) -> bool {
if cell == start || cell == goal {
return true;
}
let centre = grid.cell_centre(cell.0, cell.1);
let line_lat = line_lat_at_lon(bounds, centre.lon);
let offset = centre.lat - line_lat;
match bias {
SeaPathBias::None => true,
SeaPathBias::North => offset >= -BIAS_BARRIER_SLACK_DEG,
SeaPathBias::South => offset <= BIAS_BARRIER_SLACK_DEG,
}
}
fn astar_sea_path(
grid: &LandmassGrid,
origin: LatLon,
destination: LatLon,
bounds: &RouteBounds,
bias: SeaPathBias,
) -> Option<Vec<LatLon>> {
let start = snap_to_sea_cell(grid, origin)?;
let goal = snap_to_sea_cell(grid, destination)?;
if start == goal {
return Some(vec![origin, destination]);
}
let n_cells = grid.width * grid.height;
let mut g_score = vec![f64::INFINITY; n_cells];
let mut came_from: Vec<u32> = vec![u32::MAX; n_cells];
let mut closed = vec![false; n_cells];
let mut open: BinaryHeap<AStarNode> = BinaryHeap::new();
let goal_centre = grid.cell_centre(goal.0, goal.1);
let start_idx = grid.cell_idx(start.0, start.1);
g_score[start_idx] = 0.0;
open.push(AStarNode {
f_score: haversine(grid.cell_centre(start.0, start.1), goal_centre),
cell: start_idx as u32,
});
let mut expansions = 0usize;
while let Some(AStarNode { cell, .. }) = open.pop() {
let cell = cell as usize;
if closed[cell] {
continue;
}
closed[cell] = true;
if cell == grid.cell_idx(goal.0, goal.1) {
return Some(reconstruct_polyline(
grid,
&came_from,
cell,
origin,
destination,
));
}
expansions += 1;
if expansions > ASTAR_MAX_EXPANSIONS {
log::warn!(
"find_sea_path: A* aborted after {ASTAR_MAX_EXPANSIONS} expansions \
(origin={origin:?}, destination={destination:?})",
);
return None;
}
let i = cell % grid.width;
let j = cell / grid.width;
let cur_centre = grid.cell_centre(i, j);
for (ni, nj) in neighbours_8(grid, i, j) {
if !grid.is_sea(ni, nj) {
continue;
}
if !bias_allows(grid, bounds, start, goal, (ni, nj), bias) {
continue;
}
let neighbour_centre = grid.cell_centre(ni, nj);
if (ni, nj) != start && (ni, nj) != goal && !bounds.bbox.contains(neighbour_centre) {
continue;
}
let step = haversine(cur_centre, neighbour_centre);
let tentative_g = g_score[cell] + step;
let n_idx = grid.cell_idx(ni, nj);
if tentative_g < g_score[n_idx] {
g_score[n_idx] = tentative_g;
came_from[n_idx] = cell as u32;
let h = haversine(neighbour_centre, goal_centre);
open.push(AStarNode {
f_score: tentative_g + h,
cell: n_idx as u32,
});
}
}
}
None
}
fn reconstruct_polyline(
grid: &LandmassGrid,
came_from: &[u32],
goal_cell: usize,
origin: LatLon,
destination: LatLon,
) -> Vec<LatLon> {
let mut cells: Vec<usize> = vec![goal_cell];
let mut cur = goal_cell;
while came_from[cur] != u32::MAX {
cur = came_from[cur] as usize;
cells.push(cur);
}
cells.reverse();
let mut polyline: Vec<LatLon> = Vec::with_capacity(cells.len() + 2);
polyline.push(origin);
for cell in cells {
let i = cell % grid.width;
let j = cell / grid.width;
polyline.push(grid.cell_centre(i, j));
}
polyline.push(destination);
polyline
}
pub fn landmass_grid() -> &'static LandmassGrid {
landmass_grid_at_resolution(SDF_RESOLUTION_DEG)
}
pub fn landmass_grid_at_resolution(resolution_deg: f64) -> &'static LandmassGrid {
static REGISTRY: OnceLock<Mutex<HashMap<u64, &'static LandmassGrid>>> = OnceLock::new();
let registry = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
let key = resolution_deg.to_bits();
let mut guard = match registry.lock() {
Ok(g) => g,
Err(poison) => poison.into_inner(),
};
if let Some(grid) = guard.get(&key) {
return grid;
}
let built: &'static LandmassGrid = Box::leak(Box::new(LandmassGrid::build(
raw_polygons(),
resolution_deg,
)));
guard.insert(key, built);
built
}
#[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 swarmkit_sailing::spherical::LonLatBbox;
use swarmkit_sailing::{PathBaseline, get_segment_land_metres};
#[test]
fn benchmark_sampler_handles_around_continent_routes() {
let grid = landmass_grid();
let bob_to_angola_bbox = LonLatBbox::new(
-23.450000000000045,
16.450000000000045,
-20.317062759399413,
57.033261680603026,
);
let bob_to_arabian_bbox = LonLatBbox::new(
-32.94999999999999,
73.45000000000005,
-51.650000000000006,
63.150000000000006,
);
let scenarios: &[(&str, LatLon, LatLon, LonLatBbox)] = &[
(
"BoB -> off Angola",
LatLon::new(-6.041525363922119, 45.98321533203125),
LatLon::new(10.615649223327637, -9.267016410827637),
bob_to_angola_bbox,
),
(
"BoB -> Arabian Sea",
LatLon::new(-12.371894836425781, 46.70365524291992),
LatLon::new(58.23857498168945, 10.672679901123047),
bob_to_arabian_bbox,
),
];
for &(name, origin, destination, bbox) in scenarios {
let bounds = swarmkit_sailing::RouteBounds::new(origin, destination, bbox);
let polyline = grid
.find_sea_path(
origin,
destination,
&bounds,
swarmkit_sailing::SeaPathBias::None,
)
.unwrap_or_else(|| panic!("{name}: A* found no path"));
let raw_land: f64 = polyline
.windows(2)
.map(|w| get_segment_land_metres(grid, w[0], w[1], bounds.step_distance_max))
.sum();
assert_eq!(
raw_land, 0.0,
"{name}: raw A* polyline crosses {raw_land:.0} m of land",
);
macro_rules! check_at {
($n:literal) => {{
let baseline =
PathBaseline::<$n>::from_polyline_land_respecting(&polyline, &bounds, grid);
let mut bad: Vec<(usize, f64)> = Vec::new();
for (i, w) in baseline.positions.windows(2).enumerate() {
let land =
get_segment_land_metres(grid, w[0], w[1], bounds.step_distance_max);
if land > 0.0 {
bad.push((i, land));
}
}
assert!(
bad.is_empty(),
"{name} N={}: {} chord(s) cross land, worst {:.0} m at chord {}",
$n,
bad.len(),
bad.iter().map(|&(_, l)| l).fold(0.0_f64, f64::max),
bad.iter().max_by(|a, b| a.1.total_cmp(&b.1)).unwrap().0,
);
}};
}
check_at!(40);
check_at!(60);
}
}
fn mask_from_predicate(
width: usize,
height: usize,
cell_deg: f64,
is_land: impl Fn(f64, f64) -> bool,
) -> Vec<bool> {
let mut m = vec![false; width * height];
for j in 0..height {
for i in 0..width {
let lon = -180.0 + (i as f64 + 0.5) * cell_deg;
let lat = -90.0 + (j as f64 + 0.5) * cell_deg;
m[j * width + i] = is_land(lon, lat);
}
}
m
}
#[test]
fn synthetic_square_island_signed_distance_signs_correctly() {
let cell_deg = 0.5;
let width = (360.0_f64 / cell_deg).round() as usize;
let height = (180.0_f64 / cell_deg).round() as usize;
let mask = mask_from_predicate(width, height, cell_deg, |lon, lat| {
lon.abs() < 1.0 && lat.abs() < 1.0
});
let grid = LandmassGrid::from_mask(&mask, width, height, cell_deg);
let sd_centre = grid.signed_distance_m(LatLon::new(0.0, 0.0));
assert!(
sd_centre < -50_000.0,
"expected deep land, got {sd_centre} m"
);
let sd_ocean = grid.signed_distance_m(LatLon::new(60.0, 0.0));
assert!(
sd_ocean > 1_000_000.0,
"expected deep ocean, got {sd_ocean} m"
);
}
#[test]
fn synthetic_square_island_gradient_points_outward() {
let cell_deg = 0.5;
let width = (360.0_f64 / cell_deg).round() as usize;
let height = (180.0_f64 / cell_deg).round() as usize;
let mask = mask_from_predicate(width, height, cell_deg, |lon, lat| {
lon.abs() < 2.0 && lat.abs() < 2.0
});
let grid = LandmassGrid::from_mask(&mask, width, height, cell_deg);
let g_east = grid.gradient(LatLon::new(5.0, 0.0));
assert!(
g_east.east > 0.5,
"expected eastward gradient, got {g_east:?}"
);
let g_north = grid.gradient(LatLon::new(0.0, 5.0));
assert!(
g_north.north > 0.5,
"expected northward gradient, got {g_north:?}"
);
}
#[test]
fn raw_polygons_yields_at_least_one_polygon() {
let polys = raw_polygons();
assert!(!polys.is_empty(), "expected polygons from bundled GeoJSON");
assert!(
polys
.iter()
.any(|p| p.rings.first().is_some_and(|r| r.len() >= 3)),
"expected at least one non-degenerate ring",
);
}
#[test]
fn default_grid_classifies_known_continents_and_oceans() {
let grid = landmass_grid();
let sahara = grid.signed_distance_m(LatLon::new(20.0, 25.0));
assert!(sahara < 0.0, "Sahara should be land, got SDF = {sahara} m");
let pacific = grid.signed_distance_m(LatLon::new(-150.0, 0.0));
assert!(
pacific > 0.0,
"Mid-Pacific should be ocean, got SDF = {pacific} m"
);
}
#[test]
fn antimeridian_lookup_is_continuous() {
let grid = landmass_grid();
let lat = 0.0;
let east = grid.signed_distance_m(LatLon::new(179.99, lat));
let west = grid.signed_distance_m(LatLon::new(-179.99, lat));
let cell_m = SDF_RESOLUTION_DEG * METRES_PER_DEGREE;
assert!(
(east - west).abs() < cell_m,
"antimeridian discontinuity: east={east} m, west={west} m",
);
}
fn synthetic_grid_with_vertical_obstacle() -> LandmassGrid {
let cell_deg = 1.0;
let width = 360;
let height = 180;
let mask = mask_from_predicate(width, height, cell_deg, |lon, lat| {
lon.abs() < 2.0 && lat.abs() < 10.0
});
LandmassGrid::from_mask(&mask, width, height, cell_deg)
}
fn route_bounds(origin: LatLon, destination: LatLon) -> RouteBounds {
RouteBounds::new(
origin,
destination,
LonLatBbox::new(-30.0, 30.0, -25.0, 25.0),
)
}
#[test]
fn find_sea_path_returns_polyline_for_open_ocean_route() {
let grid = landmass_grid();
let origin = LatLon::new(-30.0, 0.0);
let destination = LatLon::new(-150.0, 0.0);
let bounds = RouteBounds::new(
origin,
destination,
LonLatBbox::new(-180.0, 180.0, -45.0, 45.0),
);
let polyline = grid
.find_sea_path(origin, destination, &bounds, SeaPathBias::None)
.expect("path");
assert!(polyline.len() >= 2, "polyline must have at least endpoints");
assert_eq!(polyline.first(), Some(&origin), "starts at origin");
assert_eq!(polyline.last(), Some(&destination), "ends at destination");
for &point in &polyline {
assert!(
grid.signed_distance_m(point) >= -SDF_RESOLUTION_DEG * METRES_PER_DEGREE,
"intermediate point {point:?} on land",
);
}
}
#[test]
fn find_sea_path_routes_around_synthetic_obstacle() {
let grid = synthetic_grid_with_vertical_obstacle();
let origin = LatLon::new(-10.0, 0.0);
let destination = LatLon::new(10.0, 0.0);
let bounds = route_bounds(origin, destination);
let polyline = grid
.find_sea_path(origin, destination, &bounds, SeaPathBias::None)
.expect("path");
assert!(polyline.len() > 4, "expected detour, got {polyline:?}");
for &point in &polyline {
assert!(!grid.is_land(point), "polyline point {point:?} on land",);
}
}
#[test]
fn find_sea_path_respects_route_bbox() {
let grid = synthetic_grid_with_vertical_obstacle();
let origin = LatLon::new(-10.0, 0.0);
let destination = LatLon::new(10.0, 0.0);
let bounds = RouteBounds::new(
origin,
destination,
LonLatBbox::new(-30.0, 30.0, -20.0, 9.0),
);
let polyline = grid
.find_sea_path(origin, destination, &bounds, SeaPathBias::None)
.expect("south detour should be reachable inside the bbox");
for &p in &polyline {
if p == origin || p == destination {
continue;
}
assert!(
bounds.bbox.contains(p),
"polyline vertex {p:?} outside bbox {:?}",
bounds.bbox,
);
}
let min_lat = polyline.iter().map(|p| p.lat).fold(f64::INFINITY, f64::min);
let max_lat = polyline
.iter()
.map(|p| p.lat)
.fold(f64::NEG_INFINITY, f64::max);
assert!(
min_lat < -9.0,
"expected south detour (min lat < -9), got min_lat = {min_lat}",
);
assert!(
max_lat <= 9.0,
"no vertex should exceed bbox lat_max = 9, got max_lat = {max_lat}",
);
}
#[test]
fn biased_sea_paths_take_opposite_sides_of_obstacle() {
let grid = synthetic_grid_with_vertical_obstacle();
let origin = LatLon::new(-10.0, 0.0);
let destination = LatLon::new(10.0, 0.0);
let bounds = route_bounds(origin, destination);
let north = grid
.find_sea_path(origin, destination, &bounds, SeaPathBias::North)
.expect("north path");
let south = grid
.find_sea_path(origin, destination, &bounds, SeaPathBias::South)
.expect("south path");
let north_max_lat = north
.iter()
.map(|p| p.lat)
.fold(f64::NEG_INFINITY, f64::max);
let south_min_lat = south.iter().map(|p| p.lat).fold(f64::INFINITY, f64::min);
assert!(
north_max_lat > 9.0,
"north-biased path should detour above the bar, max lat = {north_max_lat}",
);
assert!(
south_min_lat < -9.0,
"south-biased path should detour below the bar, min lat = {south_min_lat}",
);
}
#[test]
fn find_sea_path_returns_none_when_endpoints_landlocked() {
let cell_deg = 1.0;
let width = 360;
let height = 180;
let mask = mask_from_predicate(width, height, cell_deg, |lon, lat| {
!(lon.abs() < 2.0 && lat.abs() < 2.0)
});
let grid = LandmassGrid::from_mask(&mask, width, height, cell_deg);
let origin = LatLon::new(60.0, 30.0);
let destination = LatLon::new(-60.0, -30.0);
let bounds = RouteBounds::new(
origin,
destination,
LonLatBbox::new(-90.0, 90.0, -60.0, 60.0),
);
assert!(
grid.find_sea_path(origin, destination, &bounds, SeaPathBias::None)
.is_none()
);
}
}