From db38e2e1144dd07e419f4668c35e70d41726e721 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 23:27:44 -0700 Subject: [PATCH] Perspective: a projection that a transform overflows is scaled down or removed When moving type or a symbol instance in perspective makes the conjugated projection overflow, Homography::conjugated now forms the product from factors scaled to a largest entry of 1 and returns it at that scale, which maps every point to the same place. When even the scaled result is not invertible, the transform removes the projection and the art is drawn flat, instead of keeping the old projection while the flat art moves. --- crates/doc/src/node/tests.rs | 29 ++++++++++++++++++++++++ crates/doc/src/perspective.rs | 8 ++++--- crates/geom/src/projective.rs | 42 ++++++++++++++++++++++++++++++----- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index dc241841f..b4cc7b072 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -339,6 +339,35 @@ fn transform_group_recurses() { assert_eq!(child.appearance.stroke_width(), 2.0); } +/// A symbol instance in perspective keeps its picture moved by a transform, and loses its +/// projection when no invertible map of finite numbers draws the moved picture. +#[test] +fn a_projection_follows_the_transform_or_is_removed() { + use vectorcraft_geom::{Homography, Point}; + let instance = |h: Homography| { + let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); + n.extras_mut().perspective = + Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) })); + n + }; + let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection); + // A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down. + let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + let mut n = instance(big); + n.transform(Affine::scale(10.0), false); + let p = Point::new(3.0, 7.0); + assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9); + // A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0). + let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let mut n = instance(far); + n.transform(Affine::scale(1e150), false); + assert_eq!(projection(&n), None); + // A transform that is not invertible leaves the projection as it is. + let mut n = instance(far); + n.transform(Affine::scale(0.0), false); + assert_eq!(projection(&n), Some(far.to_array())); +} + #[test] fn unplaced_gradients_pin_only_when_a_refit_would_differ() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4de3823db..6ec18bcfa 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -58,12 +58,14 @@ impl Node { } } - /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). + /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An + /// invertible `a` that the projection cannot follow removes it, and the art is drawn flat. pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) - && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) + && let Some(h) = p.homography() + && a.determinant().abs() >= 1e-15 { - p.projection = Some(h.to_array()); + p.projection = h.conjugated(a).map(|h| h.to_array()); } } } diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 6264df67d..e7754c131 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -100,14 +100,31 @@ impl Homography { } /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it - /// maps from and to is moved by `a`). `None` when `a` isn't invertible or the result has an - /// entry that is not finite. + /// maps from and to is moved by `a`). When the product overflows, it is formed from factors + /// scaled to a largest entry of 1 and returned at that scale (the same map). `None` when `a` + /// isn't invertible, or when the product overflows and the scaled result is not invertible. pub fn conjugated(&self, a: Affine) -> Option { if a.determinant().abs() < 1e-15 { return None; } - let c = Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse())); - c.0.iter().flatten().all(|v| v.is_finite()).then_some(c) + let (to, from) = (Self::from_affine(a), Self::from_affine(a.inverse())); + let c = to.then_after(self).then_after(&from); + if c.is_finite() { + return Some(c); + } + let c = to.normalized()?.then_after(&self.normalized()?).then_after(&from.normalized()?).normalized()?; + (c.det().abs() > 1e-15).then_some(c) + } + + fn is_finite(&self) -> bool { + self.0.iter().flatten().all(|v| v.is_finite()) + } + + /// `self` divided by its largest entry magnitude, a positive scale that maps every point to + /// the same place; `None` when an entry is not finite or every entry is 0. + fn normalized(&self) -> Option { + let k = self.0.iter().flatten().fold(0.0_f64, |k, v| k.max(v.abs())); + (self.is_finite() && k > 0.0).then(|| Self(self.0.map(|row| row.map(|v| v / k)))) } /// The affine map that matches `self` to first order at `p` (its derivative there), `None` @@ -214,10 +231,25 @@ mod tests { } #[test] - fn a_conjugate_that_overflows_is_none() { + fn a_conjugate_that_overflows_is_scaled_down() { + // A move by 10, scaled by 1e307: seen through a scale by 10 it moves by 100, but the + // product's corner entry is 1e309. + let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + let t = Affine::scale(10.0); + let c = big.conjugated(t).unwrap(); + assert!(c.0.iter().flatten().all(|v| v.is_finite() && v.abs() <= 1.0)); + assert_eq!(Homography::from_array(c.to_array()), Some(c)); + for p in [Point::new(3.0, 7.0), Point::new(-4.0, 1.5)] { + assert!(c.apply(t * p).unwrap().distance(t * big.apply(p).unwrap()) < 1e-9); + assert!(c.apply(p).unwrap().distance(p + kurbo::Vec2::new(100.0, 0.0)) < 1e-9); + } + // A move by 1e200 seen through a scale by 1e150 sends every point to about (1e350, 0), + // which no invertible map of finite numbers draws. let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); assert_eq!(far.conjugated(Affine::scale(1e150)), None); assert!(far.conjugated(Affine::scale(2.0)).is_some()); + // A product that does not overflow is kept as it is. + assert_eq!(far.conjugated(Affine::scale(2.0)).unwrap().0[0][2], 2e200); } #[test]