Skip to main content

telar_geometry_core/
transform.rs

1use crate::point::Point;
2
3/// A 2D affine transform stored as a 2×3 matrix `[a, b, c, d, e, f]`, mapping a
4/// point `(x, y)` to `(a*x + c*y + e, b*x + d*y + f)`. This is the same `[f32; 6]`
5/// layout consumed by `RenderNode::transform_with`, so `to_array()` plugs in
6/// directly. Compose with [`Transform::then`] instead of multiplying matrices by
7/// hand.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Transform {
10    pub a: f32,
11    pub b: f32,
12    pub c: f32,
13    pub d: f32,
14    pub e: f32,
15    pub f: f32,
16}
17
18impl Default for Transform {
19    fn default() -> Self {
20        Self::IDENTITY
21    }
22}
23
24impl Transform {
25    pub const IDENTITY: Transform = Transform {
26        a: 1.0,
27        b: 0.0,
28        c: 0.0,
29        d: 1.0,
30        e: 0.0,
31        f: 0.0,
32    };
33
34    /// Translate by `(tx, ty)`.
35    pub fn translate(tx: f32, ty: f32) -> Self {
36        Self {
37            a: 1.0,
38            b: 0.0,
39            c: 0.0,
40            d: 1.0,
41            e: tx,
42            f: ty,
43        }
44    }
45
46    /// Scale by `(sx, sy)` keeping the point `(cx, cy)` fixed.
47    pub fn scale_around(sx: f32, sy: f32, cx: f32, cy: f32) -> Self {
48        Self {
49            a: sx,
50            b: 0.0,
51            c: 0.0,
52            d: sy,
53            e: cx - sx * cx,
54            f: cy - sy * cy,
55        }
56    }
57
58    /// Rotate by `angle_deg` degrees keeping the point `(cx, cy)` fixed.
59    pub fn rotate_around(angle_deg: f32, cx: f32, cy: f32) -> Self {
60        let a = angle_deg.to_radians();
61        let cos = a.cos();
62        let sin = a.sin();
63        Self {
64            a: cos,
65            b: sin,
66            c: -sin,
67            d: cos,
68            e: cx - cx * cos + cy * sin,
69            f: cy - cx * sin - cy * cos,
70        }
71    }
72
73    /// Returns the transform that applies `self` first and then `next`
74    /// (`next ∘ self`), so `a.then(b).apply(p) == b.apply(a.apply(p))`.
75    pub fn then(self, next: Transform) -> Transform {
76        Transform {
77            a: next.a * self.a + next.c * self.b,
78            b: next.b * self.a + next.d * self.b,
79            c: next.a * self.c + next.c * self.d,
80            d: next.b * self.c + next.d * self.d,
81            e: next.a * self.e + next.c * self.f + next.e,
82            f: next.b * self.e + next.d * self.f + next.f,
83        }
84    }
85
86    pub fn apply(&self, p: Point) -> Point {
87        Point::new(
88            self.a * p.x + self.c * p.y + self.e,
89            self.b * p.x + self.d * p.y + self.f,
90        )
91    }
92
93    pub fn to_array(&self) -> [f32; 6] {
94        [self.a, self.b, self.c, self.d, self.e, self.f]
95    }
96
97    /// Rebuilds a `Transform` from the `[a, b, c, d, e, f]` layout produced by [`Transform::to_array`].
98    pub fn from_array(m: [f32; 6]) -> Transform {
99        Transform {
100            a: m[0],
101            b: m[1],
102            c: m[2],
103            d: m[3],
104            e: m[4],
105            f: m[5],
106        }
107    }
108
109    /// Returns the affine inverse, or `None` when the linear part is singular (determinant ≈ 0).
110    pub fn invert(&self) -> Option<Transform> {
111        let det = self.a * self.d - self.b * self.c;
112        if det.abs() < 1e-6 {
113            return None;
114        }
115        Some(Transform {
116            a: self.d / det,
117            b: -self.b / det,
118            c: -self.c / det,
119            d: self.a / det,
120            e: (self.c * self.f - self.d * self.e) / det,
121            f: (self.b * self.e - self.a * self.f) / det,
122        })
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn identity_is_noop() {
132        let p = Point::new(3.0, 4.0);
133        assert_eq!(Transform::IDENTITY.apply(p), p);
134    }
135
136    #[test]
137    fn then_composes_in_application_order() {
138        let translate = Transform {
139            e: 10.0,
140            ..Transform::IDENTITY
141        };
142        let scale = Transform {
143            a: 2.0,
144            d: 2.0,
145            ..Transform::IDENTITY
146        };
147        let t = translate.then(scale);
148        assert_eq!(t.apply(Point::new(0.0, 0.0)), Point::new(20.0, 0.0));
149    }
150
151    #[test]
152    fn rotate_around_keeps_center_fixed() {
153        let c = Point::new(5.0, 5.0);
154        let r = Transform::rotate_around(90.0, c.x, c.y).apply(c);
155        assert!((r.x - c.x).abs() < 1e-4 && (r.y - c.y).abs() < 1e-4);
156    }
157
158    #[test]
159    fn scale_around_keeps_center_fixed() {
160        let c = Point::new(7.0, 2.0);
161        let s = Transform::scale_around(3.0, 3.0, c.x, c.y).apply(c);
162        assert!((s.x - c.x).abs() < 1e-4 && (s.y - c.y).abs() < 1e-4);
163    }
164
165    #[test]
166    fn from_array_round_trips_to_array() {
167        let t = Transform {
168            a: 1.5,
169            b: 0.5,
170            c: -0.25,
171            d: 2.0,
172            e: 3.0,
173            f: -4.0,
174        };
175        assert_eq!(Transform::from_array(t.to_array()), t);
176    }
177
178    #[test]
179    fn then_invert_is_identity() {
180        let t = Transform {
181            a: 1.5,
182            b: 0.5,
183            c: -0.25,
184            d: 2.0,
185            e: 3.0,
186            f: -4.0,
187        };
188        let id = t.then(t.invert().unwrap());
189        let approx = |x: f32, y: f32| (x - y).abs() < 1e-4;
190        assert!(approx(id.a, 1.0));
191        assert!(approx(id.b, 0.0));
192        assert!(approx(id.c, 0.0));
193        assert!(approx(id.d, 1.0));
194        assert!(approx(id.e, 0.0));
195        assert!(approx(id.f, 0.0));
196    }
197
198    #[test]
199    fn invert_singular_returns_none() {
200        let singular = Transform {
201            a: 0.0,
202            b: 0.0,
203            c: 0.0,
204            d: 0.0,
205            e: 5.0,
206            f: 5.0,
207        };
208        assert!(singular.invert().is_none());
209    }
210}