Skip to main content

symtropy_math/
rotor.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4use crate::bivector::Bivector;
5use crate::point::Point;
6use nalgebra::{SMatrix, SVector};
7
8/// A rotor — performs rotations in D dimensions.
9///
10/// Internally stores the rotation matrix. This is optimal for the hot path
11/// (applying rotations to many points) and avoids lossy rotor↔matrix round-trips.
12/// Construction uses geometric algebra (bivectors, plane-angle), but once built
13/// the matrix is the canonical representation.
14///
15/// Stack-allocated via `SMatrix<f64, D, D>`.
16#[derive(Clone, Debug)]
17pub struct Rotor<const D: usize> {
18    mat: SMatrix<f64, D, D>,
19}
20
21impl<const D: usize> Rotor<D> {
22    /// Identity rotor (no rotation).
23    pub fn identity() -> Self {
24        Self {
25            mat: SMatrix::identity(),
26        }
27    }
28
29    /// Rotation by `angle` radians in the plane defined by `plane` bivector.
30    ///
31    /// Positive angle rotates from the first axis toward the second axis
32    /// of the plane (e.g., `unit_plane(0,1)` with positive angle rotates x→y).
33    ///
34    /// Uses Rodrigues formula: M = I - sin(θ)*B̂ + (1-cos(θ))*B̂²
35    /// (The sign on sin is negative because our bivector convention has
36    /// B̂[i,j]=+1, B̂[j,i]=-1, producing clockwise rotation. The negation
37    /// makes positive angles go counterclockwise: axis_i → axis_j.)
38    pub fn from_plane_angle(plane: &Bivector<D>, angle: f64) -> Self {
39        let norm = plane.norm();
40        if norm < 1e-15 || angle.abs() < 1e-15 {
41            return Self::identity();
42        }
43
44        let b_hat = plane.to_matrix() / norm;
45        let b_hat_sq = b_hat * b_hat;
46        let mat = SMatrix::identity() - b_hat * angle.sin() + b_hat_sq * (1.0 - angle.cos());
47
48        Self { mat }
49    }
50
51    /// Rotation from unit vector `from` to unit vector `to`.
52    pub fn from_vectors(from: &SVector<f64, D>, to: &SVector<f64, D>) -> Self {
53        let dot = from.dot(to).clamp(-1.0, 1.0);
54        let angle = dot.acos();
55
56        if angle.abs() < 1e-14 {
57            return Self::identity();
58        }
59
60        // Exterior product (antisymmetric part)
61        let mut ext = SMatrix::<f64, D, D>::zeros();
62        for i in 0..D {
63            for j in 0..D {
64                ext[(i, j)] = from[i] * to[j] - to[i] * from[j];
65            }
66        }
67        let plane = Bivector::from_matrix(&ext);
68        Self::from_plane_angle(&plane, angle)
69    }
70
71    /// Direct construction from a rotation matrix (must be orthogonal, det=+1).
72    pub fn from_matrix(mat: SMatrix<f64, D, D>) -> Self {
73        Self { mat }
74    }
75
76    /// Get the rotation matrix.
77    #[inline]
78    pub fn to_matrix(&self) -> &SMatrix<f64, D, D> {
79        &self.mat
80    }
81
82    /// Reverse (inverse): R† = R^T for rotation matrices.
83    #[inline]
84    pub fn reverse(&self) -> Self {
85        Self {
86            mat: self.mat.transpose(),
87        }
88    }
89
90    /// Compose: apply `other` first, then `self`.
91    #[inline]
92    pub fn compose(&self, other: &Self) -> Self {
93        Self {
94            mat: self.mat * other.mat,
95        }
96    }
97
98    /// Rotate a point.
99    #[inline]
100    pub fn rotate_point(&self, point: &Point<D>) -> Point<D> {
101        Point(self.mat * point.0)
102    }
103
104    /// Rotate a vector.
105    #[inline]
106    pub fn rotate_vector(&self, v: &SVector<f64, D>) -> SVector<f64, D> {
107        self.mat * v
108    }
109
110    /// Slerp: identity at t=0, this rotation at t=1.
111    ///
112    /// Extracts the rotation angle and plane from the matrix,
113    /// then builds a partial rotation.
114    pub fn slerp(&self, t: f64) -> Self {
115        if t <= 1e-14 {
116            return Self::identity();
117        }
118        if (t - 1.0).abs() <= 1e-14 {
119            return self.clone();
120        }
121
122        // The antisymmetric part of M is -sin(θ)*B̂ (due to our sign convention)
123        let antisym = (self.mat - self.mat.transpose()) / 2.0;
124        // Negate to get sin(θ)*B̂, then extract plane
125        let plane = Bivector::from_matrix(&(-antisym));
126        let sin_theta = plane.norm();
127
128        if sin_theta < 1e-14 {
129            return Self::identity();
130        }
131
132        let trace = self.mat.trace();
133        let cos_theta = ((trace - (D as f64 - 2.0)) / 2.0).clamp(-1.0, 1.0);
134        let theta = f64::atan2(sin_theta, cos_theta);
135
136        Self::from_plane_angle(&plane, theta * t)
137    }
138}
139
140impl<const D: usize> Default for Rotor<D> {
141    fn default() -> Self {
142        Self::identity()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
150
151    fn approx_eq(a: f64, b: f64) -> bool {
152        (a - b).abs() < 1e-10
153    }
154
155    fn approx_vec<const N: usize>(a: &SVector<f64, N>, b: &SVector<f64, N>) -> bool {
156        a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-10)
157    }
158
159    #[test]
160    fn identity_preserves_point() {
161        let r = Rotor::<4>::identity();
162        let p = Point::new([1.0, 2.0, 3.0, 4.0]);
163        assert_eq!(r.rotate_point(&p), p);
164    }
165
166    #[test]
167    fn rotation_2d_90() {
168        let plane = Bivector::<2>::unit_plane(0, 1);
169        let r = Rotor::from_plane_angle(&plane, FRAC_PI_2);
170        let p = Point::new([1.0, 0.0]);
171        let q = r.rotate_point(&p);
172        assert!(approx_eq(q.coord(0), 0.0));
173        assert!(approx_eq(q.coord(1), 1.0));
174    }
175
176    #[test]
177    fn rotation_3d_xy_plane() {
178        let plane = Bivector::<3>::unit_plane(0, 1);
179        let r = Rotor::from_plane_angle(&plane, FRAC_PI_2);
180        let p = Point::new([1.0, 0.0, 0.0]);
181        let q = r.rotate_point(&p);
182        assert!(approx_eq(q.coord(0), 0.0));
183        assert!(approx_eq(q.coord(1), 1.0));
184        assert!(approx_eq(q.coord(2), 0.0));
185    }
186
187    #[test]
188    fn preserves_distance() {
189        let plane = Bivector::<4>::unit_plane(1, 3);
190        let r = Rotor::from_plane_angle(&plane, 1.0);
191        let p = Point::new([1.0, 2.0, 3.0, 4.0]);
192        let q = r.rotate_point(&p);
193        assert!(approx_eq(p.0.norm(), q.0.norm()));
194    }
195
196    #[test]
197    fn full_rotation() {
198        let plane = Bivector::<3>::unit_plane(0, 2);
199        let r = Rotor::from_plane_angle(&plane, 2.0 * PI);
200        let p = Point::new([1.0, 2.0, 3.0]);
201        let q = r.rotate_point(&p);
202        assert!(approx_vec(&p.0, &q.0));
203    }
204
205    #[test]
206    fn compose_additive() {
207        let plane = Bivector::<3>::unit_plane(0, 1);
208        let r1 = Rotor::from_plane_angle(&plane, FRAC_PI_4);
209        let r2 = Rotor::from_plane_angle(&plane, FRAC_PI_4);
210        let composed = r1.compose(&r2);
211        let direct = Rotor::from_plane_angle(&plane, FRAC_PI_2);
212
213        let p = Point::new([1.0, 0.0, 0.0]);
214        assert!(approx_vec(
215            &composed.rotate_point(&p).0,
216            &direct.rotate_point(&p).0
217        ));
218    }
219
220    #[test]
221    fn reverse_undoes() {
222        let plane = Bivector::<4>::unit_plane(0, 3);
223        let r = Rotor::from_plane_angle(&plane, 1.7);
224        let p = Point::new([1.0, 2.0, 3.0, 4.0]);
225        let q = r.rotate_point(&p);
226        let back = r.reverse().rotate_point(&q);
227        assert!(approx_vec(&p.0, &back.0));
228    }
229
230    #[test]
231    fn from_vectors_3d() {
232        let from = SVector::from([1.0, 0.0, 0.0]);
233        let to = SVector::from([0.0, 1.0, 0.0]);
234        let r = Rotor::<3>::from_vectors(&from, &to);
235        assert!(approx_vec(&r.rotate_vector(&from), &to));
236    }
237
238    #[test]
239    fn from_vectors_4d() {
240        let from = SVector::from([1.0, 0.0, 0.0, 0.0]);
241        let to = SVector::from([0.0, 0.0, 0.0, 1.0]);
242        let r = Rotor::<4>::from_vectors(&from, &to);
243        assert!(approx_vec(&r.rotate_vector(&from), &to));
244    }
245
246    #[test]
247    fn orthogonal_matrix() {
248        let plane = Bivector::<4>::unit_plane(0, 2);
249        let r = Rotor::from_plane_angle(&plane, 1.23);
250        let mat = r.to_matrix();
251        let product = mat * mat.transpose();
252        let identity = SMatrix::<f64, 4, 4>::identity();
253        for i in 0..4 {
254            for j in 0..4 {
255                assert!(
256                    (product[(i, j)] - identity[(i, j)]).abs() < 1e-10,
257                    "not orthogonal at ({i},{j})"
258                );
259            }
260        }
261    }
262
263    #[test]
264    fn det_is_one() {
265        let plane = Bivector::<3>::unit_plane(0, 1);
266        let r = Rotor::from_plane_angle(&plane, 0.5);
267        assert!(approx_eq(r.to_matrix().determinant(), 1.0));
268    }
269
270    #[test]
271    fn slerp_endpoints() {
272        let plane = Bivector::<3>::unit_plane(0, 1);
273        let r = Rotor::from_plane_angle(&plane, 1.0);
274        let p = Point::new([1.0, 0.0, 0.0]);
275
276        let at_0 = r.slerp(0.0).rotate_point(&p);
277        assert!(approx_vec(&at_0.0, &p.0));
278
279        let at_1 = r.slerp(1.0).rotate_point(&p);
280        assert!(approx_vec(&at_1.0, &r.rotate_point(&p).0));
281    }
282
283    #[test]
284    fn compose_different_planes() {
285        // Compose rotations in different planes
286        let r1 = Rotor::from_plane_angle(&Bivector::<4>::unit_plane(0, 1), 0.3);
287        let r2 = Rotor::from_plane_angle(&Bivector::<4>::unit_plane(2, 3), 0.5);
288        let composed = r2.compose(&r1);
289
290        let p = Point::new([1.0, 1.0, 1.0, 1.0]);
291        let sequential = r2.rotate_point(&r1.rotate_point(&p));
292        let via_compose = composed.rotate_point(&p);
293        assert!(approx_vec(&sequential.0, &via_compose.0));
294    }
295
296    #[test]
297    fn slerp_midpoint() {
298        let plane = Bivector::<3>::unit_plane(0, 1);
299        let r = Rotor::from_plane_angle(&plane, FRAC_PI_2);
300        let half = r.slerp(0.5);
301        let expected = Rotor::from_plane_angle(&plane, FRAC_PI_4);
302
303        let p = Point::new([1.0, 0.0, 0.0]);
304        assert!(approx_vec(
305            &half.rotate_point(&p).0,
306            &expected.rotate_point(&p).0
307        ));
308    }
309}