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}