From 9596130be49340c0faaf99ffad5e2587e70c85c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 21:46:41 -0700 Subject: [PATCH 1/2] .ai import: Objects on Path records measure each group once Each Objects on Path record measured the bounds of every object it names, and checked its path, before it was turned down. A thousand copies of a record whose object is a group of 20,000 paths took 5.6 s to open instead of about 50 ms. A record now checks its group and ids first, and the path and objects of a group are measured for one record at most. --- crates/eps/src/import/ai/read/on_path.rs | 34 +++++++++++------ crates/eps/src/import/ai/tests.rs | 48 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/crates/eps/src/import/ai/read/on_path.rs b/crates/eps/src/import/ai/read/on_path.rs index 909d61131..4fe14e854 100644 --- a/crates/eps/src/import/ai/read/on_path.rs +++ b/crates/eps/src/import/ai/read/on_path.rs @@ -131,13 +131,15 @@ pub(super) fn apply(doc: &mut Document, found: &OnPath) { // Where every object with an id is, found in one walk of the tree (setting a record doesn't // move anything). let at = doc.index_paths(found.tags.keys().copied()); + // The groups whose path and objects have been measured. + let mut tried = HashSet::new(); for r in &found.records { // A record that doesn't match leaves the group as it was read. - let _ = apply_one(doc, found, &at, r); + let _ = apply_one(doc, found, &at, &mut tried, r); } } -fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap>, r: &Record) -> Option<()> { +fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap>, tried: &mut HashSet, r: &Record) -> Option<()> { // The one object still in the document with the id `uuid` (a commented copy may have gone). let resolve = |uuid: &str| -> Option<(NodeId, &Vec)> { let mut live = found.ids.get(uuid)?.iter().filter_map(|id| at.get(id).map(|p| (*id, p))); @@ -150,26 +152,36 @@ fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap return None; } let (_, group_at) = path_at.split_last()?; + let g = doc.node_at(group_at)?; + let NodeKind::Group { children, clip: false } = &g.kind else { return None }; + if g.extras().objects_on_path.is_some() || children.len() != r.ids.len() { + return None; + } let mut seen = HashSet::from([path]); - let mut members = Vec::with_capacity(rest.len()); + let mut placed = Vec::with_capacity(rest.len()); for (i, uuid) in rest.iter().enumerate() { let (id, place) = resolve(uuid)?; let tag = found.tags.get(&id)?; if !seen.insert(id) || place.split_last().map(|(_, up)| up) != Some(group_at) || tag.key_path || tag.order != Some(i as f64) { return None; } - // The frame the object is drawn in: turned by its angle (the document's y points down) - // about the center of its bounds. - let angle = -tag.angle?; - let center = doc.node_at(place)?.geometric_bounds()?.center(); - members.push(Member { id, frame: Affine::rotate_about(angle, center), angle }); + placed.push((id, place, -tag.angle?)); } - let g = doc.node_at(group_at)?; - let NodeKind::Group { children, clip: false } = &g.kind else { return None }; - if g.extras().objects_on_path.is_some() || children.len() != seen.len() || children.iter().any(|c| !seen.contains(&c.id)) { + if children.iter().any(|c| !seen.contains(&c.id)) { + return None; + } + // What is left depends on the group's objects alone, so each group is measured once. + if !tried.insert(g.id) { return None; } attachable_path(children.iter().find(|c| c.id == path)?)?; + let mut members = Vec::with_capacity(placed.len()); + for (id, place, angle) in placed { + // The frame the object is drawn in: turned by its angle (the document's y points down) + // about the center of its bounds. + let center = doc.node_at(place)?.geometric_bounds()?.center(); + members.push(Member { id, frame: Affine::rotate_about(angle, center), angle }); + } let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate }; doc.node_mut_at(group_at)?.extras_mut().objects_on_path = Some(Box::new(on_path)); Some(()) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index a11e89139..9ef13e271 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3400,3 +3400,51 @@ fn an_objects_on_path_group_in_versioned_content_opens_once() { assert_eq!(kids[0].children().unwrap().len(), 5); assert!(kids[0].extras().objects_on_path.is_some()); } + +/// The path and objects of a group are measured once however many records name it: a thousand +/// copies of a record open about as fast as one copy followed by 999 records whose path id is +/// missing, when the group opens live, when its long path has no length, and when its first object +/// is a group of many paths and its last object has no bounds. +#[test] +fn repeated_objects_on_path_records_dont_measure_the_objects_again() { + use vectorcraft_testkit::ai::{on_path_member, on_path_options, on_path_record}; + let paths: String = (0..20_000).map(|i| format!("{}f\n", rect_path(f64::from(i % 200), 40.0, f64::from(i % 200) + 1.0, 46.0))).collect(); + let big = format!("u\n{RED}{paths}U\n{}", on_path_member(ON_PATH_IDS[1], Some(0), 0.0)); + let empty = format!("u\nU\n{}", on_path_member(ON_PATH_IDS[2], Some(1), 0.0)); + let still = format!("20 20 m\n{}S\n", "20 20 L\n".repeat(20_000)); + let cases = [ + ("a live group", OPEN_PATH.to_string(), big.clone(), 2, true), + ("a path without length", still, big.clone(), 2, false), + ("an object without bounds", OPEN_PATH.to_string(), format!("{big}{empty}"), 3, false), + ]; + for (why, path, objects, n, live) in cases { + let ids = &ON_PATH_IDS[..n]; + let group = format!( + "0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}{objects}U\n", + on_path_member(ids[0], None, 0.0) + ); + let record = on_path_record(ALIGN_ON_PATH, ids, &on_path_options(n - 1, 4, 0.0, "")); + let (start, end) = (record.find("%_/Dictionary :\n%_1 /Int").unwrap(), record.find("%_; (Constraint Data)").unwrap()); + let copy = &record[start..end]; + let unresolved = copy.replace(ids[0], "0a6d1e52-ffff-4000-8000-000000000000"); + let one = format!("{}{copy}{}{}", &record[..start], unresolved.repeat(999), &record[end..]); + let many = format!("{}{}{}", &record[..start], copy.repeat(1000), &record[end..]); + let time = |data: &str| { + let started = std::time::Instant::now(); + let doc = read_ok(&format!("{data}{}", layer(&group))).doc; + let elapsed = started.elapsed(); + let g = art(&doc)[0].clone(); + assert_eq!(g.children().unwrap().len(), n, "{why}"); + assert_eq!(g.extras().objects_on_path.is_some(), live, "{why}"); + elapsed + }; + // The fastest of three runs each, taken in turn, so that other tests running at the same + // time weigh on both. + let (mut fastest_one, mut fastest_many) = (std::time::Duration::MAX, std::time::Duration::MAX); + for _ in 0..3 { + fastest_one = fastest_one.min(time(&one)); + fastest_many = fastest_many.min(time(&many)); + } + assert!(fastest_many < fastest_one * 5, "{why}: one copy: {fastest_one:?}, 1000 copies: {fastest_many:?}"); + } +} From b4c01e31207b84b9ffbd94abbbd05632c6931645 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 17:49:54 -0800 Subject: [PATCH 2/2] Tests: move the repeated-records timing test next to the other objects-on-path record tests --- crates/eps/src/import/ai/tests.rs | 32 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9ef13e271..7a1ce88e7 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3385,22 +3385,6 @@ fn an_objects_on_path_record_that_doesnt_fit_leaves_a_plain_group() { } } -/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once, -/// live. -#[test] -fn an_objects_on_path_group_in_versioned_content_opens_once() { - use vectorcraft_testkit::ai::{on_path_options, on_path_record}; - let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); - let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); - let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", ""); - let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n"); - let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc; - let kids = art(&doc); - assert_eq!(kids.len(), 1); - assert_eq!(kids[0].children().unwrap().len(), 5); - assert!(kids[0].extras().objects_on_path.is_some()); -} - /// The path and objects of a group are measured once however many records name it: a thousand /// copies of a record open about as fast as one copy followed by 999 records whose path id is /// missing, when the group opens live, when its long path has no length, and when its first object @@ -3448,3 +3432,19 @@ fn repeated_objects_on_path_records_dont_measure_the_objects_again() { assert!(fastest_many < fastest_one * 5, "{why}: one copy: {fastest_one:?}, 1000 copies: {fastest_many:?}"); } } + +/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once, +/// live. +#[test] +fn an_objects_on_path_group_in_versioned_content_opens_once() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", ""); + let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n"); + let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc; + let kids = art(&doc); + assert_eq!(kids.len(), 1); + assert_eq!(kids[0].children().unwrap().len(), 5); + assert!(kids[0].extras().objects_on_path.is_some()); +}