Skip to main content

primase_core/
frame.rs

1//! The world frame: metres, right-handed, +Y up, -Z north (glTF's frame, and one consumer's); +X is east.
2//!
3//! The world is always in this frame. A reader converts from its game's frame on the way in and a writer to its
4//! target's on the way out, with a [`SourceFrame`]: a signed axis permutation plus a unit scale. Handedness follows
5//! from the permutation's determinant; a mirroring frame (determinant -1) reverses triangle winding, which the
6//! converting mesh code must undo ([`SourceFrame::flips_winding`]).
7//!
8//! Exactness: the axis part only moves and negates values, so it is bit-exact for every float, signed zeros
9//! included (negation flips the sign bit; nothing is computed as `0 - x`). A unit scale other than 1 multiplies
10//! positions and translations once, in f64, rounded once to f32: exact both ways when the scale is a power of two.
11//!
12//! Game-specific rotation conventions (Euler orders, row-vector matrices, angle units) are not frames: they belong
13//! to the game crates, which build a [`Transform`] in the game's own frame and convert it here.
14
15use crate::components::Transform;
16
17/// A signed source axis: which source coordinate, with which sign, becomes one world coordinate.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub enum Axis {
20    PosX,
21    NegX,
22    PosY,
23    NegY,
24    PosZ,
25    NegZ,
26}
27
28impl Axis {
29    /// The source coordinate's index (0 = x, 1 = y, 2 = z).
30    pub const fn index(self) -> usize {
31        match self {
32            Axis::PosX | Axis::NegX => 0,
33            Axis::PosY | Axis::NegY => 1,
34            Axis::PosZ | Axis::NegZ => 2,
35        }
36    }
37
38    pub const fn is_negative(self) -> bool {
39        matches!(self, Axis::NegX | Axis::NegY | Axis::NegZ)
40    }
41
42    const fn of(index: usize, negative: bool) -> Axis {
43        match (index, negative) {
44            (0, false) => Axis::PosX,
45            (0, true) => Axis::NegX,
46            (1, false) => Axis::PosY,
47            (1, true) => Axis::NegY,
48            (2, false) => Axis::PosZ,
49            _ => Axis::NegZ,
50        }
51    }
52}
53
54/// Why a frame description is not a frame.
55#[derive(Clone, Debug, PartialEq, thiserror::Error)]
56pub enum FrameError {
57    #[error("axes {0:?} are not a permutation of x, y, z")]
58    NotAPermutation([Axis; 3]),
59    #[error("metres per unit {0} is not a finite positive number")]
60    BadScale(f64),
61}
62
63/// A source coordinate frame described in world terms: `world[i] = sign_i * source[axes[i]] * metres_per_unit`
64/// for positions, without the scale for directions.
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct SourceFrame {
67    axes: [Axis; 3],
68    metres_per_unit: f64,
69}
70
71impl SourceFrame {
72    /// The world frame itself.
73    pub const WORLD: SourceFrame = SourceFrame::new([Axis::PosX, Axis::PosY, Axis::PosZ], 1.0);
74
75    /// East, north, up, metres (right-handed, z up): a source's own frame, one writer's internal frame, and
76    /// Blender's. `world = (east, up, -north)`; back out, `(east, north, up) = (x, -z, y)`.
77    pub const ENU: SourceFrame = SourceFrame::new([Axis::PosX, Axis::PosZ, Axis::NegY], 1.0);
78
79    /// A frame from constant parts. Panics (a compile error in a `const`) when the axes are not a permutation or the
80    /// scale is not finite and positive: frames written in code are checked where they are written. For frames read
81    /// from data use [`SourceFrame::try_new`].
82    pub const fn new(axes: [Axis; 3], metres_per_unit: f64) -> SourceFrame {
83        match SourceFrame::try_new(axes, metres_per_unit) {
84            Ok(f) => f,
85            Err(_) => {
86                panic!("not a frame: axes must be a permutation of x, y, z and the scale finite and positive")
87            }
88        }
89    }
90
91    pub const fn try_new(axes: [Axis; 3], metres_per_unit: f64) -> Result<SourceFrame, FrameError> {
92        let (a, b, c) = (axes[0].index(), axes[1].index(), axes[2].index());
93        if a == b || a == c || b == c {
94            return Err(FrameError::NotAPermutation(axes));
95        }
96        if !(metres_per_unit.is_finite() && metres_per_unit > 0.0) {
97            return Err(FrameError::BadScale(metres_per_unit));
98        }
99        Ok(SourceFrame {
100            axes,
101            metres_per_unit,
102        })
103    }
104
105    /// For world axis i, the source axis (with sign) it is taken from.
106    pub const fn axes(&self) -> [Axis; 3] {
107        self.axes
108    }
109
110    pub const fn metres_per_unit(&self) -> f64 {
111        self.metres_per_unit
112    }
113
114    /// The determinant of the axis part: +1 when the source is right-handed like the world, -1 when it is
115    /// left-handed (converting mirrors).
116    pub const fn determinant(&self) -> i32 {
117        let [a, b, c] = self.axes;
118        // parity of the permutation (a, b, c) of (0, 1, 2): even when it is a rotation of the identity
119        let even = (a.index() + 1) % 3 == b.index() && (b.index() + 1) % 3 == c.index();
120        let negs = a.is_negative() as u32 + b.is_negative() as u32 + c.is_negative() as u32;
121        if even == negs.is_multiple_of(2) { 1 } else { -1 }
122    }
123
124    /// Whether converting a mesh mirrors it, so its triangles' winding must be reversed to keep their fronts.
125    pub const fn flips_winding(&self) -> bool {
126        self.determinant() == -1
127    }
128
129    /// The world frame described in this frame's terms: converting with the inverse undoes a conversion.
130    pub const fn inverse(&self) -> SourceFrame {
131        let mut inv = [Axis::PosX; 3];
132        let mut i = 0;
133        while i < 3 {
134            let a = self.axes[i];
135            inv[a.index()] = Axis::of(i, a.is_negative());
136            i += 1;
137        }
138        SourceFrame::new(inv, 1.0 / self.metres_per_unit)
139    }
140
141    /// A direction (normal, tangent, axis): signed permutation only, bit-exact.
142    pub fn direction(&self, d: [f32; 3]) -> [f32; 3] {
143        self.axes.map(|a| signed(d[a.index()], a))
144    }
145
146    /// A position: signed permutation, then the unit scale (f64, rounded once; exact for scale 1).
147    pub fn point(&self, p: [f32; 3]) -> [f32; 3] {
148        let d = self.direction(p);
149        if self.metres_per_unit == 1.0 {
150            return d;
151        }
152        d.map(|x| (f64::from(x) * self.metres_per_unit) as f32)
153    }
154
155    /// A transform `world = M * local` whose local and world sides are both in this frame: `C M C^-1`. The linear
156    /// part only moves and negates entries (bit-exact; the scale cancels), the translation converts as a point.
157    pub fn transform(&self, m: &Transform) -> Transform {
158        let mut out = Transform { cols: [[0.0; 3]; 4] };
159        for (j, a) in self.axes.iter().enumerate() {
160            out.cols[j] = self.direction(m.cols[a.index()]).map(|x| signed(x, *a));
161        }
162        out.cols[3] = self.point(m.cols[3]);
163        out
164    }
165
166    /// World to this frame: the inverse conversions.
167    pub fn direction_to_source(&self, d: [f32; 3]) -> [f32; 3] {
168        let mut out = [0.0; 3];
169        for (i, a) in self.axes.iter().enumerate() {
170            out[a.index()] = signed(d[i], *a);
171        }
172        out
173    }
174
175    pub fn point_to_source(&self, p: [f32; 3]) -> [f32; 3] {
176        let d = self.direction_to_source(p);
177        if self.metres_per_unit == 1.0 {
178            return d;
179        }
180        d.map(|x| (f64::from(x) / self.metres_per_unit) as f32)
181    }
182
183    pub fn transform_to_source(&self, m: &Transform) -> Transform {
184        let mut out = Transform { cols: [[0.0; 3]; 4] };
185        for (i, a) in self.axes.iter().enumerate() {
186            out.cols[a.index()] = self.direction_to_source(m.cols[i]).map(|x| signed(x, *a));
187        }
188        out.cols[3] = self.point_to_source(m.cols[3]);
189        out
190    }
191
192    /// A length in metres, in this frame's units (exact for scale 1).
193    pub fn length_to_source(&self, l: f32) -> f32 {
194        if self.metres_per_unit == 1.0 {
195            return l;
196        }
197        (f64::from(l) / self.metres_per_unit) as f32
198    }
199
200    /// One over a length in metres, in this frame's units (exact for scale 1).
201    pub fn inverse_length_to_source(&self, l: f32) -> f32 {
202        scale(l, self.metres_per_unit)
203    }
204
205    /// A tangent (xyz, w the bitangent sign) in this frame: xyz as a direction, w negated where the frame mirrors.
206    pub fn tangent_to_source(&self, t: [f32; 4]) -> [f32; 4] {
207        let d = self.direction_to_source([t[0], t[1], t[2]]);
208        [d[0], d[1], d[2], if self.flips_winding() { -t[3] } else { t[3] }]
209    }
210
211    /// A plane `(n, d)` (`dot(n, p) + d = 0` for its world points `p`) in this frame: the normal as a direction, the
212    /// offset as a length.
213    pub fn plane_to_source(&self, plane: [f32; 4]) -> [f32; 4] {
214        let n = self.direction_to_source([plane[0], plane[1], plane[2]]);
215        [n[0], n[1], n[2], self.length_to_source(plane[3])]
216    }
217
218    /// A 4 x 4 matrix (by columns) that takes world positions to another space (the view, the clip), made to take this
219    /// frame's positions to the same space: `M C`, `C` this frame's map to the world, so `(M C) (C^-1 p) = M p` and
220    /// what it gives is unchanged. Column `j` of `M C` is the column of `M` for the world axis this frame's axis `j`
221    /// is, signed and times the unit scale: for scale 1 the entries only move and change sign (bit-exact), and the
222    /// product with a converted point sums the same terms in this frame's axis order (the same order, so the same
223    /// bits, for a frame that only changes signs).
224    pub fn from_world_matrix_to_source(&self, m: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
225        let mut out = m;
226        for (i, a) in self.axes.iter().enumerate() {
227            out[a.index()] = m[i].map(|x| scale(signed(x, *a), self.metres_per_unit));
228        }
229        out
230    }
231
232    /// A 4 x 4 matrix (by columns) that gives world positions (from the view, the clip), made to give this frame's:
233    /// `C^-1 M`, each column's xyz converted as a point and its w kept.
234    pub fn to_world_matrix_to_source(&self, m: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
235        m.map(|c| {
236            let p = self.point_to_source([c[0], c[1], c[2]]);
237            [p[0], p[1], p[2], c[3]]
238        })
239    }
240
241    /// A 4 x 4 matrix (by columns) from one world-frame space to another (an object's to the world, a joint's) in this
242    /// frame's terms: `C^-1 M C`, as [`SourceFrame::transform_to_source`] converts an affine transform.
243    pub fn world_matrix_to_source(&self, m: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
244        self.to_world_matrix_to_source(self.from_world_matrix_to_source(m))
245    }
246}
247
248/// A frame conversion moves a mesh's streams as it moves positions and directions: normals and tangents by the signed
249/// permutation (a uniform scale turns no direction), a tangent's bitangent sign negated where the conversion mirrors.
250impl crate::VertexMap for SourceFrame {
251    fn point(&self, p: [f32; 3]) -> [f32; 3] {
252        SourceFrame::point(self, p)
253    }
254    fn normal(&self, n: [f32; 3]) -> Option<[f32; 3]> {
255        Some(self.direction(n))
256    }
257    fn tangent(&self, t: [f32; 4]) -> Option<[f32; 4]> {
258        let d = self.direction([t[0], t[1], t[2]]);
259        Some([d[0], d[1], d[2], if self.flips_winding() { -t[3] } else { t[3] }])
260    }
261}
262
263/// `x` negated (sign bit flipped) when the axis is negative.
264fn signed(x: f32, a: Axis) -> f32 {
265    if a.is_negative() { -x } else { x }
266}
267
268/// `x` times the unit scale, in f64, rounded once (`x` itself for scale 1).
269fn scale(x: f32, metres_per_unit: f64) -> f32 {
270    if metres_per_unit == 1.0 {
271        return x;
272    }
273    (f64::from(x) * metres_per_unit) as f32
274}
275
276/// The ground frame `(east, north)` of a world point: `(x, -z)`, bit-exact.
277pub fn ground(p: [f32; 3]) -> [f32; 2] {
278    [p[0], -p[2]]
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use Axis::*;
285
286    fn bits(v: [f32; 3]) -> [u32; 3] {
287        v.map(f32::to_bits)
288    }
289
290    fn mul(m: &Transform, p: [f32; 3]) -> [f32; 3] {
291        std::array::from_fn(|i| {
292            m.cols[0][i] * p[0] + m.cols[1][i] * p[1] + m.cols[2][i] * p[2] + m.cols[3][i]
293        })
294    }
295
296    /// A left-handed source, Y up, metres; scene = (x, y, -z),
297    /// transforms mirrored as `C M C` with C = diag(1, 1, -1).
298    const FROMSOFT: SourceFrame = SourceFrame::new([PosX, PosY, NegZ], 1.0);
299    /// A left-handed source, Y up -> (x, y, -z).
300    const UNITY: SourceFrame = SourceFrame::new([PosX, PosY, NegZ], 1.0);
301    /// ForzaTech: left-handed, Y up, metres; Blender =
302    /// (-x, -z, y). Blender is ENU, so world = (-x, y, z).
303    const FORZA: SourceFrame = SourceFrame::new([NegX, PosY, PosZ], 1.0);
304
305    #[test]
306    fn handedness_follows_the_determinant() {
307        assert_eq!(SourceFrame::WORLD.determinant(), 1);
308        assert_eq!(SourceFrame::ENU.determinant(), 1);
309        assert!(!SourceFrame::ENU.flips_winding());
310        for f in [FROMSOFT, UNITY, FORZA] {
311            assert_eq!(f.determinant(), -1, "{f:?}");
312            assert!(f.flips_winding());
313        }
314        // every signed permutation against the determinant computed as a matrix
315        let all = [PosX, NegX, PosY, NegY, PosZ, NegZ];
316        let mut n = 0;
317        for a in all {
318            for b in all {
319                for c in all {
320                    let Ok(f) = SourceFrame::try_new([a, b, c], 1.0) else {
321                        continue;
322                    };
323                    let m: [[i32; 3]; 3] = std::array::from_fn(|i| {
324                        let ax = f.axes()[i];
325                        std::array::from_fn(|j| {
326                            if ax.index() == j {
327                                if ax.is_negative() { -1 } else { 1 }
328                            } else {
329                                0
330                            }
331                        })
332                    });
333                    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
334                        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
335                        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
336                    assert_eq!(f.determinant(), det, "{f:?}");
337                    n += 1;
338                }
339            }
340        }
341        assert_eq!(n, 48, "6 permutations x 8 sign choices");
342    }
343
344    #[test]
345    fn invalid_frames_are_errors() {
346        assert_eq!(
347            SourceFrame::try_new([PosX, NegX, PosZ], 1.0),
348            Err(FrameError::NotAPermutation([PosX, NegX, PosZ]))
349        );
350        assert!(matches!(
351            SourceFrame::try_new([PosX, PosY, PosZ], 0.0),
352            Err(FrameError::BadScale(_))
353        ));
354        assert!(matches!(
355            SourceFrame::try_new([PosX, PosY, PosZ], f64::NAN),
356            Err(FrameError::BadScale(_))
357        ));
358    }
359
360    const IDENTITY: [[f32; 4]; 4] = [
361        [1.0, 0.0, 0.0, 0.0],
362        [0.0, 1.0, 0.0, 0.0],
363        [0.0, 0.0, 1.0, 0.0],
364        [0.0, 0.0, 0.0, 1.0],
365    ];
366
367    fn mat_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] {
368        std::array::from_fn(|j| std::array::from_fn(|i| (0..4).map(|k| a[k][i] * b[j][k]).sum()))
369    }
370
371    fn mat_apply(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 4] {
372        let p = [p[0], p[1], p[2], 1.0];
373        std::array::from_fn(|i| (0..4).map(|k| m[k][i] * p[k]).sum())
374    }
375
376    fn xyz(v: [f32; 4]) -> [f32; 3] {
377        [v[0], v[1], v[2]]
378    }
379
380    /// The matrix forms against the frame's own point map, over every signed permutation at scale 1 and a scaled frame:
381    /// a world-to-view matrix gives what it gave for the converted point, an object-to-world one takes a converted
382    /// point to the converted point, a view-to-world one gives the converted point; the entries only move and change
383    /// sign at scale 1.
384    #[test]
385    fn matrices_take_and_give_the_frames_points() {
386        let m: [[f32; 4]; 4] = [
387            [0.6, -0.8, 0.1, 0.0],
388            [0.8, 0.6, -0.3, 0.0],
389            [0.25, 0.5, 0.9, 0.0],
390            [3.0, -2.0, 7.5, 1.0],
391        ];
392        let p = [1.5, -2.25, 4.0];
393        let world = mat_apply(&m, p);
394        let all = [PosX, NegX, PosY, NegY, PosZ, NegZ];
395        let mut n = 0;
396        for a in all {
397            for b in all {
398                for c in all {
399                    let Ok(f) = SourceFrame::try_new([a, b, c], 1.0) else {
400                        continue;
401                    };
402                    let q = f.point_to_source(p);
403                    let from = mat_apply(&f.from_world_matrix_to_source(m), q);
404                    assert!(
405                        from.iter().zip(world).all(|(x, y)| (x - y).abs() <= 1e-5),
406                        "{f:?}"
407                    );
408                    let moved = f.point_to_source(xyz(world));
409                    let within = xyz(mat_apply(&f.world_matrix_to_source(m), q));
410                    assert!(
411                        within.iter().zip(moved).all(|(x, y)| (x - y).abs() <= 1e-5),
412                        "{f:?}"
413                    );
414                    let to = xyz(mat_apply(&f.to_world_matrix_to_source(m), p));
415                    assert_eq!(bits(to), bits(moved), "{f:?}");
416                    let entries = |m: [[f32; 4]; 4]| {
417                        let mut e: Vec<u32> = m.iter().flatten().map(|x| x.abs().to_bits()).collect();
418                        e.sort_unstable();
419                        e
420                    };
421                    assert_eq!(entries(f.world_matrix_to_source(m)), entries(m), "{f:?}");
422                    n += 1;
423                }
424            }
425        }
426        assert_eq!(n, 48);
427        // a frame that only changes signs keeps the axis order: the products are bit-exact
428        let from = mat_apply(
429            &FROMSOFT.from_world_matrix_to_source(m),
430            FROMSOFT.point_to_source(p),
431        );
432        assert_eq!(from.map(f32::to_bits), world.map(f32::to_bits));
433        // (M C)(C^-1) = M: a world-to-clip matrix fed this way puts every vertex where it was, so rasterisation and
434        // the winding a triangle shows on screen are unchanged
435        let back = mat_mul(
436            &FROMSOFT.from_world_matrix_to_source(m),
437            &FROMSOFT.to_world_matrix_to_source(IDENTITY),
438        );
439        assert_eq!(back, m);
440        // a scaled frame: positions, offsets and lengths in its units
441        let cm = SourceFrame::new([PosX, PosZ, NegY], 0.01);
442        let from = mat_apply(&cm.from_world_matrix_to_source(m), cm.point_to_source(p));
443        assert!(
444            from.iter().zip(world).all(|(x, y)| (x - y).abs() <= 1e-4),
445            "{from:?}"
446        );
447        assert_eq!(cm.length_to_source(2.0), 200.0);
448        assert_eq!(cm.inverse_length_to_source(0.5), 0.005);
449        let plane = cm.plane_to_source([0.0, 1.0, 0.0, -3.0]);
450        let on = cm.point_to_source([5.0, 3.0, -1.0]);
451        let d = plane[0] * on[0] + plane[1] * on[1] + plane[2] * on[2] + plane[3];
452        assert!(d.abs() < 1e-3, "{plane:?}");
453        assert_eq!(
454            FROMSOFT.tangent_to_source([1.0, 0.0, 0.5, 1.0]),
455            [1.0, 0.0, -0.5, -1.0]
456        );
457        assert_eq!(SourceFrame::ENU.tangent_to_source([1.0, 0.0, 0.5, 1.0])[3], 1.0);
458    }
459
460    #[test]
461    fn fromsoft_and_unity_mirror_z() {
462        assert_eq!(FROMSOFT.point([1.5, 2.0, 3.0]), [1.5, 2.0, -3.0]);
463        assert_eq!(UNITY.direction([0.0, 0.0, 1.0]), [0.0, 0.0, -1.0]);
464        // C M C with C = diag(1, 1, -1): entries (i, j) with exactly one of i, j = z change sign
465        let m = Transform {
466            cols: [
467                [1.0, 2.0, 3.0],
468                [4.0, 5.0, 6.0],
469                [7.0, 8.0, 9.0],
470                [10.0, 11.0, 12.0],
471            ],
472        };
473        let want = Transform {
474            cols: [
475                [1.0, 2.0, -3.0],
476                [4.0, 5.0, -6.0],
477                [-7.0, -8.0, 9.0],
478                [10.0, 11.0, -12.0],
479            ],
480        };
481        assert_eq!(FROMSOFT.transform(&m), want);
482    }
483
484    #[test]
485    fn enu_is_the_writer_frame_bit_exact_with_signed_zeros() {
486        // scene-interchange v1 "Frames": (east, north, up) = (x, -z, y), exact for every float including signed zeros
487        let world = [0.0, -0.0, 0.0];
488        let enu = SourceFrame::ENU.point_to_source(world);
489        assert_eq!(bits(enu), bits([0.0, -0.0, -0.0]));
490        assert_eq!(bits(SourceFrame::ENU.point(enu)), bits(world));
491        let p = [1.25, -7.5, 3.0e-41]; // a subnormal survives too
492        assert_eq!(SourceFrame::ENU.point_to_source(p), [1.25, -3.0e-41, -7.5]);
493        assert_eq!(ground(p), [1.25, -3.0e-41]);
494        assert_eq!(
495            ground([0.0, 0.0, 0.0]).map(f32::to_bits),
496            [0.0f32.to_bits(), (-0.0f32).to_bits()]
497        );
498        // and the ENU source's (east, north, up) to the world
499        assert_eq!(SourceFrame::ENU.point([10.0, 20.0, 30.0]), [10.0, 30.0, -20.0]);
500    }
501
502    #[test]
503    fn forza_through_blender_matches_forza_direct() {
504        let to_blender = |p: [f32; 3]| [-p[0], -p[2], p[1]]; // the ForzaTech -> Blender mapping
505        for p in [[1.0, 2.0, 3.0], [-0.0, 0.5, -4.0], [7.25, -1.0, 0.0]] {
506            assert_eq!(bits(FORZA.point(p)), bits(SourceFrame::ENU.point(to_blender(p))));
507        }
508    }
509
510    #[test]
511    fn transforms_commute_with_points_and_invert() {
512        let all = [PosX, NegX, PosY, NegY, PosZ, NegZ];
513        // integer-valued so every product and sum below is exact in f32
514        let m = Transform {
515            cols: [
516                [2.0, 0.0, 1.0],
517                [-1.0, 3.0, 0.0],
518                [0.0, 1.0, -2.0],
519                [5.0, -6.0, 7.0],
520            ],
521        };
522        let locals = [[1.0, 2.0, 3.0], [-4.0, 0.0, 1.0], [0.5, -0.25, 8.0]];
523        for a in all {
524            for b in all {
525                for c in all {
526                    for scale in [1.0, 0.5, 4.0] {
527                        let Ok(f) = SourceFrame::try_new([a, b, c], scale) else {
528                            continue;
529                        };
530                        let fm = f.transform(&m);
531                        for l in locals {
532                            assert_eq!(f.point(mul(&m, l)), mul(&fm, f.point(l)), "{f:?}");
533                        }
534                        assert_eq!(f.transform_to_source(&fm), m, "{f:?}");
535                        assert_eq!(f.inverse().transform(&fm), m, "{f:?}");
536                        assert_eq!(f.inverse().inverse(), f);
537                        for l in locals {
538                            assert_eq!(bits(f.point_to_source(f.point(l))), bits(l));
539                            assert_eq!(bits(f.inverse().point(f.point(l))), bits(l));
540                            assert_eq!(bits(f.direction_to_source(f.direction(l))), bits(l));
541                        }
542                    }
543                }
544            }
545        }
546    }
547
548    #[test]
549    fn scale_applies_to_points_and_translations_only() {
550        let cm = SourceFrame::new([Axis::PosX, Axis::PosY, Axis::PosZ], 0.01);
551        assert_eq!(cm.point([100.0, 250.0, -50.0]), [1.0, 2.5, -0.5]);
552        assert_eq!(cm.direction([0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]);
553        let m = Transform {
554            cols: [
555                [1.0, 0.0, 0.0],
556                [0.0, 1.0, 0.0],
557                [0.0, 0.0, 1.0],
558                [300.0, 0.0, 0.0],
559            ],
560        };
561        assert_eq!(cm.transform(&m).cols[3], [3.0, 0.0, 0.0]);
562        assert_eq!(
563            cm.transform(&m).cols[0],
564            [1.0, 0.0, 0.0],
565            "the scale cancels in the linear part"
566        );
567    }
568}