Skip to main content

geometry_strategy/
azimuth.rs

1//! `AzimuthStrategy<P1, P2>` — the angle from `p1` to `p2`.
2//!
3//! Mirrors `boost::geometry::strategy::azimuth::*` from
4//! `boost/geometry/strategies/cartesian/azimuth.hpp`. Cartesian only in
5//! LA4.T4; spherical / geographic azimuth land in LA8.T4.
6//!
7//! The scalar is fixed to `f64` — the same convention the Haversine
8//! kernel uses (`distance_haversine.rs`) to reach `f64`'s inherent
9//! `atan2`, since `CoordinateScalar` does not yet carry a `Trig` bound.
10
11use geometry_cs::{CartesianFamily, CoordinateSystem, GeographicFamily, SphericalFamily};
12// `SameAs` is used only by the `std`-gated `CartesianAzimuth` impl below.
13#[cfg(feature = "std")]
14use geometry_tag::SameAs;
15use geometry_trait::Point;
16
17/// Strategy computing the azimuth (bearing) from one point to another.
18pub trait AzimuthStrategy<P1: Point, P2: Point> {
19    /// The angle type (radians for the Cartesian implementation).
20    type Out;
21
22    /// The signed angle from `p1` to `p2`.
23    fn azimuth(&self, p1: &P1, p2: &P2) -> Self::Out;
24}
25
26/// "Which azimuth strategy do we pick by default for this CS family?"
27///
28/// Mirrors v1's [`DefaultDistance`](crate::distance::DefaultDistance)
29/// for the azimuth algorithm — the Rust analogue of Boost's
30/// `strategy::azimuth::services::default_strategy<cs_tag>` in
31/// `strategies/azimuth.hpp` / `strategies/{cartesian,spherical,
32/// geographic}/azimuth.hpp`. Each family reports its default bearing
33/// formula:
34///
35/// ```ignore
36/// impl DefaultAzimuth<CartesianFamily>  for CartesianFamily  { type Strategy = CartesianAzimuth;  }
37/// impl DefaultAzimuth<SphericalFamily>  for SphericalFamily  { type Strategy = SphericalAzimuth;  }
38/// impl DefaultAzimuth<GeographicFamily> for GeographicFamily { type Strategy = GeographicAzimuth; }
39/// ```
40pub trait DefaultAzimuth<Family> {
41    /// The azimuth strategy chosen for this family. Must implement
42    /// [`Default`] because the free-function `azimuth(p1, p2)` builds
43    /// it without arguments.
44    type Strategy: Default;
45}
46
47/// Cartesian family defaults to [`CartesianAzimuth`].
48impl DefaultAzimuth<CartesianFamily> for CartesianFamily {
49    type Strategy = CartesianAzimuth;
50}
51
52/// Spherical family defaults to [`SphericalAzimuth`](crate::spherical::SphericalAzimuth).
53impl DefaultAzimuth<SphericalFamily> for SphericalFamily {
54    type Strategy = crate::spherical::SphericalAzimuth;
55}
56
57/// Geographic family defaults to [`GeographicAzimuth`](crate::geographic::GeographicAzimuth)
58/// (Andoyer inverse — Boost's default geographic azimuth policy).
59impl DefaultAzimuth<GeographicFamily> for GeographicFamily {
60    type Strategy = crate::geographic::GeographicAzimuth;
61}
62
63/// Type alias resolving the default azimuth strategy for a point `P` by
64/// walking `P -> Cs -> Family -> DefaultAzimuth::Strategy`.
65///
66/// Mirrors [`DefaultDistanceStrategy`](crate::distance::DefaultDistanceStrategy)
67/// for the azimuth algorithm; the free-function `azimuth(p1, p2)`
68/// monomorphises against this at the call site.
69pub type DefaultAzimuthStrategy<P> =
70    <<<P as Point>::Cs as CoordinateSystem>::Family as DefaultAzimuth<
71        <<P as Point>::Cs as CoordinateSystem>::Family,
72    >>::Strategy;
73
74/// Cartesian azimuth: `atan2(dx, dy)`, in radians, measured **clockwise
75/// from the positive y-axis** (i.e. `0` points along +y ("North"), `π/2`
76/// along +x ("East")).
77///
78/// Mirrors `boost::geometry::strategy::azimuth::cartesian` from
79/// `boost/geometry/strategies/cartesian/azimuth.hpp:74-76`, whose
80/// comment is explicit: *"azimuth 0 is at Y axis, increasing right — as
81/// in spherical/geographic where 0 is at North axis"*. This keeps the
82/// Cartesian bearing convention consistent with the crate's spherical
83/// and geographic azimuths, which also measure clockwise from North.
84#[derive(Debug, Default, Clone, Copy)]
85pub struct CartesianAzimuth;
86
87// `std`-gated: the kernel uses `f64::atan2`, which `geometry-coords`
88// does not shim under `libm` (the scalar trait exposes only `sqrt`/`abs`;
89// see the module doc). Matches the gating on the spherical/geographic
90// azimuth impls, which are `std`-only for the same reason.
91#[cfg(feature = "std")]
92impl<P1, P2> AzimuthStrategy<P1, P2> for CartesianAzimuth
93where
94    P1: Point<Scalar = f64>,
95    P2: Point<Scalar = f64>,
96    <P1::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
97    <P2::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
98{
99    type Out = f64;
100
101    #[inline]
102    fn azimuth(&self, p1: &P1, p2: &P2) -> f64 {
103        let dx = p2.get::<0>() - p1.get::<0>();
104        let dy = p2.get::<1>() - p1.get::<1>();
105        // `atan2(dx, dy)`, NOT `atan2(dy, dx)`: Boost measures the
106        // bearing clockwise from +y ("North"), so 0 points North and
107        // π/2 points East (azimuth.hpp:76).
108        dx.atan2(dy)
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    //! Reference values from
115    //! `boost/geometry/test/algorithms/azimuth.cpp` (`test_car`) — a
116    //! Cartesian azimuth is `atan2(dx, dy)`, measured clockwise from +y
117    //! ("North"): 0 = North, π/2 = East, ±π = South, −π/2 = West.
118
119    use super::{AzimuthStrategy, CartesianAzimuth};
120    use geometry_cs::Cartesian;
121    use geometry_model::Point2D;
122
123    type P = Point2D<f64, Cartesian>;
124
125    #[test]
126    fn north_is_zero() {
127        // Boost: azimuth 0 is at the +y ("North") axis.
128        let a = P::new(0.0, 0.0);
129        let b = P::new(0.0, 1.0);
130        assert!((CartesianAzimuth.azimuth(&a, &b) - 0.0).abs() < 1e-12);
131    }
132
133    #[test]
134    fn east_is_half_pi() {
135        // Boost: bearing increases clockwise (to the right), so +x is π/2.
136        let a = P::new(0.0, 0.0);
137        let b = P::new(1.0, 0.0);
138        assert!((CartesianAzimuth.azimuth(&a, &b) - core::f64::consts::FRAC_PI_2).abs() < 1e-12);
139    }
140
141    #[test]
142    fn west_is_minus_half_pi() {
143        // `azimuth.cpp` `test_car`: due West → −45°'s sibling, −π/2.
144        let a = P::new(0.0, 0.0);
145        let b = P::new(-1.0, 0.0);
146        assert!((CartesianAzimuth.azimuth(&a, &b) + core::f64::consts::FRAC_PI_2).abs() < 1e-12);
147    }
148
149    #[test]
150    fn diagonal_is_quarter_pi() {
151        // NE diagonal is π/4 under either convention — the case that
152        // hid the earlier transposed-argument bug.
153        let a = P::new(0.0, 0.0);
154        let b = P::new(1.0, 1.0);
155        assert!((CartesianAzimuth.azimuth(&a, &b) - core::f64::consts::FRAC_PI_4).abs() < 1e-12);
156    }
157
158    #[test]
159    fn northwest_diagonal_is_minus_quarter_pi() {
160        // `azimuth.cpp` `test_car`: (-1, 1) → −45° = −π/4. This case
161        // *diverges* between `atan2(dx,dy)` (correct) and the old
162        // `atan2(dy,dx)` (which gave +3π/4), so it locks in the fix.
163        let a = P::new(0.0, 0.0);
164        let b = P::new(-1.0, 1.0);
165        assert!((CartesianAzimuth.azimuth(&a, &b) + core::f64::consts::FRAC_PI_4).abs() < 1e-12);
166    }
167}