Skip to main content

vedaksha_math/
angle.rs

1// Copyright © 2026 ArthIQ Labs LLC. All rights reserved.
2// Vedākṣha — Vision from Vedas
3// Licensed under BSL 1.1. See LICENSE file.
4// Contact: info@arthiq.net | https://vedaksha.net
5
6//! Angle normalization and conversion utilities.
7//!
8//! Provides functions to normalize angles to standard ranges and convert
9//! between degrees, radians, DMS (degrees-minutes-seconds), and HMS
10//! (hours-minutes-seconds) representations.
11//!
12//! Source: Standard trigonometric identities.
13
14use core::f64::consts::PI;
15
16/// Degrees-minutes-seconds representation of an angle.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Dms {
19    pub sign: i8,
20    pub degrees: u32,
21    pub minutes: u32,
22    pub seconds: f64,
23}
24
25/// Hours-minutes-seconds representation of an angle.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Hms {
28    pub hours: u32,
29    pub minutes: u32,
30    pub seconds: f64,
31}
32
33/// Normalize an angle in degrees to the range [0, 360).
34///
35/// Returns `0.0` for NaN or infinite inputs.
36#[must_use]
37pub fn normalize_degrees(angle: f64) -> f64 {
38    if !angle.is_finite() {
39        return 0.0;
40    }
41    let result = angle % 360.0;
42    let result = if result < 0.0 { result + 360.0 } else { result };
43    // Guard against floating-point modulo returning exactly 360.0
44    if result >= 360.0 { 0.0 } else { result }
45}
46
47/// Normalize an angle in degrees to the range [-180, 180).
48///
49/// Returns `0.0` for NaN or infinite inputs.
50#[must_use]
51pub fn normalize_degrees_signed(angle: f64) -> f64 {
52    if !angle.is_finite() {
53        return 0.0;
54    }
55    let mut result = normalize_degrees(angle);
56    if result >= 180.0 {
57        result -= 360.0;
58    }
59    result
60}
61
62/// Normalize an angle in radians to the range [0, 2π).
63///
64/// Returns `0.0` for NaN or infinite inputs.
65#[must_use]
66pub fn normalize_radians(angle: f64) -> f64 {
67    if !angle.is_finite() {
68        return 0.0;
69    }
70    let two_pi = 2.0 * PI;
71    let result = angle % two_pi;
72    let result = if result < 0.0 {
73        result + two_pi
74    } else {
75        result
76    };
77    // Guard against floating-point modulo returning exactly 2*pi
78    if result >= two_pi { 0.0 } else { result }
79}
80
81/// Convert degrees to radians.
82#[must_use]
83pub fn deg_to_rad(deg: f64) -> f64 {
84    deg * (PI / 180.0)
85}
86
87/// Convert radians to degrees.
88#[must_use]
89pub fn rad_to_deg(rad: f64) -> f64 {
90    rad * (180.0 / PI)
91}
92
93/// Convert decimal degrees to DMS (degrees-minutes-seconds).
94#[must_use]
95#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
96pub fn deg_to_dms(decimal_degrees: f64) -> Dms {
97    let sign = if decimal_degrees < 0.0 { -1i8 } else { 1i8 };
98    let abs_deg = decimal_degrees.abs();
99    let mut degrees = abs_deg as u32;
100    let remainder = (abs_deg - f64::from(degrees)) * 60.0;
101    let mut minutes = remainder as u32;
102    let mut seconds = (remainder - f64::from(minutes)) * 60.0;
103    // Carry propagation for floating-point accumulation edge cases
104    if seconds >= 60.0 {
105        seconds -= 60.0;
106        minutes += 1;
107    }
108    if minutes >= 60 {
109        minutes -= 60;
110        degrees += 1;
111    }
112    Dms {
113        sign,
114        degrees,
115        minutes,
116        seconds,
117    }
118}
119
120/// Convert DMS (degrees-minutes-seconds) to decimal degrees.
121#[must_use]
122pub fn dms_to_deg(dms: &Dms) -> f64 {
123    let abs_val = f64::from(dms.degrees) + f64::from(dms.minutes) / 60.0 + dms.seconds / 3600.0;
124    f64::from(dms.sign) * abs_val
125}
126
127/// Convert decimal degrees to HMS (hours-minutes-seconds).
128///
129/// Normalizes to [0, 360) first. Uses the convention that 1 hour = 15 degrees.
130#[must_use]
131#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
132pub fn deg_to_hms(decimal_degrees: f64) -> Hms {
133    let normalized = normalize_degrees(decimal_degrees);
134    let total_hours = normalized / 15.0;
135    let mut hours = total_hours as u32;
136    let remainder = (total_hours - f64::from(hours)) * 60.0;
137    let mut minutes = remainder as u32;
138    let mut seconds = (remainder - f64::from(minutes)) * 60.0;
139    // Carry propagation for floating-point accumulation edge cases
140    if seconds >= 60.0 {
141        seconds -= 60.0;
142        minutes += 1;
143    }
144    if minutes >= 60 {
145        minutes -= 60;
146        hours += 1;
147    }
148    Hms {
149        hours,
150        minutes,
151        seconds,
152    }
153}
154
155/// Convert HMS (hours-minutes-seconds) to decimal degrees.
156#[must_use]
157pub fn hms_to_deg(hms: &Hms) -> f64 {
158    (f64::from(hms.hours) + f64::from(hms.minutes) / 60.0 + hms.seconds / 3600.0) * 15.0
159}
160
161/// Compute the shortest angular separation between two angles in degrees.
162///
163/// Both inputs are treated as degree values. The result is in [0, 180].
164#[must_use]
165pub fn angular_separation(a: f64, b: f64) -> f64 {
166    let diff = normalize_degrees(a - b);
167    if diff > 180.0 { 360.0 - diff } else { diff }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    const EPS: f64 = 1e-12;
175
176    // --- normalize_degrees ---
177
178    #[test]
179    fn normalize_degrees_zero() {
180        assert!((normalize_degrees(0.0) - 0.0).abs() < EPS);
181    }
182
183    #[test]
184    fn normalize_degrees_positive() {
185        assert!((normalize_degrees(45.0) - 45.0).abs() < EPS);
186    }
187
188    #[test]
189    fn normalize_degrees_360_becomes_0() {
190        assert!((normalize_degrees(360.0) - 0.0).abs() < EPS);
191    }
192
193    #[test]
194    fn normalize_degrees_negative() {
195        assert!((normalize_degrees(-90.0) - 270.0).abs() < EPS);
196    }
197
198    #[test]
199    fn normalize_degrees_large_positive() {
200        assert!((normalize_degrees(720.0) - 0.0).abs() < EPS);
201    }
202
203    #[test]
204    fn normalize_degrees_large_negative() {
205        assert!((normalize_degrees(-450.0) - 270.0).abs() < EPS);
206    }
207
208    #[test]
209    fn normalize_degrees_near_360() {
210        assert!((normalize_degrees(359.9999) - 359.9999).abs() < EPS);
211    }
212
213    #[test]
214    fn normalize_degrees_nan() {
215        assert!((normalize_degrees(f64::NAN) - 0.0).abs() < EPS);
216    }
217
218    #[test]
219    fn normalize_degrees_inf() {
220        assert!((normalize_degrees(f64::INFINITY) - 0.0).abs() < EPS);
221    }
222
223    // --- normalize_degrees_signed ---
224
225    #[test]
226    fn normalize_degrees_signed_positive() {
227        assert!((normalize_degrees_signed(90.0) - 90.0).abs() < EPS);
228    }
229
230    #[test]
231    fn normalize_degrees_signed_negative() {
232        assert!((normalize_degrees_signed(-45.0) - (-45.0)).abs() < EPS);
233    }
234
235    #[test]
236    fn normalize_degrees_signed_270_becomes_neg90() {
237        assert!((normalize_degrees_signed(270.0) - (-90.0)).abs() < EPS);
238    }
239
240    #[test]
241    fn normalize_degrees_signed_180() {
242        // 180 >= 180, so becomes -180
243        assert!((normalize_degrees_signed(180.0) - (-180.0)).abs() < EPS);
244    }
245
246    // --- normalize_radians ---
247
248    #[test]
249    fn normalize_radians_zero() {
250        assert!((normalize_radians(0.0) - 0.0).abs() < EPS);
251    }
252
253    #[test]
254    fn normalize_radians_pi() {
255        assert!((normalize_radians(PI) - PI).abs() < EPS);
256    }
257
258    #[test]
259    fn normalize_radians_two_pi() {
260        assert!((normalize_radians(2.0 * PI) - 0.0).abs() < EPS);
261    }
262
263    #[test]
264    fn normalize_radians_negative() {
265        assert!((normalize_radians(-PI) - PI).abs() < EPS);
266    }
267
268    // --- deg_to_rad / rad_to_deg ---
269
270    #[test]
271    fn deg_rad_roundtrip() {
272        let angle = 123.456_f64;
273        assert!((rad_to_deg(deg_to_rad(angle)) - angle).abs() < EPS);
274    }
275
276    #[test]
277    fn deg_rad_90_is_half_pi() {
278        assert!((deg_to_rad(90.0) - PI / 2.0).abs() < EPS);
279    }
280
281    // --- DMS ---
282
283    #[test]
284    fn dms_positive() {
285        let dms = deg_to_dms(45.5);
286        assert_eq!(dms.sign, 1);
287        assert_eq!(dms.degrees, 45);
288        assert_eq!(dms.minutes, 30);
289        assert!((dms.seconds - 0.0).abs() < 0.01);
290    }
291
292    #[test]
293    fn dms_negative() {
294        let dms = deg_to_dms(-10.25);
295        assert_eq!(dms.sign, -1);
296        assert_eq!(dms.degrees, 10);
297        assert_eq!(dms.minutes, 15);
298        assert!((dms.seconds - 0.0).abs() < 0.01);
299    }
300
301    #[test]
302    fn dms_roundtrip() {
303        let original = 37.123_456_f64;
304        let dms = deg_to_dms(original);
305        let back = dms_to_deg(&dms);
306        assert!((back - original).abs() < 1e-10);
307    }
308
309    #[test]
310    fn dms_zero() {
311        let dms = deg_to_dms(0.0);
312        assert_eq!(dms.sign, 1);
313        assert_eq!(dms.degrees, 0);
314        assert_eq!(dms.minutes, 0);
315        assert!((dms.seconds - 0.0).abs() < 0.01);
316    }
317
318    // --- HMS ---
319
320    #[test]
321    fn hms_zero() {
322        let hms = deg_to_hms(0.0);
323        assert_eq!(hms.hours, 0);
324        assert_eq!(hms.minutes, 0);
325        assert!((hms.seconds - 0.0).abs() < 0.01);
326    }
327
328    #[test]
329    fn hms_90deg() {
330        let hms = deg_to_hms(90.0);
331        assert_eq!(hms.hours, 6);
332        assert_eq!(hms.minutes, 0);
333        assert!((hms.seconds - 0.0).abs() < 0.01);
334    }
335
336    #[test]
337    fn hms_roundtrip() {
338        let original = 135.0_f64;
339        let hms = deg_to_hms(original);
340        let back = hms_to_deg(&hms);
341        assert!((back - original).abs() < 1e-10);
342    }
343
344    // --- angular_separation ---
345
346    #[test]
347    fn separation_same() {
348        assert!((angular_separation(45.0, 45.0) - 0.0).abs() < EPS);
349    }
350
351    #[test]
352    fn separation_opposite() {
353        assert!((angular_separation(0.0, 180.0) - 180.0).abs() < EPS);
354    }
355
356    #[test]
357    fn separation_wrap_around() {
358        // 10 and 350 are 20 degrees apart going the short way
359        assert!((angular_separation(10.0, 350.0) - 20.0).abs() < EPS);
360    }
361
362    #[test]
363    fn separation_negative_input() {
364        // -10 normalizes to 350; separation from 10 is 20
365        assert!((angular_separation(-10.0, 10.0) - 20.0).abs() < EPS);
366    }
367}