Skip to main content

celestial_core/matrix/
vector3.rs

1//! 3D Cartesian vectors for astronomical coordinate calculations.
2//!
3//! Vectors are the workhorses of celestial coordinate math. When you transform a star's
4//! position between reference frames, compute the angle between two objects, or calculate
5//! parallax corrections, you're working with 3D vectors under the hood.
6//!
7//! # Cartesian vs Spherical
8//!
9//! Astronomical positions are usually given as spherical coordinates (RA/Dec, Az/Alt,
10//! longitude/latitude), but transformations are cleanest in Cartesian form. The typical
11//! workflow is:
12//!
13//! 1. Convert spherical → Cartesian with [`from_spherical`](Vector3::from_spherical)
14//! 2. Apply rotation matrices for frame transformations
15//! 3. Convert back with [`to_spherical`](Vector3::to_spherical)
16//!
17//! ```
18//! use celestial_core::Vector3;
19//! use std::f64::consts::FRAC_PI_4;
20//!
21//! // A star at RA=45°, Dec=30° (in radians)
22//! let ra = FRAC_PI_4;
23//! let dec = FRAC_PI_4 / 1.5;  // ~30°
24//!
25//! let cartesian = Vector3::from_spherical(ra, dec);
26//! // Now apply rotations, then convert back:
27//! let (new_ra, new_dec) = cartesian.to_spherical();
28//! ```
29//!
30//! # Unit Vectors and Direction
31//!
32//! For celestial positions on the unit sphere (where distance doesn't matter), vectors
33//! are normalized to unit length. The [`normalize`](Vector3::normalize) method returns
34//! a unit vector pointing in the same direction:
35//!
36//! ```
37//! use celestial_core::Vector3;
38//!
39//! let v = Vector3::new(3.0, 4.0, 0.0);
40//! let unit = v.normalize();
41//! assert!((unit.magnitude() - 1.0).abs() < 1e-15);
42//! ```
43//!
44//! # Dot and Cross Products
45//!
46//! These operations have direct astronomical applications:
47//!
48//! - **Dot product**: Compute the cosine of the angle between two directions.
49//!   For unit vectors, `a.dot(&b)` equals `cos(θ)` where θ is the separation angle.
50//!
51//! - **Cross product**: Find the axis perpendicular to two directions.
52//!   Useful for computing rotation axes and angular momentum vectors.
53//!
54//! ```
55//! use celestial_core::Vector3;
56//!
57//! let a = Vector3::x_axis();  // Points along +X
58//! let b = Vector3::y_axis();  // Points along +Y
59//!
60//! // Perpendicular: dot product is zero
61//! assert_eq!(a.dot(&b), 0.0);
62//!
63//! // Cross product gives +Z axis (right-hand rule)
64//! let c = a.cross(&b);
65//! assert_eq!(c, Vector3::z_axis());
66//! ```
67//!
68//! # Coordinate Conventions
69//!
70//! The spherical coordinate convention used here matches standard astronomical practice:
71//! - **θ (theta)**: Azimuthal angle from +X axis toward +Y axis (like right ascension)
72//! - **φ (phi)**: Elevation angle from XY plane (like declination)
73//!
74//! This differs from the physics convention where φ is the azimuthal angle and θ is
75//! the polar angle from +Z.
76use crate::{AstroError, AstroResult, MathErrorKind};
77use std::fmt;
78
79/// A 3D Cartesian vector for coordinate calculations.
80///
81/// Used throughout the library for position vectors, direction vectors, and as
82/// intermediate representations during coordinate transformations.
83///
84/// # Fields
85///
86/// Components are public for direct access when performance matters:
87/// - `x`: First component (toward vernal equinox in equatorial coordinates)
88/// - `y`: Second component (90° east in equatorial coordinates)
89/// - `z`: Third component (toward celestial pole in equatorial coordinates)
90///
91/// # Construction
92///
93/// ```
94/// use celestial_core::Vector3;
95///
96/// // Direct construction
97/// let v = Vector3::new(1.0, 2.0, 3.0);
98///
99/// // Unit vectors along axes
100/// let x = Vector3::x_axis();
101/// let y = Vector3::y_axis();
102/// let z = Vector3::z_axis();
103///
104/// // From spherical coordinates (RA, Dec in radians)
105/// let star = Vector3::from_spherical(0.5, 0.3);
106///
107/// // From an array
108/// let v = Vector3::from_array([1.0, 2.0, 3.0]);
109/// ```
110#[derive(Debug, Clone, Copy, PartialEq)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
112pub struct Vector3 {
113    pub x: f64,
114    pub y: f64,
115    pub z: f64,
116}
117
118impl Vector3 {
119    /// Creates a new vector from x, y, z components.
120    #[inline]
121    pub fn new(x: f64, y: f64, z: f64) -> Self {
122        Self { x, y, z }
123    }
124
125    /// Returns the zero vector `[0, 0, 0]`.
126    #[inline]
127    pub fn zeros() -> Self {
128        Self::new(0.0, 0.0, 0.0)
129    }
130
131    /// Returns the unit vector along the X axis `[1, 0, 0]`.
132    ///
133    /// In equatorial coordinates, this points toward the vernal equinox.
134    #[inline]
135    pub fn x_axis() -> Self {
136        Self::new(1.0, 0.0, 0.0)
137    }
138
139    /// Returns the unit vector along the Y axis `[0, 1, 0]`.
140    ///
141    /// In equatorial coordinates, this is 90° east of the vernal equinox on the equator.
142    #[inline]
143    pub fn y_axis() -> Self {
144        Self::new(0.0, 1.0, 0.0)
145    }
146
147    /// Returns the unit vector along the Z axis `[0, 0, 1]`.
148    ///
149    /// In equatorial coordinates, this points toward the north celestial pole.
150    #[inline]
151    pub fn z_axis() -> Self {
152        Self::new(0.0, 0.0, 1.0)
153    }
154
155    /// Returns the component at the given index (0=x, 1=y, 2=z).
156    ///
157    /// Returns an error for indices outside 0-2. For unchecked access, use
158    /// indexing syntax `v[i]` or the public fields directly.
159    pub fn get(&self, index: usize) -> AstroResult<f64> {
160        match index {
161            0 => Ok(self.x),
162            1 => Ok(self.y),
163            2 => Ok(self.z),
164            _ => Err(AstroError::math_error(
165                "Vector3::get",
166                MathErrorKind::InvalidInput,
167                &format!("index {} out of bounds (valid range: 0-2)", index),
168            )),
169        }
170    }
171
172    /// Sets the component at the given index (0=x, 1=y, 2=z).
173    ///
174    /// Returns an error for indices outside 0-2. For unchecked access, use
175    /// indexing syntax `v[i] = value` or the public fields directly.
176    pub fn set(&mut self, index: usize, value: f64) -> AstroResult<()> {
177        match index {
178            0 => {
179                self.x = value;
180                Ok(())
181            }
182            1 => {
183                self.y = value;
184                Ok(())
185            }
186            2 => {
187                self.z = value;
188                Ok(())
189            }
190            _ => Err(AstroError::math_error(
191                "Vector3::set",
192                MathErrorKind::InvalidInput,
193                &format!("index {} out of bounds (valid range: 0-2)", index),
194            )),
195        }
196    }
197
198    /// Returns the Euclidean length (L2 norm) of the vector.
199    ///
200    /// For a unit vector, this returns 1.0. For the zero vector, returns 0.0.
201    #[inline]
202    pub fn magnitude(&self) -> f64 {
203        libm::sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
204    }
205
206    /// Returns the squared magnitude.
207    ///
208    /// Faster than [`magnitude`](Self::magnitude) when you only need to compare
209    /// lengths or don't need the actual distance.
210    #[inline]
211    pub fn magnitude_squared(&self) -> f64 {
212        self.x * self.x + self.y * self.y + self.z * self.z
213    }
214
215    /// Returns a unit vector pointing in the same direction.
216    ///
217    /// If the vector has zero length, returns the zero vector unchanged (avoids NaN).
218    ///
219    /// ```
220    /// use celestial_core::Vector3;
221    ///
222    /// let v = Vector3::new(3.0, 4.0, 0.0);
223    /// let unit = v.normalize();
224    /// assert!((unit.magnitude() - 1.0).abs() < 1e-15);
225    /// assert_eq!(unit, Vector3::new(0.6, 0.8, 0.0));
226    /// ```
227    pub fn normalize(&self) -> Self {
228        let mag = self.magnitude();
229        if mag == 0.0 {
230            *self
231        } else {
232            Self::new(self.x / mag, self.y / mag, self.z / mag)
233        }
234    }
235
236    /// Computes the dot product (inner product) with another vector.
237    ///
238    /// For unit vectors, this equals the cosine of the angle between them:
239    /// `a.dot(&b) = cos(θ)`. Use this to compute angular separation between
240    /// celestial positions.
241    ///
242    /// ```
243    /// use celestial_core::Vector3;
244    ///
245    /// let a = Vector3::x_axis();
246    /// let b = Vector3::y_axis();
247    /// assert_eq!(a.dot(&b), 0.0);  // Perpendicular
248    ///
249    /// let c = Vector3::new(1.0, 2.0, 3.0);
250    /// let d = Vector3::new(4.0, 5.0, 6.0);
251    /// assert_eq!(c.dot(&d), 32.0);  // 1*4 + 2*5 + 3*6
252    /// ```
253    #[inline]
254    pub fn dot(&self, other: &Self) -> f64 {
255        self.x * other.x + self.y * other.y + self.z * other.z
256    }
257
258    /// Computes the cross product with another vector.
259    ///
260    /// The result is perpendicular to both input vectors, with direction given
261    /// by the right-hand rule. The magnitude equals `|a||b|sin(θ)`.
262    ///
263    /// ```
264    /// use celestial_core::Vector3;
265    ///
266    /// let x = Vector3::x_axis();
267    /// let y = Vector3::y_axis();
268    /// let z = x.cross(&y);
269    /// assert_eq!(z, Vector3::z_axis());  // X × Y = Z
270    /// ```
271    pub fn cross(&self, other: &Self) -> Self {
272        Self::new(
273            self.y * other.z - self.z * other.y,
274            self.z * other.x - self.x * other.z,
275            self.x * other.y - self.y * other.x,
276        )
277    }
278
279    /// Returns the components as a `[f64; 3]` array.
280    #[inline]
281    pub fn to_array(&self) -> [f64; 3] {
282        [self.x, self.y, self.z]
283    }
284
285    /// Creates a vector from a `[f64; 3]` array.
286    #[inline]
287    pub fn from_array(arr: [f64; 3]) -> Self {
288        Self::new(arr[0], arr[1], arr[2])
289    }
290
291    /// Creates a unit vector from spherical coordinates.
292    ///
293    /// - `ra`: Azimuthal angle from +X toward +Y (right ascension), in radians
294    /// - `dec`: Elevation from XY plane (declination), in radians
295    ///
296    /// The result is always a unit vector (magnitude = 1).
297    ///
298    /// ```
299    /// use celestial_core::Vector3;
300    /// use std::f64::consts::FRAC_PI_2;
301    ///
302    /// // RA=0, Dec=0 → points along +X
303    /// let v = Vector3::from_spherical(0.0, 0.0);
304    /// assert!((v.x - 1.0).abs() < 1e-15);
305    ///
306    /// // RA=90°, Dec=0 → points along +Y
307    /// let v = Vector3::from_spherical(FRAC_PI_2, 0.0);
308    /// assert!((v.y - 1.0).abs() < 1e-15);
309    ///
310    /// // RA=0, Dec=90° → points along +Z (north pole)
311    /// let v = Vector3::from_spherical(0.0, FRAC_PI_2);
312    /// assert!((v.z - 1.0).abs() < 1e-15);
313    /// ```
314    pub fn from_spherical(ra: f64, dec: f64) -> Self {
315        let (sin_ra, cos_ra) = libm::sincos(ra);
316        let (sin_dec, cos_dec) = libm::sincos(dec);
317        Self::new(cos_dec * cos_ra, cos_dec * sin_ra, sin_dec)
318    }
319
320    /// Converts the vector to spherical coordinates (θ, φ).
321    ///
322    /// Returns `(theta, phi)` where:
323    /// - `theta`: Azimuthal angle from +X toward +Y (like RA), in radians `(-π, π]`
324    /// - `phi`: Elevation from XY plane (like Dec), in radians `[-π/2, π/2]`
325    ///
326    /// The vector does not need to be normalized; direction is preserved regardless
327    /// of magnitude. For the zero vector, returns `(0.0, 0.0)`.
328    ///
329    /// ```
330    /// use celestial_core::Vector3;
331    /// use std::f64::consts::FRAC_PI_2;
332    ///
333    /// let v = Vector3::new(0.0, 0.0, 1.0);  // North pole
334    /// let (theta, phi) = v.to_spherical();
335    /// assert_eq!(theta, 0.0);
336    /// assert_eq!(phi, FRAC_PI_2);
337    /// ```
338    pub fn to_spherical(&self) -> (f64, f64) {
339        let d2 = self.x * self.x + self.y * self.y;
340
341        let theta = if d2 == 0.0 {
342            0.0
343        } else {
344            libm::atan2(self.y, self.x)
345        };
346        let phi = if self.z == 0.0 {
347            0.0
348        } else {
349            libm::atan2(self.z, libm::sqrt(d2))
350        };
351
352        (theta, phi)
353    }
354}
355
356/// Vector + Vector
357impl std::ops::Add for Vector3 {
358    type Output = Self;
359
360    fn add(self, rhs: Self) -> Self {
361        Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
362    }
363}
364
365/// Vector - Vector
366impl std::ops::Sub for Vector3 {
367    type Output = Self;
368
369    fn sub(self, rhs: Self) -> Self {
370        Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
371    }
372}
373
374/// Vector * scalar
375impl std::ops::Mul<f64> for Vector3 {
376    type Output = Self;
377
378    fn mul(self, scalar: f64) -> Self {
379        Self::new(self.x * scalar, self.y * scalar, self.z * scalar)
380    }
381}
382
383/// scalar * Vector
384impl std::ops::Mul<Vector3> for f64 {
385    type Output = Vector3;
386
387    fn mul(self, vec: Vector3) -> Vector3 {
388        vec * self
389    }
390}
391
392/// Vector / scalar
393impl std::ops::Div<f64> for Vector3 {
394    type Output = Self;
395
396    fn div(self, scalar: f64) -> Self {
397        Self::new(self.x / scalar, self.y / scalar, self.z / scalar)
398    }
399}
400
401/// Vector /= scalar
402impl std::ops::DivAssign<f64> for Vector3 {
403    fn div_assign(&mut self, scalar: f64) {
404        self.x /= scalar;
405        self.y /= scalar;
406        self.z /= scalar;
407    }
408}
409
410/// -Vector
411impl std::ops::Neg for Vector3 {
412    type Output = Self;
413
414    fn neg(self) -> Self {
415        Self::new(-self.x, -self.y, -self.z)
416    }
417}
418
419/// v[i] indexing (panics if i > 2)
420impl std::ops::Index<usize> for Vector3 {
421    type Output = f64;
422
423    fn index(&self, index: usize) -> &f64 {
424        match index {
425            0 => &self.x,
426            1 => &self.y,
427            2 => &self.z,
428            _ => panic!("Vector3 index out of bounds: {}", index),
429        }
430    }
431}
432
433/// v[i] = value mutable indexing (panics if i > 2)
434impl std::ops::IndexMut<usize> for Vector3 {
435    fn index_mut(&mut self, index: usize) -> &mut f64 {
436        match index {
437            0 => &mut self.x,
438            1 => &mut self.y,
439            2 => &mut self.z,
440            _ => panic!("Vector3 index out of bounds: {}", index),
441        }
442    }
443}
444
445impl fmt::Display for Vector3 {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        write!(f, "Vector3({:.9}, {:.9}, {:.9})", self.x, self.y, self.z)
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_vector3_construction() {
457        let v = Vector3::new(1.0, 2.0, 3.0);
458        assert_eq!(v.x, 1.0);
459        assert_eq!(v.y, 2.0);
460        assert_eq!(v.z, 3.0);
461
462        let zeros = Vector3::zeros();
463        assert_eq!(zeros.x, 0.0);
464        assert_eq!(zeros.y, 0.0);
465        assert_eq!(zeros.z, 0.0);
466
467        let x_axis = Vector3::x_axis();
468        assert_eq!(x_axis, Vector3::new(1.0, 0.0, 0.0));
469
470        let from_array = Vector3::from_array([4.0, 5.0, 6.0]);
471        assert_eq!(from_array, Vector3::new(4.0, 5.0, 6.0));
472    }
473
474    #[test]
475    fn test_vector3_magnitude() {
476        let v = Vector3::new(3.0, 4.0, 0.0);
477        assert_eq!(v.magnitude(), 5.0);
478        assert_eq!(v.magnitude_squared(), 25.0);
479
480        let unit = v.normalize();
481        assert!((unit.magnitude() - 1.0).abs() < 1e-15);
482        assert_eq!(unit, Vector3::new(0.6, 0.8, 0.0));
483    }
484
485    #[test]
486    fn test_vector3_arithmetic() {
487        let a = Vector3::new(1.0, 2.0, 3.0);
488        let b = Vector3::new(4.0, 5.0, 6.0);
489
490        let sum = a + b;
491        assert_eq!(sum, Vector3::new(5.0, 7.0, 9.0));
492
493        let diff = b - a;
494        assert_eq!(diff, Vector3::new(3.0, 3.0, 3.0));
495
496        let scaled = a * 2.0;
497        assert_eq!(scaled, Vector3::new(2.0, 4.0, 6.0));
498
499        let scaled2 = 3.0 * a;
500        assert_eq!(scaled2, Vector3::new(3.0, 6.0, 9.0));
501
502        let divided = a / 2.0;
503        assert_eq!(divided, Vector3::new(0.5, 1.0, 1.5));
504
505        let negated = -a;
506        assert_eq!(negated, Vector3::new(-1.0, -2.0, -3.0));
507    }
508
509    #[test]
510    fn test_vector3_dot_cross() {
511        let a = Vector3::new(1.0, 0.0, 0.0);
512        let b = Vector3::new(0.0, 1.0, 0.0);
513
514        assert_eq!(a.dot(&b), 0.0);
515
516        let c = a.cross(&b);
517        assert_eq!(c, Vector3::new(0.0, 0.0, 1.0));
518
519        let d = Vector3::new(1.0, 2.0, 3.0);
520        let e = Vector3::new(4.0, 5.0, 6.0);
521        assert_eq!(d.dot(&e), 32.0);
522    }
523
524    #[test]
525    fn test_vector3_spherical_conversion() {
526        let v1 = Vector3::from_spherical(0.0, 0.0);
527        assert!((v1.x - 1.0).abs() < 1e-15);
528        assert!(v1.y.abs() < 1e-15);
529        assert!(v1.z.abs() < 1e-15);
530
531        let (ra, dec) = v1.to_spherical();
532        assert!(ra.abs() < 1e-15);
533        assert!(dec.abs() < 1e-15);
534
535        let v2 = Vector3::from_spherical(crate::constants::HALF_PI, 0.0);
536        assert!(v2.x.abs() < 1e-15);
537        assert!((v2.y - 1.0).abs() < 1e-15);
538        assert!(v2.z.abs() < 1e-15);
539
540        let v3 = Vector3::from_spherical(0.0, crate::constants::HALF_PI);
541        assert!(v3.x.abs() < 1e-15);
542        assert!(v3.y.abs() < 1e-15);
543        assert!((v3.z - 1.0).abs() < 1e-15);
544    }
545
546    #[test]
547    fn test_axis_constructors() {
548        // Test y_axis and z_axis constructors
549        let y_axis = Vector3::y_axis();
550        assert_eq!(y_axis, Vector3::new(0.0, 1.0, 0.0));
551
552        let z_axis = Vector3::z_axis();
553        assert_eq!(z_axis, Vector3::new(0.0, 0.0, 1.0));
554    }
555
556    #[test]
557    fn test_get_set_methods() {
558        let mut v = Vector3::new(1.0, 2.0, 3.0);
559
560        // Test get method
561        assert_eq!(v.get(0).unwrap(), 1.0);
562        assert_eq!(v.get(1).unwrap(), 2.0);
563        assert_eq!(v.get(2).unwrap(), 3.0);
564
565        // Test set method
566        v.set(0, 10.0).unwrap();
567        v.set(1, 20.0).unwrap();
568        v.set(2, 30.0).unwrap();
569        assert_eq!(v, Vector3::new(10.0, 20.0, 30.0));
570    }
571
572    #[test]
573    fn test_get_error() {
574        let v = Vector3::new(1.0, 2.0, 3.0);
575        let result = v.get(3);
576        assert!(result.is_err());
577
578        if let Err(err) = result {
579            assert!(err.to_string().contains("index 3 out of bounds"));
580        }
581    }
582
583    #[test]
584    fn test_set_error() {
585        let mut v = Vector3::new(1.0, 2.0, 3.0);
586        let result = v.set(5, 42.0);
587        assert!(result.is_err());
588
589        if let Err(err) = result {
590            assert!(err.to_string().contains("index 5 out of bounds"));
591        }
592    }
593
594    #[test]
595    fn test_normalize_zero_vector() {
596        let zero = Vector3::zeros();
597        let normalized = zero.normalize();
598        assert_eq!(normalized, zero); // Zero vector normalizes to itself
599    }
600
601    #[test]
602    fn test_to_array() {
603        let v = Vector3::new(1.5, 2.5, 3.5);
604        let arr = v.to_array();
605        assert_eq!(arr, [1.5, 2.5, 3.5]);
606    }
607
608    #[test]
609    fn test_div_assign_operator() {
610        let mut v = Vector3::new(10.0, 20.0, 30.0);
611        v /= 2.0;
612        assert_eq!(v, Vector3::new(5.0, 10.0, 15.0));
613    }
614
615    #[test]
616    fn test_indexing_operators() {
617        let mut v = Vector3::new(1.0, 2.0, 3.0);
618
619        // Test read indexing
620        assert_eq!(v[0], 1.0);
621        assert_eq!(v[1], 2.0);
622        assert_eq!(v[2], 3.0);
623
624        // Test write indexing
625        v[0] = 10.0;
626        v[1] = 20.0;
627        v[2] = 30.0;
628        assert_eq!(v, Vector3::new(10.0, 20.0, 30.0));
629    }
630
631    #[test]
632    #[should_panic(expected = "Vector3 index out of bounds: 4")]
633    fn test_index_panic() {
634        let v = Vector3::new(1.0, 2.0, 3.0);
635        let _ = v[4];
636    }
637
638    #[test]
639    #[should_panic(expected = "Vector3 index out of bounds: 7")]
640    fn test_index_mut_panic() {
641        let mut v = Vector3::new(1.0, 2.0, 3.0);
642        v[7] = 42.0;
643    }
644
645    #[test]
646    fn test_display_formatting() {
647        let v = Vector3::new(1.234567890, -2.345678901, 3.456789012);
648        let display_output = format!("{}", v);
649
650        // Check that it contains expected parts
651        assert!(display_output.contains("Vector3("));
652        assert!(display_output.contains("1.234567890"));
653        assert!(display_output.contains("-2.345678901"));
654        assert!(display_output.contains("3.456789012"));
655        assert!(display_output.ends_with(")"));
656    }
657
658    #[test]
659    fn test_to_spherical_north_pole() {
660        let north_pole = Vector3::new(0.0, 0.0, 1.0);
661        let (theta, phi) = north_pole.to_spherical();
662
663        assert_eq!(theta, 0.0);
664        assert_eq!(phi, crate::constants::HALF_PI);
665    }
666
667    #[test]
668    fn test_to_spherical_south_pole() {
669        let south_pole = Vector3::new(0.0, 0.0, -1.0);
670        let (theta, phi) = south_pole.to_spherical();
671
672        assert_eq!(theta, 0.0);
673        assert_eq!(phi, -crate::constants::HALF_PI);
674    }
675
676    #[test]
677    fn test_to_spherical_zero_z() {
678        let on_equator = Vector3::new(1.0, 0.0, 0.0);
679        let (theta, phi) = on_equator.to_spherical();
680
681        assert_eq!(theta, 0.0);
682        assert_eq!(phi, 0.0);
683    }
684
685    #[test]
686    fn test_to_spherical_zero_vector() {
687        let zero = Vector3::zeros();
688        let (theta, phi) = zero.to_spherical();
689
690        assert_eq!(theta, 0.0);
691        assert_eq!(phi, 0.0);
692    }
693
694    #[test]
695    fn test_spherical_roundtrip_at_poles() {
696        let north_pole = Vector3::new(0.0, 0.0, 1.0);
697        let (theta, phi) = north_pole.to_spherical();
698        let roundtrip = Vector3::from_spherical(theta, phi);
699
700        assert_eq!(roundtrip.z, north_pole.z);
701        assert!(roundtrip.x.abs() < 1e-15, "x component: {}", roundtrip.x);
702        assert!(roundtrip.y.abs() < 1e-15, "y component: {}", roundtrip.y);
703
704        let south_pole = Vector3::new(0.0, 0.0, -1.0);
705        let (theta, phi) = south_pole.to_spherical();
706        let roundtrip = Vector3::from_spherical(theta, phi);
707
708        assert_eq!(roundtrip.z, south_pole.z);
709        assert!(roundtrip.x.abs() < 1e-15, "x component: {}", roundtrip.x);
710        assert!(roundtrip.y.abs() < 1e-15, "y component: {}", roundtrip.y);
711    }
712}