1use core::ops::{Add, Mul, Neg, Sub};
14
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16
17use crate::{Direction, Matrix3, Vector};
18
19const ORTHONORMAL_EPS: f64 = 1e-10;
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Quaternion {
27 pub w: f64,
29 pub x: f64,
31 pub y: f64,
33 pub z: f64,
35}
36
37impl Default for Quaternion {
38 fn default() -> Self {
39 Self::IDENTITY
40 }
41}
42
43impl Quaternion {
44 pub const IDENTITY: Self = Self::new(1.0, 0.0, 0.0, 0.0);
46
47 #[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 #[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 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 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 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 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 #[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 #[must_use]
162 pub fn norm(self) -> f64 {
163 self.square_norm().sqrt()
164 }
165
166 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 #[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 #[must_use]
191 pub const fn conjugate(self) -> Self {
192 Self::new(self.w, -self.x, -self.y, -self.z)
193 }
194
195 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 #[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 #[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 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 Ok((Direction::new(u, tol)?, 2.0 * sin_half.atan2(self.w)))
263 }
264
265 #[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 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 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 #[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 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 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 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 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 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}