Skip to main content

fast_mesh_scene/
placement.rs

1//! Placements: where a mesh sits in the world.
2//!
3//! A placement maps a mesh's points to the world as
4//! `world = rotation * (scale * p) + translation`, where `p` is the mesh's
5//! own point (its origin plus a local position), all in `f64`. The scale is
6//! applied first, per axis, then the rotation, then the translation.
7//!
8//! The default scale `[1, 1, 1]` is the rigid placement the scene used
9//! before scales existed; every snapshot and default keeps working.
10
11use fast_mesh_query::{QueryError, Rigid};
12
13use crate::{Result, SceneError};
14
15/// A transform from a mesh's points to the world:
16/// `world = rotation * (scale * p) + translation`, where `p` is the mesh's
17/// own point (its origin plus a local position), all in `f64`.
18///
19/// `rotation` is row-major and must be a proper rotation (orthonormal,
20/// determinant +1, within `1e-6` per entry, the same rule as
21/// [`Rigid::validate`]). `scale` is per axis, applied before the rotation,
22/// and must be finite and positive: zero would collapse the mesh (no
23/// inverse) and a negative one would mirror it (flipping winding, culling
24/// and signs). Placements are stored exactly as given.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Placement {
27    /// The rotation, row-major, applied after the scale.
28    pub rotation: [[f64; 3]; 3],
29    /// The translation, applied after the rotation.
30    pub translation: [f64; 3],
31    /// The per-axis scale, applied first. `[1, 1, 1]` leaves the mesh rigid.
32    pub scale: [f64; 3],
33}
34
35impl Placement {
36    /// The placement that leaves the mesh where its coordinates say.
37    pub const IDENTITY: Placement = Placement {
38        rotation: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
39        translation: [0.0; 3],
40        scale: [1.0; 3],
41    };
42
43    /// Checks that the rotation is a proper rotation, and that the
44    /// translation and the scale are finite with every scale component
45    /// positive.
46    ///
47    /// # Errors
48    /// [`SceneError::InvalidInput`] otherwise.
49    pub fn validate(&self) -> Result<()> {
50        let rigid = Rigid {
51            rotation: self.rotation,
52            translation: self.translation,
53        };
54        rigid.validate().map_err(|e| match e {
55            QueryError::InvalidInput(m) => SceneError::InvalidInput(m),
56            other => SceneError::Query(other),
57        })?;
58        if !self.scale.iter().all(|s| s.is_finite()) {
59            return Err(SceneError::InvalidInput("placement scale must be finite"));
60        }
61        if !self.scale.iter().all(|&s| s > 0.0) {
62            return Err(SceneError::InvalidInput("placement scale must be positive"));
63        }
64        Ok(())
65    }
66
67    /// Whether the placement scales: any component differs from 1.
68    pub fn is_scaled(&self) -> bool {
69        self.scale != [1.0; 3]
70    }
71
72    /// The uniform scale when every component is equal, `None` otherwise.
73    pub fn uniform_scale(&self) -> Option<f64> {
74        (self.scale[0] == self.scale[1] && self.scale[1] == self.scale[2]).then_some(self.scale[0])
75    }
76
77    /// The largest scale component.
78    pub(crate) fn max_scale(&self) -> f64 {
79        self.scale[0].max(self.scale[1]).max(self.scale[2])
80    }
81
82    /// Maps a point of the mesh to the world: `rotation * (scale * p) +
83    /// translation`.
84    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
85        let (r, t, s) = (&self.rotation, &self.translation, &self.scale);
86        // Mirrors `Rigid::apply_f64` with the scale folded into the point,
87        // so an unscaled placement stays bit-identical to it (`1.0 * p ==
88        // p` exactly).
89        std::array::from_fn(|i| {
90            r[i][0] * (s[0] * p[0]) + r[i][1] * (s[1] * p[1]) + r[i][2] * (s[2] * p[2]) + t[i]
91        })
92    }
93
94    /// Maps a world point back to the mesh: `(scale⁻¹ * rotationᵀ) * (q -
95    /// translation)`.
96    pub fn inverse_apply(&self, q: [f64; 3]) -> [f64; 3] {
97        let t = &self.translation;
98        let d = [q[0] - t[0], q[1] - t[1], q[2] - t[2]];
99        let r = &self.rotation;
100        let s = &self.scale;
101        std::array::from_fn(|i| (r[0][i] * d[0] + r[1][i] * d[1] + r[2][i] * d[2]) / s[i])
102    }
103
104    /// Maps a world direction to the mesh's local frame: `scale⁻¹ *
105    /// rotationᵀ * d`. A ray mapped this way keeps its parameter: with
106    /// `o_l` the mapped origin, `apply(o_l + t * d_l)` is the world point at
107    /// `t`, since the placement is affine.
108    pub(crate) fn inverse_direction(&self, d: [f64; 3]) -> [f64; 3] {
109        let r = &self.rotation;
110        let s = &self.scale;
111        std::array::from_fn(|i| (r[0][i] * d[0] + r[1][i] * d[1] + r[2][i] * d[2]) / s[i])
112    }
113
114    /// Maps a mesh-local direction to the world: `rotation * (scale * d)`.
115    /// Renderers use this alongside [`Placement::normal_to_world`] to pose
116    /// mesh-local vectors.
117    pub fn apply_direction(&self, d: [f64; 3]) -> [f64; 3] {
118        let r = &self.rotation;
119        let s = &self.scale;
120        std::array::from_fn(|i| {
121            r[i][0] * (s[0] * d[0]) + r[i][1] * (s[1] * d[1]) + r[i][2] * (s[2] * d[2])
122        })
123    }
124
125    /// Maps a mesh-local unit normal to a world unit normal: `normalize(R *
126    /// (n / S))`, the inverse-transpose for a rotation with a diagonal
127    /// scale. Sides are preserved (the scale is positive), so culling and
128    /// signs follow. Renderers use this to pose mesh-local normals.
129    pub fn normal_to_world(&self, n: [f64; 3]) -> [f64; 3] {
130        unit(std::array::from_fn(|i| {
131            let r = &self.rotation;
132            let s = &self.scale;
133            r[i][0] * (n[0] / s[0]) + r[i][1] * (n[1] / s[1]) + r[i][2] * (n[2] / s[2])
134        }))
135    }
136
137    /// Maps a world unit normal's direction to the mesh's local frame:
138    /// `normalize(S * Rᵀ * n)`. The zero set of the local plane through
139    /// `inverse_apply` of a world point with this normal is the preimage of
140    /// the world plane's zero set, with matching sides.
141    pub fn normal_to_local(&self, n: [f64; 3]) -> [f64; 3] {
142        let r = &self.rotation;
143        let s = &self.scale;
144        unit(std::array::from_fn(|i| {
145            s[i] * (r[0][i] * n[0] + r[1][i] * n[1] + r[2][i] * n[2])
146        }))
147    }
148}
149
150impl Default for Placement {
151    fn default() -> Self {
152        Placement::IDENTITY
153    }
154}
155
156/// A mesh-local point to the world: `R (S (o + p)) + t`.
157pub(crate) fn local_to_world(p: &Placement, origin: [f64; 3], local: [f64; 3]) -> [f64; 3] {
158    p.apply([
159        origin[0] + local[0],
160        origin[1] + local[1],
161        origin[2] + local[2],
162    ])
163}
164
165/// A world point to mesh-local coordinates: `S⁻¹ Rᵀ (q - t) - o`.
166pub(crate) fn world_to_local(p: &Placement, origin: [f64; 3], q: [f64; 3]) -> [f64; 3] {
167    let w = p.inverse_apply(q);
168    [w[0] - origin[0], w[1] - origin[1], w[2] - origin[2]]
169}
170
171fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
172    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
173}
174
175fn unit(a: [f64; 3]) -> [f64; 3] {
176    let n = dot(a, a).sqrt();
177    [a[0] / n, a[1] / n, a[2] / n]
178}
179
180/// Gram-Schmidt on the rows, so the rotation is orthonormal to f64 rounding.
181pub(crate) fn orthonormalize(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
182    let r0 = unit(m[0]);
183    let d = dot(m[1], r0);
184    let r1 = unit([
185        m[1][0] - d * r0[0],
186        m[1][1] - d * r0[1],
187        m[1][2] - d * r0[2],
188    ]);
189    // Third row from the cross product: keeps the determinant at +1.
190    let r2 = [
191        r0[1] * r1[2] - r0[2] * r1[1],
192        r0[2] * r1[0] - r0[0] * r1[2],
193        r0[0] * r1[1] - r0[1] * r1[0],
194    ];
195    [r0, r1, r2]
196}
197
198/// An affine map between two meshes' local spaces: `matrix * p +
199/// translation`, origins included. [`relative_affine`] builds the one from
200/// mesh B's local space to mesh A's. With unscaled placements it is the same
201/// map as [`relative_rigid`], bit for bit.
202#[derive(Debug, Clone, Copy, PartialEq)]
203pub(crate) struct Affine {
204    pub(crate) matrix: [[f64; 3]; 3],
205    pub(crate) translation: [f64; 3],
206}
207
208impl Affine {
209    /// Maps a point of B's local space into A's.
210    pub(crate) fn apply(&self, p: [f64; 3]) -> [f64; 3] {
211        let (m, t) = (&self.matrix, &self.translation);
212        std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2] + t[i])
213    }
214}
215
216/// The transform from mesh B's local space to mesh A's local space, origins
217/// included, with scales: `M_A⁻¹ M_B`, where each `M` is `R (S (o + ·)) +
218/// t`, in `f64`.
219pub(crate) fn relative_affine(
220    a: &Placement,
221    origin_a: [f64; 3],
222    b: &Placement,
223    origin_b: [f64; 3],
224) -> Affine {
225    // B's origin in the world, relative to A's translation, then into A's
226    // scaled frame: `S_A⁻¹ R_Aᵀ (M_B(o_B) - t_A) - o_A`.
227    let wb = b.apply(origin_b);
228    let w = [
229        wb[0] - a.translation[0],
230        wb[1] - a.translation[1],
231        wb[2] - a.translation[2],
232    ];
233    let ra = &a.rotation;
234    let unscaled: [f64; 3] =
235        std::array::from_fn(|i| ra[0][i] * w[0] + ra[1][i] * w[1] + ra[2][i] * w[2]);
236    let translation = std::array::from_fn(|i| unscaled[i] / a.scale[i] - origin_a[i]);
237    // The linear part: `S_A⁻¹ R_Aᵀ R_B S_B`.
238    let (rb, sa, sb) = (&b.rotation, &a.scale, &b.scale);
239    let matrix: [[f64; 3]; 3] = std::array::from_fn(|i| {
240        std::array::from_fn(|j| {
241            (ra[0][i] * rb[0][j] + ra[1][i] * rb[1][j] + ra[2][i] * rb[2][j]) * sb[j] / sa[i]
242        })
243    });
244    Affine {
245        matrix,
246        translation,
247    }
248}
249
250/// Whether `relative_affine(a, oa, b, ob)` is a rigid map (both placements
251/// unscaled): then [`relative_rigid`] is that map, re-orthonormalised.
252pub(crate) fn is_relative_rigid(a: &Placement, b: &Placement) -> bool {
253    !a.is_scaled() && !b.is_scaled()
254}
255
256/// The transform from mesh B's local space to mesh A's local space, origins
257/// included: `R = R_Aᵀ R_B`, `t = R_Aᵀ (R_B o_B + t_B - t_A) - o_A`, in `f64`.
258/// Only for unscaled placements (see [`is_relative_rigid`]); with scales the
259/// map is affine (see [`relative_affine`]).
260/// `R` is re-orthonormalised (within about 1e-6 of the exact product) so that
261/// `Rigid::validate` always accepts it.
262pub(crate) fn relative_rigid(
263    a: &Placement,
264    origin_a: [f64; 3],
265    b: &Placement,
266    origin_b: [f64; 3],
267) -> Rigid {
268    let (ra, rb) = (&a.rotation, &b.rotation);
269    let rotation: [[f64; 3]; 3] = std::array::from_fn(|i| {
270        std::array::from_fn(|j| ra[0][i] * rb[0][j] + ra[1][i] * rb[1][j] + ra[2][i] * rb[2][j])
271    });
272    // B's origin in the world, relative to A's translation, then into A's frame.
273    let wb = b.apply(origin_b);
274    let w = [
275        wb[0] - a.translation[0],
276        wb[1] - a.translation[1],
277        wb[2] - a.translation[2],
278    ];
279    let translation =
280        std::array::from_fn(|i| ra[0][i] * w[0] + ra[1][i] * w[1] + ra[2][i] * w[2] - origin_a[i]);
281    Rigid {
282        rotation: orthonormalize(rotation),
283        translation,
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    const RZ90: [[f64; 3]; 3] = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
292    const RX90: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]];
293
294    fn p(rotation: [[f64; 3]; 3], translation: [f64; 3]) -> Placement {
295        Placement {
296            rotation,
297            translation,
298            scale: [1.0; 3],
299        }
300    }
301
302    fn with_scale(translation: [f64; 3], scale: [f64; 3]) -> Placement {
303        Placement {
304            translation,
305            scale,
306            ..Placement::IDENTITY
307        }
308    }
309
310    #[test]
311    fn placement_validation() {
312        assert!(Placement::IDENTITY.validate().is_ok());
313        assert!(p(RZ90, [1.0, 2.0, 3.0]).validate().is_ok());
314        assert!(with_scale([0.0; 3], [2.0, 0.5, 3.0]).validate().is_ok());
315        let mut scaled = Placement::IDENTITY;
316        scaled.rotation[0][0] = 2.0;
317        assert!(matches!(
318            scaled.validate(),
319            Err(SceneError::InvalidInput(_))
320        ));
321        let mut mirror = Placement::IDENTITY;
322        mirror.rotation[2][2] = -1.0;
323        assert!(matches!(
324            mirror.validate(),
325            Err(SceneError::InvalidInput(_))
326        ));
327        let mut nan = Placement::IDENTITY;
328        nan.translation[1] = f64::NAN;
329        assert!(matches!(nan.validate(), Err(SceneError::InvalidInput(_))));
330        let mut inf = Placement::IDENTITY;
331        inf.rotation[0][1] = f64::INFINITY;
332        assert!(matches!(inf.validate(), Err(SceneError::InvalidInput(_))));
333        // Scales must be finite and positive.
334        for scale in [
335            [0.0, 1.0, 1.0],
336            [1.0, 0.0, 1.0],
337            [-1.0, 1.0, 1.0],
338            [1.0, -2.0, 1.0],
339            [f64::NAN, 1.0, 1.0],
340            [1.0, f64::INFINITY, 1.0],
341        ] {
342            assert!(
343                matches!(
344                    with_scale([0.0; 3], scale).validate(),
345                    Err(SceneError::InvalidInput(_))
346                ),
347                "scale {scale:?}"
348            );
349        }
350        assert!(Placement::IDENTITY.uniform_scale().is_some());
351        assert_eq!(with_scale([0.0; 3], [2.0; 3]).uniform_scale(), Some(2.0));
352        assert_eq!(with_scale([0.0; 3], [2.0, 1.0, 1.0]).uniform_scale(), None);
353        assert!(!Placement::IDENTITY.is_scaled());
354        assert!(with_scale([0.0; 3], [1.0, 1.0, 2.0]).is_scaled());
355    }
356
357    #[test]
358    fn scaled_apply_round_trips() {
359        let pl = Placement {
360            rotation: RZ90,
361            translation: [10.0, -4.0, 7.0],
362            scale: [2.0, 0.5, 3.0],
363        };
364        assert!(pl.validate().is_ok());
365        // world = R (S p) + t, checked by hand: S p = (2, -0.5, 9) for
366        // p = (1, -1, 3); Rz90 (x, y, z) = (-y, x, z); plus t.
367        assert_eq!(pl.apply([1.0, -1.0, 3.0]), [10.5, -2.0, 16.0]);
368        for p in [[0.0; 3], [1.0, 2.0, -3.0], [-100.5, 0.25, 999.0]] {
369            let back = pl.inverse_apply(pl.apply(p));
370            for (b, x) in back.iter().zip(p) {
371                assert!((b - x).abs() < 1e-12, "{back:?} vs {p:?}");
372            }
373        }
374        // Directions map without the translation, keeping the ray
375        // parameter: `apply(o_l + t d_l)` is the world point `apply(o) +
376        // t d`.
377        let o = [1.0, 2.0, 3.0];
378        let d = unit([1.0, -2.0, 0.5]);
379        let base = pl.apply(o);
380        let ol = pl.inverse_apply(base);
381        let dl = pl.inverse_direction(d);
382        for t in [0.0, 0.5, 10.0] {
383            let want: [f64; 3] = std::array::from_fn(|k| base[k] + t * d[k]);
384            let got: [f64; 3] = pl.apply(std::array::from_fn(|k| ol[k] + t * dl[k]));
385            for (g, w) in got.iter().zip(want) {
386                assert!((g - w).abs() < 1e-9, "{got:?} vs {want:?}");
387            }
388        }
389        // Unscaled placements behave exactly as before.
390        let rigid = p(RZ90, [10.0, -4.0, 7.0]);
391        assert_eq!(
392            rigid.apply([1.0, 2.0, 3.0]),
393            [-2.0 + 10.0, 1.0 - 4.0, 3.0 + 7.0]
394        );
395    }
396
397    #[test]
398    fn normals_follow_inverse_transpose() {
399        // Non-uniform scale [2, 1, 1]: a local 45-degree normal in xy maps
400        // to a world normal tilted towards x, still unit, on the same side.
401        let pl = with_scale([5.0, 0.0, 0.0], [2.0, 1.0, 1.0]);
402        let n = unit([1.0, 1.0, 0.0]);
403        let w = pl.normal_to_world(n);
404        assert!((dot(w, w) - 1.0).abs() < 1e-15);
405        // Inverse-transpose: R (n / S) is (0.5, 1, 0) normalised.
406        let want = unit([0.5, 1.0, 0.0]);
407        for (g, x) in w.iter().zip(want) {
408            assert!((g - x).abs() < 1e-15, "{w:?} vs {want:?}");
409        }
410        // Round trip through the local frame.
411        let back = pl.normal_to_local(w);
412        for (b, x) in back.iter().zip(n) {
413            assert!((b - x).abs() < 1e-15, "{back:?} vs {n:?}");
414        }
415        // The local plane with this normal is the preimage of the world
416        // plane: sides match.
417        let origin_w = pl.apply([0.0; 3]);
418        let local_origin = pl.inverse_apply(origin_w);
419        let nl = pl.normal_to_local([0.0, 0.0, 1.0]);
420        for q in [[1.0, 0.0, 5.0], [0.0, -3.0, -2.0], [7.0, 7.0, 0.5]] {
421            let l = pl.inverse_apply(q);
422            let side_w = dot(q, [0.0, 0.0, 1.0]) - dot(origin_w, [0.0, 0.0, 1.0]);
423            let side_l = dot(
424                [
425                    l[0] - local_origin[0],
426                    l[1] - local_origin[1],
427                    l[2] - local_origin[2],
428                ],
429                nl,
430            );
431            assert!(side_w * side_l > 0.0 || (side_w == 0.0 && side_l == 0.0));
432        }
433    }
434
435    #[test]
436    fn relative_affine_matches_apply() {
437        // B's local points mapped by the affine land where B's placement
438        // puts them relative to A: applying A's placement gives the world.
439        let a = Placement {
440            rotation: RZ90,
441            translation: [10.0, -4.0, 7.0],
442            scale: [2.0, 0.5, 1.0],
443        };
444        let b = Placement {
445            rotation: RX90,
446            translation: [-3.0, 8.0, 2.0],
447            scale: [0.5, 3.0, 2.0],
448        };
449        let (oa, ob) = ([1.0, 2.0, 3.0], [-4.0, 5.0, -6.0]);
450        let rel = relative_affine(&a, oa, &b, ob);
451        for local in [[0.0; 3], [1.0, -1.0, 0.5], [-100.0, 200.0, -300.0]] {
452            // The map takes origin-exclusive local points, like
453            // `relative_rigid`: `rel(local)` is where B's stored point
454            // `local` sits in A's stored space.
455            let in_a = rel.apply(local);
456            let world_direct = b.apply([ob[0] + local[0], ob[1] + local[1], ob[2] + local[2]]);
457            let world_via_a = a.apply([oa[0] + in_a[0], oa[1] + in_a[1], oa[2] + in_a[2]]);
458            for (g, w) in world_via_a.iter().zip(world_direct) {
459                assert!((g - w).abs() < 1e-9, "{world_via_a:?} vs {world_direct:?}");
460            }
461        }
462        // Unscaled: the affine is the rigid map before orthonormalisation.
463        let c = p(RZ90, [1.0, 0.0, 0.0]);
464        let d = p(RX90, [0.0, 1.0, 0.0]);
465        let aff = relative_affine(&c, [0.0; 3], &d, [0.0; 3]);
466        let rig = relative_rigid(&c, [0.0; 3], &d, [0.0; 3]);
467        for p in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 2.0, 3.0]] {
468            let g = aff.apply(p.map(f64::from));
469            let w = rig.apply(p.map(|x| x as f32));
470            for (g, w) in g.iter().zip(w) {
471                assert!((g - w).abs() < 1e-6, "{g:?} vs {w:?}");
472            }
473        }
474        assert!(is_relative_rigid(&c, &d));
475        assert!(!is_relative_rigid(&a, &b));
476    }
477
478    #[test]
479    fn relative_rigid_is_exact_for_integer_placements() {
480        let a = p(RZ90, [10.0, -4.0, 7.0]);
481        let b = p(RX90, [-3.0, 8.0, 2.0]);
482        let oa = [1_000_000.0, 2_000_000.0, -1_500_000.0];
483        let ob = [-1_000_000.0, 1_000_001.0, 2_500_000.0];
484        let rel = relative_rigid(&a, oa, &b, ob);
485        for local in [[1.0f32, 2.0, 3.0], [-5.0, 0.0, 4.0], [0.5, 0.25, -8.0]] {
486            let got = rel.apply(local);
487            let l = local.map(f64::from);
488            let world = b.apply([ob[0] + l[0], ob[1] + l[1], ob[2] + l[2]]);
489            // Hand-computed Rz90ᵀ (world - t_A) - o_A with Rz90ᵀ (x, y, z) = (y, -x, z).
490            let d = [world[0] - 10.0, world[1] + 4.0, world[2] - 7.0];
491            let want = [d[1] - oa[0], -d[0] - oa[1], d[2] - oa[2]];
492            assert_eq!(got, want);
493        }
494    }
495
496    #[test]
497    fn relative_rotation_of_near_orthonormal_inputs_validates() {
498        // Shears: each input is within tolerance, but the product's columns
499        // are 2e off orthogonal, so only re-orthonormalising makes it valid.
500        let e = 0.9e-6;
501        let mut a = p(RZ90, [0.0; 3]);
502        a.rotation[0][0] = e;
503        let mut b = p(RZ90, [0.0; 3]);
504        b.rotation[0][0] = e;
505        assert!(a.validate().is_ok() && b.validate().is_ok());
506        let (ra, rb) = (&a.rotation, &b.rotation);
507        let raw: [[f64; 3]; 3] = std::array::from_fn(|i| {
508            std::array::from_fn(|j| ra[0][i] * rb[0][j] + ra[1][i] * rb[1][j] + ra[2][i] * rb[2][j])
509        });
510        let raw_rigid = Rigid {
511            rotation: raw,
512            translation: [0.0; 3],
513        };
514        assert!(raw_rigid.validate().is_err(), "raw product must be invalid");
515        let rel = relative_rigid(&a, [0.0; 3], &b, [0.0; 3]);
516        assert_eq!(rel.validate(), Ok(()));
517        for (row, raw_row) in rel.rotation.iter().zip(&raw) {
518            for (x, y) in row.iter().zip(raw_row) {
519                assert!((x - y).abs() < 2e-6, "{x} vs {y}");
520            }
521        }
522    }
523
524    #[test]
525    fn world_local_round_trip() {
526        let pl = p(RX90, [3.0, -2.0, 5.0]);
527        let origin = [100.0, -50.0, 25.0];
528        let local = [0.25, -1.5, 3.0];
529        let world = local_to_world(&pl, origin, local);
530        let back = world_to_local(&pl, origin, world);
531        for (b, l) in back.iter().zip(local) {
532            assert!((b - l).abs() < 1e-12);
533        }
534    }
535}