Skip to main content

rustial_engine/math/
geodesic.rs

1//! Geodesic calculations on an ellipsoid using Vincenty formulae.
2//!
3//! This module solves the two classical geodesic problems on an oblate
4//! ellipsoid:
5//!
6//! | Problem | Function | Description |
7//! |---------|----------|-------------|
8//! | **Inverse** | [`geodesic_distance`] | Distance and azimuths between two points. |
9//! | **Direct** | [`geodesic_destination`] | Destination given start, azimuth, distance. |
10//!
11//! The default helpers operate on WGS-84; the `_on` variants accept any
12//! [`Ellipsoid`].
13//!
14//! # Algorithm
15//!
16//! Both solvers implement the iterative Vincenty formulae as described in:
17//!
18//! > T. Vincenty, "Direct and Inverse Solutions of Geodesics on the Ellipsoid
19//! > with Application of Nested Equations", *Survey Review* **23**(176),
20//! > April 1975, pp. 88–93.
21//!
22//! # Numerical behavior
23//!
24//! - **Accuracy:** sub-millimeter for all well-conditioned pairs on WGS-84.
25//! - **Convergence:** up to [`MAX_ITERATIONS`] (200) iterations with a
26//!   tolerance of 1 × 10⁻¹² radians (~0.006 mm on Earth).
27//! - **Near-antipodal failure:** Vincenty can fail to converge when the two
28//!   points are nearly diametrically opposite.  In those cases a
29//!   [`VincentyConvergenceError`] is returned instead of panicking.
30//!   Callers needing guaranteed convergence for arbitrary inputs should
31//!   consider a Karney-class algorithm as a future enhancement.
32//! - **Coincident points:** detected early (sin σ < 10⁻¹⁵) and return
33//!   distance = 0, azimuths = 0.
34//! - **Equatorial paths:** the cos²α ≈ 0 guard prevents division-by-zero
35//!   when both points lie on the equator.
36//!
37//! # Altitude
38//!
39//! Altitude (`alt`) is **not** used in the geodesic computation.  The direct
40//! solver copies the start point's altitude to the destination unchanged.
41
42use crate::coord::GeoCoord;
43use crate::ellipsoid::Ellipsoid;
44use std::f64::consts::PI;
45use thiserror::Error;
46
47// ---------------------------------------------------------------------------
48// Error and result types
49// ---------------------------------------------------------------------------
50
51/// Error returned when a geodesic computation fails to converge.
52///
53/// This typically occurs for near-antipodal point pairs where the Vincenty
54/// iteration oscillates without settling.
55#[derive(Debug, Clone, PartialEq, Eq, Error)]
56#[error("Vincenty iteration did not converge after {0} iterations (near-antipodal points)")]
57pub struct VincentyConvergenceError(u32);
58
59impl VincentyConvergenceError {
60    /// Number of iterations that were attempted before giving up.
61    #[inline]
62    pub fn iterations(&self) -> u32 {
63        self.0
64    }
65}
66
67/// Result of the inverse geodesic problem.
68#[derive(Debug, Clone, Copy, PartialEq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct GeodesicResult {
71    /// Distance between the two points in meters.
72    pub distance: f64,
73    /// Forward azimuth at the start point, in radians (0 = north, clockwise).
74    pub azimuth_start: f64,
75    /// Forward azimuth at the end point, in radians.
76    pub azimuth_end: f64,
77}
78
79// ---------------------------------------------------------------------------
80// Iteration parameters
81// ---------------------------------------------------------------------------
82
83/// Maximum Vincenty iterations before declaring non-convergence.
84const MAX_ITERATIONS: u32 = 200;
85
86/// Convergence threshold in radians (~0.006 mm on Earth's surface).
87const CONVERGENCE: f64 = 1e-12;
88
89// ---------------------------------------------------------------------------
90// Longitude normalization helpers
91// ---------------------------------------------------------------------------
92
93/// Normalize a radian delta-longitude into `[-π, π]`.
94///
95/// Ensures the inverse solve always follows the shortest (antimeridian-aware)
96/// great-elliptic path between the two points.
97#[inline]
98fn normalize_delta_lon_radians(mut lambda: f64) -> f64 {
99    while lambda > PI {
100        lambda -= 2.0 * PI;
101    }
102    while lambda < -PI {
103        lambda += 2.0 * PI;
104    }
105    lambda
106}
107
108/// Wrap a degree longitude into `[-180, 180]`.
109///
110/// Used by the direct solver to keep the output coordinate in the canonical
111/// geographic range.
112#[inline]
113fn wrap_lon_degrees(mut lon: f64) -> f64 {
114    while lon > 180.0 {
115        lon -= 360.0;
116    }
117    while lon < -180.0 {
118        lon += 360.0;
119    }
120    lon
121}
122
123// ---------------------------------------------------------------------------
124// Inverse problem (distance + azimuths)
125// ---------------------------------------------------------------------------
126
127/// Compute the geodesic distance and azimuths between two geographic
128/// coordinates on the WGS-84 ellipsoid using the Vincenty inverse formula.
129///
130/// Returns [`Err`] only for near-antipodal pairs where the iteration
131/// fails to converge.
132pub fn geodesic_distance(
133    from: &GeoCoord,
134    to: &GeoCoord,
135) -> Result<GeodesicResult, VincentyConvergenceError> {
136    geodesic_distance_on(from, to, &Ellipsoid::WGS84)
137}
138
139/// Compute the geodesic distance on an arbitrary ellipsoid.
140///
141/// See [`geodesic_distance`] for behavioral details.
142pub fn geodesic_distance_on(
143    from: &GeoCoord,
144    to: &GeoCoord,
145    ellipsoid: &Ellipsoid,
146) -> Result<GeodesicResult, VincentyConvergenceError> {
147    let a = ellipsoid.a;
148    let f = ellipsoid.f;
149    let b = ellipsoid.b;
150
151    // --- Reduced latitudes (parametric latitude on the auxiliary sphere) ---
152    let u1 = ((1.0 - f) * from.lat.to_radians().tan()).atan();
153    let u2 = ((1.0 - f) * to.lat.to_radians().tan()).atan();
154    let (sin_u1, cos_u1) = u1.sin_cos();
155    let (sin_u2, cos_u2) = u2.sin_cos();
156
157    // Difference in longitude on the auxiliary sphere, normalized to [-π, π].
158    let l = normalize_delta_lon_radians((to.lon - from.lon).to_radians());
159    let mut lambda = l;
160
161    let mut sin_sigma;
162    let mut cos_sigma;
163    let mut sigma;
164    let mut sin_alpha;
165    let mut cos2_alpha;
166    let mut cos_2sigma_m;
167
168    // --- Vincenty iteration ---
169    for i in 0..MAX_ITERATIONS {
170        let (sin_lambda, cos_lambda) = lambda.sin_cos();
171
172        // Eq. (14): sin σ
173        sin_sigma = ((cos_u2 * sin_lambda).powi(2)
174            + (cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda).powi(2))
175        .sqrt();
176
177        // Coincident-point fast path.
178        if sin_sigma < 1e-15 {
179            return Ok(GeodesicResult {
180                distance: 0.0,
181                azimuth_start: 0.0,
182                azimuth_end: 0.0,
183            });
184        }
185
186        // Eq. (15): cos σ
187        cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lambda;
188        // Eq. (16): σ
189        sigma = sin_sigma.atan2(cos_sigma);
190
191        // Eq. (17): sin α  (α = azimuth of the geodesic at the equator)
192        sin_alpha = cos_u1 * cos_u2 * sin_lambda / sin_sigma;
193        cos2_alpha = 1.0 - sin_alpha * sin_alpha;
194
195        // Eq. (18): cos 2σ_m — guarded for equatorial paths (cos²α ≈ 0).
196        cos_2sigma_m = if cos2_alpha.abs() < 1e-15 {
197            0.0
198        } else {
199            cos_sigma - 2.0 * sin_u1 * sin_u2 / cos2_alpha
200        };
201
202        // Eq. (10): C
203        let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
204
205        // Eq. (11): updated λ
206        let lambda_prev = lambda;
207        lambda = l
208            + (1.0 - c)
209                * f
210                * sin_alpha
211                * (sigma
212                    + c * sin_sigma
213                        * (cos_2sigma_m
214                            + c * cos_sigma * (-1.0 + 2.0 * cos_2sigma_m * cos_2sigma_m)));
215
216        if (lambda - lambda_prev).abs() < CONVERGENCE {
217            // --- Converged: compute final distance and azimuths ---
218
219            // Eq. (3): u²
220            let u_sq = cos2_alpha * (a * a - b * b) / (b * b);
221            // Eq. (3): A
222            let cap_a =
223                1.0 + u_sq / 16384.0 * (4096.0 + u_sq * (-768.0 + u_sq * (320.0 - 175.0 * u_sq)));
224            // Eq. (4): B
225            let cap_b = u_sq / 1024.0 * (256.0 + u_sq * (-128.0 + u_sq * (74.0 - 47.0 * u_sq)));
226
227            // Eq. (6): Δσ
228            let delta_sigma = cap_b
229                * sin_sigma
230                * (cos_2sigma_m
231                    + cap_b / 4.0
232                        * (cos_sigma * (-1.0 + 2.0 * cos_2sigma_m * cos_2sigma_m)
233                            - cap_b / 6.0
234                                * cos_2sigma_m
235                                * (-3.0 + 4.0 * sin_sigma * sin_sigma)
236                                * (-3.0 + 4.0 * cos_2sigma_m * cos_2sigma_m)));
237
238            // Eq. (19): geodesic distance
239            let distance = b * cap_a * (sigma - delta_sigma);
240
241            // Eq. (20) & (21): forward azimuths, normalized to [0, 2π).
242            let (sin_lam, cos_lam) = lambda.sin_cos();
243            let az_start = (cos_u2 * sin_lam).atan2(cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lam);
244            let az_end = (cos_u1 * sin_lam).atan2(-sin_u1 * cos_u2 + cos_u1 * sin_u2 * cos_lam);
245
246            return Ok(GeodesicResult {
247                distance,
248                azimuth_start: (az_start + 2.0 * PI) % (2.0 * PI),
249                azimuth_end: (az_end + 2.0 * PI) % (2.0 * PI),
250            });
251        }
252
253        if i == MAX_ITERATIONS - 1 {
254            return Err(VincentyConvergenceError(MAX_ITERATIONS));
255        }
256    }
257
258    Err(VincentyConvergenceError(MAX_ITERATIONS))
259}
260
261// ---------------------------------------------------------------------------
262// Direct problem (destination from azimuth + distance)
263// ---------------------------------------------------------------------------
264
265/// Solve the direct geodesic problem: given a start point, azimuth (radians,
266/// 0 = north, clockwise), and distance (meters), compute the destination
267/// point on the WGS-84 ellipsoid.
268///
269/// Returns [`Err`] only if the iteration fails to converge (extremely rare
270/// for the direct problem).
271pub fn geodesic_destination(
272    from: &GeoCoord,
273    azimuth: f64,
274    distance: f64,
275) -> Result<GeoCoord, VincentyConvergenceError> {
276    geodesic_destination_on(from, azimuth, distance, &Ellipsoid::WGS84)
277}
278
279/// Solve the direct geodesic problem on an arbitrary ellipsoid.
280///
281/// See [`geodesic_destination`] for behavioral details.
282pub fn geodesic_destination_on(
283    from: &GeoCoord,
284    azimuth: f64,
285    distance: f64,
286    ellipsoid: &Ellipsoid,
287) -> Result<GeoCoord, VincentyConvergenceError> {
288    // Zero-distance fast path: return the start point unchanged.
289    if distance.abs() < 1e-15 {
290        return Ok(*from);
291    }
292
293    let a = ellipsoid.a;
294    let f = ellipsoid.f;
295    let b = ellipsoid.b;
296
297    let (sin_az, cos_az) = azimuth.sin_cos();
298    let tan_u1 = (1.0 - f) * from.lat.to_radians().tan();
299    let cos_u1 = 1.0 / (1.0 + tan_u1 * tan_u1).sqrt();
300    let sin_u1 = tan_u1 * cos_u1;
301
302    // σ₁ = angular distance on the sphere from the equator to the start.
303    let sigma1 = tan_u1.atan2(cos_az);
304    let sin_alpha = cos_u1 * sin_az;
305    let cos2_alpha = 1.0 - sin_alpha * sin_alpha;
306
307    // u² and series coefficients A, B (same as inverse).
308    let u_sq = cos2_alpha * (a * a - b * b) / (b * b);
309    let cap_a = 1.0 + u_sq / 16384.0 * (4096.0 + u_sq * (-768.0 + u_sq * (320.0 - 175.0 * u_sq)));
310    let cap_b = u_sq / 1024.0 * (256.0 + u_sq * (-128.0 + u_sq * (74.0 - 47.0 * u_sq)));
311
312    // Initial estimate of σ (angular distance on the auxiliary sphere).
313    let mut sigma = distance / (b * cap_a);
314
315    // --- Vincenty iteration for σ ---
316    for i in 0..MAX_ITERATIONS {
317        let cos_2sigma_m = (2.0 * sigma1 + sigma).cos();
318        let sin_sigma = sigma.sin();
319        let cos_sigma = sigma.cos();
320
321        let delta_sigma = cap_b
322            * sin_sigma
323            * (cos_2sigma_m
324                + cap_b / 4.0
325                    * (cos_sigma * (-1.0 + 2.0 * cos_2sigma_m * cos_2sigma_m)
326                        - cap_b / 6.0
327                            * cos_2sigma_m
328                            * (-3.0 + 4.0 * sin_sigma * sin_sigma)
329                            * (-3.0 + 4.0 * cos_2sigma_m * cos_2sigma_m)));
330
331        let sigma_prev = sigma;
332        sigma = distance / (b * cap_a) + delta_sigma;
333
334        if (sigma - sigma_prev).abs() < CONVERGENCE {
335            // --- Converged: compute destination lat/lon ---
336            let sin_sigma = sigma.sin();
337            let cos_sigma = sigma.cos();
338            let cos_2sigma_m = (2.0 * sigma1 + sigma).cos();
339
340            let lat2 = (sin_u1 * cos_sigma + cos_u1 * sin_sigma * cos_az).atan2(
341                (1.0 - f)
342                    * (sin_alpha * sin_alpha
343                        + (sin_u1 * sin_sigma - cos_u1 * cos_sigma * cos_az).powi(2))
344                    .sqrt(),
345            );
346
347            let lambda =
348                (sin_sigma * sin_az).atan2(cos_u1 * cos_sigma - sin_u1 * sin_sigma * cos_az);
349            let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
350            let l = lambda
351                - (1.0 - c)
352                    * f
353                    * sin_alpha
354                    * (sigma
355                        + c * sin_sigma
356                            * (cos_2sigma_m
357                                + c * cos_sigma * (-1.0 + 2.0 * cos_2sigma_m * cos_2sigma_m)));
358
359            let lon2 = wrap_lon_degrees((from.lon.to_radians() + l).to_degrees());
360
361            return Ok(GeoCoord::new(lat2.to_degrees(), lon2, from.alt));
362        }
363
364        if i == MAX_ITERATIONS - 1 {
365            return Err(VincentyConvergenceError(MAX_ITERATIONS));
366        }
367    }
368
369    Err(VincentyConvergenceError(MAX_ITERATIONS))
370}
371
372// ---------------------------------------------------------------------------
373// Tests
374// ---------------------------------------------------------------------------
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    // -- Inverse: basic cases ---------------------------------------------
381
382    #[test]
383    fn same_point_zero_distance() {
384        let p = GeoCoord::from_lat_lon(45.0, 10.0);
385        let result = geodesic_distance(&p, &p).unwrap();
386        assert!(result.distance < 1e-6);
387    }
388
389    #[test]
390    fn known_distance_london_paris() {
391        // London (51.5074 N, 0.1278 W) to Paris (48.8566 N, 2.3522 E)
392        // Vincenty gives ~343.5 km.
393        let london = GeoCoord::from_lat_lon(51.5074, -0.1278);
394        let paris = GeoCoord::from_lat_lon(48.8566, 2.3522);
395        let result = geodesic_distance(&london, &paris).unwrap();
396        assert!((result.distance - 343_500.0).abs() < 1500.0);
397    }
398
399    #[test]
400    fn equatorial_points() {
401        // 1 degree along the equator ~ 111,319 m.
402        let a = GeoCoord::from_lat_lon(0.0, 0.0);
403        let b = GeoCoord::from_lat_lon(0.0, 1.0);
404        let result = geodesic_distance(&a, &b).unwrap();
405        assert!((result.distance - 111_319.0).abs() < 100.0);
406    }
407
408    // -- Inverse: symmetry ------------------------------------------------
409
410    #[test]
411    fn inverse_is_symmetric() {
412        let a = GeoCoord::from_lat_lon(40.0, -74.0);
413        let b = GeoCoord::from_lat_lon(51.5, -0.1);
414        let ab = geodesic_distance(&a, &b).unwrap();
415        let ba = geodesic_distance(&b, &a).unwrap();
416        assert!((ab.distance - ba.distance).abs() < 1e-6);
417    }
418
419    // -- Inverse: antimeridian --------------------------------------------
420
421    #[test]
422    fn inverse_crosses_antimeridian_short_path() {
423        let a = GeoCoord::from_lat_lon(10.0, 179.0);
424        let b = GeoCoord::from_lat_lon(10.0, -179.0);
425        let result = geodesic_distance(&a, &b).unwrap();
426        // Should follow the short dateline crossing (~220 km), not almost full globe.
427        assert!(result.distance > 100_000.0);
428        assert!(result.distance < 500_000.0);
429    }
430
431    // -- Inverse: near-antipodal convergence failure ----------------------
432
433    #[test]
434    fn near_antipodal_returns_error() {
435        let a = GeoCoord::from_lat_lon(0.0, 0.0);
436        let b = GeoCoord::from_lat_lon(0.5, 179.7);
437        // Near-antipodal on the equator: Vincenty may fail to converge.
438        let result = geodesic_distance(&a, &b);
439        // We accept either a converged result or a convergence error --
440        // the important contract is that we never panic.
441        if let Ok(r) = result {
442            assert!(r.distance > 1e7);
443        }
444    }
445
446    // -- Direct: basic cases ----------------------------------------------
447
448    #[test]
449    fn direct_roundtrip() {
450        let start = GeoCoord::from_lat_lon(40.0, -74.0);
451        let azimuth = 0.8; // ~45.8 degrees
452        let dist = 500_000.0; // 500 km
453
454        let dest = geodesic_destination(&start, azimuth, dist).unwrap();
455        let inv = geodesic_distance(&start, &dest).unwrap();
456        assert!((inv.distance - dist).abs() < 0.01);
457    }
458
459    #[test]
460    fn direct_due_north() {
461        let start = GeoCoord::from_lat_lon(0.0, 0.0);
462        let dest = geodesic_destination(&start, 0.0, 1_000_000.0).unwrap();
463        // Should be roughly 9.04 degrees north.
464        assert!(dest.lat > 8.0 && dest.lat < 10.0);
465        assert!(dest.lon.abs() < 0.001);
466    }
467
468    // -- Direct: zero distance --------------------------------------------
469
470    #[test]
471    fn direct_zero_distance_returns_start() {
472        let start = GeoCoord::from_lat_lon(35.0, 139.0);
473        let dest = geodesic_destination(&start, 1.23, 0.0).unwrap();
474        assert!((dest.lat - start.lat).abs() < 1e-12);
475        assert!((dest.lon - start.lon).abs() < 1e-12);
476    }
477
478    // -- Direct: longitude wrapping ---------------------------------------
479
480    #[test]
481    fn direct_wraps_longitude_range() {
482        let start = GeoCoord::from_lat_lon(0.0, 179.8);
483        let dest = geodesic_destination(&start, PI / 2.0, 100_000.0).unwrap();
484        assert!((-180.0..=180.0).contains(&dest.lon));
485    }
486
487    // -- Altitude passthrough ---------------------------------------------
488
489    #[test]
490    fn altitude_passthrough_inverse() {
491        let a = GeoCoord::new(0.0, 0.0, 1234.5);
492        let b = GeoCoord::new(1.0, 1.0, 9999.0);
493        let result = geodesic_distance(&a, &b).unwrap();
494        // Altitude must not affect the distance calculation.
495        let a0 = GeoCoord::from_lat_lon(0.0, 0.0);
496        let b0 = GeoCoord::from_lat_lon(1.0, 1.0);
497        let result0 = geodesic_distance(&a0, &b0).unwrap();
498        assert!((result.distance - result0.distance).abs() < 1e-6);
499    }
500
501    #[test]
502    fn altitude_passthrough_direct() {
503        let start = GeoCoord::new(0.0, 0.0, 500.0);
504        let dest = geodesic_destination(&start, 0.0, 100_000.0).unwrap();
505        assert!((dest.alt - 500.0).abs() < 1e-12);
506    }
507}