Skip to main content

valo_geometry/
matrix.rs

1use crate::{Point, Rect};
2
3/// Full 4×4 column-major transform (glam-backed — the byte layout Flutter
4/// and Impeller use).
5///
6/// Canvas semantics throughout valo: the current transform maps LOCAL
7/// (drawn) coordinates to the list's ROOT space, and `then(local)` appends
8/// a transform that applies to subsequently drawn geometry FIRST — i.e.
9/// `current ∘ local`, matrix product `current × local`. Same convention as
10/// Skia/Impeller's transform stack.
11///
12/// 2D content maps as (x, y, 0, 1): the w row does perspective (the
13/// hardware divide, with perspective-correct interpolation); the z output
14/// is IGNORED for painting — valo writes its own per-draw depth (2.5D,
15/// Flutter's model).
16#[derive(Clone, Copy, Debug, PartialEq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(transparent))]
19pub struct Matrix(glam::Mat4);
20
21/// What the fast paths may assume about a matrix — computed when the
22/// transform stack changes, consulted per draw.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum MatrixKind {
25    /// Positive scale + translation only: device-snapped glyphs, scissor
26    /// clips, and analytic blur stay exact.
27    AxisAligned,
28    /// Any other affine (rotation, shear, flips).
29    Affine,
30    /// A live perspective row: conservative bounds, approximate scales.
31    General,
32}
33
34/// Below this w a projected corner counts as at/behind the eye plane —
35/// bounds go conservative instead of exploding across the flip.
36const W_EPSILON: f32 = 1e-6;
37
38impl Matrix {
39    pub const IDENTITY: Matrix = Matrix(glam::Mat4::IDENTITY);
40
41    pub fn translation(tx: f32, ty: f32) -> Self {
42        Matrix(glam::Mat4::from_translation(glam::Vec3::new(tx, ty, 0.0)))
43    }
44
45    pub fn scale(sx: f32, sy: f32) -> Self {
46        Matrix(glam::Mat4::from_scale(glam::Vec3::new(sx, sy, 1.0)))
47    }
48
49    pub fn rotation(radians: f32) -> Self {
50        Matrix(glam::Mat4::from_rotation_z(radians))
51    }
52
53    /// The classic 2×3 affine (column vectors (a,b), (c,d), translation).
54    pub fn from_affine(a: f32, b: f32, c: f32, d: f32, tx: f32, ty: f32) -> Self {
55        Matrix(glam::Mat4::from_cols_array(&[
56            a, b, 0.0, 0.0, //
57            c, d, 0.0, 0.0, //
58            0.0, 0.0, 1.0, 0.0, //
59            tx, ty, 0.0, 1.0,
60        ]))
61    }
62
63    /// The 16 column-major floats Flutter-architecture hosts hand a canvas.
64    pub fn from_flutter_array(values: &[f32; 16]) -> Self {
65        Matrix(glam::Mat4::from_cols_array(values))
66    }
67
68    pub fn to_flutter_array(&self) -> [f32; 16] {
69        self.0.to_cols_array()
70    }
71
72    /// The backing matrix, for MVP assembly.
73    pub fn to_mat4(self) -> glam::Mat4 {
74        self.0
75    }
76
77    /// `self ∘ other`: apply `other` first, then `self` (product self × other).
78    pub fn then(&self, other: &Matrix) -> Matrix {
79        Matrix(self.0 * other.0)
80    }
81
82    /// True when the w row is inert for 2D content ((0, 0, ·, 1) — the
83    /// z column never matters because inputs have z = 0).
84    pub fn is_affine(&self) -> bool {
85        let m = &self.0;
86        m.x_axis.w == 0.0 && m.y_axis.w == 0.0 && m.w_axis.w == 1.0
87    }
88
89    pub fn kind(&self) -> MatrixKind {
90        if !self.is_affine() {
91            return MatrixKind::General;
92        }
93        let m = &self.0;
94        let axis_aligned =
95            m.x_axis.y == 0.0 && m.y_axis.x == 0.0 && m.x_axis.x > 0.0 && m.y_axis.y > 0.0;
96        if axis_aligned {
97            MatrixKind::AxisAligned
98        } else {
99            MatrixKind::Affine
100        }
101    }
102
103    pub fn map_point(&self, p: Point) -> Point {
104        let v = self.0 * glam::Vec4::new(p.x, p.y, 0.0, 1.0);
105        let w = if v.w > W_EPSILON { v.w } else { W_EPSILON };
106        Point::new(v.x / w, v.y / w)
107    }
108
109    /// Axis-aligned bounds of the mapped rect: exact for rectilinear
110    /// transforms, conservative under rotation, and [`Rect::EVERYTHING`]
111    /// when any corner reaches the eye plane (w ≤ ε) — culling must never
112    /// reject such content, and layers clamp to their clip instead.
113    pub fn map_rect(&self, r: &Rect) -> Rect {
114        let (mut left, mut top) = (f32::MAX, f32::MAX);
115        let (mut right, mut bottom) = (f32::MIN, f32::MIN);
116        for corner in r.corners() {
117            let v = self.0 * glam::Vec4::new(corner.x, corner.y, 0.0, 1.0);
118            if v.w <= W_EPSILON {
119                return Rect::EVERYTHING;
120            }
121            let (x, y) = (v.x / v.w, v.y / v.w);
122            left = left.min(x);
123            top = top.min(y);
124            right = right.max(x);
125            bottom = bottom.max(y);
126        }
127        Rect::from_ltrb(left, top, right, bottom)
128    }
129
130    /// Maximum length the XY basis vectors scale a unit vector to — the
131    /// device-scale factor text pickers and blur sigmas care about
132    /// (Impeller's maxBasisLengthXY; ignores perspective, so approximate
133    /// under it).
134    pub fn max_scale(&self) -> f32 {
135        let m = &self.0;
136        let sx = (m.x_axis.x * m.x_axis.x + m.x_axis.y * m.x_axis.y).sqrt();
137        let sy = (m.y_axis.x * m.y_axis.x + m.y_axis.y * m.y_axis.y).sqrt();
138        sx.max(sy)
139    }
140
141    /// The 2D affine block `[a, b, c, d, tx, ty]` (column vectors (a,b),
142    /// (c,d), translation) — ignores any perspective row; pair with
143    /// [`Self::is_affine`] where exactness matters (gradient locals and
144    /// embed quads are affine by construction).
145    pub fn to_affine(&self) -> [f32; 6] {
146        let m = &self.0;
147        [
148            m.x_axis.x, m.x_axis.y, m.y_axis.x, m.y_axis.y, m.w_axis.x, m.w_axis.y,
149        ]
150    }
151
152    /// The 2D block's determinant (orientation / area factor of the
153    /// xy plane — what stroking and winding care about).
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    pub fn invert(&self) -> Option<Matrix> {
160        let det = self.0.determinant();
161        if det == 0.0 || !det.is_finite() {
162            return None;
163        }
164        let inverse = self.0.inverse();
165        inverse.is_finite().then_some(Matrix(inverse))
166    }
167}
168
169impl Default for Matrix {
170    fn default() -> Self {
171        Self::IDENTITY
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn close(a: Point, b: Point) -> bool {
180        (a.x - b.x).abs() < 1e-4 && (a.y - b.y).abs() < 1e-4
181    }
182
183    #[test]
184    fn near_singular_matrices_invert_to_none() {
185        assert!(Matrix::scale(1e-20, 1e-20).invert().is_none());
186        assert!(Matrix::scale(0.0, 1.0).invert().is_none());
187    }
188
189    #[test]
190    fn then_applies_local_first() {
191        // translate then scale: point scales, THEN translates (current ∘ local).
192        let t = Matrix::translation(10.0, 0.0).then(&Matrix::scale(2.0, 2.0));
193        assert!(close(
194            t.map_point(Point::new(1.0, 1.0)),
195            Point::new(12.0, 2.0)
196        ));
197    }
198
199    #[test]
200    fn rotation_quarter_turn() {
201        let t = Matrix::rotation(std::f32::consts::FRAC_PI_2);
202        // y-down: (1,0) rotates clockwise to (0,1).
203        assert!(close(
204            t.map_point(Point::new(1.0, 0.0)),
205            Point::new(0.0, 1.0)
206        ));
207    }
208
209    #[test]
210    fn invert_roundtrip() {
211        let t = Matrix::translation(5.0, -3.0)
212            .then(&Matrix::rotation(0.7))
213            .then(&Matrix::scale(2.0, 0.5));
214        let inv = t.invert().unwrap();
215        let p = Point::new(3.0, 4.0);
216        assert!(close(inv.map_point(t.map_point(p)), p));
217    }
218
219    #[test]
220    fn map_rect_rotation_is_conservative_bounds() {
221        let t = Matrix::rotation(std::f32::consts::FRAC_PI_4);
222        let r = t.map_rect(&Rect::new(-1.0, -1.0, 2.0, 2.0));
223        let d = 2.0_f32.sqrt();
224        assert!((r.width - 2.0 * d).abs() < 1e-4 && (r.height - 2.0 * d).abs() < 1e-4);
225    }
226
227    #[test]
228    fn perspective_divides_by_w() {
229        // Flutter's classic card tilt: entry[3][2] bends z into w — for 2D
230        // content that only matters through concatenation (below); a raw
231        // w-row on x makes near points larger than far ones.
232        let mut values = Matrix::IDENTITY.to_flutter_array();
233        values[3] = 0.001; // w += 0.001 · x
234        let t = Matrix::from_flutter_array(&values);
235        assert!(close(
236            t.map_point(Point::new(100.0, 100.0)),
237            Point::new(100.0 / 1.1, 100.0 / 1.1)
238        ));
239        assert_eq!(t.kind(), MatrixKind::General);
240    }
241
242    #[test]
243    fn concatenation_stays_four_by_four() {
244        // tilt ∘ translate ∘ tilt: the sequence Flutter's Transform widgets
245        // produce. Slicing each factor to its 2D action BEFORE multiplying
246        // loses the z column the middle translation feeds into the outer
247        // tilt's w row — full 4×4 concatenation keeps it.
248        let mut tilt_values = Matrix::IDENTITY.to_flutter_array();
249        tilt_values[11] = 0.001; // w += 0.001 · z (the Flutter entry(3,2))
250        let tilt = Matrix::from_flutter_array(&tilt_values);
251        let mut rotate_x = Matrix::IDENTITY.to_flutter_array();
252        // rotateX(0.5): y/z plane rotation — feeds y into z.
253        let (sin, cos) = 0.5_f32.sin_cos();
254        rotate_x[5] = cos;
255        rotate_x[6] = sin;
256        rotate_x[9] = -sin;
257        rotate_x[10] = cos;
258        let full = tilt.then(&Matrix::from_flutter_array(&rotate_x));
259        // The composed matrix must carry perspective from y (via z).
260        assert_eq!(full.kind(), MatrixKind::General);
261        let p = full.map_point(Point::new(0.0, 100.0));
262        // y rotated toward the viewer shrinks: 100·cos / (1 + 0.001·100·sin).
263        let expected_y = 100.0 * cos / (1.0 + 0.001 * 100.0 * sin);
264        assert!((p.y - expected_y).abs() < 1e-2, "{} vs {expected_y}", p.y);
265    }
266
267    #[test]
268    fn eye_plane_bounds_are_everything() {
269        let mut values = Matrix::IDENTITY.to_flutter_array();
270        values[3] = -0.1; // w = 1 - 0.1 · x → w ≤ 0 from x = 10 on
271        let t = Matrix::from_flutter_array(&values);
272        assert_eq!(
273            t.map_rect(&Rect::new(0.0, 0.0, 100.0, 10.0)),
274            Rect::EVERYTHING
275        );
276    }
277}