#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EnuConverter {
pub origin_lat: f64,
pub origin_lon: f64,
pub origin_alt: f64,
}
impl EnuConverter {
const A: f64 = 6_378_137.0; const B: f64 = 6_356_752.314_245; const E2: f64 = 1.0 - (Self::B * Self::B) / (Self::A * Self::A);
pub fn new(lat: f64, lon: f64, alt: f64) -> Self {
Self { origin_lat: lat, origin_lon: lon, origin_alt: alt }
}
fn geodetic_to_ecef(lat: f64, lon: f64, alt: f64) -> [f64; 3] {
let lat_r = lat.to_radians();
let lon_r = lon.to_radians();
let n = Self::A / (1.0 - Self::E2 * lat_r.sin().powi(2)).sqrt();
[
(n + alt) * lat_r.cos() * lon_r.cos(),
(n + alt) * lat_r.cos() * lon_r.sin(),
(n * (1.0 - Self::E2) + alt) * lat_r.sin(),
]
}
pub fn to_enu(&self, lat: f64, lon: f64, alt: f64) -> [f64; 3] {
let p = Self::geodetic_to_ecef(lat, lon, alt);
let o = Self::geodetic_to_ecef(self.origin_lat, self.origin_lon, self.origin_alt);
let dx = p[0] - o[0];
let dy = p[1] - o[1];
let dz = p[2] - o[2];
let lat_r = self.origin_lat.to_radians();
let lon_r = self.origin_lon.to_radians();
let (slat, clat) = (lat_r.sin(), lat_r.cos());
let (slon, clon) = (lon_r.sin(), lon_r.cos());
[
-slon * dx + clon * dy, -slat * clon * dx - slat * slon * dy + clat * dz, clat * clon * dx + clat * slon * dy + slat * dz, ]
}
pub fn from_enu(&self, east: f64, north: f64, up: f64) -> [f64; 3] {
let lat_r = self.origin_lat.to_radians();
let lon_r = self.origin_lon.to_radians();
let (slat, clat) = (lat_r.sin(), lat_r.cos());
let (slon, clon) = (lon_r.sin(), lon_r.cos());
let o = Self::geodetic_to_ecef(self.origin_lat, self.origin_lon, self.origin_alt);
let dx = -slon * east - slat * clon * north + clat * clon * up;
let dy = clon * east - slat * slon * north + clat * slon * up;
let dz = clat * north + slat * up;
let x = o[0] + dx;
let y = o[1] + dy;
let z = o[2] + dz;
let p2 = x * x + y * y;
let p = p2.sqrt();
let lon = y.atan2(x).to_degrees();
let mut lat_r = (z / (p * (1.0 - Self::E2))).atan();
for _ in 0..10 {
let n = Self::A / (1.0 - Self::E2 * lat_r.sin().powi(2)).sqrt();
let next = ((z + Self::E2 * n * lat_r.sin()) / p).atan();
if (next - lat_r).abs() < 1e-14 {
lat_r = next;
break;
}
lat_r = next;
}
let n = Self::A / (1.0 - Self::E2 * lat_r.sin().powi(2)).sqrt();
let alt = p / lat_r.cos() - n;
[lat_r.to_degrees(), lon, alt]
}
}
#[allow(clippy::wrong_self_convention)]
pub trait CoordSystem: Send + Sync {
fn to_internal(&self, pos: [f64; 3]) -> [f64; 3];
fn from_internal(&self, pos: [f64; 3]) -> [f64; 3];
}
impl CoordSystem for EnuConverter {
fn to_internal(&self, p: [f64; 3]) -> [f64; 3] {
self.to_enu(p[0], p[1], p[2])
}
fn from_internal(&self, p: [f64; 3]) -> [f64; 3] {
self.from_enu(p[0], p[1], p[2])
}
}
impl CoordSystem for std::sync::Arc<EnuConverter> {
fn to_internal(&self, p: [f64; 3]) -> [f64; 3] {
(**self).to_internal(p)
}
fn from_internal(&self, p: [f64; 3]) -> [f64; 3] {
(**self).from_internal(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn origin_maps_to_zero() {
let c = EnuConverter::new(10.7626, 106.6601, 0.0);
let e = c.to_enu(10.7626, 106.6601, 0.0);
for v in e {
assert!(v.abs() < 1e-6, "origin should map to ENU zero, got {e:?}");
}
}
#[test]
fn east_north_up_axes_point_the_right_way() {
let c = EnuConverter::new(0.0, 0.0, 0.0);
let east = c.to_enu(0.0, 0.001, 0.0);
assert!(east[0] > 0.0, "east component positive: {east:?}");
assert!(east[1].abs() < 1.0, "north component near zero: {east:?}");
let north = c.to_enu(0.001, 0.0, 0.0);
assert!(north[1] > 0.0, "north component positive: {north:?}");
let up = c.to_enu(0.0, 0.0, 25.0);
assert!((up[2] - 25.0).abs() < 1e-3, "up ≈ altitude: {up:?}");
}
#[test]
fn enu_distance_matches_metres() {
let c = EnuConverter::new(48.0, 11.0, 0.0);
let e = c.to_enu(48.0 + 1.0 / 3600.0, 11.0, 0.0);
assert!((e[1] - 30.8).abs() < 0.5, "≈30.8 m north, got {}", e[1]);
}
#[test]
fn round_trip_to_enu_and_back() {
let c = EnuConverter::new(37.7749, -122.4194, 12.0);
let (lat, lon, alt) = (37.7800, -122.4100, 55.0);
let enu = c.to_enu(lat, lon, alt);
let geo = c.from_enu(enu[0], enu[1], enu[2]);
assert!((geo[0] - lat).abs() < 1e-7, "lat round-trip: {} vs {}", geo[0], lat);
assert!((geo[1] - lon).abs() < 1e-7, "lon round-trip: {} vs {}", geo[1], lon);
assert!((geo[2] - alt).abs() < 1e-3, "alt round-trip: {} vs {}", geo[2], alt);
}
#[test]
fn round_trip_at_high_latitudes() {
for lat in [60.0, 80.0, 89.0] {
let c = EnuConverter::new(lat, 0.0, 50.0);
let (tgt_lat, tgt_lon, tgt_alt) = (lat + 0.001, 0.001, 60.0);
let enu = c.to_enu(tgt_lat, tgt_lon, tgt_alt);
let geo = c.from_enu(enu[0], enu[1], enu[2]);
assert!(
(geo[0] - tgt_lat).abs() < 1e-5,
"high-lat round-trip at {lat} deg: {} vs {}",
geo[0],
tgt_lat
);
assert!(
(geo[1] - tgt_lon).abs() < 1e-5,
"lon at {lat} deg"
);
}
}
#[test]
fn from_enu_converges_in_few_iterations() {
let c = EnuConverter::new(37.7749, -122.4194, 12.0);
let enu = c.to_enu(37.7800, -122.4100, 55.0);
let geo = c.from_enu(enu[0], enu[1], enu[2]);
assert!(
(geo[0] - 37.7800).abs() < 1e-7,
"converged lat: {} vs 37.78",
geo[0]
);
}
}