Skip to main content

rigidity_core/lie/
so3.rs

1//! The rotation group `SO(3)` and its Lie algebra `so(3)`.
2
3use nalgebra::{Matrix3, Vector3};
4use std::f64::consts::PI;
5
6use super::series;
7
8/// A rotation of three-dimensional space.
9///
10/// Stored as a 3×3 matrix. Every constructor except
11/// [`from_matrix_unchecked`](So3::from_matrix_unchecked) guarantees
12/// orthogonality to within rounding error.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct So3 {
15    matrix: Matrix3<f64>,
16}
17
18impl So3 {
19    /// The identity rotation.
20    pub fn identity() -> Self {
21        Self {
22            matrix: Matrix3::identity(),
23        }
24    }
25
26    /// Wraps a matrix without checking orthogonality.
27    ///
28    /// The caller is responsible for the matrix belonging to `SO(3)`.
29    pub fn from_matrix_unchecked(matrix: Matrix3<f64>) -> Self {
30        Self { matrix }
31    }
32
33    /// The rotation matrix.
34    pub fn matrix(&self) -> &Matrix3<f64> {
35        &self.matrix
36    }
37
38    /// The exponential map `so(3) → SO(3)`, i.e. the Rodrigues formula.
39    ///
40    /// `exp(φ) = I + (sin θ / θ) φ^ + ((1 − cos θ) / θ²) (φ^)²` with
41    /// `θ = |φ|`. For small θ the coefficients come from Taylor series,
42    /// because the trigonometric form suffers catastrophic cancellation
43    /// there.
44    pub fn exp(phi: &Vector3<f64>) -> Self {
45        let theta = phi.norm();
46        let hat_phi = hat(phi);
47        let matrix = Matrix3::identity()
48            + hat_phi * series::sin_over_theta(theta)
49            + hat_phi * hat_phi * series::one_minus_cos_over_theta_sq(theta);
50        Self { matrix }
51    }
52
53    /// The logarithm `SO(3) → so(3)`; the result has norm in `[0, π]`.
54    ///
55    /// Three branches:
56    /// 1. θ < `THETA_SMALL` — the series for `θ / sin θ`;
57    /// 2. the middle range — the direct formula via the skew part;
58    /// 3. θ near π — the axis is recovered from the symmetric part, since
59    ///    the skew part degenerates there: `R − Rᵀ → 0`.
60    ///
61    /// At θ = π the result is defined only up to sign: `exp(πa)` and
62    /// `exp(−πa)` are the same matrix.
63    pub fn log(&self) -> Vector3<f64> {
64        let m = &self.matrix;
65        // vee(R − Rᵀ)/2 = sin(θ)·a
66        let skew = Vector3::new(
67            m[(2, 1)] - m[(1, 2)],
68            m[(0, 2)] - m[(2, 0)],
69            m[(1, 0)] - m[(0, 1)],
70        ) * 0.5;
71
72        let sin_theta = skew.norm();
73        let cos_theta = 0.5 * (m.trace() - 1.0);
74        // atan2 is stable across the whole range, unlike acos near ±1.
75        let theta = sin_theta.atan2(cos_theta);
76
77        if theta < series::THETA_SMALL {
78            skew * series::theta_over_sin_theta(theta)
79        } else if theta < PI - series::THETA_NEAR_PI {
80            skew * (theta / sin_theta)
81        } else {
82            self.log_near_pi(theta, cos_theta, &skew)
83        }
84    }
85
86    /// The θ ≈ π branch: `R + Rᵀ = 2cos θ·I + 2(1 − cos θ)·a aᵀ` yields the
87    /// outer product of the axis, and from it the axis itself.
88    fn log_near_pi(&self, theta: f64, cos_theta: f64, skew: &Vector3<f64>) -> Vector3<f64> {
89        let m = &self.matrix;
90        let outer = (m + m.transpose() - Matrix3::identity() * (2.0 * cos_theta))
91            / (2.0 * (1.0 - cos_theta));
92
93        // The column with the largest diagonal entry: its norm is at least
94        // 1/√3, so dividing by it is safe.
95        let mut best = 0;
96        for i in 1..3 {
97            if outer[(i, i)] > outer[(best, best)] {
98                best = i;
99            }
100        }
101        let mut axis = outer.column(best) / outer[(best, best)].max(0.0).sqrt();
102
103        // The sign comes from the skew part. At θ = π that part degenerates,
104        // but there both signs produce the same matrix.
105        if axis.dot(skew) < 0.0 {
106            axis = -axis;
107        }
108        axis * theta
109    }
110
111    /// The inverse rotation.
112    pub fn inverse(&self) -> Self {
113        Self {
114            matrix: self.matrix.transpose(),
115        }
116    }
117
118    /// The adjoint representation. For `SO(3)` it is the matrix itself.
119    pub fn adjoint(&self) -> Matrix3<f64> {
120        self.matrix
121    }
122}
123
124impl std::ops::Mul for So3 {
125    type Output = So3;
126    fn mul(self, rhs: So3) -> So3 {
127        So3 {
128            matrix: self.matrix * rhs.matrix,
129        }
130    }
131}
132
133impl std::ops::Mul<Vector3<f64>> for So3 {
134    type Output = Vector3<f64>;
135    fn mul(self, rhs: Vector3<f64>) -> Vector3<f64> {
136        self.matrix * rhs
137    }
138}
139
140impl Default for So3 {
141    fn default() -> Self {
142        Self::identity()
143    }
144}
145
146/// The skew-symmetric matrix of a vector: `hat(v) w = v × w`.
147pub fn hat(v: &Vector3<f64>) -> Matrix3<f64> {
148    Matrix3::new(0.0, -v.z, v.y, v.z, 0.0, -v.x, -v.y, v.x, 0.0)
149}
150
151/// The inverse of [`hat`]: recovers the vector from a skew-symmetric matrix.
152pub fn vee(m: &Matrix3<f64>) -> Vector3<f64> {
153    Vector3::new(m[(2, 1)], m[(0, 2)], m[(1, 0)])
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn hat_is_cross_product() {
162        let a = Vector3::new(1.0, 2.0, 3.0);
163        let b = Vector3::new(-4.0, 5.0, 6.0);
164        assert!((hat(&a) * b - a.cross(&b)).norm() < 1e-15);
165    }
166
167    #[test]
168    fn vee_inverts_hat() {
169        let v = Vector3::new(0.3, -1.7, 2.2);
170        assert!((vee(&hat(&v)) - v).norm() < 1e-15);
171    }
172
173    /// A dedicated check of the θ ≈ π branch. That branch is the one most
174    /// often implemented wrongly, and it fails by quietly returning an
175    /// almost-correct answer.
176    #[test]
177    fn log_near_pi_is_exact() {
178        for offset in [0.0, 1e-12, 1e-8, 1e-4, 5e-3] {
179            let theta = PI - offset;
180            let axis = Vector3::new(1.0, -2.0, 0.5).normalize();
181            let phi = axis * theta;
182            let recovered = So3::exp(&phi).log();
183            // At θ = π the sign is undefined, so compare up to it.
184            let err = (recovered - phi).norm().min((recovered + phi).norm());
185            assert!(err < 1e-12, "θ = π − {offset:e}: error {err:.3e}");
186        }
187    }
188
189    /// A rotation by exactly π.
190    #[test]
191    fn log_at_exactly_pi() {
192        let axis = Vector3::new(0.0, 0.0, 1.0);
193        let recovered = So3::exp(&(axis * PI)).log();
194        assert!((recovered.norm() - PI).abs() < 1e-12);
195        assert!(recovered.normalize().cross(&axis).norm() < 1e-12);
196    }
197}