Skip to main content

geometry_algorithm/
azimuth.rs

1//! `azimuth(&p1, &p2)` — the bearing from `p1` to `p2`.
2//!
3//! Mirrors `boost::geometry::azimuth` from
4//! `boost/geometry/algorithms/azimuth.hpp`. Dispatches to the per-CS-
5//! family default strategy via [`geometry_strategy::DefaultAzimuth`] —
6//! Cartesian points resolve to [`geometry_strategy::CartesianAzimuth`],
7//! spherical to [`geometry_strategy::SphericalAzimuth`] and geographic
8//! to [`geometry_strategy::GeographicAzimuth`]. All three share one
9//! bearing convention (`0` = north, increasing clockwise — the geodetic
10//! convention Boost uses uniformly, `azimuth.hpp:76`).
11
12use geometry_cs::CoordinateSystem;
13use geometry_strategy::{AzimuthStrategy, DefaultAzimuth, DefaultAzimuthStrategy};
14use geometry_trait::Point;
15
16/// Shorthand for the CS family of a point type. Keeps the `where`
17/// clauses readable; matches the projection in
18/// [`geometry_strategy::DefaultAzimuthStrategy`].
19type Family<P> = <<P as Point>::Cs as CoordinateSystem>::Family;
20
21/// The azimuth (bearing) from `p1` to `p2`, in radians, using the
22/// default strategy for the points' coordinate-system family.
23///
24/// Mirrors `boost::geometry::azimuth(p1, p2)` from
25/// `boost/geometry/algorithms/azimuth.hpp`. The strategy is resolved
26/// via [`geometry_strategy::DefaultAzimuthStrategy`]; for an explicit
27/// strategy use [`azimuth_with`].
28#[must_use]
29pub fn azimuth<P1, P2>(
30    p1: &P1,
31    p2: &P2,
32) -> <DefaultAzimuthStrategy<P1> as AzimuthStrategy<P1, P2>>::Out
33where
34    P1: Point,
35    P2: Point,
36    Family<P1>: DefaultAzimuth<Family<P1>>,
37    DefaultAzimuthStrategy<P1>: AzimuthStrategy<P1, P2> + Default,
38{
39    DefaultAzimuthStrategy::<P1>::default().azimuth(p1, p2)
40}
41
42/// Azimuth from `p1` to `p2` using an explicitly supplied strategy.
43///
44/// Mirrors the `azimuth(p1, p2, strategy)` overload at
45/// `boost/geometry/algorithms/azimuth.hpp`. Taking the strategy by
46/// value matches the by-value call shape of [`crate::distance_with`];
47/// concrete strategies are zero-sized or small `Copy` configuration
48/// objects, so this monomorphises into nothing.
49#[must_use]
50#[allow(
51    clippy::needless_pass_by_value,
52    reason = "Strategies are ZST/small Copy configuration objects; by-value matches the user-facing call shape."
53)]
54pub fn azimuth_with<P1, P2, S>(p1: &P1, p2: &P2, s: S) -> S::Out
55where
56    P1: Point,
57    P2: Point,
58    S: AzimuthStrategy<P1, P2>,
59{
60    s.azimuth(p1, p2)
61}
62
63#[cfg(test)]
64mod tests {
65    //! Reference from `boost/geometry/test/algorithms/azimuth.cpp`.
66    //! Cartesian is `atan2(dx, dy)` (0 = north, clockwise); spherical /
67    //! geographic are the geodetic bearings from `test_sph` / `test_geo`
68    //! (`:49-66`).
69    #![allow(
70        clippy::float_cmp,
71        reason = "azimuths are compared with an explicit absolute tolerance, not `==`"
72    )]
73    #![allow(
74        clippy::excessive_precision,
75        reason = "reference constants are copied verbatim from Boost's azimuth.cpp; the trailing digits document the source value even past f64 precision."
76    )]
77
78    use super::azimuth;
79    use geometry_cs::Cartesian;
80    use geometry_model::Point2D;
81
82    type P = Point2D<f64, Cartesian>;
83
84    #[test]
85    fn due_north_is_zero() {
86        assert!((azimuth(&P::new(0.0, 0.0), &P::new(0.0, 1.0)) - 0.0).abs() < 1e-12);
87    }
88
89    #[test]
90    fn due_east_is_half_pi() {
91        let a = azimuth(&P::new(0.0, 0.0), &P::new(1.0, 0.0));
92        assert!((a - core::f64::consts::FRAC_PI_2).abs() < 1e-12);
93    }
94
95    /// `azimuth.cpp:52` (`test_sph`) — spherical `(0,0) → (1,1)` ≈
96    /// 44.9956° dispatches to `SphericalAzimuth`.
97    #[cfg(feature = "std")]
98    #[test]
99    fn spherical_diagonal_close_to_45() {
100        use geometry_adapt::{Adapt, WithCs};
101        use geometry_cs::{Degree, Spherical};
102
103        type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
104        let sp = |lon: f64, lat: f64| -> Sp { WithCs::new(Adapt([lon, lat])) };
105
106        let got = azimuth(&sp(0., 0.), &sp(1., 1.));
107        let expected = 44.995_636_455_344_851_f64.to_radians();
108        assert!((got - expected).abs() < 1e-9, "got {got}");
109    }
110
111    /// `azimuth.cpp:62` (`test_geo`) — geographic `(0,0) → (1,1)` ≈
112    /// 45.1877° dispatches to `GeographicAzimuth` (Andoyer).
113    #[cfg(feature = "std")]
114    #[test]
115    fn geographic_diagonal_close_to_45() {
116        use geometry_adapt::{Adapt, WithCs};
117        use geometry_cs::{Degree, Geographic};
118
119        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
120        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
121
122        let got = azimuth(&gg(0., 0.), &gg(1., 1.));
123        let expected = 45.187_718_848_049_521_f64.to_radians();
124        assert!((got - expected).abs() < 1e-9, "got {got}");
125    }
126
127    /// Geographic bearing to a pole (`cos φ₂ == 0`): due north → 0,
128    /// due south → π (the pole branch of `andoyer_inverse.hpp`).
129    #[cfg(feature = "std")]
130    #[test]
131    fn geographic_destination_at_a_pole() {
132        use geometry_adapt::{Adapt, WithCs};
133        use geometry_cs::{Degree, Geographic};
134
135        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
136        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
137
138        let north = azimuth(&gg(10., 20.), &gg(10., 90.));
139        assert!(north.abs() < 1e-12, "got {north}");
140        let south = azimuth(&gg(10., 20.), &gg(10., -90.));
141        assert!((south - core::f64::consts::PI).abs() < 1e-12, "got {south}");
142    }
143
144    /// Geographic bearing *from* the north pole (`cos φ₁ == 0`): every
145    /// direction is south, azimuth π.
146    #[cfg(feature = "std")]
147    #[test]
148    fn geographic_start_at_north_pole_points_south() {
149        use geometry_adapt::{Adapt, WithCs};
150        use geometry_cs::{Degree, Geographic};
151
152        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
153        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
154
155        let got = azimuth(&gg(0., 90.), &gg(0., 0.));
156        assert!((got - core::f64::consts::PI).abs() < 1e-12, "got {got}");
157    }
158
159    /// A geographic pair straddling the ±180° antimeridian: the bearing
160    /// is (near) due east, not a trip most of the way around the globe.
161    #[cfg(feature = "std")]
162    #[test]
163    fn geographic_antimeridian_crossing_heads_east() {
164        use geometry_adapt::{Adapt, WithCs};
165        use geometry_cs::{Degree, Geographic};
166
167        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
168        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
169
170        let got = azimuth(&gg(179.5, 0.), &gg(-179.5, 0.));
171        assert!(
172            (got - core::f64::consts::FRAC_PI_2).abs() < 1e-6,
173            "got {got}"
174        );
175    }
176}