1use glam::{Mat3, Vec2, Vec3};
10
11#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Transform {
16 pub position: Vec2,
18 pub scale: Vec2,
20 pub rotation: f32,
22 pub pivot: Vec2,
24 pub skew: Vec2,
26}
27
28impl Transform {
29 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 #[must_use]
40 pub const fn from_position(position: Vec2) -> Self {
41 Self {
42 position,
43 ..Self::IDENTITY
44 }
45 }
46
47 #[must_use]
49 pub const fn from_scale(scale: Vec2) -> Self {
50 Self {
51 scale,
52 ..Self::IDENTITY
53 }
54 }
55
56 #[must_use]
58 pub const fn from_rotation(rotation: f32) -> Self {
59 Self {
60 rotation,
61 ..Self::IDENTITY
62 }
63 }
64
65 #[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 #[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#[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 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 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 #[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 #[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 #[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 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 #[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}