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}