From 02a8928f8ea2cf61b901da0b35a3f3400ee83f44 Mon Sep 17 00:00:00 2001 From: el-j Date: Thu, 5 Nov 2026 19:33:45 +0700 Subject: [PATCH] M2.1: copy and cut direct-selected segments and anchors as independent paths (#2122) Squashed from #3903 onto main. --- crates/engine/src/cmd/clipboard.rs | 138 ++- crates/engine/src/cmd/edit.rs | 183 +++- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/doc_state.rs | 63 +- crates/engine/tests/it/clear_commands.rs | 21 + .../engine/tests/it/copy_direct_selection.rs | 806 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 8 files changed, 1168 insertions(+), 48 deletions(-) create mode 100644 crates/engine/tests/it/copy_direct_selection.rs diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 13186ed2e..aa8f3b7df 100644 --- 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 use flavours::{ImageRead, read_image}; @@ -98,6 +98,114 @@ pub struct Clipboard { pub guides: Vec, } +/// 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 { + 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 = 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> { + 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 = (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() && self.guides.is_empty() @@ -118,15 +226,31 @@ impl Clipboard { } /// 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 index e022998fa..5b68b28d8 100644 --- 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; @@ -626,7 +626,7 @@ pub(crate) fn selected_roots(s: &Session) -> Result> { /// rest still go to the compound path, which holds the paint. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { 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), |_, _| true)) } /// [`selected_roots`] for the selected objects inside the isolated object `root` @@ -637,18 +637,18 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { let st = s.doc()?; let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); - Ok(roots_within(&st.doc, ids, |compound| members || compound == root)) + Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root)) } /// 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) -> Vec { - roots_within(doc, ids, |_| false) + roots_within(doc, ids, |_, _| false) } -/// [`roots_of`]; a compound path's member listed stays itself when `members` holds for the -/// compound's id. -fn roots_within(doc: &Document, ids: Vec, members: impl Fn(NodeId) -> bool) -> Vec { +/// [`roots_of`]; a compound path's member listed stays itself when `keep_member` holds for the +/// compound's id or the member's id. +fn roots_within(doc: &Document, ids: Vec, keep_member: impl Fn(NodeId, NodeId) -> bool) -> Vec { // 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). @@ -658,7 +658,7 @@ fn roots_within(doc: &Document, ids: Vec, members: impl Fn(NodeId) -> bo let path = paths.get(&id); let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty()); 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) => (n.id, Some(up.to_vec())), + Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !keep_member(n.id, id) => (n.id, Some(up.to_vec())), _ => (id, path.cloned()), } }) @@ -689,11 +689,24 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i }) } +/// The direct-selection flag and resolved root nodes for Copy and Cut operations. +fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { + 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 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))) + }); + Ok((direct, roots)) +} + fn copy(s: &mut Session, _: &Value) -> Result { // 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 (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; // Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay // behind. @@ -704,9 +717,13 @@ fn copy(s: &mut Session, _: &Value) -> Result { .filter_map(|i| st.doc.guides.get(*i)) .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() }) .collect(); - let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { selected_roots(s)? }; - let st = s.doc()?; - let mut clip = Clipboard::copy(st, &roots); + let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { targets }; + let mut clip = Clipboard::copy(st, &roots, direct); + // Direct-selected anchors or segments that make no segment: nothing to copy, so the + // clipboard keeps what it held. + if direct && clip.nodes.is_empty() { + return Ok(json!({ "copied": 0 })); + } // Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes // measure from, as the objects' artboard is. if clip.nodes.is_empty() @@ -729,23 +746,80 @@ fn cut(s: &mut Session, p: &Value) -> Result { 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. // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { copy(s, p)?; return clear(s, &json!({})); } - let ids = selected_roots(s)?; - copy(s, p)?; + // 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 (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); + } // The selected ruler guides it copied go with the objects, in the same undo step (#3226). - if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids, "guides": true })) } else { clear(s, &json!({ "guides": true })) } + if !direct { clear(s, &json!({ "ids": roots, "guides": true })) } else { cut_direct_selection(s, &roots) } +} + +fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { + let st = s.doc()?; + let sel = st.selection.clone(); + s.edit("Cut", |d, selection| { + // The selected ruler guides were copied too (#3226): they go in the same undo step. + let gone = selection.guides.clone(); + d.retain_guides(selection, |i, _| !gone.contains(&i)); + 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 { 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). @@ -756,6 +830,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { // Guides selected with the art go with it (not when ids are given, unless `guides` says so: // Cut, which names the objects it copied). let with_guides = explicit.is_none() || bool_or(p, "guides", false); + 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. @@ -769,12 +844,26 @@ fn clear(s: &mut Session, p: &Value) -> Result { 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() @@ -803,7 +892,7 @@ fn drop_selection(s: &mut Session, p: &Value) -> Result { 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() { @@ -874,7 +963,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. @@ -907,8 +996,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let anchor = if front || back { let order = st.selection.in_paint_order(&st.doc); let a = if front { order.last() } else { order.first() }; - 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)) + let isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); + let can_enter_compound = clip.nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })) + || (isolated_compound && clip.nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); + a.map(|id| paste_anchor(&st.doc, *id, can_enter_compound)).and_then(|id| st.doc.position(id)) } else { None }; @@ -975,7 +1066,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()); @@ -1016,11 +1145,11 @@ 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 { +/// What pasted art goes beside: `id`, or the compound path holding it when the art cannot enter +/// the compound path (a compound path holds paths only; compound paths unpack into isolated compounds). +fn paste_anchor(doc: &Document, id: NodeId, can_enter_compound: 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, + Some(p) if !can_enter_compound && doc.node(p).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. })) => p, _ => id, } } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index ff4e3d0fb..1f8370d5d 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -942,7 +942,7 @@ fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result { 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 index 880b2838d..ea81bf26e 100644 --- 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 = 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/doc_state.rs b/crates/engine/src/doc_state.rs index 34592575c..3d1c4be89 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -452,27 +452,66 @@ impl DocState { pub fn current_layer(&self) -> Option { 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 { - // 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 { + self.insertion_parent_for(true) + } + + fn insertion_parent_for(&self, for_path: bool) -> Option { + 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> { - 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> { + self.target_parent_for(true) + } + + fn target_parent_for(&self, for_path: bool) -> Result> { + 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 index 50a6fbd86..3b7fb79e9 100644 --- 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.rs new file mode 100644 index 000000000..d34b34293 --- /dev/null +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -0,0 +1,806 @@ +//! 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, ¶ms).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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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> = (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 = 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::>(); + 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 = 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::>(); + 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 = 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 = 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 = 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 = 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 = 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(); +} + +#[test] +fn copy_and_paste_direct_selected_segment_of_open_path() { + 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": 20}]}))["id"].as_u64().unwrap()); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "paint.setStroke", json!({"color": "#0000ff"})); + run(&mut s, "stroke.set", json!({"weight": 4.0})); + + // Select segment 0 of the open path (from (10, 20) to (60, 20)). + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[0, 0]]})); + run(&mut s, "edit.copy", json!({})); + + // Paste in place: position and appearance must be preserved. + let pasted = run(&mut s, "edit.pasteInPlace", json!({})); + let ids: Vec = 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 src_node = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!(pasted_node.appearance, src_node.appearance, "appearance (stroke & fill) must match source"); + + let path = pasted_node.path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + let sp = &path.subpaths[0]; + assert!(!sp.closed); + assert_eq!(sp.segment_count(), 1); + assert_eq!(sp.anchors.len(), 2); + assert_eq!(sp.anchors[0].p.x, 10.0); + assert_eq!(sp.anchors[0].p.y, 20.0); + assert_eq!(sp.anchors[1].p.x, 60.0); + assert_eq!(sp.anchors[1].p.y, 20.0); + + check_session(&s).unwrap(); +} + +#[test] +fn copy_two_non_adjacent_segments_of_open_path_pastes_two_paths() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 300, "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": 40, "y": 10})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 80, "y": 10})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 120, "y": 10})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 160, "y": 10})); + + // Path has 5 anchors (0..4) and 4 segments (0..3). + // Select disjoint/non-adjacent segments 0 and 2. + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[0, 0], [0, 2]]})); + run(&mut s, "edit.copy", json!({})); + let pasted = run(&mut s, "edit.paste", json!({})); + let ids: Vec = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 2, "two non-adjacent segments paste as two independent paths"); + + for node_id in ids { + let node = s.doc().unwrap().doc.node(NodeId(node_id)).unwrap(); + let path = node.path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + assert!(!path.subpaths[0].closed); + assert_eq!(path.subpaths[0].segment_count(), 1); + assert_eq!(path.subpaths[0].anchors.len(), 2); + } + + check_session(&s).unwrap(); +} + +#[test] +fn copy_all_anchors_of_open_path_copies_whole_path() { + 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": 80, "y": 40})); + + // Select all 3 anchors. + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"})); + run(&mut s, "edit.copy", json!({})); + let pasted = run(&mut s, "edit.paste", json!({})); + let ids: Vec = 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); + assert!(!path.subpaths[0].closed); + assert_eq!(path.subpaths[0].segment_count(), 2); + assert_eq!(path.subpaths[0].anchors.len(), 3); + + check_session(&s).unwrap(); +} + +#[test] +fn copy_single_anchor_on_open_path_and_lone_point_states_what_is_pasted() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + + // 1. Single interior anchor of an open path: + // Path: (10, 10) -> (50, 10) -> (90, 50). + // Selecting interior anchor 1 selects both incident segments (0 and 1). + // Stated behavior: pasting pastes an open path containing the anchor and both its incident segments (3 anchors, 2 segments). + let p1 = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap()); + run(&mut s, "path.appendAnchor", json!({"id": p1.0, "x": 50, "y": 10})); + run(&mut s, "path.appendAnchor", json!({"id": p1.0, "x": 90, "y": 50})); + run(&mut s, "select.anchors", json!({"id": p1.0, "anchors": [[0, 1]], "mode": "set"})); + run(&mut s, "edit.copy", json!({})); + let pasted1 = run(&mut s, "edit.paste", json!({})); + let ids1: Vec = pasted1["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids1.len(), 1); + let path1 = s.doc().unwrap().doc.node(NodeId(ids1[0])).unwrap().path_data().unwrap(); + assert!(!path1.subpaths[0].closed); + assert_eq!(path1.subpaths[0].anchors.len(), 3); + assert_eq!(path1.subpaths[0].segment_count(), 2); + + // 2. Single endpoint anchor of an open path: + // Selecting endpoint anchor 0 selects its single incident segment (segment 0). + // Stated behavior: pasting pastes an open path for that segment (2 anchors, 1 segment). + run(&mut s, "select.anchors", json!({"id": p1.0, "anchors": [[0, 0]], "mode": "set"})); + run(&mut s, "edit.copy", json!({})); + let pasted2 = run(&mut s, "edit.paste", json!({})); + let ids2: Vec = pasted2["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids2.len(), 1); + let path2 = s.doc().unwrap().doc.node(NodeId(ids2[0])).unwrap().path_data().unwrap(); + assert!(!path2.subpaths[0].closed); + assert_eq!(path2.subpaths[0].anchors.len(), 2); + assert_eq!(path2.subpaths[0].segment_count(), 1); + + // 3. A lone point path (a path with only 1 anchor and 0 segments): + // Selecting the lone anchor on a single-point path copies the object whole. + // Stated behavior: pasting pastes a standalone lone-point path (1 anchor, 0 segments). + let p_lone = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 100, "y": 100}]}))["id"].as_u64().unwrap()); + run(&mut s, "select.anchors", json!({"id": p_lone.0, "anchors": [[0, 0]], "mode": "set"})); + run(&mut s, "edit.copy", json!({})); + let pasted_lone = run(&mut s, "edit.paste", json!({})); + let ids_lone: Vec = pasted_lone["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids_lone.len(), 1); + let path_lone = s.doc().unwrap().doc.node(NodeId(ids_lone[0])).unwrap().path_data().unwrap(); + assert_eq!(path_lone.subpaths[0].anchors.len(), 1); + assert_eq!(path_lone.subpaths[0].segment_count(), 0); + + check_session(&s).unwrap(); +} + +#[test] +fn cut_anchor_of_closed_path_opens_it_with_single_undo_step() { + 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 anchor 1 of the closed 4-anchor rectangle. + run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1]], "mode": "set"})); + let undo_before = s.doc().unwrap().history.undo.len(); + + // Cut the anchor. + run(&mut s, "edit.cut", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "cut is recorded as one undo step"); + + // Source rectangle opens: 1 open subpath with 3 anchors remaining. + 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, "cutting anchor of closed path opens the path"); + assert_eq!(src_path.subpaths[0].anchors.len(), 3); + assert_eq!(src_path.subpaths[0].segment_count(), 2); + + // Clipboard holds the cut portion (the anchor and its two incident segments: 3 anchors). + assert_eq!(s.clipboard.nodes.len(), 1); + let clip_path = s.clipboard.nodes[0].path_data().unwrap(); + assert!(!clip_path.subpaths[0].closed); + assert_eq!(clip_path.subpaths[0].anchors.len(), 3); + assert_eq!(clip_path.subpaths[0].segment_count(), 2); + + // Verify undo restores the closed path in one step. + run(&mut s, "edit.undo", json!({})); + let restored_node = s.doc().unwrap().doc.node(r).unwrap(); + let restored_path = restored_node.path_data().unwrap(); + assert_eq!(restored_path.subpaths.len(), 1); + assert!(restored_path.subpaths[0].closed, "undo restores closed path"); + assert_eq!(restored_path.subpaths[0].anchors.len(), 4); + assert_eq!(restored_path.subpaths[0].segment_count(), 4); + + // Verify redo re-applies the cut. + run(&mut s, "edit.redo", json!({})); + let redone_node = s.doc().unwrap().doc.node(r).unwrap(); + let redone_path = redone_node.path_data().unwrap(); + assert!(!redone_path.subpaths[0].closed); + assert_eq!(redone_path.subpaths[0].anchors.len(), 3); + + check_session(&s).unwrap(); +} + +#[test] +fn cut_middle_segment_of_open_path_splits_into_two_open_paths_with_single_undo_step() { + 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": 90, "y": 10})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 130, "y": 10})); + + // Open path has 4 anchors (0, 1, 2, 3) and 3 segments (0, 1, 2). + // Direct-select middle segment 1 (between anchor 1 and 2). + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[0, 1]]})); + let undo_before = s.doc().unwrap().history.undo.len(); + + // Cut the middle segment. + run(&mut s, "edit.cut", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "cut is recorded as one undo step"); + + // Source path should now be split into two surviving open runs. + let src_node = s.doc().unwrap().doc.node(id).unwrap(); + let src_path = src_node.path_data().unwrap(); + assert_eq!(src_path.subpaths.len(), 2, "two open paths/subpaths remain after cutting middle segment"); + assert!(!src_path.subpaths[0].closed); + assert_eq!(src_path.subpaths[0].anchors.len(), 2); + assert_eq!(src_path.subpaths[0].anchors[0].p.x, 10.0); + assert_eq!(src_path.subpaths[0].anchors[1].p.x, 50.0); + + assert!(!src_path.subpaths[1].closed); + assert_eq!(src_path.subpaths[1].anchors.len(), 2); + assert_eq!(src_path.subpaths[1].anchors[0].p.x, 90.0); + assert_eq!(src_path.subpaths[1].anchors[1].p.x, 130.0); + + // Clipboard holds the cut middle segment: + assert_eq!(s.clipboard.nodes.len(), 1); + let clip_path = s.clipboard.nodes[0].path_data().unwrap(); + assert_eq!(clip_path.subpaths[0].anchors.len(), 2); + assert_eq!(clip_path.subpaths[0].anchors[0].p.x, 50.0); + assert_eq!(clip_path.subpaths[0].anchors[1].p.x, 90.0); + + // Verify undo restores the original intact path in one step: + run(&mut s, "edit.undo", json!({})); + let restored_node = s.doc().unwrap().doc.node(id).unwrap(); + let restored_path = restored_node.path_data().unwrap(); + assert_eq!(restored_path.subpaths.len(), 1, "undo restores single subpath"); + assert_eq!(restored_path.subpaths[0].anchors.len(), 4, "undo restores all 4 anchors"); + + // Redo re-applies the split: + run(&mut s, "edit.redo", json!({})); + let redone_path = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap(); + assert_eq!(redone_path.subpaths.len(), 2); + + check_session(&s).unwrap(); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 5b9dc39c6..f17d27e4d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -13,6 +13,7 @@ mod artboard_click; mod beyond_canvas; mod clear_commands; mod compound_attributes; +mod copy_direct_selection; mod dimensions; mod eyedropper_sources; mod find_replace_paras;