From d098d3318852a6fe478b6598fa5fcf8008d434cf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 10:50:29 -0700 Subject: [PATCH] Perspective: a stored projection whose determinant overflows is judged on a scaled copy Homography::from_array accepted nine finite numbers only when det().abs() was above 1e-15. With large entries the determinant can be inf - inf, so a valid stored projection was rejected on reading and its type drew flat, or it can overflow to inf, so a projection that cannot be inverted was accepted. When the determinant is not finite, inverse() now inverts a copy divided by a power of two (largest entry between 0.5 and 1), and treats that copy as not invertible when its determinant is at most 1e-15 times the largest of the six products it sums. from_array and the overflow path of conjugated accept such a map only when that inverse exists. Maps with a finite determinant are judged as before. Follows #4912. --- crates/geom/src/projective.rs | 96 ++++++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 77bb513b4..53405233a 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -31,7 +31,7 @@ impl Homography { return None; } let h = Self([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]]); - (h.det().abs() > 1e-15).then_some(h) + h.is_invertible().then_some(h) } /// The nine row-major numbers. @@ -60,14 +60,58 @@ impl Homography { + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) } - /// Inverse; `None` when the map is not invertible or its inverse has an entry that is not - /// finite. - pub fn inverse(&self) -> Option { - let m = &self.0; + /// Whether the map is invertible: the magnitude of its determinant is above 1e-15, or, when the + /// determinant is not finite, [`Self::inverse`] gives one. + fn is_invertible(&self) -> bool { let d = self.det(); - if d.abs() < 1e-15 || !d.is_finite() { + if d.is_finite() { + return d.abs() > 1e-15; + } + self.inverse().is_some() + } + + /// The same map with every entry divided by one power of two, the smallest above every entry's + /// magnitude (so its largest entry is at least 0.5 and below 1). `None` when every entry is 0 + /// or one is not finite. + fn scaled(&self) -> Option { + if !self.is_finite() { return None; } + let e = self.0.iter().flatten().filter(|v| **v != 0.0).map(|v| frexp(*v).1).max()?; + Some(Self(self.0.map(|row| row.map(|v| ldexp(v, -e))))) + } + + /// Inverse; `None` when the map is not invertible or its inverse has an entry that is not + /// finite. When the determinant is not finite, this is the inverse of [`Self::scaled`] (the + /// exact inverse multiplied by one power of two, which is the same map), and that copy counts + /// as not invertible when the magnitude of its determinant is at most 1e-15 times that of the largest + /// of the six products the determinant sums. + pub fn inverse(&self) -> Option { + let d = self.det(); + let (h, d) = if d.is_finite() { + if d.abs() < 1e-15 { + return None; + } + (*self, d) + } else { + let s = self.scaled()?; + let m = &s.0; + let terms = [ + m[0][0] * m[1][1] * m[2][2], + m[0][0] * m[1][2] * m[2][1], + m[0][1] * m[1][0] * m[2][2], + m[0][1] * m[1][2] * m[2][0], + m[0][2] * m[1][0] * m[2][1], + m[0][2] * m[1][1] * m[2][0], + ]; + let largest = terms.iter().fold(0.0_f64, |k, v| k.max(v.abs())); + let d = s.det(); + if d.abs() <= 1e-15 * largest { + return None; + } + (s, d) + }; + let m = &h.0; let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; let adj = [ [c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)], @@ -143,7 +187,7 @@ impl Homography { return None; } let c = Self(c.map(|row| row.map(|v| v / k))); - (c.is_finite() && c.det().abs() > 1e-15).then_some(c) + (c.is_finite() && c.is_invertible()).then_some(c) } fn is_finite(&self) -> bool { @@ -274,6 +318,44 @@ mod tests { assert!(h().inverse().is_some()); } + #[test] + fn a_determinant_that_overflows_is_taken_at_a_smaller_scale() { + // (x, y) ↦ (x + 1, y) / (x + 2) with every entry multiplied by 1e200: the determinant is + // inf − inf, though the map is invertible. + let big = Homography([[1e200, 0.0, 1e200], [0.0, 1e200, 0.0], [1e200, 0.0, 2e200]]); + assert!(big.det().is_nan()); + assert_eq!(Homography::from_array(big.to_array()), Some(big)); + let inv = big.inverse().unwrap(); + for p in [Point::new(3.0, -2.0), Point::new(-1.5, 4.0)] { + assert!(inv.apply(big.apply(p).unwrap()).unwrap().distance(p) < 1e-9); + } + // A scale by 1e500: the determinant is inf, but scaled to a largest entry below 1 the + // w row is 0, and no point maps to a finite page point except the origin. + let flat = Homography([[1e300, 0.0, 0.0], [0.0, 1e300, 0.0], [0.0, 0.0, 1e-200]]); + assert!(flat.det().is_infinite()); + assert_eq!(Homography::from_array(flat.to_array()), None); + assert_eq!(flat.inverse(), None); + // Two equal rows with large entries. + let twice = Homography([[1e200, 1e200, 0.0], [1e200, 1e200, 0.0], [0.0, 0.0, 1e200]]); + assert_eq!(Homography::from_array(twice.to_array()), None); + assert_eq!(twice.inverse(), None); + // (x, y) ↦ 1e200·(x + 1, y) / (x + 2) seen through a scale by 1e10: the product + // overflows, and the determinant of the result at a w row of 1 is inf − inf. + let far = Homography([[1e300, 0.0, 1e300], [0.0, 1e300, 0.0], [1e100, 0.0, 2e100]]); + let t = Affine::scale(1e10); + let c = far.conjugated(t).unwrap(); + assert!(c.det().is_nan()); + assert_eq!(Homography::from_array(c.to_array()), Some(c)); + for p in [Point::new(3.0, -2.0), Point::new(0.5, 7.0)] { + let want = t * far.apply(p).unwrap(); + assert!(c.apply(t * p).unwrap().distance(want) <= 1e-9 * want.to_vec2().hypot(), "{c:?}"); + } + // A determinant that does not overflow is judged as it is. + assert!(Homography::from_array([1e-6, 0.0, 0.0, 0.0, 1e-6, 0.0, 0.0, 0.0, 1e-6]).is_none()); + assert!(Homography::from_array([f64::NAN; 9]).is_none()); + assert!(Homography::from_array([0.0; 9]).is_none()); + } + #[test] fn a_product_that_overflows_is_none() { // Each factor is finite and invertible, but the product's first entry is 1e400. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 914b7e2a5..356929cdf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1006,6 +1006,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing. Move in Perspective (its copy, perpendicular and Snap to Grid choices), Transform in Perspective (its copy), Nudge in Perspective (its ×10 and copy), Draw in Perspective (Snap to Grid), Define Perspective Grid (its reproject choice), Show Perspective Grid, Width Point (Adjust Adjoining Width Points), Liquify (its pen pressure, Simplify and Affect choices) and Puppet Warp (its pins' rest shape) refuse a value other than true, false or null, such as "true", and leave the document and its undo history as they were. Moves and transforms in perspective and Define Perspective Grid with reproject return an error, and leave the document and its undo history as they were, when the projection they would give type or a symbol instance in perspective has a number too large to store. Object › Perspective › Edit Text returns an error, and does not enter isolation mode, when the type cannot be shown flat where it is drawn: when part of it crosses the horizon, or when that view's projection has a number too large to store. Regressions: `a_product_that_overflows_is_none` in `crates/geom/src/projective.rs`, and `moving_type_whose_projection_would_overflow_is_refused` and `edit_text_whose_flat_view_would_overflow_is_refused` in `crates/engine/src/tests/persp_text.rs`. + A stored projection with numbers so large that its determinant overflows is judged on a copy divided by a power of two: an invertible one is read back when the document opens, and one that cannot be inverted, such as one whose bottom row is 0 at that scale, is not. Regression: `a_determinant_that_overflows_is_taken_at_a_smaller_scale` in `crates/geom/src/projective.rs`. Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4. Move, Rotate, Scale, Reflect, Shear, Transform Again, Nudge and the Transform panel's values (their copy, Scale Strokes & Effects, Scale Corners, Transform Patterns, Transform Objects and area type options), Align, Distribute and Distribute Spacing (Transform Patterns), Object Properties (visible, locked, Isolate Blending and Opacity & Mask Define Knockout Shape) and Live Shape Properties (Invert Pie, Make Sides Equal and relative rounding) refuse a value other than true, false or null, such as "false", and leave the document as it was. Object Properties (its opacity), Live Shape Properties (corner radius and polygon sides), the Transform panel's reference point and Align, Distribute and Distribute Spacing to an artboard (the artboard's number) refuse a value other than a number or null, such as "50", and leave the document as it was.