Skip to main content

phyz_math/
spatial.rs

1//! 6D spatial algebra following Featherstone's "Rigid Body Dynamics Algorithms".
2//!
3//! Convention: spatial vectors are [angular; linear] (Featherstone order).
4//! A spatial motion vector (twist): [ω; v]
5//! A spatial force vector (wrench): [τ; f]
6
7use crate::{Mat3, Mat6, Vec3, Vec6, skew};
8use nalgebra as na;
9
10/// 6D spatial vector — either a motion vector (twist) or force vector (wrench).
11#[derive(Debug, Clone, Copy)]
12pub struct SpatialVec {
13    /// The underlying 6D vector [angular(3); linear(3)].
14    pub data: Vec6,
15}
16
17impl SpatialVec {
18    /// Create from angular and linear parts.
19    #[inline]
20    pub fn new(angular: Vec3, linear: Vec3) -> Self {
21        Self {
22            data: Vec6::new(
23                angular.x, angular.y, angular.z, linear.x, linear.y, linear.z,
24            ),
25        }
26    }
27
28    /// Zero spatial vector.
29    #[inline]
30    pub fn zero() -> Self {
31        Self {
32            data: Vec6::zeros(),
33        }
34    }
35
36    /// Angular (top 3) component.
37    #[inline]
38    pub fn angular(&self) -> Vec3 {
39        Vec3::new(self.data[0], self.data[1], self.data[2])
40    }
41
42    /// Linear (bottom 3) component.
43    #[inline]
44    pub fn linear(&self) -> Vec3 {
45        Vec3::new(self.data[3], self.data[4], self.data[5])
46    }
47
48    /// Spatial cross product for motion vectors: v ×ₘ w
49    /// Used in velocity propagation.
50    pub fn cross_motion(&self, other: &SpatialVec) -> SpatialVec {
51        let w = self.angular();
52        let v = self.linear();
53        let w2 = other.angular();
54        let v2 = other.linear();
55        SpatialVec::new(w.cross(&w2), w.cross(&v2) + v.cross(&w2))
56    }
57
58    /// Spatial cross product for force vectors: v ×f f
59    /// Used in bias force computation.
60    pub fn cross_force(&self, other: &SpatialVec) -> SpatialVec {
61        let w = self.angular();
62        let v = self.linear();
63        let t = other.angular();
64        let f = other.linear();
65        SpatialVec::new(w.cross(&t) + v.cross(&f), w.cross(&f))
66    }
67
68    /// Dot product of two spatial vectors.
69    #[inline]
70    pub fn dot(&self, other: &SpatialVec) -> f64 {
71        self.data.dot(&other.data)
72    }
73
74    /// Create from linear and angular parts (convenient alternative order).
75    #[inline]
76    pub fn from_linear_angular(linear: Vec3, angular: Vec3) -> Self {
77        Self::new(angular, linear)
78    }
79}
80
81impl std::ops::Add for SpatialVec {
82    type Output = SpatialVec;
83    #[inline]
84    fn add(self, rhs: SpatialVec) -> SpatialVec {
85        SpatialVec {
86            data: self.data + rhs.data,
87        }
88    }
89}
90
91impl std::ops::Sub for SpatialVec {
92    type Output = SpatialVec;
93    #[inline]
94    fn sub(self, rhs: SpatialVec) -> SpatialVec {
95        SpatialVec {
96            data: self.data - rhs.data,
97        }
98    }
99}
100
101impl std::ops::Mul<f64> for SpatialVec {
102    type Output = SpatialVec;
103    #[inline]
104    fn mul(self, rhs: f64) -> SpatialVec {
105        SpatialVec {
106            data: self.data * rhs,
107        }
108    }
109}
110
111impl std::ops::Neg for SpatialVec {
112    type Output = SpatialVec;
113    #[inline]
114    fn neg(self) -> SpatialVec {
115        SpatialVec { data: -self.data }
116    }
117}
118
119/// 6x6 spatial matrix (inertia, transforms acting on spatial vectors).
120#[derive(Debug, Clone, Copy)]
121pub struct SpatialMat {
122    pub data: Mat6,
123}
124
125impl SpatialMat {
126    /// Create from a 6x6 nalgebra matrix.
127    #[inline]
128    pub fn from_mat6(data: Mat6) -> Self {
129        Self { data }
130    }
131
132    /// Zero matrix.
133    #[inline]
134    pub fn zero() -> Self {
135        Self {
136            data: Mat6::zeros(),
137        }
138    }
139
140    /// Identity matrix.
141    #[inline]
142    pub fn identity() -> Self {
143        Self {
144            data: Mat6::identity(),
145        }
146    }
147
148    /// Multiply by a spatial vector.
149    #[inline]
150    pub fn mul_vec(&self, v: &SpatialVec) -> SpatialVec {
151        SpatialVec {
152            data: self.data * v.data,
153        }
154    }
155
156    /// Matrix-matrix multiply.
157    #[inline]
158    pub fn mul_mat(&self, other: &SpatialMat) -> SpatialMat {
159        SpatialMat {
160            data: self.data * other.data,
161        }
162    }
163
164    /// Transpose.
165    #[inline]
166    pub fn transpose(&self) -> SpatialMat {
167        SpatialMat {
168            data: self.data.transpose(),
169        }
170    }
171}
172
173impl std::ops::Add for SpatialMat {
174    type Output = SpatialMat;
175    #[inline]
176    fn add(self, rhs: SpatialMat) -> SpatialMat {
177        SpatialMat {
178            data: self.data + rhs.data,
179        }
180    }
181}
182
183impl std::ops::Sub for SpatialMat {
184    type Output = SpatialMat;
185    #[inline]
186    fn sub(self, rhs: SpatialMat) -> SpatialMat {
187        SpatialMat {
188            data: self.data - rhs.data,
189        }
190    }
191}
192
193/// Plücker transform: rigid body transformation acting on spatial vectors.
194///
195/// Represents a coordinate transform from frame A to frame B.
196/// Stored as rotation R and translation p (position of B's origin in A's frame).
197#[derive(Debug, Clone, Copy)]
198pub struct SpatialTransform {
199    /// Rotation from frame A to frame B.
200    pub rot: Mat3,
201    /// Position of frame B's origin expressed in frame A.
202    pub pos: Vec3,
203}
204
205impl SpatialTransform {
206    /// Create from rotation matrix and translation.
207    pub fn new(rot: Mat3, pos: Vec3) -> Self {
208        Self { rot, pos }
209    }
210
211    /// Identity transform.
212    pub fn identity() -> Self {
213        Self {
214            rot: Mat3::identity(),
215            pos: Vec3::zeros(),
216        }
217    }
218
219    /// Pure rotation about the X axis.
220    pub fn rot_x(angle: f64) -> Self {
221        let (s, c) = angle.sin_cos();
222        Self {
223            rot: Mat3::new(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c),
224            pos: Vec3::zeros(),
225        }
226    }
227
228    /// Pure rotation about the Y axis.
229    pub fn rot_y(angle: f64) -> Self {
230        let (s, c) = angle.sin_cos();
231        Self {
232            rot: Mat3::new(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c),
233            pos: Vec3::zeros(),
234        }
235    }
236
237    /// Pure rotation about the Z axis.
238    pub fn rot_z(angle: f64) -> Self {
239        let (s, c) = angle.sin_cos();
240        Self {
241            rot: Mat3::new(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0),
242            pos: Vec3::zeros(),
243        }
244    }
245
246    /// Pure translation.
247    pub fn translation(pos: Vec3) -> Self {
248        Self {
249            rot: Mat3::identity(),
250            pos,
251        }
252    }
253
254    /// Rotation about an arbitrary axis.
255    pub fn rot_axis(axis: &na::Unit<Vec3>, angle: f64) -> Self {
256        let rot = na::Rotation3::from_axis_angle(axis, angle);
257        Self {
258            rot: *rot.matrix(),
259            pos: Vec3::zeros(),
260        }
261    }
262
263    /// Get the 6x6 Plücker transform matrix for motion vectors.
264    ///
265    /// X = | R    0 |
266    ///     | -R[p]× R |
267    ///
268    /// This transforms spatial motion vectors from frame A to frame B.
269    pub fn to_motion_matrix(&self) -> Mat6 {
270        let r = self.rot;
271        let px = skew(&self.pos);
272        let neg_rpx = -r * px;
273
274        let mut m = Mat6::zeros();
275        // Top-left: R
276        m.fixed_view_mut::<3, 3>(0, 0).copy_from(&r);
277        // Bottom-left: -R[p]×
278        m.fixed_view_mut::<3, 3>(3, 0).copy_from(&neg_rpx);
279        // Bottom-right: R
280        m.fixed_view_mut::<3, 3>(3, 3).copy_from(&r);
281        m
282    }
283
284    /// Get the 6x6 Plücker transform matrix for force vectors.
285    ///
286    /// X* = | R    -R[p]× |
287    ///      | 0      R    |
288    ///
289    /// This is the transpose-inverse of the motion transform.
290    pub fn to_force_matrix(&self) -> Mat6 {
291        let r = self.rot;
292        let px = skew(&self.pos);
293        let neg_rpx = -r * px;
294
295        let mut m = Mat6::zeros();
296        // Top-left: R
297        m.fixed_view_mut::<3, 3>(0, 0).copy_from(&r);
298        // Top-right: -R[p]×
299        m.fixed_view_mut::<3, 3>(0, 3).copy_from(&neg_rpx);
300        // Bottom-right: R
301        m.fixed_view_mut::<3, 3>(3, 3).copy_from(&r);
302        m
303    }
304
305    /// Transform a spatial motion vector from frame A to frame B.
306    pub fn apply_motion(&self, v: &SpatialVec) -> SpatialVec {
307        let w = v.angular();
308        let vel = v.linear();
309        let new_w = self.rot * w;
310        let new_v = self.rot * (vel - self.pos.cross(&w));
311        SpatialVec::new(new_w, new_v)
312    }
313
314    /// Transform a spatial force vector from frame A to frame B.
315    pub fn apply_force(&self, f: &SpatialVec) -> SpatialVec {
316        let tau = f.angular();
317        let force = f.linear();
318        let new_f = self.rot * force;
319        let new_tau = self.rot * (tau - self.pos.cross(&force));
320        SpatialVec::new(new_tau, new_f)
321    }
322
323    /// Inverse transform a spatial motion vector (from B to A).
324    pub fn inv_apply_motion(&self, v: &SpatialVec) -> SpatialVec {
325        let rt = self.rot.transpose();
326        let w = v.angular();
327        let vel = v.linear();
328        let new_w = rt * w;
329        let new_v = rt * vel + self.pos.cross(&(rt * w));
330        SpatialVec::new(new_w, new_v)
331    }
332
333    /// Inverse transform a spatial force vector (from B to A).
334    pub fn inv_apply_force(&self, f: &SpatialVec) -> SpatialVec {
335        let rt = self.rot.transpose();
336        let tau = f.angular();
337        let force = f.linear();
338        let new_f = rt * force;
339        let new_tau = rt * tau + self.pos.cross(&(rt * force));
340        SpatialVec::new(new_tau, new_f)
341    }
342
343    /// Compose two transforms: self ∘ other.
344    pub fn compose(&self, other: &SpatialTransform) -> SpatialTransform {
345        SpatialTransform {
346            rot: self.rot * other.rot,
347            pos: other.pos + other.rot.transpose() * self.pos,
348        }
349    }
350
351    /// Inverse of this transform.
352    pub fn inverse(&self) -> SpatialTransform {
353        let rt = self.rot.transpose();
354        SpatialTransform {
355            rot: rt,
356            pos: -(self.rot * self.pos),
357        }
358    }
359
360    /// Get the translation vector.
361    pub fn translation_vector(&self) -> Vec3 {
362        self.pos
363    }
364
365    /// Get the rotation matrix.
366    pub fn rotation_matrix(&self) -> Mat3 {
367        self.rot
368    }
369}
370
371/// Spatial inertia of a rigid body about its center of mass.
372///
373/// Stored as mass, center of mass offset, and rotational inertia.
374/// Can be converted to a 6x6 spatial inertia matrix.
375#[derive(Debug, Clone, Copy)]
376pub struct SpatialInertia {
377    /// Mass of the body.
378    pub mass: f64,
379    /// Center of mass position in body frame.
380    pub com: Vec3,
381    /// Rotational inertia about the center of mass (3x3 symmetric).
382    pub inertia: Mat3,
383}
384
385impl SpatialInertia {
386    /// Create a spatial inertia with the given mass, CoM offset, and inertia matrix.
387    pub fn new(mass: f64, com: Vec3, inertia: Mat3) -> Self {
388        Self { mass, com, inertia }
389    }
390
391    /// Create spatial inertia for a point mass at a given position.
392    pub fn point_mass(mass: f64, pos: Vec3) -> Self {
393        Self {
394            mass,
395            com: pos,
396            inertia: Mat3::zeros(),
397        }
398    }
399
400    /// Create spatial inertia for a uniform rod of given mass and length along Y axis.
401    /// Rod is centered at origin.
402    pub fn rod(mass: f64, length: f64) -> Self {
403        let i = mass * length * length / 12.0;
404        Self {
405            mass,
406            com: Vec3::zeros(),
407            inertia: Mat3::new(i, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, i),
408        }
409    }
410
411    /// Create spatial inertia for a uniform sphere.
412    pub fn sphere(mass: f64, radius: f64) -> Self {
413        let i = 2.0 / 5.0 * mass * radius * radius;
414        Self {
415            mass,
416            com: Vec3::zeros(),
417            inertia: Mat3::from_diagonal(&Vec3::new(i, i, i)),
418        }
419    }
420
421    /// Convert to 6x6 spatial inertia matrix (about the body frame origin).
422    ///
423    /// I_spatial = | I + m[c]×[c]×ᵀ   m[c]× |
424    ///             | m[c]×ᵀ             mE    |
425    pub fn to_matrix(&self) -> SpatialMat {
426        let cx = skew(&self.com);
427        let m = self.mass;
428        let i3 = Mat3::identity() * m;
429
430        let mut mat = Mat6::zeros();
431        // Top-left: I + m * cx * cx^T
432        let top_left = self.inertia + cx * cx.transpose() * m;
433        mat.fixed_view_mut::<3, 3>(0, 0).copy_from(&top_left);
434        // Top-right: m * cx
435        let mcx = cx * m;
436        mat.fixed_view_mut::<3, 3>(0, 3).copy_from(&mcx);
437        // Bottom-left: m * cx^T
438        mat.fixed_view_mut::<3, 3>(3, 0).copy_from(&mcx.transpose());
439        // Bottom-right: m * E
440        mat.fixed_view_mut::<3, 3>(3, 3).copy_from(&i3);
441
442        SpatialMat::from_mat6(mat)
443    }
444
445    /// Transform this inertia to a new frame.
446    pub fn transform(&self, xform: &SpatialTransform) -> SpatialInertia {
447        // For simplicity, transform via 6x6 matrix.
448        // I_B = X* I_A X*^T  (in force transform convention)
449        let x_force = SpatialMat::from_mat6(xform.to_force_matrix());
450        let i_mat = self.to_matrix();
451        let transformed = x_force
452            .mul_mat(&i_mat)
453            .mul_mat(&SpatialMat::from_mat6(xform.to_motion_matrix()).transpose());
454
455        // Extract mass (unchanged), com, and inertia from the 6x6
456        let mass = transformed.data[(3, 3)];
457        let com = if mass.abs() > 1e-12 {
458            Vec3::new(
459                transformed.data[(2, 3)] / mass,
460                -transformed.data[(0, 3)] / mass, // Note: skew sign
461                transformed.data[(1, 3)] / mass,  // Note: skew sign
462            )
463        } else {
464            Vec3::zeros()
465        };
466        // Rotational inertia: top-left 3x3 minus mass * [c]x [c]x^T
467        let cx = skew(&com);
468        let inertia_with_com = transformed.data.fixed_view::<3, 3>(0, 0).into_owned();
469        let inertia = inertia_with_com - cx * cx.transpose() * mass;
470
471        SpatialInertia { mass, com, inertia }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use approx::assert_relative_eq;
479
480    #[test]
481    fn test_spatial_vec_cross_motion() {
482        let v1 = SpatialVec::new(Vec3::new(0.0, 0.0, 1.0), Vec3::zeros());
483        let v2 = SpatialVec::new(Vec3::new(1.0, 0.0, 0.0), Vec3::zeros());
484        let result = v1.cross_motion(&v2);
485        // [0,0,1] × [1,0,0] = [0,1,0]
486        assert_relative_eq!(result.angular().y, 1.0, epsilon = 1e-10);
487    }
488
489    #[test]
490    fn test_transform_identity() {
491        let xf = SpatialTransform::identity();
492        let v = SpatialVec::new(Vec3::new(1.0, 2.0, 3.0), Vec3::new(4.0, 5.0, 6.0));
493        let result = xf.apply_motion(&v);
494        assert_relative_eq!(result.data, v.data, epsilon = 1e-10);
495    }
496
497    #[test]
498    fn test_transform_inverse_roundtrip() {
499        let xf = SpatialTransform::new(
500            *na::Rotation3::from_axis_angle(&na::Vector3::z_axis(), 0.5).matrix(),
501            Vec3::new(1.0, 2.0, 3.0),
502        );
503        let v = SpatialVec::new(Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
504
505        let forward = xf.apply_motion(&v);
506        let back = xf.inv_apply_motion(&forward);
507        assert_relative_eq!(back.data, v.data, epsilon = 1e-10);
508    }
509
510    #[test]
511    fn test_compose_transforms() {
512        let xf1 = SpatialTransform::translation(Vec3::new(1.0, 0.0, 0.0));
513        let xf2 = SpatialTransform::translation(Vec3::new(0.0, 2.0, 0.0));
514        let composed = xf1.compose(&xf2);
515        assert_relative_eq!(composed.pos, Vec3::new(1.0, 2.0, 0.0), epsilon = 1e-10);
516    }
517
518    #[test]
519    fn test_spatial_inertia_point_mass() {
520        let si = SpatialInertia::point_mass(2.0, Vec3::new(0.0, 1.0, 0.0));
521        let mat = si.to_matrix();
522        // Mass is 2, com at (0,1,0)
523        // Bottom-right should be 2*I3
524        assert_relative_eq!(mat.data[(3, 3)], 2.0, epsilon = 1e-10);
525        assert_relative_eq!(mat.data[(4, 4)], 2.0, epsilon = 1e-10);
526        assert_relative_eq!(mat.data[(5, 5)], 2.0, epsilon = 1e-10);
527    }
528
529    #[test]
530    fn test_spatial_inertia_sphere() {
531        let si = SpatialInertia::sphere(5.0, 0.1);
532        let expected_i = 2.0 / 5.0 * 5.0 * 0.01;
533        assert_relative_eq!(si.inertia[(0, 0)], expected_i, epsilon = 1e-10);
534        assert_relative_eq!(si.inertia[(1, 1)], expected_i, epsilon = 1e-10);
535        assert_relative_eq!(si.inertia[(2, 2)], expected_i, epsilon = 1e-10);
536    }
537}
538
539#[cfg(test)]
540mod prop_tests {
541    use super::*;
542    use crate::quaternion::Quat;
543    use proptest::prelude::*;
544
545    const EPS: f64 = 1e-9;
546
547    fn arb_pos() -> impl Strategy<Value = Vec3> {
548        (-10.0..10.0_f64, -10.0..10.0_f64, -10.0..10.0_f64)
549            .prop_map(|(x, y, z)| Vec3::new(x, y, z))
550    }
551
552    fn arb_angle() -> impl Strategy<Value = f64> {
553        -std::f64::consts::PI..std::f64::consts::PI
554    }
555
556    fn arb_unit_axis() -> impl Strategy<Value = na::Unit<Vec3>> {
557        // Generate a non-zero vector, then normalize
558        (-1.0..1.0_f64, -1.0..1.0_f64, -1.0..1.0_f64)
559            .prop_filter("non-zero axis", |(x, y, z)| x * x + y * y + z * z > 0.01)
560            .prop_map(|(x, y, z)| na::Unit::new_normalize(Vec3::new(x, y, z)))
561    }
562
563    fn arb_transform() -> impl Strategy<Value = SpatialTransform> {
564        (arb_unit_axis(), arb_angle(), arb_pos()).prop_map(|(axis, angle, pos)| {
565            let rot = na::Rotation3::from_axis_angle(&axis, angle);
566            SpatialTransform::new(*rot.matrix(), pos)
567        })
568    }
569
570    fn arb_spatial_vec() -> impl Strategy<Value = SpatialVec> {
571        (arb_pos(), arb_pos()).prop_map(|(a, l)| SpatialVec::new(a, l))
572    }
573
574    proptest! {
575        #[test]
576        fn compose_with_inverse_is_identity(xf in arb_transform()) {
577            let result = xf.compose(&xf.inverse());
578            let id = SpatialTransform::identity();
579            for i in 0..3 {
580                for j in 0..3 {
581                    prop_assert!((result.rot[(i, j)] - id.rot[(i, j)]).abs() < EPS,
582                        "rot[{},{}]: {} vs {}", i, j, result.rot[(i, j)], id.rot[(i, j)]);
583                }
584            }
585            for i in 0..3 {
586                prop_assert!((result.pos[i] - id.pos[i]).abs() < EPS,
587                    "pos[{}]: {} vs {}", i, result.pos[i], id.pos[i]);
588            }
589        }
590
591        #[test]
592        fn compose_is_associative(
593            a in arb_transform(),
594            b in arb_transform(),
595            c in arb_transform(),
596        ) {
597            let ab_c = a.compose(&b).compose(&c);
598            let a_bc = a.compose(&b.compose(&c));
599            for i in 0..3 {
600                for j in 0..3 {
601                    prop_assert!((ab_c.rot[(i, j)] - a_bc.rot[(i, j)]).abs() < EPS,
602                        "rot[{},{}]: {} vs {}", i, j, ab_c.rot[(i, j)], a_bc.rot[(i, j)]);
603                }
604            }
605            for i in 0..3 {
606                prop_assert!((ab_c.pos[i] - a_bc.pos[i]).abs() < EPS,
607                    "pos[{}]: {} vs {}", i, ab_c.pos[i], a_bc.pos[i]);
608            }
609        }
610
611        #[test]
612        fn apply_force_matches_matrix(xf in arb_transform(), f in arb_spatial_vec()) {
613            let applied = xf.apply_force(&f);
614            let mat_result = SpatialMat::from_mat6(xf.to_force_matrix()).mul_vec(&f);
615            for i in 0..6 {
616                prop_assert!((applied.data[i] - mat_result.data[i]).abs() < EPS,
617                    "component {}: {} vs {}", i, applied.data[i], mat_result.data[i]);
618            }
619        }
620
621        #[test]
622        fn apply_motion_matches_matrix(xf in arb_transform(), v in arb_spatial_vec()) {
623            let applied = xf.apply_motion(&v);
624            let mat_result = SpatialMat::from_mat6(xf.to_motion_matrix()).mul_vec(&v);
625            for i in 0..6 {
626                prop_assert!((applied.data[i] - mat_result.data[i]).abs() < EPS,
627                    "component {}: {} vs {}", i, applied.data[i], mat_result.data[i]);
628            }
629        }
630
631        #[test]
632        fn sphere_inertia_matrix_is_symmetric(
633            mass in 0.1..100.0_f64,
634            radius in 0.01..10.0_f64,
635        ) {
636            let si = SpatialInertia::sphere(mass, radius);
637            let mat = si.to_matrix().data;
638            for i in 0..6 {
639                for j in 0..6 {
640                    prop_assert!((mat[(i, j)] - mat[(j, i)]).abs() < EPS,
641                        "not symmetric at ({},{}): {} vs {}", i, j, mat[(i, j)], mat[(j, i)]);
642                }
643            }
644        }
645
646        #[test]
647        fn quat_to_matrix_is_rotation(
648            axis in arb_unit_axis(),
649            angle in arb_angle(),
650        ) {
651            let q = Quat::from_axis_angle(&axis.into_inner(), angle).normalize();
652            let m = q.to_matrix();
653            // determinant should be 1
654            let det = m.determinant();
655            prop_assert!((det - 1.0).abs() < EPS, "det = {}", det);
656            // R * R^T should be identity
657            let rrt = m * m.transpose();
658            let id = Mat3::identity();
659            for i in 0..3 {
660                for j in 0..3 {
661                    prop_assert!((rrt[(i, j)] - id[(i, j)]).abs() < EPS,
662                        "R*R^T[{},{}]: {} vs {}", i, j, rrt[(i, j)], id[(i, j)]);
663                }
664            }
665        }
666
667        #[test]
668        fn quat_matrix_roundtrip(
669            axis in arb_unit_axis(),
670            angle in arb_angle(),
671        ) {
672            let q = Quat::from_axis_angle(&axis.into_inner(), angle).normalize();
673            let m = q.to_matrix();
674            let q2 = Quat::from_matrix(&m).normalize();
675            // q and -q represent the same rotation
676            let same = (q.w - q2.w).abs() < EPS
677                && (q.v.x - q2.v.x).abs() < EPS
678                && (q.v.y - q2.v.y).abs() < EPS
679                && (q.v.z - q2.v.z).abs() < EPS;
680            let neg = (q.w + q2.w).abs() < EPS
681                && (q.v.x + q2.v.x).abs() < EPS
682                && (q.v.y + q2.v.y).abs() < EPS
683                && (q.v.z + q2.v.z).abs() < EPS;
684            prop_assert!(same || neg,
685                "roundtrip failed: q={:?}, q2={:?}", q, q2);
686        }
687    }
688}