use std::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign};
pub const EARTH_RADIUS_M: f64 = 6_371_000.0;
pub const METRES_PER_DEGREE: f64 = std::f64::consts::PI * EARTH_RADIUS_M / 180.0;
pub const POLE_LATITUDE_LIMIT_DEG: f64 = 89.99;
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct LatLon {
pub lon: f64,
pub lat: f64,
}
impl LatLon {
pub const fn new(lon: f64, lat: f64) -> Self {
Self { lon, lat }
}
pub fn delta_to(self, other: Self) -> LatLonDelta {
LatLonDelta::new(signed_lon_delta(self.lon, other.lon), other.lat - self.lat)
}
pub fn offset_by(self, tangent: TangentMetres) -> Self {
self + tangent_metres_to_delta(self, tangent)
}
}
impl From<(f64, f64)> for LatLon {
fn from((lon, lat): (f64, f64)) -> Self {
Self::new(lon, lat)
}
}
impl Add<LatLonDelta> for LatLon {
type Output = Self;
fn add(self, rhs: LatLonDelta) -> Self::Output {
Self::new(self.lon + rhs.lon, self.lat + rhs.lat)
}
}
impl AddAssign<LatLonDelta> for LatLon {
fn add_assign(&mut self, rhs: LatLonDelta) {
self.lon += rhs.lon;
self.lat += rhs.lat;
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct LatLonDelta {
pub lon: f64,
pub lat: f64,
}
impl LatLonDelta {
pub const fn new(lon: f64, lat: f64) -> Self {
Self { lon, lat }
}
pub const fn zero() -> Self {
Self::new(0.0, 0.0)
}
}
impl From<(f64, f64)> for LatLonDelta {
fn from((lon, lat): (f64, f64)) -> Self {
Self::new(lon, lat)
}
}
impl Add for LatLonDelta {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self::new(self.lon + rhs.lon, self.lat + rhs.lat)
}
}
impl Sub for LatLonDelta {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self::new(self.lon - rhs.lon, self.lat - rhs.lat)
}
}
impl Mul<f64> for LatLonDelta {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self::new(self.lon * rhs, self.lat * rhs)
}
}
impl Neg for LatLonDelta {
type Output = Self;
fn neg(self) -> Self::Output {
Self::new(-self.lon, -self.lat)
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct TangentMetres {
pub east: f64,
pub north: f64,
}
impl TangentMetres {
pub const fn new(east: f64, north: f64) -> Self {
Self { east, north }
}
pub const fn zero() -> Self {
Self::new(0.0, 0.0)
}
pub fn norm(self) -> f64 {
self.east.hypot(self.north)
}
pub fn norm_squared(self) -> f64 {
self.east * self.east + self.north * self.north
}
pub fn normalize(self) -> Self {
let n = self.norm();
if n > 0.0 {
Self::new(self.east / n, self.north / n)
} else {
Self::zero()
}
}
}
impl From<(f64, f64)> for TangentMetres {
fn from((east, north): (f64, f64)) -> Self {
Self::new(east, north)
}
}
impl Add for TangentMetres {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self::new(self.east + rhs.east, self.north + rhs.north)
}
}
impl AddAssign for TangentMetres {
fn add_assign(&mut self, rhs: Self) {
self.east += rhs.east;
self.north += rhs.north;
}
}
impl Sub for TangentMetres {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self::new(self.east - rhs.east, self.north - rhs.north)
}
}
impl SubAssign for TangentMetres {
fn sub_assign(&mut self, rhs: Self) {
self.east -= rhs.east;
self.north -= rhs.north;
}
}
impl Mul<f64> for TangentMetres {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self::new(self.east * rhs, self.north * rhs)
}
}
impl Mul<TangentMetres> for f64 {
type Output = TangentMetres;
fn mul(self, rhs: TangentMetres) -> TangentMetres {
rhs * self
}
}
impl Neg for TangentMetres {
type Output = Self;
fn neg(self) -> Self::Output {
Self::new(-self.east, -self.north)
}
}
pub fn haversine(a: LatLon, b: LatLon) -> f64 {
let lat_a = a.lat.to_radians();
let lat_b = b.lat.to_radians();
let dlat = (b.lat - a.lat).to_radians();
let dlon = signed_lon_delta(a.lon, b.lon).to_radians();
let sin_dlat_half = (dlat * 0.5).sin();
let sin_dlon_half = (dlon * 0.5).sin();
let h =
sin_dlat_half * sin_dlat_half + lat_a.cos() * lat_b.cos() * sin_dlon_half * sin_dlon_half;
2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin()
}
pub fn initial_bearing(a: LatLon, b: LatLon) -> Option<f64> {
if a.lat.abs() >= POLE_LATITUDE_LIMIT_DEG {
return None;
}
let lat_a = a.lat.to_radians();
let lat_b = b.lat.to_radians();
let dlon = signed_lon_delta(a.lon, b.lon).to_radians();
let y = dlon.sin() * lat_b.cos();
let x = lat_a.cos() * lat_b.sin() - lat_a.sin() * lat_b.cos() * dlon.cos();
Some(y.atan2(x))
}
pub fn destination_point(start: LatLon, distance_m: f64, bearing_rad: f64) -> Option<LatLon> {
if start.lat.abs() >= POLE_LATITUDE_LIMIT_DEG {
return None;
}
let angular = distance_m / EARTH_RADIUS_M;
let lat_start = start.lat.to_radians();
let lon_start = start.lon.to_radians();
let sin_lat_dest =
lat_start.sin() * angular.cos() + lat_start.cos() * angular.sin() * bearing_rad.cos();
let lat_dest = sin_lat_dest.clamp(-1.0, 1.0).asin();
let lon_dest = lon_start
+ (bearing_rad.sin() * angular.sin() * lat_start.cos())
.atan2(angular.cos() - lat_start.sin() * sin_lat_dest);
Some(LatLon::new(
wrap_lon_deg(lon_dest.to_degrees()),
lat_dest.to_degrees(),
))
}
#[expect(
clippy::float_cmp,
reason = "`rem_euclid(360.0) - 180.0` produces an exactly-representable \
`-180.0` at the antipodal boundary; bit equality is the intended \
canonicalisation check."
)]
pub fn signed_lon_delta(a_lon: f64, b_lon: f64) -> f64 {
let raw = ((b_lon - a_lon + 540.0).rem_euclid(360.0)) - 180.0;
if raw == -180.0 { 180.0 } else { raw }
}
#[expect(
clippy::float_cmp,
reason = "`rem_euclid(360.0) - 180.0` produces an exactly-representable \
`-180.0` at the antimeridian; bit equality is the intended \
canonicalisation check."
)]
pub fn wrap_lon_deg(lon: f64) -> f64 {
let wrapped = ((lon + 540.0).rem_euclid(360.0)) - 180.0;
if wrapped == -180.0 { 180.0 } else { wrapped }
}
pub fn wrap_lon_deg_f32(lon: f32) -> f32 {
wrap_lon_deg(f64::from(lon)) as f32
}
pub fn delta_to_tangent_metres(pos: LatLon, delta: LatLonDelta) -> TangentMetres {
let cos_lat = pos.lat.to_radians().cos();
TangentMetres::new(
delta.lon * cos_lat * METRES_PER_DEGREE,
delta.lat * METRES_PER_DEGREE,
)
}
pub fn tangent_metres_to_delta(pos: LatLon, tangent: TangentMetres) -> LatLonDelta {
let cos_lat = pos.lat.to_radians().cos().max(1e-9);
LatLonDelta::new(
tangent.east / (cos_lat * METRES_PER_DEGREE),
tangent.north / METRES_PER_DEGREE,
)
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct LonLatBbox {
pub lon_min: f64,
pub lon_max: f64,
pub lat_min: f64,
pub lat_max: f64,
}
impl LonLatBbox {
pub const fn new(lon_min: f64, lon_max: f64, lat_min: f64, lat_max: f64) -> Self {
Self {
lon_min,
lon_max,
lat_min,
lat_max,
}
}
pub fn wraps_antimeridian(self) -> bool {
self.lon_min > self.lon_max
}
pub fn lon_max_unwrapped(self) -> f64 {
if self.wraps_antimeridian() {
self.lon_max + 360.0
} else {
self.lon_max
}
}
pub fn lon_extent(self) -> f64 {
(self.lon_max_unwrapped() - self.lon_min).max(0.0)
}
pub fn lat_extent(self) -> f64 {
(self.lat_max - self.lat_min).max(0.0)
}
#[expect(
clippy::float_cmp,
reason = "bbox endpoints are stored values, not computed; exact \
inequality is the precise zero-extent check (including \
the wrap-encoded case where `lon_min > lon_max`)."
)]
pub fn is_non_degenerate(self) -> bool {
self.lat_max > self.lat_min && self.lon_min != self.lon_max
}
pub fn diagonal_m(self) -> f64 {
haversine(
LatLon::new(self.lon_min, self.lat_min),
LatLon::new(self.lon_max, self.lat_max),
)
}
pub fn clamp(self, p: LatLon) -> LatLon {
LatLon::new(
clamp_lon_wrap_inline(p.lon, self.lon_min, self.lon_max),
f64::clamp(p.lat, self.lat_min, self.lat_max)
.clamp(-POLE_LATITUDE_LIMIT_DEG, POLE_LATITUDE_LIMIT_DEG),
)
}
pub fn contains(self, p: LatLon) -> bool {
if p.lat < self.lat_min || p.lat > self.lat_max {
return false;
}
if self.wraps_antimeridian() {
p.lon >= self.lon_min || p.lon <= self.lon_max
} else {
p.lon >= self.lon_min && p.lon <= self.lon_max
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct Wind {
pub east_mps: f64,
pub north_mps: f64,
}
impl Wind {
pub const fn new(east_mps: f64, north_mps: f64) -> Self {
Self {
east_mps,
north_mps,
}
}
pub const fn zero() -> Self {
Self::new(0.0, 0.0)
}
pub fn speed(self) -> f64 {
self.east_mps.hypot(self.north_mps)
}
}
#[derive(Copy, Clone, Debug)]
pub struct Segment {
pub origin: LatLon,
pub destination: LatLon,
pub origin_time: f64,
pub step_distance_max: f64,
}
fn clamp_lon_wrap_inline(lon: f64, lon_min: f64, lon_max: f64) -> f64 {
if lon_min <= lon_max {
return lon.clamp(lon_min, lon_max);
}
if lon >= lon_min || lon <= lon_max {
return lon;
}
let d_to_min = signed_lon_delta(lon, lon_min).abs();
let d_to_max = signed_lon_delta(lon, lon_max).abs();
if d_to_min <= d_to_max {
lon_min
} else {
lon_max
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
reason = "tests rely on bit-exact comparisons of constant or stored f32/f64 values."
)]
use super::*;
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn approx_ll(a: LatLon, b: LatLon, tol: f64) -> bool {
approx(a.lon, b.lon, tol) && approx(a.lat, b.lat, tol)
}
fn approx_ld(a: LatLonDelta, b: LatLonDelta, tol: f64) -> bool {
approx(a.lon, b.lon, tol) && approx(a.lat, b.lat, tol)
}
#[test]
fn haversine_zero_for_identical_points() {
let p = LatLon::new(12.5, 45.0);
assert!(approx(haversine(p, p), 0.0, 1e-6));
}
#[test]
fn haversine_symmetric() {
let a = LatLon::new(0.0, 0.0);
let b = LatLon::new(10.0, 20.0);
assert!(approx(haversine(a, b), haversine(b, a), 1e-6));
}
#[test]
fn haversine_one_degree_of_latitude_is_metres_per_degree() {
let a = LatLon::new(0.0, 0.0);
let b = LatLon::new(0.0, 1.0);
assert!(approx(haversine(a, b), METRES_PER_DEGREE, 1.0));
}
#[test]
fn haversine_one_degree_of_longitude_scales_with_cos_lat() {
let a = LatLon::new(0.0, 60.0);
let b = LatLon::new(1.0, 60.0);
let expected = 0.5 * METRES_PER_DEGREE;
assert!(approx(haversine(a, b), expected, 5.0));
}
#[test]
fn haversine_handles_antimeridian() {
let a = LatLon::new(179.0, 0.0);
let b = LatLon::new(-179.0, 0.0);
let expected = 2.0 * METRES_PER_DEGREE;
assert!(approx(haversine(a, b), expected, 1.0));
}
#[test]
fn initial_bearing_due_north_is_zero() {
let a = LatLon::new(0.0, 0.0);
let b = LatLon::new(0.0, 10.0);
let bearing = initial_bearing(a, b).unwrap();
assert!(approx(bearing, 0.0, 1e-9));
}
#[test]
fn initial_bearing_due_east_is_pi_over_two() {
let a = LatLon::new(0.0, 0.0);
let b = LatLon::new(10.0, 0.0);
let bearing = initial_bearing(a, b).unwrap();
assert!(approx(bearing, std::f64::consts::FRAC_PI_2, 1e-9));
}
#[test]
fn initial_bearing_due_south_is_pi() {
let a = LatLon::new(0.0, 10.0);
let b = LatLon::new(0.0, 0.0);
let bearing = initial_bearing(a, b).unwrap();
assert!(approx(bearing.abs(), std::f64::consts::PI, 1e-9));
}
#[test]
fn initial_bearing_at_pole_is_none() {
let pole = LatLon::new(0.0, 90.0);
let other = LatLon::new(50.0, 80.0);
assert!(initial_bearing(pole, other).is_none());
}
#[test]
fn destination_point_round_trips_with_haversine_and_bearing() {
let cases = [
(LatLon::new(0.0, 0.0), LatLon::new(10.0, 5.0)),
(LatLon::new(45.0, 30.0), LatLon::new(50.0, 35.0)),
(LatLon::new(-100.0, -20.0), LatLon::new(-80.0, 10.0)),
(LatLon::new(170.0, 0.0), LatLon::new(-170.0, 5.0)),
];
for (a, b) in cases {
let dist = haversine(a, b);
let bearing = initial_bearing(a, b).unwrap();
let arrived = destination_point(a, dist, bearing).unwrap();
assert!(
approx_ll(arrived, b, 1e-6),
"from {a:?} → {b:?}: arrived {arrived:?} (dist={dist}, bearing={bearing})",
);
}
}
#[test]
fn destination_point_at_pole_is_none() {
let pole = LatLon::new(0.0, 90.0);
assert!(destination_point(pole, 1000.0, 0.0).is_none());
}
#[test]
fn signed_lon_delta_handles_antimeridian() {
assert!(approx(signed_lon_delta(179.0, -179.0), 2.0, 1e-12));
assert!(approx(signed_lon_delta(-179.0, 179.0), -2.0, 1e-12));
assert!(approx(signed_lon_delta(0.0, 90.0), 90.0, 1e-12));
assert!(approx(signed_lon_delta(90.0, 0.0), -90.0, 1e-12));
assert!(approx(signed_lon_delta(0.0, 180.0), 180.0, 1e-12));
assert!(approx(signed_lon_delta(0.0, -180.0), 180.0, 1e-12));
}
#[test]
fn wrap_lon_deg_canonicalises_to_half_open_range() {
assert!(approx(wrap_lon_deg(0.0), 0.0, 1e-12));
assert!(approx(wrap_lon_deg(180.0), 180.0, 1e-12));
assert!(approx(wrap_lon_deg(-180.0), 180.0, 1e-12));
assert!(approx(wrap_lon_deg(181.0), -179.0, 1e-12));
assert!(approx(wrap_lon_deg(-181.0), 179.0, 1e-12));
assert!(approx(wrap_lon_deg(540.0), 180.0, 1e-12));
}
#[test]
fn tangent_frame_round_trips_at_various_latitudes() {
for &lat in &[0.0_f64, 30.0, 45.0, 60.0, 85.0] {
let pos = LatLon::new(10.0, lat);
let original = LatLonDelta::new(0.5, -0.25);
let metres = delta_to_tangent_metres(pos, original);
let recovered = tangent_metres_to_delta(pos, metres);
assert!(
approx_ld(original, recovered, 1e-12),
"lat={lat}: {original:?} → {metres:?} → {recovered:?}",
);
}
}
#[test]
fn lat_lon_delta_to_handles_antimeridian() {
let a = LatLon::new(179.0, 10.0);
let b = LatLon::new(-179.0, 12.0);
let d = a.delta_to(b);
assert!(approx(d.lon, 2.0, 1e-12));
assert!(approx(d.lat, 2.0, 1e-12));
let recovered = a + d;
assert!(approx(recovered.lat, b.lat, 1e-12));
assert!(approx(wrap_lon_deg(recovered.lon), b.lon, 1e-12));
}
#[test]
fn lat_lon_offset_by_round_trips_with_haversine_at_short_range() {
let pos = LatLon::new(0.0, 45.0);
let step = TangentMetres::new(10_000.0, 0.0);
let arrived = pos.offset_by(step);
let dist = haversine(pos, arrived);
assert!(approx(dist, step.norm(), 1.0));
}
#[test]
fn tangent_frame_compresses_lon_at_high_latitude() {
let at_equator = delta_to_tangent_metres(LatLon::new(0.0, 0.0), LatLonDelta::new(1.0, 0.0));
let at_60 = delta_to_tangent_metres(LatLon::new(0.0, 60.0), LatLonDelta::new(1.0, 0.0));
let ratio = at_60.east / at_equator.east;
assert!((ratio - 0.5).abs() < 1e-9);
assert!(at_equator.north.abs() < 1e-9);
assert!(at_60.north.abs() < 1e-9);
}
#[test]
fn bbox_non_wrap_basic_predicates() {
let b = LonLatBbox::new(-10.0, 30.0, -5.0, 5.0);
assert!(!b.wraps_antimeridian());
assert!(b.is_non_degenerate());
assert_eq!(b.lon_max_unwrapped(), 30.0);
assert!((b.lon_extent() - 40.0).abs() < 1e-12);
assert!((b.lat_extent() - 10.0).abs() < 1e-12);
}
#[test]
fn bbox_wrapping_extent_reaches_across_antimeridian() {
let b = LonLatBbox::new(139.0, -122.0, -10.0, 10.0);
assert!(b.wraps_antimeridian());
assert!(b.is_non_degenerate());
assert!((b.lon_max_unwrapped() - 238.0).abs() < 1e-12);
assert!((b.lon_extent() - 99.0).abs() < 1e-12);
}
#[test]
fn bbox_degenerate_collapses_lon_or_lat() {
let b = LonLatBbox::new(0.0, 0.0, -5.0, 5.0);
assert!(!b.is_non_degenerate());
let b = LonLatBbox::new(-10.0, 10.0, 5.0, 5.0);
assert!(!b.is_non_degenerate());
}
#[test]
fn bbox_clamp_non_wrap_uses_interval_clamp() {
let b = LonLatBbox::new(-10.0, 30.0, -5.0, 5.0);
let c = b.clamp(LatLon::new(20.0, 0.0));
assert!(approx_ll(c, LatLon::new(20.0, 0.0), 1e-12));
let c = b.clamp(LatLon::new(-50.0, 0.0));
assert!(approx(c.lon, -10.0, 1e-12));
let c = b.clamp(LatLon::new(0.0, 80.0));
assert!(approx(c.lat, 5.0, 1e-12));
}
#[test]
fn bbox_clamp_wrap_passes_through_inside_ranges_and_snaps_outside() {
let b = LonLatBbox::new(170.0, -170.0, -10.0, 10.0);
let c = b.clamp(LatLon::new(178.0, 0.0));
assert!(approx(c.lon, 178.0, 1e-12));
let c = b.clamp(LatLon::new(-175.0, 0.0));
assert!(approx(c.lon, -175.0, 1e-12));
let c = b.clamp(LatLon::new(160.0, 0.0));
assert!(approx(c.lon, 170.0, 1e-12));
}
#[test]
fn bbox_clamp_lat_stays_inside_pole_limit() {
let b = LonLatBbox::new(-10.0, 10.0, -90.0, 90.0);
let c = b.clamp(LatLon::new(0.0, 89.999));
assert!(c.lat <= POLE_LATITUDE_LIMIT_DEG);
let c = b.clamp(LatLon::new(0.0, -89.999));
assert!(c.lat >= -POLE_LATITUDE_LIMIT_DEG);
}
}