Skip to main content

celestial_core/matrix/
rotation_matrix.rs

1//! 3x3 rotation matrices for astronomical coordinate transformations.
2//!
3//! Rotation matrices are the fundamental tool for transforming coordinates between
4//! reference frames in astronomy. When you convert a star's position from ICRS to
5//! galactic coordinates, or account for Earth's precession over centuries, or rotate
6//! from equatorial to horizon coordinates for telescope pointing -- you're applying
7//! rotation matrices.
8//!
9//! # The Role of Rotation Matrices in Astronomy
10//!
11//! A rotation matrix is a 3x3 orthogonal matrix with determinant +1. When applied to
12//! a position vector, it rotates that vector while preserving its length. In astronomy,
13//! we use rotation matrices for:
14//!
15//! - **Frame bias**: The small rotation between the FK5 catalog frame and ICRS
16//! - **Precession**: Earth's axis traces a cone over ~26,000 years, requiring frame updates
17//! - **Nutation**: Short-period oscillations of Earth's axis (18.6-year cycle and harmonics)
18//! - **Earth rotation**: Converting between celestial and terrestrial reference frames
19//! - **Coordinate system changes**: ICRS to galactic, equatorial to ecliptic, etc.
20//!
21//! # Composing Transformations
22//!
23//! Rotation matrices compose by multiplication. To apply rotation A, then rotation B,
24//! you compute `B * A` (note the order -- the rightmost matrix acts first on the vector).
25//!
26//! ```
27//! use celestial_core::RotationMatrix3;
28//!
29//! // Build up a combined precession-nutation-bias transformation
30//! let mut bias = RotationMatrix3::identity();
31//! bias.rotate_x(-0.0068192);  // Frame bias around X
32//! bias.rotate_z(0.041775);    // Frame bias around Z
33//!
34//! let mut precession = RotationMatrix3::identity();
35//! precession.rotate_z(0.00385);  // Example precession angles
36//! precession.rotate_y(-0.00205);
37//!
38//! // Combined transformation: precession * bias
39//! let combined = precession * bias;
40//! ```
41//!
42//! For the full celestial-to-terrestrial transformation, the IAU defines the
43//! complete chain as: `W * R * NPB` where NPB is the frame bias-precession-nutation
44//! matrix, R is Earth rotation, and W is polar motion.
45//!
46//! # Rotation Conventions (ERFA-Compatible)
47//!
48//! This implementation follows the ERFA (Essential Routines for Fundamental Astronomy)
49//! conventions. Rotations are defined as positive when counterclockwise when looking
50//! from the positive axis toward the origin:
51//!
52//! - `rotate_x(phi)`: Rotation about the X-axis by angle phi (radians)
53//! - `rotate_y(theta)`: Rotation about the Y-axis by angle theta (radians)
54//! - `rotate_z(psi)`: Rotation about the Z-axis by angle psi (radians)
55//!
56//! This is the "passive" or "alias" convention where we rotate the coordinate frame
57//! rather than the vector. A positive rotation of 90 degrees about Z takes the
58//! vector `[1, 0, 0]` to `[0, -1, 0]`.
59//!
60//! # Storage Layout
61//!
62//! Elements are stored in row-major order as `[[f64; 3]; 3]`. The element at row `i`,
63//! column `j` is accessed as `matrix[(i, j)]` or `matrix.get(i, j)`. When the matrix
64//! multiplies a column vector, the result is the standard matrix-vector product:
65//!
66//! ```text
67//! | r00 r01 r02 |   | x |   | r00*x + r01*y + r02*z |
68//! | r10 r11 r12 | * | y | = | r10*x + r11*y + r12*z |
69//! | r20 r21 r22 |   | z |   | r20*x + r21*y + r22*z |
70//! ```
71//!
72//! # Inverting Rotations
73//!
74//! For a proper rotation matrix, the inverse equals the transpose. This is much cheaper
75//! to compute than a general matrix inverse and is numerically stable:
76//!
77//! ```
78//! use celestial_core::RotationMatrix3;
79//!
80//! let mut m = RotationMatrix3::identity();
81//! m.rotate_z(0.5);
82//!
83//! let m_inverse = m.transpose();
84//!
85//! // Verify: m * m_inverse should be identity
86//! let product = m * m_inverse;
87//! assert!((product.get(0, 0) - 1.0).abs() < 1e-15);
88//! ```
89//!
90//! # Spherical Coordinate Transformations
91//!
92//! For the common case of transforming right ascension and declination (or longitude
93//! and latitude), use [`transform_spherical`](RotationMatrix3::transform_spherical):
94//!
95//! ```
96//! use celestial_core::RotationMatrix3;
97//! use std::f64::consts::PI;
98//!
99//! let mut frame_rotation = RotationMatrix3::identity();
100//! frame_rotation.rotate_z(PI / 6.0);  // 30 degree rotation
101//!
102//! let (ra, dec) = (0.0, 0.0);  // On the celestial equator at RA=0
103//! let (new_ra, new_dec) = frame_rotation.transform_spherical(ra, dec);
104//! ```
105
106use std::fmt;
107
108/// A 3x3 rotation matrix for coordinate frame transformations.
109///
110/// This type represents proper rotation matrices (orthogonal with determinant +1).
111/// All angles are in radians. The matrix uses row-major storage.
112///
113/// # Construction
114///
115/// ```
116/// use celestial_core::RotationMatrix3;
117///
118/// // Start with identity and build up rotations
119/// let mut m = RotationMatrix3::identity();
120/// m.rotate_z(0.1);  // Rotate 0.1 radians about Z
121/// m.rotate_x(0.05); // Then rotate 0.05 radians about X
122///
123/// // Or construct directly from elements
124/// let m = RotationMatrix3::from_array([
125///     [1.0, 0.0, 0.0],
126///     [0.0, 1.0, 0.0],
127///     [0.0, 0.0, 1.0],
128/// ]);
129/// ```
130#[derive(Debug, Clone, Copy, PartialEq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132pub struct RotationMatrix3 {
133    elements: [[f64; 3]; 3],
134}
135
136impl RotationMatrix3 {
137    /// Creates the 3x3 identity matrix.
138    ///
139    /// The identity matrix leaves any vector unchanged when applied. It serves as
140    /// the starting point for building up rotation sequences.
141    ///
142    /// ```
143    /// use celestial_core::RotationMatrix3;
144    ///
145    /// let m = RotationMatrix3::identity();
146    /// let v = [1.0, 2.0, 3.0];
147    /// let result = m.apply_to_vector(v);
148    /// assert_eq!(result, v);
149    /// ```
150    pub fn identity() -> Self {
151        Self {
152            elements: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
153        }
154    }
155
156    /// Creates a rotation matrix from a 3x3 array of elements.
157    ///
158    /// The array is interpreted as row-major: `elements[i][j]` is row `i`, column `j`.
159    ///
160    /// This does not validate that the matrix is a proper rotation. Use
161    /// [`is_rotation_matrix`](Self::is_rotation_matrix) to check if needed.
162    ///
163    /// ```
164    /// use celestial_core::RotationMatrix3;
165    ///
166    /// // A rotation by 90 degrees about Z
167    /// let m = RotationMatrix3::from_array([
168    ///     [0.0, 1.0, 0.0],
169    ///     [-1.0, 0.0, 0.0],
170    ///     [0.0, 0.0, 1.0],
171    /// ]);
172    /// ```
173    pub fn from_array(elements: [[f64; 3]; 3]) -> Self {
174        Self { elements }
175    }
176
177    /// Returns the element at the specified row and column.
178    ///
179    /// Indices are 0-based. Panics if `row >= 3` or `col >= 3`.
180    ///
181    /// You can also use indexing syntax: `matrix[(row, col)]`.
182    pub fn get(&self, row: usize, col: usize) -> f64 {
183        self.elements[row][col]
184    }
185
186    /// Sets the element at the specified row and column.
187    ///
188    /// Indices are 0-based. Panics if `row >= 3` or `col >= 3`.
189    ///
190    /// You can also use indexing syntax: `matrix[(row, col)] = value`.
191    pub fn set(&mut self, row: usize, col: usize, value: f64) {
192        self.elements[row][col] = value;
193    }
194
195    /// Returns a reference to the underlying 3x3 array.
196    ///
197    /// Useful when you need direct access to all elements, for example when
198    /// passing to external APIs or serialization.
199    pub fn elements(&self) -> &[[f64; 3]; 3] {
200        &self.elements
201    }
202
203    /// Applies a rotation about the X-axis to this matrix (in place).
204    ///
205    /// The rotation angle `phi` is in radians. Positive angles rotate counterclockwise
206    /// when looking from the positive X-axis toward the origin (ERFA convention).
207    ///
208    /// This modifies `self` to become `Rx(phi) * self`, where `Rx` is the standard
209    /// X-axis rotation matrix:
210    ///
211    /// ```text
212    /// Rx(phi) = | 1    0         0      |
213    ///           | 0    cos(phi)  sin(phi)|
214    ///           | 0   -sin(phi)  cos(phi)|
215    /// ```
216    ///
217    /// In astronomy, X-axis rotations appear in frame bias corrections and some
218    /// nutation models.
219    ///
220    /// ```
221    /// use celestial_core::RotationMatrix3;
222    /// use std::f64::consts::FRAC_PI_2;
223    ///
224    /// let mut m = RotationMatrix3::identity();
225    /// m.rotate_x(FRAC_PI_2);  // 90 degrees
226    ///
227    /// // [0, 1, 0] rotates to [0, 0, -1]
228    /// let v = m.apply_to_vector([0.0, 1.0, 0.0]);
229    /// assert!(v[0].abs() < 1e-15);
230    /// assert!(v[1].abs() < 1e-15);
231    /// assert!((v[2] + 1.0).abs() < 1e-15);
232    /// ```
233    pub fn rotate_x(&mut self, phi: f64) {
234        let (s, c) = libm::sincos(phi);
235
236        let a10 = c * self.elements[1][0] + s * self.elements[2][0];
237        let a11 = c * self.elements[1][1] + s * self.elements[2][1];
238        let a12 = c * self.elements[1][2] + s * self.elements[2][2];
239        let a20 = -s * self.elements[1][0] + c * self.elements[2][0];
240        let a21 = -s * self.elements[1][1] + c * self.elements[2][1];
241        let a22 = -s * self.elements[1][2] + c * self.elements[2][2];
242
243        self.elements[1][0] = a10;
244        self.elements[1][1] = a11;
245        self.elements[1][2] = a12;
246        self.elements[2][0] = a20;
247        self.elements[2][1] = a21;
248        self.elements[2][2] = a22;
249    }
250
251    /// Applies a rotation about the Z-axis to this matrix (in place).
252    ///
253    /// The rotation angle `psi` is in radians. Positive angles rotate counterclockwise
254    /// when looking from the positive Z-axis toward the origin (ERFA convention).
255    ///
256    /// This modifies `self` to become `Rz(psi) * self`, where `Rz` is the standard
257    /// Z-axis rotation matrix:
258    ///
259    /// ```text
260    /// Rz(psi) = | cos(psi)  sin(psi)  0 |
261    ///           |-sin(psi)  cos(psi)  0 |
262    ///           |    0         0      1 |
263    /// ```
264    ///
265    /// Z-axis rotations are ubiquitous in astronomy. Earth rotation about its axis,
266    /// precession in right ascension, and rotations in longitude all use Rz.
267    ///
268    /// ```
269    /// use celestial_core::RotationMatrix3;
270    /// use std::f64::consts::FRAC_PI_2;
271    ///
272    /// let mut m = RotationMatrix3::identity();
273    /// m.rotate_z(FRAC_PI_2);  // 90 degrees
274    ///
275    /// // [1, 0, 0] rotates to [0, -1, 0]
276    /// let v = m.apply_to_vector([1.0, 0.0, 0.0]);
277    /// assert!(v[0].abs() < 1e-15);
278    /// assert!((v[1] + 1.0).abs() < 1e-15);
279    /// assert!(v[2].abs() < 1e-15);
280    /// ```
281    pub fn rotate_z(&mut self, psi: f64) {
282        let (s, c) = libm::sincos(psi);
283
284        let a00 = c * self.elements[0][0] + s * self.elements[1][0];
285        let a01 = c * self.elements[0][1] + s * self.elements[1][1];
286        let a02 = c * self.elements[0][2] + s * self.elements[1][2];
287        let a10 = -s * self.elements[0][0] + c * self.elements[1][0];
288        let a11 = -s * self.elements[0][1] + c * self.elements[1][1];
289        let a12 = -s * self.elements[0][2] + c * self.elements[1][2];
290
291        self.elements[0][0] = a00;
292        self.elements[0][1] = a01;
293        self.elements[0][2] = a02;
294        self.elements[1][0] = a10;
295        self.elements[1][1] = a11;
296        self.elements[1][2] = a12;
297    }
298
299    /// Applies a rotation about the Y-axis to this matrix (in place).
300    ///
301    /// The rotation angle `theta` is in radians. Positive angles rotate counterclockwise
302    /// when looking from the positive Y-axis toward the origin (ERFA convention).
303    ///
304    /// This modifies `self` to become `Ry(theta) * self`, where `Ry` is the standard
305    /// Y-axis rotation matrix:
306    ///
307    /// ```text
308    /// Ry(theta) = | cos(theta)  0  -sin(theta) |
309    ///             |     0       1       0      |
310    ///             | sin(theta)  0   cos(theta) |
311    /// ```
312    ///
313    /// Y-axis rotations appear in obliquity of the ecliptic and some precession models.
314    ///
315    /// ```
316    /// use celestial_core::RotationMatrix3;
317    /// use std::f64::consts::FRAC_PI_2;
318    ///
319    /// let mut m = RotationMatrix3::identity();
320    /// m.rotate_y(FRAC_PI_2);  // 90 degrees
321    ///
322    /// // [0, 0, 1] rotates to [-1, 0, 0]
323    /// let v = m.apply_to_vector([0.0, 0.0, 1.0]);
324    /// assert!((v[0] + 1.0).abs() < 1e-15);
325    /// assert!(v[1].abs() < 1e-15);
326    /// assert!(v[2].abs() < 1e-15);
327    /// ```
328    pub fn rotate_y(&mut self, theta: f64) {
329        let (s, c) = libm::sincos(theta);
330
331        let a00 = c * self.elements[0][0] - s * self.elements[2][0];
332        let a01 = c * self.elements[0][1] - s * self.elements[2][1];
333        let a02 = c * self.elements[0][2] - s * self.elements[2][2];
334        let a20 = s * self.elements[0][0] + c * self.elements[2][0];
335        let a21 = s * self.elements[0][1] + c * self.elements[2][1];
336        let a22 = s * self.elements[0][2] + c * self.elements[2][2];
337
338        self.elements[0][0] = a00;
339        self.elements[0][1] = a01;
340        self.elements[0][2] = a02;
341        self.elements[2][0] = a20;
342        self.elements[2][1] = a21;
343        self.elements[2][2] = a22;
344    }
345
346    /// Multiplies this matrix by another, returning the product.
347    ///
348    /// Matrix multiplication is not commutative: `A * B` is generally different
349    /// from `B * A`. The result represents the composition of two rotations where
350    /// `other` is applied first, then `self`.
351    ///
352    /// You can also use the `*` operator: `a * b` or `&a * &b`.
353    ///
354    /// ```
355    /// use celestial_core::RotationMatrix3;
356    ///
357    /// let mut rx = RotationMatrix3::identity();
358    /// rx.rotate_x(0.1);
359    ///
360    /// let mut rz = RotationMatrix3::identity();
361    /// rz.rotate_z(0.2);
362    ///
363    /// // First rotate by X, then by Z
364    /// let combined = rz.multiply(&rx);
365    /// // Equivalent using operator:
366    /// let combined_op = rz * rx;
367    /// ```
368    pub fn multiply(&self, other: &Self) -> Self {
369        let mut result = [[0.0; 3]; 3];
370
371        for (i, row) in result.iter_mut().enumerate() {
372            for (j, cell) in row.iter_mut().enumerate() {
373                for k in 0..3 {
374                    *cell += self.elements[i][k] * other.elements[k][j];
375                }
376            }
377        }
378
379        Self::from_array(result)
380    }
381
382    /// Applies this rotation matrix to a 3D vector.
383    ///
384    /// Computes the standard matrix-vector product `M * v`. For coordinate
385    /// transformations, this rotates the position vector from one frame to another.
386    ///
387    /// You can also use the `*` operator with [`Vector3`](super::Vector3):
388    /// `matrix * vector`.
389    ///
390    /// ```
391    /// use celestial_core::RotationMatrix3;
392    ///
393    /// let mut m = RotationMatrix3::identity();
394    /// m.rotate_z(std::f64::consts::FRAC_PI_2);  // 90 degrees
395    ///
396    /// let v = [1.0, 0.0, 0.0];
397    /// let rotated = m.apply_to_vector(v);
398    /// // Result is approximately [0, -1, 0]
399    /// ```
400    pub fn apply_to_vector(&self, vector: [f64; 3]) -> [f64; 3] {
401        [
402            self.elements[0][0] * vector[0]
403                + self.elements[0][1] * vector[1]
404                + self.elements[0][2] * vector[2],
405            self.elements[1][0] * vector[0]
406                + self.elements[1][1] * vector[1]
407                + self.elements[1][2] * vector[2],
408            self.elements[2][0] * vector[0]
409                + self.elements[2][1] * vector[1]
410                + self.elements[2][2] * vector[2],
411        ]
412    }
413
414    /// Computes the determinant of this matrix.
415    ///
416    /// For a proper rotation matrix, the determinant is always +1. A determinant
417    /// of -1 indicates a reflection (improper rotation). Values far from +/-1
418    /// indicate the matrix is not orthogonal.
419    ///
420    /// Used internally by [`is_rotation_matrix`](Self::is_rotation_matrix).
421    pub fn determinant(&self) -> f64 {
422        let m = &self.elements;
423
424        m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
425            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
426            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
427    }
428
429    /// Returns the transpose of this matrix.
430    ///
431    /// For a rotation matrix, the transpose equals the inverse. This provides
432    /// a numerically stable way to compute the reverse transformation without
433    /// general matrix inversion.
434    ///
435    /// ```
436    /// use celestial_core::RotationMatrix3;
437    ///
438    /// let mut m = RotationMatrix3::identity();
439    /// m.rotate_z(0.5);
440    /// m.rotate_x(0.3);
441    ///
442    /// let m_inv = m.transpose();
443    ///
444    /// // Applying m then m_inv returns to the original
445    /// let v = [1.0, 2.0, 3.0];
446    /// let rotated = m.apply_to_vector(v);
447    /// let restored = m_inv.apply_to_vector(rotated);
448    ///
449    /// assert!((restored[0] - v[0]).abs() < 1e-14);
450    /// assert!((restored[1] - v[1]).abs() < 1e-14);
451    /// assert!((restored[2] - v[2]).abs() < 1e-14);
452    /// ```
453    pub fn transpose(&self) -> Self {
454        Self::from_array([
455            [
456                self.elements[0][0],
457                self.elements[1][0],
458                self.elements[2][0],
459            ],
460            [
461                self.elements[0][1],
462                self.elements[1][1],
463                self.elements[2][1],
464            ],
465            [
466                self.elements[0][2],
467                self.elements[1][2],
468                self.elements[2][2],
469            ],
470        ])
471    }
472
473    /// Checks whether this matrix is a valid rotation matrix within a tolerance.
474    ///
475    /// A proper rotation matrix must satisfy two conditions:
476    /// 1. Determinant equals +1 (not -1, which would be a reflection)
477    /// 2. The matrix is orthogonal: `M * M^T = I`
478    ///
479    /// Due to floating-point arithmetic, these conditions are checked within
480    /// the specified tolerance.
481    ///
482    /// ```
483    /// use celestial_core::RotationMatrix3;
484    ///
485    /// let mut m = RotationMatrix3::identity();
486    /// m.rotate_z(0.5);
487    /// m.rotate_x(0.3);
488    /// assert!(m.is_rotation_matrix(1e-14));
489    ///
490    /// // A scaling matrix is not a rotation
491    /// let scaled = RotationMatrix3::from_array([
492    ///     [2.0, 0.0, 0.0],
493    ///     [0.0, 1.0, 0.0],
494    ///     [0.0, 0.0, 1.0],
495    /// ]);
496    /// assert!(!scaled.is_rotation_matrix(1e-14));
497    /// ```
498    pub fn is_rotation_matrix(&self, tolerance: f64) -> bool {
499        let det = self.determinant();
500        if (det - 1.0).abs() > tolerance {
501            return false;
502        }
503
504        let rt = self.transpose();
505        let product = self.multiply(&rt);
506        let identity = Self::identity();
507
508        for i in 0..3 {
509            for j in 0..3 {
510                if (product.elements[i][j] - identity.elements[i][j]).abs() > tolerance {
511                    return false;
512                }
513            }
514        }
515
516        true
517    }
518
519    /// Returns the maximum absolute difference between corresponding elements.
520    ///
521    /// Useful for comparing matrices, especially when testing against reference
522    /// implementations like ERFA.
523    ///
524    /// ```
525    /// use celestial_core::RotationMatrix3;
526    ///
527    /// let a = RotationMatrix3::identity();
528    /// let b = RotationMatrix3::from_array([
529    ///     [1.0, 0.001, 0.0],
530    ///     [0.0, 1.0, 0.0],
531    ///     [0.0, 0.0, 1.0],
532    /// ]);
533    ///
534    /// let diff = a.max_difference(&b);
535    /// assert!((diff - 0.001).abs() < 1e-15);
536    /// ```
537    pub fn max_difference(&self, other: &Self) -> f64 {
538        let mut max_diff: f64 = 0.0;
539
540        for i in 0..3 {
541            for j in 0..3 {
542                let diff = (self.elements[i][j] - other.elements[i][j]).abs();
543                max_diff = max_diff.max(diff);
544            }
545        }
546
547        max_diff
548    }
549
550    /// Transforms spherical coordinates (RA, Dec or longitude, latitude) through
551    /// this rotation matrix.
552    ///
553    /// This is the common operation for coordinate frame transformations in astronomy.
554    /// The input angles are in radians:
555    /// - `ra`: Right ascension or longitude (azimuthal angle from X toward Y)
556    /// - `dec`: Declination or latitude (elevation from the XY plane)
557    ///
558    /// Internally, this converts to a unit Cartesian vector, applies the rotation,
559    /// and converts back to spherical coordinates.
560    ///
561    /// The output RA is in the range `(-pi, pi]`. The output Dec is in `[-pi/2, pi/2]`.
562    ///
563    /// ```
564    /// use celestial_core::RotationMatrix3;
565    /// use std::f64::consts::FRAC_PI_4;
566    ///
567    /// // Rotate the equatorial coordinate system by 45 degrees in RA
568    /// let mut m = RotationMatrix3::identity();
569    /// m.rotate_z(FRAC_PI_4);
570    ///
571    /// let (ra, dec) = (0.0, 0.0);  // Point on equator at RA=0
572    /// let (new_ra, new_dec) = m.transform_spherical(ra, dec);
573    ///
574    /// // RA shifted by -45 degrees (rotation convention), Dec unchanged
575    /// assert!((new_ra + FRAC_PI_4).abs() < 1e-14);
576    /// assert!(new_dec.abs() < 1e-14);
577    /// ```
578    pub fn transform_spherical(&self, ra: f64, dec: f64) -> (f64, f64) {
579        let (sin_ra, cos_ra) = libm::sincos(ra);
580        let (sin_dec, cos_dec) = libm::sincos(dec);
581        let vector = [cos_dec * cos_ra, cos_dec * sin_ra, sin_dec];
582
583        let transformed = self.apply_to_vector(vector);
584
585        let new_ra = libm::atan2(transformed[1], transformed[0]);
586        let norm = libm::sqrt(
587            transformed[0] * transformed[0]
588                + transformed[1] * transformed[1]
589                + transformed[2] * transformed[2],
590        );
591        let z = if norm == 0.0 {
592            0.0
593        } else {
594            (transformed[2] / norm).clamp(-1.0, 1.0)
595        };
596        let new_dec = libm::asin(z);
597
598        (new_ra, new_dec)
599    }
600}
601
602impl std::ops::Mul for RotationMatrix3 {
603    type Output = Self;
604
605    fn mul(self, rhs: Self) -> Self {
606        self.multiply(&rhs)
607    }
608}
609
610impl std::ops::Mul<&RotationMatrix3> for RotationMatrix3 {
611    type Output = RotationMatrix3;
612
613    fn mul(self, rhs: &RotationMatrix3) -> RotationMatrix3 {
614        self.multiply(rhs)
615    }
616}
617
618impl std::ops::Mul<RotationMatrix3> for &RotationMatrix3 {
619    type Output = RotationMatrix3;
620
621    fn mul(self, rhs: RotationMatrix3) -> RotationMatrix3 {
622        self.multiply(&rhs)
623    }
624}
625
626impl std::ops::Mul<&RotationMatrix3> for &RotationMatrix3 {
627    type Output = RotationMatrix3;
628
629    fn mul(self, rhs: &RotationMatrix3) -> RotationMatrix3 {
630        self.multiply(rhs)
631    }
632}
633
634impl std::ops::Index<(usize, usize)> for RotationMatrix3 {
635    type Output = f64;
636
637    fn index(&self, (row, col): (usize, usize)) -> &f64 {
638        &self.elements[row][col]
639    }
640}
641
642impl std::ops::IndexMut<(usize, usize)> for RotationMatrix3 {
643    fn index_mut(&mut self, (row, col): (usize, usize)) -> &mut f64 {
644        &mut self.elements[row][col]
645    }
646}
647
648impl std::ops::Mul<super::Vector3> for RotationMatrix3 {
649    type Output = super::Vector3;
650
651    fn mul(self, vec: super::Vector3) -> super::Vector3 {
652        let result = self.apply_to_vector([vec.x, vec.y, vec.z]);
653        super::Vector3::from_array(result)
654    }
655}
656
657impl std::ops::Mul<super::Vector3> for &RotationMatrix3 {
658    type Output = super::Vector3;
659
660    fn mul(self, vec: super::Vector3) -> super::Vector3 {
661        let result = self.apply_to_vector([vec.x, vec.y, vec.z]);
662        super::Vector3::from_array(result)
663    }
664}
665
666impl fmt::Display for RotationMatrix3 {
667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668        writeln!(f, "RotationMatrix3:")?;
669        for row in &self.elements {
670            writeln!(f, "  [{:12.9} {:12.9} {:12.9}]", row[0], row[1], row[2])?;
671        }
672        Ok(())
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use crate::constants::HALF_PI;
680
681    #[test]
682    fn test_identity_and_get() {
683        let m = RotationMatrix3::identity();
684        assert_eq!(m.get(0, 0), 1.0);
685        assert_eq!(m.get(1, 1), 1.0);
686        assert_eq!(m.get(2, 2), 1.0);
687        assert_eq!(m.get(0, 1), 0.0);
688    }
689
690    #[test]
691    fn test_set() {
692        let mut m = RotationMatrix3::identity();
693        m.set(0, 1, 0.5);
694        assert_eq!(m.get(0, 1), 0.5);
695    }
696
697    #[test]
698    fn test_rotate_z() {
699        // ERFA convention: Rz(+psi) rotates anticlockwise looking from +z toward origin
700        // This means [1,0,0] -> [cos(psi), -sin(psi), 0]
701        // At 90°: [1,0,0] -> [0, -1, 0]
702        let mut m = RotationMatrix3::identity();
703        m.rotate_z(HALF_PI);
704        let result = m.apply_to_vector([1.0, 0.0, 0.0]);
705        assert!(result[0].abs() < 1e-15);
706        assert!((result[1] + 1.0).abs() < 1e-15);
707        assert!(result[2].abs() < 1e-15);
708    }
709
710    #[test]
711    fn test_rotate_x() {
712        // ERFA convention: Rx(+phi) rotates anticlockwise looking from +x toward origin
713        // This means [0,1,0] -> [0, cos(phi), -sin(phi)]
714        // At 90°: [0,1,0] -> [0, 0, -1]
715        let mut m = RotationMatrix3::identity();
716        m.rotate_x(HALF_PI);
717        let result = m.apply_to_vector([0.0, 1.0, 0.0]);
718        assert!(result[0].abs() < 1e-15);
719        assert!(result[1].abs() < 1e-15);
720        assert!((result[2] + 1.0).abs() < 1e-15);
721    }
722
723    #[test]
724    fn test_rotate_y() {
725        // ERFA convention: Ry(+theta) rotates anticlockwise looking from +y toward origin
726        // This means [0,0,1] -> [-sin(theta), 0, cos(theta)]
727        // At 90°: [0,0,1] -> [-1, 0, 0]
728        let mut m = RotationMatrix3::identity();
729        m.rotate_y(HALF_PI);
730        let result = m.apply_to_vector([0.0, 0.0, 1.0]);
731        assert!((result[0] + 1.0).abs() < 1e-15);
732        assert!(result[1].abs() < 1e-15);
733        assert!(result[2].abs() < 1e-15);
734    }
735
736    #[test]
737    fn test_is_rotation_matrix_valid() {
738        let mut m = RotationMatrix3::identity();
739        m.rotate_z(0.5);
740        assert!(m.is_rotation_matrix(1e-14));
741    }
742
743    #[test]
744    fn test_is_rotation_matrix_bad_determinant() {
745        let m = RotationMatrix3::from_array([[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
746        assert!(!m.is_rotation_matrix(1e-15));
747    }
748
749    #[test]
750    fn test_is_rotation_matrix_not_orthogonal() {
751        let m = RotationMatrix3::from_array([[1.0, 0.1, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
752        assert!(!m.is_rotation_matrix(1e-15));
753    }
754
755    #[test]
756    fn test_transform_spherical_identity() {
757        let m = RotationMatrix3::identity();
758        let (ra, dec) = (1.0, 0.5);
759        let (new_ra, new_dec) = m.transform_spherical(ra, dec);
760        assert!((new_ra - ra).abs() < 1e-14);
761        assert!((new_dec - dec).abs() < 1e-14);
762    }
763
764    #[test]
765    fn test_transform_spherical_rotation() {
766        // ERFA Rz rotates in opposite direction to naive expectation
767        // Rz(+90°) takes RA=0 to RA=-90° (or equivalently RA=270°=-HALF_PI)
768        let mut m = RotationMatrix3::identity();
769        m.rotate_z(HALF_PI);
770        let (new_ra, new_dec) = m.transform_spherical(0.0, 0.0);
771        assert!((new_ra + HALF_PI).abs() < 1e-14);
772        assert!(new_dec.abs() < 1e-14);
773    }
774
775    #[test]
776    fn test_transform_spherical_zero_norm() {
777        let zero_matrix =
778            RotationMatrix3::from_array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]);
779        let (_, dec) = zero_matrix.transform_spherical(0.0, 0.0);
780        assert!(dec.is_finite());
781    }
782
783    #[test]
784    fn test_mul_matrix_matrix() {
785        let mut a = RotationMatrix3::identity();
786        a.rotate_x(0.1);
787        let mut b = RotationMatrix3::identity();
788        b.rotate_y(0.2);
789
790        let r1 = a * b;
791        let r2 = a * &b;
792        let r3 = &a * b;
793        let r4 = &a * &b;
794
795        assert_eq!(r1.get(0, 0), r2.get(0, 0));
796        assert_eq!(r2.get(0, 0), r3.get(0, 0));
797        assert_eq!(r3.get(0, 0), r4.get(0, 0));
798    }
799
800    #[test]
801    fn test_index_operators() {
802        let mut m = RotationMatrix3::identity();
803        assert_eq!(m[(0, 0)], 1.0);
804        assert_eq!(m[(0, 1)], 0.0);
805        m[(0, 1)] = 0.5;
806        assert_eq!(m[(0, 1)], 0.5);
807    }
808
809    #[test]
810    fn test_mul_matrix_vector() {
811        use crate::Vector3;
812        let m = RotationMatrix3::identity();
813        let v = Vector3::new(1.0, 2.0, 3.0);
814        let r1 = m * v;
815        let r2 = &m * v;
816        assert_eq!(r1, v);
817        assert_eq!(r2, v);
818    }
819
820    #[test]
821    fn test_display() {
822        let mut m = RotationMatrix3::identity();
823        m.rotate_z(0.1);
824        let s = format!("{}", m);
825        assert!(s.contains("RotationMatrix3:"));
826        assert!(s.contains("["));
827    }
828
829    #[test]
830    fn test_max_difference() {
831        let a = RotationMatrix3::identity();
832        let b = RotationMatrix3::from_array([[1.0, 0.1, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
833        assert!((a.max_difference(&b) - 0.1).abs() < 1e-15);
834    }
835
836    #[test]
837    fn test_elements() {
838        let m = RotationMatrix3::identity();
839        let e = m.elements();
840        assert_eq!(e[0][0], 1.0);
841        assert_eq!(e[1][1], 1.0);
842    }
843}