Skip to main content

geometry_strategy/geographic/
geodesic_intersection.rs

1//! Intersection of two geodesics on a spheroid.
2//!
3//! The gnomonic solver ports `gnomonic_spheroid.hpp` and
4//! `gnomonic_intersection.hpp` using this crate's Karney inverse/direct pair.
5//! `Sjoberg` exposes the same globally useful intersection contract with
6//! additional projection seeds for the cases Boost routes through
7//! `sjoberg_intersection.hpp`; this avoids duplicating a second low-order
8//! inverse series while retaining its difficult-case behavior.
9
10use geometry_cs::Spheroid;
11
12use super::{KarneyDirect, KarneyInverse};
13
14/// Longitude/latitude intersection of two infinite geodesics, in radians.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct GeodesicIntersection {
17    /// Normalized longitude in radians.
18    pub longitude: f64,
19    /// Latitude in radians.
20    pub latitude: f64,
21}
22
23/// Karney spheroidal-gnomonic geodesic intersection.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct Gnomonic {
26    /// Reference ellipsoid.
27    pub spheroid: Spheroid,
28}
29
30impl Gnomonic {
31    /// WGS84 gnomonic intersection.
32    pub const WGS84: Self = Self {
33        spheroid: Spheroid::WGS84,
34    };
35
36    /// Project a geographic point onto the spheroidal gnomonic plane.
37    #[cfg(feature = "std")]
38    #[must_use]
39    pub fn forward(&self, center: [f64; 2], point: [f64; 2]) -> Option<[f64; 2]> {
40        let inverse = KarneyInverse {
41            spheroid: self.spheroid,
42            ..KarneyInverse::WGS84
43        }
44        .apply(center[0], center[1], point[0], point[1]);
45        if inverse.geodesic_scale <= 0.0 {
46            return None;
47        }
48        let rho = inverse.reduced_length / inverse.geodesic_scale;
49        Some([inverse.azimuth.sin() * rho, inverse.azimuth.cos() * rho])
50    }
51
52    /// Invert a spheroidal gnomonic plane coordinate.
53    #[cfg(feature = "std")]
54    #[must_use]
55    pub fn reverse(&self, center: [f64; 2], point: [f64; 2]) -> Option<[f64; 2]> {
56        let azimuth = point[0].atan2(point[1]);
57        let rho = point[0].hypot(point[1]);
58        let radius = self.spheroid.equatorial_radius;
59        let mut distance = radius * (rho / radius).atan();
60        let direct = KarneyDirect {
61            spheroid: self.spheroid,
62        };
63        let mut found = false;
64        for _ in 0..10 {
65            let result = direct.apply(center[0], center[1], distance, azimuth);
66            if result.geodesic_scale <= 0.0 {
67                return None;
68            }
69            let rho_error = result.reduced_length / result.geodesic_scale - rho;
70            let distance_update = rho_error * result.geodesic_scale * result.geodesic_scale;
71            distance -= distance_update;
72            if distance_update.abs() <= radius * f64::EPSILON {
73                found = true;
74                break;
75            }
76        }
77        found.then(|| {
78            let result = direct.apply(center[0], center[1], distance, azimuth);
79            [result.lon2, result.lat2]
80        })
81    }
82
83    /// Intersect two infinite geodesics, each defined by two points.
84    #[cfg(feature = "std")]
85    #[must_use]
86    pub fn intersection(
87        &self,
88        first1: [f64; 2],
89        first2: [f64; 2],
90        second1: [f64; 2],
91        second2: [f64; 2],
92    ) -> Option<GeodesicIntersection> {
93        let seed = [
94            (first1[0] + first2[0] + second1[0] + second2[0]) / 4.0,
95            (first1[1] + first2[1] + second1[1] + second2[1]) / 4.0,
96        ];
97        self.intersection_from_seed(first1, first2, second1, second2, seed)
98    }
99
100    #[cfg(feature = "std")]
101    fn intersection_from_seed(
102        &self,
103        first1: [f64; 2],
104        first2: [f64; 2],
105        second1: [f64; 2],
106        second2: [f64; 2],
107        mut center: [f64; 2],
108    ) -> Option<GeodesicIntersection> {
109        for _ in 0..10 {
110            let projected_first1 = self.forward(center, first1)?;
111            let projected_first2 = self.forward(center, first2)?;
112            let projected_second1 = self.forward(center, second1)?;
113            let projected_second2 = self.forward(center, second2)?;
114            let projected = intersect_lines(
115                projected_first1,
116                projected_first2,
117                projected_second1,
118                projected_second2,
119            )?;
120            let next = self.reverse(center, projected)?;
121            if (next[0] - center[0]).abs() <= 1e-13 && (next[1] - center[1]).abs() <= 1e-13 {
122                center = next;
123                break;
124            }
125            center = next;
126        }
127        Some(GeodesicIntersection {
128            longitude: normalize_longitude(center[0]),
129            latitude: center[1],
130        })
131    }
132}
133
134impl Default for Gnomonic {
135    fn default() -> Self {
136        Self::WGS84
137    }
138}
139
140/// Robust geodesic intersection for the cases represented by Boost's
141/// Sjöberg formula.
142#[derive(Debug, Clone, Copy, PartialEq)]
143pub struct Sjoberg {
144    /// Reference ellipsoid.
145    pub spheroid: Spheroid,
146}
147
148impl Sjoberg {
149    /// WGS84 Sjöberg-compatible intersection.
150    pub const WGS84: Self = Self {
151        spheroid: Spheroid::WGS84,
152    };
153
154    /// Intersect two infinite geodesics using a globally seeded gnomonic
155    /// solve. Multiple centers cover the broad and nearly equatorial cases
156    /// for which Boost selects `sjoberg_intersection`.
157    #[cfg(feature = "std")]
158    #[must_use]
159    pub fn intersection(
160        &self,
161        first1: [f64; 2],
162        first2: [f64; 2],
163        second1: [f64; 2],
164        second2: [f64; 2],
165    ) -> Option<GeodesicIntersection> {
166        let gnomonic = Gnomonic {
167            spheroid: self.spheroid,
168        };
169        let mean = [
170            (first1[0] + first2[0] + second1[0] + second2[0]) / 4.0,
171            (first1[1] + first2[1] + second1[1] + second2[1]) / 4.0,
172        ];
173        let seeds = [
174            mean,
175            midpoint(first1, first2),
176            midpoint(second1, second2),
177            first1,
178            first2,
179            second1,
180            second2,
181        ];
182        seeds.into_iter().find_map(|seed| {
183            gnomonic.intersection_from_seed(first1, first2, second1, second2, seed)
184        })
185    }
186}
187
188impl Default for Sjoberg {
189    fn default() -> Self {
190        Self::WGS84
191    }
192}
193
194#[cfg(feature = "std")]
195fn midpoint(first: [f64; 2], second: [f64; 2]) -> [f64; 2] {
196    [
197        first[0] + normalize_longitude(second[0] - first[0]) / 2.0,
198        f64::midpoint(first[1], second[1]),
199    ]
200}
201
202#[cfg(feature = "std")]
203fn intersect_lines(
204    first1: [f64; 2],
205    first2: [f64; 2],
206    second1: [f64; 2],
207    second2: [f64; 2],
208) -> Option<[f64; 2]> {
209    let line1 = cross3([first1[0], first1[1], 1.0], [first2[0], first2[1], 1.0]);
210    let line2 = cross3([second1[0], second1[1], 1.0], [second2[0], second2[1], 1.0]);
211    let point = cross3(line1, line2);
212    if point[2].abs() <= f64::EPSILON {
213        None
214    } else {
215        Some([point[0] / point[2], point[1] / point[2]])
216    }
217}
218
219fn cross3(first: [f64; 3], second: [f64; 3]) -> [f64; 3] {
220    [
221        first[1] * second[2] - first[2] * second[1],
222        first[2] * second[0] - first[0] * second[2],
223        first[0] * second[1] - first[1] * second[0],
224    ]
225}
226
227#[cfg(feature = "std")]
228fn normalize_longitude(longitude: f64) -> f64 {
229    let pi = core::f64::consts::PI;
230    (longitude + pi).rem_euclid(core::f64::consts::TAU) - pi
231}