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