Merge pull request #4969 from dotCooCoo/fix/projective-from-array-validity

Perspective: a stored projection with large entries is judged by a scaled copy, so it reads back
This commit is contained in:
BFlat
2026-11-01 05:30:42 +07:00
committed by GitHub
3 changed files with 147 additions and 15 deletions
+9 -7
View File
@@ -119,13 +119,15 @@ fn moving_type_whose_projection_would_overflow_is_refused() {
let id = persp_text(&mut s);
let NodeKind::Text(t) = &node(&s, id).kind else { panic!("type") };
let flat = t.bounds().unwrap();
// A stored projection that draws the type flat where it is, with every entry scaled by `k`: a
// document can hold it (its entries are finite) and the type draws, but its product with a
// scale by 2 in perspective overflows.
// A stored projection that maps each point p of the type to k·(p − 0.9·q), with q its top-left
// corner: a document can hold it (its entries are finite, and its w row is already 1) and the
// type draws, but its product with a scale by 2 in perspective overflows, since its move,
// 0.9·k·q, is larger than any point it draws.
let step = grid(&s).transform_map(Plane::Right, 0.0, vectorcraft_geom::Affine::scale(2.0), 0.0).unwrap();
let q = flat.origin();
let scaled = |e: i32| {
let k = 2f64.powf(f64::from(e) / 16.0);
vectorcraft_geom::Homography([[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]])
vectorcraft_geom::Homography([[k, 0.0, -0.9 * k * q.x], [0.0, k, -0.9 * k * q.y], [0.0, 0.0, 1.0]])
};
let finite = |m: &vectorcraft_geom::Homography| m.to_array().iter().all(|v| v.is_finite());
let draws = |m: &vectorcraft_geom::Homography| {
@@ -180,12 +182,12 @@ fn edit_text_whose_flat_view_would_overflow_is_refused() {
let id = persp_text(&mut s);
let NodeKind::Text(t) = &node(&s, id).kind else { panic!("type") };
let flat = t.bounds().unwrap();
// A stored projection that draws the type 4 times its size, with every entry scaled by `k`: a
// document can hold it and the type draws, but the projection that shows it flat where it is
// A stored projection that draws the type `4k` times its size: a document can hold it (its w
// row is already 1) and the type draws, but the projection that shows it flat where it is
// drawn overflows.
let scaled = |e: i32| {
let k = 2f64.powf(f64::from(e) / 16.0);
vectorcraft_geom::Homography([[4.0 * k, 0.0, 0.0], [0.0, 4.0 * k, 0.0], [0.0, 0.0, k]])
vectorcraft_geom::Homography([[4.0 * k, 0.0, 0.0], [0.0, 4.0 * k, 0.0], [0.0, 0.0, 1.0]])
};
let overflows = |m: &vectorcraft_geom::Homography| {
let Some(drawn) = m.map_rect_bbox(flat) else { return false };
+137 -8
View File
@@ -25,13 +25,23 @@ impl Homography {
}
/// From nine row-major numbers (as stored in documents); `None` unless all are finite and the
/// map is invertible.
/// map is invertible. When the determinant is not finite, this is the same map divided by the
/// largest magnitude in its `w` row (so that row's largest entry is 1), and `None` when that
/// copy has an entry that is not finite or is not invertible.
pub fn from_array(v: [f64; 9]) -> Option<Self> {
if v.iter().any(|x| !x.is_finite()) {
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)
if h.det().is_finite() {
return h.is_invertible().then_some(h);
}
let k = h.0[2].iter().fold(0.0_f64, |k, v| k.max(v.abs()));
if k <= 0.0 || !h.is_invertible() {
return None;
}
let c = Self(h.0.map(|row| row.map(|v| v / k)));
(c.is_finite() && c.is_invertible()).then_some(c)
}
/// The nine row-major numbers.
@@ -60,14 +70,67 @@ 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<Self> {
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, chosen so that the largest of the
/// six products its determinant sums is at least 1/8 and below 4. `None` when every one of
/// those products is 0 or an entry is not finite.
fn scaled(&self) -> Option<Self> {
if !self.is_finite() {
return None;
}
let f = self.0.map(|row| row.map(frexp));
// The columns each of the six products takes from rows 0, 1 and 2.
let cols = [(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)];
let e = cols
.iter()
.map(|&(a, b, c)| (f[0][a], f[1][b], f[2][c]))
.filter(|(a, b, c)| a.0 * b.0 * c.0 != 0.0)
.map(|(a, b, c)| a.1 + b.1 + c.1)
.max()?;
let e = e.div_euclid(3);
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<Self> {
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 +206,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 +337,72 @@ 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());
// Read back as the same map with a w row whose largest entry is 1.
let read = Homography::from_array(big.to_array()).unwrap();
assert_eq!(read, Homography([[0.5, 0.0, 0.5], [0.0, 0.5, 0.0], [0.5, 0.0, 1.0]]));
assert_eq!(Homography::from_array(read.to_array()), Some(read));
let inv = big.inverse().unwrap();
for p in [Point::new(3.0, -2.0), Point::new(-1.5, 4.0)] {
assert!(read.apply(p).unwrap().distance(big.apply(p).unwrap()) < 1e-12);
assert!(inv.apply(big.apply(p).unwrap()).unwrap().distance(p) < 1e-9);
}
// The identity with every entry 1e308: stored as it is, (2, 2) overflows to 2e308, but the
// map read back draws it at (2, 2).
let wide = Homography([[1e308, 0.0, 0.0], [0.0, 1e308, 0.0], [0.0, 0.0, 1e308]]);
assert_eq!(wide.apply(Point::new(2.0, 2.0)), None);
let read = Homography::from_array(wide.to_array()).unwrap();
assert_eq!(read.apply(Point::new(2.0, 2.0)), Some(Point::new(2.0, 2.0)));
// A large map whose w row is 1e-205 times its largest entry: read back at a w row of 1,
// its points are in front of the horizon and draw.
let low = Homography([[1e300, 0.0, 1e305], [0.0, 1e300, 0.0], [1e90, 0.0, 1e100]]);
assert!(low.det().is_nan());
let read = Homography::from_array(low.to_array()).unwrap();
assert_eq!(Homography::from_array(read.to_array()), Some(read));
let p = Point::new(1.0, 1.0);
let want = Point::new((1e200 + 1e205) / (1.0 + 1e-10), 1e200 / (1.0 + 1e-10));
assert!(read.apply(p).unwrap().distance(want) <= 1e-9 * want.to_vec2().hypot());
// A scale by 1e305 and a move by -3.9e307 with a w row of 1: the determinant is inf, and
// the inverse is a scale by 1e-305 and a move by 390.
let moved = Homography([[1e305, 0.0, -3.9e307], [0.0, 1e305, -3.2e307], [0.0, 0.0, 1.0]]);
assert!(moved.det().is_infinite());
assert_eq!(Homography::from_array(moved.to_array()), Some(moved));
let inv = moved.inverse().unwrap();
for p in [Point::new(400.0, 330.0), Point::new(500.0, 400.0)] {
assert!(inv.apply(moved.apply(p).unwrap()).unwrap().distance(p) < 1e-9);
}
// A scale by 1e500: the determinant is inf, but scaled so that its products are near 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.
+1
View File
@@ -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 as the same map at a smaller scale (divided by the largest number in its bottom row), so points that overflowed at the stored scale draw, 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.