Skip to main content

ogeom_math/
quaternion.rs

1//! Unit quaternions for rotation.
2//!
3//! Preferred over matrices wherever rotations are *composed* or *interpolated*:
4//! repeated matrix products drift away from orthonormality and have to be
5//! re-orthonormalized, while a quaternion only needs renormalizing, and there is
6//! no meaningful way to interpolate two rotation matrices directly.
7//!
8//! [`Quaternion`] is not constrained to unit length by construction (the
9//! arithmetic needs unnormalized intermediates), but every rotation operation
10//! either requires or restores unit length, and says which in its
11//! documentation.
12
13use core::ops::{Add, Mul, Neg, Sub};
14
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16
17use crate::{Direction, Matrix3, Vector};
18
19/// How far a matrix may stray from orthonormal and still be accepted as a
20/// rotation. Dimensionless, and generous enough to admit a matrix assembled
21/// from a chain of rotations without admitting a scaling.
22const ORTHONORMAL_EPS: f64 = 1e-10;
23
24/// A quaternion `w + xi + yj + zk`.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Quaternion {
27    /// Scalar part.
28    pub w: f64,
29    /// `i` coefficient.
30    pub x: f64,
31    /// `j` coefficient.
32    pub y: f64,
33    /// `k` coefficient.
34    pub z: f64,
35}
36
37impl Default for Quaternion {
38    fn default() -> Self {
39        Self::IDENTITY
40    }
41}
42
43impl Quaternion {
44    /// The identity rotation.
45    pub const IDENTITY: Self = Self::new(1.0, 0.0, 0.0, 0.0);
46
47    /// From components.
48    #[must_use]
49    pub const fn new(w: f64, x: f64, y: f64, z: f64) -> Self {
50        Self { w, x, y, z }
51    }
52
53    /// The rotation of `angle` radians about `axis`, right-handed.
54    #[must_use]
55    pub fn from_axis_angle(axis: Direction, angle: f64) -> Self {
56        let (s, c) = (angle * 0.5).sin_cos();
57        Self::new(c, axis.x() * s, axis.y() * s, axis.z() * s)
58    }
59
60    /// The shortest rotation taking `from` to `to`.
61    ///
62    /// # Errors
63    ///
64    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the two are
65    /// antiparallel: infinitely many shortest rotations exist and picking one
66    /// arbitrarily would make the result depend on unobservable rounding.
67    pub fn between(from: Direction, to: Direction, tol: Tolerances) -> OgeomResult<Self> {
68        let d = from.dot(to);
69        if d < -1.0 + tol.confusion() {
70            ogeom_bail!(
71                Construction,
72                "rotation between antiparallel directions is not unique"
73            );
74        }
75        let axis = from.cross_vector(to);
76        // w = 1 + cos(theta), (x,y,z) = sin(theta) * axis. Normalizing this
77        // halves the angle, which is what the quaternion needs.
78        Self::new(1.0 + d, axis.x, axis.y, axis.z).normalized(tol)
79    }
80
81    /// From a rotation matrix.
82    ///
83    /// Uses Shepperd's method: pick the largest of the four possible divisors so
84    /// the division is never by something near zero. The naive `w`-first
85    /// formulation loses precision for rotations near π, where `w → 0`.
86    ///
87    /// # Errors
88    ///
89    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `m` is not
90    /// orthonormal with determinant `+1`.
91    pub fn from_matrix(m: &Matrix3, tol: Tolerances) -> OgeomResult<Self> {
92        if !m.is_orthonormal(ORTHONORMAL_EPS) {
93            ogeom_bail!(Construction, "rotation matrix is not orthonormal");
94        }
95        if m.determinant() < 0.0 {
96            ogeom_bail!(Construction, "matrix is a reflection, not a rotation");
97        }
98        let r = &m.rows;
99        let trace = m.trace();
100        let q = if trace > 0.0 {
101            let s = (trace + 1.0).sqrt() * 2.0;
102            Self::new(
103                0.25 * s,
104                (r[2][1] - r[1][2]) / s,
105                (r[0][2] - r[2][0]) / s,
106                (r[1][0] - r[0][1]) / s,
107            )
108        } else if r[0][0] > r[1][1] && r[0][0] > r[2][2] {
109            let s = (1.0 + r[0][0] - r[1][1] - r[2][2]).sqrt() * 2.0;
110            Self::new(
111                (r[2][1] - r[1][2]) / s,
112                0.25 * s,
113                (r[0][1] + r[1][0]) / s,
114                (r[0][2] + r[2][0]) / s,
115            )
116        } else if r[1][1] > r[2][2] {
117            let s = (1.0 - r[0][0] + r[1][1] - r[2][2]).sqrt() * 2.0;
118            Self::new(
119                (r[0][2] - r[2][0]) / s,
120                (r[0][1] + r[1][0]) / s,
121                0.25 * s,
122                (r[1][2] + r[2][1]) / s,
123            )
124        } else {
125            let s = (1.0 - r[0][0] - r[1][1] + r[2][2]).sqrt() * 2.0;
126            Self::new(
127                (r[1][0] - r[0][1]) / s,
128                (r[0][2] + r[2][0]) / s,
129                (r[1][2] + r[2][1]) / s,
130                0.25 * s,
131            )
132        };
133        q.normalized(tol)
134    }
135
136    /// Squared norm.
137    #[must_use]
138    pub fn square_norm(self) -> f64 {
139        self.w.mul_add(
140            self.w,
141            self.x
142                .mul_add(self.x, self.y.mul_add(self.y, self.z * self.z)),
143        )
144    }
145
146    /// Norm.
147    #[must_use]
148    pub fn norm(self) -> f64 {
149        self.square_norm().sqrt()
150    }
151
152    /// This quaternion scaled to unit norm.
153    ///
154    /// # Errors
155    ///
156    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the norm is
157    /// below `tol.confusion()` or any component is non-finite.
158    pub fn normalized(self, tol: Tolerances) -> OgeomResult<Self> {
159        if !self.is_finite() {
160            ogeom_bail!(Construction, "cannot normalize a non-finite quaternion");
161        }
162        let n = self.norm();
163        if n <= tol.confusion() {
164            ogeom_bail!(Construction, "cannot normalize a quaternion of norm {n}");
165        }
166        Ok(Self::new(self.w / n, self.x / n, self.y / n, self.z / n))
167    }
168
169    /// Whether every component is finite.
170    #[must_use]
171    pub fn is_finite(self) -> bool {
172        self.w.is_finite() && self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
173    }
174
175    /// The conjugate. For a unit quaternion this is the inverse rotation.
176    #[must_use]
177    pub const fn conjugate(self) -> Self {
178        Self::new(self.w, -self.x, -self.y, -self.z)
179    }
180
181    /// The multiplicative inverse.
182    ///
183    /// # Errors
184    ///
185    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the norm is
186    /// degenerate.
187    pub fn inverse(self, tol: Tolerances) -> OgeomResult<Self> {
188        let n2 = self.square_norm();
189        if n2 <= tol.confusion() * tol.confusion() {
190            ogeom_bail!(Numeric, "quaternion of norm {} has no inverse", n2.sqrt());
191        }
192        let c = self.conjugate();
193        Ok(Self::new(c.w / n2, c.x / n2, c.y / n2, c.z / n2))
194    }
195
196    /// Apply this rotation to a vector. Assumes unit norm.
197    ///
198    /// Evaluated as `v + 2w(u × v) + 2(u × (u × v))` with `u` the vector part,
199    /// which costs fewer operations than building the matrix and avoids the
200    /// intermediate rounding of a full quaternion sandwich product.
201    #[must_use]
202    pub fn rotate(self, v: Vector) -> Vector {
203        let u = Vector::new(self.x, self.y, self.z);
204        let uv = u.cross(v);
205        v + uv * (2.0 * self.w) + u.cross(uv) * 2.0
206    }
207
208    /// The equivalent rotation matrix. Assumes unit norm.
209    #[must_use]
210    pub fn to_matrix(self) -> Matrix3 {
211        let (w, x, y, z) = (self.w, self.x, self.y, self.z);
212        let (xx, yy, zz) = (x * x, y * y, z * z);
213        let (xy, xz, yz) = (x * y, x * z, y * z);
214        let (wx, wy, wz) = (w * x, w * y, w * z);
215        Matrix3::new([
216            [
217                (-2.0f64).mul_add(yy + zz, 1.0),
218                2.0 * (xy - wz),
219                2.0 * (xz + wy),
220            ],
221            [
222                2.0 * (xy + wz),
223                (-2.0f64).mul_add(xx + zz, 1.0),
224                2.0 * (yz - wx),
225            ],
226            [
227                2.0 * (xz - wy),
228                2.0 * (yz + wx),
229                (-2.0f64).mul_add(xx + yy, 1.0),
230            ],
231        ])
232    }
233
234    /// The rotation axis and angle. Assumes unit norm; angle is in `[0, π]`.
235    ///
236    /// # Errors
237    ///
238    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the rotation
239    /// is the identity, where the axis is undefined.
240    pub fn to_axis_angle(self, tol: Tolerances) -> OgeomResult<(Direction, f64)> {
241        let u = Vector::new(self.x, self.y, self.z);
242        let sin_half = u.magnitude();
243        if sin_half <= tol.confusion() {
244            ogeom_bail!(Construction, "identity rotation has no defined axis");
245        }
246        // atan2 rather than acos(w) or asin: accurate across the whole range,
247        // including angles near 0 and near pi.
248        Ok((Direction::new(u, tol)?, 2.0 * sin_half.atan2(self.w)))
249    }
250
251    /// Dot product, as 4-vectors.
252    #[must_use]
253    pub fn dot(self, o: Self) -> f64 {
254        self.w
255            .mul_add(o.w, self.x.mul_add(o.x, self.y.mul_add(o.y, self.z * o.z)))
256    }
257
258    /// Spherical linear interpolation, `t = 0` giving `self`.
259    ///
260    /// Takes the shorter of the two arcs, and falls back to normalized linear
261    /// interpolation when the two are nearly coincident, where the `sin` in the
262    /// denominator of the spherical form goes to zero.
263    ///
264    /// # Errors
265    ///
266    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
267    /// operand cannot be normalized.
268    pub fn slerp(self, other: Self, t: f64, tol: Tolerances) -> OgeomResult<Self> {
269        let a = self.normalized(tol)?;
270        let mut b = other.normalized(tol)?;
271        let mut cos = a.dot(b);
272        // q and -q are the same rotation; pick the representative that gives the
273        // shorter path.
274        if cos < 0.0 {
275            b = -b;
276            cos = -cos;
277        }
278        if cos > 1.0 - 1e-9 {
279            return (a * (1.0 - t) + b * t).normalized(tol);
280        }
281        let theta = cos.clamp(-1.0, 1.0).acos();
282        let sin = theta.sin();
283        let wa = ((1.0 - t) * theta).sin() / sin;
284        let wb = (t * theta).sin() / sin;
285        (a * wa + b * wb).normalized(tol)
286    }
287
288    /// The rotation angle to `other`, in `[0, π]`. Assumes unit norms.
289    #[must_use]
290    pub fn angle_to(self, other: Self) -> f64 {
291        let cos = self.dot(other).abs().clamp(-1.0, 1.0);
292        2.0 * cos.acos()
293    }
294}
295
296impl Mul for Quaternion {
297    type Output = Self;
298    /// Hamilton product. `(a * b)` rotates by `b` first, then by `a`.
299    fn mul(self, o: Self) -> Self {
300        Self::new(
301            self.w
302                .mul_add(o.w, -self.x.mul_add(o.x, self.y.mul_add(o.y, self.z * o.z))),
303            self.w.mul_add(
304                o.x,
305                self.x.mul_add(o.w, self.y.mul_add(o.z, -(self.z * o.y))),
306            ),
307            self.w.mul_add(
308                o.y,
309                self.y.mul_add(o.w, self.z.mul_add(o.x, -(self.x * o.z))),
310            ),
311            self.w.mul_add(
312                o.z,
313                self.z.mul_add(o.w, self.x.mul_add(o.y, -(self.y * o.x))),
314            ),
315        )
316    }
317}
318
319impl Mul<f64> for Quaternion {
320    type Output = Self;
321    fn mul(self, s: f64) -> Self {
322        Self::new(self.w * s, self.x * s, self.y * s, self.z * s)
323    }
324}
325
326impl Add for Quaternion {
327    type Output = Self;
328    fn add(self, o: Self) -> Self {
329        Self::new(self.w + o.w, self.x + o.x, self.y + o.y, self.z + o.z)
330    }
331}
332
333impl Sub for Quaternion {
334    type Output = Self;
335    fn sub(self, o: Self) -> Self {
336        Self::new(self.w - o.w, self.x - o.x, self.y - o.y, self.z - o.z)
337    }
338}
339
340impl Neg for Quaternion {
341    type Output = Self;
342    fn neg(self) -> Self {
343        Self::new(-self.w, -self.x, -self.y, -self.z)
344    }
345}
346
347#[cfg(test)]
348#[allow(clippy::unwrap_used)]
349mod tests {
350    use super::*;
351    use approx::assert_relative_eq;
352
353    const T: Tolerances = Tolerances::millimetres();
354
355    fn axis() -> Direction {
356        Direction::from_coords(1.0, 2.0, -1.0, T).unwrap()
357    }
358
359    #[test]
360    fn rotate_agrees_with_the_matrix_form() {
361        let q = Quaternion::from_axis_angle(axis(), 1.234);
362        let m = q.to_matrix();
363        for v in [
364            Vector::X,
365            Vector::new(3.0, -2.0, 5.0),
366            Vector::new(-1e3, 1e-3, 7.0),
367        ] {
368            assert!(
369                q.rotate(v).is_equal(m * v, T),
370                "quaternion and matrix disagree"
371            );
372        }
373    }
374
375    #[test]
376    fn rotation_preserves_length_and_is_invertible() {
377        let q = Quaternion::from_axis_angle(axis(), 2.1);
378        let v = Vector::new(1.0, 2.0, 3.0);
379        assert_relative_eq!(q.rotate(v).magnitude(), v.magnitude(), epsilon = 1e-14);
380        assert!(q.conjugate().rotate(q.rotate(v)).is_equal(v, T));
381        assert!(q.inverse(T).unwrap().rotate(q.rotate(v)).is_equal(v, T));
382    }
383
384    #[test]
385    fn composition_applies_right_to_left() {
386        let a = Quaternion::from_axis_angle(Direction::Z, core::f64::consts::FRAC_PI_2);
387        let b = Quaternion::from_axis_angle(Direction::X, core::f64::consts::FRAC_PI_2);
388        let v = Vector::Y;
389        assert!((a * b).rotate(v).is_equal(a.rotate(b.rotate(v)), T));
390    }
391
392    #[test]
393    fn matrix_round_trip_is_exact_near_pi() {
394        // The naive w-first extraction divides by something approaching zero
395        // here. Shepperd's largest-divisor choice does not.
396        // Including angles right at pi, where w -> 0 and the naive extraction
397        // divides by something vanishing.
398        let near_pi = core::f64::consts::PI - 1e-8;
399        for angle in [0.0_f64, 0.1, 1.0, 3.0, near_pi, core::f64::consts::PI] {
400            let q = Quaternion::from_axis_angle(axis(), angle);
401            let back = Quaternion::from_matrix(&q.to_matrix(), T).unwrap();
402            // q and -q are the same rotation, so compare the rotations.
403            assert!(
404                back.to_matrix().is_equal(&q.to_matrix(), 1e-12),
405                "round trip failed at angle {angle}"
406            );
407        }
408    }
409
410    #[test]
411    fn reflections_are_rejected_as_rotations() {
412        let m = Matrix3::reflection(Direction::Z);
413        assert!(Quaternion::from_matrix(&m, T).is_err());
414        assert!(Quaternion::from_matrix(&Matrix3::scaling(2.0), T).is_err());
415    }
416
417    #[test]
418    fn axis_angle_round_trip() {
419        let a = axis();
420        for angle in [0.01_f64, 0.5, 1.5, 3.0] {
421            let q = Quaternion::from_axis_angle(a, angle);
422            let (back_axis, back_angle) = q.to_axis_angle(T).unwrap();
423            assert_relative_eq!(back_angle, angle, epsilon = 1e-12);
424            assert!(back_axis.is_equal(a, T));
425        }
426        assert!(Quaternion::IDENTITY.to_axis_angle(T).is_err());
427    }
428
429    #[test]
430    fn between_gives_the_shortest_rotation() {
431        let from = Direction::X;
432        let to = Direction::from_coords(1.0, 1.0, 0.0, T).unwrap();
433        let q = Quaternion::between(from, to, T).unwrap();
434        assert!(
435            Direction::new(q.rotate(from.vector()), T)
436                .unwrap()
437                .is_equal(to, T)
438        );
439        let (_, angle) = q.to_axis_angle(T).unwrap();
440        assert_relative_eq!(angle, core::f64::consts::FRAC_PI_4, epsilon = 1e-12);
441    }
442
443    #[test]
444    fn between_refuses_the_ambiguous_antiparallel_case() {
445        assert!(Quaternion::between(Direction::X, -Direction::X, T).is_err());
446        // Identical directions are fine: the answer is the identity.
447        let q = Quaternion::between(Direction::X, Direction::X, T).unwrap();
448        assert!(q.rotate(Vector::Y).is_equal(Vector::Y, T));
449    }
450
451    #[test]
452    fn slerp_hits_the_endpoints_and_stays_unit() {
453        let a = Quaternion::from_axis_angle(Direction::Z, 0.2);
454        let b = Quaternion::from_axis_angle(Direction::X, 1.9);
455        assert!(
456            a.slerp(b, 0.0, T)
457                .unwrap()
458                .to_matrix()
459                .is_equal(&a.to_matrix(), 1e-12)
460        );
461        assert!(
462            a.slerp(b, 1.0, T)
463                .unwrap()
464                .to_matrix()
465                .is_equal(&b.to_matrix(), 1e-12)
466        );
467        for i in 0..=10 {
468            let q = a.slerp(b, f64::from(i) / 10.0, T).unwrap();
469            assert_relative_eq!(q.norm(), 1.0, epsilon = 1e-14);
470        }
471    }
472
473    #[test]
474    fn slerp_takes_the_short_way_round() {
475        let a = Quaternion::from_axis_angle(Direction::Z, 0.0);
476        // Same rotation, opposite representative. Naive slerp would sweep the
477        // long way; the sign correction must prevent that.
478        let b = -Quaternion::from_axis_angle(Direction::Z, 0.4);
479        let mid = a.slerp(b, 0.5, T).unwrap();
480        let expected = Quaternion::from_axis_angle(Direction::Z, 0.2);
481        assert!(mid.to_matrix().is_equal(&expected.to_matrix(), 1e-12));
482    }
483
484    #[test]
485    fn slerp_survives_nearly_identical_inputs() {
486        let a = Quaternion::from_axis_angle(Direction::Z, 1.0);
487        let b = Quaternion::from_axis_angle(Direction::Z, 1.0 + 1e-12);
488        let mid = a.slerp(b, 0.5, T).unwrap();
489        assert!(mid.is_finite());
490        assert_relative_eq!(mid.norm(), 1.0, epsilon = 1e-14);
491    }
492
493    #[test]
494    fn degenerate_quaternions_are_refused() {
495        let zero = Quaternion::new(0.0, 0.0, 0.0, 0.0);
496        assert!(zero.normalized(T).is_err());
497        assert!(zero.inverse(T).is_err());
498        assert!(
499            Quaternion::new(f64::NAN, 0.0, 0.0, 1.0)
500                .normalized(T)
501                .is_err()
502        );
503    }
504
505    #[test]
506    fn angle_to_ignores_the_sign_representative() {
507        let a = Quaternion::from_axis_angle(Direction::Z, 0.0);
508        let b = Quaternion::from_axis_angle(Direction::Z, 1.0);
509        assert_relative_eq!(a.angle_to(b), 1.0, epsilon = 1e-12);
510        assert_relative_eq!(a.angle_to(-b), 1.0, epsilon = 1e-12);
511    }
512}