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}