Skip to main content

repose_core/
geometry.rs

1#[derive(Clone, Copy, Debug, Default, PartialEq)]
2pub struct Vec2 {
3    pub x: f32,
4    pub y: f32,
5}
6
7impl Vec2 {
8    pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 };
9}
10
11impl std::ops::Add for Vec2 {
12    type Output = Vec2;
13    fn add(self, other: Vec2) -> Vec2 {
14        Vec2 {
15            x: self.x + other.x,
16            y: self.y + other.y,
17        }
18    }
19}
20
21impl std::ops::Sub for Vec2 {
22    type Output = Vec2;
23    fn sub(self, other: Vec2) -> Vec2 {
24        Vec2 {
25            x: self.x - other.x,
26            y: self.y - other.y,
27        }
28    }
29}
30
31impl std::ops::Neg for Vec2 {
32    type Output = Vec2;
33    fn neg(self) -> Vec2 {
34        Vec2 {
35            x: -self.x,
36            y: -self.y,
37        }
38    }
39}
40
41#[derive(Clone, Copy, Debug, Default, PartialEq)]
42pub struct Size {
43    pub width: f32,
44    pub height: f32,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq)]
48pub struct Rect {
49    pub x: f32,
50    pub y: f32,
51    pub w: f32,
52    pub h: f32,
53}
54
55impl Rect {
56    pub fn contains(&self, p: Vec2) -> bool {
57        p.x >= self.x && p.x <= self.x + self.w && p.y >= self.y && p.y <= self.y + self.h
58    }
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq)]
62pub struct Transform {
63    pub translate_x: f32,
64    pub translate_y: f32,
65    pub scale_x: f32,
66    pub scale_y: f32,
67    pub rotate: f32, // radians
68    /// Horizontal shear factor (`x += shear_x * y`), e.g. ASS `\fax`.
69    pub shear_x: f32,
70    /// Vertical shear factor (`y += shear_y * x`), e.g. ASS `\fay`.
71    pub shear_y: f32,
72    /// Normalized transform origin in `[0, 1]` (0.5 = center).
73    ///
74    /// This is a *normalized* pivot, not an absolute point. It is only
75    /// meaningful together with the target [`Rect`]: the absolute pivot is
76    /// `rect.x + rect.w * origin_x` (same for y). The paint bake path
77    /// (`LayoutEngine::walk_paint`) is the single source of truth for
78    /// rendering: it resolves the pivot from the node's `Rect`, folds it
79    /// into `translate_x/y`, and pushes a transform with
80    /// `origin_x/y == 0.0`. Renderers consume transforms origin-free.
81    /// Do not interpret `origin_*` as absolute pixels (see
82    /// [`apply_to_point_in_rect`](Self::apply_to_point_in_rect)).
83    pub origin_x: f32,
84    pub origin_y: f32,
85    /// Projective row of the homogeneous 3x3 map: `w = px * x + py * y + pw`
86    /// and the rendered point is the affine result divided by `w`. Identity
87    /// (pure affine) is `[0.0, 0.0, 1.0]`, e.g. a 3D tilt of a planar layer.
88    ///
89    /// Only the scene-graph translation consumes this (perspective subtrees
90    /// are flattened into offscreen layers and composited projectively, CSS
91    /// style). [`linear`](Self::linear), [`apply_to_point`](Self::apply_to_point)
92    /// and [`combine`](Self::combine) stay affine-only by design.
93    pub perspective: [f32; 3],
94}
95
96impl Transform {
97    pub fn identity() -> Self {
98        Self {
99            translate_x: 0.0,
100            translate_y: 0.0,
101            scale_x: 1.0,
102            scale_y: 1.0,
103            rotate: 0.0,
104            shear_x: 0.0,
105            shear_y: 0.0,
106            origin_x: 0.5,
107            origin_y: 0.5,
108            perspective: [0.0, 0.0, 1.0],
109        }
110    }
111
112    pub fn translate(x: f32, y: f32) -> Self {
113        Self {
114            translate_x: x,
115            translate_y: y,
116            scale_x: 1.0,
117            scale_y: 1.0,
118            rotate: 0.0,
119            shear_x: 0.0,
120            shear_y: 0.0,
121            origin_x: 0.5,
122            origin_y: 0.5,
123            perspective: [0.0, 0.0, 1.0],
124        }
125    }
126
127    /// Forward 2x2 linear part (row-major `[m00, m01, m10, m11]`):
128    /// `M = R(rotate) * H(shear) * S(scale)` applied before translation.
129    pub fn linear(&self) -> [f32; 4] {
130        let c = self.rotate.cos();
131        let s = self.rotate.sin();
132        let hsx = self.scale_x;
133        let hsy = self.scale_y;
134        let k00 = hsx;
135        let k01 = self.shear_x * hsy;
136        let k10 = self.shear_y * hsx;
137        let k11 = hsy;
138        [
139            c * k00 - s * k10,
140            c * k01 - s * k11,
141            s * k00 + c * k10,
142            s * k01 + c * k11,
143        ]
144    }
145
146    /// Inverse of [`linear`](Self::linear), or `None` when singular.
147    pub fn inverse_linear(&self) -> Option<[f32; 4]> {
148        let m = self.linear();
149        let det = m[0] * m[3] - m[1] * m[2];
150        if det.abs() < 1e-12 {
151            return None;
152        }
153        Some([m[3] / det, -m[1] / det, -m[2] / det, m[0] / det])
154    }
155
156    /// Whether scale/rotation/shear are all identity (translation may apply).
157    pub fn linear_is_identity(&self) -> bool {
158        self.scale_x == 1.0
159            && self.scale_y == 1.0
160            && self.rotate == 0.0
161            && self.shear_x == 0.0
162            && self.shear_y == 0.0
163    }
164
165    /// Whether the projective row is non-trivial (true perspective).
166    pub fn has_perspective(&self) -> bool {
167        self.perspective != [0.0, 0.0, 1.0]
168    }
169
170    /// Full homogeneous 3x3 map (row-major, 9 elements): the affine
171    /// `linear()` + translation in rows 0-1, [`perspective`](Self::perspective)
172    /// in row 2. `world_h = M * [x, y, 1]`, `world = world_h.xy / world_h.z`.
173    ///
174    /// Origin pivots are intentionally not folded in: the renderer consumes
175    /// the affine parts origin-free (see `rect_to_instance_ndc`), so pivots
176    /// must be baked into the rows by the producer (see
177    /// [`from_projective_rows`](Self::from_projective_rows)).
178    pub fn projective_matrix(&self) -> [f32; 9] {
179        let m = self.linear();
180        [
181            m[0],
182            m[1],
183            self.translate_x,
184            m[2],
185            m[3],
186            self.translate_y,
187            self.perspective[0],
188            self.perspective[1],
189            self.perspective[2],
190        ]
191    }
192
193    /// Build a transform from explicit homogeneous rows: `row0`/`row1` are the
194    /// affine `[a, b, t]` rows, `perspective` the projective `[px, py, pw]`
195    /// row. The affine 2x2 is decomposed into scale/rotate/shear parts (via
196    /// the same decomposition [`combine`](Self::combine) uses), so this
197    /// round-trips any 3D-rotation-of-a-plane + perspective map exactly —
198    /// e.g. ASS `\frx`/`\fry` about an `\org` pivot, where the pivot lives in
199    /// the rows because the renderer ignores `origin_*`.
200    pub fn from_projective_rows(row0: [f32; 3], row1: [f32; 3], perspective: [f32; 3]) -> Self {
201        let (sx, sy, rot, hx, hy) = decompose_linear([row0[0], row0[1], row1[0], row1[1]])
202            .unwrap_or((1.0, 1.0, 0.0, 0.0, 0.0));
203        Self {
204            translate_x: row0[2],
205            translate_y: row1[2],
206            scale_x: sx,
207            scale_y: sy,
208            rotate: rot,
209            shear_x: hx,
210            shear_y: hy,
211            origin_x: 0.5,
212            origin_y: 0.5,
213            perspective,
214        }
215    }
216
217    /// Apply the full projective map to a point, with perspective divide.
218    /// Degenerate `w` (≈ 0, at/behind the viewer) clamps to a tiny epsilon of
219    /// the original sign so output stays finite; callers culling
220    /// behind-camera content should test `w` via [`projective_w`](Self::projective_w).
221    pub fn apply_projective(&self, p: Vec2) -> Vec2 {
222        let m = self.linear();
223        let x = m[0] * p.x + m[1] * p.y + self.translate_x;
224        let y = m[2] * p.x + m[3] * p.y + self.translate_y;
225        let w = self.projective_w(p);
226        Vec2 { x: x / w, y: y / w }
227    }
228
229    /// The homogeneous `w` of a point under this transform's projective row.
230    pub fn projective_w(&self, p: Vec2) -> f32 {
231        let w = self.perspective[0] * p.x + self.perspective[1] * p.y + self.perspective[2];
232        if w.abs() < 1e-6 {
233            if w < 0.0 { -1e-6 } else { 1e-6 }
234        } else {
235            w
236        }
237    }
238
239    /// Compose two homogeneous 3x3 maps (row-major 9-element arrays, as from
240    /// [`projective_matrix`](Self::projective_matrix)): `world = outer ×
241    /// inner × local`. Used at scene-translation time to fold affine
242    /// ancestors over a perspective node; the result feeds layer-flattening,
243    /// never the part-based [`combine`](Self::combine).
244    pub fn compose_projective(outer: &[f32; 9], inner: &[f32; 9]) -> [f32; 9] {
245        let mut c = [0.0f32; 9];
246        for i in 0..3 {
247            for j in 0..3 {
248                c[i * 3 + j] = outer[i * 3] * inner[j]
249                    + outer[i * 3 + 1] * inner[3 + j]
250                    + outer[i * 3 + 2] * inner[6 + j];
251            }
252        }
253        c
254    }
255
256    /// Axis-aligned bounds of a projectively mapped rect (projects all four
257    /// corners and bounds them; the correct cull rect for flattened layers).
258    pub fn project_rect(&self, r: &Rect) -> Rect {
259        let corners = [
260            Vec2 { x: r.x, y: r.y },
261            Vec2 {
262                x: r.x + r.w,
263                y: r.y,
264            },
265            Vec2 {
266                x: r.x + r.w,
267                y: r.y + r.h,
268            },
269            Vec2 {
270                x: r.x,
271                y: r.y + r.h,
272            },
273        ];
274        let mut min_x = f32::MAX;
275        let mut min_y = f32::MAX;
276        let mut max_x = f32::MIN;
277        let mut max_y = f32::MIN;
278        for c in corners {
279            let p = self.apply_projective(c);
280            min_x = min_x.min(p.x);
281            min_y = min_y.min(p.y);
282            max_x = max_x.max(p.x);
283            max_y = max_y.max(p.y);
284        }
285        Rect {
286            x: min_x,
287            y: min_y,
288            w: (max_x - min_x).max(0.0),
289            h: (max_y - min_y).max(0.0),
290        }
291    }
292
293    pub fn apply_to_point(&self, p: Vec2) -> Vec2 {
294        //
295        let ox = self.origin_x;
296        let oy = self.origin_y;
297        let m = self.linear();
298        let x = p.x - ox;
299        let y = p.y - oy;
300
301        Vec2 {
302            x: m[0] * x + m[1] * y + ox + self.translate_x,
303            y: m[2] * x + m[3] * y + oy + self.translate_y,
304        }
305    }
306
307    /// Absolute pivot for `r` given the normalized `origin_*` (`[0, 1]`).
308    pub fn pivot_for_rect(&self, r: &Rect) -> Vec2 {
309        Vec2 {
310            x: r.x + r.w * self.origin_x,
311            y: r.y + r.h * self.origin_y,
312        }
313    }
314
315    /// Apply with an explicit absolute pivot (same space as `p`).
316    /// This is the pivot-correct primitive; `apply_to_point` is this with
317    /// `pivot == (origin_x, origin_y)`.
318    pub fn apply_to_point_with_pivot(&self, p: Vec2, pivot: Vec2) -> Vec2 {
319        let m = self.linear();
320        let x = p.x - pivot.x;
321        let y = p.y - pivot.y;
322        Vec2 {
323            x: m[0] * x + m[1] * y + pivot.x + self.translate_x,
324            y: m[2] * x + m[3] * y + pivot.y + self.translate_y,
325        }
326    }
327
328    /// Pivot-correct point mapping for a normalized origin + layout rect.
329    /// Use this for hit-testing / bounds of scaled nodes (e.g. dialogs).
330    pub fn apply_to_point_in_rect(&self, p: Vec2, r: &Rect) -> Vec2 {
331        self.apply_to_point_with_pivot(p, self.pivot_for_rect(r))
332    }
333
334    pub fn apply_to_rect(&self, r: Rect) -> Rect {
335        let ox = r.x + r.w * self.origin_x;
336        let oy = r.y + r.h * self.origin_y;
337        let m = self.linear();
338        let corners = [
339            Vec2 { x: r.x, y: r.y },
340            Vec2 {
341                x: r.x + r.w,
342                y: r.y,
343            },
344            Vec2 {
345                x: r.x,
346                y: r.y + r.h,
347            },
348            Vec2 {
349                x: r.x + r.w,
350                y: r.y + r.h,
351            },
352        ];
353        let mut min_x = f32::MAX;
354        let mut min_y = f32::MAX;
355        let mut max_x = f32::MIN;
356        let mut max_y = f32::MIN;
357        for c in corners {
358            let x = c.x - ox;
359            let y = c.y - oy;
360            let tx = m[0] * x + m[1] * y + ox + self.translate_x;
361            let ty = m[2] * x + m[3] * y + oy + self.translate_y;
362            min_x = min_x.min(tx);
363            min_y = min_y.min(ty);
364            max_x = max_x.max(tx);
365            max_y = max_y.max(ty);
366        }
367        Rect {
368            x: min_x,
369            y: min_y,
370            w: max_x - min_x,
371            h: max_y - min_y,
372        }
373    }
374
375    /// Compose two transforms (`self` outer, `other` inner) for a
376    /// transform stack: `current.combine(pushed)` where `pushed` is the
377    /// newly pushed (inner) node.
378    ///
379    /// Returns a transform such that `combined.apply_to_point(p) ==
380    /// self.apply_to_point(other.apply_to_point(p))` for the absolute-pivot
381    /// [`apply_to_point`](Self::apply_to_point) primitive.
382    ///
383    /// Origin semantics: the linear part and translation are composed
384    /// **origin-free** (pure affine composition of `linear()` + translation).
385    /// `origin_*` (normalized `[0, 1]`) cannot be composed part-wise without
386    /// the target rect, so the result inherits the outer (`self`) origin for
387    /// back-compat. Callers that need pivot-correct nesting must bake each
388    /// level against its own `Rect` first (exactly what the paint bake path
389    /// does: resolve pivot from rect, fold into translation, zero the
390    /// origin) and then combine the baked, origin-free transforms. That bake
391    /// path is the single source of truth for rendering; the renderer
392    /// consumes transforms origin-free.
393    ///
394    /// Affine-only by design: a part-based transform cannot represent a
395    /// composed projective map, so `perspective` rows do NOT compose here
396    /// (debug-asserted). Perspective folds at scene-translation time into a
397    /// full 3x3 via [`projective_matrix`](Self::projective_matrix) and
398    /// [`compose_projective`](Self::compose_projective), which is what
399    /// flattens perspective subtrees into projectively composited layers.
400    pub fn combine(&self, other: &Transform) -> Transform {
401        debug_assert!(
402            !self.has_perspective() && !other.has_perspective(),
403            "Transform::combine is affine-only; compose projective rows with compose_projective"
404        );
405        let a = self.linear();
406        let b = other.linear();
407        let m = [
408            a[0] * b[0] + a[1] * b[2],
409            a[0] * b[1] + a[1] * b[3],
410            a[2] * b[0] + a[3] * b[2],
411            a[2] * b[1] + a[3] * b[3],
412        ];
413        let translate_x = a[0] * other.translate_x + a[1] * other.translate_y + self.translate_x;
414        let translate_y = a[2] * other.translate_x + a[3] * other.translate_y + self.translate_y;
415
416        let (scale_x, scale_y, rotate, shear_x, shear_y) =
417            decompose_linear(m).unwrap_or((1.0, 1.0, 0.0, 0.0, 0.0));
418
419        Transform {
420            translate_x,
421            translate_y,
422            scale_x,
423            scale_y,
424            rotate,
425            shear_x,
426            shear_y,
427            origin_x: self.origin_x,
428            origin_y: self.origin_y,
429            // Affine-only (see doc): callers must not push perspective here.
430            perspective: [0.0, 0.0, 1.0],
431        }
432    }
433}
434
435/// Split a 2x2 matrix `[m00, m01, m10, m11]` into
436/// `(scale_x, scale_y, rotate, shear_x, shear_y)` such that
437/// `M = R(rotate) * H(shear) * S(scale)`, or `None` when degenerate.
438///
439/// Uses polar decomposition (`M = R * K` with symmetric `K`), then reads
440/// scale/shear off `K`. Reflections fold their sign into `scale_x`.
441fn decompose_linear(m: [f32; 4]) -> Option<(f32, f32, f32, f32, f32)> {
442    let (mut a, mut b, c, d) = (m[0], m[1], m[2], m[3]);
443    let mut angle_sign = 1.0;
444    if a * d - b * c < 0.0 {
445        a = -a;
446        b = -b;
447        angle_sign = -1.0;
448    }
449    let e = a * a + c * c;
450    let f = a * b + c * d;
451    let g = b * b + d * d;
452    let det_p = (e * g - f * f).max(0.0);
453    let s = (e + g + 2.0 * det_p.sqrt()).sqrt();
454    if !(s > 1e-12) {
455        return None;
456    }
457    let root_det = det_p.sqrt();
458    let k00 = (e + root_det) / s;
459    let k01 = f / s;
460    let k10 = f / s;
461    let k11 = (g + root_det) / s;
462    let det_k = (k00 * k11 - k01 * k10).max(1e-24);
463    let r00 = (a * k11 - b * k10) / det_k;
464    let r10 = (c * k11 - d * k10) / det_k;
465    let rotate = r10.atan2(r00);
466    let (sx, sy) = (k00, k11);
467    if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
468        return None;
469    }
470    if angle_sign < 0.0 {
471        return Some((-sx, sy, -rotate, -(k01 / sy), -(k10 / sx)));
472    }
473    Some((sx, sy, rotate, k01 / sy, k10 / sx))
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn approx(a: f32, b: f32) -> bool {
481        (a - b).abs() < 1e-3
482    }
483
484    fn transform(tx: f32, ty: f32, sx: f32, sy: f32, rot: f32, hx: f32, hy: f32) -> Transform {
485        Transform {
486            translate_x: tx,
487            translate_y: ty,
488            scale_x: sx,
489            scale_y: sy,
490            rotate: rot,
491            shear_x: hx,
492            shear_y: hy,
493            origin_x: 0.0,
494            origin_y: 0.0,
495            perspective: [0.0, 0.0, 1.0],
496        }
497    }
498
499    #[test]
500    fn shear_free_matches_legacy_formula() {
501        let t = transform(5.0, -3.0, 2.0, 0.5, 0.7, 0.0, 0.0);
502        let p = Vec2 { x: 11.0, y: 7.0 };
503        let got = t.apply_to_point(p);
504        let (c, s) = (0.7f32.cos(), 0.7f32.sin());
505        let x = (p.x) * 2.0;
506        let y = (p.y) * 0.5;
507        assert!(approx(got.x, x * c - y * s + 5.0));
508        assert!(approx(got.y, x * s + y * c - 3.0));
509    }
510
511    #[test]
512    fn shear_order_is_scale_then_shear_then_rotate() {
513        let t = transform(0.0, 0.0, 2.0, 3.0, 0.0, 1.0, 0.0);
514        let got = t.apply_to_point(Vec2 { x: 1.0, y: 1.0 });
515        assert!(approx(got.x, 2.0 + 3.0));
516        assert!(approx(got.y, 3.0));
517    }
518
519    #[test]
520    fn combine_round_trips_through_apply() {
521        let cases = [
522            (
523                transform(3.0, 4.0, 1.0, 1.0, 0.0, 0.0, 0.0),
524                transform(0.0, 0.0, 2.0, 2.0, 0.0, 0.0, 0.0),
525            ),
526            (
527                transform(1.0, 2.0, 1.5, 0.5, 0.6, 0.0, 0.0),
528                transform(-2.0, 1.0, 0.7, 1.3, -0.4, 0.0, 0.0),
529            ),
530            (
531                transform(0.0, 0.0, 2.0, 0.5, 0.9, 0.0, 0.0),
532                transform(4.0, -1.0, 1.0, 1.0, 0.3, 0.0, 0.0),
533            ),
534            (
535                transform(2.0, 0.0, 1.0, 1.0, 0.2, 0.8, -0.3),
536                transform(0.0, 5.0, 1.2, 0.9, -0.5, 0.4, 0.1),
537            ),
538            (
539                transform(0.0, 0.0, -1.0, 1.0, 0.0, 0.0, 0.0),
540                transform(7.0, 7.0, 1.0, 1.0, 1.1, 0.2, 0.0),
541            ),
542        ];
543        let points = [
544            Vec2 { x: 0.0, y: 0.0 },
545            Vec2 { x: 10.0, y: -4.0 },
546            Vec2 { x: -3.5, y: 8.25 },
547            Vec2 { x: 100.0, y: 200.0 },
548        ];
549        for (a, b) in cases {
550            let combined = a.combine(&b);
551            for p in points {
552                let expect = a.apply_to_point(b.apply_to_point(p));
553                let got = combined.apply_to_point(p);
554                assert!(
555                    approx(got.x, expect.x) && approx(got.y, expect.y),
556                    "combine mismatch: {a:?} then {b:?} at {p:?}: got {got:?}, want {expect:?}"
557                );
558            }
559        }
560    }
561
562    #[test]
563    fn inverse_linear_round_trips() {
564        let t = transform(3.0, -2.0, 1.5, 0.75, 0.8, 0.6, -0.2);
565        let m = t.linear();
566        let n = t.inverse_linear().expect("invertible");
567        let id = [
568            n[0] * m[0] + n[1] * m[2],
569            n[0] * m[1] + n[1] * m[3],
570            n[2] * m[0] + n[3] * m[2],
571            n[2] * m[1] + n[3] * m[3],
572        ];
573        assert!(approx(id[0], 1.0) && approx(id[3], 1.0));
574        assert!(approx(id[1], 0.0) && approx(id[2], 0.0));
575    }
576}
577
578#[cfg(test)]
579mod projective_tests {
580    use super::*;
581
582    fn approx(a: f32, b: f32) -> bool {
583        (a - b).abs() < 1e-3
584    }
585
586    #[test]
587    fn identity_row_is_affine_noop() {
588        let t = Transform::identity();
589        assert!(!t.has_perspective());
590        let p = Vec2 { x: 13.0, y: -7.0 };
591        let q = t.apply_projective(p);
592        assert!(approx(q.x, p.x) && approx(q.y, p.y));
593        assert!(approx(t.projective_w(p), 1.0));
594    }
595
596    #[test]
597    fn tilt_about_center_matches_hand_computation() {
598        // Rotation about the x-axis through the rect centre, focal length f:
599        // y' = cy + (y - cy) * cos, w = 1 + (y - cy) * sin / f.
600        let (cx, cy, f) = (100.0f32, 100.0f32, 1000.0f32);
601        let th = 0.5f32;
602        let (s, c) = (th.sin(), th.cos());
603        let t = Transform::from_projective_rows(
604            [1.0, 0.0, 0.0],
605            [0.0, c, cy * (1.0 - c)],
606            [0.0, s / f, 1.0 - cy * s / f],
607        );
608        assert!(t.has_perspective());
609        // Centre is fixed; w varies linearly with distance from the axis.
610        let q = t.apply_projective(Vec2 { x: cx, y: cy });
611        assert!(approx(q.x, cx) && approx(q.y, cy));
612        assert!(
613            t.projective_w(Vec2 {
614                x: cx,
615                y: cy - 50.0
616            }) < 1.0
617        );
618        assert!(
619            t.projective_w(Vec2 {
620                x: cx,
621                y: cy + 50.0
622            }) > 1.0
623        );
624    }
625
626    #[test]
627    fn from_projective_rows_round_trips_affine_part() {
628        let t = Transform::from_projective_rows(
629            [2.0, 0.5, 7.0],
630            [-0.5, 3.0, -2.0],
631            [0.001, -0.002, 1.0],
632        );
633        // Affine rows survive the parts round-trip; translation is exact.
634        let m = t.projective_matrix();
635        assert!(approx(m[0], 2.0) && approx(m[1], 0.5) && approx(m[2], 7.0));
636        assert!(approx(m[3], -0.5) && approx(m[4], 3.0) && approx(m[5], -2.0));
637        assert!(approx(m[6], 0.001) && approx(m[7], -0.002) && approx(m[8], 1.0));
638    }
639
640    #[test]
641    fn compose_projective_matches_sequential_apply() {
642        let outer = Transform {
643            translate_x: 5.0,
644            ..Transform::identity()
645        };
646        let inner =
647            Transform::from_projective_rows([1.0, 0.0, 0.0], [0.0, 0.9, 10.0], [0.0, 0.001, 1.0]);
648        let c =
649            Transform::compose_projective(&outer.projective_matrix(), &inner.projective_matrix());
650        for p in [Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 40.0, y: -25.0 }] {
651            // Sequential: inner (projective) then outer (affine).
652            let q1 = inner.apply_projective(p);
653            let m = outer.projective_matrix();
654            let q1 = Vec2 {
655                x: m[0] * q1.x + m[1] * q1.y + m[2],
656                y: m[3] * q1.x + m[4] * q1.y + m[5],
657            };
658            let w = c[6] * p.x + c[7] * p.y + c[8];
659            let q2 = Vec2 {
660                x: (c[0] * p.x + c[1] * p.y + c[2]) / w,
661                y: (c[3] * p.x + c[4] * p.y + c[5]) / w,
662            };
663            assert!(
664                approx(q1.x, q2.x) && approx(q1.y, q2.y),
665                "mismatch at {p:?}: {q1:?} vs {q2:?}"
666            );
667        }
668    }
669
670    #[test]
671    fn project_rect_bounds_projected_corners() {
672        let t =
673            Transform::from_projective_rows([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.002, 0.0, 1.0]);
674        let r = Rect {
675            x: 0.0,
676            y: 0.0,
677            w: 100.0,
678            h: 100.0,
679        };
680        let b = t.project_rect(&r);
681        // w grows with x: the left edge (w=1) is unscaled, the right edge
682        // (w=1.2) shrinks in both axes.
683        assert!(approx(b.x, 0.0));
684        assert!(approx(b.w, 100.0 / 1.2));
685        assert!(approx(b.y, 0.0) && approx(b.h, 100.0));
686    }
687}