collective

Browse / github.com/storytold/vectorcraft / patch

M2.1: copy and cut direct-selected segments and anchors as independent paths (#2122)

[Feature Request] Copy/Cut with Direct Selection: copy only the selected anchors/segment, not the whole path (as in Illustrator) ## Expected In Illustrator, with the Direct Selection tool, select two anchor points (or one segment) of a path and press Ctrl+C, then Ctrl+V (or Paste in Place). Only the selected part is copied: the segment between the two points is pasted as a new open path, and the rest of the original path is not copied. ## Actual Ctrl+C copies the whole path, whatever anchors are selected. Pasting gives a full copy of the original object. Cut (Ctrl+X) is inconsistent: it puts the whole object on the clipboard, but then removes only the selected anchors from the original. ## Steps 1. Draw a path with se

Status
open
Score
0.00
Adopters
0 other operators
Author
el-j (imported from pull request #3903, open)
Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd
Size
+925 −40 in 9 files
Review
pending
Shared
2026-10-28
Id
a4f70c7a6a66a56e947eb31d

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/clipboard.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/library.rs crates/engine/src/cmd/panelcmds.rs crates/engine/src/cmd/path.rs crates/engine/src/doc_state.rs crates/engine/tests/it/clear_commands.rs crates/engine/tests/it/copy_direct_selection.rs crates/engine/tests/it/main.rs

diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs--- a/crates/engine/src/cmd/clipboard.rs+++ b/crates/engine/src/cmd/clipboard.rs@@ -12,14 +12,14 @@ mod flavours; pub(crate) mod resources; -use std::collections::BTreeMap;+use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc;  use serde_json::{Value, json}; use vectorcraft_brush::Brush; use vectorcraft_color::Swatch;-use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};-use vectorcraft_geom::Rect;+use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, NodeKind, PatternDef, Selection, Symbol, TextStyleDef};+use vectorcraft_geom::{PathData, Rect};  pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture}; pub(crate) use resources::SwatchChoices;@@ -94,21 +94,145 @@ pub struct Clipboard {     pub artboards: Vec<vectorcraft_doc::Artboard>, } +/// Indices of segments selected on `sp` (either explicitly or via incident anchors).+pub(crate) fn subpath_selected_segments(+    sp: &vectorcraft_geom::SubPath,+    si: usize,+    seg_refs: Option<&BTreeSet<(usize, usize)>>,+    anchor_refs: Option<&BTreeSet<(usize, usize)>>,+) -> BTreeSet<usize> {+    let seg_count = sp.segment_count();+    if seg_count == 0 {+        return BTreeSet::new();+    }+    let n = sp.anchors.len();+    let mut selected_segs = BTreeSet::new();++    let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si));+    if has_explicit_segs {+        if let Some(segments) = seg_refs {+            for &(s, seg) in segments {+                if s == si && seg < seg_count {+                    selected_segs.insert(seg);+                }+            }+        }+        return selected_segs;+    }++    if let Some(anchors) = anchor_refs {+        let sub_anchors: BTreeSet<usize> = anchors.iter().filter_map(|&(s, a)| (s == si && a < n).then_some(a)).collect();+        if !sub_anchors.is_empty() {+            for i in 0..seg_count {+                let next = (i + 1) % n;+                if sub_anchors.contains(&i) && sub_anchors.contains(&next) {+                    selected_segs.insert(i);+                }+            }++            for &i in &sub_anchors {+                let prev_selected = if sp.closed { sub_anchors.contains(&((i + n - 1) % n)) } else { i > 0 && sub_anchors.contains(&(i - 1)) };+                let next_selected = if sp.closed { sub_anchors.contains(&((i + 1) % n)) } else { i + 1 < n && sub_anchors.contains(&(i + 1)) };+                if !prev_selected && !next_selected {+                    if sp.closed {+                        selected_segs.insert((i + n - 1) % n);+                        selected_segs.insert(i);+                    } else {+                        if i > 0 {+                            selected_segs.insert(i - 1);+                        }+                        if i < seg_count {+                            selected_segs.insert(i);+                        }+                    }+                }+            }+        }+    }++    selected_segs+}++/// When direct selection is active on a path (some anchors or segments selected), extract only+/// the selected runs as new open path nodes (with retracted endpoint handles). Returns `None`+/// when the node is selected whole or is not a path.+pub(crate) fn extract_direct_selection(node: &Node, selection: &Selection) -> Option<Vec<Node>> {+    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 None;+    }+    let path = node.path_data()?;+    let mut subpath_segs = Vec::new();+    let mut total_segs = 0;+    let mut total_selected = 0;++    for (si, sp) in path.subpaths.iter().enumerate() {+        let segs = subpath_selected_segments(sp, si, seg_refs, anchor_refs);+        total_segs += sp.segment_count();+        total_selected += segs.len();+        subpath_segs.push(segs);+    }++    if total_selected == 0 {+        return None;+    }++    // When every segment of the path is selected, copy the object unchanged.+    if total_segs > 0 && total_selected == total_segs {+        return None;+    }++    let mut pieces = vec![];+    for (sp, selected_segs) in path.subpaths.iter().zip(subpath_segs) {+        let seg_count = sp.segment_count();+        if selected_segs.is_empty() || seg_count == 0 {+            continue;+        }+        let unselected: BTreeSet<usize> = (0..seg_count).filter(|i| !selected_segs.contains(i)).collect();+        let surviving = if unselected.is_empty() { vec![sp.clone()] } else { super::path::without_segments(sp.clone(), &unselected) };+        for surviving_sp in surviving {+            let mut piece = node.clone();+            if let NodeKind::Path { rule, clipping, guide, .. } = &node.kind {+                piece.kind = NodeKind::Path { path: PathData::single(surviving_sp), rule: *rule, live: None, clipping: *clipping, guide: *guide };+            }+            pieces.push(piece);+        }+    }+    if pieces.is_empty() { None } else { Some(pieces) }+}+ impl Clipboard {     pub fn is_empty(&self) -> bool {         self.nodes.is_empty() && self.artboards.is_empty()     }      /// Objects `roots` of `st`'s document with their resources, source artboard and layers.-    pub(crate) fn copy(st: &DocState, roots: &[NodeId]) -> Self {+    pub(crate) fn copy(st: &DocState, roots: &[NodeId], direct: bool) -> Self {         let d = &*st.doc;         let mut nodes = vec![];         let mut source_layers = vec![];         for id in roots {             let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue };-            nodes.push(n.clone());-            // The nearest enclosing layer (or sublayer).-            source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));+            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 { .. })));+            let inherit_appearance = |mut piece: Node| {+                if let Some(parent) = compound_parent+                    && piece.appearance.items.is_empty()+                {+                    piece.appearance = parent.appearance.clone();+                }+                piece+            };+            if direct && let Some(pieces) = extract_direct_selection(n, &st.selection) {+                for piece in pieces {+                    nodes.push(inherit_appearance(piece));+                    source_layers.push(layer_name.clone());+                }+            } else {+                nodes.push(inherit_appearance(n.clone()));+                source_layers.push(layer_name);+            }         }         // Symbol instances are measured by their art.         let symbols = d.symbol_boxes();diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc;  use serde_json::{Value, json};-use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};+use vectorcraft_doc::{Document, Node, NodeId, NodeKind, RulerMode, Unit}; use vectorcraft_geom::{Affine, Point, Vec2};  use super::clipboard::SwatchChoices;@@ -545,17 +545,25 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> { /// rest still go to the compound path, which holds the paint. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {     let st = s.doc()?;-    Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))+    Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), CompoundHandling::KeepAll)) }  /// The top-level objects among `ids` (in paint order): compound-path members stand for their /// compound, and ids whose ancestor is also listed, and layers, are dropped. pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {-    roots_within(doc, ids, false)+    roots_within(doc, ids, CompoundHandling::PromoteAll) } -/// [`roots_of`]; with `members`, a compound path's member listed stays itself.-fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId> {+#[derive(Copy, Clone)]+enum CompoundHandling<'a> {+    PromoteAll,+    KeepAll,+    KeepOnlyPartial(&'a vectorcraft_doc::Selection),+}++/// [`roots_of`]; depending on `handling`, compound members may stay themselves or be promoted+/// to their compound path parent.+fn roots_within(doc: &Document, ids: Vec<NodeId>, handling: CompoundHandling<'_>) -> Vec<NodeId> {     // Every id's place in the tree, found in one walk (a lookup per id is a walk per id).     let paths = doc.index_paths(ids.iter().copied());     // A compound-path member acts as its compound (compounds are one object).@@ -564,8 +572,13 @@ fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId>         .map(|id| {             let path = paths.get(&id);             let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());+            let keep_member = match handling {+                CompoundHandling::PromoteAll => false,+                CompoundHandling::KeepAll => true,+                CompoundHandling::KeepOnlyPartial(sel) => sel.partial(id).is_some() || sel.segments.contains_key(&id),+            };             match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {-                Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),+                Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !keep_member => (n.id, Some(up.to_vec())),                 _ => (id, path.cloned()),             }         })@@ -596,13 +609,35 @@ 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.+fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {+    let st = s.doc()?;+    let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature");+    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 handling = if in_isolated_compound {+        CompoundHandling::KeepAll+    } else if direct {+        CompoundHandling::KeepOnlyPartial(&st.selection)+    } else {+        CompoundHandling::PromoteAll+    };+    let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), handling);+    Ok((direct, roots))+}+ fn copy(s: &mut Session, _: &Value) -> Result<Value> {     // The Artboard tool copies its artboard (and the art on it).     if s.tool_id() == "artboard" {         return super::panelcmds::artboard_copy(s, &json!({}), false);     }-    let roots = selected_roots(s)?;-    s.clipboard = Clipboard::copy(s.doc()?, &roots);+    let (direct, roots) = copy_cut_targets(s)?;+    let st = s.doc()?;+    let new_clip = Clipboard::copy(st, &roots, direct);+    if direct && new_clip.nodes.is_empty() {+        return Ok(json!({ "copied": 0 }));+    }+    s.clipboard = new_clip;     Ok(json!({ "copied": s.clipboard.nodes.len() })) } @@ -610,17 +645,69 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {     if s.tool_id() == "artboard" {         return super::panelcmds::artboard_copy(s, &json!({}), true);     }-    // 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.-    let ids = selected_roots(s)?;-    copy(s, p)?;-    if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids })) } else { clear(s, &json!({})) }+    let (direct, roots) = copy_cut_targets(s)?;+    let res = copy(s, p)?;+    if res.get("copied").and_then(Value::as_u64) == Some(0) {+        return Ok(res);+    }+    if !direct { clear(s, &json!({ "ids": roots })) } else { cut_direct_selection(s, &roots) }+}++fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {+    let st = s.doc()?;+    let sel = st.selection.clone();+    s.edit("Cut", |d, selection| {+        let mut survivors = vec![];+        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;+            }+            let Some(path) = node.path_data() else {+                d.remove(*id)?;+                continue;+            };++            let seg_refs = sel.segments.get(id);+            let anchor_refs = sel.anchors.get(id);+            let mut subpaths = Vec::new();+            for (si, sp) in path.subpaths.iter().enumerate() {+                let cuts = super::clipboard::subpath_selected_segments(sp, si, seg_refs, anchor_refs);+                if cuts.is_empty() {+                    subpaths.push(sp.clone());+                } else {+                    subpaths.extend(super::path::without_segments(sp.clone(), &cuts));+                }+            }+            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)?;+                }+            } else {+                let path_node = super::path::path_mut(d, *id)?;+                path_node.subpaths = subpaths;+                survivors.push(*id);+            }+        }+        selection.set(survivors);+        selection.anchors.clear();+        selection.segments.clear();+        Ok(())+    })?;+    ok() }  fn clear(s: &mut Session, p: &Value) -> Result<Value> {     let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };     // Direct-selected anchors or segments: remove those, and any objects selected whole with them.-    if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {+    if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) {         return super::path::delete_anchors(s, p);     }     // Selected ruler guides (selected on their own).@@ -630,6 +717,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {     let st = s.doc()?;     // Guides selected with the art go with it (not when ids are given).     let with_guides = explicit.is_none();+    let is_explicit = explicit.is_some();     let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));     // Keep the selected ids themselves, without promoting compound members. Delete a selected     // ancestor just once; its selected descendants leave with it. Layers themselves are kept.@@ -643,12 +731,26 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {         return ok();     }     s.edit("Clear", |d, sel| {+        let parents: Vec<_> =+            ids.iter().filter_map(|id| d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })))).collect();         d.remove_many(&ids)?;+        for p in parents {+            if d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty())) {+                d.remove(p)?;+            }+        }         if with_guides {             let gone = sel.guides.clone();             d.retain_guides(sel, |i, _| !gone.contains(&i));         }-        sel.clear();+        if is_explicit {+            for id in &ids {+                sel.remove(*id);+            }+            sel.prune(d);+        } else {+            sel.clear();+        }         Ok(())     })?;     ok()@@ -677,7 +779,7 @@ fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {     if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) {         return Err(EngineError::NoNode(*id));     }-    let clip = Clipboard::copy(source, &roots_of(&source.doc, ids));+    let clip = Clipboard::copy(source, &roots_of(&source.doc, ids), false);     let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?;     let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]);     if !center.x.is_finite() || !center.y.is_finite() {@@ -748,7 +850,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho     let off = s.prefs.paste_offset;     let st = s.doc()?;     let same_doc = clip.source_doc == Some(st.uid);-    let parent = st.target_parent()?;+    let parent = st.target_parent_for_path()?;     // Copied artboards come back with their art: together right of the last artboard, as they lay,     // or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the     // art.@@ -837,7 +939,45 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho                     }                 };                 *placed.entry(par).or_default() += 1;-                new_ids.push(d.insert(par, idx, c)?);+                let compound_par = par.filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));+                if let Some(par_id) = compound_par {+                    match c.kind {+                        NodeKind::Compound { children, .. } => {+                            let count = children.len();+                            for (offset, child) in children.into_iter().enumerate() {+                                let mut child_node = Arc::unwrap_or_clone(child);+                                let child_id = d.alloc_id();+                                child_node.id = child_id;+                                child_node.appearance = Default::default();+                                if let NodeKind::Path { live, .. } = &mut child_node.kind {+                                    *live = None;+                                }+                                let insert_idx = if idx == usize::MAX { usize::MAX } else { idx.saturating_add(offset) };+                                new_ids.push(d.insert(par, insert_idx, child_node)?);+                            }+                            if count > 1 {+                                *placed.entry(par).or_default() += count - 1;+                            }+                        }+                        NodeKind::Path { .. } => {+                            c.appearance = Default::default();+                            if let NodeKind::Path { live, .. } = &mut c.kind {+                                *live = None;+                            }+                            new_ids.push(d.insert(par, idx, c)?);+                        }+                        _ => {+                            let outer_par = d.parent_of(par_id);+                            let beside_idx = d.position(par_id).map(|(_, i, _)| i + 1).unwrap_or(usize::MAX);+                            let offset = *placed.entry(outer_par).or_default();+                            *placed.entry(outer_par).or_default() += 1;+                            let insert_idx = if beside_idx == usize::MAX { usize::MAX } else { beside_idx.saturating_add(offset) };+                            new_ids.push(d.insert(outer_par, insert_idx, c)?);+                        }+                    }+                } else {+                    new_ids.push(d.insert(par, idx, c)?);+                }             }         }         sel.set(new_ids.iter().copied());diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs--- a/crates/engine/src/cmd/library.rs+++ b/crates/engine/src/cmd/library.rs@@ -776,7 +776,7 @@ fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {     if roots.is_empty() {         return Err(bad(cmd, "select the art to add first"));     }-    let clip = Clipboard::copy(st, &roots);+    let clip = Clipboard::copy(st, &roots, false);     let bounds = clip.bounds().ok_or_else(|| bad(cmd, "the selection draws nothing"))?;     let mut doc = clip.to_document();     let board = bounds.inflate(1.0, 1.0);diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs--- a/crates/engine/src/cmd/panelcmds.rs+++ b/crates/engine/src/cmd/panelcmds.rs@@ -207,7 +207,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val     }     let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));     let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();-    let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };+    let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art, false) };     let copied = clip.nodes.len();     if cut {         s.edit("Cut Artboard", |d, sel| {diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs--- a/crates/engine/src/cmd/path.rs+++ b/crates/engine/src/cmd/path.rs@@ -712,7 +712,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> { }  /// Remove connections, splitting open/closed contours into their surviving open runs.-fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {+pub(super) fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {     let cuts: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect();     let Some(&first) = cuts.first() else { return vec![sp] };     let n = sp.anchors.len();diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs--- a/crates/engine/src/doc_state.rs+++ b/crates/engine/src/doc_state.rs@@ -427,27 +427,66 @@ impl DocState {     pub fn current_layer(&self) -> Option<NodeId> {         self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()))     }+    /// Where new art is inserted: the isolation container (or its parent if the container cannot hold+    /// the art type), else the active layer.     pub fn insertion_parent(&self) -> Option<NodeId> {-        // Ids are reused after undo (the id counter is part of the document), so a remembered-        // layer or isolated group must still be one: a pasted group must never land in a path.-        if let Some(i) = self.isolation-            && self.doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))-        {-            return Some(i);+        self.insertion_parent_for(false)+    }++    /// Where new path art is inserted. When a compound path is isolated, returns the compound path+    /// so new paths become members of the compound path.+    pub fn insertion_parent_for_path(&self) -> Option<NodeId> {+        self.insertion_parent_for(true)+    }++    fn insertion_parent_for(&self, for_path: bool) -> Option<NodeId> {+        if let Some(i) = self.isolation {+            if self.doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) {+                return Some(i);+            }+            if for_path && self.doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {+                return Some(i);+            }         }-        // A single object isolated: new art goes beside it, in its group or layer.-        if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i))-            && self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))-        {-            return Some(p);+        if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i)) {+            if self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) {+                return Some(p);+            }+            if for_path && self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {+                return Some(p);+            }+        }+        // Non-path art created while a compound path is isolated goes into the compound's parent container:+        if let Some(iso) = self.isolation {+            let compound = if self.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {+                Some(iso)+            } else {+                self.doc.parent_of(iso).filter(|p| self.doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })))+            };+            if let Some(c) = compound+                && let Some(p) = self.doc.parent_of(c)+                && self.doc.is_editable(p)+            {+                return Some(p);+            }         }         // A sublayer takes new art only while it and the layers around it are shown and unlocked.         self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())     }+     /// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error     /// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away.     pub fn target_parent(&self) -> Result<Option<NodeId>> {-        match self.insertion_parent() {+        self.target_parent_for(false)+    }++    /// [`Self::insertion_parent_for_path`] for new path art.+    pub fn target_parent_for_path(&self) -> Result<Option<NodeId>> {+        self.target_parent_for(true)+    }++    fn target_parent_for(&self, for_path: bool) -> Result<Option<NodeId>> {+        match self.insertion_parent_for(for_path) {             Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())),             parent => Ok(parent),         }diff --git a/crates/engine/tests/it/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs--- a/crates/engine/tests/it/clear_commands.rs+++ b/crates/engine/tests/it/clear_commands.rs@@ -202,3 +202,24 @@ fn cut_direct_selected_anchors_retains_anchor_deletion() {     assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3);     assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); }++#[test]+fn clear_explicit_ids_preserves_unrelated_selection() {+    let mut s = Session::new();+    s.execute("file.new", &json!({})).unwrap();+    let r1 = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap());+    let r2 = NodeId(s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap());+    s.execute("select.set", &json!({"ids": [r1.0, r2.0]})).unwrap();+    assert_eq!(s.active().unwrap().selection.objects, vec![r1, r2]);++    // Clearing r1 explicitly should remove r1 from doc and from selection, keeping r2 selected.+    s.execute("edit.clear", &json!({"ids": [r1.0]})).unwrap();+    assert!(s.active().unwrap().doc.node(r1).is_none());+    assert!(s.active().unwrap().doc.node(r2).is_some());+    assert_eq!(s.active().unwrap().selection.objects, vec![r2]);++    // Parameterless clear clears the rest of the selection.+    s.execute("edit.clear", &json!({})).unwrap();+    assert!(s.active().unwrap().doc.node(r2).is_none());+    assert!(s.active().unwrap().selection.is_empty());+}diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/copy_direct_selection.rs@@ -0,0 +1,560 @@+//! Direct-selection Copy and Cut tests (#2122).++use serde_json::{Value, json};+use vectorcraft_doc::{NodeId, NodeKind};+use vectorcraft_engine::Session;+use vectorcraft_geom::FillRule;+use vectorcraft_testkit::invariants::check_session;++fn run(s: &mut Session, command: &str, params: Value) -> Value {+    s.execute(command, &params).unwrap()+}++fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {+    NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap())+}++#[test]+fn copy_and_paste_direct_selected_segment_of_rect() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Select segment 0 of the rectangle.+    run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0]]}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    if let NodeKind::Path { live, .. } = &pasted_node.kind {+        assert!(live.is_none(), "extracted partial path must not retain live shape metadata");+    }+    let path = pasted_node.path_data().unwrap();+    assert_eq!(path.subpaths.len(), 1);+    let sp = &path.subpaths[0];+    assert!(!sp.closed);+    assert_eq!(sp.anchors.len(), 2);+}++#[test]+fn copy_and_paste_direct_selected_adjacent_anchors_of_rect() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Direct-select two adjacent anchors: 0 and 1.+    run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    let path = pasted_node.path_data().unwrap();+    assert_eq!(path.subpaths.len(), 1);+    let sp = &path.subpaths[0];+    assert!(!sp.closed);+    assert_eq!(sp.anchors.len(), 2);+}++#[test]+fn copy_single_anchor_of_rect_copies_three_anchors_with_two_segments() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Direct-select only anchor 1 of the closed 4-anchor rectangle.+    run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1]], "mode": "set"}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    let path = pasted_node.path_data().unwrap();+    assert_eq!(path.subpaths.len(), 1);+    let sp = &path.subpaths[0];+    assert!(!sp.closed);+    // Copying a single anchor on a closed path copies the anchor and both its incident segments.+    assert_eq!(sp.anchors.len(), 3);+    assert_eq!(sp.segment_count(), 2);+}++#[test]+fn copy_disjoint_segments_does_not_copy_unselected_segment_in_between() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Rectangle has 4 segments (0, 1, 2, 3). Select disjoint segments 0 and 2.+    run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0], [0, 2]]}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    // Two separate open path pieces should be created, each with 2 anchors.+    assert_eq!(ids.len(), 2);+    for id in ids {+        let node = s.doc().unwrap().doc.node(NodeId(id)).unwrap();+        let path = node.path_data().unwrap();+        assert_eq!(path.subpaths.len(), 1);+        let sp = &path.subpaths[0];+        assert!(!sp.closed);+        assert_eq!(sp.anchors.len(), 2);+    }+}++#[test]+fn cut_direct_selected_segment_cuts_source_and_pastes_segment() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Select segment 0 and cut it.+    run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0]]}));+    run(&mut s, "edit.cut", json!({}));++    // Source rectangle should now have segment 0 removed, turning it into an open path.+    let src_node = s.doc().unwrap().doc.node(r).unwrap();+    let src_path = src_node.path_data().unwrap();+    assert_eq!(src_path.subpaths.len(), 1);+    assert!(!src_path.subpaths[0].closed);++    // Paste the cut segment:+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);+    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    let pasted_path = pasted_node.path_data().unwrap();+    assert_eq!(pasted_path.subpaths.len(), 1);+    assert!(!pasted_path.subpaths[0].closed);+    assert_eq!(pasted_path.subpaths[0].anchors.len(), 2);+}++#[test]+fn copy_compound_path_direct_selected_segment_inherits_appearance() {+    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"}));++    // Select segment 0 of the inner member.+    run(&mut s, "select.segments", json!({"id": inner.0, "segments": [[0, 0]]}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    let compound_node = s.doc().unwrap().doc.node(compound).unwrap();+    assert_eq!(pasted_node.appearance, compound_node.appearance);+}++#[test]+fn direct_selection_copy_preserves_fill_rule_and_attributes() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);+    run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}));++    run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0]]}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    assert!(matches!(pasted_node.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));+}++#[test]+fn pen_tool_drawing_copies_whole_path_not_single_anchor() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    // Start drawing with Pen tool+    s.select_tool("pen", vectorcraft_engine::ViewInfo::default()).unwrap();+    let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());+    run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 10}));+    run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 50}));++    // Pen tool has the last anchor in sel.anchors, but active tool is pen+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    // The whole 3-point path must be copied, not just the last anchor!+    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    let path = pasted_node.path_data().unwrap();+    assert_eq!(path.subpaths[0].anchors.len(), 3);+}++#[test]+fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Direct-select two adjacent anchors: 1 and 2 of the 4-anchor rectangle.+    run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1], [0, 2]], "mode": "set"}));+    run(&mut s, "edit.cut", json!({}));++    // Cut placed 1 segment on the clipboard (anchors 1 to 2).+    assert_eq!(s.clipboard.nodes.len(), 1);+    let clip_path = s.clipboard.nodes[0].path_data().unwrap();+    assert_eq!(clip_path.subpaths[0].segment_count(), 1);+    assert_eq!(clip_path.subpaths[0].anchors.len(), 2);++    // Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1.+    let src_node = s.doc().unwrap().doc.node(r).unwrap();+    let src_path = src_node.path_data().unwrap();+    assert_eq!(src_path.subpaths[0].segment_count(), 3);+    assert_eq!(src_path.subpaths[0].anchors.len(), 4);+    assert!(!src_path.subpaths[0].closed);++    // Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments).+    assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4);+}++#[test]+fn copy_non_adjacent_anchors_on_rect_copies_full_object() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Select non-adjacent anchors 1 and 3 of the 4-anchor rectangle.+    // Incident segments for anchor 1 are segments 0 and 1; for anchor 3 are segments 2 and 3.+    // Together they cover all 4 segments, so the path is copied whole.+    run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1], [0, 3]], "mode": "set"}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    if let NodeKind::Path { live, .. } = &pasted_node.kind {+        assert!(live.is_some(), "copying all segments keeps live shape metadata");+    }+    let path = pasted_node.path_data().unwrap();+    assert!(path.subpaths[0].closed);+    assert_eq!(path.subpaths[0].segment_count(), 4);+}++#[test]+fn copy_non_adjacent_anchors_on_five_point_path_extracts_selected_segments() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());+    run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 10}));+    run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 50}));+    run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 30, "y": 80}));+    run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 10, "y": 50}));+    run(&mut s, "path.close", json!({"id": id.0}));++    // Path has 5 anchors (0..4) and 5 segments (0..4).+    // Select non-adjacent anchors 1 and 3.+    // Anchor 1 gives segments 0, 1. Anchor 3 gives segments 2, 3. Segment 4 is unselected.+    run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 1], [0, 3]], "mode": "set"}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    let path = pasted_node.path_data().unwrap();+    assert!(!path.subpaths[0].closed);+    assert_eq!(path.subpaths[0].segment_count(), 4);+    assert_eq!(path.subpaths[0].anchors.len(), 5);+}++#[test]+fn copy_when_all_segments_selected_preserves_live_shape() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);++    // Select all 4 segments explicitly (like Select > Object > Direction Handles).+    run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0], [0, 1], [0, 2], [0, 3]]}));+    run(&mut s, "edit.copy", json!({}));+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);++    let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();+    if let NodeKind::Path { live, .. } = &pasted_node.kind {+        assert!(live.is_some(), "all segments selected preserves live shape");+    }+}++#[test]+fn compound_member_selected_whole_with_direct_selection_promotes_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());++    let standalone = rect(&mut s, 120.0, 10.0, 50.0, 50.0);++    // Select outer compound member whole, and direct-select anchor 0 on standalone.+    let outer_anchors: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();+    run(+        &mut s,+        "select.anchorsMany",+        json!({+            "items": [+                {"id": outer.0, "anchors": outer_anchors},+                {"id": standalone.0, "anchors": [[0, 0]]}+            ]+        }),+    );++    // outer is selected whole, standalone is partially selected.+    assert!(s.active().unwrap().selection.partial(outer).is_none());+    assert!(s.active().unwrap().selection.partial(standalone).is_some());++    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+    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");+}++#[test]+fn cut_whole_compound_member_with_direct_selection_cuts_compound_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());+    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());++    let standalone = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50}))["id"].as_u64().unwrap());++    let outer_anchors = (0..4).map(|i| vec![0, i]).collect::<Vec<_>>();+    run(+        &mut s,+        "select.anchorsMany",+        json!({+            "items": [+                {"id": outer.0, "anchors": outer_anchors},+                {"id": standalone.0, "anchors": [[0, 0]]}+            ]+        }),+    );++    run(&mut s, "edit.cut", json!({}));+    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");++    // Compound was removed because member was selected whole+    let doc = &s.doc().unwrap().doc;+    assert!(doc.node(compound).is_none(), "compound path must be removed on cut");+    // Standalone has cut pieces remaining+    assert!(doc.node(standalone).is_some(), "partially cut standalone path remains");+}++#[test]+fn cut_direct_selected_only_path_in_layer_preserves_layer() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    // Create a new layer+    let layer_id = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());+    // Create a path inside the layer+    let rect = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap());+    let doc = &s.doc().unwrap().doc;+    assert_eq!(doc.parent_of(rect), Some(layer_id));++    // Direct-select all 4 anchors of rect and cut+    let all_anchors = (0..4).map(|i| vec![0, i]).collect::<Vec<_>>();+    run(&mut s, "select.anchors", json!({"id": rect.0, "anchors": all_anchors, "mode": "set"}));+    run(&mut s, "edit.cut", json!({}));++    // The rect was cut, but the layer must NOT be deleted+    let doc = &s.doc().unwrap().doc;+    assert!(doc.node(rect).is_none(), "rect was cut");+    assert!(doc.node(layer_id).is_some(), "layer must remain even when empty");+    assert_eq!(doc.node(layer_id).unwrap().children().unwrap().len(), 0);+}++#[test]+fn copy_and_paste_path_in_compound_path_isolation_adds_member_to_compound() {+    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());++    // Enter isolation mode on the compound path.+    run(&mut s, "object.isolate", json!({"id": compound.0}));+    assert_eq!(s.doc().unwrap().isolation, Some(compound));++    // In isolation mode, select member `inner` whole.+    run(&mut s, "select.set", json!({"ids": [inner.0]}));+    run(&mut s, "edit.copy", json!({}));++    // Clipboard copies the selected member path, NOT the entire compound path.+    assert_eq!(s.clipboard.nodes.len(), 1);+    assert_eq!(s.clipboard.nodes[0].id, inner);++    // Paste in isolation mode: the pasted path must be inserted into the compound path,+    // not as a new path beside the compound path.+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);+    let pasted_id = NodeId(ids[0]);++    let doc = &s.doc().unwrap().doc;+    assert_eq!(doc.parent_of(pasted_id), Some(compound), "pasted path must be added to the compound path");+    let compound_node = doc.node(compound).unwrap();+    assert_eq!(compound_node.children().unwrap().len(), 3, "compound path now has 3 member paths");+}++#[test]+fn cut_and_paste_direct_selected_anchors_in_compound_path_isolation() {+    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());++    // Enter isolation mode on the compound path.+    run(&mut s, "object.isolate", json!({"id": compound.0}));++    // Direct-select anchor 0 of `inner` and cut.+    run(&mut s, "select.anchors", json!({"id": inner.0, "anchors": [[0, 0]], "mode": "set"}));+    run(&mut s, "edit.cut", json!({}));++    // Clipboard holds the cut segment piece.+    assert_eq!(s.clipboard.nodes.len(), 1);++    // Paste inside isolation mode:+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);+    let pasted_id = NodeId(ids[0]);++    let doc = &s.doc().unwrap().doc;+    assert_eq!(doc.parent_of(pasted_id), Some(compound), "cut and pasted anchors must be inserted into compound path");+    let compound_node = doc.node(compound).unwrap();+    assert_eq!(compound_node.children().unwrap().len(), 3);+    check_session(&s).unwrap();+}++#[test]+fn paste_non_path_in_compound_path_isolation_inserts_into_layer() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let a = rect(&mut s, 10.0, 10.0, 40.0, 40.0);+    let b = rect(&mut s, 60.0, 10.0, 40.0, 40.0);+    run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));+    let _group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());++    // Copy the group to the clipboard.+    run(&mut s, "edit.copy", json!({}));++    // Create a compound path on the layer.+    let c1 = rect(&mut s, 10.0, 100.0, 80.0, 80.0);+    let c2 = rect(&mut s, 30.0, 120.0, 40.0, 40.0);+    run(&mut s, "select.set", json!({"ids": [c1.0, c2.0]}));+    let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());+    let layer_id = s.doc().unwrap().doc.parent_of(compound).unwrap();++    // Isolate the compound path.+    run(&mut s, "object.isolate", json!({"id": compound.0}));+    assert_eq!(s.doc().unwrap().isolation, Some(compound));++    // Generic insertion parent inside compound isolation resolves to the layer, not the compound.+    assert_eq!(s.doc().unwrap().insertion_parent(), Some(layer_id));++    // Paste the group while in compound path isolation.+    // The group cannot be inserted into the compound path; it must be pasted into the layer beside the compound.+    let pasted = run(&mut s, "edit.paste", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 1);+    let pasted_group = NodeId(ids[0]);++    let doc = &s.doc().unwrap().doc;+    assert_eq!(doc.parent_of(pasted_group), Some(layer_id), "pasted group must be added to layer, not compound");+    let compound_node = doc.node(compound).unwrap();+    assert_eq!(compound_node.children().unwrap().len(), 2, "compound path must keep only its 2 path children");++    // All document invariants (including compound children being paths only) must hold.+    check_session(&s).unwrap();+}++#[test]+fn shape_creation_in_compound_path_isolation_inserts_into_layer() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 200, "height": 200}));+    let outer = rect(&mut s, 10.0, 10.0, 80.0, 80.0);+    let inner = rect(&mut s, 30.0, 30.0, 40.0, 40.0);+    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());++    // Isolate the compound path.+    run(&mut s, "object.isolate", json!({"id": compound.0}));++    // Draw a new rectangle while isolating the compound path.+    let new_rect = rect(&mut s, 40.0, 40.0, 20.0, 20.0);++    let doc = &s.doc().unwrap().doc;+    assert_ne!(doc.parent_of(new_rect), Some(compound), "new shape path is placed outside compound path in layer");+    let compound_node = doc.node(compound).unwrap();+    assert_eq!(compound_node.children().unwrap().len(), 2, "compound path maintains original member paths");++    check_session(&s).unwrap();+}++#[test]+fn paste_compound_into_isolated_compound_preserves_child_order() {+    let mut s = Session::new();+    run(&mut s, "file.new", json!({"width": 300, "height": 300}));+    let r1 = rect(&mut s, 10.0, 10.0, 10.0, 10.0);+    let r2 = rect(&mut s, 30.0, 10.0, 10.0, 10.0);+    let r3 = rect(&mut s, 50.0, 10.0, 10.0, 10.0);+    run(&mut s, "select.set", json!({"ids": [r1.0, r2.0, r3.0]}));+    let src_compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());++    // Copy src_compound (contains [r1, r2, r3] in order)+    run(&mut s, "select.set", json!({"ids": [src_compound.0]}));+    run(&mut s, "edit.copy", json!({}));++    // Create target compound+    let t1 = rect(&mut s, 100.0, 100.0, 80.0, 80.0);+    let t2 = rect(&mut s, 120.0, 120.0, 40.0, 40.0);+    run(&mut s, "select.set", json!({"ids": [t1.0, t2.0]}));+    let target_compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());++    // Isolate target compound and select member t1 so paste-in-front targets index 1+    run(&mut s, "object.isolate", json!({"id": target_compound.0}));+    run(&mut s, "select.set", json!({"ids": [t1.0]}));++    // Paste in front of t1+    let pasted = run(&mut s, "edit.pasteInFront", json!({}));+    let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();+    assert_eq!(ids.len(), 3, "unpacked all 3 children");++    let doc = &s.doc().unwrap().doc;+    let target_node = doc.node(target_compound).unwrap();+    let children = target_node.children().unwrap();+    assert_eq!(children.len(), 5);++    // Verify the 3 pasted children appear in children in exact forward order [r1_copy, r2_copy, r3_copy]+    let child_ids: Vec<u64> = children.iter().map(|c| c.id.0).collect();+    let pos0 = child_ids.iter().position(|&id| id == ids[0]).unwrap();+    let pos1 = child_ids.iter().position(|&id| id == ids[1]).unwrap();+    let pos2 = child_ids.iter().position(|&id| id == ids[2]).unwrap();+    assert_eq!(pos1, pos0 + 1, "child 1 must immediately follow child 0");+    assert_eq!(pos2, pos1 + 1, "child 2 must immediately follow child 1");++    check_session(&s).unwrap();+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -12,6 +12,7 @@ mod affinity_local; mod artboard_click; mod clear_commands; mod compound_attributes;+mod copy_direct_selection; mod dimensions; mod eyedropper_sources; mod find_replace_paras;