use crate::error::{Error, Result};
use core::f64::consts::PI;
pub fn azimuthal_equidistant_forward(
lon: f64,
lat: f64,
lon_0: f64,
lat_0: f64,
semi_major: f64,
) -> Result<(f64, f64)> {
if !lon.is_finite() || !lat.is_finite() {
return Err(Error::invalid_coordinate("aeqd: non-finite input"));
}
let cos_c = lat_0.sin() * lat.sin() + lat_0.cos() * lat.cos() * (lon - lon_0).cos();
let cos_c = cos_c.clamp(-1.0, 1.0);
if (cos_c - 1.0).abs() < 1e-12 {
return Ok((0.0, 0.0));
}
if (cos_c + 1.0).abs() < 1e-12 {
return Err(Error::numerical_error(
"aeqd: antipodal point — projection undefined",
));
}
let c = cos_c.acos(); let k = c / c.sin();
let x = semi_major * k * lat.cos() * (lon - lon_0).sin();
let y =
semi_major * k * (lat_0.cos() * lat.sin() - lat_0.sin() * lat.cos() * (lon - lon_0).cos());
Ok((x, y))
}
pub fn azimuthal_equidistant_inverse(
x: f64,
y: f64,
lon_0: f64,
lat_0: f64,
semi_major: f64,
) -> Result<(f64, f64)> {
if !x.is_finite() || !y.is_finite() {
return Err(Error::invalid_coordinate("aeqd: non-finite input"));
}
let rho = (x * x + y * y).sqrt();
if rho < 1e-10 {
return Ok((lon_0, lat_0));
}
let c = rho / semi_major;
if c > PI + 1e-10 {
return Err(Error::coordinate_out_of_bounds(x, y));
}
let sin_c = c.sin();
let cos_c = c.cos();
let lat = (cos_c * lat_0.sin() + y * sin_c * lat_0.cos() / rho)
.clamp(-1.0, 1.0)
.asin();
let lon = if (lat_0.abs() - PI / 2.0).abs() < 1e-10 {
if lat_0 > 0.0 {
lon_0 + x.atan2(-y)
} else {
lon_0 + x.atan2(y)
}
} else {
lon_0 + (x * sin_c).atan2(rho * lat_0.cos() * cos_c - y * lat_0.sin() * sin_c)
};
Ok((lon, lat))
}
pub fn gnomonic_forward(
lon: f64,
lat: f64,
lon_0: f64,
lat_0: f64,
semi_major: f64,
) -> Result<(f64, f64)> {
if !lon.is_finite() || !lat.is_finite() {
return Err(Error::invalid_coordinate("gnom: non-finite input"));
}
let cos_c = lat_0.sin() * lat.sin() + lat_0.cos() * lat.cos() * (lon - lon_0).cos();
if cos_c <= 0.0 {
return Err(Error::numerical_error(
"gnom: point is on or beyond the horizon (cos(c) ≤ 0)",
));
}
let x = semi_major * lat.cos() * (lon - lon_0).sin() / cos_c;
let y = semi_major * (lat_0.cos() * lat.sin() - lat_0.sin() * lat.cos() * (lon - lon_0).cos())
/ cos_c;
Ok((x, y))
}
pub fn gnomonic_inverse(
x: f64,
y: f64,
lon_0: f64,
lat_0: f64,
semi_major: f64,
) -> Result<(f64, f64)> {
if !x.is_finite() || !y.is_finite() {
return Err(Error::invalid_coordinate("gnom: non-finite input"));
}
let rho = (x * x + y * y).sqrt();
let c = (rho / semi_major).atan();
let sin_c = c.sin();
let cos_c = c.cos();
if rho < 1e-10 {
return Ok((lon_0, lat_0));
}
let lat = (cos_c * lat_0.sin() + y * sin_c * lat_0.cos() / rho)
.clamp(-1.0, 1.0)
.asin();
let lon = if (lat_0.abs() - PI / 2.0).abs() < 1e-10 {
if lat_0 > 0.0 {
lon_0 + x.atan2(-y)
} else {
lon_0 + x.atan2(y)
}
} else {
lon_0 + (x * sin_c).atan2(rho * lat_0.cos() * cos_c - y * lat_0.sin() * sin_c)
};
Ok((lon, lat))
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
const R: f64 = 6_371_000.0;
#[test]
fn test_aeqd_centre_is_origin() {
let lon_0 = 0.0_f64;
let lat_0 = 0.0_f64;
let (x, y) = azimuthal_equidistant_forward(lon_0, lat_0, lon_0, lat_0, R).expect("ok");
assert!(x.abs() < 1e-6);
assert!(y.abs() < 1e-6);
}
#[test]
fn test_aeqd_roundtrip() {
let lon_0 = 0.0_f64;
let lat_0 = 45.0_f64.to_radians();
let cases = [
(10.0_f64.to_radians(), 50.0_f64.to_radians()),
(-30.0_f64.to_radians(), 20.0_f64.to_radians()),
(90.0_f64.to_radians(), 0.0_f64),
];
for (lon, lat) in cases {
let (x, y) =
azimuthal_equidistant_forward(lon, lat, lon_0, lat_0, R).expect("forward ok");
let (lon2, lat2) =
azimuthal_equidistant_inverse(x, y, lon_0, lat_0, R).expect("inverse ok");
assert!((lon - lon2).abs() < 1e-9, "lon: {lon:.4} vs {lon2:.4}");
assert!((lat - lat2).abs() < 1e-9, "lat: {lat:.4} vs {lat2:.4}");
}
}
#[test]
fn test_gnomonic_roundtrip() {
let lon_0 = 0.0_f64;
let lat_0 = 45.0_f64.to_radians();
let cases = [
(10.0_f64.to_radians(), 50.0_f64.to_radians()),
(-10.0_f64.to_radians(), 40.0_f64.to_radians()),
];
for (lon, lat) in cases {
let (x, y) = gnomonic_forward(lon, lat, lon_0, lat_0, R).expect("forward ok");
let (lon2, lat2) = gnomonic_inverse(x, y, lon_0, lat_0, R).expect("inverse ok");
assert!((lon - lon2).abs() < 1e-9, "lon: {lon:.4} vs {lon2:.4}");
assert!((lat - lat2).abs() < 1e-9, "lat: {lat:.4} vs {lat2:.4}");
}
}
#[test]
fn test_gnomonic_opposite_hemisphere_rejected() {
let result = gnomonic_forward(0.0, (-PI / 2.0) + 0.01, 0.0, PI / 2.0, R);
assert!(result.is_err());
}
}