geometry_strategy/geographic/
azimuth.rs1use geometry_cs::{CoordinateSystem, GeographicFamily, Spheroid};
32use geometry_tag::SameAs;
33use geometry_trait::Point;
34
35use crate::azimuth::AzimuthStrategy;
36
37#[cfg(feature = "std")]
38use crate::geographic::spheroid_calc::SpheroidCalc;
39#[cfg(feature = "std")]
40use crate::normalise::{HasAngularUnits, lonlat_radians};
41
42#[derive(Debug, Clone, Copy)]
51pub struct GeographicAzimuth {
52 pub spheroid: Spheroid,
54}
55
56impl GeographicAzimuth {
57 pub const WGS84: Self = Self {
60 spheroid: Spheroid::WGS84,
61 };
62}
63
64impl Default for GeographicAzimuth {
65 #[inline]
66 fn default() -> Self {
67 Self::WGS84
68 }
69}
70
71#[cfg(feature = "std")]
72impl<P1, P2> AzimuthStrategy<P1, P2> for GeographicAzimuth
73where
74 P1: Point<Scalar = f64>,
75 P2: Point<Scalar = f64>,
76 P1::Cs: HasAngularUnits,
77 P2::Cs: HasAngularUnits,
78 <P1::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
79 <P2::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
80{
81 type Out = f64;
82
83 #[allow(clippy::many_single_char_names, clippy::float_cmp)]
89 #[inline]
90 fn azimuth(&self, p1: &P1, p2: &P2) -> f64 {
91 let calc = SpheroidCalc::from(self.spheroid);
92 let f = calc.f;
93 let (lon1, lat1) = lonlat_radians(p1);
94 let (lon2, lat2) = lonlat_radians(p2);
95
96 let dlon = lon2 - lon1;
97 let sin_dlon = dlon.sin();
98 let cos_dlon = dlon.cos();
99 let sin_lat1 = lat1.sin();
100 let cos_lat1 = lat1.cos();
101 let sin_lat2 = lat2.sin();
102 let cos_lat2 = lat2.cos();
103
104 let cos_d = (sin_lat1 * sin_lat2 + cos_lat1 * cos_lat2 * cos_dlon).clamp(-1.0, 1.0);
107 let d = cos_d.acos();
108 let sin_d = d.sin();
109
110 if sin_d.abs() <= f64::EPSILON {
115 return 0.0;
116 }
117
118 let pi = core::f64::consts::PI;
119
120 let (a, u) = if cos_lat2.abs() <= f64::EPSILON {
122 (if sin_lat2 < 0.0 { pi } else { 0.0 }, 0.0)
123 } else {
124 let tan_lat2 = sin_lat2 / cos_lat2;
125 let m = cos_lat1 * tan_lat2 - sin_lat1 * cos_dlon;
126 let a = sin_dlon.atan2(m);
127 let u = (f / 2.0) * (cos_lat1 * cos_lat1) * (2.0 * a).sin();
128 (a, u)
129 };
130
131 let v = if cos_lat1.abs() <= f64::EPSILON {
134 0.0
135 } else {
136 let tan_lat1 = sin_lat1 / cos_lat1;
137 let n = cos_lat2 * tan_lat1 - sin_lat2 * cos_dlon;
138 let b = sin_dlon.atan2(n);
139 (f / 2.0) * (cos_lat2 * cos_lat2) * (2.0 * b).sin()
140 };
141
142 let t = d / sin_d;
143 let da = v * t - u;
144 let mut azimuth = a - da;
145 normalize_azimuth(&mut azimuth, a, da);
146 azimuth
147 }
148}
149
150#[cfg(feature = "std")]
155#[inline]
156fn normalize_azimuth(azimuth: &mut f64, a: f64, da: f64) {
157 let pi = core::f64::consts::PI;
158 if a >= 0.0 {
159 if da >= 0.0 {
161 if *azimuth < 0.0 {
162 *azimuth = 0.0;
163 }
164 } else if *azimuth > pi {
165 *azimuth = pi;
166 }
167 } else {
168 if da <= 0.0 {
170 if *azimuth > 0.0 {
171 *azimuth = 0.0;
172 }
173 } else if *azimuth < -pi {
174 *azimuth = -pi;
175 }
176 }
177}
178
179#[cfg(all(test, feature = "std"))]
180mod tests {
181 #![allow(
185 clippy::float_cmp,
186 reason = "azimuths are compared with an explicit absolute tolerance, not `==`"
187 )]
188 #![allow(
189 clippy::excessive_precision,
190 reason = "reference constants are copied verbatim from Boost's azimuth.cpp; the trailing digits document the source value even past f64 precision."
191 )]
192
193 use super::GeographicAzimuth;
194 use crate::azimuth::AzimuthStrategy;
195 use geometry_adapt::{Adapt, WithCs};
196 use geometry_cs::{Degree, Geographic};
197
198 type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
199
200 #[inline]
201 fn gg(lon: f64, lat: f64) -> Gg {
202 WithCs::new(Adapt([lon, lat]))
203 }
204
205 #[test]
207 fn coincident_is_zero() {
208 let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(0., 0.));
209 assert!(got.abs() < 1e-12);
210 }
211
212 #[test]
214 fn diagonal() {
215 let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(1., 1.));
216 let expected = 45.187_718_848_049_521_f64.to_radians();
217 assert!((got - expected).abs() < 1e-9, "got {got}");
218 }
219
220 #[test]
222 fn shallow_east() {
223 let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(100., 1.));
224 let expected = 88.986_933_066_023_497_f64.to_radians();
225 assert!((got - expected).abs() < 1e-9, "got {got}");
226 }
227
228 #[test]
230 fn diagonal_west() {
231 let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(-1., 1.));
232 let expected = (-45.187_718_848_049_521_f64).to_radians();
233 assert!((got - expected).abs() < 1e-9, "got {got}");
234 }
235
236 #[test]
240 fn normalize_azimuth_clamps_all_four_quadrants() {
241 use super::normalize_azimuth;
242 let pi = core::f64::consts::PI;
243
244 let mut az = -0.1;
246 normalize_azimuth(&mut az, 0.05, 0.15);
247 assert_eq!(az, 0.0);
248
249 let mut az = pi + 0.1;
251 normalize_azimuth(&mut az, pi - 0.05, -0.15);
252 assert_eq!(az, pi);
253
254 let mut az = 0.1;
256 normalize_azimuth(&mut az, -0.05, -0.15);
257 assert_eq!(az, 0.0);
258
259 let mut az = -pi - 0.1;
261 normalize_azimuth(&mut az, -pi + 0.05, 0.15);
262 assert_eq!(az, -pi);
263
264 let mut az = 0.5;
266 normalize_azimuth(&mut az, 0.4, -0.1);
267 assert_eq!(az, 0.5);
268
269 let mut az = -0.5;
270 normalize_azimuth(&mut az, -0.4, -0.1);
271 assert_eq!(az, -0.5);
272 }
273}