From 668b3fa3d7b800e69e5727719c3a958b626e4e20 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 12:25:30 -0800 Subject: [PATCH] Edit: a copied member keeps its compound's gradient, and Clear leaves no empty compound A compound member copied or cut on its own now keeps the compound's gradient fitted where it was: an unplaced gradient is pinned to the compound's bounds, measured once per compound. Clear that deletes every anchor or segment of every member of a compound path now removes the compound too, as Cut does, instead of leaving an empty compound in the layer. The edit.copy and edit.clear descriptions, docs/mcp.md and the progress line now say that selecting the compound copies it whole, that isolation mode takes each member as itself, and how a compound of one and Paste in Front with the copied member still selected behave. --- crates/engine/src/cmd/clipboard.rs | 9 ++ crates/engine/src/cmd/edit.rs | 9 +- crates/engine/src/cmd/path.rs | 11 +++ .../engine/tests/it/copy_direct_selection.rs | 94 +++++++++++++++++++ docs/mcp.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 127 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 4060230e9..edc0fd06f 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -252,10 +252,16 @@ impl Clipboard { let d = &*st.doc; let mut nodes = vec![]; let mut source_layers = vec![]; + // Each compound's bounds, measured once however many of its members are copied. + let mut compound_bounds: std::collections::HashMap> = std::collections::HashMap::new(); for id in roots { let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue }; let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()); let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. }))); + // Gradients fitted to the compound's bounds are pinned there, as a split pins them. + let gradient_box = compound_parent + .filter(|p| p.appearance.has_unplaced_gradient()) + .and_then(|p| *compound_bounds.entry(p.id).or_insert_with(|| p.geometric_bounds())); // A compound's member or piece copied on its own takes the compound's look (its paint, // transparency, opacity mask, pixel alignment and fill rule), so it pastes as it looked. let inherit_appearance = |mut piece: Node| { @@ -264,6 +270,9 @@ impl Clipboard { { *rule = *compound_rule; piece.appearance = parent.appearance.clone(); + if let Some(bounds) = gradient_box { + piece.appearance.pin_gradients(bounds); + } piece.opacity = parent.opacity; piece.blend = parent.blend; piece.knockout_shape = parent.knockout_shape; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a1dd26df6..68fb9b74e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask and fill rule; the compound is copied when every member is selected whole), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask and fill rule, a gradient keeping the compound's fit; the compound is copied when it is selected, or when every member is selected whole outside isolation mode), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -220,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides; a compound-path member selected on its own is deleted alone); without ids, refused while an object of a symbol instance's art is selected", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides; a compound-path member selected on its own is deleted alone, and a compound left with no members goes too); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -934,7 +934,8 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i /// The direct-selection flag and resolved root nodes for Copy and Cut operations. A compound-path /// member selected on its own (whole, with Direct or Group Selection, or by some of its anchors or /// segments) is taken as itself, so Copy copies it as an ordinary path and Cut removes it alone. The -/// compound is taken whole when every one of its members is selected whole. +/// compound is taken whole when it is selected, or when every one of its members is selected whole +/// outside isolation mode; in an isolated compound every member is taken as itself. fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes @@ -1030,7 +1031,7 @@ fn cut(s: &mut Session, p: &Value) -> Result { return clear(s, &json!({})); } // Cut removes the same objects Copy takes: a compound member selected on its own, or the - // whole compound when every member is selected whole. + // whole compound when it is selected or every member is selected whole. let (direct, roots) = copy_cut_targets(s)?; let res = copy(s, p)?; if res.get("copied").and_then(Value::as_u64) == Some(0) { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 5d7f93581..2b529a84b 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1232,6 +1232,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .copied() .filter(|id| !d.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor))) .collect(); + // The compound paths that hold a deleted object; one left with no members goes too. + let compounds: BTreeSet = whole_ids + .iter() + .chain(sel.keys()) + .filter_map(|id| d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })))) + .collect(); if !whole_ids.is_empty() { d.remove_many(&whole_ids)?; } @@ -1280,6 +1286,11 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { } } emptied.finish(d)?; + for p in compounds { + if d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty())) { + d.remove(p)?; + } + } selection.set(survivors); Ok(()) })?; diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index e24a03bd8..53f189f2f 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -621,6 +621,100 @@ fn every_segment_of_every_member_picked_copies_and_cuts_the_compound() { check_session(&s).unwrap(); } +#[test] +fn clear_of_every_segment_or_anchor_of_every_member_leaves_no_empty_compound() { + let all: Vec> = (0..4).map(|i| vec![0, i]).collect(); + let picks: [(&str, Value, Value); 3] = [ + ("every segment", json!({"segments": all}), json!({"segments": all, "mode": "add"})), + ("every anchor", json!({"anchors": all}), json!({"anchors": all})), + ("one member whole, every segment of the other", json!({"anchors": all}), json!({"segments": all, "mode": "add"})), + ]; + for (label, first, second) in picks { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + let layer = s.doc().unwrap().doc.parent_of(compound).unwrap(); + if first.get("segments").is_some() { + run(&mut s, "select.segments", json!({"id": m[0].0, "segments": first["segments"]})); + } else { + run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": first["anchors"]}]})); + } + if second.get("segments").is_some() { + run(&mut s, "select.segments", json!({"id": m[1].0, "segments": second["segments"], "mode": "add"})); + } else { + run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": all}, {"id": m[1].0, "anchors": all}]})); + } + let undo_before = s.doc().unwrap().history.undo.len(); + run(&mut s, "edit.clear", json!({})); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(compound).is_none(), "{label}: Clear removes the compound with its members, as Cut does"); + assert!(doc.node(layer).and_then(|l| l.children()).is_some_and(|c| c.is_empty()), "{label}: nothing is left in the layer"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "{label}: Clear is one undo step"); + check_session(&s).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(members_of(&s, compound), Some(m.clone()), "{label}: Undo puts the compound back"); + } +} + +#[test] +fn a_copied_member_keeps_the_compounds_gradient_where_it_was() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + // Members at x 10..50 and 60..100: the compound's gradient spans x 10..100. + let (compound, m) = compound_of(&mut s, 2); + run(&mut s, "paint.setFill", json!({"ids": [compound.0], "gradient": {"kind": "linear"}})); + let (mut expected, bounds) = { + let c = s.doc().unwrap().doc.node(compound).unwrap(); + (c.appearance.clone(), c.geometric_bounds().unwrap()) + }; + assert!(expected.has_unplaced_gradient(), "the compound's gradient is fitted to its bounds"); + assert_eq!((bounds.x0, bounds.x1), (10.0, 100.0)); + expected.pin_gradients(bounds); + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m[0].0]})); + run(&mut s, "edit.copy", json!({})); + let member = &s.clipboard.nodes[0]; + assert_eq!(member.id, m[0]); + assert!(!member.appearance.has_unplaced_gradient(), "the member's gradient is not refitted to its own bounds"); + assert_eq!(member.appearance, expected, "the gradient keeps the compound's fit"); + + // The member cut and pasted in place draws the gradient over the compound's stretch. + run(&mut s, "edit.cut", json!({})); + let pasted = NodeId(run(&mut s, "edit.pasteInPlace", json!({}))["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.node(pasted).unwrap().appearance, expected); +} + +#[test] +fn the_only_member_of_a_compound_of_one_copies_the_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.cut", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "Cut leaves a compound of one"); + // Its one member selected whole is every member selected whole. + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m[1].0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::>(), [compound]); + assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Compound { .. })); +} + +#[test] +fn copy_of_a_member_then_paste_in_front_adds_a_member_to_the_compound() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let (compound, m) = compound_of(&mut s, 2); + pick_all_anchors(&mut s, m[0]); + run(&mut s, "edit.copy", json!({})); + // The member is still selected: a path pasted in front of it goes into its compound. + let pasted = NodeId(run(&mut s, "edit.pasteInFront", json!({}))["ids"][0].as_u64().unwrap()); + let doc = &s.doc().unwrap().doc; + assert!(matches!(doc.node(pasted).unwrap().kind, NodeKind::Path { .. })); + assert_eq!(members_of(&s, compound), Some(vec![m[0], pasted, m[1]]), "the pasted path sits just above the member"); + check_session(&s).unwrap(); +} + #[test] fn cut_of_a_whole_member_with_anchors_of_another_member_takes_each_by_its_own_rule() { let after = |command: &str| { diff --git a/docs/mcp.md b/docs/mcp.md index 81425c71b..4623ac961 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1707,9 +1707,13 @@ or unknown id leaves the document unchanged. An empty `ids` array does nothing. are kept, and selecting an ancestor together with its descendants deletes that subtree once. Without `ids`, direct-selected anchors or selected ruler guides retain their usual Clear behavior. `edit.copy` takes a compound-path member selected on its own (whole or in part, with Direct or Group Selection) as an -ordinary path with the compound's paint, transparency, opacity mask and fill rule, and the whole compound only when -every member is selected whole. `edit.cut` removes the same objects it copies, so cutting one member leaves the other -members in the compound, a compound of one member included. +ordinary path with the compound's paint, transparency, opacity mask and fill rule. A gradient fitted to the compound +keeps the compound's fit. Copy takes the whole compound when the compound itself is selected, or when every member is +selected whole outside isolation mode, which includes the one member of a compound of one. In an isolated compound +path each member is taken as itself. `edit.cut` removes the same objects it copies, so cutting one member leaves the +other members in the compound, a compound of one member included. When Clear deletes every anchor or segment of every +member, it also removes the compound. With a copied member still selected, `edit.pasteInFront` puts the path into the +compound, just above the member. With `ids`, `guides: true` also deletes the selected ruler guides. The command rejects a `guides` value that is not a boolean. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 81585579b..f46edad7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -894,7 +894,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A symbol instance is measured by its symbol's art, as its transform places it, for clicks, the marquee, the bounding box, the Transform panel, Align and Distribute (the art's strokes too with Use Preview Bounds), Fit to Artwork Bounds, exports of the art, thumbnails and canvas culling; one whose symbol is missing is measured as the 20 × 20 square around the origin. A selected clip group shows the outline and anchors of its clipping path, not of the art the path clips, and its hover highlight and key object outline follow the clipping path too. That art shows its whole outline when it is selected on its own: with Edit Contents, Direct or Group Selection, or in isolation mode (#2422). Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). - Copy and Cut of a compound-path member selected on its own, with Direct or Group Selection, take only that member, as an ordinary path with the compound's paint, transparency, opacity mask and fill rule; the other members stay a compound, also when one is left. Every member selected whole still takes the whole compound (`engine/tests/it/copy_direct_selection.rs`). + Copy and Cut of a compound-path member selected on its own, with Direct or Group Selection, take only that member, as an ordinary path with the compound's paint, transparency, opacity mask and fill rule, and a gradient keeps the compound's fit; the other members stay a compound, also when one is left. Selecting the compound, or every member whole outside isolation mode, still takes the whole compound, and Clear of every member's anchors or segments removes the compound too (`engine/tests/it/copy_direct_selection.rs`). A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Make Repeat and Live Paint no longer fail on such a list, New Symbol no longer stores the object twice, and `object.transform` no longer moves it twice. - **Compound paths:** Make keeps the backmost object's appearance and each contour; selecting a member in Direct Selection reads and changes the compound's fill rule in Attributes. Direction remains a per-member edit. Rendered holes, unchanged contour geometry, no-op history and Undo/Redo are covered by `engine/tests/it/compound_attributes.rs` (#1197). Behaviour reference: [public compound-path documentation](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-paths.html). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens.