Skip to main content

emblema_geometry/
transform.rs

1//! Mapping user space onto clip space.
2//!
3//! Three coordinate systems meet here, and confusing any two of them produces
4//! output that is mirrored or off by a scale factor rather than obviously
5//! broken:
6//!
7//! - **Path space** — whatever units a path was authored in.
8//! - **Device space** — pixels on the target. Origin top-left, X right, Y
9//!   *down*, which is the convention 2D UI works in.
10//! - **Clip space** — what the vertex shader emits. Both axes in `[-1, 1]`,
11//!   and Y runs *up*, following the WGSL convention that shader translation
12//!   normalizes to. This is the axis that flips.
13//!
14//! Tolerance is a device-space quantity, so flattening a path that will be
15//! scaled up has to happen more finely in path space. [`max_scale`] is what
16//! that adjustment is computed from.
17
18use glam::{Affine2, Mat2, Mat3, Vec2, Vec3};
19
20/// A transform of the plane that may carry perspective.
21///
22/// A three-by-three homography acting on a point written `(x, y, 1)`, which is
23/// what `dart:ui`'s four-by-four reduces to for content on the `z = 0` plane.
24/// An affine is the case whose bottom row is `(0, 0, 1)`, and it is the common
25/// one — [`is_affine`](Self::is_affine) is how the fast paths ask.
26///
27/// # Why this is a newtype and not a `Mat3`
28///
29/// Because [`glam::Mat3::transform_point2`] exists, compiles here, reads
30/// correctly, and is wrong. It documents itself as assuming a valid affine
31/// transform: it computes `x_axis * x + y_axis * y + z_axis` and stops, never
32/// dividing by the `w` it just produced. Anyone converting a call site would
33/// reach for it, and what comes out is a picture that is plausible and is not
34/// of the transform that was asked for — the failure this renderer is built to
35/// refuse.
36///
37/// So the divide is not optional here. This type offers
38/// [`project_point2`](Self::project_point2), which divides, and
39/// [`project_homogeneous`](Self::project_homogeneous), which hands back the
40/// undivided triple for the one caller that wants it — the vertex path, where
41/// the rasterizer does the divide and needs `w` to clip against first. There is
42/// no method that maps a point without doing one or the other.
43///
44/// # Storage
45///
46/// glam's `Mat3` is column-major, so `z_axis.xy` is the translation and the
47/// bottom row — the one that makes this projective — is spread across the three
48/// columns as `(x_axis.z, y_axis.z, z_axis.z)`.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Transform2D(Mat3);
51
52impl Default for Transform2D {
53    fn default() -> Self {
54        Self::IDENTITY
55    }
56}
57
58impl From<Affine2> for Transform2D {
59    fn from(affine: Affine2) -> Self {
60        Self::from_affine(affine)
61    }
62}
63
64/// So that a caller holding an affine by reference need not name the lift.
65impl From<&Affine2> for Transform2D {
66    fn from(affine: &Affine2) -> Self {
67        Self::from_affine(*affine)
68    }
69}
70
71impl std::ops::Mul for Transform2D {
72    type Output = Self;
73
74    fn mul(self, rhs: Self) -> Self {
75        Self(self.0 * rhs.0)
76    }
77}
78
79/// So that composing with an affine reads the way composing two of these does.
80///
81/// Most of what a transform is composed with here is affine — a projection, a
82/// translation to a paint's origin, a rotation folded into a gradient's space —
83/// and lifting each of those at the call site would bury the composition it is
84/// there to express.
85impl std::ops::Mul<Affine2> for Transform2D {
86    type Output = Self;
87
88    fn mul(self, rhs: Affine2) -> Self {
89        self * Self::from_affine(rhs)
90    }
91}
92
93impl std::ops::Mul<Transform2D> for Affine2 {
94    type Output = Transform2D;
95
96    fn mul(self, rhs: Transform2D) -> Transform2D {
97        Transform2D::from_affine(self) * rhs
98    }
99}
100
101impl Transform2D {
102    pub const IDENTITY: Self = Self(Mat3::IDENTITY);
103
104    /// Lift an affine into a homography, which is exact and free.
105    pub fn from_affine(affine: Affine2) -> Self {
106        let m = affine.matrix2;
107        let t = affine.translation;
108        Self(Mat3::from_cols(
109            Vec3::new(m.x_axis.x, m.x_axis.y, 0.0),
110            Vec3::new(m.y_axis.x, m.y_axis.y, 0.0),
111            Vec3::new(t.x, t.y, 1.0),
112        ))
113    }
114
115    /// The affine this is, or `None` if it carries perspective.
116    pub fn to_affine(self) -> Option<Affine2> {
117        self.is_affine().then(|| {
118            let c = self.0.to_cols_array();
119            Affine2::from_mat2_translation(
120                Mat2::from_cols(Vec2::new(c[0], c[1]), Vec2::new(c[3], c[4])),
121                Vec2::new(c[6], c[7]),
122            )
123        })
124    }
125
126    /// Whether the bottom row is `(0, 0, 1)`, so that `w` is one everywhere.
127    ///
128    /// Tested exactly rather than against a tolerance, and the distinction is
129    /// worth stating because it looks like an oversight. The bottom row's first
130    /// two entries have units of inverse length, so there is no scale-free
131    /// threshold to compare them against: whether `0.001` is negligible depends
132    /// entirely on how far across the plane the geometry reaches. What this
133    /// answers is "was perspective asked for", which is a question about how the
134    /// transform was built, and a transform built from an affine has exact
135    /// zeros there. Composition preserves them exactly, since the product's
136    /// bottom row is `(0, 0, 1)` times the other matrix.
137    ///
138    /// A caller wanting "close enough to affine over this region" is asking a
139    /// different question, and it needs the region.
140    pub fn is_affine(self) -> bool {
141        let c = self.0.to_cols_array();
142        c[2] == 0.0 && c[5] == 0.0 && c[8] == 1.0
143    }
144
145    /// Reduce the `dart:ui` four-by-four, which is column-major and sixteen long.
146    ///
147    /// Exact for the content this renderer draws rather than an approximation of
148    /// it. Applying a four-by-four to `(x, y, 0, 1)` never reads the `z` column,
149    /// and the `z` row only produces a depth nothing here has a use for — so
150    /// what is left, rows and columns zero, one and three, is the whole of what
151    /// the matrix means on the plane.
152    pub fn from_column_major_4x4(m: &[f32; 16]) -> Self {
153        Self(Mat3::from_cols(
154            Vec3::new(m[0], m[1], m[3]),
155            Vec3::new(m[4], m[5], m[7]),
156            Vec3::new(m[12], m[13], m[15]),
157        ))
158    }
159
160    /// The four-by-four this reduces from, with the `z` axis left alone.
161    pub fn to_column_major_4x4(self) -> [f32; 16] {
162        let c = self.0.to_cols_array();
163        [
164            c[0], c[1], 0.0, c[2], //
165            c[3], c[4], 0.0, c[5], //
166            0.0, 0.0, 1.0, 0.0, //
167            c[6], c[7], 0.0, c[8],
168        ]
169    }
170
171    /// The point mapped and divided.
172    ///
173    /// A point on the vanishing line has no image, and the divide reports that
174    /// as an infinity or a NaN rather than a wrong finite answer — which is what
175    /// every caller here already guards for with `is_finite`.
176    pub fn project_point2(self, point: Vec2) -> Vec2 {
177        let h = self.project_homogeneous(point);
178        h.truncate() / h.z
179    }
180
181    /// The point mapped and *not* divided.
182    ///
183    /// What a vertex carries. The rasterizer divides, and it needs `w` first in
184    /// order to clip against the plane where `w` reaches zero.
185    pub fn project_homogeneous(self, point: Vec2) -> Vec3 {
186        self.0 * Vec3::new(point.x, point.y, 1.0)
187    }
188
189    /// The inverse, or `None` if there is not one.
190    ///
191    /// An affine inverts as an affine, through `Affine2` rather than through the
192    /// general path, which is cheaper and — the part worth writing down — makes
193    /// the exactness this code's property instead of a dependency's.
194    ///
195    /// Everything downstream that asks [`is_affine`](Self::is_affine) is asking
196    /// about an exact zero, and an inverse taken the general way arrives at that
197    /// zero through the same adjugate arithmetic as every other entry. It does
198    /// in fact land on it today: glam returns a bottom row of exactly
199    /// `(0, -0, 1)` for a lifted affine, and negative zero compares equal, so
200    /// routing affines through the general path would work. It would work
201    /// because of how a dependency happens to arrange one division, which is not
202    /// a thing this can check and not a thing a version bump would announce. The
203    /// branch costs a comparison and removes the question.
204    pub fn inverse(self) -> Option<Self> {
205        if let Some(affine) = self.to_affine() {
206            let determinant = affine.matrix2.determinant();
207            if determinant.abs() <= 1e-9 || !determinant.is_finite() {
208                return None;
209            }
210            let inverse = affine.inverse();
211            return inverse.is_finite().then(|| Self::from_affine(inverse));
212        }
213        let determinant = self.0.determinant();
214        if determinant.abs() <= 1e-9 || !determinant.is_finite() {
215            return None;
216        }
217        let inverse = self.0.inverse();
218        inverse.is_finite().then_some(Self(inverse))
219    }
220
221    /// Whether every entry is a number, which a caller checks before handing
222    /// this to a shader that would otherwise spread a NaN across a draw.
223    pub fn is_finite(self) -> bool {
224        self.0.is_finite()
225    }
226
227    /// The determinant, which is zero exactly when the plane has collapsed.
228    pub fn determinant(self) -> f32 {
229        self.0.determinant()
230    }
231
232    /// [`transformed_bounds`], for a transform already in this form.
233    pub fn transformed_bounds_of(self, min: Vec2, max: Vec2) -> Option<(Vec2, Vec2)> {
234        transformed_bounds(self, min, max)
235    }
236
237    /// The nine floats, in column order.
238    pub fn to_cols_array(self) -> [f32; 9] {
239        self.0.to_cols_array()
240    }
241
242    /// The largest factor by which this can stretch a direction anywhere in a
243    /// box, or `None` if the box reaches the vanishing line.
244    ///
245    /// [`max_scale`] answers this for an affine with one number, because an
246    /// affine stretches every part of the plane alike. A homography does not:
247    /// writing it as `(A p + b) / (R · p + s)`, its derivative at `p` is
248    /// `(A - N(p) Rᵀ) / w(p)` for `N` the mapped point and `w` the divisor, so
249    /// the stretch grows as roughly one over `w` squared and the near end of a
250    /// shape needs finer flattening than the far end.
251    ///
252    /// Bounding it over a box rather than solving for the worst point: `w` is
253    /// affine, so its smallest value over a box is at a corner, and while `w`
254    /// stays positive the image of the box is the convex hull of the mapped
255    /// corners, so the largest `‖N‖` is at a corner too. Four evaluations, the
256    /// same four [`transformed_bounds`] already makes.
257    ///
258    /// Taking the largest is conservative in the direction that costs
259    /// triangles rather than correctness: the far end of a shape is flattened
260    /// more finely than it needs, and the near end is flattened finely enough.
261    ///
262    /// For an affine this reduces to [`max_scale`] exactly — `R` is zero and
263    /// `w` is one, leaving the longer basis vector — so no shape already being
264    /// drawn is tessellated any differently than it was.
265    pub fn max_scale_over(self, min: Vec2, max: Vec2) -> Option<f32> {
266        let c = self.0.to_cols_array();
267        let linear = Vec2::new(c[0], c[1])
268            .length()
269            .max(Vec2::new(c[3], c[4]).length());
270        if self.is_affine() {
271            return linear.is_finite().then_some(linear);
272        }
273        let corners = [min, Vec2::new(max.x, min.y), max, Vec2::new(min.x, max.y)];
274        let mut smallest_w = f32::INFINITY;
275        let mut furthest = 0.0f32;
276        for corner in corners {
277            let h = self.project_homogeneous(corner);
278            smallest_w = smallest_w.min(h.z);
279            furthest = furthest.max((h.truncate() / h.z).length());
280        }
281        // At or past the vanishing line there is no bound to give. The caller
282        // decides what to do about it; inventing a number here would be the
283        // one answer that cannot be checked.
284        // NaN tested for by name rather than by inverting the comparison: a
285        // NaN divisor compares false against everything, so `<=` alone would
286        // let it through as though the box were safely in front.
287        if smallest_w.is_nan() || smallest_w <= VANISHING_EPSILON || !furthest.is_finite() {
288            return None;
289        }
290        let perspective_row = Vec2::new(c[2], c[5]).length();
291        let bound = (linear + furthest * perspective_row) / smallest_w;
292        if !bound.is_finite() {
293            return None;
294        }
295        // The bound grows without limit toward the vanishing line, and past
296        // some point the extra subdivision is buying detail no display
297        // resolves. Capping it against the flat part keeps the work within a
298        // fixed multiple of what the same shape costs under an affine.
299        //
300        // The cap matters more than it looks. `MAX_SEGMENTS` is a *silent*
301        // quality floor -- a curve that reaches it is under-flattened and
302        // nothing says so -- and without a cap here ordinary perspective
303        // content would reach it and the picture would quietly go faceted.
304        // With one, a curve needing ten segments flat needs at most six
305        // hundred and forty, and `MAX_SEGMENTS` goes back to being a backstop
306        // against nonsense.
307        Some(bound.min(linear * MAX_PERSPECTIVE_REFINEMENT))
308    }
309}
310
311/// How far past the affine cost a shape may be flattened. See
312/// [`Transform2D::max_scale_over`].
313const MAX_PERSPECTIVE_REFINEMENT: f32 = 64.0;
314
315/// How near the vanishing line counts as on it.
316///
317/// Absolute rather than relative because `w` is already normalized: an affine
318/// lifted here has `w` of exactly one everywhere, so this is a fraction of that
319/// rather than of an arbitrary scale.
320const VANISHING_EPSILON: f32 = 1e-6;
321
322/// Map device pixels onto clip space for a target of the given size.
323///
324/// Device Y runs down from the top-left and clip Y runs up, so this flips the
325/// Y axis. Getting the flip wrong renders everything upside down, which is
326/// easy to miss on symmetric test content and obvious on text.
327pub fn viewport_projection(width: u32, height: u32) -> Affine2 {
328    // Guard against a zero-sized target producing infinities that then
329    // propagate into every vertex as NaN.
330    let w = if width == 0 { 1.0 } else { width as f32 };
331    let h = if height == 0 { 1.0 } else { height as f32 };
332    Affine2::from_cols(
333        Vec2::new(2.0 / w, 0.0),
334        Vec2::new(0.0, -2.0 / h),
335        Vec2::new(-1.0, 1.0),
336    )
337}
338
339/// The largest factor by which a transform can stretch a direction.
340///
341/// Used to scale flattening tolerance: a curve drawn at four times its
342/// authored size needs finer subdivision in path space to stay within the same
343/// device-space error. Estimated from the basis vector lengths, which bounds
344/// the true largest singular value from below by at most a factor of the
345/// square root of two — close enough for choosing a segment count, and cheaper
346/// than a decomposition.
347pub fn max_scale(transform: &Affine2) -> f32 {
348    let x = transform.matrix2.x_axis.length();
349    let y = transform.matrix2.y_axis.length();
350    x.max(y)
351}
352
353/// Whether a transform maps axis-aligned rectangles to axis-aligned rectangles.
354///
355/// True for any composition of translation, scale, reflection, and quarter
356/// turns; false as soon as an arbitrary rotation or a skew is involved. This is
357/// what decides whether a rectangular clip can be handed to the fixed-function
358/// scissor unit exactly, or whether it needs machinery that can express a
359/// rotated quadrilateral.
360///
361/// The comparison is relative rather than exact because a quarter turn does not
362/// produce exact zeros: `cos` of a right angle in `f32` is about `-4.4e-8`, so
363/// a caller who asked for exactly that rotation would otherwise be told their
364/// rectangle is no longer one. The threshold is scaled by the transform's own
365/// magnitude, since an absolute one means something different at a scale of a
366/// thousand than at a scale of a thousandth.
367/// # Perspective is never axis-preserving, and the check is exact
368///
369/// A homography carries axis-aligned rectangles to axis-aligned rectangles only
370/// when its bottom row is `(0, 0, s)` — that is, only when it is an affine.
371/// With a bottom row of `(p, 0, 1)` a vertical line stays vertical, but a
372/// horizontal one does not: `y` comes back divided by `p x + 1`, which varies
373/// along the line. So anything that is not exactly affine is refused here.
374///
375/// Refused exactly, not within a tolerance, and deliberately so. The bottom
376/// row's entries have units of inverse length, so there is no scale-free
377/// threshold that could say whether a small one is negligible — it depends on
378/// how far the geometry reaches. Being exact costs a transform with a
379/// vanishingly small perspective term the scissor fast path, which is a stencil
380/// pass nobody will measure; being approximate would cost a wrong clip, which
381/// is a picture with pixels in it the caller removed.
382///
383/// This is the one place in the perspective work where the existing correct
384/// code would have gone wrong without changing: it read the two-by-two and
385/// nothing else, and a pure scale carrying a perspective row would have been
386/// waved through to the scissor unit as a rectangle it is not.
387pub fn preserves_axis_alignment(transform: impl Into<Transform2D>) -> bool {
388    let transform = transform.into();
389    let Some(affine) = transform.to_affine() else {
390        return false;
391    };
392    let m = affine.matrix2;
393    let magnitude = max_scale(&affine);
394    if magnitude == 0.0 || !magnitude.is_finite() {
395        // A degenerate transform collapses every rectangle to a line or a
396        // point. That is a rectangle in the trivial sense and an empty clip in
397        // practice, so it is left to the caller's intersection to resolve
398        // rather than reported as an unsupported shape.
399        return true;
400    }
401    let epsilon = 1e-6 * magnitude;
402    // Diagonal is a scale, possibly reflected; anti-diagonal is that composed
403    // with a quarter turn. Anything else rotates or skews.
404    let diagonal = m.x_axis.y.abs() <= epsilon && m.y_axis.x.abs() <= epsilon;
405    let anti_diagonal = m.x_axis.x.abs() <= epsilon && m.y_axis.y.abs() <= epsilon;
406    diagonal || anti_diagonal
407}
408
409/// Bounds standing in for "anywhere", where a mapping gives none.
410///
411/// The conservative answer in the direction that is safe: every caller of
412/// [`transformed_bounds`] narrows something by what it returns, so a bound that
413/// is too large costs the chance to allocate something smaller, and one that is
414/// too small drops a shape that should have been drawn. A layer's bounds are
415/// clamped to its parent's target before anything is allocated, so this becomes
416/// the target rather than a demand for a texture the size of the number.
417///
418/// A quarter of the float range rather than the whole of it, so that a caller
419/// adding a blur's reach to one of these still has a finite number.
420pub fn unbounded() -> (Vec2, Vec2) {
421    let far = f32::MAX / 4.0;
422    (Vec2::splat(-far), Vec2::splat(far))
423}
424
425/// The bounds of a rectangle's corners after a transform, if there are any.
426///
427/// Exact when [`preserves_axis_alignment`] holds, and the bounding box of a
428/// rotated quadrilateral otherwise — which is why callers that need the clip to
429/// be the region asked for must check that first rather than relying on this to
430/// tell them.
431///
432/// # Why this can fail, and why it says so rather than guessing
433///
434/// Four corners bound a rectangle's image because a transform carries the
435/// rectangle's convex hull onto the hull of the corners' images. Under a
436/// homography that still holds — but only while the divisor stays positive
437/// across the whole rectangle. Let the rectangle reach the vanishing line and
438/// the corners land on both sides of infinity, and their bounding box is not
439/// merely loose, it is wrong: too small, which is the unsafe direction, because
440/// every caller here narrows something by it and a bound that is too small
441/// drops a shape that should have been drawn.
442///
443/// So that case returns `None` rather than a number. Not an empty rectangle,
444/// which would read as "nothing is there", and not an enormous one, which would
445/// read as "everything is". `None` makes each caller say what it does about a
446/// bound that does not exist, and there are two different right answers among
447/// them: a clip that cannot be narrowed is left alone, and a layer that cannot
448/// be sized is refused.
449pub fn transformed_bounds(
450    transform: impl Into<Transform2D>,
451    min: Vec2,
452    max: Vec2,
453) -> Option<(Vec2, Vec2)> {
454    let transform = transform.into();
455    let corners = [min, Vec2::new(max.x, min.y), max, Vec2::new(min.x, max.y)];
456    let mut mapped = [Vec2::ZERO; 4];
457    for (out, corner) in mapped.iter_mut().zip(corners) {
458        let h = transform.project_homogeneous(corner);
459        if h.z.is_nan() || h.z <= VANISHING_EPSILON {
460            return None;
461        }
462        *out = h.truncate() / h.z;
463    }
464    Some(mapped.iter().fold(
465        (mapped[0], mapped[0]),
466        |(lo, hi): (Vec2, Vec2), c: &Vec2| (lo.min(*c), hi.max(*c)),
467    ))
468}
469
470/// Apply a transform to every point in place.
471pub fn transform_points(points: &mut [Vec2], transform: &Affine2) {
472    for p in points.iter_mut() {
473        *p = transform.transform_point2(*p);
474    }
475}
476
477/// Invert a paint's placement into the form a shader's `to_local` takes.
478///
479/// `local_to_clip` carries the paint's own space onto clip space — the canvas
480/// transform, then wherever the paint sits, then whatever normalization its
481/// kind wants, a radius or a rectangle's size. What comes back is the inverse,
482/// as three columns of a three-by-three each padded to four floats, which is how
483/// a `mat3x3` sits in a uniform block and what lets both backends copy the
484/// packed material in without writing padding around anything.
485///
486/// # Why the whole matrix rather than a linear part and an anchor
487///
488/// It used to be four floats and a separately packed clip-space anchor, and the
489/// shader subtracted the one before applying the other. That works only for an
490/// affine, where the image of a difference is the difference of the images. A
491/// map with perspective divides by a quantity that depends on the absolute
492/// position, so an anchor subtracted before the mapping is subtracted in the
493/// wrong space — correct only in the case that made it look correct. Inside the
494/// matrix is the one place the translation belongs, and it rides there for free.
495///
496/// # A placement that has collapsed
497///
498/// A degenerate transform — a zero scale, or one axis collapsed — has no
499/// inverse. That is a caller mistake rather than a renderer one, and the shape
500/// it fills is collapsed to nothing anyway, so the paint it would have carried
501/// is not observable: the geometry went through the same matrix the mapping is
502/// the inverse of, which is the whole of why substituting anything here is safe.
503///
504/// The identity is what stands in, and it is chosen over anything else because
505/// its bottom row is `(0, 0, 1)`. A fragment that reached it despite the above
506/// would divide by one rather than by zero, and a NaN put into a fragment's
507/// color survives the blend and spreads across whatever it touches.
508pub fn invert_to_local(local_to_clip: Transform2D) -> [f32; 12] {
509    to_local_columns(local_to_clip.inverse().unwrap_or(Transform2D::IDENTITY))
510}
511
512/// The same packing, for a mapping already stated in the direction the shader
513/// reads it.
514///
515/// A layer composite and a picture's placement both build the clip-to-local
516/// mapping directly, because they know the texture's size rather than a
517/// placement to invert. Inverting a matrix only to invert it back would be
518/// arithmetic with nothing to show for it.
519pub fn to_local_columns(clip_to_local: Transform2D) -> [f32; 12] {
520    let c = clip_to_local.to_cols_array();
521    let padded = [
522        c[0], c[1], c[2], 0.0, //
523        c[3], c[4], c[5], 0.0, //
524        c[6], c[7], c[8], 0.0,
525    ];
526    if padded.iter().all(|v| v.is_finite()) {
527        padded
528    } else {
529        [
530            1.0, 0.0, 0.0, 0.0, //
531            0.0, 1.0, 0.0, 0.0, //
532            0.0, 0.0, 1.0, 0.0,
533        ]
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn scales_translations_and_quarter_turns_keep_rectangles_rectangular() {
543        let quarter = std::f32::consts::FRAC_PI_2;
544        for transform in [
545            Affine2::IDENTITY,
546            Affine2::from_translation(Vec2::new(13.0, -4.0)),
547            Affine2::from_scale(Vec2::new(3.0, 0.5)),
548            // A reflection, which is a negative scale rather than a rotation.
549            Affine2::from_scale(Vec2::new(-1.0, 1.0)),
550            Affine2::from_angle(quarter),
551            Affine2::from_angle(-quarter),
552            Affine2::from_angle(2.0 * quarter),
553            // Composition of all of them is still axis-preserving.
554            Affine2::from_angle(quarter)
555                * Affine2::from_scale(Vec2::new(2.0, 7.0))
556                * Affine2::from_translation(Vec2::new(1.0, 1.0)),
557        ] {
558            assert!(
559                preserves_axis_alignment(transform),
560                "{transform:?} was rejected"
561            );
562        }
563    }
564
565    #[test]
566    fn arbitrary_rotations_and_skews_do_not() {
567        let mut skew = Affine2::IDENTITY;
568        skew.matrix2.y_axis.x = 0.4;
569        for transform in [
570            Affine2::from_angle(0.3),
571            Affine2::from_angle(std::f32::consts::FRAC_PI_4),
572            skew,
573        ] {
574            assert!(
575                !preserves_axis_alignment(transform),
576                "{transform:?} was accepted"
577            );
578        }
579    }
580
581    #[test]
582    fn the_threshold_scales_with_the_transform() {
583        // The same tiny skew is noise beside a large scale and the whole of the
584        // transform beside a small one. An absolute threshold would call both
585        // the same thing.
586        let skew = 1e-3;
587        let mut large = Affine2::from_scale(Vec2::splat(1e4));
588        large.matrix2.y_axis.x = skew;
589        assert!(preserves_axis_alignment(large));
590
591        let mut small = Affine2::from_scale(Vec2::splat(1e-2));
592        small.matrix2.y_axis.x = skew;
593        assert!(!preserves_axis_alignment(small));
594    }
595
596    #[test]
597    fn a_quarter_turn_maps_a_rectangle_onto_the_other_axis() {
598        let quarter = Affine2::from_angle(std::f32::consts::FRAC_PI_2);
599        let (min, max) = transformed_bounds(quarter, Vec2::new(0.0, 0.0), Vec2::new(4.0, 1.0))
600            .expect("an affine always has bounds");
601        // Width and height exchange places; the corner positions follow the
602        // rotation rather than staying put.
603        assert!(
604            (max.x - min.x - 1.0).abs() < 1e-5,
605            "width became {}",
606            max.x - min.x
607        );
608        assert!(
609            (max.y - min.y - 4.0).abs() < 1e-5,
610            "height became {}",
611            max.y - min.y
612        );
613    }
614
615    fn approx(a: Vec2, b: Vec2) -> bool {
616        (a - b).length() < 1e-5
617    }
618
619    #[test]
620    fn the_top_left_pixel_maps_to_the_top_of_clip_space() {
621        let p = viewport_projection(64, 64);
622        // Device origin is top-left; clip Y runs up, so the top is +1.
623        assert!(approx(
624            p.transform_point2(Vec2::new(0.0, 0.0)),
625            Vec2::new(-1.0, 1.0)
626        ));
627    }
628
629    #[test]
630    fn the_bottom_right_corner_maps_to_the_bottom_of_clip_space() {
631        let p = viewport_projection(64, 64);
632        assert!(approx(
633            p.transform_point2(Vec2::new(64.0, 64.0)),
634            Vec2::new(1.0, -1.0)
635        ));
636    }
637
638    #[test]
639    fn the_center_maps_to_the_origin() {
640        let p = viewport_projection(800, 600);
641        assert!(approx(
642            p.transform_point2(Vec2::new(400.0, 300.0)),
643            Vec2::ZERO
644        ));
645    }
646
647    #[test]
648    fn the_y_axis_is_flipped_and_the_x_axis_is_not() {
649        let p = viewport_projection(100, 100);
650        let top = p.transform_point2(Vec2::new(50.0, 10.0));
651        let bottom = p.transform_point2(Vec2::new(50.0, 90.0));
652        // Further down in device space must be further down in clip space,
653        // which under a Y-up clip convention means a smaller value.
654        assert!(top.y > bottom.y, "device Y down should map to clip Y up");
655
656        let left = p.transform_point2(Vec2::new(10.0, 50.0));
657        let right = p.transform_point2(Vec2::new(90.0, 50.0));
658        assert!(left.x < right.x, "X must not be flipped");
659    }
660
661    #[test]
662    fn a_non_square_target_scales_each_axis_independently() {
663        let p = viewport_projection(200, 100);
664        // Half the width in device space is the same clip distance as half the
665        // height; a single shared scale factor would squash one axis.
666        assert!(approx(
667            p.transform_point2(Vec2::new(200.0, 0.0)),
668            Vec2::new(1.0, 1.0)
669        ));
670        assert!(approx(
671            p.transform_point2(Vec2::new(0.0, 100.0)),
672            Vec2::new(-1.0, -1.0)
673        ));
674    }
675
676    #[test]
677    fn a_zero_sized_target_does_not_produce_non_finite_coordinates() {
678        let p = viewport_projection(0, 0);
679        let v = p.transform_point2(Vec2::new(1.0, 1.0));
680        assert!(v.is_finite(), "got {v:?}");
681    }
682
683    #[test]
684    fn max_scale_reports_the_larger_axis() {
685        let t = Affine2::from_scale(Vec2::new(3.0, 7.0));
686        assert!((max_scale(&t) - 7.0).abs() < 1e-5);
687        assert!((max_scale(&Affine2::IDENTITY) - 1.0).abs() < 1e-5);
688    }
689
690    #[test]
691    fn max_scale_is_unchanged_by_rotation_and_translation() {
692        let rotated = Affine2::from_angle(0.9) * Affine2::from_scale(Vec2::splat(2.0));
693        assert!((max_scale(&rotated) - 2.0).abs() < 1e-4);
694
695        let translated = Affine2::from_translation(Vec2::new(100.0, -50.0));
696        assert!((max_scale(&translated) - 1.0).abs() < 1e-5);
697    }
698
699    #[test]
700    fn transforming_points_matches_transforming_them_one_at_a_time() {
701        let t =
702            Affine2::from_scale_angle_translation(Vec2::new(2.0, 3.0), 0.4, Vec2::new(5.0, -1.0));
703        let source = [Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.0), Vec2::ZERO];
704        let mut batch = source;
705        transform_points(&mut batch, &t);
706        for (i, p) in source.iter().enumerate() {
707            assert!(approx(batch[i], t.transform_point2(*p)));
708        }
709    }
710
711    /// A homography is defined by where it sends four points, so checking it
712    /// against a square's corners checks the whole map.
713    #[test]
714    fn a_homography_maps_a_square_to_the_quadrilateral_its_corners_describe() {
715        // Bottom row (0.002, 0, 1): w grows with x, so the far side shrinks.
716        let t = Transform2D::from_column_major_4x4(&[
717            1.0, 0.0, 0.0, 0.002, //
718            0.0, 1.0, 0.0, 0.0, //
719            0.0, 0.0, 1.0, 0.0, //
720            0.0, 0.0, 0.0, 1.0,
721        ]);
722        assert!(!t.is_affine());
723        // At x = 100 the divisor is 1.2, at x = 0 it is 1.
724        let near = t.project_point2(Vec2::new(0.0, 100.0));
725        let far = t.project_point2(Vec2::new(100.0, 100.0));
726        assert!(approx(near, Vec2::new(0.0, 100.0)));
727        assert!(approx(far, Vec2::new(100.0 / 1.2, 100.0 / 1.2)));
728        // The undivided form carries the divisor rather than applying it.
729        let homogeneous = t.project_homogeneous(Vec2::new(100.0, 100.0));
730        assert!((homogeneous.z - 1.2).abs() < 1e-5);
731    }
732
733    /// The property that lets every existing call site keep working.
734    #[test]
735    fn an_affine_lifts_and_lowers_without_moving_a_point() {
736        let affine = Affine2::from_angle(0.3)
737            * Affine2::from_scale(Vec2::new(2.0, 7.0))
738            * Affine2::from_translation(Vec2::new(11.0, -3.0));
739        let lifted = Transform2D::from(affine);
740        assert!(lifted.is_affine());
741        assert_eq!(lifted.to_affine(), Some(affine));
742        for p in [Vec2::ZERO, Vec2::new(5.0, -2.0), Vec2::new(-100.0, 40.0)] {
743            assert!(approx(lifted.project_point2(p), affine.transform_point2(p)));
744            // And w stayed exactly one, which is what keeps the divide free.
745            assert_eq!(lifted.project_homogeneous(p).z, 1.0);
746        }
747    }
748
749    #[test]
750    fn a_transform_round_trips_through_the_four_by_four_form() {
751        let t = Transform2D::from_column_major_4x4(&[
752            2.0, 0.5, 0.0, 0.003, //
753            -1.0, 3.0, 0.0, 0.007, //
754            0.0, 0.0, 1.0, 0.0, //
755            9.0, -4.0, 0.0, 1.5,
756        ]);
757        assert_eq!(
758            Transform2D::from_column_major_4x4(&t.to_column_major_4x4()),
759            t
760        );
761    }
762
763    #[test]
764    fn composing_two_homographies_agrees_with_transforming_twice() {
765        let a = Transform2D::from_column_major_4x4(&[
766            1.0, 0.0, 0.0, 0.002, //
767            0.0, 1.0, 0.0, 0.0, //
768            0.0, 0.0, 1.0, 0.0, //
769            5.0, 0.0, 0.0, 1.0,
770        ]);
771        let b = Transform2D::from(Affine2::from_angle(0.4) * Affine2::from_scale(Vec2::splat(2.0)));
772        for p in [Vec2::new(3.0, 4.0), Vec2::new(-20.0, 60.0)] {
773            assert!(approx(
774                (a * b).project_point2(p),
775                a.project_point2(b.project_point2(p))
776            ));
777        }
778    }
779
780    /// Composition must not manufacture perspective that nobody asked for, or
781    /// every affine fast path downstream turns itself off after one `concat`.
782    #[test]
783    fn composing_two_affines_stays_exactly_affine() {
784        let a = Transform2D::from(Affine2::from_angle(0.3));
785        let b = Transform2D::from(Affine2::from_scale(Vec2::new(1e6, 1e-6)));
786        assert!((a * b).is_affine());
787        assert!((b * a).is_affine());
788    }
789
790    #[test]
791    fn the_inverse_of_a_projective_map_undoes_it() {
792        let t = Transform2D::from_column_major_4x4(&[
793            1.0, 0.0, 0.0, 0.002, //
794            0.0, 1.0, 0.0, 0.001, //
795            0.0, 0.0, 1.0, 0.0, //
796            7.0, -2.0, 0.0, 1.0,
797        ]);
798        let inverse = t.inverse().expect("invertible");
799        for p in [Vec2::new(10.0, 20.0), Vec2::new(-30.0, 5.0)] {
800            assert!(approx(inverse.project_point2(t.project_point2(p)), p));
801        }
802    }
803
804    /// The contract the shader's clamp rests on: forward `w` positive means the
805    /// inverse's divisor is positive too, so a fragment in front of the
806    /// vanishing line is never mistaken for one behind it.
807    #[test]
808    fn the_inverse_keeps_the_sign_of_the_divisor() {
809        let t = Transform2D::from_column_major_4x4(&[
810            1.0, 0.0, 0.0, 0.002, //
811            0.0, 1.0, 0.0, 0.001, //
812            0.0, 0.0, 1.0, 0.0, //
813            7.0, -2.0, 0.0, 1.0,
814        ]);
815        let inverse = t.inverse().expect("invertible");
816        for p in [Vec2::new(10.0, 20.0), Vec2::new(-30.0, 5.0)] {
817            let forward = t.project_homogeneous(p);
818            assert!(forward.z > 0.0, "test point is in front");
819            let back = inverse.project_homogeneous(forward.truncate() / forward.z);
820            assert!(back.z > 0.0, "divisor flipped sign through the inverse");
821        }
822    }
823
824    /// An affine's inverse has to come back exactly affine, not affine to
825    /// within rounding, or `is_affine` answers no and the fast paths stop.
826    #[test]
827    fn the_inverse_of_an_affine_is_still_exactly_affine() {
828        let affine = Affine2::from_angle(0.37)
829            * Affine2::from_scale(Vec2::new(1e4, 3e-3))
830            * Affine2::from_translation(Vec2::new(1234.0, -99.0));
831        let inverse = Transform2D::from(affine).inverse().expect("invertible");
832        assert!(inverse.is_affine());
833        let c = inverse.to_cols_array();
834        assert_eq!((c[2], c[5], c[8]), (0.0, 0.0, 1.0));
835    }
836
837    #[test]
838    fn a_singular_matrix_has_no_inverse_to_report() {
839        // Collapsed in one axis, which still has a well-defined image.
840        assert!(Transform2D::from(Affine2::from_scale(Vec2::new(0.0, 1.0)))
841            .inverse()
842            .is_none());
843        assert!(Transform2D::from(Affine2::from_scale(Vec2::ZERO))
844            .inverse()
845            .is_none());
846        // Projective and singular: two columns the same.
847        let degenerate = Transform2D::from_column_major_4x4(&[
848            1.0, 2.0, 0.0, 3.0, //
849            1.0, 2.0, 0.0, 3.0, //
850            0.0, 0.0, 1.0, 0.0, //
851            0.0, 0.0, 0.0, 1.0,
852        ]);
853        assert!(degenerate.inverse().is_none());
854    }
855
856    /// A perspective transform whose divisor grows with x, so the plane is
857    /// magnified toward negative x and shrunk toward positive.
858    fn receding() -> Transform2D {
859        Transform2D::from_column_major_4x4(&[
860            1.0, 0.0, 0.0, 0.002, //
861            0.0, 1.0, 0.0, 0.0, //
862            0.0, 0.0, 1.0, 0.0, //
863            0.0, 0.0, 0.0, 1.0,
864        ])
865    }
866
867    /// The property that lets this land without retessellating the world: under
868    /// an affine the bound is what `max_scale` always said, whatever box it is
869    /// asked about.
870    #[test]
871    fn an_affine_is_bounded_by_exactly_what_max_scale_reports() {
872        for affine in [
873            Affine2::IDENTITY,
874            Affine2::from_scale(Vec2::new(3.0, 0.5)),
875            Affine2::from_angle(0.7) * Affine2::from_scale(Vec2::new(9.0, 2.0)),
876            Affine2::from_translation(Vec2::new(500.0, -20.0)),
877        ] {
878            let lifted = Transform2D::from(affine);
879            for (min, max) in [
880                (Vec2::ZERO, Vec2::splat(1.0)),
881                (Vec2::splat(-1e4), Vec2::splat(1e4)),
882            ] {
883                assert_eq!(
884                    lifted.max_scale_over(min, max),
885                    Some(max_scale(&affine)),
886                    "{affine:?} over {min:?}..{max:?}"
887                );
888            }
889        }
890    }
891
892    #[test]
893    fn perspective_is_bounded_more_loosely_where_it_magnifies() {
894        let t = receding();
895        // Toward negative x the divisor is below one, so the plane is
896        // magnified and the same curve needs finer flattening.
897        let near = t
898            .max_scale_over(Vec2::new(-400.0, -10.0), Vec2::new(-300.0, 10.0))
899            .expect("in front");
900        let far = t
901            .max_scale_over(Vec2::new(300.0, -10.0), Vec2::new(400.0, 10.0))
902            .expect("in front");
903        assert!(near > far, "near {near} should exceed far {far}");
904        // And the far end is still bounded below by nothing silly.
905        assert!(far > 0.0);
906    }
907
908    #[test]
909    fn a_box_reaching_the_vanishing_line_has_no_bound_to_give() {
910        let t = receding();
911        // The divisor 1 + 0.002x reaches zero at x = -500.
912        assert_eq!(
913            t.max_scale_over(Vec2::new(-600.0, -10.0), Vec2::new(-400.0, 10.0)),
914            None
915        );
916        assert_eq!(
917            t.max_scale_over(Vec2::new(-500.0, -10.0), Vec2::new(-499.0, 10.0)),
918            None
919        );
920    }
921
922    /// Without a cap the bound runs away as a shape approaches the vanishing
923    /// line, and `MAX_SEGMENTS` -- which is silent when it bites -- becomes the
924    /// thing deciding how a picture looks.
925    #[test]
926    fn the_bound_is_capped_against_what_the_same_shape_costs_flat() {
927        let t = receding();
928        let just_in_front = t
929            .max_scale_over(Vec2::new(-499.9, -1.0), Vec2::new(-499.5, 1.0))
930            .expect("in front");
931        assert_eq!(just_in_front, MAX_PERSPECTIVE_REFINEMENT);
932    }
933
934    /// The trap this whole change had to avoid, and the one place where code
935    /// that was correct would have become wrong without being touched.
936    ///
937    /// `preserves_axis_alignment` read the two-by-two and nothing else. Widen
938    /// the transform and leave that reading alone, and a pure scale carrying a
939    /// perspective row is waved through to the fixed-function scissor as a
940    /// rectangle -- which it is not, because the divisor varies along a
941    /// horizontal line and carries `y` with it. The clip then admits pixels the
942    /// caller asked to remove, silently, on both backends alike.
943    #[test]
944    fn a_projective_row_is_not_axis_preserving_however_plain_the_two_by_two_looks() {
945        for row in [[0.002, 0.0], [0.0, 0.002], [-1e-4, 3e-5]] {
946            let projective = Transform2D::from_column_major_4x4(&[
947                3.0, 0.0, 0.0, row[0], //
948                0.0, 7.0, 0.0, row[1], //
949                0.0, 0.0, 1.0, 0.0, //
950                20.0, -5.0, 0.0, 1.0,
951            ]);
952            // The two-by-two on its own is a plain scale, and would pass.
953            assert!(preserves_axis_alignment(Affine2::from_scale(Vec2::new(
954                3.0, 7.0
955            ))));
956            assert!(
957                !preserves_axis_alignment(projective),
958                "a perspective row of {row:?} was accepted as axis-preserving"
959            );
960        }
961    }
962
963    #[test]
964    fn an_affine_always_has_bounds_and_a_box_across_the_horizon_has_none() {
965        let t = receding();
966        // Wholly in front: the four corners still bound the image, because a
967        // homography with a positive divisor preserves convexity.
968        let (min, max) = t
969            .transformed_bounds_of(Vec2::new(-100.0, -50.0), Vec2::new(100.0, 50.0))
970            .expect("in front");
971        assert!(min.x < max.x && min.y < max.y);
972        // Reaching the vanishing line at x = -500, where the corners land on
973        // both sides of infinity and their box is too small rather than loose.
974        assert_eq!(
975            t.transformed_bounds_of(Vec2::new(-600.0, -50.0), Vec2::new(-400.0, 50.0)),
976            None
977        );
978        // An affine never fails, which is what keeps every existing caller
979        // taking the answer it always did.
980        assert!(transformed_bounds(
981            Affine2::from_scale(Vec2::new(1e6, 1e-6)),
982            Vec2::splat(-1e3),
983            Vec2::splat(1e3)
984        )
985        .is_some());
986    }
987
988    /// The fallback is wide rather than narrow, and finite enough to add to.
989    #[test]
990    fn unbounded_is_large_and_still_arithmetic() {
991        let (min, max) = unbounded();
992        assert!(min.x < -1e30 && max.x > 1e30);
993        assert!(
994            (max - min).is_finite(),
995            "a reach added to this must stay finite"
996        );
997        assert!((min - Vec2::splat(1e6)).is_finite());
998    }
999}