Skip to main content

wisp/scene/
transform.rs

1//! `Transform` — local 2D affine transform built from translate / scale / rotate / pivot / skew.
2//!
3//! Composition order matches Pixi:
4//! `M = T(position) · S(scale) · R(rotation) · K(skew) · T(-pivot)`
5//!
6//! That is: move the pivot to the origin, skew, rotate, scale, then move the
7//! pivot to its target `position` in parent space.
8
9use glam::{Mat3, Vec2, Vec3};
10
11/// Local 2D affine transform.
12///
13/// Defaults to identity: zero translation, unit scale, zero rotation/skew/pivot.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Transform {
16    /// Translation in parent coordinates. The pivot lands here.
17    pub position: Vec2,
18    /// Per-axis scale around the pivot.
19    pub scale: Vec2,
20    /// Rotation in radians (around the pivot).
21    pub rotation: f32,
22    /// Anchor point in local coordinates that stays fixed under rotation/scale.
23    pub pivot: Vec2,
24    /// Per-axis skew in radians.
25    pub skew: Vec2,
26}
27
28impl Transform {
29    /// Identity transform — `to_mat3` returns [`Mat3::IDENTITY`].
30    pub const IDENTITY: Self = Self {
31        position: Vec2::ZERO,
32        scale: Vec2::ONE,
33        rotation: 0.0,
34        pivot: Vec2::ZERO,
35        skew: Vec2::ZERO,
36    };
37
38    /// Construct a translation-only transform.
39    #[must_use]
40    pub const fn from_position(position: Vec2) -> Self {
41        Self {
42            position,
43            ..Self::IDENTITY
44        }
45    }
46
47    /// Construct a scale-only transform.
48    #[must_use]
49    pub const fn from_scale(scale: Vec2) -> Self {
50        Self {
51            scale,
52            ..Self::IDENTITY
53        }
54    }
55
56    /// Construct a rotation-only transform (radians).
57    #[must_use]
58    pub const fn from_rotation(rotation: f32) -> Self {
59        Self {
60            rotation,
61            ..Self::IDENTITY
62        }
63    }
64
65    /// Compose this transform into a 3×3 affine matrix.
66    ///
67    /// Order: `T(position) · S(scale) · R(rotation) · K(skew) · T(-pivot)`.
68    #[must_use]
69    pub fn to_mat3(&self) -> Mat3 {
70        let translate = Mat3::from_translation(self.position);
71        let scale = Mat3::from_scale(self.scale);
72        let rotation = Mat3::from_angle(self.rotation);
73        let skew = Mat3::from_cols(
74            Vec3::new(1.0, self.skew.y.tan(), 0.0),
75            Vec3::new(self.skew.x.tan(), 1.0, 0.0),
76            Vec3::new(0.0, 0.0, 1.0),
77        );
78        let pivot_inverse = Mat3::from_translation(-self.pivot);
79
80        translate * scale * rotation * skew * pivot_inverse
81    }
82
83    /// Transform a 2D point through this transform.
84    #[must_use]
85    pub fn transform_point(&self, p: Vec2) -> Vec2 {
86        self.to_mat3().transform_point2(p)
87    }
88}
89
90impl Default for Transform {
91    fn default() -> Self {
92        Self::IDENTITY
93    }
94}
95
96/// Compose a parent world transform with a local transform → child world transform.
97///
98/// `world_child = world_parent · local_child` — apply `local` first, then `parent`.
99#[must_use]
100pub fn compose(world_parent: Mat3, local: &Transform) -> Mat3 {
101    world_parent * local.to_mat3()
102}
103
104#[cfg(test)]
105mod tests {
106    use std::f32::consts::{FRAC_PI_2, PI, TAU};
107
108    use proptest::prelude::*;
109
110    use super::*;
111
112    fn approx_vec2(a: Vec2, b: Vec2, tol: f32) -> bool {
113        (a - b).length_squared() < tol * tol
114    }
115
116    fn approx_mat3(a: Mat3, b: Mat3, tol: f32) -> bool {
117        a.x_axis.distance_squared(b.x_axis) < tol * tol
118            && a.y_axis.distance_squared(b.y_axis) < tol * tol
119            && a.z_axis.distance_squared(b.z_axis) < tol * tol
120    }
121
122    #[test]
123    fn identity_to_mat3_is_mat3_identity() {
124        assert!(approx_mat3(
125            Transform::IDENTITY.to_mat3(),
126            Mat3::IDENTITY,
127            1e-6
128        ));
129    }
130
131    #[test]
132    fn default_is_identity() {
133        assert_eq!(Transform::default(), Transform::IDENTITY);
134    }
135
136    #[test]
137    fn translation_only_moves_origin() {
138        let t = Transform::from_position(Vec2::new(10.0, 20.0));
139        assert!(approx_vec2(
140            t.transform_point(Vec2::ZERO),
141            Vec2::new(10.0, 20.0),
142            1e-6
143        ));
144    }
145
146    #[test]
147    fn scale_only_scales_around_origin() {
148        let t = Transform::from_scale(Vec2::new(2.0, 3.0));
149        assert!(approx_vec2(
150            t.transform_point(Vec2::new(4.0, 5.0)),
151            Vec2::new(8.0, 15.0),
152            1e-6
153        ));
154    }
155
156    #[test]
157    fn rotation_quarter_turn_swaps_axes() {
158        let t = Transform::from_rotation(FRAC_PI_2);
159        assert!(approx_vec2(t.transform_point(Vec2::X), Vec2::Y, 1e-5));
160        assert!(approx_vec2(t.transform_point(Vec2::Y), -Vec2::X, 1e-5));
161    }
162
163    #[test]
164    fn rotation_full_turn_returns_to_origin_point() {
165        let t = Transform::from_rotation(TAU);
166        let p = Vec2::new(3.0, 7.0);
167        assert!(approx_vec2(t.transform_point(p), p, 1e-4));
168    }
169
170    #[test]
171    fn pivot_keeps_anchor_at_position_under_rotation() {
172        // Pivot at (5,5) in local, position at (10,10) in parent, rotated 180°.
173        // The pivot itself maps to position regardless of rotation.
174        let t = Transform {
175            position: Vec2::new(10.0, 10.0),
176            pivot: Vec2::new(5.0, 5.0),
177            rotation: PI,
178            ..Transform::IDENTITY
179        };
180        assert!(approx_vec2(
181            t.transform_point(Vec2::new(5.0, 5.0)),
182            Vec2::new(10.0, 10.0),
183            1e-5
184        ));
185    }
186
187    #[test]
188    fn compose_identity_parent_yields_local() {
189        let local = Transform::from_position(Vec2::new(3.0, 4.0));
190        let world = compose(Mat3::IDENTITY, &local);
191        assert!(approx_mat3(world, local.to_mat3(), 1e-6));
192    }
193
194    #[test]
195    fn compose_translations_add() {
196        let parent = Transform::from_position(Vec2::new(10.0, 20.0));
197        let child = Transform::from_position(Vec2::new(3.0, 4.0));
198        let world = compose(parent.to_mat3(), &child);
199        // Child's local origin in world space = parent.position + child.position.
200        let p = world.transform_point2(Vec2::ZERO);
201        assert!(approx_vec2(p, Vec2::new(13.0, 24.0), 1e-6));
202    }
203
204    proptest! {
205        #![proptest_config(ProptestConfig::with_cases(64))]
206
207        /// Pure translation: `transform_point(p) = p + position`.
208        #[test]
209        fn prop_translation_adds(
210            tx in -1000.0_f32..1000.0,
211            ty in -1000.0_f32..1000.0,
212            px in -1000.0_f32..1000.0,
213            py in -1000.0_f32..1000.0,
214        ) {
215            let t = Transform::from_position(Vec2::new(tx, ty));
216            let p = Vec2::new(px, py);
217            let expected = p + Vec2::new(tx, ty);
218            prop_assert!(approx_vec2(t.transform_point(p), expected, 1e-3));
219        }
220
221        /// Pure scale: `transform_point(p) = p * scale` (around origin).
222        #[test]
223        fn prop_scale_multiplies(
224            sx in 0.01_f32..100.0,
225            sy in 0.01_f32..100.0,
226            px in -1000.0_f32..1000.0,
227            py in -1000.0_f32..1000.0,
228        ) {
229            let t = Transform::from_scale(Vec2::new(sx, sy));
230            let p = Vec2::new(px, py);
231            let expected = Vec2::new(px * sx, py * sy);
232            prop_assert!(approx_vec2(t.transform_point(p), expected, 1e-2));
233        }
234
235        /// `compose(parent, local)` applied to a point equals
236        /// `parent · local · p`.
237        #[test]
238        fn prop_compose_associates_with_application(
239            parent_tx in -100.0_f32..100.0,
240            parent_ty in -100.0_f32..100.0,
241            child_tx in -100.0_f32..100.0,
242            child_ty in -100.0_f32..100.0,
243            px in -100.0_f32..100.0,
244            py in -100.0_f32..100.0,
245        ) {
246            let parent = Transform::from_position(Vec2::new(parent_tx, parent_ty));
247            let child = Transform::from_position(Vec2::new(child_tx, child_ty));
248            let world = compose(parent.to_mat3(), &child);
249            let p = Vec2::new(px, py);
250            // World application = parent applied to (child applied to p).
251            let direct = parent.transform_point(child.transform_point(p));
252            let composed = world.transform_point2(p);
253            prop_assert!(approx_vec2(composed, direct, 1e-2));
254        }
255
256        /// Rotating by θ then by -θ returns to the starting point.
257        #[test]
258        fn prop_rotation_inverse_round_trips(
259            theta in -10.0_f32..10.0,
260            px in -100.0_f32..100.0,
261            py in -100.0_f32..100.0,
262        ) {
263            let forward = Transform::from_rotation(theta);
264            let backward = Transform::from_rotation(-theta);
265            let p = Vec2::new(px, py);
266            let round_trip = backward.transform_point(forward.transform_point(p));
267            prop_assert!(approx_vec2(round_trip, p, 1e-3));
268        }
269    }
270}