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.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:16 +07:00
committed by bflatastic
parent 3d345ff95b
commit db38e2e114
3 changed files with 71 additions and 8 deletions
+29
View File
@@ -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};
+5 -3
View File
@@ -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());
}
}
}
+37 -5
View File
@@ -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<Self> {
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<Self> {
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]