Skip to main content

lie_groups/
quaternion.rs

1//! Quaternionic Formulation of SU(2)
2//!
3//! **Addressing Penrose's Recommendation:**
4//! > "SU(2) ≅ unit quaternions. Why not use this beautiful structure?"
5//!
6//! This module implements SU(2) using quaternions instead of 2×2 matrices.
7//!
8//! # Mathematical Background
9//!
10//! **Quaternions:** ℍ = {a + bi + cj + dk | a,b,c,d ∈ ℝ}
11//! with multiplication rules: i² = j² = k² = ijk = -1
12//!
13//! **Unit Quaternions:** S³ = {q ∈ ℍ | |q| = 1} ⊂ ℍ
14//!
15//! **Fundamental Isomorphism:** SU(2) ≅ S³
16//!
17//! The map is:
18//! ```text
19//! SU(2) matrix [[α, -β*], [β, α*]]  ↔  Quaternion q = α + βj
20//!                                        where α = a + bi, β = c + di
21//!                                        ⟺ q = a + bi + cj + dk
22//! ```
23//!
24//! # Advantages Over Matrix Representation
25//!
26//! 1. **Compact**: 4 reals vs 8 reals (complex 2×2 matrix)
27//! 2. **Efficient**: Quaternion multiplication is faster than matrix multiplication
28//! 3. **Geometric**: Direct axis-angle interpretation for rotations
29//! 4. **Numerical**: Better numerical stability (no matrix decomposition needed)
30//! 5. **Elegant**: Exponential map is cleaner
31//!
32//! # The SU(2) Double Cover of SO(3) ⭐
33//!
34//! **Critical Physics Insight:**
35//!
36//! SU(2) is a **double cover** of SO(3) (3D rotations):
37//! ```text
38//! SU(2) → SO(3)  is a 2-to-1 surjective homomorphism
39//! ```
40//!
41//! **Meaning:** Quaternions **q** and **-q** represent the **same rotation** in 3D space,
42//! but **different quantum states** in SU(2).
43//!
44//! ## Mathematical Statement
45//!
46//! For any unit quaternion q:
47//! - **q** rotates a vector v by angle θ around axis n̂
48//! - **-q** also rotates v by angle θ around axis n̂  (same rotation!)
49//! - But q ≠ -q as group elements in SU(2)
50//!
51//! Path from q → -q → q (going 4π around) is **topologically non-trivial**.
52//!
53//! ## Physical Consequences
54//!
55//! 1. **Fermions (spin-½ particles)**:
56//!    - Under 2π rotation: ψ → -ψ  (sign flip!)
57//!    - Under 4π rotation: ψ → ψ  (back to original)
58//!    - This is why fermions have **half-integer spin**
59//!
60//! 2. **Bosons (integer spin)**:
61//!    - Under 2π rotation: ψ → ψ  (no sign change)
62//!    - Described by SO(3), not SU(2)
63//!
64//! 3. **Berry Phase**:
65//!    - Geometric phase accumulated when traversing closed path in SU(2)
66//!    - q → -q → q acquires phase π (Pancharatnam-Berry phase)
67//!
68//! ## Example: Rotation by 2π
69//!
70//! ```text
71//! Rotation by angle θ = 2π around z-axis:
72//!   q = cos(π) + sin(π)·k = -1 + 0·k = -1  (minus identity!)
73//!
74//! But -1 acts on vector v as:
75//!   v → -1·v·(-1)⁻¹ = -1·v·(-1) = v  (identity rotation in SO(3))
76//! ```
77//!
78//! ## Gauge Theory Interpretation
79//!
80//! In gauge theory on networks:
81//! - **Connections** A_{ij} ∈ SU(2) carry full SU(2) structure (not just rotations)
82//! - **Wilson loops** W(C) can wind around non-trivially in SU(2)
83//! - Path C → 2π rotation can give W(C) = -I ≠ I (topological effect!)
84//! - This is invisible in classical physics but crucial for quantum systems
85//!
86//! ## References
87//!
88//! - Dirac, P.A.M.: "The Principles of Quantum Mechanics" (1930) - Original treatment
89//! - Feynman & Hibbs: "Quantum Mechanics and Path Integrals" - Spin-statistics connection
90//! - Penrose: "The Road to Reality" § 11.3 - Geometric interpretation
91//! - Aharonov & Susskind: "Observability of the sign change of spinors" (1967)
92
93use std::ops::{Mul, MulAssign};
94
95/// Unit quaternion representing an element of SU(2)
96///
97/// Quaternion q = w + xi + yj + zk where w² + x² + y² + z² = 1
98///
99/// # Geometric Interpretation
100///
101/// Every unit quaternion represents a rotation in 3D space:
102/// - Rotation by angle θ around axis n̂ = (nx, ny, nz):
103///   q = cos(θ/2) + sin(θ/2)(nx·i + ny·j + nz·k)
104///
105/// # ⚠️ Important: Double Cover Property
106///
107/// **q and -q represent the SAME rotation but DIFFERENT quantum states:**
108/// - Both rotate 3D vectors identically (v → q·v·q⁻¹ = (-q)·v·(-q)⁻¹)
109/// - But q ≠ -q as SU(2) group elements
110/// - Consequence: Fermions change sign under 2π rotation (ψ → -ψ)
111///
112/// This is NOT a numerical issue or degeneracy - it's fundamental topology!
113///
114/// # Relation to SU(2) Matrix
115///
116/// q = w + xi + yj + zk corresponds to SU(2) matrix:
117/// ```text
118/// U = [[ w + ix,  -y + iz],
119///      [ y + iz,   w - ix]]
120/// ```
121///
122/// Note: -q gives matrix -U, which acts identically on vectors but represents
123/// a different element of SU(2) (relevant for spinors/fermions).
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct UnitQuaternion {
126    // Private fields enforce unit norm invariant through constructors
127    w: f64,
128    x: f64,
129    y: f64,
130    z: f64,
131}
132
133impl UnitQuaternion {
134    /// Get the real (scalar) component.
135    #[inline]
136    #[must_use]
137    pub fn w(&self) -> f64 {
138        self.w
139    }
140
141    /// Get the imaginary i component.
142    #[inline]
143    #[must_use]
144    pub fn x(&self) -> f64 {
145        self.x
146    }
147
148    /// Get the imaginary j component.
149    #[inline]
150    #[must_use]
151    pub fn y(&self) -> f64 {
152        self.y
153    }
154
155    /// Get the imaginary k component.
156    #[inline]
157    #[must_use]
158    pub fn z(&self) -> f64 {
159        self.z
160    }
161
162    /// Get all components as a tuple (w, x, y, z).
163    #[inline]
164    #[must_use]
165    pub fn components(&self) -> (f64, f64, f64, f64) {
166        (self.w, self.x, self.y, self.z)
167    }
168
169    /// Get all components as an array [w, x, y, z].
170    #[inline]
171    #[must_use]
172    pub fn to_array(&self) -> [f64; 4] {
173        [self.w, self.x, self.y, self.z]
174    }
175}
176
177// ============================================================================
178// Numerical Tolerance Constants
179// ============================================================================
180//
181// These thresholds are chosen based on IEEE 754 f64 precision (ε ≈ 2.2e-16)
182// and practical considerations for accumulated floating-point error.
183//
184// Rationale: 1e-10 = ~10^6 × machine_epsilon
185// This allows for approximately 6 orders of magnitude of accumulated error
186// from typical operations (multiplication, addition, normalization) before
187// triggering degeneracy handling.
188//
189// For quaternion operations on unit quaternions, typical error accumulation:
190// - Single operation: O(ε) ≈ 2e-16
191// - After ~1000 operations: O(1000ε) ≈ 2e-13
192// - Threshold 1e-10 provides ~1000× safety margin
193//
194// Reference: Higham, "Accuracy and Stability of Numerical Algorithms" (2002)
195
196/// Threshold below which a norm is considered zero (degenerate case).
197/// Used for normalization guards and axis-angle singularity detection.
198const NORM_EPSILON: f64 = 1e-10;
199
200/// Threshold for detecting near-identity quaternions.
201/// Used in logarithm to avoid division by near-zero sin(θ/2).
202const IDENTITY_EPSILON: f64 = 1e-10;
203
204/// Threshold for detecting rotation angle near π (axis ambiguity).
205/// At θ = π, the rotation axis becomes undefined (any axis works).
206/// Reserved for future implementation of θ≈π handling.
207#[allow(dead_code)]
208const SINGULARITY_EPSILON: f64 = 1e-6;
209
210impl UnitQuaternion {
211    /// Identity element: q = 1
212    #[must_use]
213    pub fn identity() -> Self {
214        Self {
215            w: 1.0,
216            x: 0.0,
217            y: 0.0,
218            z: 0.0,
219        }
220    }
221
222    /// Create from components (automatically normalizes to unit quaternion)
223    ///
224    /// Returns identity if input norm < `NORM_EPSILON` (degenerate case).
225    #[must_use]
226    pub fn new(w: f64, x: f64, y: f64, z: f64) -> Self {
227        let norm = (w * w + x * x + y * y + z * z).sqrt();
228        if norm < NORM_EPSILON {
229            return Self::identity();
230        }
231        Self {
232            w: w / norm,
233            x: x / norm,
234            y: y / norm,
235            z: z / norm,
236        }
237    }
238
239    /// Create from axis-angle representation
240    ///
241    /// # Arguments
242    /// * `axis` - Rotation axis (nx, ny, nz), will be normalized
243    /// * `angle` - Rotation angle in radians
244    ///
245    /// # Returns
246    /// Unit quaternion q = cos(θ/2) + sin(θ/2)(nx·i + ny·j + nz·k)
247    #[must_use]
248    pub fn from_axis_angle(axis: [f64; 3], angle: f64) -> Self {
249        let [nx, ny, nz] = axis;
250        let axis_norm = (nx * nx + ny * ny + nz * nz).sqrt();
251
252        // Degenerate axis: return identity (no rotation)
253        if axis_norm < NORM_EPSILON {
254            return Self::identity();
255        }
256
257        let half_angle = angle / 2.0;
258        let sin_half = half_angle.sin();
259        let cos_half = half_angle.cos();
260
261        Self {
262            w: cos_half,
263            x: sin_half * nx / axis_norm,
264            y: sin_half * ny / axis_norm,
265            z: sin_half * nz / axis_norm,
266        }
267    }
268
269    /// Extract axis and angle from quaternion
270    ///
271    /// # Returns
272    /// (axis, angle) where axis is normalized and angle ∈ [0, 2π]
273    #[must_use]
274    pub fn to_axis_angle(&self) -> ([f64; 3], f64) {
275        // q = cos(θ/2) + sin(θ/2)·n̂
276        let angle = 2.0 * self.w.acos();
277        let sin_half = (1.0 - self.w * self.w).sqrt();
278
279        if sin_half < IDENTITY_EPSILON {
280            // Near identity, axis is arbitrary
281            return ([1.0, 0.0, 0.0], 0.0);
282        }
283
284        let axis = [self.x / sin_half, self.y / sin_half, self.z / sin_half];
285
286        (axis, angle)
287    }
288
289    /// Rotation around X-axis by angle θ
290    #[must_use]
291    pub fn rotation_x(theta: f64) -> Self {
292        Self::from_axis_angle([1.0, 0.0, 0.0], theta)
293    }
294
295    /// Rotation around Y-axis by angle θ
296    #[must_use]
297    pub fn rotation_y(theta: f64) -> Self {
298        Self::from_axis_angle([0.0, 1.0, 0.0], theta)
299    }
300
301    /// Rotation around Z-axis by angle θ
302    #[must_use]
303    pub fn rotation_z(theta: f64) -> Self {
304        Self::from_axis_angle([0.0, 0.0, 1.0], theta)
305    }
306
307    /// Quaternion conjugate: q* = w - xi - yj - zk
308    ///
309    /// For unit quaternions: q* = q⁻¹ (inverse)
310    #[must_use]
311    pub fn conjugate(&self) -> Self {
312        Self {
313            w: self.w,
314            x: -self.x,
315            y: -self.y,
316            z: -self.z,
317        }
318    }
319
320    /// Quaternion inverse: q⁻¹
321    ///
322    /// For unit quaternions: q⁻¹ = q*
323    #[must_use]
324    pub fn inverse(&self) -> Self {
325        self.conjugate()
326    }
327
328    /// Norm squared: |q|² = w² + x² + y² + z²
329    ///
330    /// Should always be 1 for unit quaternions
331    #[must_use]
332    pub fn norm_squared(&self) -> f64 {
333        self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z
334    }
335
336    /// Norm: |q| = sqrt(w² + x² + y² + z²)
337    ///
338    /// Should always be 1 for unit quaternions
339    #[must_use]
340    pub fn norm(&self) -> f64 {
341        self.norm_squared().sqrt()
342    }
343
344    /// Renormalize to unit quaternion (fix numerical drift)
345    #[must_use]
346    pub fn normalize(&self) -> Self {
347        Self::new(self.w, self.x, self.y, self.z)
348    }
349
350    /// Distance to identity (geodesic distance on S³)
351    ///
352    /// d(q, 1) = 2·arccos(|w|)
353    ///
354    /// This is the rotation angle in [0, π]
355    #[must_use]
356    pub fn distance_to_identity(&self) -> f64 {
357        2.0 * self.w.abs().clamp(-1.0, 1.0).acos()
358    }
359
360    /// Geodesic distance to another quaternion
361    ///
362    /// d(q₁, q₂) = arccos(|q₁·q₂|) where · is quaternion dot product
363    #[must_use]
364    pub fn distance_to(&self, other: &Self) -> f64 {
365        let dot = self.w * other.w + self.x * other.x + self.y * other.y + self.z * other.z;
366        dot.abs().clamp(-1.0, 1.0).acos()
367    }
368
369    /// Spherical linear interpolation (SLERP)
370    ///
371    /// Interpolate between self and other with parameter t ∈ `[0,1]`
372    /// Returns shortest path on S³
373    #[must_use]
374    pub fn slerp(&self, other: &Self, t: f64) -> Self {
375        let dot = self.w * other.w + self.x * other.x + self.y * other.y + self.z * other.z;
376
377        // If quaternions are close, use linear interpolation
378        if dot.abs() > 0.9995 {
379            return Self::new(
380                self.w + t * (other.w - self.w),
381                self.x + t * (other.x - self.x),
382                self.y + t * (other.y - self.y),
383                self.z + t * (other.z - self.z),
384            );
385        }
386
387        // Ensure shortest path
388        let (other_w, other_x, other_y, other_z) = if dot < 0.0 {
389            (-other.w, -other.x, -other.y, -other.z)
390        } else {
391            (other.w, other.x, other.y, other.z)
392        };
393
394        let theta = dot.abs().acos();
395        let sin_theta = theta.sin();
396
397        let a = ((1.0 - t) * theta).sin() / sin_theta;
398        let b = (t * theta).sin() / sin_theta;
399
400        Self::new(
401            a * self.w + b * other_w,
402            a * self.x + b * other_x,
403            a * self.y + b * other_y,
404            a * self.z + b * other_z,
405        )
406    }
407
408    /// Exponential map from Lie algebra 𝔰𝔲(2) to group SU(2)
409    ///
410    /// Given a vector v = (v₁, v₂, v₃) ∈ ℝ³ (Lie algebra element),
411    /// compute exp(v) as a unit quaternion.
412    ///
413    /// Formula: exp(v) = cos(|v|/2) + sin(|v|/2)·(v/|v|)
414    #[must_use]
415    pub fn exp(v: [f64; 3]) -> Self {
416        let norm = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
417
418        if norm < NORM_EPSILON {
419            return Self::identity();
420        }
421
422        let half_norm = norm / 2.0;
423        let cos_half = half_norm.cos();
424        let sin_half = half_norm.sin();
425
426        Self {
427            w: cos_half,
428            x: sin_half * v[0] / norm,
429            y: sin_half * v[1] / norm,
430            z: sin_half * v[2] / norm,
431        }
432    }
433
434    /// Logarithm map from group SU(2) to Lie algebra 𝔰𝔲(2)
435    ///
436    /// Returns vector v ∈ ℝ³ such that exp(v) = q
437    ///
438    /// Formula: log(q) = (θ/sin(θ/2))·(x, y, z) where θ = 2·arccos(w)
439    #[must_use]
440    pub fn log(&self) -> [f64; 3] {
441        let theta = 2.0 * self.w.acos();
442        let sin_half = (1.0 - self.w * self.w).sqrt();
443
444        if sin_half < IDENTITY_EPSILON {
445            // Near identity
446            return [0.0, 0.0, 0.0];
447        }
448
449        let scale = theta / sin_half;
450        [scale * self.x, scale * self.y, scale * self.z]
451    }
452
453    /// Convert to SU(2) matrix representation (for compatibility)
454    ///
455    /// Returns 2×2 complex matrix:
456    /// U = [[ w + ix,  -y + iz],
457    ///      [ y + iz,   w - ix]]
458    #[must_use]
459    pub fn to_matrix(&self) -> [[num_complex::Complex64; 2]; 2] {
460        use num_complex::Complex64;
461
462        [
463            [
464                Complex64::new(self.w, self.x),
465                Complex64::new(-self.y, self.z),
466            ],
467            [
468                Complex64::new(self.y, self.z),
469                Complex64::new(self.w, -self.x),
470            ],
471        ]
472    }
473
474    /// Create from SU(2) matrix
475    ///
476    /// Expects matrix [[α, -β*], [β, α*]] with |α|² + |β|² = 1
477    #[must_use]
478    pub fn from_matrix(matrix: [[num_complex::Complex64; 2]; 2]) -> Self {
479        // Extract α = a + ib from matrix[0][0]
480        let a = matrix[0][0].re;
481        let b = matrix[0][0].im;
482
483        // Extract β = c + id from matrix[1][0]
484        let c = matrix[1][0].re;
485        let d = matrix[1][0].im;
486
487        // q = a + bi + cj + dk
488        Self::new(a, b, c, d)
489    }
490
491    /// Act on a 3D vector by conjugation: v' = qvq*
492    ///
493    /// This is the rotation action of SU(2) on ℝ³
494    /// Identifies v = (x,y,z) with quaternion xi + yj + zk
495    ///
496    /// Uses the direct formula (Rodrigues) for efficiency:
497    /// v' = v + 2w(n×v) + 2(n×(n×v)) where n = (x,y,z)
498    #[must_use]
499    pub fn rotate_vector(&self, v: [f64; 3]) -> [f64; 3] {
500        let (w, qx, qy, qz) = (self.w, self.x, self.y, self.z);
501        let (vx, vy, vz) = (v[0], v[1], v[2]);
502
503        // Cross product: n × v
504        let cx = qy * vz - qz * vy;
505        let cy = qz * vx - qx * vz;
506        let cz = qx * vy - qy * vx;
507
508        // Cross product: n × (n × v)
509        let ccx = qy * cz - qz * cy;
510        let ccy = qz * cx - qx * cz;
511        let ccz = qx * cy - qy * cx;
512
513        // v' = v + 2w(n×v) + 2(n×(n×v))
514        [
515            vx + 2.0 * (w * cx + ccx),
516            vy + 2.0 * (w * cy + ccy),
517            vz + 2.0 * (w * cz + ccz),
518        ]
519    }
520
521    /// Verify this is approximately a unit quaternion
522    #[must_use]
523    pub fn verify_unit(&self, tolerance: f64) -> bool {
524        (self.norm_squared() - 1.0).abs() < tolerance
525    }
526
527    /// Construct the 3×3 rotation matrix corresponding to this unit quaternion.
528    ///
529    /// Returns the SO(3) matrix R such that R·v = `rotate_vector(v)` for all v ∈ ℝ³.
530    ///
531    /// # Lean Correspondence
532    ///
533    /// This is the Rust counterpart of `toRotationMatrix` in
534    /// `OrthogonalGroups.lean`. The Lean proofs establish:
535    /// - `toRotationMatrix_orthogonal_axiom`: R(q)ᵀ·R(q) = I
536    /// - `toRotationMatrix_det_axiom`: det(R(q)) = 1
537    /// - `toRotationMatrix_mul_axiom`: R(q₁·q₂) = R(q₁)·R(q₂)
538    /// - `toRotationMatrix_neg`: R(-q) = R(q)
539    #[must_use]
540    pub fn to_rotation_matrix(&self) -> [[f64; 3]; 3] {
541        let (w, x, y, z) = (self.w, self.x, self.y, self.z);
542        [
543            [
544                1.0 - 2.0 * (y * y + z * z),
545                2.0 * (x * y - w * z),
546                2.0 * (x * z + w * y),
547            ],
548            [
549                2.0 * (x * y + w * z),
550                1.0 - 2.0 * (x * x + z * z),
551                2.0 * (y * z - w * x),
552            ],
553            [
554                2.0 * (x * z - w * y),
555                2.0 * (y * z + w * x),
556                1.0 - 2.0 * (x * x + y * y),
557            ],
558        ]
559    }
560}
561
562// Quaternion multiplication: Hamilton product
563impl Mul for UnitQuaternion {
564    type Output = Self;
565
566    fn mul(self, rhs: Self) -> Self {
567        // (w₁ + x₁i + y₁j + z₁k)(w₂ + x₂i + y₂j + z₂k)
568        Self::new(
569            self.w * rhs.w - self.x * rhs.x - self.y * rhs.y - self.z * rhs.z,
570            self.w * rhs.x + self.x * rhs.w + self.y * rhs.z - self.z * rhs.y,
571            self.w * rhs.y - self.x * rhs.z + self.y * rhs.w + self.z * rhs.x,
572            self.w * rhs.z + self.x * rhs.y - self.y * rhs.x + self.z * rhs.w,
573        )
574    }
575}
576
577impl MulAssign for UnitQuaternion {
578    fn mul_assign(&mut self, rhs: Self) {
579        *self = *self * rhs;
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use std::f64::consts::PI;
587
588    #[test]
589    fn test_identity() {
590        let q = UnitQuaternion::identity();
591        assert_eq!(q.w, 1.0);
592        assert_eq!(q.x, 0.0);
593        assert_eq!(q.y, 0.0);
594        assert_eq!(q.z, 0.0);
595        assert!(q.verify_unit(1e-10));
596    }
597
598    #[test]
599    fn test_normalization() {
600        let q = UnitQuaternion::new(1.0, 2.0, 3.0, 4.0);
601        assert!((q.norm() - 1.0).abs() < 1e-10);
602        assert!(q.verify_unit(1e-10));
603    }
604
605    #[test]
606    fn test_axis_angle_roundtrip() {
607        let axis = [1.0, 2.0, 3.0];
608        let angle = PI / 3.0;
609
610        let q = UnitQuaternion::from_axis_angle(axis, angle);
611        let (axis_out, angle_out) = q.to_axis_angle();
612
613        // Normalize input axis for comparison
614        let axis_norm = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
615        let axis_normalized = [
616            axis[0] / axis_norm,
617            axis[1] / axis_norm,
618            axis[2] / axis_norm,
619        ];
620
621        assert!((angle - angle_out).abs() < 1e-10);
622        for i in 0..3 {
623            assert!((axis_normalized[i] - axis_out[i]).abs() < 1e-10);
624        }
625
626        println!(
627            "✓ Axis-angle roundtrip: axis=[{:.3},{:.3},{:.3}], angle={:.3}",
628            axis_out[0], axis_out[1], axis_out[2], angle_out
629        );
630    }
631
632    #[test]
633    fn test_rotation_x() {
634        let q = UnitQuaternion::rotation_x(PI / 2.0);
635
636        // Should rotate (0,1,0) to (0,0,1)
637        let v = [0.0, 1.0, 0.0];
638        let rotated = q.rotate_vector(v);
639
640        assert!(rotated[0].abs() < 1e-10);
641        assert!(rotated[1].abs() < 1e-10);
642        assert!((rotated[2] - 1.0).abs() < 1e-10);
643
644        println!(
645            "✓ Rotation X by π/2: (0,1,0) → ({:.3},{:.3},{:.3})",
646            rotated[0], rotated[1], rotated[2]
647        );
648    }
649
650    #[test]
651    fn test_quaternion_multiplication() {
652        let q1 = UnitQuaternion::rotation_x(PI / 4.0);
653        let q2 = UnitQuaternion::rotation_y(PI / 4.0);
654
655        let q3 = q1 * q2;
656
657        // Should still be unit quaternion
658        assert!(q3.verify_unit(1e-10));
659
660        // Composition of rotations
661        let v = [1.0, 0.0, 0.0];
662        let rotated_composed = q3.rotate_vector(v);
663        let rotated_separate = q1.rotate_vector(q2.rotate_vector(v));
664
665        for i in 0..3 {
666            assert!((rotated_composed[i] - rotated_separate[i]).abs() < 1e-10);
667        }
668
669        println!("✓ Quaternion multiplication preserves unitarity and composition");
670    }
671
672    #[test]
673    fn test_inverse() {
674        let q = UnitQuaternion::rotation_z(PI / 3.0);
675        let q_inv = q.inverse();
676
677        let product = q * q_inv;
678
679        assert!((product.w - 1.0).abs() < 1e-10);
680        assert!(product.x.abs() < 1e-10);
681        assert!(product.y.abs() < 1e-10);
682        assert!(product.z.abs() < 1e-10);
683
684        println!("✓ Inverse: q · q⁻¹ = identity");
685    }
686
687    #[test]
688    fn test_distance_to_identity() {
689        let q_identity = UnitQuaternion::identity();
690        assert!(q_identity.distance_to_identity() < 1e-10);
691
692        let q_pi = UnitQuaternion::rotation_x(PI);
693        assert!((q_pi.distance_to_identity() - PI).abs() < 1e-10);
694
695        let q_half_pi = UnitQuaternion::rotation_y(PI / 2.0);
696        assert!((q_half_pi.distance_to_identity() - PI / 2.0).abs() < 1e-10);
697
698        println!(
699            "✓ Distance to identity: d(I)=0, d(π)={:.3}, d(π/2)={:.3}",
700            q_pi.distance_to_identity(),
701            q_half_pi.distance_to_identity()
702        );
703    }
704
705    #[test]
706    fn test_exp_log_roundtrip() {
707        let v = [0.5, 0.3, 0.2];
708
709        let q = UnitQuaternion::exp(v);
710        let v_out = q.log();
711
712        for i in 0..3 {
713            assert!((v[i] - v_out[i]).abs() < 1e-10);
714        }
715
716        println!(
717            "✓ Exp-log roundtrip: v=[{:.3},{:.3},{:.3}]",
718            v[0], v[1], v[2]
719        );
720    }
721
722    #[test]
723    fn test_slerp() {
724        let q1 = UnitQuaternion::rotation_x(0.0);
725        let q2 = UnitQuaternion::rotation_x(PI / 2.0);
726
727        let q_mid = q1.slerp(&q2, 0.5);
728
729        // Should be rotation by π/4
730        assert!((q_mid.distance_to_identity() - PI / 4.0).abs() < 1e-10);
731
732        // At t=0, should be q1
733        let q_0 = q1.slerp(&q2, 0.0);
734        assert!(q_0.distance_to(&q1) < 1e-10);
735
736        // At t=1, should be q2
737        let q_1 = q1.slerp(&q2, 1.0);
738        assert!(q_1.distance_to(&q2) < 1e-10);
739
740        println!("✓ SLERP: smooth interpolation on S³");
741    }
742
743    #[test]
744    fn test_matrix_conversion() {
745        let q = UnitQuaternion::rotation_z(PI / 3.0);
746
747        let matrix = q.to_matrix();
748        let q_back = UnitQuaternion::from_matrix(matrix);
749
750        assert!((q.w - q_back.w).abs() < 1e-10);
751        assert!((q.x - q_back.x).abs() < 1e-10);
752        assert!((q.y - q_back.y).abs() < 1e-10);
753        assert!((q.z - q_back.z).abs() < 1e-10);
754
755        println!("✓ Matrix conversion roundtrip preserves quaternion");
756    }
757}