Skip to main content

game_gem/math/
transform.rs

1//! 2D Transform with hierarchy support.
2
3use super::{Vec2, Angle};
4
5/// A 2D transform representing position, rotation, and scale.
6///
7/// Supports parent-child hierarchies so that child transforms inherit
8/// their parent's world-space transform.
9#[derive(Clone, Debug)]
10pub struct Transform {
11    /// Local position relative to parent.
12    pub position: Vec2,
13    /// Local rotation in radians.
14    pub rotation: Angle,
15    /// Local scale (independent X/Y).
16    pub scale: Vec2,
17    /// Optional parent reference index (used by [`SceneNode`]).
18    pub parent: Option<usize>,
19    /// Cached world-space matrix (invalidated when local values change).
20    world_matrix: glam::Mat4,
21    /// Whether the world matrix needs recomputation.
22    dirty: bool,
23}
24
25impl Default for Transform {
26    fn default() -> Self {
27        Self {
28            position: Vec2::ZERO,
29            rotation: Angle::zero(),
30            scale: Vec2::ONE,
31            parent: None,
32            world_matrix: glam::Mat4::IDENTITY,
33            dirty: false,
34        }
35    }
36}
37
38impl Transform {
39    /// Create a transform with the given position.
40    pub fn at(x: f32, y: f32) -> Self {
41        Self {
42            position: Vec2::new(x, y),
43            ..Default::default()
44        }
45    }
46
47    /// Create a transform with position, rotation (degrees), and uniform scale.
48    pub fn new(x: f32, y: f32, rotation_degrees: f32, scale: f32) -> Self {
49        Self {
50            position: Vec2::new(x, y),
51            rotation: Angle::from_degrees(rotation_degrees),
52            scale: Vec2::splat(scale),
53            ..Default::default()
54        }
55    }
56
57    /// Set position and mark dirty.
58    pub fn set_position(&mut self, pos: Vec2) {
59        self.position = pos;
60        self.dirty = true;
61    }
62
63    /// Set rotation in degrees and mark dirty.
64    pub fn set_rotation_degrees(&mut self, degrees: f32) {
65        self.rotation = Angle::from_degrees(degrees);
66        self.dirty = true;
67    }
68
69    /// Set uniform scale and mark dirty.
70    pub fn set_scale(&mut self, s: f32) {
71        self.scale = Vec2::splat(s);
72        self.dirty = true;
73    }
74
75    /// Set non-uniform scale and mark dirty.
76    pub fn set_scale_vec(&mut self, s: Vec2) {
77        self.scale = s;
78        self.dirty = true;
79    }
80
81    /// Translate by an offset.
82    pub fn translate(&mut self, offset: Vec2) {
83        self.position += offset;
84        self.dirty = true;
85    }
86
87    /// Rotate by an angle (radians).
88    pub fn rotate(&mut self, angle: Angle) {
89        self.rotation += angle;
90        self.dirty = true;
91    }
92
93    /// Get the local-space 4×4 matrix (TR × S).
94    pub fn local_matrix(&self) -> glam::Mat4 {
95        let translation = glam::Mat4::from_translation(glam::Vec3::new(
96            self.position.x,
97            self.position.y,
98            0.0,
99        ));
100        let rotation = glam::Mat4::from_rotation_z(self.rotation.as_radians());
101        let scale = glam::Mat4::from_scale(glam::Vec3::new(self.scale.x, self.scale.y, 1.0));
102        translation * rotation * scale
103    }
104
105    /// Get the world-space matrix, recomputing if dirty.
106    ///
107    /// If a parent index is set, the parent's world matrix should be passed.
108    pub fn world_matrix(&mut self, parent_world: Option<glam::Mat4>) -> glam::Mat4 {
109        if self.dirty || parent_world.is_some() {
110            let local = self.local_matrix();
111            self.world_matrix = match parent_world {
112                Some(pw) => pw * local,
113                None => local,
114            };
115            self.dirty = false;
116        }
117        self.world_matrix
118    }
119
120    /// Invalidate the world matrix, forcing recomputation next frame.
121    pub fn mark_dirty(&mut self) {
122        self.dirty = true;
123    }
124
125    /// Extract the world-space position from the world matrix.
126    pub fn world_position(&self) -> Vec2 {
127        Vec2::new(self.world_matrix.w_axis.x, self.world_matrix.w_axis.y)
128    }
129
130    /// Transform a local point to world space.
131    pub fn transform_point(&self, local_point: Vec2) -> Vec2 {
132        let transformed = self.world_matrix
133            * glam::Vec4::new(local_point.x, local_point.y, 0.0, 1.0);
134        Vec2::new(transformed.x, transformed.y)
135    }
136
137    /// Inverse-transform a world point to local space.
138    pub fn inverse_transform_point(&self, world_point: Vec2) -> Vec2 {
139        // glam's `Mat4::inverse()` returns the inverse matrix directly
140        // (an identity-like matrix when non-invertible). For a degenerate
141        // transform we simply return the input point unchanged.
142        let det = self.world_matrix.determinant();
143        if det.abs() < 1e-9 {
144            return world_point;
145        }
146        let inv = self.world_matrix.inverse();
147        let transformed = inv * glam::Vec4::new(world_point.x, world_point.y, 0.0, 1.0);
148        Vec2::new(transformed.x, transformed.y)
149    }
150
151    /// Linearly interpolate between two transforms.
152    pub fn lerp_to(&self, other: &Transform, t: f32) -> Transform {
153        use super::FloatExt;
154        Transform {
155            position: self.position.lerp(other.position, t),
156            rotation: Angle::from_radians(
157                self.rotation.as_radians().lerp(other.rotation.as_radians(), t),
158            ),
159            scale: Vec2::new(
160                self.scale.x.lerp(other.scale.x, t),
161                self.scale.y.lerp(other.scale.y, t),
162            ),
163            parent: self.parent,
164            world_matrix: glam::Mat4::IDENTITY,
165            dirty: true,
166        }
167    }
168
169    /// Look at a target point (sets rotation to face the target).
170    pub fn look_at(&mut self, target: Vec2) {
171        let diff = target - self.position;
172        self.rotation = Angle::from_radians(diff.y.atan2(diff.x));
173        self.dirty = true;
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_default_transform() {
183        let t = Transform::default();
184        assert_eq!(t.position, Vec2::ZERO);
185        assert_eq!(t.rotation.as_radians(), 0.0);
186        assert_eq!(t.scale, Vec2::ONE);
187    }
188
189    #[test]
190    fn test_local_matrix_identity() {
191        let t = Transform::default();
192        let m = t.local_matrix();
193        // Verify that `m` is approximately equal to the identity matrix.
194        assert!(m.abs_diff_eq(glam::Mat4::IDENTITY, 1e-6));
195    }
196
197    #[test]
198    fn test_translate() {
199        let mut t = Transform::default();
200        t.translate(Vec2::new(10.0, 20.0));
201        assert_eq!(t.position, Vec2::new(10.0, 20.0));
202    }
203}