From 827e137b5cc0283dd201e52fdca2c2d899117cdb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:42:33 -0800 Subject: [PATCH 1/2] Paste: in front or back of a compound path's member, other art goes beside the compound path With a member of a compound path selected, Paste in Front and Paste in Back put the clipboard's objects next to that member, inside the compound path, whatever they were. A pasted group, compound path or type object then sat inside a compound path, which holds only paths. The model-based test `random_command_sequences` stops on this with "compound #N has a non-path child". When the clipboard holds anything other than paths, the objects now go right above or below the compound path. Paths still go next to the member, inside the compound path. --- crates/engine/src/cmd/edit.rs | 15 ++++++++++-- crates/engine/src/tests/clipboard.rs | 36 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a00f90952..3427e23f1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -813,11 +813,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho && (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); // Front/back: relative to the selection (top-most / bottom-most selected object); with - // nothing selected, the top / bottom of the current layer. + // nothing selected, the top / bottom of the current layer. Art other than paths goes beside + // the compound path that holds the selected object, not into it. let anchor = if front || back { let order = st.selection.in_paint_order(&st.doc); let a = if front { order.last() } else { order.first() }; - a.and_then(|id| st.doc.position(*id)) + let paths_only = clip.nodes.iter().all(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })); + a.map(|id| paste_anchor(&st.doc, *id, paths_only)).and_then(|id| st.doc.position(id)) } else { None }; @@ -900,6 +902,15 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho })) } +/// What pasted art goes beside: `id`, or the compound path holding it when the art isn't only +/// paths (a compound path holds paths only). +fn paste_anchor(doc: &Document, id: NodeId, paths_only: bool) -> NodeId { + match doc.parent_of(id) { + Some(p) if !paths_only && doc.node(p).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. })) => p, + _ => id, + } +} + /// The first unlocked layer or sublayer called `name` (topmost first), or a new top layer. fn layer_named(d: &mut Document, name: &str) -> NodeId { let mut found = None; diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 410a828b9..3336d384e 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -563,6 +563,42 @@ fn front_and_back_without_a_selection_use_the_current_layers_ends() { assert_eq!(&l[i - 2..i], &below[..]); } +#[test] +fn front_and_back_beside_a_compound_paths_member_go_beside_the_compound_path() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let members = |s: &Session| doc(s).node(c).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(); + let before = members(&s); + let layer = |s: &Session| doc(s).layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); + // A compound path and a group, each pasted with one of the compound path's members selected. + let other = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + sel(&mut s, &[other]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + for copied in [c, group] { + copy(&mut s, &[copied]); + sel(&mut s, &[before[0]]); + let above = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap()); + let l = layer(&s); + let i = l.iter().position(|id| *id == c).unwrap(); + assert_eq!(&l[i + 1..i + 2], &above[..], "right above the compound path"); + sel(&mut s, &[before[1]]); + let below = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap()); + let l = layer(&s); + let i = l.iter().position(|id| *id == c).unwrap(); + assert_eq!(&l[i - 1..i], &below[..], "right below the compound path"); + assert_eq!(members(&s), before, "the compound path keeps its own members"); + } + // A path still goes in front of the member, inside the compound path. + let loose = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + copy(&mut s, &[loose]); + sel(&mut s, &[before[0]]); + let path = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap()); + assert_eq!(members(&s), [before[0], path[0], before[1]]); +} + #[test] fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() { let mut s = Session::new(); From 637229931f546f381c4c9299c90d96522d6cb9c7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 10:18:06 -0800 Subject: [PATCH 2/2] Paste: Paste in Back keeps a clip group's clipping path at the bottom With the clipping path of a clip group or a clipping layer selected, or a member of a compound path that is the clipping path, Paste in Back put the clipboard's objects below it. The bottom pasted object then became the clipping path. With nothing selected in a clipping layer, Paste in Back did the same at the bottom of the layer. Paste in Back now puts the objects right above the clipping path, as Send to Back keeps art above it. --- crates/engine/src/cmd/edit.rs | 7 ++++-- crates/engine/src/tests/clipboard.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3427e23f1..62a17bda3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -862,17 +862,20 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), _ => None, }; + // In a clip group or a clipping layer the bottom child is the clipping path: art pasted + // in back goes above it, not below it. + let floor = |d: &Document, par: Option| usize::from(par.and_then(|p| d.node(p)).is_some_and(Node::clips)); let (par, idx) = match (into, anchor, layer) { // Inside the object Draw Inside draws into, on top of what is in it already. (Some(group), ..) => (Some(group), usize::MAX), // Next to the anchor, unless the object goes back to another layer. (None, Some((par, i, _)), l) if l.is_none() || l == par => { let k = placed.get(&par).copied().unwrap_or(0); - (par, if front { i + 1 + k } else { i + k }) + (par, if front { i + 1 + k } else { i.max(floor(d, par)) + k }) } (None, _, l) => { let par = l.or(parent); - (par, if back { placed.get(&par).copied().unwrap_or(0) } else { usize::MAX }) + (par, if back { floor(d, par) + placed.get(&par).copied().unwrap_or(0) } else { usize::MAX }) } }; *placed.entry(par).or_default() += 1; diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 3336d384e..e7ddbc7c0 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -599,6 +599,39 @@ fn front_and_back_beside_a_compound_paths_member_go_beside_the_compound_path() { assert_eq!(members(&s), [before[0], path[0], before[1]]); } +#[test] +fn paste_in_back_of_a_clipping_path_keeps_it_the_clipping_path() { + let mut s = session(); + let children = |s: &Session, g: NodeId| doc(s).node(g).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(); + let loose = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + let other = rect(&mut s, 220.0, 0.0, 10.0, 10.0); + sel(&mut s, &[other]); + let group = id_of(&s.execute("object.group", &json!({})).unwrap()); + // A path as the clipping path, and a compound path as the clipping path (a group pasted in back + // of a member goes beside the compound path). + let art = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let mask = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + sel(&mut s, &[art, mask]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let art2 = rect(&mut s, 0.0, 120.0, 100.0, 100.0); + let (a, b) = (rect(&mut s, 10.0, 130.0, 20.0, 20.0), rect(&mut s, 40.0, 130.0, 20.0, 20.0)); + sel(&mut s, &[a, b]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + sel(&mut s, &[art2, c]); + let g2 = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + for (g, clipping, target, copies) in [(g, mask, mask, vec![loose, group]), (g2, c, a, vec![group])] { + assert_eq!(children(&s, g)[0], clipping); + for copied in copies { + copy(&mut s, &[copied]); + sel(&mut s, &[target]); + let pasted = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap()); + let ch = children(&s, g); + assert_eq!(ch[0], clipping, "the clipping path stays the bottom child"); + assert_eq!(&ch[1..2], &pasted[..], "right above the clipping path"); + } + } +} + #[test] fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() { let mut s = Session::new();