.ai save: check a split top object's sides at every point they have

The writer compared the top object's patch with the one the reader makes
of the written grid on an even 9 x 9 grid of places. A split side can
change how the reader samples a short curve that falls between those
places, so a top object could be written live although the reader would
map the contents differently there.

Coons::places gives the places in u and v where the patch's sides have
points. Each side runs straight between them, so the writer now compares
the four sides of both patches at every one of those places, within a
third of the tolerance; a point inside a patch blends its sides and
corners, so the patches then lie within the tolerance everywhere.
This commit is contained in:
dotCooCoo
2026-11-14 19:55:47 -08:00
parent c7352528bf
commit 8767cf847f
4 changed files with 86 additions and 3 deletions
+28
View File
@@ -358,6 +358,34 @@ mod tests {
assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}");
}
#[test]
fn coons_places_are_where_the_patch_bends() {
// A rectangle's sides have 65 evenly spaced points.
let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places();
assert_eq!((u.len(), v.len()), (65, 65));
assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}");
// A top object with a curved top: between two places next to each other the patch is
// straight, in `u` and in `v`.
let mut path = kurbo::BezPath::new();
path.move_to((0.0, 0.0));
path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0));
path.line_to((100.0, 50.0));
path.line_to((40.0, 50.0));
path.line_to((0.0, 50.0));
path.close_path();
let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap();
let [u, v] = c.places();
assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}");
assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}");
for w in u.windows(2) {
for y in [0.0, 0.3, 1.0] {
let mid = c.eval((w[0] + w[1]) / 2.0, y);
let half = c.eval(w[0], y).midpoint(c.eval(w[1], y));
assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}");
}
}
}
#[test]
fn maps_go_on_past_the_content_box() {
// A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there.
+25
View File
@@ -338,6 +338,31 @@ impl Coons {
let end = |side: &[Point]| side.last().copied().unwrap_or_default();
[self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]]
}
/// The places in `u` where the top or bottom side has a point, and the places in `v` where the
/// left or right side has one, rising from 0 to 1. Between two places next to each other, each
/// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of
/// the grid they make.
pub fn places(&self) -> [Vec<f64>; 2] {
let sides = [&self.top, &self.right, &self.bottom, &self.left];
let at = |which: usize| -> Vec<f64> {
match self.knots.as_deref().and_then(|k| k.get(which)) {
Some(knots) => knots.clone(),
None => {
let last = sides[which].len().saturating_sub(1).max(1);
(0..=last).map(|i| i as f64 / last as f64).collect()
}
}
};
let merge = |a: usize, b: usize| {
let mut v = at(a);
v.extend(at(b));
v.sort_by(f64::total_cmp);
v.dedup();
v
};
[merge(0, 2), merge(1, 3)]
}
}
/// The most points one side of [`Coons::from_top_object`] holds while it adds points between its
+19
View File
@@ -10633,6 +10633,25 @@ fn a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art() {
assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}");
}
/// A top object whose split side would map the content another way only between the places of an
/// even grid of eighths is written as its art too: a bottom side of a long straight segment, which is
/// split, and a short curve in its last eighth, which the reader then samples at fewer points.
#[test]
fn a_top_object_whose_split_side_differs_between_eighths_is_written_as_its_art() {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
let mut path = uneven_top_object(3);
path.truncate(5);
path.curve_to((168.0, 85.0), (164.0, 85.0), (162.0, 90.0));
path.line_to((30.0, 90.0));
path.close_path();
let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None };
let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW);
assert!(!program(&doc).contains("AIDeformOptionsKey"));
let (back, warnings, _) = envelope_back(&doc);
assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}");
assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}");
}
/// An envelope with its contents in an opacity mask's art is written as its art there, and an
/// envelope in a group stays live.
#[test]
+14 -3
View File
@@ -328,9 +328,20 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize,
let read = Coons::from_top_object(&outline, Some(corners))?;
let [tl, tr, br, bl] = coons.corners();
let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE;
const STEPS: usize = 8;
let at = |i: usize| i as f64 / STEPS as f64;
let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach));
// Each side of both patches runs straight between the places the sides have points
// ([`Coons::places`]), so two matching sides lie furthest apart at one of those places. A point
// inside a patch blends its four sides and corners, so where every side lies within a third of
// `reach` of the other patch's, the patches lie within `reach` of each other everywhere.
let merge = |a: Vec<f64>, b: Vec<f64>| {
let mut v = [a, b].concat();
v.sort_by(f64::total_cmp);
v.dedup();
v
};
let ([cu, cv], [ru, rv]) = (coons.places(), read.places());
let (us, vs) = (merge(cu, ru), merge(cv, rv));
let near = |u: f64, v: f64| coons.eval(u, v).distance(read.eval(u, v)) <= reach / 3.0;
let same = us.iter().all(|&u| near(u, 0.0) && near(u, 1.0)) && vs.iter().all(|&v| near(0.0, v) && near(1.0, v));
same.then_some((rows, cols, pts))
}