Skip to main content

esoc_scene/
transform.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! 2D affine transforms (3×2 matrix).
3
4/// A 2D affine transformation matrix (3×2).
5///
6/// Stored as `[a, b, c, d, tx, ty]` representing:
7/// ```text
8/// | a  b  tx |
9/// | c  d  ty |
10/// | 0  0   1 |
11/// ```
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct Affine2D {
14    /// Scale/rotation element (0,0).
15    pub a: f32,
16    /// Scale/rotation element (0,1).
17    pub b: f32,
18    /// Scale/rotation element (1,0).
19    pub c: f32,
20    /// Scale/rotation element (1,1).
21    pub d: f32,
22    /// Translation X.
23    pub tx: f32,
24    /// Translation Y.
25    pub ty: f32,
26}
27
28impl Affine2D {
29    /// Identity transform.
30    pub const IDENTITY: Self = Self {
31        a: 1.0,
32        b: 0.0,
33        c: 0.0,
34        d: 1.0,
35        tx: 0.0,
36        ty: 0.0,
37    };
38
39    /// Create a translation transform.
40    pub fn translate(tx: f32, ty: f32) -> Self {
41        Self {
42            a: 1.0,
43            b: 0.0,
44            c: 0.0,
45            d: 1.0,
46            tx,
47            ty,
48        }
49    }
50
51    /// Create a scaling transform.
52    pub fn scale(sx: f32, sy: f32) -> Self {
53        Self {
54            a: sx,
55            b: 0.0,
56            c: 0.0,
57            d: sy,
58            tx: 0.0,
59            ty: 0.0,
60        }
61    }
62
63    /// Create a rotation transform (angle in radians).
64    pub fn rotate(angle: f32) -> Self {
65        let (s, c) = angle.sin_cos();
66        Self {
67            a: c,
68            b: -s,
69            c: s,
70            d: c,
71            tx: 0.0,
72            ty: 0.0,
73        }
74    }
75
76    /// Compose: apply `self` then `other` (other × self).
77    #[allow(clippy::suspicious_operation_groupings)]
78    pub fn then(self, other: Self) -> Self {
79        Self {
80            a: (other.a * self.a) + (other.b * self.c),
81            b: (other.a * self.b) + (other.b * self.d),
82            c: (other.c * self.a) + (other.d * self.c),
83            d: (other.c * self.b) + (other.d * self.d),
84            tx: other.a * self.tx + other.b * self.ty + other.tx,
85            ty: other.c * self.tx + other.d * self.ty + other.ty,
86        }
87    }
88
89    /// Transform a point.
90    pub fn apply(self, p: [f32; 2]) -> [f32; 2] {
91        [
92            self.a * p[0] + self.b * p[1] + self.tx,
93            self.c * p[0] + self.d * p[1] + self.ty,
94        ]
95    }
96
97    /// Convert to a column-major 3×3 matrix for GPU uniform upload.
98    pub fn to_mat3_cols(self) -> [f32; 12] {
99        // Column-major 3×3 with std140 padding (each column = vec4)
100        [
101            self.a, self.c, 0.0, 0.0, // col 0
102            self.b, self.d, 0.0, 0.0, // col 1
103            self.tx, self.ty, 1.0, 0.0, // col 2
104        ]
105    }
106}
107
108impl Default for Affine2D {
109    fn default() -> Self {
110        Self::IDENTITY
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn identity() {
120        let p = Affine2D::IDENTITY.apply([3.0, 4.0]);
121        assert!((p[0] - 3.0).abs() < 1e-6);
122        assert!((p[1] - 4.0).abs() < 1e-6);
123    }
124
125    #[test]
126    fn translate() {
127        let p = Affine2D::translate(10.0, 20.0).apply([1.0, 2.0]);
128        assert!((p[0] - 11.0).abs() < 1e-6);
129        assert!((p[1] - 22.0).abs() < 1e-6);
130    }
131
132    #[test]
133    fn scale() {
134        let p = Affine2D::scale(2.0, 3.0).apply([4.0, 5.0]);
135        assert!((p[0] - 8.0).abs() < 1e-6);
136        assert!((p[1] - 15.0).abs() < 1e-6);
137    }
138
139    #[test]
140    fn compose() {
141        let t = Affine2D::translate(10.0, 0.0);
142        let s = Affine2D::scale(2.0, 2.0);
143        // Scale first, then translate
144        let combined = s.then(t);
145        let p = combined.apply([5.0, 0.0]);
146        assert!((p[0] - 20.0).abs() < 1e-5);
147    }
148
149    #[test]
150    fn rotate_90() {
151        let r = Affine2D::rotate(std::f32::consts::FRAC_PI_2);
152        let p = r.apply([1.0, 0.0]);
153        assert!(p[0].abs() < 1e-5);
154        assert!((p[1] - 1.0).abs() < 1e-5);
155    }
156}