From 8767cf847f40260eb7ccb713b6c69b76702d1f8e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 19:48:56 -0800 Subject: [PATCH] .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. --- crates/doc/src/live.rs | 28 +++++++++++++++++++ crates/doc/src/live/mesh.rs | 25 +++++++++++++++++ crates/eps/src/ai_editing/tests.rs | 19 +++++++++++++ crates/eps/src/ai_editing/writer/envelopes.rs | 17 +++++++++-- 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 20d0623d4d..6b16973cf1 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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. diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index b6d5bbc88d..ef0a9a908d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -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; 2] { + let sides = [&self.top, &self.right, &self.bottom, &self.left]; + let at = |which: usize| -> Vec { + 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 diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index be833e258b..4a37a65152 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -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] diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index 1e1f16996b..cae8993827 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -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, b: Vec| { + 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)) }