Perspective: a homography product that overflows is refused instead of stored or drawn

Homography::then_after returned products whose entries could overflow to
infinity or become NaN. A move, transform or grid reproject of type or a
symbol instance whose stored projection has large finite entries stored such
a product, and the type was then drawn flat, since a projection that is not
finite reads as none.

- Homography::checked_then_after returns None when an entry of the product
  is not finite.
- project_node composes each type and symbol instance projection with it and
  returns false, leaving the object as it is, when one would not be finite;
  moves and transforms in perspective and Define Perspective Grid with
  reproject then return the horizon error and leave the document unchanged.
- transform_map, attach_homography and size_homography return None for such
  a product.
- Edit Text returns the horizon error, and does not enter isolation mode,
  when the type cannot be shown flat where it is drawn.
This commit is contained in:
dotCooCoo
2026-10-31 23:29:37 +07:00
committed by bflatastic
parent f9e78eb144
commit 77bdf478e4
6 changed files with 124 additions and 14 deletions
+64
View File
@@ -113,6 +113,39 @@ fn moving_type_in_perspective_moves_its_projection() {
let _ = rect;
}
#[test]
fn moving_type_whose_projection_would_overflow_is_refused() {
let (mut s, _) = persp_session();
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.
let step = grid(&s).transform_map(Plane::Right, 0.0, vectorcraft_geom::Affine::scale(2.0), 0.0).unwrap();
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]])
};
let finite = |m: &vectorcraft_geom::Homography| m.to_array().iter().all(|v| v.is_finite());
let draws = |m: &vectorcraft_geom::Homography| {
let drawn = m.map_rect_bbox(flat);
drawn.is_some_and(|b| step.map_rect_bbox(b).is_some()) && vectorcraft_geom::Homography::from_array(m.to_array()) == Some(*m)
};
let e = (0..16 * 1024).find(|e| draws(&scaled(*e)) && !finite(&step.then_after(&scaled(*e)))).unwrap_or_else(|| panic!("{step:?} {flat:?}"));
let big = scaled(e);
let mut doc = s.doc().unwrap().doc.clone();
let mut extras = std::sync::Arc::make_mut(&mut doc).node_mut(id).unwrap().extras_mut();
extras.perspective.as_deref_mut().unwrap().projection = Some(big.to_array());
drop(extras);
s.doc_mut().unwrap().doc = doc;
let before = (node(&s, id), undo_len(&s));
assert!(s.execute("perspective.transform", &json!({"ids": [id.0], "matrix": [2, 0, 0, 2, 0, 0]})).is_err());
assert_eq!((node(&s, id), undo_len(&s)), before, "the document is unchanged");
let stored = node(&s, id).extras().perspective.as_deref().and_then(|p| p.projection).unwrap();
assert!(stored.iter().all(|v| v.is_finite()));
}
#[test]
fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() {
let (mut s, _) = persp_session();
@@ -141,6 +174,37 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() {
assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err());
}
#[test]
fn edit_text_whose_flat_view_would_overflow_is_refused() {
let (mut s, _) = persp_session();
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
// 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]])
};
let overflows = |m: &vectorcraft_geom::Homography| {
let Some(drawn) = m.map_rect_bbox(flat) else { return false };
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
let moved = m.then_after(&vectorcraft_geom::Homography::from_affine(shift.inverse()));
vectorcraft_geom::Homography::from_array(m.to_array()) == Some(*m) && moved.to_array().iter().any(|v| !v.is_finite())
};
let e = (0..16 * 1024).find(|e| overflows(&scaled(*e))).unwrap_or_else(|| panic!("{flat:?}"));
let mut doc = s.doc().unwrap().doc.clone();
let mut extras = std::sync::Arc::make_mut(&mut doc).node_mut(id).unwrap().extras_mut();
extras.perspective.as_deref_mut().unwrap().projection = Some(scaled(e).to_array());
drop(extras);
s.doc_mut().unwrap().doc = doc;
let before = (node(&s, id), undo_len(&s));
assert!(s.execute("perspective.editText", &json!({"id": id.0})).is_err());
assert_eq!((node(&s, id), undo_len(&s)), before, "the document is unchanged");
assert_eq!(s.doc().unwrap().isolation, None, "not in isolation mode");
}
/// Edit Text, which shows the type in isolation mode, is off while an opacity mask is edited.
#[test]
fn edit_text_is_off_while_an_opacity_mask_is_edited() {