1use glam::{DMat3, DVec3};
13use serde::{Deserialize, Serialize};
14
15use crate::error::CoreError;
16
17#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
19#[serde(try_from = "EllipsoidData", into = "EllipsoidData")]
20pub struct Ellipsoid {
21 semi_major_axis_m: f64,
22 flattening: f64,
23}
24
25#[derive(Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct EllipsoidData {
28 semi_major_axis_m: f64,
29 flattening: f64,
32}
33
34impl TryFrom<EllipsoidData> for Ellipsoid {
35 type Error = CoreError;
36
37 fn try_from(data: EllipsoidData) -> Result<Self, CoreError> {
38 Ellipsoid::from_flattening(data.semi_major_axis_m, data.flattening)
39 }
40}
41
42impl From<Ellipsoid> for EllipsoidData {
43 fn from(ellipsoid: Ellipsoid) -> Self {
44 EllipsoidData {
45 semi_major_axis_m: ellipsoid.semi_major_axis_m,
46 flattening: ellipsoid.flattening,
47 }
48 }
49}
50
51impl Ellipsoid {
52 pub const WGS84: Self = Self {
55 semi_major_axis_m: 6_378_137.0,
56 flattening: 1.0 / 298.257_223_563,
57 };
58
59 pub fn new(semi_major_axis_m: f64, inverse_flattening: f64) -> Result<Self, CoreError> {
67 if inverse_flattening.is_nan() || inverse_flattening <= 1.0 {
68 return Err(CoreError::Domain {
69 what: "inverse flattening",
70 value: inverse_flattening,
71 });
72 }
73 Self::from_flattening(semi_major_axis_m, 1.0 / inverse_flattening)
74 }
75
76 pub fn from_flattening(semi_major_axis_m: f64, flattening: f64) -> Result<Self, CoreError> {
82 if !(semi_major_axis_m.is_finite() && semi_major_axis_m > 0.0) {
83 return Err(CoreError::Domain {
84 what: "semi-major axis (m)",
85 value: semi_major_axis_m,
86 });
87 }
88 if !(0.0..1.0).contains(&flattening) {
89 return Err(CoreError::Domain {
90 what: "flattening",
91 value: flattening,
92 });
93 }
94 Ok(Self {
95 semi_major_axis_m,
96 flattening,
97 })
98 }
99
100 #[must_use]
102 pub fn semi_major_axis_m(&self) -> f64 {
103 self.semi_major_axis_m
104 }
105
106 #[must_use]
108 pub fn flattening(&self) -> f64 {
109 self.flattening
110 }
111
112 #[must_use]
114 pub fn inverse_flattening(&self) -> f64 {
115 1.0 / self.flattening
116 }
117
118 #[must_use]
120 pub fn semi_minor_axis_m(&self) -> f64 {
121 self.semi_major_axis_m * (1.0 - self.flattening)
122 }
123
124 #[must_use]
126 pub fn eccentricity_squared(&self) -> f64 {
127 self.flattening * (2.0 - self.flattening)
128 }
129
130 #[must_use]
132 pub fn linear_eccentricity_m(&self) -> f64 {
133 self.semi_major_axis_m * self.eccentricity_squared().sqrt()
134 }
135
136 #[must_use]
138 pub fn prime_vertical_radius_m(&self, latitude_rad: f64) -> f64 {
139 let s = latitude_rad.sin();
140 self.semi_major_axis_m / (1.0 - self.eccentricity_squared() * s * s).sqrt()
141 }
142
143 #[must_use]
149 pub fn ecef_from_geodetic(&self, point: Geodetic) -> DVec3 {
150 let (sin_lat, cos_lat) = point.latitude_rad.sin_cos();
151 let (sin_lon, cos_lon) = point.longitude_rad.sin_cos();
152 let n = self.prime_vertical_radius_m(point.latitude_rad);
153 let horizontal = (n + point.height_m) * cos_lat;
154 DVec3::new(
155 horizontal * cos_lon,
156 horizontal * sin_lon,
157 (n * (1.0 - self.eccentricity_squared()) + point.height_m) * sin_lat,
158 )
159 }
160
161 pub fn geodetic_from_ecef(&self, position_ecef_m: DVec3) -> Result<Geodetic, CoreError> {
178 if let Some(value) = first_non_finite(position_ecef_m) {
179 return Err(CoreError::Domain {
180 what: "ECEF position component (m)",
181 value,
182 });
183 }
184 let a = self.semi_major_axis_m;
185 let e2 = self.eccentricity_squared();
186 let e4 = e2 * e2;
187 let big_r = position_ecef_m.x.hypot(position_ecef_m.y);
188 let big_z = position_ecef_m.z;
189 let longitude_rad = position_ecef_m.y.atan2(position_ecef_m.x);
190
191 let x = big_r / a;
192 let y = (1.0 - e2).sqrt() * big_z / a;
193 let (x2, y2) = (x * x, y * y);
194 let r = (x2 + y2 - e4) / 6.0;
195 let s = e4 * x2 * y2 / 4.0;
196 let r3 = r * r * r;
197 let d = s * (s + 2.0 * r3);
198 let u = if d >= 0.0 {
199 let t3 = s + r3;
202 let t = (t3 + d.sqrt().copysign(t3)).cbrt();
203 if t == 0.0 { 0.0 } else { r + t + r * r / t }
204 } else {
205 let psi = (-d).sqrt().atan2(-s - r3);
206 r * (1.0 + 2.0 * (psi / 3.0).cos())
207 };
208 let v = (u * u + e4 * y2).sqrt();
209 let v_plus_u = if u < 0.0 { e4 * y2 / (v - u) } else { u + v };
211 let w = (v_plus_u - y2) * e2 / (2.0 * v);
212 let kappa = v_plus_u / ((v_plus_u + w * w).sqrt() + w);
213
214 let latitude_rad = (big_z / kappa).atan2(big_r / (kappa + e2));
215 let big_d = kappa * big_r / (kappa + e2);
216 let height_m = (1.0 - (1.0 - e2) / kappa) * big_d.hypot(big_z);
217 if !(kappa > 0.0 && latitude_rad.is_finite() && height_m.is_finite()) {
218 return Err(CoreError::Domain {
219 what: "distance from the Earth's center (m)",
220 value: position_ecef_m.length(),
221 });
222 }
223 Geodetic::new(latitude_rad, longitude_rad, height_m)
224 }
225}
226
227pub(crate) fn first_non_finite(v: DVec3) -> Option<f64> {
229 v.to_array().into_iter().find(|c| !c.is_finite())
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
238#[serde(try_from = "GeodeticData", into = "GeodeticData")]
239pub struct Geodetic {
240 pub latitude_rad: f64,
242 pub longitude_rad: f64,
244 pub height_m: f64,
247}
248
249#[derive(Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251struct GeodeticData {
252 latitude_rad: f64,
253 longitude_rad: f64,
254 height_m: f64,
255}
256
257impl TryFrom<GeodeticData> for Geodetic {
258 type Error = CoreError;
259
260 fn try_from(data: GeodeticData) -> Result<Self, CoreError> {
261 Geodetic::new(data.latitude_rad, data.longitude_rad, data.height_m)
262 }
263}
264
265impl From<Geodetic> for GeodeticData {
266 fn from(point: Geodetic) -> Self {
267 GeodeticData {
268 latitude_rad: point.latitude_rad,
269 longitude_rad: point.longitude_rad,
270 height_m: point.height_m,
271 }
272 }
273}
274
275impl Geodetic {
276 pub fn validated(self) -> Result<Self, CoreError> {
282 Self::new(self.latitude_rad, self.longitude_rad, self.height_m)
283 }
284
285 pub fn new(latitude_rad: f64, longitude_rad: f64, height_m: f64) -> Result<Self, CoreError> {
291 if latitude_rad.is_nan() || latitude_rad.abs() > std::f64::consts::FRAC_PI_2 {
292 return Err(CoreError::Domain {
293 what: "geodetic latitude (rad)",
294 value: latitude_rad,
295 });
296 }
297 if !longitude_rad.is_finite() {
298 return Err(CoreError::Domain {
299 what: "longitude (rad)",
300 value: longitude_rad,
301 });
302 }
303 if !height_m.is_finite() {
304 return Err(CoreError::Domain {
305 what: "ellipsoidal height (m)",
306 value: height_m,
307 });
308 }
309 Ok(Self {
310 latitude_rad,
311 longitude_rad,
312 height_m,
313 })
314 }
315
316 pub fn from_degrees(
322 latitude_deg: f64,
323 longitude_deg: f64,
324 height_m: f64,
325 ) -> Result<Self, CoreError> {
326 Self::new(
327 latitude_deg.to_radians(),
328 longitude_deg.to_radians(),
329 height_m,
330 )
331 }
332}
333
334#[must_use]
343pub fn ecef_from_enu_rotation(point: Geodetic) -> DMat3 {
344 let (sin_lat, cos_lat) = point.latitude_rad.sin_cos();
345 let (sin_lon, cos_lon) = point.longitude_rad.sin_cos();
346 DMat3::from_cols(
347 DVec3::new(-sin_lon, cos_lon, 0.0),
348 DVec3::new(-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat),
349 DVec3::new(cos_lat * cos_lon, cos_lat * sin_lon, sin_lat),
350 )
351}
352
353#[cfg(test)]
354mod tests {
355 use std::f64::consts::FRAC_PI_2;
356
357 use proptest::prelude::*;
358
359 use super::*;
360
361 const WGS84: Ellipsoid = Ellipsoid::WGS84;
362
363 #[test]
364 fn wgs84_derived_geometry_matches_table_3_5() {
365 assert!((WGS84.semi_minor_axis_m() - 6_356_752.314_2).abs() < 5e-5);
367 assert!((WGS84.eccentricity_squared() - 6.694_379_990_141e-3).abs() < 5e-16);
368 assert!((WGS84.linear_eccentricity_m() - 5.218_540_084_233_9e5).abs() < 5e-9);
369 assert!((WGS84.flattening() - 3.352_810_664_747_5e-3).abs() < 5e-17);
370 }
371
372 #[test]
373 fn rejects_invalid_ellipsoids_and_points() {
374 assert!(Ellipsoid::new(0.0, 298.0).is_err());
375 assert!(Ellipsoid::new(6.4e6, 1.0).is_err());
376 assert!(Ellipsoid::new(6.4e6, f64::NAN).is_err());
377 assert!(Ellipsoid::new(6.4e6, f64::INFINITY).is_ok());
378 assert!(Geodetic::new(FRAC_PI_2 + 1e-9, 0.0, 0.0).is_err());
379 assert!(Geodetic::new(0.0, f64::INFINITY, 0.0).is_err());
380 assert!(Geodetic::from_degrees(0.0, 0.0, f64::NAN).is_err());
381 assert!(
382 WGS84
383 .geodetic_from_ecef(DVec3::new(f64::NAN, 0.0, 0.0))
384 .is_err()
385 );
386 assert!(
388 WGS84
389 .geodetic_from_ecef(DVec3::new(1.0e4, 0.0, 0.0))
390 .is_err()
391 );
392 assert!(WGS84.geodetic_from_ecef(DVec3::ZERO).is_err());
393 }
394
395 #[test]
396 fn closed_form_points_on_the_axes() {
397 let a = WGS84.semi_major_axis_m();
398 let b = WGS84.semi_minor_axis_m();
399 let equator = WGS84.ecef_from_geodetic(Geodetic::from_degrees(0.0, 90.0, 250.0).unwrap());
400 assert!((equator - DVec3::new(0.0, a + 250.0, 0.0)).length() < 1e-9);
401 let pole = WGS84.ecef_from_geodetic(Geodetic::from_degrees(-90.0, 0.0, 1000.0).unwrap());
402 assert!((pole - DVec3::new(0.0, 0.0, -(b + 1000.0))).length() < 1e-9);
403
404 let g = WGS84
405 .geodetic_from_ecef(DVec3::new(0.0, 0.0, b + 3.0e5))
406 .unwrap();
407 assert_eq!(g.latitude_rad, FRAC_PI_2);
408 assert!((g.height_m - 3.0e5).abs() < 1e-8);
409 let g = WGS84
410 .geodetic_from_ecef(DVec3::new(-(a - 1.0e3), 0.0, 0.0))
411 .unwrap();
412 assert_eq!(g.latitude_rad, 0.0);
413 assert!((g.longitude_rad.abs() - std::f64::consts::PI).abs() < 1e-15);
414 assert!((g.height_m + 1.0e3).abs() < 1e-8);
415 }
416
417 #[test]
418 fn a_sphere_inverts_to_spherical_coordinates() {
419 let sphere = Ellipsoid::new(6.371e6, f64::INFINITY).unwrap();
420 let p = DVec3::new(3.0e6, 4.0e6, 5.0e6);
421 let g = sphere.geodetic_from_ecef(p).unwrap();
422 assert!((g.height_m - (p.length() - 6.371e6)).abs() < 1e-8);
423 assert!((g.latitude_rad - (5.0e6f64).atan2(5.0e6)).abs() < 1e-15);
424 }
425
426 #[test]
427 fn serde_round_trips_and_rechecks() {
428 for ellipsoid in [WGS84, Ellipsoid::new(6.371e6, f64::INFINITY).unwrap()] {
429 let json = serde_json::to_string(&ellipsoid).unwrap();
430 assert_eq!(
431 serde_json::from_str::<Ellipsoid>(&json).unwrap(),
432 ellipsoid,
433 "{json}"
434 );
435 }
436 assert!(
437 serde_json::from_str::<Ellipsoid>(r#"{"semi_major_axis_m": 6.4e6, "flattening": 1.0}"#)
438 .is_err()
439 );
440 let point = Geodetic::from_degrees(-33.9, 18.6, 300.0).unwrap();
441 let json = serde_json::to_string(&point).unwrap();
442 assert_eq!(serde_json::from_str::<Geodetic>(&json).unwrap(), point);
443 let degrees_in_radian_fields =
444 r#"{"latitude_rad": 32.99, "longitude_rad": -106.97, "height_m": 1400.0}"#;
445 assert!(serde_json::from_str::<Geodetic>(degrees_in_radian_fields).is_err());
446 let unchecked = Geodetic {
447 latitude_rad: f64::NAN,
448 ..point
449 };
450 assert!(unchecked.validated().is_err());
451 assert_eq!(point.validated(), Ok(point));
452 }
453
454 #[test]
455 fn errors_report_the_offending_component() {
456 let err = WGS84
457 .geodetic_from_ecef(DVec3::new(f64::NAN, 1.0, 2.0))
458 .unwrap_err();
459 assert!(matches!(err, CoreError::Domain { value, .. } if value.is_nan()));
460 }
461
462 #[test]
463 fn enu_rotation_is_proper_and_up_is_the_ellipsoid_normal() {
464 let point = Geodetic::from_degrees(37.2, -115.8, 1300.0).unwrap();
465 let r = ecef_from_enu_rotation(point);
466 assert!((r.transpose() * r).abs_diff_eq(DMat3::IDENTITY, 1e-15));
467 assert!((r.determinant() - 1.0).abs() < 1e-15);
468 let foot = WGS84.ecef_from_geodetic(Geodetic {
470 height_m: 0.0,
471 ..point
472 });
473 let a2 = WGS84.semi_major_axis_m().powi(2);
474 let b2 = WGS84.semi_minor_axis_m().powi(2);
475 let normal = DVec3::new(foot.x / a2, foot.y / a2, foot.z / b2).normalize();
476 assert!((r.z_axis - normal).length() < 1e-15);
477 }
478
479 proptest! {
480 #[test]
482 fn geodetic_round_trips_through_ecef(
483 lat in -FRAC_PI_2..=FRAC_PI_2,
484 lon in -std::f64::consts::PI..std::f64::consts::PI,
485 h in -1.0e4..1.0e6f64,
486 ) {
487 let point = Geodetic::new(lat, lon, h).unwrap();
488 let ecef = WGS84.ecef_from_geodetic(point);
489 let back = WGS84.geodetic_from_ecef(ecef).unwrap();
490 prop_assert!((back.latitude_rad - lat).abs() < 1e-14, "lat {} vs {}", back.latitude_rad, lat);
491 prop_assert!((back.height_m - h).abs() < 2e-8, "h {} vs {}", back.height_m, h);
492 prop_assert!((WGS84.ecef_from_geodetic(back) - ecef).length() < 2e-8);
494 if lat.abs() < FRAC_PI_2 - 1e-9 {
495 let dlon = (back.longitude_rad - lon + std::f64::consts::PI)
496 .rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
497 prop_assert!(dlon.abs() < 1e-14 / lat.cos().max(1e-6), "lon {} vs {}", back.longitude_rad, lon);
498 }
499 }
500
501 #[test]
503 fn ecef_round_trips_through_geodetic(
504 direction in prop::array::uniform3(-1.0..1.0f64),
505 radius in 6.25e6..4.6e7f64,
506 ) {
507 let d = DVec3::from_array(direction);
508 prop_assume!(d.length() > 1e-3);
509 let p = d.normalize() * radius;
510 let g = WGS84.geodetic_from_ecef(p).unwrap();
511 let back = WGS84.ecef_from_geodetic(g);
512 prop_assert!((back - p).length() < 1e-7 * (radius / 6.4e6), "{back} vs {p}");
513 }
514 }
515}