Skip to main content

kozan_primitives/
transform.rs

1use crate::geometry::{Point, Rect};
2
3/// A 2D affine transformation (translate, rotate, scale, skew).
4///
5/// Stored as a 3×2 matrix internally via [`glam::Affine2`], which gives
6/// us SIMD-optimized operations on supported platforms. The matrix layout:
7///
8/// ```text
9/// | a  c  tx |
10/// | b  d  ty |
11/// | 0  0  1  |
12/// ```
13///
14/// Use this for standard 2D UI work — CSS transforms, canvas drawing,
15/// element positioning. For 3D perspective and rotations around X/Y axes,
16/// use [`Transform3D`] instead.
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub struct AffineTransform {
19    inner: glam::Affine2,
20}
21
22impl AffineTransform {
23    pub const IDENTITY: Self = Self {
24        inner: glam::Affine2::IDENTITY,
25    };
26
27    /// Construct from the six matrix components directly.
28    #[must_use]
29    pub fn new(a: f32, b: f32, c: f32, d: f32, tx: f32, ty: f32) -> Self {
30        Self {
31            inner: glam::Affine2::from_cols(
32                glam::Vec2::new(a, b),
33                glam::Vec2::new(c, d),
34                glam::Vec2::new(tx, ty),
35            ),
36        }
37    }
38
39    #[must_use]
40    pub fn translate(tx: f32, ty: f32) -> Self {
41        Self {
42            inner: glam::Affine2::from_translation(glam::Vec2::new(tx, ty)),
43        }
44    }
45
46    #[must_use]
47    pub fn scale(sx: f32, sy: f32) -> Self {
48        Self {
49            inner: glam::Affine2::from_scale(glam::Vec2::new(sx, sy)),
50        }
51    }
52
53    #[must_use]
54    pub fn uniform_scale(s: f32) -> Self {
55        Self::scale(s, s)
56    }
57
58    /// Counter-clockwise rotation by `angle` radians.
59    #[must_use]
60    pub fn rotate(angle: f32) -> Self {
61        Self {
62            inner: glam::Affine2::from_angle(angle),
63        }
64    }
65
66    #[must_use]
67    pub fn is_identity(&self) -> bool {
68        self.inner == glam::Affine2::IDENTITY
69    }
70
71    /// True when the transform only translates (no rotation, scale, or skew).
72    #[must_use]
73    pub fn is_translation_only(&self) -> bool {
74        self.inner.matrix2 == glam::Mat2::IDENTITY
75    }
76
77    /// True when axis-aligned rectangles stay axis-aligned after
78    /// transformation (no rotation or skew — only scale and translation).
79    #[must_use]
80    pub fn preserves_axis_alignment(&self) -> bool {
81        let m = self.inner.matrix2;
82        (m.x_axis.y == 0.0 && m.y_axis.x == 0.0) || (m.x_axis.x == 0.0 && m.y_axis.y == 0.0)
83    }
84
85    /// Compose: apply `self` first, then `other`.
86    #[must_use]
87    pub fn then(&self, other: &Self) -> Self {
88        Self {
89            inner: other.inner * self.inner,
90        }
91    }
92
93    /// Prepend a translation to this transform.
94    #[must_use]
95    pub fn pre_translate(&self, tx: f32, ty: f32) -> Self {
96        let t = glam::Affine2::from_translation(glam::Vec2::new(tx, ty));
97        Self {
98            inner: self.inner * t,
99        }
100    }
101
102    /// Prepend a scale to this transform.
103    #[must_use]
104    pub fn pre_scale(&self, sx: f32, sy: f32) -> Self {
105        let s = glam::Affine2::from_scale(glam::Vec2::new(sx, sy));
106        Self {
107            inner: self.inner * s,
108        }
109    }
110
111    /// Compute the inverse. Returns `None` for singular (degenerate)
112    /// transforms where the determinant is zero.
113    #[must_use]
114    pub fn inverse(&self) -> Option<Self> {
115        let det = self.determinant();
116        if det.abs() < f32::EPSILON {
117            return None;
118        }
119        Some(Self {
120            inner: self.inner.inverse(),
121        })
122    }
123
124    #[must_use]
125    pub fn determinant(&self) -> f32 {
126        self.inner.matrix2.determinant()
127    }
128
129    #[must_use]
130    pub fn transform_point(&self, p: Point) -> Point {
131        let result = self.inner.transform_point2(glam::Vec2::new(p.x, p.y));
132        Point::new(result.x, result.y)
133    }
134
135    /// Transform a rectangle. When the transform involves rotation or skew,
136    /// the result is the axis-aligned bounding box of the transformed corners.
137    pub fn transform_rect(&self, r: &Rect) -> Rect {
138        if self.preserves_axis_alignment() {
139            let p0 = self.transform_point(r.origin);
140            let p1 = self.transform_point(Point::new(r.right(), r.bottom()));
141            let min_x = p0.x.min(p1.x);
142            let min_y = p0.y.min(p1.y);
143            let max_x = p0.x.max(p1.x);
144            let max_y = p0.y.max(p1.y);
145            return Rect::from_ltrb(min_x, min_y, max_x, max_y);
146        }
147
148        let corners = [
149            self.transform_point(Point::new(r.left(), r.top())),
150            self.transform_point(Point::new(r.right(), r.top())),
151            self.transform_point(Point::new(r.right(), r.bottom())),
152            self.transform_point(Point::new(r.left(), r.bottom())),
153        ];
154
155        let min_x = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
156        let min_y = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
157        let max_x = corners
158            .iter()
159            .map(|p| p.x)
160            .fold(f32::NEG_INFINITY, f32::max);
161        let max_y = corners
162            .iter()
163            .map(|p| p.y)
164            .fold(f32::NEG_INFINITY, f32::max);
165
166        Rect::from_ltrb(min_x, min_y, max_x, max_y)
167    }
168
169    /// Access the underlying `glam::Affine2` for interop with rendering
170    /// libraries that accept glam types directly.
171    #[must_use]
172    pub fn as_raw(&self) -> &glam::Affine2 {
173        &self.inner
174    }
175}
176
177impl Default for AffineTransform {
178    fn default() -> Self {
179        Self::IDENTITY
180    }
181}
182
183impl std::ops::Mul for AffineTransform {
184    type Output = Self;
185    /// `a * b` means "apply b first, then a".
186    fn mul(self, rhs: Self) -> Self {
187        rhs.then(&self)
188    }
189}
190
191/// A full 3D transformation stored as a 4×4 matrix.
192///
193/// Backed by [`glam::Mat4`] which provides SIMD-optimized operations.
194/// Needed for perspective transforms, 3D rotations, and compositing
195/// layers that exist in 3D space.
196///
197/// For pure 2D work, prefer [`AffineTransform`] — it uses fewer
198/// operations and less memory.
199#[derive(Clone, Copy, Debug, PartialEq)]
200pub struct Transform3D {
201    inner: glam::Mat4,
202}
203
204impl Transform3D {
205    pub const IDENTITY: Self = Self {
206        inner: glam::Mat4::IDENTITY,
207    };
208
209    #[must_use]
210    pub fn translate(tx: f32, ty: f32, tz: f32) -> Self {
211        Self {
212            inner: glam::Mat4::from_translation(glam::Vec3::new(tx, ty, tz)),
213        }
214    }
215
216    #[must_use]
217    pub fn scale(sx: f32, sy: f32, sz: f32) -> Self {
218        Self {
219            inner: glam::Mat4::from_scale(glam::Vec3::new(sx, sy, sz)),
220        }
221    }
222
223    #[must_use]
224    pub fn rotate_x(angle: f32) -> Self {
225        Self {
226            inner: glam::Mat4::from_rotation_x(angle),
227        }
228    }
229
230    #[must_use]
231    pub fn rotate_y(angle: f32) -> Self {
232        Self {
233            inner: glam::Mat4::from_rotation_y(angle),
234        }
235    }
236
237    /// Rotation around the Z axis (equivalent to 2D rotation).
238    #[must_use]
239    pub fn rotate_z(angle: f32) -> Self {
240        Self {
241            inner: glam::Mat4::from_rotation_z(angle),
242        }
243    }
244
245    /// A perspective projection that makes distant objects appear smaller.
246    /// `depth` is the distance from the viewer to the z=0 plane.
247    #[must_use]
248    pub fn perspective(depth: f32) -> Self {
249        if depth == 0.0 {
250            return Self::IDENTITY;
251        }
252        let mut m = glam::Mat4::IDENTITY;
253        // Set m[3][2] = -1/d for CSS-style perspective.
254        *m.col_mut(2) = glam::Vec4::new(0.0, 0.0, 1.0, -1.0 / depth);
255        Self { inner: m }
256    }
257
258    #[must_use]
259    pub fn is_identity(&self) -> bool {
260        self.inner == glam::Mat4::IDENTITY
261    }
262
263    /// True when the transform operates only in the X/Y plane (no Z
264    /// rotation, no perspective, no Z translation). Can be losslessly
265    /// represented as an [`AffineTransform`].
266    #[must_use]
267    pub fn is_2d(&self) -> bool {
268        let m = self.inner;
269        let c = |col: usize, row: usize| m.col(col)[row];
270        c(0, 2) == 0.0
271            && c(1, 2) == 0.0
272            && c(2, 0) == 0.0
273            && c(2, 1) == 0.0
274            && c(2, 2) == 1.0
275            && c(2, 3) == 0.0
276            && c(3, 2) == 0.0
277            && c(0, 3) == 0.0
278            && c(1, 3) == 0.0
279            && c(3, 3) == 1.0
280    }
281
282    /// Extract a 2D affine transform, dropping the Z components.
283    /// Only meaningful when [`is_2d`](Self::is_2d) returns true.
284    #[must_use]
285    pub fn to_affine(&self) -> AffineTransform {
286        let m = self.inner;
287        AffineTransform::new(
288            m.col(0)[0],
289            m.col(0)[1],
290            m.col(1)[0],
291            m.col(1)[1],
292            m.col(3)[0],
293            m.col(3)[1],
294        )
295    }
296
297    /// Promote a 2D affine transform to a 3D matrix.
298    #[must_use]
299    pub fn from_affine(t: &AffineTransform) -> Self {
300        let a = t.inner;
301        let m2 = a.matrix2;
302        let tr = a.translation;
303        Self {
304            inner: glam::Mat4::from_cols(
305                glam::Vec4::new(m2.x_axis.x, m2.x_axis.y, 0.0, 0.0),
306                glam::Vec4::new(m2.y_axis.x, m2.y_axis.y, 0.0, 0.0),
307                glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
308                glam::Vec4::new(tr.x, tr.y, 0.0, 1.0),
309            ),
310        }
311    }
312
313    #[must_use]
314    pub fn then(&self, other: &Self) -> Self {
315        Self {
316            inner: other.inner * self.inner,
317        }
318    }
319
320    /// Compute the inverse. Returns `None` if the matrix is singular.
321    #[must_use]
322    pub fn inverse(&self) -> Option<Self> {
323        let det = self.inner.determinant();
324        if det.abs() < f32::EPSILON {
325            return None;
326        }
327        Some(Self {
328            inner: self.inner.inverse(),
329        })
330    }
331
332    #[must_use]
333    pub fn determinant(&self) -> f32 {
334        self.inner.determinant()
335    }
336
337    /// Transform a 2D point through this 3D matrix. The point is treated
338    /// as (x, y, 0, 1) and projected back to 2D by dividing by the
339    /// homogeneous `w` coordinate.
340    #[must_use]
341    pub fn transform_point(&self, p: Point) -> Point {
342        let v = self.inner * glam::Vec4::new(p.x, p.y, 0.0, 1.0);
343        if v.w.abs() < f32::EPSILON {
344            return Point::new(v.x, v.y);
345        }
346        Point::new(v.x / v.w, v.y / v.w)
347    }
348
349    /// True when the back face of a transformed plane would be visible
350    /// (the Z component of the transformed normal is negative).
351    #[must_use]
352    pub fn is_back_face_visible(&self) -> bool {
353        // The Z component of the transformed normal tells us if the
354        // surface faces away from the viewer. Computed from the
355        // determinant of the upper-left 3×3 sub-matrix.
356        let m = self.inner;
357        let col0 = glam::Vec3::new(m.col(0)[0], m.col(0)[1], m.col(0)[2]);
358        let col1 = glam::Vec3::new(m.col(1)[0], m.col(1)[1], m.col(1)[2]);
359        let col2 = glam::Vec3::new(m.col(2)[0], m.col(2)[1], m.col(2)[2]);
360        col0.cross(col1).dot(col2) < 0.0
361    }
362
363    /// Access the underlying `glam::Mat4` for interop with rendering
364    /// libraries that accept glam types directly.
365    #[must_use]
366    pub fn as_raw(&self) -> &glam::Mat4 {
367        &self.inner
368    }
369}
370
371impl Default for Transform3D {
372    fn default() -> Self {
373        Self::IDENTITY
374    }
375}
376
377impl std::ops::Mul for Transform3D {
378    type Output = Self;
379    fn mul(self, rhs: Self) -> Self {
380        rhs.then(&self)
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::f32::consts::FRAC_PI_2;
388
389    fn approx_eq(a: f32, b: f32) -> bool {
390        (a - b).abs() < 1e-4
391    }
392
393    fn point_approx_eq(a: Point, b: Point) -> bool {
394        approx_eq(a.x, b.x) && approx_eq(a.y, b.y)
395    }
396
397    #[test]
398    fn identity_does_nothing() {
399        let p = Point::new(42.0, 17.0);
400        assert_eq!(AffineTransform::IDENTITY.transform_point(p), p);
401    }
402
403    #[test]
404    fn translate_moves_point() {
405        let t = AffineTransform::translate(10.0, 20.0);
406        assert_eq!(t.transform_point(Point::ZERO), Point::new(10.0, 20.0));
407    }
408
409    #[test]
410    fn scale_multiplies() {
411        let t = AffineTransform::scale(2.0, 3.0);
412        assert_eq!(
413            t.transform_point(Point::new(5.0, 10.0)),
414            Point::new(10.0, 30.0)
415        );
416    }
417
418    #[test]
419    fn rotate_90_degrees() {
420        let t = AffineTransform::rotate(FRAC_PI_2);
421        let result = t.transform_point(Point::new(1.0, 0.0));
422        assert!(point_approx_eq(result, Point::new(0.0, 1.0)));
423    }
424
425    #[test]
426    fn compose_scale_then_translate() {
427        let s = AffineTransform::scale(2.0, 2.0);
428        let t = AffineTransform::translate(10.0, 0.0);
429        let combined = s.then(&t);
430        let p = combined.transform_point(Point::new(5.0, 0.0));
431        assert_eq!(p, Point::new(20.0, 0.0));
432    }
433
434    #[test]
435    fn inverse_roundtrip() {
436        let t = AffineTransform::translate(10.0, 20.0)
437            .then(&AffineTransform::scale(2.0, 3.0))
438            .then(&AffineTransform::rotate(0.5));
439
440        let inv = t.inverse().unwrap();
441        let p = Point::new(42.0, 17.0);
442        let roundtrip = inv.transform_point(t.transform_point(p));
443        assert!(point_approx_eq(roundtrip, p));
444    }
445
446    #[test]
447    fn singular_matrix_no_inverse() {
448        let t = AffineTransform::scale(0.0, 1.0);
449        assert!(t.inverse().is_none());
450    }
451
452    #[test]
453    fn transform_rect_with_translation() {
454        let t = AffineTransform::translate(10.0, 10.0);
455        let r = Rect::new(0.0, 0.0, 50.0, 50.0);
456        assert_eq!(t.transform_rect(&r), Rect::new(10.0, 10.0, 50.0, 50.0));
457    }
458
459    #[test]
460    fn transform_rect_with_scale() {
461        let t = AffineTransform::scale(2.0, 0.5);
462        let r = Rect::new(10.0, 10.0, 20.0, 40.0);
463        let result = t.transform_rect(&r);
464        assert_eq!(result, Rect::new(20.0, 5.0, 40.0, 20.0));
465    }
466
467    #[test]
468    fn mul_operator() {
469        let a = AffineTransform::translate(5.0, 0.0);
470        let b = AffineTransform::scale(2.0, 2.0);
471        let p = (a * b).transform_point(Point::new(10.0, 0.0));
472        assert_eq!(p, Point::new(25.0, 0.0));
473    }
474
475    #[test]
476    fn is_translation_only() {
477        assert!(AffineTransform::translate(1.0, 2.0).is_translation_only());
478        assert!(!AffineTransform::scale(2.0, 1.0).is_translation_only());
479        assert!(!AffineTransform::rotate(0.1).is_translation_only());
480    }
481
482    #[test]
483    fn pre_translate() {
484        let t = AffineTransform::scale(2.0, 2.0).pre_translate(5.0, 0.0);
485        let p = t.transform_point(Point::ZERO);
486        assert_eq!(p, Point::new(10.0, 0.0));
487    }
488
489    // --- Transform3D ---
490
491    #[test]
492    fn identity_3d() {
493        let p = Point::new(42.0, 17.0);
494        assert_eq!(Transform3D::IDENTITY.transform_point(p), p);
495    }
496
497    #[test]
498    fn translate_3d() {
499        let t = Transform3D::translate(10.0, 20.0, 0.0);
500        assert_eq!(t.transform_point(Point::ZERO), Point::new(10.0, 20.0));
501    }
502
503    #[test]
504    fn rotate_z_matches_2d() {
505        let t2d = AffineTransform::rotate(FRAC_PI_2);
506        let t3d = Transform3D::rotate_z(FRAC_PI_2);
507        let p = Point::new(1.0, 0.0);
508        assert!(point_approx_eq(
509            t2d.transform_point(p),
510            t3d.transform_point(p)
511        ));
512    }
513
514    #[test]
515    fn affine_roundtrip() {
516        let t2d = AffineTransform::new(2.0, 0.5, -0.3, 1.5, 10.0, 20.0);
517        let t3d = Transform3D::from_affine(&t2d);
518        assert!(t3d.is_2d());
519
520        let back = t3d.to_affine();
521        assert_eq!(back, t2d);
522    }
523
524    #[test]
525    fn perspective_leaves_z0_unchanged() {
526        let t = Transform3D::perspective(100.0);
527        let p = Point::new(50.0, 50.0);
528        assert_eq!(t.transform_point(p), p);
529    }
530
531    #[test]
532    fn is_2d_checks() {
533        assert!(Transform3D::IDENTITY.is_2d());
534        assert!(Transform3D::translate(1.0, 2.0, 0.0).is_2d());
535        assert!(!Transform3D::translate(0.0, 0.0, 5.0).is_2d());
536        assert!(!Transform3D::rotate_x(0.1).is_2d());
537        assert!(!Transform3D::perspective(100.0).is_2d());
538    }
539
540    #[test]
541    fn back_face_detection() {
542        assert!(!Transform3D::IDENTITY.is_back_face_visible());
543        // Use a scale with negative X — this flips the face unambiguously,
544        // unlike rotate_y(PI) which has float precision issues at exactly 180°.
545        assert!(Transform3D::scale(-1.0, 1.0, 1.0).is_back_face_visible());
546        assert!(!Transform3D::scale(1.0, 1.0, 1.0).is_back_face_visible());
547    }
548
549    #[test]
550    fn inverse_3d_roundtrip() {
551        let t = Transform3D::translate(5.0, 10.0, 0.0)
552            .then(&Transform3D::rotate_z(0.7))
553            .then(&Transform3D::scale(2.0, 3.0, 1.0));
554
555        let inv = t.inverse().unwrap();
556        let p = Point::new(42.0, 17.0);
557        let roundtrip = inv.transform_point(t.transform_point(p));
558        assert!(point_approx_eq(roundtrip, p));
559    }
560}