From 3c070a8cdd3be018cc19fb07423c9adb357aa6e1 Mon Sep 17 00:00:00 2001 From: Saad Abdullah Date: Tue, 13 Oct 2026 16:49:51 +0000 Subject: [PATCH] fix(geom): decompose of a flattened transform reports 0 shear instead of NaN --- crates/geom/src/decompose.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/decompose.rs b/crates/geom/src/decompose.rs index 4c06a4261..0ea247366 100644 --- a/crates/geom/src/decompose.rs +++ b/crates/geom/src/decompose.rs @@ -24,7 +24,13 @@ pub fn decompose(m: Affine) -> Decomposed { let det = a * d - b * c; let rotation = (-b).atan2(a).to_degrees(); let rotation = if rotation <= -180.0 + 1e-9 { 180.0 } else { rotation }; - Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear: ((a * c + b * d) / det).atan().to_degrees() } + // A flattened transform (e.g. scale_y = 0, so `det` and the shear numerator are both 0) has + // no recoverable shear: 0 / 0 would be NaN, and a NaN here poisons the object when the + // caller feeds the decomposed values back into `compose` (transform.set does exactly that). + // Report 0 instead; a non-zero numerator over a zero determinant still limits to ±90°. + let shear = ((a * c + b * d) / det).atan().to_degrees(); + let shear = if shear.is_nan() { 0.0 } else { shear }; + Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear } } /// The linear transform with these values (no translation); `decompose` inverts it. @@ -50,4 +56,29 @@ mod tests { // transform.rotate's convention: rotate(-θ) is θ counter-clockwise. assert!((decompose(Affine::rotate(-0.5)).rotation - 0.5f64.to_degrees()).abs() < 1e-9); } + + #[test] + fn flattened_transform_has_no_nan_shear() { + // Regression: an object scaled to zero height (scale_y = 0) is singular — its + // determinant and shear numerator are both 0, and decompose used to report NaN shear, + // which compose then turned into a NaN transform. Shear is unrecoverable there, so + // it reports 0 and every other field stays finite and exact. + for v in [ + Decomposed { scale_x: 1.0, scale_y: 0.0, rotation: 0.0, shear: 20.0 }, + Decomposed { scale_x: 2.0, scale_y: 0.0, rotation: 30.0, shear: -45.0 }, + ] { + let back = decompose(compose(&v)); + assert!(back.scale_x.is_finite() && back.scale_y.is_finite() && back.rotation.is_finite() && back.shear.is_finite(), "{v:?} → {back:?}"); + assert_eq!(back.shear, 0.0, "{v:?} → {back:?}"); + assert!((back.scale_x - v.scale_x).abs() < 1e-9, "{v:?} → {back:?}"); + assert_eq!(back.scale_y, 0.0, "{v:?} → {back:?}"); + assert!((back.rotation - v.rotation).abs() < 1e-9, "{v:?} → {back:?}"); + // Feeding the decomposed values back into compose stays finite too. + assert!(compose(&back).as_coeffs().iter().all(|c| c.is_finite()), "{v:?} → {back:?}"); + } + // Directly singular matrices, including one whose shear numerator is non-zero + // (that case limits to ±90°, not NaN). + assert_eq!(decompose(Affine::new([1.0, 0.0, 0.0, 0.0, 0.0, 0.0])).shear, 0.0); + assert!((decompose(Affine::new([1.0, 0.0, 1.0, 0.0, 0.0, 0.0])).shear.abs() - 90.0).abs() < 1e-9); + } }