use crate::error::{Error, Result};
const DEFAULT_RADIUS: f64 = 6_378_137.0;
const TOLERANCE: f64 = 1e-12;
#[derive(Debug, Clone)]
pub struct LambertAzimuthalEqualArea {
pub lon_0: f64,
pub lat_0: f64,
pub false_easting: f64,
pub false_northing: f64,
pub radius: f64,
}
impl Default for LambertAzimuthalEqualArea {
fn default() -> Self {
Self {
lon_0: 0.0,
lat_0: 0.0,
false_easting: 0.0,
false_northing: 0.0,
radius: DEFAULT_RADIUS,
}
}
}
impl LambertAzimuthalEqualArea {
pub fn new(
lon_0: f64,
lat_0: f64,
false_easting: f64,
false_northing: f64,
radius: f64,
) -> Self {
Self {
lon_0,
lat_0,
false_easting,
false_northing,
radius,
}
}
pub fn forward(&self, lon_deg: f64, lat_deg: f64) -> Result<(f64, f64)> {
let phi = lat_deg.to_radians();
let lam = lon_deg.to_radians();
let phi_0 = self.lat_0.to_radians();
let lam_0 = self.lon_0.to_radians();
let d_lam = lam - lam_0;
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let cos_phi0 = phi_0.cos();
let sin_phi0 = phi_0.sin();
let cos_dlam = d_lam.cos();
let denom = 1.0 + sin_phi0 * sin_phi + cos_phi0 * cos_phi * cos_dlam;
if denom.abs() < TOLERANCE {
return Err(Error::numerical_error(
"lambert azimuthal: point is antipodal to projection centre",
));
}
let kp = (2.0 / denom).sqrt();
let x = self.radius * kp * cos_phi * d_lam.sin() + self.false_easting;
let y = self.radius * kp * (cos_phi0 * sin_phi - sin_phi0 * cos_phi * cos_dlam)
+ self.false_northing;
if !x.is_finite() || !y.is_finite() {
return Err(Error::numerical_error(
"lambert azimuthal forward: non-finite result",
));
}
Ok((x, y))
}
pub fn inverse(&self, x: f64, y: f64) -> Result<(f64, f64)> {
let xn = x - self.false_easting;
let yn = y - self.false_northing;
let phi_0 = self.lat_0.to_radians();
let cos_phi0 = phi_0.cos();
let sin_phi0 = phi_0.sin();
let rho = (xn * xn + yn * yn).sqrt();
if rho < TOLERANCE {
return Ok((self.lon_0, self.lat_0));
}
let sin_c_half = rho / (2.0 * self.radius);
if sin_c_half.abs() > 1.0 + TOLERANCE {
return Err(Error::numerical_error(
"lambert azimuthal inverse: coordinate out of range",
));
}
let c = 2.0 * sin_c_half.clamp(-1.0, 1.0).asin();
let cos_c = c.cos();
let sin_c = c.sin();
let sin_phi = cos_c * sin_phi0 + yn * sin_c * cos_phi0 / rho;
let phi = sin_phi.clamp(-1.0, 1.0).asin();
let lam_num = xn * sin_c;
let lam_den = rho * cos_phi0 * cos_c - yn * sin_phi0 * sin_c;
let lam = self.lon_0 + lam_num.atan2(lam_den).to_degrees();
Ok((lam, phi.to_degrees()))
}
}
#[derive(Debug, Clone)]
pub struct AzimuthalEquidistant {
pub lon_0: f64,
pub lat_0: f64,
pub false_easting: f64,
pub false_northing: f64,
pub radius: f64,
}
impl Default for AzimuthalEquidistant {
fn default() -> Self {
Self {
lon_0: 0.0,
lat_0: 0.0,
false_easting: 0.0,
false_northing: 0.0,
radius: DEFAULT_RADIUS,
}
}
}
impl AzimuthalEquidistant {
pub fn new(
lon_0: f64,
lat_0: f64,
false_easting: f64,
false_northing: f64,
radius: f64,
) -> Self {
Self {
lon_0,
lat_0,
false_easting,
false_northing,
radius,
}
}
pub fn forward(&self, lon_deg: f64, lat_deg: f64) -> Result<(f64, f64)> {
let phi = lat_deg.to_radians();
let lam = lon_deg.to_radians();
let phi_0 = self.lat_0.to_radians();
let lam_0 = self.lon_0.to_radians();
let d_lam = lam - lam_0;
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let cos_phi0 = phi_0.cos();
let sin_phi0 = phi_0.sin();
let cos_dlam = d_lam.cos();
let cos_c = sin_phi0 * sin_phi + cos_phi0 * cos_phi * cos_dlam;
let cos_c_clamped = cos_c.clamp(-1.0, 1.0);
if (cos_c_clamped - 1.0).abs() < TOLERANCE {
return Ok((self.false_easting, self.false_northing));
}
let c = cos_c_clamped.acos();
let k = c / c.sin();
let x = self.radius * k * cos_phi * d_lam.sin() + self.false_easting;
let y = self.radius * k * (cos_phi0 * sin_phi - sin_phi0 * cos_phi * cos_dlam)
+ self.false_northing;
if !x.is_finite() || !y.is_finite() {
return Err(Error::numerical_error(
"azimuthal equidistant forward: non-finite result",
));
}
Ok((x, y))
}
pub fn inverse(&self, x: f64, y: f64) -> Result<(f64, f64)> {
let xn = x - self.false_easting;
let yn = y - self.false_northing;
let phi_0 = self.lat_0.to_radians();
let cos_phi0 = phi_0.cos();
let sin_phi0 = phi_0.sin();
let rho = (xn * xn + yn * yn).sqrt();
if rho < TOLERANCE {
return Ok((self.lon_0, self.lat_0));
}
let c = rho / self.radius;
if c > core::f64::consts::PI + TOLERANCE {
return Err(Error::numerical_error(
"azimuthal equidistant inverse: distance exceeds half circumference",
));
}
let cos_c = c.cos();
let sin_c = c.sin();
let sin_phi = cos_c * sin_phi0 + yn * sin_c * cos_phi0 / rho;
let phi = sin_phi.clamp(-1.0, 1.0).asin();
let lam_num = xn * sin_c;
let lam_den = rho * cos_phi0 * cos_c - yn * sin_phi0 * sin_c;
let lam = self.lon_0 + lam_num.atan2(lam_den).to_degrees();
Ok((lam, phi.to_degrees()))
}
}
#[derive(Debug, Clone)]
pub struct Gnomonic {
pub lon_0: f64,
pub lat_0: f64,
pub false_easting: f64,
pub false_northing: f64,
pub radius: f64,
}
impl Default for Gnomonic {
fn default() -> Self {
Self {
lon_0: 0.0,
lat_0: 0.0,
false_easting: 0.0,
false_northing: 0.0,
radius: DEFAULT_RADIUS,
}
}
}
impl Gnomonic {
pub fn new(
lon_0: f64,
lat_0: f64,
false_easting: f64,
false_northing: f64,
radius: f64,
) -> Self {
Self {
lon_0,
lat_0,
false_easting,
false_northing,
radius,
}
}
pub fn forward(&self, lon_deg: f64, lat_deg: f64) -> Result<(f64, f64)> {
let phi = lat_deg.to_radians();
let lam = lon_deg.to_radians();
let phi_0 = self.lat_0.to_radians();
let lam_0 = self.lon_0.to_radians();
let d_lam = lam - lam_0;
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let cos_phi0 = phi_0.cos();
let sin_phi0 = phi_0.sin();
let cos_dlam = d_lam.cos();
let cos_c = sin_phi0 * sin_phi + cos_phi0 * cos_phi * cos_dlam;
if cos_c < TOLERANCE {
return Err(Error::numerical_error(
"gnomonic: point is at or beyond the horizon (≥ 90° from centre)",
));
}
let x = self.radius * cos_phi * d_lam.sin() / cos_c + self.false_easting;
let y = self.radius * (cos_phi0 * sin_phi - sin_phi0 * cos_phi * cos_dlam) / cos_c
+ self.false_northing;
if !x.is_finite() || !y.is_finite() {
return Err(Error::numerical_error(
"gnomonic forward: non-finite result",
));
}
Ok((x, y))
}
pub fn inverse(&self, x: f64, y: f64) -> Result<(f64, f64)> {
let xn = x - self.false_easting;
let yn = y - self.false_northing;
let phi_0 = self.lat_0.to_radians();
let cos_phi0 = phi_0.cos();
let sin_phi0 = phi_0.sin();
let rho = (xn * xn + yn * yn).sqrt();
if rho < TOLERANCE {
return Ok((self.lon_0, self.lat_0));
}
let c = (rho / self.radius).atan();
let cos_c = c.cos();
let sin_c = c.sin();
let sin_phi = cos_c * sin_phi0 + yn * sin_c * cos_phi0 / rho;
let phi = sin_phi.clamp(-1.0, 1.0).asin();
let lam_num = xn * sin_c;
let lam_den = rho * cos_phi0 * cos_c - yn * sin_phi0 * sin_c;
let lam = self.lon_0 + lam_num.atan2(lam_den).to_degrees();
Ok((lam, phi.to_degrees()))
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
const ROUND_TRIP_TOL: f64 = 1e-6;
fn round_trip_laea(lon: f64, lat: f64) {
let proj = LambertAzimuthalEqualArea::default();
let (x, y) = proj.forward(lon, lat).expect("forward ok");
let (lon2, lat2) = proj.inverse(x, y).expect("inverse ok");
assert!(
(lon - lon2).abs() < ROUND_TRIP_TOL,
"laea lon: {} vs {}",
lon,
lon2
);
assert!(
(lat - lat2).abs() < ROUND_TRIP_TOL,
"laea lat: {} vs {}",
lat,
lat2
);
}
fn round_trip_aeqd(lon: f64, lat: f64) {
let proj = AzimuthalEquidistant::default();
let (x, y) = proj.forward(lon, lat).expect("forward ok");
let (lon2, lat2) = proj.inverse(x, y).expect("inverse ok");
assert!(
(lon - lon2).abs() < ROUND_TRIP_TOL,
"aeqd lon: {} vs {}",
lon,
lon2
);
assert!(
(lat - lat2).abs() < ROUND_TRIP_TOL,
"aeqd lat: {} vs {}",
lat,
lat2
);
}
fn round_trip_gnom(lon: f64, lat: f64) {
let proj = Gnomonic::default();
let (x, y) = proj.forward(lon, lat).expect("forward ok");
let (lon2, lat2) = proj.inverse(x, y).expect("inverse ok");
assert!(
(lon - lon2).abs() < ROUND_TRIP_TOL,
"gnomonic lon: {} vs {}",
lon,
lon2
);
assert!(
(lat - lat2).abs() < ROUND_TRIP_TOL,
"gnomonic lat: {} vs {}",
lat,
lat2
);
}
#[test]
fn test_laea_origin() {
let proj = LambertAzimuthalEqualArea::default();
let (x, y) = proj.forward(0.0, 0.0).expect("ok");
assert!(x.abs() < 1.0, "x={}", x);
assert!(y.abs() < 1.0, "y={}", y);
}
#[test]
fn test_laea_round_trips() {
round_trip_laea(0.0, 0.0);
round_trip_laea(10.0, 20.0);
round_trip_laea(-80.0, 60.0);
round_trip_laea(120.0, -30.0);
}
#[test]
fn test_laea_antipodal_error() {
let proj = LambertAzimuthalEqualArea::default(); let result = proj.forward(180.0, 0.0); assert!(result.is_err(), "should fail at antipode");
}
#[test]
fn test_aeqd_origin() {
let proj = AzimuthalEquidistant::default();
let (x, y) = proj.forward(0.0, 0.0).expect("ok");
assert!(x.abs() < 1.0, "x={}", x);
assert!(y.abs() < 1.0, "y={}", y);
}
#[test]
fn test_aeqd_round_trips() {
round_trip_aeqd(0.0, 0.0);
round_trip_aeqd(30.0, 45.0);
round_trip_aeqd(-60.0, -30.0);
round_trip_aeqd(90.0, 60.0);
}
#[test]
fn test_aeqd_distance_preserved() {
let proj = AzimuthalEquidistant::default();
let (x, y) = proj.forward(90.0, 0.0).expect("ok");
let dist = (x * x + y * y).sqrt();
let expected = core::f64::consts::FRAC_PI_2 * DEFAULT_RADIUS;
assert!(
(dist - expected).abs() < 1.0, "dist={} expected={}",
dist,
expected
);
}
#[test]
fn test_gnomonic_origin() {
let proj = Gnomonic::default();
let (x, y) = proj.forward(0.0, 0.0).expect("ok");
assert!(x.abs() < 1.0, "x={}", x);
assert!(y.abs() < 1.0, "y={}", y);
}
#[test]
fn test_gnomonic_round_trips() {
round_trip_gnom(0.0, 0.0);
round_trip_gnom(10.0, 20.0);
round_trip_gnom(-30.0, 45.0);
round_trip_gnom(50.0, -20.0);
}
#[test]
fn test_gnomonic_horizon_error() {
let proj = Gnomonic::default();
let result = proj.forward(90.0, 0.0);
assert!(result.is_err(), "should fail at horizon");
}
#[test]
fn test_gnomonic_great_circle_straight() {
let centre_lat = 45.0_f64;
let proj = Gnomonic::new(0.0, centre_lat, 0.0, 0.0, DEFAULT_RADIUS);
let (x1, y1) = proj.forward(0.0, 30.0).expect("ok");
let (x2, y2) = proj.forward(0.0, 60.0).expect("ok");
assert!(x1.abs() < 1.0, "x1={}", x1);
assert!(x2.abs() < 1.0, "x2={}", x2);
assert!(y1.is_finite() && y2.is_finite());
}
}