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 (a, b) = (from.vector(), to.vector());
69        let cross = a.cross(b);
70        let dot = a.dot(b);
71        if dot >= 0.0 {
72            // Within a quarter turn: the half-way direction is well
73            // conditioned, and the rotation turns `from` onto it and on by
74            // as much again. w = cos(theta / 2), (x, y, z) = sin(theta / 2)
75            // times the axis.
76            let half = (a + b) / (a + b).magnitude();
77            let axis = a.cross(half);
78            return Self::new(a.dot(half), axis.x, axis.y, axis.z).normalized(tol);
79        }
80        // Past a quarter turn the angle is read off the cross product, which
81        // holds its digits as the two turn antiparallel where `1 + cos` does
82        // not. The axis is held to the angular tolerance, independent of the
83        // unit.
84        let sine = cross.magnitude();
85        if sine <= tol.angular() {
86            ogeom_bail!(
87                Construction,
88                "rotation between antiparallel directions is not unique"
89            );
90        }
91        let axis = Direction::new(cross / sine, tol)?;
92        Ok(Self::from_axis_angle(axis, sine.atan2(dot)))
93    }
94
95    /// From a rotation matrix.
96    ///
97    /// Uses Shepperd's method: pick the largest of the four possible divisors so
98    /// the division is never by something near zero. The naive `w`-first
99    /// formulation loses precision for rotations near π, where `w → 0`.
100    ///
101    /// # Errors
102    ///
103    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `m` is not
104    /// orthonormal with determinant `+1`.
105    pub fn from_matrix(m: &Matrix3, tol: Tolerances) -> OgeomResult<Self> {
106        if !m.is_orthonormal(ORTHONORMAL_EPS) {
107            ogeom_bail!(Construction, "rotation matrix is not orthonormal");
108        }
109        if m.determinant() < 0.0 {
110            ogeom_bail!(Construction, "matrix is a reflection, not a rotation");
111        }
112        let r = &m.rows;
113        let trace = m.trace();
114        let q = if trace > 0.0 {
115            let s = (trace + 1.0).sqrt() * 2.0;
116            Self::new(
117                0.25 * s,
118                (r[2][1] - r[1][2]) / s,
119                (r[0][2] - r[2][0]) / s,
120                (r[1][0] - r[0][1]) / s,
121            )
122        } else if r[0][0] > r[1][1] && r[0][0] > r[2][2] {
123            let s = (1.0 + r[0][0] - r[1][1] - r[2][2]).sqrt() * 2.0;
124            Self::new(
125                (r[2][1] - r[1][2]) / s,
126                0.25 * s,
127                (r[0][1] + r[1][0]) / s,
128                (r[0][2] + r[2][0]) / s,
129            )
130        } else if r[1][1] > r[2][2] {
131            let s = (1.0 - r[0][0] + r[1][1] - r[2][2]).sqrt() * 2.0;
132            Self::new(
133                (r[0][2] - r[2][0]) / s,
134                (r[0][1] + r[1][0]) / s,
135                0.25 * s,
136                (r[1][2] + r[2][1]) / s,
137            )
138        } else {
139            let s = (1.0 - r[0][0] - r[1][1] + r[2][2]).sqrt() * 2.0;
140            Self::new(
141                (r[1][0] - r[0][1]) / s,
142                (r[0][2] + r[2][0]) / s,
143                (r[1][2] + r[2][1]) / s,
144                0.25 * s,
145            )
146        };
147        q.normalized(tol)
148    }
149
150    /// Squared norm.
151    #[must_use]
152    pub fn square_norm(self) -> f64 {
153        self.w.mul_add(
154            self.w,
155            self.x
156                .mul_add(self.x, self.y.mul_add(self.y, self.z * self.z)),
157        )
158    }
159
160    /// Norm.
161    #[must_use]
162    pub fn norm(self) -> f64 {
163        self.square_norm().sqrt()
164    }
165
166    /// This quaternion scaled to unit norm.
167    ///
168    /// # Errors
169    ///
170    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the norm is
171    /// below `tol.confusion()` or any component is non-finite.
172    pub fn normalized(self, tol: Tolerances) -> OgeomResult<Self> {
173        if !self.is_finite() {
174            ogeom_bail!(Construction, "cannot normalize a non-finite quaternion");
175        }
176        let n = self.norm();
177        if n <= tol.confusion() {
178            ogeom_bail!(Construction, "cannot normalize a quaternion of norm {n}");
179        }
180        Ok(Self::new(self.w / n, self.x / n, self.y / n, self.z / n))
181    }
182
183    /// Whether every component is finite.
184    #[must_use]
185    pub fn is_finite(self) -> bool {
186        self.w.is_finite() && self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
187    }
188
189    /// The conjugate. For a unit quaternion this is the inverse rotation.
190    #[must_use]
191    pub const fn conjugate(self) -> Self {
192        Self::new(self.w, -self.x, -self.y, -self.z)
193    }
194
195    /// The multiplicative inverse.
196    ///
197    /// # Errors
198    ///
199    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the norm is
200    /// degenerate.
201    pub fn inverse(self, tol: Tolerances) -> OgeomResult<Self> {
202        let n2 = self.square_norm();
203        if n2 <= tol.confusion() * tol.confusion() {
204            ogeom_bail!(Numeric, "quaternion of norm {} has no inverse", n2.sqrt());
205        }
206        let c = self.conjugate();
207        Ok(Self::new(c.w / n2, c.x / n2, c.y / n2, c.z / n2))
208    }
209
210    /// Apply this rotation to a vector. Assumes unit norm.
211    ///
212    /// Evaluated as `v + 2w(u × v) + 2(u × (u × v))` with `u` the vector part,
213    /// which costs fewer operations than building the matrix and avoids the
214    /// intermediate rounding of a full quaternion sandwich product.
215    #[must_use]
216    pub fn rotate(self, v: Vector) -> Vector {
217        let u = Vector::new(self.x, self.y, self.z);
218        let uv = u.cross(v);
219        v + uv * (2.0 * self.w) + u.cross(uv) * 2.0
220    }
221
222    /// The equivalent rotation matrix. Assumes unit norm.
223    #[must_use]
224    pub fn to_matrix(self) -> Matrix3 {
225        let (w, x, y, z) = (self.w, self.x, self.y, self.z);
226        let (xx, yy, zz) = (x * x, y * y, z * z);
227        let (xy, xz, yz) = (x * y, x * z, y * z);
228        let (wx, wy, wz) = (w * x, w * y, w * z);
229        Matrix3::new([
230            [
231                (-2.0f64).mul_add(yy + zz, 1.0),
232                2.0 * (xy - wz),
233                2.0 * (xz + wy),
234            ],
235            [
236                2.0 * (xy + wz),
237                (-2.0f64).mul_add(xx + zz, 1.0),
238                2.0 * (yz - wx),
239            ],
240            [
241                2.0 * (xz - wy),
242                2.0 * (yz + wx),
243                (-2.0f64).mul_add(xx + yy, 1.0),
244            ],
245        ])
246    }
247
248    /// The rotation axis and angle. Assumes unit norm; angle is in `[0, π]`.
249    ///
250    /// # Errors
251    ///
252    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the rotation
253    /// is the identity, where the axis is undefined.
254    pub fn to_axis_angle(self, tol: Tolerances) -> OgeomResult<(Direction, f64)> {
255        let u = Vector::new(self.x, self.y, self.z);
256        let sin_half = u.magnitude();
257        if sin_half <= tol.confusion() {
258            ogeom_bail!(Construction, "identity rotation has no defined axis");
259        }
260        // atan2 rather than acos(w) or asin: accurate across the whole range,
261        // including angles near 0 and near pi.
262        Ok((Direction::new(u, tol)?, 2.0 * sin_half.atan2(self.w)))
263    }
264
265    /// Dot product, as 4-vectors.
266    #[must_use]
267    pub fn dot(self, o: Self) -> f64 {
268        self.w
269            .mul_add(o.w, self.x.mul_add(o.x, self.y.mul_add(o.y, self.z * o.z)))
270    }
271
272    /// Spherical linear interpolation, `t = 0` giving `self`.
273    ///
274    /// Takes the shorter of the two arcs, and falls back to normalized linear
275    /// interpolation when the two are nearly coincident, where the `sin` in the
276    /// denominator of the spherical form goes to zero.
277    ///
278    /// # Errors
279    ///
280    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
281    /// operand cannot be normalized.
282    pub fn slerp(self, other: Self, t: f64, tol: Tolerances) -> OgeomResult<Self> {
283        let a = self.normalized(tol)?;
284        let mut b = other.normalized(tol)?;
285        let mut cos = a.dot(b);
286        // q and -q are the same rotation. Pick the representative that gives the
287        // shorter path.
288        if cos < 0.0 {
289            b = -b;
290            cos = -cos;
291        }
292        if cos > 1.0 - 1e-9 {
293            return (a * (1.0 - t) + b * t).normalized(tol);
294        }
295        let theta = cos.clamp(-1.0, 1.0).acos();
296        let sin = theta.sin();
297        let wa = ((1.0 - t) * theta).sin() / sin;
298        let wb = (t * theta).sin() / sin;
299        (a * wa + b * wb).normalized(tol)
300    }
301
302    /// The rotation angle to `other`, in `[0, π]`. Assumes unit norms.
303    #[must_use]
304    pub fn angle_to(self, other: Self) -> f64 {
305        let cos = self.dot(other).abs().clamp(-1.0, 1.0);
306        2.0 * cos.acos()
307    }
308}
309
310impl Mul for Quaternion {
311    type Output = Self;
312    /// Hamilton product. `(a * b)` rotates by `b` first, then by `a`.
313    fn mul(self, o: Self) -> Self {
314        Self::new(
315            self.w
316                .mul_add(o.w, -self.x.mul_add(o.x, self.y.mul_add(o.y, self.z * o.z))),
317            self.w.mul_add(
318                o.x,
319                self.x.mul_add(o.w, self.y.mul_add(o.z, -(self.z * o.y))),
320            ),
321            self.w.mul_add(
322                o.y,
323                self.y.mul_add(o.w, self.z.mul_add(o.x, -(self.x * o.z))),
324            ),
325            self.w.mul_add(
326                o.z,
327                self.z.mul_add(o.w, self.x.mul_add(o.y, -(self.y * o.x))),
328            ),
329        )
330    }
331}
332
333impl Mul<f64> for Quaternion {
334    type Output = Self;
335    fn mul(self, s: f64) -> Self {
336        Self::new(self.w * s, self.x * s, self.y * s, self.z * s)
337    }
338}
339
340impl Add for Quaternion {
341    type Output = Self;
342    fn add(self, o: Self) -> Self {
343        Self::new(self.w + o.w, self.x + o.x, self.y + o.y, self.z + o.z)
344    }
345}
346
347impl Sub for Quaternion {
348    type Output = Self;
349    fn sub(self, o: Self) -> Self {
350        Self::new(self.w - o.w, self.x - o.x, self.y - o.y, self.z - o.z)
351    }
352}
353
354impl Neg for Quaternion {
355    type Output = Self;
356    fn neg(self) -> Self {
357        Self::new(-self.w, -self.x, -self.y, -self.z)
358    }
359}
360
361#[cfg(test)]
362#[allow(clippy::unwrap_used)]
363mod tests {
364    use super::*;
365    use approx::assert_relative_eq;
366
367    const T: Tolerances = Tolerances::millimetres();
368
369    fn axis() -> Direction {
370        Direction::from_coords(1.0, 2.0, -1.0, T).unwrap()
371    }
372
373    #[test]
374    fn rotate_agrees_with_the_matrix_form() {
375        let q = Quaternion::from_axis_angle(axis(), 1.234);
376        let m = q.to_matrix();
377        for v in [
378            Vector::X,
379            Vector::new(3.0, -2.0, 5.0),
380            Vector::new(-1e3, 1e-3, 7.0),
381        ] {
382            assert!(
383                q.rotate(v).is_equal(m * v, T),
384                "quaternion and matrix disagree"
385            );
386        }
387    }
388
389    #[test]
390    fn rotation_preserves_length_and_is_invertible() {
391        let q = Quaternion::from_axis_angle(axis(), 2.1);
392        let v = Vector::new(1.0, 2.0, 3.0);
393        assert_relative_eq!(q.rotate(v).magnitude(), v.magnitude(), epsilon = 1e-14);
394        assert!(q.conjugate().rotate(q.rotate(v)).is_equal(v, T));
395        assert!(q.inverse(T).unwrap().rotate(q.rotate(v)).is_equal(v, T));
396    }
397
398    #[test]
399    fn composition_applies_right_to_left() {
400        let a = Quaternion::from_axis_angle(Direction::Z, core::f64::consts::FRAC_PI_2);
401        let b = Quaternion::from_axis_angle(Direction::X, core::f64::consts::FRAC_PI_2);
402        let v = Vector::Y;
403        assert!((a * b).rotate(v).is_equal(a.rotate(b.rotate(v)), T));
404    }
405
406    #[test]
407    fn matrix_round_trip_is_exact_near_pi() {
408        // The naive w-first extraction divides by something approaching zero
409        // near pi, and by something vanishing right at it, where w -> 0.
410        // Shepperd's largest-divisor choice does not.
411        let near_pi = core::f64::consts::PI - 1e-8;
412        for angle in [0.0_f64, 0.1, 1.0, 3.0, near_pi, core::f64::consts::PI] {
413            let q = Quaternion::from_axis_angle(axis(), angle);
414            let back = Quaternion::from_matrix(&q.to_matrix(), T).unwrap();
415            // q and -q are the same rotation, so compare the rotations.
416            assert!(
417                back.to_matrix().is_equal(&q.to_matrix(), 1e-12),
418                "round trip failed at angle {angle}"
419            );
420        }
421    }
422
423    #[test]
424    fn reflections_are_rejected_as_rotations() {
425        let m = Matrix3::reflection(Direction::Z);
426        assert!(Quaternion::from_matrix(&m, T).is_err());
427        assert!(Quaternion::from_matrix(&Matrix3::scaling(2.0), T).is_err());
428    }
429
430    #[test]
431    fn axis_angle_round_trip() {
432        let a = axis();
433        for angle in [0.01_f64, 0.5, 1.5, 3.0] {
434            let q = Quaternion::from_axis_angle(a, angle);
435            let (back_axis, back_angle) = q.to_axis_angle(T).unwrap();
436            assert_relative_eq!(back_angle, angle, epsilon = 1e-12);
437            assert!(back_axis.is_equal(a, T));
438        }
439        assert!(Quaternion::IDENTITY.to_axis_angle(T).is_err());
440    }
441
442    #[test]
443    fn between_gives_the_shortest_rotation() {
444        let from = Direction::X;
445        let to = Direction::from_coords(1.0, 1.0, 0.0, T).unwrap();
446        let q = Quaternion::between(from, to, T).unwrap();
447        assert!(
448            Direction::new(q.rotate(from.vector()), T)
449                .unwrap()
450                .is_equal(to, T)
451        );
452        let (_, angle) = q.to_axis_angle(T).unwrap();
453        assert_relative_eq!(angle, core::f64::consts::FRAC_PI_4, epsilon = 1e-12);
454    }
455
456    #[test]
457    fn between_holds_its_digits_near_a_half_turn() {
458        for gap in [1e-4_f64, 1e-6, 1e-9] {
459            let to = Direction::new(Vector::new(-gap.cos(), gap.sin(), 0.0), T).unwrap();
460            let q = Quaternion::between(Direction::X, to, T).unwrap();
461            let turned = q.rotate(Direction::X.vector());
462            assert!(
463                (turned - to.vector()).magnitude() < 1e-12,
464                "gap {gap}: {turned:?} against {to:?}"
465            );
466        }
467    }
468
469    #[test]
470    fn between_refuses_the_ambiguous_antiparallel_case() {
471        assert!(Quaternion::between(Direction::X, -Direction::X, T).is_err());
472        // Identical directions are fine: the answer is the identity.
473        let q = Quaternion::between(Direction::X, Direction::X, T).unwrap();
474        assert!(q.rotate(Vector::Y).is_equal(Vector::Y, T));
475    }
476
477    #[test]
478    fn slerp_hits_the_endpoints_and_stays_unit() {
479        let a = Quaternion::from_axis_angle(Direction::Z, 0.2);
480        let b = Quaternion::from_axis_angle(Direction::X, 1.9);
481        assert!(
482            a.slerp(b, 0.0, T)
483                .unwrap()
484                .to_matrix()
485                .is_equal(&a.to_matrix(), 1e-12)
486        );
487        assert!(
488            a.slerp(b, 1.0, T)
489                .unwrap()
490                .to_matrix()
491                .is_equal(&b.to_matrix(), 1e-12)
492        );
493        for i in 0..=10 {
494            let q = a.slerp(b, f64::from(i) / 10.0, T).unwrap();
495            assert_relative_eq!(q.norm(), 1.0, epsilon = 1e-14);
496        }
497    }
498
499    #[test]
500    fn slerp_takes_the_short_way_round() {
501        let a = Quaternion::from_axis_angle(Direction::Z, 0.0);
502        // Same rotation, opposite representative. Naive slerp would sweep the
503        // long way. The sign correction must prevent that.
504        let b = -Quaternion::from_axis_angle(Direction::Z, 0.4);
505        let mid = a.slerp(b, 0.5, T).unwrap();
506        let expected = Quaternion::from_axis_angle(Direction::Z, 0.2);
507        assert!(mid.to_matrix().is_equal(&expected.to_matrix(), 1e-12));
508    }
509
510    #[test]
511    fn slerp_survives_nearly_identical_inputs() {
512        let a = Quaternion::from_axis_angle(Direction::Z, 1.0);
513        let b = Quaternion::from_axis_angle(Direction::Z, 1.0 + 1e-12);
514        let mid = a.slerp(b, 0.5, T).unwrap();
515        assert!(mid.is_finite());
516        assert_relative_eq!(mid.norm(), 1.0, epsilon = 1e-14);
517    }
518
519    #[test]
520    fn degenerate_quaternions_are_refused() {
521        let zero = Quaternion::new(0.0, 0.0, 0.0, 0.0);
522        assert!(zero.normalized(T).is_err());
523        assert!(zero.inverse(T).is_err());
524        assert!(
525            Quaternion::new(f64::NAN, 0.0, 0.0, 1.0)
526                .normalized(T)
527                .is_err()
528        );
529    }
530
531    #[test]
532    fn angle_to_ignores_the_sign_representative() {
533        let a = Quaternion::from_axis_angle(Direction::Z, 0.0);
534        let b = Quaternion::from_axis_angle(Direction::Z, 1.0);
535        assert_relative_eq!(a.angle_to(b), 1.0, epsilon = 1e-12);
536        assert_relative_eq!(a.angle_to(-b), 1.0, epsilon = 1e-12);
537    }
538}