Skip to main content

valo_geometry/
matrix.rs

1use crate::{Point, Rect};
2
3/// `Matrix` is a full 4×4 column-major transform.
4///
5/// Valo maps two-dimensional input as `(x, y, 0, 1)`, including perspective
6/// division. The renderer ignores transformed z for draw ordering.
7#[derive(Clone, Copy, Debug, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9#[cfg_attr(feature = "serde", serde(transparent))]
10pub struct Matrix(glam::Mat4);
11
12/// `MatrixKind` classifies the behavior relevant to two-dimensional rendering.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum MatrixKind {
15    /// `AxisAligned` contains only positive scale and translation.
16    AxisAligned,
17    /// `Affine` includes rotation, shear, or reflection without perspective.
18    Affine,
19    /// `General` includes perspective.
20    General,
21}
22
23/// `W_EPSILON` keeps points at the eye plane from producing unbounded values.
24const W_EPSILON: f32 = 1e-6;
25
26impl Matrix {
27    /// `IDENTITY` leaves coordinates unchanged.
28    pub const IDENTITY: Matrix = Matrix(glam::Mat4::IDENTITY);
29
30    /// `translation` creates a two-dimensional translation.
31    pub fn translation(tx: f32, ty: f32) -> Self {
32        Matrix(glam::Mat4::from_translation(glam::Vec3::new(tx, ty, 0.0)))
33    }
34
35    /// `scale` creates a two-dimensional scale.
36    pub fn scale(sx: f32, sy: f32) -> Self {
37        Matrix(glam::Mat4::from_scale(glam::Vec3::new(sx, sy, 1.0)))
38    }
39
40    /// `rotation` creates a rotation around the origin.
41    ///
42    /// Positive angles rotate clockwise in Valo's y-down coordinate system.
43    pub fn rotation(radians: f32) -> Self {
44        Matrix(glam::Mat4::from_rotation_z(radians))
45    }
46
47    /// `from_affine` creates a matrix from `[a, b, c, d, tx, ty]`.
48    ///
49    /// The linear columns are `(a, b)` and `(c, d)`.
50    pub fn from_affine(a: f32, b: f32, c: f32, d: f32, tx: f32, ty: f32) -> Self {
51        Matrix(glam::Mat4::from_cols_array(&[
52            a, b, 0.0, 0.0, //
53            c, d, 0.0, 0.0, //
54            0.0, 0.0, 1.0, 0.0, //
55            tx, ty, 0.0, 1.0,
56        ]))
57    }
58
59    /// `from_flutter_array` creates a matrix from 16 column-major Flutter values.
60    pub fn from_flutter_array(values: &[f32; 16]) -> Self {
61        Matrix(glam::Mat4::from_cols_array(values))
62    }
63
64    /// `to_flutter_array` returns 16 column-major Flutter values.
65    pub fn to_flutter_array(&self) -> [f32; 16] {
66        self.0.to_cols_array()
67    }
68
69    /// `to_mat4` returns the backing glam matrix.
70    pub fn to_mat4(self) -> glam::Mat4 {
71        self.0
72    }
73
74    /// `then` composes this matrix with `other`.
75    ///
76    /// The result applies `other` first and this matrix second.
77    pub fn then(&self, other: &Matrix) -> Matrix {
78        Matrix(self.0 * other.0)
79    }
80
81    /// `is_affine` reports whether two-dimensional input has no perspective.
82    pub fn is_affine(&self) -> bool {
83        let m = &self.0;
84        m.x_axis.w == 0.0 && m.y_axis.w == 0.0 && m.w_axis.w == 1.0
85    }
86
87    /// `kind` classifies this matrix for two-dimensional rendering.
88    pub fn kind(&self) -> MatrixKind {
89        if !self.is_affine() {
90            return MatrixKind::General;
91        }
92        let m = &self.0;
93        let axis_aligned =
94            m.x_axis.y == 0.0 && m.y_axis.x == 0.0 && m.x_axis.x > 0.0 && m.y_axis.y > 0.0;
95        if axis_aligned {
96            MatrixKind::AxisAligned
97        } else {
98            MatrixKind::Affine
99        }
100    }
101
102    /// `map_point` transforms a point and applies perspective division.
103    ///
104    /// Points at or behind the eye plane use a small positive divisor.
105    pub fn map_point(&self, p: Point) -> Point {
106        let v = self.0 * glam::Vec4::new(p.x, p.y, 0.0, 1.0);
107        let w = if v.w > W_EPSILON { v.w } else { W_EPSILON };
108        Point::new(v.x / w, v.y / w)
109    }
110
111    /// `map_rect` returns axis-aligned bounds around a transformed rectangle.
112    ///
113    /// It returns [`Rect::EVERYTHING`] when a corner reaches or crosses the
114    /// eye plane and finite conservative bounds cannot be proven.
115    pub fn map_rect(&self, r: &Rect) -> Rect {
116        let (mut left, mut top) = (f32::MAX, f32::MAX);
117        let (mut right, mut bottom) = (f32::MIN, f32::MIN);
118        for corner in r.corners() {
119            let v = self.0 * glam::Vec4::new(corner.x, corner.y, 0.0, 1.0);
120            if v.w <= W_EPSILON {
121                return Rect::EVERYTHING;
122            }
123            let (x, y) = (v.x / v.w, v.y / v.w);
124            left = left.min(x);
125            top = top.min(y);
126            right = right.max(x);
127            bottom = bottom.max(y);
128        }
129        Rect::from_ltrb(left, top, right, bottom)
130    }
131
132    /// `max_scale` returns the larger length of the transformed x and y basis vectors.
133    ///
134    /// It ignores perspective and is therefore approximate for general matrices.
135    pub fn max_scale(&self) -> f32 {
136        let m = &self.0;
137        let sx = (m.x_axis.x * m.x_axis.x + m.x_axis.y * m.x_axis.y).sqrt();
138        let sy = (m.y_axis.x * m.y_axis.x + m.y_axis.y * m.y_axis.y).sqrt();
139        sx.max(sy)
140    }
141
142    /// `to_affine` returns `[a, b, c, d, tx, ty]` from the two-dimensional block.
143    ///
144    /// Perspective components are omitted; check [`Self::is_affine`] when
145    /// exact conversion is required.
146    pub fn to_affine(&self) -> [f32; 6] {
147        let m = &self.0;
148        [
149            m.x_axis.x, m.x_axis.y, m.y_axis.x, m.y_axis.y, m.w_axis.x, m.w_axis.y,
150        ]
151    }
152
153    /// `determinant` returns the signed area scale of the two-dimensional block.
154    pub fn determinant(&self) -> f32 {
155        let m = &self.0;
156        m.x_axis.x * m.y_axis.y - m.x_axis.y * m.y_axis.x
157    }
158
159    /// `invert` returns the inverse matrix or `None` when no finite inverse exists.
160    pub fn invert(&self) -> Option<Matrix> {
161        let det = self.0.determinant();
162        if det == 0.0 || !det.is_finite() {
163            return None;
164        }
165        let inverse = self.0.inverse();
166        inverse.is_finite().then_some(Matrix(inverse))
167    }
168}
169
170impl Default for Matrix {
171    fn default() -> Self {
172        Self::IDENTITY
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn close(a: Point, b: Point) -> bool {
181        (a.x - b.x).abs() < 1e-4 && (a.y - b.y).abs() < 1e-4
182    }
183
184    #[test]
185    fn near_singular_matrices_invert_to_none() {
186        assert!(Matrix::scale(1e-20, 1e-20).invert().is_none());
187        assert!(Matrix::scale(0.0, 1.0).invert().is_none());
188    }
189
190    #[test]
191    fn then_applies_local_first() {
192        // translate then scale: point scales, THEN translates (current ∘ local).
193        let t = Matrix::translation(10.0, 0.0).then(&Matrix::scale(2.0, 2.0));
194        assert!(close(
195            t.map_point(Point::new(1.0, 1.0)),
196            Point::new(12.0, 2.0)
197        ));
198    }
199
200    #[test]
201    fn rotation_quarter_turn() {
202        let t = Matrix::rotation(std::f32::consts::FRAC_PI_2);
203        // y-down: (1,0) rotates clockwise to (0,1).
204        assert!(close(
205            t.map_point(Point::new(1.0, 0.0)),
206            Point::new(0.0, 1.0)
207        ));
208    }
209
210    #[test]
211    fn invert_roundtrip() {
212        let t = Matrix::translation(5.0, -3.0)
213            .then(&Matrix::rotation(0.7))
214            .then(&Matrix::scale(2.0, 0.5));
215        let inv = t.invert().unwrap();
216        let p = Point::new(3.0, 4.0);
217        assert!(close(inv.map_point(t.map_point(p)), p));
218    }
219
220    #[test]
221    fn map_rect_rotation_is_conservative_bounds() {
222        let t = Matrix::rotation(std::f32::consts::FRAC_PI_4);
223        let r = t.map_rect(&Rect::new(-1.0, -1.0, 2.0, 2.0));
224        let d = 2.0_f32.sqrt();
225        assert!((r.width - 2.0 * d).abs() < 1e-4 && (r.height - 2.0 * d).abs() < 1e-4);
226    }
227
228    #[test]
229    fn perspective_divides_by_w() {
230        // Flutter's classic card tilt: entry[3][2] bends z into w — for 2D
231        // content that only matters through concatenation (below); a raw
232        // w-row on x makes near points larger than far ones.
233        let mut values = Matrix::IDENTITY.to_flutter_array();
234        values[3] = 0.001; // w += 0.001 · x
235        let t = Matrix::from_flutter_array(&values);
236        assert!(close(
237            t.map_point(Point::new(100.0, 100.0)),
238            Point::new(100.0 / 1.1, 100.0 / 1.1)
239        ));
240        assert_eq!(t.kind(), MatrixKind::General);
241    }
242
243    #[test]
244    fn concatenation_stays_four_by_four() {
245        // tilt ∘ translate ∘ tilt: the sequence Flutter's Transform widgets
246        // produce. Slicing each factor to its 2D action BEFORE multiplying
247        // loses the z column the middle translation feeds into the outer
248        // tilt's w row — full 4×4 concatenation keeps it.
249        let mut tilt_values = Matrix::IDENTITY.to_flutter_array();
250        tilt_values[11] = 0.001; // w += 0.001 · z (the Flutter entry(3,2))
251        let tilt = Matrix::from_flutter_array(&tilt_values);
252        let mut rotate_x = Matrix::IDENTITY.to_flutter_array();
253        // rotateX(0.5): y/z plane rotation — feeds y into z.
254        let (sin, cos) = 0.5_f32.sin_cos();
255        rotate_x[5] = cos;
256        rotate_x[6] = sin;
257        rotate_x[9] = -sin;
258        rotate_x[10] = cos;
259        let full = tilt.then(&Matrix::from_flutter_array(&rotate_x));
260        // The composed matrix must carry perspective from y (via z).
261        assert_eq!(full.kind(), MatrixKind::General);
262        let p = full.map_point(Point::new(0.0, 100.0));
263        // y rotated toward the viewer shrinks: 100·cos / (1 + 0.001·100·sin).
264        let expected_y = 100.0 * cos / (1.0 + 0.001 * 100.0 * sin);
265        assert!((p.y - expected_y).abs() < 1e-2, "{} vs {expected_y}", p.y);
266    }
267
268    #[test]
269    fn eye_plane_bounds_are_everything() {
270        let mut values = Matrix::IDENTITY.to_flutter_array();
271        values[3] = -0.1; // w = 1 - 0.1 · x → w ≤ 0 from x = 10 on
272        let t = Matrix::from_flutter_array(&values);
273        assert_eq!(
274            t.map_rect(&Rect::new(0.0, 0.0, 100.0, 10.0)),
275            Rect::EVERYTHING
276        );
277    }
278}