From 7d8f2a315ada09e67f81615a8c16a1249ac2de69 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:44:44 -0800 Subject: [PATCH 1/2] Paste: skips relinking when nothing changes and finds pasted type in one walk A paste relinked every copy to the document's resources even when no resource had another name or color and the objects linked no graphic style. It now leaves those copies as they are. Finding the pasted type looked each id up and compared every text object with the ones already found. Text commands and the bounds refresh after a paste now look all ids up in one walk and keep the found ids in a set. Giving a pasted group new ids builds its map of old to new ids only for an Objects on Path group, which is the only kind that reads it. A pasted solid paint with no swatch link and a tint other than 1 now keeps that tint when no relinking runs. Saved art carries a tint of 1 on such a paint. --- crates/doc/src/document_guides.rs | 5 +- crates/engine/src/cmd/clipboard/resources.rs | 8 +- crates/engine/src/cmd/typecmd.rs | 17 +- crates/engine/src/cmd/typemenu.rs | 6 +- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/paste_many.rs | 209 +++++++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/it/paste_many.rs diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index c24c18bc4..f4a037f36 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -348,11 +348,14 @@ impl Document { t.cached_bounds = None; t.cached_baselines.clear(); } + let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { let old: Vec> = std::mem::take(ch); *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); - renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); + if on_path { + renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); + } } // An Objects on Path group names its path and its objects by id: the copy names the copies. if let Some(e) = n.extras_if_any() diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 22159ca2a..98824d142 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -669,6 +669,9 @@ impl SymbolOptions { #[derive(Debug, Default)] pub(crate) struct Imported { relink: Relink, + /// Whether relinking can change a pasted copy: a resource has another name or color in the + /// document, or the objects link a graphic style. + relinks: bool, /// Resources added to the document (renamed ones included). pub added: usize, /// Swatch conflicts resolved by merging. @@ -690,7 +693,9 @@ impl Imported { } /// Point a pasted copy at the document's resources. pub(crate) fn apply(&self, n: &mut Node) { - self.relink.node(n); + if self.relinks { + self.relink.node(n); + } } } @@ -822,6 +827,7 @@ impl Clipboard { } vectorcraft_brush::store(d, &lib); } + out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty()); out } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index e127d4e69..26db35c3d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -432,7 +432,7 @@ fn columns_hold(lines: &[vectorcraft_text::LineInfo], cols: usize, y0: f64, inse /// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough /// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off. pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) { - for id in text_ids(d, ids) { + for id in text_ids_where(d, ids, |t| t.cached_bounds.is_none()) { if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) && t.cached_bounds.is_none() { @@ -498,11 +498,22 @@ fn overflow_note(ids: &[NodeId], left_out: &str) -> Option { /// Text objects among `ids` and their descendants. fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { + text_ids_where(d, ids, |_| true) +} + +/// The text objects among `ids` and their descendants that `keep` accepts, each once, in the order +/// they are found. All of `ids` are looked up in one walk of the tree. +fn text_ids_where(d: &Document, ids: &[NodeId], keep: impl Fn(&TextObject) -> bool) -> Vec { + let paths = d.index_paths(ids.iter().copied()); + let mut seen = std::collections::HashSet::new(); let mut out = vec![]; for id in ids { - if let Some(n) = d.node(*id) { + if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) { + if let NodeKind::Text(t) = &c.kind + && keep(t) + && seen.insert(c.id) + { out.push(c.id); } }); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index f5d36a7b2..6d18bb5eb 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -108,11 +108,13 @@ pub fn specs() -> Vec { /// Text objects among `ids` and their descendants. pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { + let paths = d.index_paths(ids.iter().copied()); + let mut seen = std::collections::HashSet::new(); let mut out = vec![]; for id in ids { - if let Some(n) = d.node(*id) { + if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) { + if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) { out.push(c.id); } }); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f17d27e4d..a6f0b02c7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -22,6 +22,7 @@ mod known_bugs; mod lossy_overwrite; mod model_based; mod param_keys; +mod paste_many; mod pen_click_after_smooth; mod pen_endpoint_picking; mod ruler_origin; diff --git a/crates/engine/tests/it/paste_many.rs b/crates/engine/tests/it/paste_many.rs new file mode 100644 index 000000000..0482b25c0 --- /dev/null +++ b/crates/engine/tests/it/paste_many.rs @@ -0,0 +1,209 @@ +//! Paste of many objects: what lands in the document (nodes, ids, names, order, selection, one undo +//! step), and the shortcuts the paste takes for objects that have nothing to relink or rename. + +use std::collections::HashSet; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_engine::Session; +use vectorcraft_geom::Point; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ids_of(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id_of(&run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 8, "height": 8}))) +} + +/// Three named groups of three named rectangles each, on the document's only layer. +fn named_groups() -> Session { + let mut doc = Document::new(400.0, 400.0); + let layer = doc.layers[0].id; + for g in 0..3 { + let group = doc.alloc_id(); + let mut node = Node::group(group, vec![]); + node.name = Some(format!("group {g}")); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + for r in 0..3 { + let x = f64::from(g * 3 + r) * 10.0; + let shape = vectorcraft_geom::rect_from_points(Point::new(x, 0.0), Point::new(x + 8.0, 8.0)); + let appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0); + let id = doc.alloc_id(); + let mut node = Node::path(id, vectorcraft_geom::shapes::rectangle(shape), appearance); + node.name = Some(format!("rect {g}.{r}")); + doc.insert(Some(group), usize::MAX, node).unwrap(); + } + } + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +/// `n` and its descendants in tree order, as (id, name). +fn tree(n: &Node) -> Vec<(NodeId, Option)> { + let mut out = vec![]; + n.walk(&mut |c| out.push((c.id, c.name.clone()))); + out +} + +#[test] +fn a_paste_of_groups_gives_the_same_nodes_new_ids_in_tree_order_and_one_undo_step() { + let mut s = named_groups(); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + let st = s.doc().unwrap(); + let (first_id, steps, before) = (st.doc.peek_next_id(), st.history.undo.len(), st.doc.clone()); + let sources: Vec = st.selection.objects.clone(); + + let pasted = run(&mut s, "edit.paste", json!({"dx": 5, "dy": 7})); + let ids = ids_of(&pasted); + let st = s.doc().unwrap(); + + // The copies, on top of the layer in the order of their sources, are selected. + assert_eq!(ids.len(), sources.len()); + assert_eq!(st.selection.objects, ids); + let top: Vec = st.doc.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(top[sources.len()..], ids[..]); + assert_eq!(top[..sources.len()], sources[..]); + + // Names and geometry match the sources; ids run on from the counter in tree order. + let mut next = first_id; + for (source, copy) in sources.iter().zip(&ids) { + let (source, copy) = (st.doc.node(*source).unwrap(), st.doc.node(*copy).unwrap()); + let (a, b) = (tree(source), tree(copy)); + assert_eq!(a.len(), b.len()); + for ((_, name_a), (id_b, name_b)) in a.iter().zip(&b) { + assert_eq!(name_a, name_b); + assert_eq!(id_b.0, next); + next += 1; + } + let (from, to) = (source.geometric_bounds().unwrap(), copy.geometric_bounds().unwrap()); + assert!((to.x0 - from.x0 - 5.0).abs() < 1e-9 && (to.y0 - from.y0 - 7.0).abs() < 1e-9, "{from:?} {to:?}"); + } + assert_eq!(st.doc.peek_next_id(), next); + + // One undo step, back to the document as it was. + assert_eq!(st.history.undo.len(), steps + 1); + run(&mut s, "edit.undo", json!({})); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), steps); + assert_eq!(*st.doc, *before); +} + +#[test] +fn pasted_type_in_a_group_gets_its_layout_bounds() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let svg = r#"WWWMMM"#; + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.paste", json!({})); + let mut checked = 0; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + assert!(t.cached_bounds.is_some(), "{:?} has no layout bounds", n.id); + checked += 1; + } + }); + assert_eq!(checked, 2); +} + +/// A group holding point type and a rectangle: (the group, the type). +fn group_with_type() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let text = id_of(&run(&mut s, "text.create", json!({"x": 10, "y": 40, "text": "a", "size": 12}))); + let other = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [text.0, other.0]})); + let group = id_of(&run(&mut s, "object.group", json!({}))); + (s, group, text) +} + +#[test] +fn find_and_replace_given_a_group_and_type_in_it_changes_that_type_once() { + let (mut s, group, text) = group_with_type(); + // Replacing "a" with "aa" twice would leave "aaaa". + run(&mut s, "edit.findReplace", json!({"find": "a", "replace": "aa", "ids": [group.0, text.0, group.0]})); + let doc = &s.doc().unwrap().doc; + let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("the type is gone") }; + assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::(), "aa"); +} + +#[test] +fn a_type_command_given_a_group_and_type_in_it_lists_that_type_once() { + let (mut s, group, text) = group_with_type(); + let changed = run(&mut s, "type.orientation.vertical", json!({"ids": [group.0, text.0, group.0]})); + assert_eq!(ids_of(&changed), vec![text]); +} + +#[test] +fn a_pasted_objects_on_path_group_names_its_own_copies() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0)); + let path = id_of(&run(&mut s, "shape.line", json!({"x1": 0, "y1": 100, "x2": 300, "y2": 100}))); + let group = id_of(&run(&mut s, "object.objectsOnPath.attach", json!({"path": path.0, "ids": [a.0, b.0]}))); + + // Inside another group too: every level of a copy gets new ids. + let plain = rect(&mut s, 200.0); + run(&mut s, "select.set", json!({"ids": [group.0, plain.0]})); + let outer = id_of(&run(&mut s, "object.group", json!({}))); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + run(&mut s, "edit.copy", json!({})); + let pasted = ids_of(&run(&mut s, "edit.paste", json!({}))); + + let doc = &s.doc().unwrap().doc; + let copy = doc.node(pasted[0]).unwrap().children().unwrap().iter().find(|c| c.extras().objects_on_path.is_some()).unwrap(); + let rec = copy.extras().objects_on_path.as_deref().unwrap(); + let children: HashSet = copy.children().unwrap().iter().map(|c| c.id).collect(); + assert!(children.contains(&rec.path), "the path {:?} is not in the copy", rec.path); + assert_ne!(rec.path, path); + assert_eq!(rec.members.len(), 2); + for m in &rec.members { + assert!(children.contains(&m.id), "the member {:?} is not in the copy", m.id); + } +} + +#[test] +fn a_pasted_object_keeps_its_graphic_style_under_the_id_it_gets_in_the_document() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let source = rect(&mut s, 10.0); + run(&mut s, "paint.setFill", json!({"ids": [source.0], "color": "#ff0000"})); + run(&mut s, "graphicStyle.new", json!({"name": "Copied", "id": source.0})); + run(&mut s, "select.set", json!({"ids": [source.0]})); + run(&mut s, "edit.copy", json!({})); + + // A second document whose own style already has the id the copied one has. + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let own = rect(&mut s, 10.0); + run(&mut s, "paint.setFill", json!({"ids": [own.0], "color": "#0000ff"})); + run(&mut s, "graphicStyle.new", json!({"name": "Own", "id": own.0})); + let pasted = ids_of(&run(&mut s, "edit.paste", json!({}))); + + let doc = &s.doc().unwrap().doc; + let link = doc.node(pasted[0]).unwrap().extras().graphic_style; + let copied = doc.graphic_styles.iter().find(|g| g.name == "Copied").unwrap().id; + assert_ne!(copied, doc.graphic_styles.iter().find(|g| g.name == "Own").unwrap().id); + assert_eq!(link, Some(copied)); + assert_eq!(doc.node(own).unwrap().extras().graphic_style, doc.graphic_styles.iter().find(|g| g.name == "Own").map(|g| g.id)); +} + +#[test] +fn a_pasted_object_without_a_graphic_style_stays_without_one() { + let mut s = named_groups(); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + for id in ids_of(&run(&mut s, "edit.paste", json!({}))) { + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| assert_eq!(n.extras().graphic_style, None)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e6f448a79..e7cd136eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1398,6 +1398,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live objects in documents with many symbols:** a live object, or an object with effects, that shows symbols or patterns is not walked again in a later frame to find them while the document's symbols and patterns stay the same. One that shows a placed file is walked in every frame. At 1000 × 1000 pixels, a page of 2,000 envelopes around instances of the last 2,000 of 10,000 symbols takes 30 ms a frame, and 83 ms when every frame walks them again, with the same pixels. - **Control bar and task bar on large paths:** the Control bar and the contextual task bar read the first selected object in place each frame instead of copying it; the task bar copies the object only when it is a basic shape, for its Shape Properties. With one selected open path of 200,000 anchors, an idle frame with both bars shown takes 3.8 ms in a release build instead of 8.0 ms (medians of five interleaved runs). Tests: `the_control_bar_label_follows_edits_undo_documents_and_selection`, `the_task_bar_follows_the_active_document`, `the_task_bar_follows_edits_and_undo`. +- **Paste of many objects:** Paste leaves pasted copies as they are when no resource has another name or color in the document and the objects link no graphic style. It looks up the pasted type in one walk of the tree. A paste of 20,000 rectangles in 200 groups takes 25.1 ms in a release build instead of 40.0 ms, and of 5,000 point type objects 9.8 ms instead of 14.4 ms (medians of five interleaved runs of 21 pastes each). Tests: `crates/engine/tests/it/paste_many.rs`. - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it. From a18fbee3c949f735b5262f98c2631707dc07be82 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:14:01 -0800 Subject: [PATCH 2/2] Docs: the paste timing line gives CPU time per paste and undo --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7cd136eb..7bf38edc6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1398,7 +1398,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live objects in documents with many symbols:** a live object, or an object with effects, that shows symbols or patterns is not walked again in a later frame to find them while the document's symbols and patterns stay the same. One that shows a placed file is walked in every frame. At 1000 × 1000 pixels, a page of 2,000 envelopes around instances of the last 2,000 of 10,000 symbols takes 30 ms a frame, and 83 ms when every frame walks them again, with the same pixels. - **Control bar and task bar on large paths:** the Control bar and the contextual task bar read the first selected object in place each frame instead of copying it; the task bar copies the object only when it is a basic shape, for its Shape Properties. With one selected open path of 200,000 anchors, an idle frame with both bars shown takes 3.8 ms in a release build instead of 8.0 ms (medians of five interleaved runs). Tests: `the_control_bar_label_follows_edits_undo_documents_and_selection`, `the_task_bar_follows_the_active_document`, `the_task_bar_follows_edits_and_undo`. -- **Paste of many objects:** Paste leaves pasted copies as they are when no resource has another name or color in the document and the objects link no graphic style. It looks up the pasted type in one walk of the tree. A paste of 20,000 rectangles in 200 groups takes 25.1 ms in a release build instead of 40.0 ms, and of 5,000 point type objects 9.8 ms instead of 14.4 ms (medians of five interleaved runs of 21 pastes each). Tests: `crates/engine/tests/it/paste_many.rs`. +- **Paste of many objects:** Paste leaves pasted copies as they are when no resource has another name or color in the document and the objects link no graphic style. It looks up the pasted type in one walk of the tree. A paste and undo of 20,000 rectangles in 200 groups uses 49.2 ms of CPU time in a release build instead of 59.4 ms, and of 5,000 point type objects 25.3 ms instead of 29.4 ms (medians of seven interleaved rounds, each round the CPU time of a 61-paste run minus a 1-paste run, divided by 60). Tests: `crates/engine/tests/it/paste_many.rs`. - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it.