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 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 Self::new(1.0 + d, axis.x, axis.y, axis.z).normalized(tol)
79 }
80
81 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 #[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 #[must_use]
148 pub fn norm(self) -> f64 {
149 self.square_norm().sqrt()
150 }
151
152 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 #[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 #[must_use]
177 pub const fn conjugate(self) -> Self {
178 Self::new(self.w, -self.x, -self.y, -self.z)
179 }
180
181 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 #[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 #[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 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 Ok((Direction::new(u, tol)?, 2.0 * sin_half.atan2(self.w)))
249 }
250
251 #[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 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 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 #[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 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 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 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 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 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}