Edit: Copy and Cut of a whole compound member take only that member

A compound-path member selected on its own, whole with Direct Selection
or with Group Selection, is now copied as an ordinary path instead of
promoting the selection to its compound. Cut removes the same objects
Copy takes, so the other members stay a compound path, a compound of
one included. The compound is still taken whole when every member is
selected whole, or when the compound itself is selected.

A copied member or piece takes the compound's paint, opacity, blending
mode, opacity mask, pixel alignment and fill rule.

Tests that pinned the earlier rule from #786 now check the new one.
This commit is contained in:
dotCooCoo
2026-11-10 11:26:35 -08:00
parent e1137877ac
commit 1f8423c2a9
6 changed files with 336 additions and 38 deletions
+29 -1
View File
@@ -210,6 +210,24 @@ pub(crate) fn extract_direct_selection(node: &Node, selection: &Selection) -> Op
if pieces.is_empty() { None } else { Some(pieces) }
}
/// Does Copy take `node` unchanged under direct selection, as [`extract_direct_selection`]
/// returning `None` means: no anchors or segments of it are picked, it is not a path, or the
/// picked ones cover every segment?
pub(crate) fn copied_whole(node: &Node, selection: &Selection) -> bool {
let seg_refs = selection.segments.get(&node.id);
let anchor_refs = selection.anchors.get(&node.id);
if seg_refs.is_none() && anchor_refs.is_none() {
return true;
}
let Some(path) = node.path_data() else { return true };
let (mut total_segs, mut total_selected) = (0, 0);
for (si, sp) in path.subpaths.iter().enumerate() {
total_segs += sp.segment_count();
total_selected += subpath_selected_segments(sp, si, seg_refs, anchor_refs).len();
}
total_selected == 0 || total_selected == total_segs
}
impl Clipboard {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.artboards.is_empty() && self.guides.is_empty()
@@ -238,11 +256,21 @@ impl Clipboard {
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 { .. })));
// 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| {
if let Some(parent) = compound_parent
&& piece.appearance.items.is_empty()
&& let (NodeKind::Compound { rule: compound_rule, .. }, NodeKind::Path { rule, .. }) = (&parent.kind, &mut piece.kind)
{
*rule = *compound_rule;
piece.appearance = parent.appearance.clone();
piece.opacity = parent.opacity;
piece.blend = parent.blend;
piece.knockout_shape = parent.knockout_shape;
let (mask, pixel_aligned) = (parent.extras().mask.clone(), parent.extras().pixel_aligned);
let mut extras = piece.extras_mut();
extras.mask = mask;
extras.pixel_aligned = pixel_aligned;
}
piece
};
+38 -22
View File
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cut",
["Edit"],
Some("Cmd+X"),
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) removes the objects it copies, in one undo step: a compound-path member selected on its own leaves the compound's other members in the compound (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
has_selection_guides_or_artboard_tool,
cut
),
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, 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; 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}",
has_selection_guides_or_artboard_tool,
copy
),
@@ -220,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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); 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); without ids, refused while an object of a symbol instance's art is selected",
has_selection_or_guides,
clear
),
@@ -931,7 +931,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
})
}
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
/// 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.
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
let st = s.doc()?;
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
@@ -940,9 +943,19 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
in_isolated_compound || (direct && (st.selection.partial(member).is_some() || st.selection.segments.contains_key(&member)))
});
// A member counts as selected whole when Copy would take it unchanged: no anchors or segments
// of it are picked, the picked ones cover every segment, or a drawing tool's own anchor is
// all that is picked.
let selected: std::collections::HashSet<NodeId> = st.selection.objects.iter().copied().collect();
let whole = |m: &vectorcraft_doc::Node| selected.contains(&m.id) && (!direct || super::clipboard::copied_whole(m, &st.selection));
// Worked out once per compound: every selected member asks about its compound.
let checked = std::cell::RefCell::new(std::collections::HashMap::new());
let all_members_whole = |compound: NodeId| {
*checked.borrow_mut().entry(compound).or_insert_with(|| {
st.doc.node(compound).and_then(|n| n.children()).is_some_and(|members| !members.is_empty() && members.iter().all(|m| whole(m)))
})
};
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |compound, _member| in_isolated_compound || !all_members_whole(compound));
Ok((direct, roots))
}
@@ -1016,8 +1029,8 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
copy(s, p)?;
return clear(s, &json!({}));
}
// Copy promotes a compound member to its compound. Cut must remove the same objects it
// copied, while Clear on its own deletes exactly the selected members.
// Cut removes the same objects Copy takes: a compound member selected on its own, or the
// whole compound when 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) {
@@ -1045,12 +1058,8 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
for id in roots {
let Some(node) = d.node(*id) else { continue };
let has_direct = sel.anchors.contains_key(id) || sel.segments.contains_key(id);
if !has_direct {
d.remove(*id)?;
continue;
}
if node.path_data().is_none() {
d.remove(*id)?;
if !has_direct || node.path_data().is_none() {
remove_leaving_no_empty_compound(d, *id)?;
continue;
}
let original = super::path::deletion_source(d, *id)?;
@@ -1073,13 +1082,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
}
subpaths.retain(|sp| sp.anchors.len() >= 2);
if subpaths.is_empty() {
let parent = d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
d.remove(*id)?;
if let Some(p) = parent
&& d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty()))
{
d.remove(p)?;
}
remove_leaving_no_empty_compound(d, *id)?;
} else {
survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, original, subpaths)?);
}
@@ -1093,6 +1096,19 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
ok()
}
/// Remove object `id`, and the compound path that held it when it was the compound's last member.
/// A compound left with one member stays a compound path, as after Clear.
fn remove_leaving_no_empty_compound(d: &mut Document, id: NodeId) -> Result<()> {
let parent = d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
d.remove(id)?;
if let Some(p) = parent
&& d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty()))
{
d.remove(p)?;
}
Ok(())
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let guides = switch_or(p, "guides", false, "edit.clear")?;
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
+5 -3
View File
@@ -144,12 +144,14 @@ fn cut_compound_member_keeps_copy_and_delete_targets_consistent() {
let (mut s, a, b, c) = compound();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
// Cut takes the member it copies, and the other member stays in the compound.
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].id, c);
assert!(s.active().unwrap().doc.node(c).is_none());
assert_eq!(s.clipboard.nodes[0].id, a);
assert!(s.active().unwrap().doc.node(a).is_none());
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
s.execute("edit.pasteInPlace", &json!({})).unwrap();
let pasted = s.active().unwrap().selection.objects[0];
assert_eq!(s.active().unwrap().doc.node(pasted).unwrap().children().unwrap().len(), 2);
assert!(s.active().unwrap().doc.node(pasted).unwrap().path_data().is_some(), "an ordinary path is pasted");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
+259 -11
View File
@@ -367,13 +367,14 @@ fn copy_when_all_segments_selected_preserves_live_shape() {
}
#[test]
fn compound_member_selected_whole_with_direct_selection_promotes_member() {
fn compound_member_selected_whole_with_direct_selection_copies_only_the_member() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let standalone = rect(&mut s, 120.0, 10.0, 50.0, 50.0);
@@ -394,17 +395,24 @@ fn compound_member_selected_whole_with_direct_selection_promotes_member() {
assert!(s.active().unwrap().selection.partial(outer).is_none());
assert!(s.active().unwrap().selection.partial(standalone).is_some());
let undo_before = s.doc().unwrap().history.undo.len();
run(&mut s, "edit.copy", json!({}));
// Clipboard should have:
// 1. The compound path (outer promoted to compound #786)
// 2. The extracted segment piece from standalone
// The clipboard holds the member as an ordinary path with the compound's paint, and the
// piece extracted from the standalone path.
let clip_ids: Vec<NodeId> = s.clipboard.nodes.iter().map(|n| n.id).collect();
assert_eq!(clip_ids.len(), 2, "promoted compound plus the extracted standalone piece");
assert!(clip_ids.contains(&compound), "whole compound member must be promoted to compound parent");
assert_eq!(clip_ids.len(), 2, "the member plus the extracted standalone piece");
assert!(clip_ids.contains(&outer), "the member is copied itself: {clip_ids:?}");
assert!(!clip_ids.contains(&compound), "the compound is not copied");
let member = s.clipboard.nodes.iter().find(|n| n.id == outer).unwrap();
assert!(matches!(member.kind, NodeKind::Path { .. }));
assert_eq!(member.appearance, s.doc().unwrap().doc.node(compound).unwrap().appearance);
// Copy leaves the document and its history as they were.
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
assert_eq!(s.doc().unwrap().doc.node(compound).unwrap().children().unwrap().len(), 2);
}
#[test]
fn cut_whole_compound_member_with_direct_selection_cuts_compound_and_extracted_piece() {
fn cut_whole_compound_member_with_direct_selection_cuts_member_and_extracted_piece() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
@@ -426,16 +434,256 @@ fn cut_whole_compound_member_with_direct_selection_cuts_compound_and_extracted_p
}),
);
let undo_before = s.doc().unwrap().history.undo.len();
run(&mut s, "edit.cut", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "Cut is one undo step");
let clip_ids: Vec<NodeId> = s.clipboard.nodes.iter().map(|n| n.id).collect();
assert_eq!(clip_ids.len(), 2, "promoted compound plus the extracted standalone piece");
assert!(clip_ids.contains(&compound), "whole compound member must be promoted to compound parent");
assert_eq!(clip_ids.len(), 2, "the member plus the extracted standalone piece");
assert!(clip_ids.contains(&outer) && !clip_ids.contains(&compound), "{clip_ids:?}");
// Compound was removed because member was selected whole
// The member left; the other member stays in the compound.
let doc = &s.doc().unwrap().doc;
assert!(doc.node(compound).is_none(), "compound path must be removed on cut");
assert!(doc.node(outer).is_none(), "the cut member is removed");
assert_eq!(doc.node(compound).and_then(|n| n.children()).map(|c| c.iter().map(|m| m.id).collect::<Vec<_>>()), Some(vec![inner]));
// Standalone has cut pieces remaining
assert!(doc.node(standalone).is_some(), "partially cut standalone path remains");
check_session(&s).unwrap();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.node(compound).unwrap().children().unwrap().len(), 2, "Undo puts the member back");
run(&mut s, "edit.redo", json!({}));
assert!(s.doc().unwrap().doc.node(outer).is_none());
check_session(&s).unwrap();
}
/// A compound path of `n` rectangles side by side (x = 10, 60, 110, …), filled red, with its id
/// and its members' ids.
fn compound_of(s: &mut Session, n: usize) -> (NodeId, Vec<NodeId>) {
let members: Vec<NodeId> = (0..n).map(|i| rect(s, 10.0 + 50.0 * i as f64, 10.0, 40.0, 40.0)).collect();
run(s, "select.set", json!({"ids": members.iter().map(|m| m.0).collect::<Vec<_>>()}));
let compound = NodeId(run(s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"color": "#ff0000"}));
(compound, members)
}
/// Every anchor of the four-anchor rectangle `id` picked with Direct Selection, which selects it
/// whole.
fn pick_all_anchors(s: &mut Session, id: NodeId) {
run(s, "select.anchorsMany", json!({"items": [{"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2], [0, 3]]}]}));
assert!(s.doc().unwrap().selection.contains(id) && s.doc().unwrap().selection.partial(id).is_none(), "the member is selected whole");
}
fn members_of(s: &Session, compound: NodeId) -> Option<Vec<NodeId>> {
s.doc().unwrap().doc.node(compound).and_then(|n| n.children()).map(|c| c.iter().map(|m| m.id).collect())
}
#[test]
fn cut_of_one_member_of_two_leaves_a_compound_of_one() {
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]);
let undo_before = s.doc().unwrap().history.undo.len();
run(&mut s, "edit.cut", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "Cut is one undo step");
assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "the other member stays a compound of one, as after Clear");
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].id, m[0]);
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { .. }), "the clipboard holds an ordinary path");
check_session(&s).unwrap();
run(&mut s, "edit.undo", json!({}));
assert_eq!(members_of(&s, compound), Some(m.clone()), "Undo puts the member back in its place");
run(&mut s, "edit.redo", json!({}));
assert_eq!(members_of(&s, compound), Some(vec![m[1]]));
check_session(&s).unwrap();
}
#[test]
fn cut_of_the_middle_member_of_three_leaves_the_other_two() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 3);
pick_all_anchors(&mut s, m[1]);
run(&mut s, "edit.cut", json!({}));
assert_eq!(members_of(&s, compound), Some(vec![m[0], m[2]]));
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[1]]);
check_session(&s).unwrap();
}
#[test]
fn clear_of_a_whole_member_removes_only_the_member_and_keeps_the_clipboard() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
let standalone = rect(&mut s, 10.0, 120.0, 40.0, 40.0);
run(&mut s, "select.set", json!({"ids": [standalone.0]}));
run(&mut s, "edit.copy", json!({}));
pick_all_anchors(&mut s, m[0]);
run(&mut s, "edit.clear", json!({}));
assert_eq!(members_of(&s, compound), Some(vec![m[1]]));
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [standalone]);
}
#[test]
fn group_selection_of_one_member_copies_and_cuts_only_that_member() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
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::<Vec<_>>(), [m[1]]);
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { .. }));
assert_eq!(members_of(&s, compound), Some(m.clone()), "Copy leaves both members");
run(&mut s, "edit.cut", json!({}));
assert_eq!(members_of(&s, compound), Some(vec![m[0]]));
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[1]]);
check_session(&s).unwrap();
}
#[test]
fn every_member_selected_whole_copies_and_cuts_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);
s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap();
run(&mut s, "select.set", json!({"ids": [m[0].0, m[1].0]}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
run(&mut s, "edit.cut", json!({}));
assert!(s.doc().unwrap().doc.node(compound).is_none(), "the compound leaves whole");
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
check_session(&s).unwrap();
}
#[test]
fn a_copied_member_keeps_the_compounds_transparency_mask_and_fill_rule() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
let mask = rect(&mut s, 0.0, 0.0, 120.0, 60.0);
run(&mut s, "select.set", json!({"ids": [compound.0, mask.0]}));
run(&mut s, "transparency.makeOpacityMask", json!({}));
let mask_art = s.doc().unwrap().doc.node(compound).unwrap().extras().mask.clone();
assert!(mask_art.is_some(), "the compound has an opacity mask");
run(&mut s, "select.set", json!({"ids": [compound.0]}));
run(&mut s, "transparency.set", json!({"opacity": 50, "blend": "Multiply"}));
run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}));
assert!(matches!(s.doc().unwrap().doc.node(m[0]).unwrap().kind, NodeKind::Path { rule: FillRule::NonZero, .. }), "the member keeps its own rule");
let (opacity, blend) = {
let c = s.doc().unwrap().doc.node(compound).unwrap();
(c.opacity, c.blend)
};
assert_eq!(opacity, 0.5);
pick_all_anchors(&mut s, m[0]);
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard.nodes.iter().map(|n| (n.id, n.opacity, n.blend)).collect::<Vec<_>>(), [(m[0], opacity, blend)]);
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
assert_eq!(s.clipboard.nodes[0].extras().mask, mask_art, "the member keeps the compound's opacity mask");
}
#[test]
fn a_copied_member_with_its_own_appearance_takes_the_compounds() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
// The member carries an appearance of its own, as an imported member can; the compound's
// appearance is the one drawn.
run(&mut s, "paint.setFill", json!({"ids": [compound.0], "color": "#00ff00"}));
let own_look = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone();
run(&mut s, "paint.setFill", json!({"ids": [compound.0], "color": "#0000ff"}));
let compound_look = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone();
assert_ne!(own_look, compound_look);
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(m[0]).unwrap().appearance = own_look.clone();
assert!(!s.doc().unwrap().doc.node(m[0]).unwrap().appearance.items.is_empty(), "the member has its own appearance");
pick_all_anchors(&mut s, m[0]);
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard.nodes[0].appearance, compound_look);
}
#[test]
fn every_segment_of_every_member_picked_copies_and_cuts_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);
let all: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();
run(&mut s, "select.segments", json!({"id": m[0].0, "segments": all}));
run(&mut s, "select.segments", json!({"id": m[1].0, "segments": all, "mode": "add"}));
let sel = &s.doc().unwrap().selection;
assert!(sel.segments.contains_key(&m[0]) && sel.segments.contains_key(&m[1]), "both members keep their picked segments");
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
run(&mut s, "edit.cut", json!({}));
assert!(s.doc().unwrap().doc.node(compound).is_none(), "the compound leaves whole");
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
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| {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
let whole: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();
run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": whole}, {"id": m[1].0, "anchors": [[0, 1], [0, 2]]}]}));
run(&mut s, command, json!({}));
check_session(&s).unwrap();
(s, compound, m)
};
let (s, compound, m) = after("edit.cut");
// The whole member and the segment between the two picked anchors went to the clipboard.
assert_eq!(s.clipboard.nodes.len(), 2);
assert!(s.clipboard.nodes.iter().any(|n| n.id == m[0]));
assert!(s.clipboard.nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// The partly picked member stays in the compound, with what Clear leaves of its outline.
assert_eq!(members_of(&s, compound).map(|c| c.len()), Some(1));
assert_eq!(contours(&s), contours(&after("edit.clear").0));
assert_eq!(contours(&s).len(), 1);
}
#[test]
fn cut_of_a_member_of_a_compound_inside_a_group_leaves_the_group_and_compound() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
let other = rect(&mut s, 10.0, 120.0, 40.0, 40.0);
run(&mut s, "select.set", json!({"ids": [compound.0, other.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
pick_all_anchors(&mut s, m[0]);
run(&mut s, "edit.cut", json!({}));
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.parent_of(compound), Some(group));
assert_eq!(members_of(&s, compound), Some(vec![m[1]]));
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[0]]);
check_session(&s).unwrap();
}
#[test]
fn paste_in_place_and_in_front_of_a_cut_member_paste_an_ordinary_path() {
for command in ["edit.pasteInPlace", "edit.pasteInFront"] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let (compound, m) = compound_of(&mut s, 2);
let fill = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone();
pick_all_anchors(&mut s, m[0]);
run(&mut s, "edit.cut", json!({}));
let pasted = run(&mut s, command, json!({}));
let ids = pasted["ids"].as_array().unwrap();
assert_eq!(ids.len(), 1, "{command}");
let id = NodeId(ids[0].as_u64().unwrap());
let doc = &s.doc().unwrap().doc;
let node = doc.node(id).unwrap();
assert!(matches!(node.kind, NodeKind::Path { .. }), "{command}");
assert_eq!(node.appearance, fill, "{command}: the path keeps the compound's paint");
assert!(doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| p.is_layer()), "{command}: pasted into the layer");
let anchors: Vec<[f64; 2]> = node.path_data().unwrap().subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect();
assert!(anchors.contains(&[10.0, 10.0]) && anchors.contains(&[50.0, 50.0]), "{command}: where it was: {anchors:?}");
assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "{command}");
check_session(&s).unwrap();
}
}
#[test]
+4 -1
View File
@@ -1706,7 +1706,10 @@ overrides selected anchors or guides. Every explicit id is validated before any
or unknown id leaves the document unchanged. An empty `ids` array does nothing. Layers themselves
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.
Cut still removes the objects it copied, including a whole compound when a member is selected.
`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.
With `ids`, `guides: true` also deletes the selected ruler guides. The command rejects a `guides` value that is not a
boolean.
+1
View File
@@ -894,6 +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`).
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.