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

Squashed from #3903 onto main.
This commit is contained in:
el-j
2026-11-05 19:40:58 +07:00
committed by bflatastic
parent 08ff029ac2
commit 02a8928f8e
8 changed files with 1168 additions and 48 deletions
+131 -7
View File
@@ -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<vectorcraft_doc::Guide>,
}
/// 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() && 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();
+156 -27
View File
@@ -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<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), |_, _| 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<Vec<NodeId>> {
pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result<Vec<NodeId>> {
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<NodeId>) -> Vec<NodeId> {
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<NodeId>, members: impl Fn(NodeId) -> bool) -> Vec<NodeId> {
/// [`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<NodeId>, keep_member: impl Fn(NodeId, NodeId) -> bool) -> 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).
@@ -658,7 +658,7 @@ fn roots_within(doc: &Document, ids: Vec<NodeId>, 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<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 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<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 (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<Value> {
.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<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.
// 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<Value> {
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<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).
@@ -756,6 +830,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
// 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<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()
@@ -803,7 +892,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() {
@@ -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,
}
}
+1 -1
View File
@@ -942,7 +942,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);
+1 -1
View File
@@ -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| {