Skip to main content

azul_core/
transform.rs

1//! 3D transform matrix computations for CSS transforms.
2//!
3//! This module implements 4x4 transformation matrices for CSS `transform` properties,
4//! including translation, rotation, scaling, skewing, and perspective. It handles conversion
5//! from CSS transform functions to hardware-accelerated matrices for WebRender.
6//!
7//! On x86_64 platforms, the module automatically detects and uses SSE/AVX instructions
8//! for optimized matrix multiplication and inversion.
9//!
10//! **NOTE**: Matrices are stored in **row-major** format (unlike some graphics APIs that
11//! use column-major). The module handles coordinate system differences between WebRender
12//! and hit-testing via the `RotationMode` enum.
13
14use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
15
16use azul_css::props::style::{StyleTransform, StyleTransformOrigin};
17
18use crate::geom::LogicalPosition;
19
20/// CPU feature detection: true if initialization has been performed
21pub static INITIALIZED: AtomicBool = AtomicBool::new(false);
22/// CPU feature detection: true if AVX instructions are available
23pub static USE_AVX: AtomicBool = AtomicBool::new(false);
24/// CPU feature detection: true if SSE instructions are available
25pub static USE_SSE: AtomicBool = AtomicBool::new(false);
26
27/// Specifies the coordinate system convention for rotations.
28///
29/// `WebRender` uses a different rotation direction than hit-testing, so transforms
30/// must be adjusted based on their use case. This enum controls whether the
31/// rotation matrix is inverted to match the expected behavior.
32#[derive(Debug, Copy, Clone)]
33pub enum RotationMode {
34    /// Use rotation convention for `WebRender` (counter-clockwise, requires inversion)
35    ForWebRender,
36    /// Use rotation convention for hit-testing (clockwise, no inversion)
37    ForHitTesting,
38}
39
40/// A computed 4x4 transformation matrix in pixel space.
41///
42/// Represents the final transformation matrix for a DOM element after applying
43/// all CSS transform functions (translate, rotate, scale, etc.) and accounting
44/// for transform-origin.
45///
46/// # Memory Layout
47///
48/// Matrix is stored in **row-major** format:
49/// ```text
50/// m[0] = [m11, m12, m13, m14]
51/// m[1] = [m21, m22, m23, m24]
52/// m[2] = [m31, m32, m33, m34]
53/// m[3] = [m41, m42, m43, m44]
54/// ```
55#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
56#[repr(C)]
57pub struct ComputedTransform3D {
58    /// The 4x4 matrix in row-major format
59    pub m: [[f32; 4]; 4],
60}
61
62impl ComputedTransform3D {
63    /// The identity matrix (no transformation).
64    pub const IDENTITY: Self = Self {
65        m: [
66            [1.0, 0.0, 0.0, 0.0],
67            [0.0, 1.0, 0.0, 0.0],
68            [0.0, 0.0, 1.0, 0.0],
69            [0.0, 0.0, 0.0, 1.0],
70        ],
71    };
72
73    /// Creates a new 4x4 transformation matrix with the given elements.
74    ///
75    /// Elements are specified in row-major order (m11, m12, ..., m44).
76    #[must_use]
77    pub const fn new(
78        m11: f32,
79        m12: f32,
80        m13: f32,
81        m14: f32,
82        m21: f32,
83        m22: f32,
84        m23: f32,
85        m24: f32,
86        m31: f32,
87        m32: f32,
88        m33: f32,
89        m34: f32,
90        m41: f32,
91        m42: f32,
92        m43: f32,
93        m44: f32,
94    ) -> Self {
95        Self {
96            m: [
97                [m11, m12, m13, m14],
98                [m21, m22, m23, m24],
99                [m31, m32, m33, m34],
100                [m41, m42, m43, m44],
101            ],
102        }
103    }
104
105    /// Creates a 2D transformation matrix (3D matrix with Z = 0).
106    ///
107    /// This is equivalent to the CSS `matrix()` function. The transformation
108    /// only affects the X and Y axes.
109    ///
110    /// Corresponds to `matrix(m11, m12, m21, m22, m41, m42)` in CSS.
111    const fn new_2d(m11: f32, m12: f32, m21: f32, m22: f32, m41: f32, m42: f32) -> Self {
112        Self::new(
113            m11, m12, 0.0, 0.0, m21, m22, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, m41, m42, 0.0, 1.0,
114        )
115    }
116
117    /// Computes the inverse of this transformation matrix.
118    ///
119    /// This function uses a standard matrix inversion algorithm. Returns the
120    /// identity matrix if the determinant is zero (singular matrix).
121    ///
122    /// NOTE: This is a relatively expensive operation.
123    #[must_use]
124    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
125    pub fn inverse(&self) -> Self {
126        let det = self.determinant();
127
128        if det.abs() < f32::EPSILON {
129            return Self::IDENTITY;
130        }
131
132        let m = Self::new(
133            self.m[1][2] * self.m[2][3] * self.m[3][1] - self.m[1][3] * self.m[2][2] * self.m[3][1]
134                + self.m[1][3] * self.m[2][1] * self.m[3][2]
135                - self.m[1][1] * self.m[2][3] * self.m[3][2]
136                - self.m[1][2] * self.m[2][1] * self.m[3][3]
137                + self.m[1][1] * self.m[2][2] * self.m[3][3],
138            self.m[0][3] * self.m[2][2] * self.m[3][1]
139                - self.m[0][2] * self.m[2][3] * self.m[3][1]
140                - self.m[0][3] * self.m[2][1] * self.m[3][2]
141                + self.m[0][1] * self.m[2][3] * self.m[3][2]
142                + self.m[0][2] * self.m[2][1] * self.m[3][3]
143                - self.m[0][1] * self.m[2][2] * self.m[3][3],
144            self.m[0][2] * self.m[1][3] * self.m[3][1] - self.m[0][3] * self.m[1][2] * self.m[3][1]
145                + self.m[0][3] * self.m[1][1] * self.m[3][2]
146                - self.m[0][1] * self.m[1][3] * self.m[3][2]
147                - self.m[0][2] * self.m[1][1] * self.m[3][3]
148                + self.m[0][1] * self.m[1][2] * self.m[3][3],
149            self.m[0][3] * self.m[1][2] * self.m[2][1]
150                - self.m[0][2] * self.m[1][3] * self.m[2][1]
151                - self.m[0][3] * self.m[1][1] * self.m[2][2]
152                + self.m[0][1] * self.m[1][3] * self.m[2][2]
153                + self.m[0][2] * self.m[1][1] * self.m[2][3]
154                - self.m[0][1] * self.m[1][2] * self.m[2][3],
155            self.m[1][3] * self.m[2][2] * self.m[3][0]
156                - self.m[1][2] * self.m[2][3] * self.m[3][0]
157                - self.m[1][3] * self.m[2][0] * self.m[3][2]
158                + self.m[1][0] * self.m[2][3] * self.m[3][2]
159                + self.m[1][2] * self.m[2][0] * self.m[3][3]
160                - self.m[1][0] * self.m[2][2] * self.m[3][3],
161            self.m[0][2] * self.m[2][3] * self.m[3][0] - self.m[0][3] * self.m[2][2] * self.m[3][0]
162                + self.m[0][3] * self.m[2][0] * self.m[3][2]
163                - self.m[0][0] * self.m[2][3] * self.m[3][2]
164                - self.m[0][2] * self.m[2][0] * self.m[3][3]
165                + self.m[0][0] * self.m[2][2] * self.m[3][3],
166            self.m[0][3] * self.m[1][2] * self.m[3][0]
167                - self.m[0][2] * self.m[1][3] * self.m[3][0]
168                - self.m[0][3] * self.m[1][0] * self.m[3][2]
169                + self.m[0][0] * self.m[1][3] * self.m[3][2]
170                + self.m[0][2] * self.m[1][0] * self.m[3][3]
171                - self.m[0][0] * self.m[1][2] * self.m[3][3],
172            self.m[0][2] * self.m[1][3] * self.m[2][0] - self.m[0][3] * self.m[1][2] * self.m[2][0]
173                + self.m[0][3] * self.m[1][0] * self.m[2][2]
174                - self.m[0][0] * self.m[1][3] * self.m[2][2]
175                - self.m[0][2] * self.m[1][0] * self.m[2][3]
176                + self.m[0][0] * self.m[1][2] * self.m[2][3],
177            self.m[1][1] * self.m[2][3] * self.m[3][0] - self.m[1][3] * self.m[2][1] * self.m[3][0]
178                + self.m[1][3] * self.m[2][0] * self.m[3][1]
179                - self.m[1][0] * self.m[2][3] * self.m[3][1]
180                - self.m[1][1] * self.m[2][0] * self.m[3][3]
181                + self.m[1][0] * self.m[2][1] * self.m[3][3],
182            self.m[0][3] * self.m[2][1] * self.m[3][0]
183                - self.m[0][1] * self.m[2][3] * self.m[3][0]
184                - self.m[0][3] * self.m[2][0] * self.m[3][1]
185                + self.m[0][0] * self.m[2][3] * self.m[3][1]
186                + self.m[0][1] * self.m[2][0] * self.m[3][3]
187                - self.m[0][0] * self.m[2][1] * self.m[3][3],
188            self.m[0][1] * self.m[1][3] * self.m[3][0] - self.m[0][3] * self.m[1][1] * self.m[3][0]
189                + self.m[0][3] * self.m[1][0] * self.m[3][1]
190                - self.m[0][0] * self.m[1][3] * self.m[3][1]
191                - self.m[0][1] * self.m[1][0] * self.m[3][3]
192                + self.m[0][0] * self.m[1][1] * self.m[3][3],
193            self.m[0][3] * self.m[1][1] * self.m[2][0]
194                - self.m[0][1] * self.m[1][3] * self.m[2][0]
195                - self.m[0][3] * self.m[1][0] * self.m[2][1]
196                + self.m[0][0] * self.m[1][3] * self.m[2][1]
197                + self.m[0][1] * self.m[1][0] * self.m[2][3]
198                - self.m[0][0] * self.m[1][1] * self.m[2][3],
199            self.m[1][2] * self.m[2][1] * self.m[3][0]
200                - self.m[1][1] * self.m[2][2] * self.m[3][0]
201                - self.m[1][2] * self.m[2][0] * self.m[3][1]
202                + self.m[1][0] * self.m[2][2] * self.m[3][1]
203                + self.m[1][1] * self.m[2][0] * self.m[3][2]
204                - self.m[1][0] * self.m[2][1] * self.m[3][2],
205            self.m[0][1] * self.m[2][2] * self.m[3][0] - self.m[0][2] * self.m[2][1] * self.m[3][0]
206                + self.m[0][2] * self.m[2][0] * self.m[3][1]
207                - self.m[0][0] * self.m[2][2] * self.m[3][1]
208                - self.m[0][1] * self.m[2][0] * self.m[3][2]
209                + self.m[0][0] * self.m[2][1] * self.m[3][2],
210            self.m[0][2] * self.m[1][1] * self.m[3][0]
211                - self.m[0][1] * self.m[1][2] * self.m[3][0]
212                - self.m[0][2] * self.m[1][0] * self.m[3][1]
213                + self.m[0][0] * self.m[1][2] * self.m[3][1]
214                + self.m[0][1] * self.m[1][0] * self.m[3][2]
215                - self.m[0][0] * self.m[1][1] * self.m[3][2],
216            self.m[0][1] * self.m[1][2] * self.m[2][0] - self.m[0][2] * self.m[1][1] * self.m[2][0]
217                + self.m[0][2] * self.m[1][0] * self.m[2][1]
218                - self.m[0][0] * self.m[1][2] * self.m[2][1]
219                - self.m[0][1] * self.m[1][0] * self.m[2][2]
220                + self.m[0][0] * self.m[1][1] * self.m[2][2],
221        );
222
223        m.multiply_scalar(1.0 / det)
224    }
225
226    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
227    fn determinant(&self) -> f32 {
228        // Accumulate in f64. Individual f32 products (e.g. m[0][0]*m[1][1] on a
229        // diag(1e20) matrix = 1e40) overflow to ±inf BEFORE the legitimately-zero
230        // off-diagonal factors multiply in, and inf * 0 = NaN, poisoning the whole sum.
231        // f64 has the range to hold the products; the final cast saturates a real
232        // overflow to ±inf and propagates a NaN input as NaN.
233        let m = |i: usize, j: usize| f64::from(self.m[i][j]);
234        let det = m(0, 3) * m(1, 2) * m(2, 1) * m(3, 0)
235            - m(0, 2) * m(1, 3) * m(2, 1) * m(3, 0)
236            - m(0, 3) * m(1, 1) * m(2, 2) * m(3, 0)
237            + m(0, 1) * m(1, 3) * m(2, 2) * m(3, 0)
238            + m(0, 2) * m(1, 1) * m(2, 3) * m(3, 0)
239            - m(0, 1) * m(1, 2) * m(2, 3) * m(3, 0)
240            - m(0, 3) * m(1, 2) * m(2, 0) * m(3, 1)
241            + m(0, 2) * m(1, 3) * m(2, 0) * m(3, 1)
242            + m(0, 3) * m(1, 0) * m(2, 2) * m(3, 1)
243            - m(0, 0) * m(1, 3) * m(2, 2) * m(3, 1)
244            - m(0, 2) * m(1, 0) * m(2, 3) * m(3, 1)
245            + m(0, 0) * m(1, 2) * m(2, 3) * m(3, 1)
246            + m(0, 3) * m(1, 1) * m(2, 0) * m(3, 2)
247            - m(0, 1) * m(1, 3) * m(2, 0) * m(3, 2)
248            - m(0, 3) * m(1, 0) * m(2, 1) * m(3, 2)
249            + m(0, 0) * m(1, 3) * m(2, 1) * m(3, 2)
250            + m(0, 1) * m(1, 0) * m(2, 3) * m(3, 2)
251            - m(0, 0) * m(1, 1) * m(2, 3) * m(3, 2)
252            - m(0, 2) * m(1, 1) * m(2, 0) * m(3, 3)
253            + m(0, 1) * m(1, 2) * m(2, 0) * m(3, 3)
254            + m(0, 2) * m(1, 0) * m(2, 1) * m(3, 3)
255            - m(0, 0) * m(1, 2) * m(2, 1) * m(3, 3)
256            - m(0, 1) * m(1, 0) * m(2, 2) * m(3, 3)
257            + m(0, 0) * m(1, 1) * m(2, 2) * m(3, 3);
258        #[allow(clippy::cast_possible_truncation)]
259        // determinant computed in f64, narrowed to the f32 public type
260        let det = det as f32;
261        det
262    }
263
264    fn multiply_scalar(&self, x: f32) -> Self {
265        Self::new(
266            self.m[0][0] * x,
267            self.m[0][1] * x,
268            self.m[0][2] * x,
269            self.m[0][3] * x,
270            self.m[1][0] * x,
271            self.m[1][1] * x,
272            self.m[1][2] * x,
273            self.m[1][3] * x,
274            self.m[2][0] * x,
275            self.m[2][1] * x,
276            self.m[2][2] * x,
277            self.m[2][3] * x,
278            self.m[3][0] * x,
279            self.m[3][1] * x,
280            self.m[3][2] * x,
281            self.m[3][3] * x,
282        )
283    }
284
285    /// Computes the matrix of a rect from a `&[StyleTransform]`.
286    pub fn from_style_transform_vec(
287        t_vec: &[StyleTransform],
288        transform_origin: &StyleTransformOrigin,
289        percent_resolve_x: f32,
290        percent_resolve_y: f32,
291        rotation_mode: RotationMode,
292    ) -> Self {
293        // Uses AVX or SSE SIMD when available on x86_64
294        //
295        // AUDIT-TODO: `USE_AVX`/`USE_SSE` are populated in `gpu.rs` from a raw
296        // CPUID leaf-1 feature bit (ECX[28] for AVX), which reports only that
297        // the CPU *implements* AVX — NOT that the OS has enabled the YMM state
298        // via XCR0 (XGETBV). On a kernel that didn't `XSETBV`-enable AVX, using
299        // these intrinsics faults with SIGILL. The robust gate is
300        // `is_x86_feature_detected!("avx")` / `("sse")`, which also checks the
301        // OS-enabled bit. That detection lives in `gpu.rs` (out of scope for
302        // this edit); consumers here rely on it having gated the flags. Prefer
303        // migrating the `gpu.rs` probe to `is_x86_feature_detected!`.
304        // CSS Transforms Level 1 §9 ("The Transform Rendering Model"):
305        //
306        //   1. The functions are MULTIPLIED left to right, so the LAST listed
307        //      function is the first one applied to a point: `translate(100px)
308        //      scale(2)` scales first, then translates - a point at (1, 0)
309        //      lands at (102, 0), not (202, 0). `a.then(b)` applies `a` first,
310        //      so each function is composed BEFORE the accumulated rest.
311        //      (This used to fold left to right - the reverse - so every
312        //      multi-function transform rendered differently than in a
313        //      browser, and `perspective() rotateX()` projected the
314        //      un-rotated plane, i.e. did nothing.)
315        //   2. The WHOLE product is applied about `transform-origin`:
316        //      `translate(origin) * M * translate(-origin)`. Wrapping happens
317        //      ONCE, here - not per component - so `scale()` and `skew()`
318        //      pivot at the origin exactly like `rotate()` does.
319        use azul_css::props::basic::pixel::DEFAULT_FONT_SIZE;
320        use azul_css::props::basic::PixelValue;
321        let no_origin = StyleTransformOrigin {
322            x: PixelValue::const_px(0),
323            y: PixelValue::const_px(0),
324        };
325        let mut matrix = Self::IDENTITY;
326        let use_avx =
327            INITIALIZED.load(AtomicOrdering::Relaxed) && USE_AVX.load(AtomicOrdering::Relaxed);
328        let use_sse = !use_avx
329            && INITIALIZED.load(AtomicOrdering::Relaxed)
330            && USE_SSE.load(AtomicOrdering::Relaxed);
331
332        if use_avx {
333            for t in t_vec {
334                let component = Self::from_style_transform(
335                    t,
336                    &no_origin,
337                    percent_resolve_x,
338                    percent_resolve_y,
339                    rotation_mode,
340                );
341                // SAFETY: `use_avx` is only set when the AVX feature flag was
342                // detected (see AUDIT-TODO above), so calling the AVX intrinsics
343                // in `then_avx8` is legal on this CPU.
344                #[cfg(target_arch = "x86_64")]
345                unsafe {
346                    matrix = component.then_avx8(&matrix);
347                }
348            }
349        } else if use_sse {
350            for t in t_vec {
351                let component = Self::from_style_transform(
352                    t,
353                    &no_origin,
354                    percent_resolve_x,
355                    percent_resolve_y,
356                    rotation_mode,
357                );
358                // SAFETY: `use_sse` is only set when the SSE feature flag was
359                // detected (see AUDIT-TODO above), so calling the SSE intrinsics
360                // in `then_sse` is legal on this CPU.
361                #[cfg(target_arch = "x86_64")]
362                unsafe {
363                    matrix = component.then_sse(&matrix);
364                }
365            }
366        } else {
367            // fallback for everything else
368            for t in t_vec {
369                let component = Self::from_style_transform(
370                    t,
371                    &no_origin,
372                    percent_resolve_x,
373                    percent_resolve_y,
374                    rotation_mode,
375                );
376                matrix = component.then(&matrix);
377            }
378        }
379
380        // Percentages in `transform-origin` resolve against the element's
381        // own border box (the caller passes its size as the percent basis).
382        let origin_x = transform_origin.x.to_pixels_internal(
383            percent_resolve_x,
384            DEFAULT_FONT_SIZE,
385            DEFAULT_FONT_SIZE,
386        );
387        let origin_y = transform_origin.y.to_pixels_internal(
388            percent_resolve_y,
389            DEFAULT_FONT_SIZE,
390            DEFAULT_FONT_SIZE,
391        );
392        if origin_x == 0.0 && origin_y == 0.0 {
393            return matrix;
394        }
395        Self::new_translation(-origin_x, -origin_y, 0.0)
396            .then(&matrix)
397            .then(&Self::new_translation(origin_x, origin_y, 0.0))
398    }
399
400    /// Creates a new transform from a style transform using the
401    /// parent width as a way to resolve for percentages
402    #[allow(clippy::many_single_char_names)] // domain-standard colour/coordinate component names
403    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
404    fn from_style_transform(
405        t: &StyleTransform,
406        transform_origin: &StyleTransformOrigin,
407        percent_resolve_x: f32,
408        percent_resolve_y: f32,
409        rotation_mode: RotationMode,
410    ) -> Self {
411        use azul_css::props::basic::pixel::DEFAULT_FONT_SIZE;
412        use azul_css::props::style::StyleTransform::{
413            Matrix, Matrix3D, Perspective, Rotate, Rotate3D, RotateX, RotateY, RotateZ, Scale,
414            Scale3D, ScaleX, ScaleY, ScaleZ, Skew, SkewX, SkewY, Translate, Translate3D,
415            TranslateX, TranslateY, TranslateZ,
416        };
417        match t {
418            Matrix(mat2d) => {
419                let a = mat2d.a.get();
420                let b = mat2d.b.get();
421                let c = mat2d.c.get();
422                let d = mat2d.d.get();
423                let tx = mat2d.tx.get();
424                let ty = mat2d.ty.get();
425
426                Self::new_2d(a, b, c, d, tx, ty)
427            }
428            Matrix3D(mat3d) => {
429                let m11 = mat3d.m11.get();
430                let m12 = mat3d.m12.get();
431                let m13 = mat3d.m13.get();
432                let m14 = mat3d.m14.get();
433                let m21 = mat3d.m21.get();
434                let m22 = mat3d.m22.get();
435                let m23 = mat3d.m23.get();
436                let m24 = mat3d.m24.get();
437                let m31 = mat3d.m31.get();
438                let m32 = mat3d.m32.get();
439                let m33 = mat3d.m33.get();
440                let m34 = mat3d.m34.get();
441                let m41 = mat3d.m41.get();
442                let m42 = mat3d.m42.get();
443                let m43 = mat3d.m43.get();
444                let m44 = mat3d.m44.get();
445
446                Self::new(
447                    m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44,
448                )
449            }
450            Translate(trans2d) => Self::new_translation(
451                trans2d.x.to_pixels_internal(
452                    percent_resolve_x,
453                    DEFAULT_FONT_SIZE,
454                    DEFAULT_FONT_SIZE,
455                ),
456                trans2d.y.to_pixels_internal(
457                    percent_resolve_y,
458                    DEFAULT_FONT_SIZE,
459                    DEFAULT_FONT_SIZE,
460                ),
461                0.0,
462            ),
463            Translate3D(trans3d) => {
464                Self::new_translation(
465                    trans3d.x.to_pixels_internal(
466                        percent_resolve_x,
467                        DEFAULT_FONT_SIZE,
468                        DEFAULT_FONT_SIZE,
469                    ),
470                    trans3d.y.to_pixels_internal(
471                        percent_resolve_y,
472                        DEFAULT_FONT_SIZE,
473                        DEFAULT_FONT_SIZE,
474                    ),
475                    trans3d
476                        .z
477                        // CSS has no containing block for Z-axis percentages; use X as fallback
478                        .to_pixels_internal(percent_resolve_x, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
479                )
480            }
481            TranslateX(trans_x) => Self::new_translation(
482                trans_x.to_pixels_internal(percent_resolve_x, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
483                0.0,
484                0.0,
485            ),
486            TranslateY(trans_y) => Self::new_translation(
487                0.0,
488                trans_y.to_pixels_internal(percent_resolve_y, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
489                0.0,
490            ),
491            TranslateZ(trans_z) => Self::new_translation(
492                0.0,
493                0.0,
494                trans_z.to_pixels_internal(percent_resolve_x, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
495            ), // CSS has no containing block for Z-axis percentages; use X as fallback
496            Rotate3D(rot3d) => {
497                let rotation_origin = (
498                    transform_origin.x.to_pixels_internal(
499                        percent_resolve_x,
500                        DEFAULT_FONT_SIZE,
501                        DEFAULT_FONT_SIZE,
502                    ),
503                    transform_origin.y.to_pixels_internal(
504                        percent_resolve_y,
505                        DEFAULT_FONT_SIZE,
506                        DEFAULT_FONT_SIZE,
507                    ),
508                );
509                Self::make_rotation(
510                    rotation_origin,
511                    rot3d.angle.to_degrees(),
512                    rot3d.x.get(),
513                    rot3d.y.get(),
514                    rot3d.z.get(),
515                    rotation_mode,
516                )
517            }
518            RotateX(angle_x) => {
519                let rotation_origin = (
520                    transform_origin.x.to_pixels_internal(
521                        percent_resolve_x,
522                        DEFAULT_FONT_SIZE,
523                        DEFAULT_FONT_SIZE,
524                    ),
525                    transform_origin.y.to_pixels_internal(
526                        percent_resolve_y,
527                        DEFAULT_FONT_SIZE,
528                        DEFAULT_FONT_SIZE,
529                    ),
530                );
531                Self::make_rotation(
532                    rotation_origin,
533                    angle_x.to_degrees(),
534                    1.0,
535                    0.0,
536                    0.0,
537                    rotation_mode,
538                )
539            }
540            RotateY(angle_y) => {
541                let rotation_origin = (
542                    transform_origin.x.to_pixels_internal(
543                        percent_resolve_x,
544                        DEFAULT_FONT_SIZE,
545                        DEFAULT_FONT_SIZE,
546                    ),
547                    transform_origin.y.to_pixels_internal(
548                        percent_resolve_y,
549                        DEFAULT_FONT_SIZE,
550                        DEFAULT_FONT_SIZE,
551                    ),
552                );
553                Self::make_rotation(
554                    rotation_origin,
555                    angle_y.to_degrees(),
556                    0.0,
557                    1.0,
558                    0.0,
559                    rotation_mode,
560                )
561            }
562            Rotate(angle_z) | RotateZ(angle_z) => {
563                let rotation_origin = (
564                    transform_origin.x.to_pixels_internal(
565                        percent_resolve_x,
566                        DEFAULT_FONT_SIZE,
567                        DEFAULT_FONT_SIZE,
568                    ),
569                    transform_origin.y.to_pixels_internal(
570                        percent_resolve_y,
571                        DEFAULT_FONT_SIZE,
572                        DEFAULT_FONT_SIZE,
573                    ),
574                );
575                Self::make_rotation(
576                    rotation_origin,
577                    angle_z.to_degrees(),
578                    0.0,
579                    0.0,
580                    1.0,
581                    rotation_mode,
582                )
583            }
584            Scale(scale2d) => Self::new_scale(scale2d.x.get(), scale2d.y.get(), 1.0),
585            Scale3D(scale3d) => Self::new_scale(scale3d.x.get(), scale3d.y.get(), scale3d.z.get()),
586            ScaleX(scale_x) => Self::new_scale(scale_x.normalized(), 1.0, 1.0),
587            ScaleY(scale_y) => Self::new_scale(1.0, scale_y.normalized(), 1.0),
588            ScaleZ(scale_z) => Self::new_scale(1.0, 1.0, scale_z.normalized()),
589            Skew(skew2d) => Self::new_skew(skew2d.x.to_degrees(), skew2d.y.to_degrees()),
590            SkewX(skew_x) => Self::new_skew(skew_x.to_degrees(), 0.0),
591            SkewY(skew_y) => Self::new_skew(0.0, skew_y.to_degrees()),
592            Perspective(px) => {
593                // CSS applies the WHOLE transform list about the
594                // transform-origin, `perspective()` included: the vanishing
595                // point sits at the origin. Building it about (0, 0) skewed a
596                // `perspective() rotateX()` tilt towards the element's
597                // top-left corner instead of keeping the centre line
598                // vertical (the map's 3D tilt leaned sideways).
599                let origin_x = transform_origin.x.to_pixels_internal(
600                    percent_resolve_x,
601                    DEFAULT_FONT_SIZE,
602                    DEFAULT_FONT_SIZE,
603                );
604                let origin_y = transform_origin.y.to_pixels_internal(
605                    percent_resolve_y,
606                    DEFAULT_FONT_SIZE,
607                    DEFAULT_FONT_SIZE,
608                );
609                let d =
610                    px.to_pixels_internal(percent_resolve_x, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
611                Self::new_translation(-origin_x, -origin_y, 0.0)
612                    .then(&Self::new_perspective(d))
613                    .then(&Self::new_translation(origin_x, origin_y, 0.0))
614            }
615        }
616    }
617
618    /// The plane z = 0 of this transform as a 3x3 homography.
619    ///
620    /// Over `(x, y, 1)` row vectors — `[x' y' w'] = [x y 1] * H`, row-major
621    /// `[m00 m01 m03; m10 m11 m13; m30 m31 m33]` — i.e. exactly what a 2D
622    /// compositor needs to place a flat layer: the affine part plus the
623    /// perspective row. [`Self::is_plane_affine`] tells whether the
624    /// perspective row is the trivial `[0 0 1]`.
625    #[must_use]
626    pub const fn plane_homography(&self) -> [f32; 9] {
627        [
628            self.m[0][0],
629            self.m[0][1],
630            self.m[0][3],
631            self.m[1][0],
632            self.m[1][1],
633            self.m[1][3],
634            self.m[3][0],
635            self.m[3][1],
636            self.m[3][3],
637        ]
638    }
639
640    /// Does the z = 0 plane map affinely (no perspective foreshortening)?
641    #[must_use]
642    pub const fn is_plane_affine(&self) -> bool {
643        let (a, b, w) = (self.m[0][3], self.m[1][3], self.m[3][3]);
644        a > -1e-7 && a < 1e-7 && b > -1e-7 && b < 1e-7 && w > 1.0 - 1e-6 && w < 1.0 + 1e-6
645    }
646
647    /// Creates a scaling matrix with independent scale factors per axis.
648    #[must_use]
649    #[inline]
650    pub const fn new_scale(x: f32, y: f32, z: f32) -> Self {
651        Self::new(
652            x, 0.0, 0.0, 0.0, 0.0, y, 0.0, 0.0, 0.0, 0.0, z, 0.0, 0.0, 0.0, 0.0, 1.0,
653        )
654    }
655
656    /// Creates a translation matrix that moves by `(x, y, z)`.
657    #[must_use]
658    #[inline]
659    pub const fn new_translation(x: f32, y: f32, z: f32) -> Self {
660        Self::new(
661            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, x, y, z, 1.0,
662        )
663    }
664
665    /// Creates a perspective projection matrix with distance `d`.
666    #[must_use]
667    #[inline]
668    fn new_perspective(d: f32) -> Self {
669        Self::new(
670            1.0,
671            0.0,
672            0.0,
673            0.0,
674            0.0,
675            1.0,
676            0.0,
677            0.0,
678            0.0,
679            0.0,
680            1.0,
681            -1.0 / d,
682            0.0,
683            0.0,
684            0.0,
685            1.0,
686        )
687    }
688
689    /// Create a 3d rotation transform from an angle / axis.
690    /// The supplied axis must be normalized.
691    #[must_use]
692    #[inline]
693    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
694    fn new_rotation(x: f32, y: f32, z: f32, theta_radians: f32) -> Self {
695        let xx = x * x;
696        let yy = y * y;
697        let zz = z * z;
698
699        let half_theta = theta_radians / 2.0;
700        let sc = half_theta.sin() * half_theta.cos();
701        let sq = half_theta.sin() * half_theta.sin();
702
703        Self::new(
704            1.0 - 2.0 * (yy + zz) * sq,
705            2.0 * (x * y * sq + z * sc),
706            2.0 * (x * z * sq - y * sc),
707            0.0,
708            2.0 * (x * y * sq - z * sc),
709            1.0 - 2.0 * (xx + zz) * sq,
710            2.0 * (y * z * sq + x * sc),
711            0.0,
712            2.0 * (x * z * sq + y * sc),
713            2.0 * (y * z * sq - x * sc),
714            1.0 - 2.0 * (xx + yy) * sq,
715            0.0,
716            0.0,
717            0.0,
718            0.0,
719            1.0,
720        )
721    }
722
723    /// Creates a 2D skew matrix from angles in degrees.
724    #[must_use]
725    #[inline]
726    fn new_skew(alpha: f32, beta: f32) -> Self {
727        let (sx, sy) = (beta.to_radians().tan(), alpha.to_radians().tan());
728        Self::new(
729            1.0, sx, 0.0, 0.0, sy, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
730        )
731    }
732
733    /// Returns this matrix transposed to column-major layout.
734    #[must_use]
735    pub(crate) const fn get_column_major(&self) -> Self {
736        Self::new(
737            self.m[0][0],
738            self.m[1][0],
739            self.m[2][0],
740            self.m[3][0],
741            self.m[0][1],
742            self.m[1][1],
743            self.m[2][1],
744            self.m[3][1],
745            self.m[0][2],
746            self.m[1][2],
747            self.m[2][2],
748            self.m[3][2],
749            self.m[0][3],
750            self.m[1][3],
751            self.m[2][3],
752            self.m[3][3],
753        )
754    }
755
756    /// Transforms a 2D point into the target coordinate space.
757    #[must_use]
758    pub fn transform_point2d(&self, p: LogicalPosition) -> Option<LogicalPosition> {
759        let w =
760            p.x.mul_add(self.m[0][3], p.y.mul_add(self.m[1][3], self.m[3][3]));
761
762        if !w.is_sign_positive() {
763            return None;
764        }
765
766        let x =
767            p.x.mul_add(self.m[0][0], p.y.mul_add(self.m[1][0], self.m[3][0]));
768        let y =
769            p.x.mul_add(self.m[0][1], p.y.mul_add(self.m[1][1], self.m[3][1]));
770
771        Some(LogicalPosition { x: x / w, y: y / w })
772    }
773
774    /// Scales the translation components of this matrix by `scale_factor` for DPI adjustment.
775    pub fn scale_for_dpi(&mut self, scale_factor: f32) {
776        // only scale the translation, don't scale anything else
777        self.m[3][0] *= scale_factor;
778        self.m[3][1] *= scale_factor;
779        self.m[3][2] *= scale_factor;
780    }
781
782    /// Multiplies this matrix by `other`, applying `other` AFTER the current matrix.
783    #[must_use]
784    #[inline]
785    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
786    pub fn then(&self, other: &Self) -> Self {
787        Self::new(
788            self.m[0][0].mul_add(
789                other.m[0][0],
790                self.m[0][1].mul_add(
791                    other.m[1][0],
792                    self.m[0][2].mul_add(other.m[2][0], self.m[0][3] * other.m[3][0]),
793                ),
794            ),
795            self.m[0][0].mul_add(
796                other.m[0][1],
797                self.m[0][1].mul_add(
798                    other.m[1][1],
799                    self.m[0][2].mul_add(other.m[2][1], self.m[0][3] * other.m[3][1]),
800                ),
801            ),
802            self.m[0][0].mul_add(
803                other.m[0][2],
804                self.m[0][1].mul_add(
805                    other.m[1][2],
806                    self.m[0][2].mul_add(other.m[2][2], self.m[0][3] * other.m[3][2]),
807                ),
808            ),
809            self.m[0][0].mul_add(
810                other.m[0][3],
811                self.m[0][1].mul_add(
812                    other.m[1][3],
813                    self.m[0][2].mul_add(other.m[2][3], self.m[0][3] * other.m[3][3]),
814                ),
815            ),
816            self.m[1][0].mul_add(
817                other.m[0][0],
818                self.m[1][1].mul_add(
819                    other.m[1][0],
820                    self.m[1][2].mul_add(other.m[2][0], self.m[1][3] * other.m[3][0]),
821                ),
822            ),
823            self.m[1][0].mul_add(
824                other.m[0][1],
825                self.m[1][1].mul_add(
826                    other.m[1][1],
827                    self.m[1][2].mul_add(other.m[2][1], self.m[1][3] * other.m[3][1]),
828                ),
829            ),
830            self.m[1][0].mul_add(
831                other.m[0][2],
832                self.m[1][1].mul_add(
833                    other.m[1][2],
834                    self.m[1][2].mul_add(other.m[2][2], self.m[1][3] * other.m[3][2]),
835                ),
836            ),
837            self.m[1][0].mul_add(
838                other.m[0][3],
839                self.m[1][1].mul_add(
840                    other.m[1][3],
841                    self.m[1][2].mul_add(other.m[2][3], self.m[1][3] * other.m[3][3]),
842                ),
843            ),
844            self.m[2][0].mul_add(
845                other.m[0][0],
846                self.m[2][1].mul_add(
847                    other.m[1][0],
848                    self.m[2][2].mul_add(other.m[2][0], self.m[2][3] * other.m[3][0]),
849                ),
850            ),
851            self.m[2][0].mul_add(
852                other.m[0][1],
853                self.m[2][1].mul_add(
854                    other.m[1][1],
855                    self.m[2][2].mul_add(other.m[2][1], self.m[2][3] * other.m[3][1]),
856                ),
857            ),
858            self.m[2][0].mul_add(
859                other.m[0][2],
860                self.m[2][1].mul_add(
861                    other.m[1][2],
862                    self.m[2][2].mul_add(other.m[2][2], self.m[2][3] * other.m[3][2]),
863                ),
864            ),
865            self.m[2][0].mul_add(
866                other.m[0][3],
867                self.m[2][1].mul_add(
868                    other.m[1][3],
869                    self.m[2][2].mul_add(other.m[2][3], self.m[2][3] * other.m[3][3]),
870                ),
871            ),
872            self.m[3][0].mul_add(
873                other.m[0][0],
874                self.m[3][1].mul_add(
875                    other.m[1][0],
876                    self.m[3][2].mul_add(other.m[2][0], self.m[3][3] * other.m[3][0]),
877                ),
878            ),
879            self.m[3][0].mul_add(
880                other.m[0][1],
881                self.m[3][1].mul_add(
882                    other.m[1][1],
883                    self.m[3][2].mul_add(other.m[2][1], self.m[3][3] * other.m[3][1]),
884                ),
885            ),
886            self.m[3][0].mul_add(
887                other.m[0][2],
888                self.m[3][1].mul_add(
889                    other.m[1][2],
890                    self.m[3][2].mul_add(other.m[2][2], self.m[3][3] * other.m[3][2]),
891                ),
892            ),
893            self.m[3][0].mul_add(
894                other.m[0][3],
895                self.m[3][1].mul_add(
896                    other.m[1][3],
897                    self.m[3][2].mul_add(other.m[2][3], self.m[3][3] * other.m[3][3]),
898                ),
899            ),
900        )
901    }
902
903    // credit: https://gist.github.com/rygorous/4172889
904
905    // linear combination:
906    // a[0] * B.row[0] + a[1] * B.row[1] + a[2] * B.row[2] + a[3] * B.row[3]
907    //
908    // SAFETY: the caller must guarantee SSE is available on this CPU (see the
909    // `use_sse` gate in `from_style_transform_vec`). Every `mem::transmute` here
910    // is a BY-VALUE `[f32; 4]` -> `__m128` conversion: both types are 16 bytes
911    // and the value is moved through a register, so no *reference* to under-
912    // aligned storage is ever formed and there is no alignment invariant to
913    // violate (unlike the AVX broadcast, which must use an unaligned load).
914    #[cfg(target_arch = "x86_64")]
915    #[inline]
916    unsafe fn linear_combine_sse(a: [f32; 4], b: &Self) -> [f32; 4] {
917        unsafe {
918            use core::{
919                arch::x86_64::{__m128, _mm_add_ps, _mm_mul_ps, _mm_shuffle_ps},
920                mem,
921            };
922
923            let a: __m128 = mem::transmute(a);
924            let mut result = _mm_mul_ps(
925                _mm_shuffle_ps(a, a, 0x00),
926                mem::transmute::<[f32; 4], __m128>(b.m[0]),
927            );
928            result = _mm_add_ps(
929                result,
930                _mm_mul_ps(
931                    _mm_shuffle_ps(a, a, 0x55),
932                    mem::transmute::<[f32; 4], __m128>(b.m[1]),
933                ),
934            );
935            result = _mm_add_ps(
936                result,
937                _mm_mul_ps(
938                    _mm_shuffle_ps(a, a, 0xaa),
939                    mem::transmute::<[f32; 4], __m128>(b.m[2]),
940                ),
941            );
942            result = _mm_add_ps(
943                result,
944                _mm_mul_ps(
945                    _mm_shuffle_ps(a, a, 0xff),
946                    mem::transmute::<[f32; 4], __m128>(b.m[3]),
947                ),
948            );
949
950            mem::transmute(result)
951        }
952    }
953
954    /// Multiplies this matrix by `other` using SSE instructions.
955    ///
956    /// SAFETY: caller must guarantee SSE is available; only forwards to
957    /// `linear_combine_sse`, whose safety contract is identical.
958    #[cfg(target_arch = "x86_64")]
959    #[inline]
960    unsafe fn then_sse(&self, other: &Self) -> Self {
961        unsafe {
962            Self {
963                m: [
964                    Self::linear_combine_sse(self.m[0], other),
965                    Self::linear_combine_sse(self.m[1], other),
966                    Self::linear_combine_sse(self.m[2], other),
967                    Self::linear_combine_sse(self.m[3], other),
968                ],
969            }
970        }
971    }
972
973    /// Dual linear combination using AVX instructions on YMM registers.
974    ///
975    /// AUDIT: the rows `b.m[i]` are `[f32; 4]` fields with alignment 4, but
976    /// `_mm256_broadcast_ps` takes a `&__m128` (alignment 16). Forming that
977    /// reference — `&*(ptr as *const __m128)` — from an align-4 field is
978    /// misaligned-reference UB even though the underlying `vbroadcastf128`
979    /// tolerates it. Use `_mm256_loadu2_m128`, which does an *unaligned*
980    /// 128-bit load from a raw `*const f32` and never forms a `&__m128`;
981    /// passing the same row pointer for both lanes reproduces the broadcast
982    /// (`result[127:0] = result[255:128] = row`).
983    ///
984    /// SAFETY: caller must guarantee AVX is available. Each `broadcast_row`
985    /// reads exactly 4 f32 (16 bytes) through `_mm256_loadu2_m128`, an
986    /// *unaligned* load, so the align-4 `[f32; 4]` rows are read in-bounds and
987    /// no `&__m128` (align 16) is ever formed from them.
988    #[cfg(target_arch = "x86_64")]
989    unsafe fn linear_combine_avx8(
990        a01: core::arch::x86_64::__m256,
991        b: &Self,
992    ) -> core::arch::x86_64::__m256 {
993        unsafe {
994            use core::arch::x86_64::{
995                _mm256_add_ps, _mm256_loadu2_m128, _mm256_mul_ps, _mm256_shuffle_ps,
996            };
997
998            // Unaligned broadcast of a row into both 128-bit lanes. Runs inside the
999            // enclosing `unsafe` block, so the intrinsic call needs no inner `unsafe`.
1000            let broadcast_row = |row: &[f32; 4]| {
1001                let p = row.as_ptr();
1002                _mm256_loadu2_m128(p, p)
1003            };
1004
1005            let mut result =
1006                _mm256_mul_ps(_mm256_shuffle_ps(a01, a01, 0x00), broadcast_row(&b.m[0]));
1007            result = _mm256_add_ps(
1008                result,
1009                _mm256_mul_ps(_mm256_shuffle_ps(a01, a01, 0x55), broadcast_row(&b.m[1])),
1010            );
1011            result = _mm256_add_ps(
1012                result,
1013                _mm256_mul_ps(_mm256_shuffle_ps(a01, a01, 0xaa), broadcast_row(&b.m[2])),
1014            );
1015            result = _mm256_add_ps(
1016                result,
1017                _mm256_mul_ps(_mm256_shuffle_ps(a01, a01, 0xff), broadcast_row(&b.m[3])),
1018            );
1019            result
1020        }
1021    }
1022
1023    /// Multiplies this matrix by `other` using AVX instructions.
1024    ///
1025    /// SAFETY: caller must guarantee AVX is available. Both `_mm256_loadu_ps`
1026    /// reads and `_mm256_storeu_ps` writes are *unaligned* 8-f32 (32-byte)
1027    /// accesses. `m` is `[[f32; 4]; 4]`, i.e. 16 contiguous f32 with no padding,
1028    /// so `&m[0][0]..` and `&m[2][0]..` each span two full rows in-bounds; the
1029    /// raw pointers come from live `self`/`out` locals, so lifetimes are valid.
1030    #[cfg(target_arch = "x86_64")]
1031    #[inline]
1032    unsafe fn then_avx8(&self, other: &Self) -> Self {
1033        unsafe {
1034            use core::{
1035                arch::x86_64::{__m256, _mm256_loadu_ps, _mm256_storeu_ps, _mm256_zeroupper},
1036                mem,
1037            };
1038
1039            _mm256_zeroupper();
1040
1041            let a01: __m256 = _mm256_loadu_ps(&raw const self.m[0][0]);
1042            let a23: __m256 = _mm256_loadu_ps(&raw const self.m[2][0]);
1043
1044            let out01x = Self::linear_combine_avx8(a01, other);
1045            let out23x = Self::linear_combine_avx8(a23, other);
1046
1047            let mut out = Self {
1048                m: [self.m[0], self.m[1], self.m[2], self.m[3]],
1049            };
1050
1051            _mm256_storeu_ps(&raw mut out.m[0][0], out01x);
1052            _mm256_storeu_ps(&raw mut out.m[2][0], out23x);
1053
1054            out
1055        }
1056    }
1057
1058    /// Creates a rotation matrix around the given axis, adjusted for the coordinate system.
1059    #[must_use]
1060    #[inline]
1061    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1062    fn make_rotation(
1063        rotation_origin: (f32, f32),
1064        mut degrees: f32,
1065        axis_x: f32,
1066        axis_y: f32,
1067        axis_z: f32,
1068        // see documentation for RotationMode
1069        rotation_mode: RotationMode,
1070    ) -> Self {
1071        degrees = match rotation_mode {
1072            // CSS rotations are clockwise
1073            RotationMode::ForWebRender => -degrees,
1074            // hit-testing turns counter-clockwise
1075            RotationMode::ForHitTesting => degrees,
1076        };
1077
1078        let (origin_x, origin_y) = rotation_origin;
1079        let pre_transform = Self::new_translation(-origin_x, -origin_y, 0.0);
1080        let post_transform = Self::new_translation(origin_x, origin_y, 0.0);
1081        let theta = 2.0_f32 * core::f32::consts::PI - degrees.to_radians();
1082        let rotate_transform = Self::new_rotation(axis_x, axis_y, axis_z, theta);
1083
1084        pre_transform.then(&rotate_transform).then(&post_transform)
1085    }
1086}
1087
1088#[cfg(test)]
1089#[allow(
1090    clippy::items_after_statements,
1091    clippy::redundant_clone,
1092    clippy::cast_possible_truncation,
1093    clippy::cast_sign_loss,
1094    trivial_casts,
1095    clippy::borrow_as_ptr,
1096    clippy::cast_ptr_alignment,
1097    clippy::unused_self,
1098    unused_qualifications,
1099    unreachable_pub,
1100    private_interfaces
1101)] // pedantic lints are noise in unsafe-exercising test code
1102mod audit_tests {
1103    use super::*;
1104
1105    fn sample_a() -> ComputedTransform3D {
1106        ComputedTransform3D::new(
1107            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
1108        )
1109    }
1110    fn sample_b() -> ComputedTransform3D {
1111        ComputedTransform3D::new(
1112            16.0, 15.0, 14.0, 13.0, 12.0, 11.0, 10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0,
1113        )
1114    }
1115
1116    fn approx_eq(a: &ComputedTransform3D, b: &ComputedTransform3D) {
1117        for r in 0..4 {
1118            for c in 0..4 {
1119                assert!(
1120                    (a.m[r][c] - b.m[r][c]).abs() < 1e-3,
1121                    "mismatch at [{r}][{c}]: {} vs {}",
1122                    a.m[r][c],
1123                    b.m[r][c]
1124                );
1125            }
1126        }
1127    }
1128
1129    /// Naive row-major 4x4 multiply used as an independent reference for the
1130    /// `then` (and hence SIMD) paths. Deliberately avoids `mul_add` so it is a
1131    /// separate implementation from the code under test.
1132    fn naive_then(a: &ComputedTransform3D, b: &ComputedTransform3D) -> ComputedTransform3D {
1133        let mut out = ComputedTransform3D::IDENTITY;
1134        for r in 0..4 {
1135            for c in 0..4 {
1136                let mut acc = 0.0f32;
1137                for k in 0..4 {
1138                    acc += a.m[r][k] * b.m[k][c];
1139                }
1140                out.m[r][c] = acc;
1141            }
1142        }
1143        out
1144    }
1145
1146    // Miri-compatible: exercises only the safe scalar `then` against an
1147    // independent naive reference. Runs everywhere, including under Miri, so the
1148    // scalar anchor that the SIMD paths are compared against is itself checked.
1149    #[test]
1150    fn scalar_matmul_matches_reference() {
1151        let a = sample_a();
1152        let b = sample_b();
1153        approx_eq(&a.then(&b), &naive_then(&a, &b));
1154        // Identity is a left/right unit.
1155        approx_eq(&ComputedTransform3D::IDENTITY.then(&b), &b);
1156        approx_eq(&a.then(&ComputedTransform3D::IDENTITY), &a);
1157    }
1158
1159    // AUDIT: the SSE/AVX matrix-multiply paths must agree with the scalar
1160    // reference. In particular this exercises `linear_combine_avx8`, whose
1161    // unaligned-load fix (`_mm256_loadu2_m128` instead of forming a misaligned
1162    // `&__m128`) must produce identical results. Only runs the SIMD paths when
1163    // the CPU (and OS) actually support the feature.
1164    //
1165    // `#[cfg(not(miri))]`: the AVX/SSE intrinsics cannot execute under Miri, so
1166    // this test is skipped there; a native run covers it.
1167    #[cfg(not(miri))]
1168    #[test]
1169    fn simd_matmul_matches_scalar() {
1170        let a = sample_a();
1171        let b = sample_b();
1172        let scalar = a.then(&b);
1173
1174        #[cfg(target_arch = "x86_64")]
1175        {
1176            if std::is_x86_feature_detected!("sse") {
1177                let sse = unsafe { a.then_sse(&b) };
1178                approx_eq(&scalar, &sse);
1179            }
1180            if std::is_x86_feature_detected!("avx") {
1181                let avx = unsafe { a.then_avx8(&b) };
1182                approx_eq(&scalar, &avx);
1183            }
1184        }
1185
1186        // Always assert the scalar path is self-consistent (identity * b == b).
1187        approx_eq(&ComputedTransform3D::IDENTITY.then(&b), &b);
1188    }
1189
1190    // AUDIT regression test for the misaligned-`&__m128` bug: the AVX path reads
1191    // matrix rows (`[f32; 4]`, alignment 4) that are NOT guaranteed to sit on a
1192    // 16-byte boundary. The earlier code formed a `&__m128` from such a row,
1193    // which is misaligned-reference UB; the current code uses unaligned loads.
1194    // This runs `then_avx8` on the same logical matrix placed at a 16-byte
1195    // aligned address AND at that address + 4 (i.e. 4-mod-16, deliberately not
1196    // 16-aligned) and asserts identical results. A sanitizer/Valgrind run over
1197    // this test would fault on the pre-fix misaligned access.
1198    //
1199    // `#[cfg(not(miri))]`: invokes AVX intrinsics, which Miri cannot execute.
1200    #[cfg(all(target_arch = "x86_64", not(miri)))]
1201    #[test]
1202    fn avx_result_independent_of_row_alignment() {
1203        if !std::is_x86_feature_detected!("avx") {
1204            return;
1205        }
1206
1207        let a = sample_a();
1208        let b = sample_b();
1209        let expected = unsafe { a.then_avx8(&b) };
1210
1211        const N: usize = core::mem::size_of::<ComputedTransform3D>(); // 64, no padding
1212        let mut buf = vec![0u8; N * 2 + 16];
1213        let base = buf.as_mut_ptr();
1214
1215        // SAFETY: `aligned` lands within `buf` (align_offset < 16, then +N),
1216        // `misaligned` = aligned + 4 stays in-bounds (buf has N*2+16 bytes).
1217        // Both are >= 4-byte aligned (base is heap-aligned; +4 preserves that),
1218        // so forming `&ComputedTransform3D` (alignment 4) from them is valid.
1219        unsafe {
1220            let aligned = base.add(base.align_offset(16));
1221            let misaligned = aligned.add(4); // 4 mod 16: not 16-aligned
1222            for off_ptr in [aligned, misaligned] {
1223                core::ptr::copy_nonoverlapping((&raw const a).cast::<u8>(), off_ptr, N);
1224                let a_ref = &*off_ptr.cast::<ComputedTransform3D>();
1225                let got = a_ref.then_avx8(&b);
1226                approx_eq(&expected, &got);
1227            }
1228        }
1229    }
1230}
1231
1232#[cfg(test)]
1233#[path = "transform_test.rs"]
1234mod transform_test;