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        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
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) = ra.sin_cos();
316        let (sin_dec, cos_dec) = dec.sin_cos();
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 { 0.0 } else { self.y.atan2(self.x) };
342        let phi = if self.z == 0.0 {
343            0.0
344        } else {
345            self.z.atan2(d2.sqrt())
346        };
347
348        (theta, phi)
349    }
350}
351
352/// Vector + Vector
353impl std::ops::Add for Vector3 {
354    type Output = Self;
355
356    fn add(self, rhs: Self) -> Self {
357        Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
358    }
359}
360
361/// Vector - Vector
362impl std::ops::Sub for Vector3 {
363    type Output = Self;
364
365    fn sub(self, rhs: Self) -> Self {
366        Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
367    }
368}
369
370/// Vector * scalar
371impl std::ops::Mul<f64> for Vector3 {
372    type Output = Self;
373
374    fn mul(self, scalar: f64) -> Self {
375        Self::new(self.x * scalar, self.y * scalar, self.z * scalar)
376    }
377}
378
379/// scalar * Vector
380impl std::ops::Mul<Vector3> for f64 {
381    type Output = Vector3;
382
383    fn mul(self, vec: Vector3) -> Vector3 {
384        vec * self
385    }
386}
387
388/// Vector / scalar
389impl std::ops::Div<f64> for Vector3 {
390    type Output = Self;
391
392    fn div(self, scalar: f64) -> Self {
393        Self::new(self.x / scalar, self.y / scalar, self.z / scalar)
394    }
395}
396
397/// Vector /= scalar
398impl std::ops::DivAssign<f64> for Vector3 {
399    fn div_assign(&mut self, scalar: f64) {
400        self.x /= scalar;
401        self.y /= scalar;
402        self.z /= scalar;
403    }
404}
405
406/// -Vector
407impl std::ops::Neg for Vector3 {
408    type Output = Self;
409
410    fn neg(self) -> Self {
411        Self::new(-self.x, -self.y, -self.z)
412    }
413}
414
415/// v[i] indexing (panics if i > 2)
416impl std::ops::Index<usize> for Vector3 {
417    type Output = f64;
418
419    fn index(&self, index: usize) -> &f64 {
420        match index {
421            0 => &self.x,
422            1 => &self.y,
423            2 => &self.z,
424            _ => panic!("Vector3 index out of bounds: {}", index),
425        }
426    }
427}
428
429/// v[i] = value mutable indexing (panics if i > 2)
430impl std::ops::IndexMut<usize> for Vector3 {
431    fn index_mut(&mut self, index: usize) -> &mut f64 {
432        match index {
433            0 => &mut self.x,
434            1 => &mut self.y,
435            2 => &mut self.z,
436            _ => panic!("Vector3 index out of bounds: {}", index),
437        }
438    }
439}
440
441impl fmt::Display for Vector3 {
442    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443        write!(f, "Vector3({:.9}, {:.9}, {:.9})", self.x, self.y, self.z)
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_vector3_construction() {
453        let v = Vector3::new(1.0, 2.0, 3.0);
454        assert_eq!(v.x, 1.0);
455        assert_eq!(v.y, 2.0);
456        assert_eq!(v.z, 3.0);
457
458        let zeros = Vector3::zeros();
459        assert_eq!(zeros.x, 0.0);
460        assert_eq!(zeros.y, 0.0);
461        assert_eq!(zeros.z, 0.0);
462
463        let x_axis = Vector3::x_axis();
464        assert_eq!(x_axis, Vector3::new(1.0, 0.0, 0.0));
465
466        let from_array = Vector3::from_array([4.0, 5.0, 6.0]);
467        assert_eq!(from_array, Vector3::new(4.0, 5.0, 6.0));
468    }
469
470    #[test]
471    fn test_vector3_magnitude() {
472        let v = Vector3::new(3.0, 4.0, 0.0);
473        assert_eq!(v.magnitude(), 5.0);
474        assert_eq!(v.magnitude_squared(), 25.0);
475
476        let unit = v.normalize();
477        assert!((unit.magnitude() - 1.0).abs() < 1e-15);
478        assert_eq!(unit, Vector3::new(0.6, 0.8, 0.0));
479    }
480
481    #[test]
482    fn test_vector3_arithmetic() {
483        let a = Vector3::new(1.0, 2.0, 3.0);
484        let b = Vector3::new(4.0, 5.0, 6.0);
485
486        let sum = a + b;
487        assert_eq!(sum, Vector3::new(5.0, 7.0, 9.0));
488
489        let diff = b - a;
490        assert_eq!(diff, Vector3::new(3.0, 3.0, 3.0));
491
492        let scaled = a * 2.0;
493        assert_eq!(scaled, Vector3::new(2.0, 4.0, 6.0));
494
495        let scaled2 = 3.0 * a;
496        assert_eq!(scaled2, Vector3::new(3.0, 6.0, 9.0));
497
498        let divided = a / 2.0;
499        assert_eq!(divided, Vector3::new(0.5, 1.0, 1.5));
500
501        let negated = -a;
502        assert_eq!(negated, Vector3::new(-1.0, -2.0, -3.0));
503    }
504
505    #[test]
506    fn test_vector3_dot_cross() {
507        let a = Vector3::new(1.0, 0.0, 0.0);
508        let b = Vector3::new(0.0, 1.0, 0.0);
509
510        assert_eq!(a.dot(&b), 0.0);
511
512        let c = a.cross(&b);
513        assert_eq!(c, Vector3::new(0.0, 0.0, 1.0));
514
515        let d = Vector3::new(1.0, 2.0, 3.0);
516        let e = Vector3::new(4.0, 5.0, 6.0);
517        assert_eq!(d.dot(&e), 32.0);
518    }
519
520    #[test]
521    fn test_vector3_spherical_conversion() {
522        let v1 = Vector3::from_spherical(0.0, 0.0);
523        assert!((v1.x - 1.0).abs() < 1e-15);
524        assert!(v1.y.abs() < 1e-15);
525        assert!(v1.z.abs() < 1e-15);
526
527        let (ra, dec) = v1.to_spherical();
528        assert!(ra.abs() < 1e-15);
529        assert!(dec.abs() < 1e-15);
530
531        let v2 = Vector3::from_spherical(crate::constants::HALF_PI, 0.0);
532        assert!(v2.x.abs() < 1e-15);
533        assert!((v2.y - 1.0).abs() < 1e-15);
534        assert!(v2.z.abs() < 1e-15);
535
536        let v3 = Vector3::from_spherical(0.0, crate::constants::HALF_PI);
537        assert!(v3.x.abs() < 1e-15);
538        assert!(v3.y.abs() < 1e-15);
539        assert!((v3.z - 1.0).abs() < 1e-15);
540    }
541
542    #[test]
543    fn test_axis_constructors() {
544        // Test y_axis and z_axis constructors
545        let y_axis = Vector3::y_axis();
546        assert_eq!(y_axis, Vector3::new(0.0, 1.0, 0.0));
547
548        let z_axis = Vector3::z_axis();
549        assert_eq!(z_axis, Vector3::new(0.0, 0.0, 1.0));
550    }
551
552    #[test]
553    fn test_get_set_methods() {
554        let mut v = Vector3::new(1.0, 2.0, 3.0);
555
556        // Test get method
557        assert_eq!(v.get(0).unwrap(), 1.0);
558        assert_eq!(v.get(1).unwrap(), 2.0);
559        assert_eq!(v.get(2).unwrap(), 3.0);
560
561        // Test set method
562        v.set(0, 10.0).unwrap();
563        v.set(1, 20.0).unwrap();
564        v.set(2, 30.0).unwrap();
565        assert_eq!(v, Vector3::new(10.0, 20.0, 30.0));
566    }
567
568    #[test]
569    fn test_get_error() {
570        let v = Vector3::new(1.0, 2.0, 3.0);
571        let result = v.get(3);
572        assert!(result.is_err());
573
574        if let Err(err) = result {
575            assert!(err.to_string().contains("index 3 out of bounds"));
576        }
577    }
578
579    #[test]
580    fn test_set_error() {
581        let mut v = Vector3::new(1.0, 2.0, 3.0);
582        let result = v.set(5, 42.0);
583        assert!(result.is_err());
584
585        if let Err(err) = result {
586            assert!(err.to_string().contains("index 5 out of bounds"));
587        }
588    }
589
590    #[test]
591    fn test_normalize_zero_vector() {
592        let zero = Vector3::zeros();
593        let normalized = zero.normalize();
594        assert_eq!(normalized, zero); // Zero vector normalizes to itself
595    }
596
597    #[test]
598    fn test_to_array() {
599        let v = Vector3::new(1.5, 2.5, 3.5);
600        let arr = v.to_array();
601        assert_eq!(arr, [1.5, 2.5, 3.5]);
602    }
603
604    #[test]
605    fn test_div_assign_operator() {
606        let mut v = Vector3::new(10.0, 20.0, 30.0);
607        v /= 2.0;
608        assert_eq!(v, Vector3::new(5.0, 10.0, 15.0));
609    }
610
611    #[test]
612    fn test_indexing_operators() {
613        let mut v = Vector3::new(1.0, 2.0, 3.0);
614
615        // Test read indexing
616        assert_eq!(v[0], 1.0);
617        assert_eq!(v[1], 2.0);
618        assert_eq!(v[2], 3.0);
619
620        // Test write indexing
621        v[0] = 10.0;
622        v[1] = 20.0;
623        v[2] = 30.0;
624        assert_eq!(v, Vector3::new(10.0, 20.0, 30.0));
625    }
626
627    #[test]
628    #[should_panic(expected = "Vector3 index out of bounds: 4")]
629    fn test_index_panic() {
630        let v = Vector3::new(1.0, 2.0, 3.0);
631        let _ = v[4];
632    }
633
634    #[test]
635    #[should_panic(expected = "Vector3 index out of bounds: 7")]
636    fn test_index_mut_panic() {
637        let mut v = Vector3::new(1.0, 2.0, 3.0);
638        v[7] = 42.0;
639    }
640
641    #[test]
642    fn test_display_formatting() {
643        let v = Vector3::new(1.234567890, -2.345678901, 3.456789012);
644        let display_output = format!("{}", v);
645
646        // Check that it contains expected parts
647        assert!(display_output.contains("Vector3("));
648        assert!(display_output.contains("1.234567890"));
649        assert!(display_output.contains("-2.345678901"));
650        assert!(display_output.contains("3.456789012"));
651        assert!(display_output.ends_with(")"));
652    }
653
654    #[test]
655    fn test_to_spherical_north_pole() {
656        let north_pole = Vector3::new(0.0, 0.0, 1.0);
657        let (theta, phi) = north_pole.to_spherical();
658
659        assert_eq!(theta, 0.0);
660        assert_eq!(phi, crate::constants::HALF_PI);
661    }
662
663    #[test]
664    fn test_to_spherical_south_pole() {
665        let south_pole = Vector3::new(0.0, 0.0, -1.0);
666        let (theta, phi) = south_pole.to_spherical();
667
668        assert_eq!(theta, 0.0);
669        assert_eq!(phi, -crate::constants::HALF_PI);
670    }
671
672    #[test]
673    fn test_to_spherical_zero_z() {
674        let on_equator = Vector3::new(1.0, 0.0, 0.0);
675        let (theta, phi) = on_equator.to_spherical();
676
677        assert_eq!(theta, 0.0);
678        assert_eq!(phi, 0.0);
679    }
680
681    #[test]
682    fn test_to_spherical_zero_vector() {
683        let zero = Vector3::zeros();
684        let (theta, phi) = zero.to_spherical();
685
686        assert_eq!(theta, 0.0);
687        assert_eq!(phi, 0.0);
688    }
689
690    #[test]
691    fn test_spherical_roundtrip_at_poles() {
692        let north_pole = Vector3::new(0.0, 0.0, 1.0);
693        let (theta, phi) = north_pole.to_spherical();
694        let roundtrip = Vector3::from_spherical(theta, phi);
695
696        assert_eq!(roundtrip.z, north_pole.z);
697        assert!(roundtrip.x.abs() < 1e-15, "x component: {}", roundtrip.x);
698        assert!(roundtrip.y.abs() < 1e-15, "y component: {}", roundtrip.y);
699
700        let south_pole = Vector3::new(0.0, 0.0, -1.0);
701        let (theta, phi) = south_pole.to_spherical();
702        let roundtrip = Vector3::from_spherical(theta, phi);
703
704        assert_eq!(roundtrip.z, south_pole.z);
705        assert!(roundtrip.x.abs() < 1e-15, "x component: {}", roundtrip.x);
706        assert!(roundtrip.y.abs() < 1e-15, "y component: {}", roundtrip.y);
707    }
708}