use std::fmt;
use geo::prelude::Contains;
use geo::{Closest, HaversineClosestPoint};
#[cfg(feature = "geojson")]
use geojson::GeoJson;
#[cfg(feature = "geojson")]
use std::convert::TryInto;
use thiserror::Error;
fn densify_polygon_linear(polygon: geo::Polygon<f64>, max_deg: f64) -> geo::Polygon<f64> {
let exterior = densify_ring_linear(polygon.exterior(), max_deg);
let interiors: Vec<_> = polygon
.interiors()
.iter()
.map(|ring| densify_ring_linear(ring, max_deg))
.collect();
geo::Polygon::new(exterior, interiors)
}
fn densify_ring_linear(ring: &geo::LineString<f64>, max_deg: f64) -> geo::LineString<f64> {
let coords: Vec<geo::Coord<f64>> = ring.coords().copied().collect();
let mut out: Vec<geo::Coord<f64>> = Vec::with_capacity(coords.len() * 4);
for pair in coords.windows(2) {
let (x0, y0) = (pair[0].x, pair[0].y);
let (x1, y1) = (pair[1].x, pair[1].y);
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
let n = ((len / max_deg).ceil() as usize).max(1);
out.push(geo::Coord { x: x0, y: y0 });
for i in 1..n {
let t = i as f64 / n as f64;
out.push(geo::Coord {
x: x0 + t * dx,
y: y0 + t * dy,
});
}
}
if let Some(&last) = coords.last() {
out.push(last);
}
geo::LineString::new(out)
}
fn haversine_distance(a: geo::Point<f64>, b: geo::Point<f64>, radius_m: f64) -> f64 {
let (lon1, lat1) = (a.x().to_radians(), a.y().to_radians());
let (lon2, lat2) = (b.x().to_radians(), b.y().to_radians());
let dlat = lat2 - lat1;
let dlon = lon2 - lon1;
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
2.0 * radius_m * h.sqrt().asin()
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AoiId(String);
impl AoiId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AoiId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone)]
pub struct Aoi {
polygon: geo::Polygon<f64>,
}
#[cfg(feature = "geojson")]
#[derive(Debug, Error)]
pub enum AoiError {
#[error("invalid GeoJSON: {0}")]
InvalidGeoJson(String),
#[error("no polygon found in GeoJSON")]
NoPolygon,
}
impl Aoi {
pub fn new(polygon: geo::Polygon<f64>) -> Self {
let polygon = densify_polygon_linear(polygon, 0.5);
Self { polygon }
}
#[cfg(feature = "geojson")]
pub fn from_geojson(geojson: &str) -> Result<Self, AoiError> {
let gj: GeoJson = geojson
.parse()
.map_err(|e: geojson::Error| AoiError::InvalidGeoJson(e.to_string()))?;
let geometry = match gj {
GeoJson::Geometry(g) => g,
GeoJson::Feature(f) => f.geometry.ok_or(AoiError::NoPolygon)?,
GeoJson::FeatureCollection(fc) => fc
.features
.into_iter()
.find_map(|f| f.geometry)
.ok_or(AoiError::NoPolygon)?,
};
let polygon: geo::Polygon<f64> =
geometry.value.try_into().map_err(|_| AoiError::NoPolygon)?;
Ok(Self::new(polygon))
}
pub fn distance_to(&self, point: &geo::Point<f64>, mean_radius_m: f64) -> f64 {
if self.polygon.contains(point) {
0.0
} else {
match self.polygon.haversine_closest_point(point) {
Closest::Intersection(_) => 0.0,
Closest::SinglePoint(closest) => haversine_distance(*point, closest, mean_radius_m),
Closest::Indeterminate => f64::INFINITY,
}
}
}
pub fn nearest_point(&self, point: &geo::Point<f64>) -> geo::Point<f64> {
if self.polygon.contains(point) {
return *point;
}
match self.polygon.haversine_closest_point(point) {
geo::Closest::Intersection(p) | geo::Closest::SinglePoint(p) => p,
geo::Closest::Indeterminate => *point,
}
}
pub fn nearest_point_and_distance(
&self,
point: &geo::Point<f64>,
mean_radius_m: f64,
) -> (geo::Point<f64>, f64) {
if self.polygon.contains(point) {
return (*point, 0.0);
}
match self.polygon.haversine_closest_point(point) {
Closest::Intersection(p) => (p, 0.0),
Closest::SinglePoint(p) => (p, haversine_distance(*point, p, mean_radius_m)),
Closest::Indeterminate => (*point, f64::INFINITY),
}
}
pub fn polygon(&self) -> &geo::Polygon<f64> {
&self.polygon
}
}
#[cfg(test)]
mod tests {
use super::*;
use geo::{Distance as GeoDistance, Geodesic, LineString, Polygon, point};
use lox_bodies::{Earth, MeanRadius};
fn earth_mean_radius_m() -> f64 {
Earth.mean_radius().to_meters()
}
#[test]
fn test_aoi_id() {
let id = AoiId::new("rome");
assert_eq!(id.as_str(), "rome");
assert_eq!(format!("{id}"), "rome");
}
#[test]
fn test_aoi_distance_inside() {
let polygon = Polygon::new(
LineString::from(vec![
(10.0, 45.0),
(11.0, 45.0),
(11.0, 46.0),
(10.0, 46.0),
(10.0, 45.0),
]),
vec![],
);
let aoi = Aoi::new(polygon);
let inside = point!(x: 10.5, y: 45.5);
assert_eq!(aoi.distance_to(&inside, earth_mean_radius_m()), 0.0);
}
#[test]
fn test_aoi_distance_large_polygon_near_great_circle_arc_peak() {
let polygon = Polygon::new(
LineString::from(vec![
(-45.0, 30.0),
(55.0, 30.0),
(55.0, 50.0),
(-45.0, 50.0),
(-45.0, 30.0),
]),
vec![],
);
let aoi = Aoi::new(polygon);
let near_arc_peak = point!(x: 5.0, y: 62.0);
let d = aoi.distance_to(&near_arc_peak, earth_mean_radius_m());
assert!(
d > 1_000_000.0,
"expected distance > 1000 km for point 12° north of AOI boundary, got {:.0} m",
d
);
}
#[test]
fn test_aoi_distance_outside() {
let polygon = Polygon::new(
LineString::from(vec![
(10.0, 45.0),
(11.0, 45.0),
(11.0, 46.0),
(10.0, 46.0),
(10.0, 45.0),
]),
vec![],
);
let aoi = Aoi::new(polygon);
let outside = point!(x: 12.0, y: 45.5);
let d = aoi.distance_to(&outside, earth_mean_radius_m());
assert!(d > 0.0);
assert!(d > 70_000.0 && d < 90_000.0);
}
#[test]
fn test_haversine_matches_geodesic_for_earth() {
let a = point!(x: 11.0, y: 45.5); let b = point!(x: 12.0, y: 45.5);
let geodesic = Geodesic::distance(a, b);
let haversine = haversine_distance(a, b, earth_mean_radius_m());
let rel_err = (haversine - geodesic).abs() / geodesic;
assert!(
rel_err < 0.005,
"haversine ({haversine:.1}) vs geodesic ({geodesic:.1}): {:.2}% error",
rel_err * 100.0,
);
}
#[test]
fn test_distance_scales_with_body_radius() {
let polygon = Polygon::new(
LineString::from(vec![
(10.0, 45.0),
(11.0, 45.0),
(11.0, 46.0),
(10.0, 46.0),
(10.0, 45.0),
]),
vec![],
);
let aoi = Aoi::new(polygon);
let outside = point!(x: 12.0, y: 45.5);
let earth_d = aoi.distance_to(&outside, earth_mean_radius_m());
let mars_d = aoi.distance_to(&outside, 3_389_500.0);
assert!(mars_d < earth_d);
let ratio = earth_d / mars_d;
let expected_ratio = earth_mean_radius_m() / 3_389_500.0;
assert!(
(ratio - expected_ratio).abs() < 0.01,
"distance should scale linearly with radius"
);
}
#[cfg(feature = "geojson")]
#[test]
fn test_aoi_from_geojson() {
let geojson =
r#"{"type":"Polygon","coordinates":[[[10,45],[11,45],[11,46],[10,46],[10,45]]]}"#;
let aoi = Aoi::from_geojson(geojson).unwrap();
let inside = point!(x: 10.5, y: 45.5);
assert_eq!(aoi.distance_to(&inside, earth_mean_radius_m()), 0.0);
}
#[cfg(feature = "geojson")]
#[test]
fn test_aoi_from_geojson_feature() {
let geojson = r#"{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10,45],[11,45],[11,46],[10,46],[10,45]]]},"properties":{}}"#;
let aoi = Aoi::from_geojson(geojson).unwrap();
let inside = point!(x: 10.5, y: 45.5);
assert_eq!(aoi.distance_to(&inside, earth_mean_radius_m()), 0.0);
}
#[cfg(feature = "geojson")]
#[test]
fn test_aoi_from_geojson_invalid() {
let result = Aoi::from_geojson("not json");
assert!(result.is_err());
}
#[test]
fn nearest_point_inside_polygon_returns_query_point() {
let aoi = Aoi::new(Polygon::new(
LineString::from(vec![
(10.0, 45.0),
(11.0, 45.0),
(11.0, 46.0),
(10.0, 46.0),
(10.0, 45.0),
]),
vec![],
));
let inside = point!(x: 10.5, y: 45.5);
let np = aoi.nearest_point(&inside);
assert!((np.x() - 10.5).abs() < 1e-12 && (np.y() - 45.5).abs() < 1e-12);
}
#[test]
fn nearest_point_outside_polygon_lies_on_boundary() {
let aoi = Aoi::new(Polygon::new(
LineString::from(vec![
(10.0, 45.0),
(11.0, 45.0),
(11.0, 46.0),
(10.0, 46.0),
(10.0, 45.0),
]),
vec![],
));
let outside = point!(x: 12.0, y: 45.5);
let np = aoi.nearest_point(&outside);
assert!((np.x() - 11.0).abs() < 0.01);
assert!((np.y() - 45.5).abs() < 0.05);
}
}