diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d28bd0e6c9..446634d21c 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -143,17 +143,13 @@ pub(crate) fn subpath_selected_segments( let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty()); 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); - } - } + selected_segs.extend(super::path::subpath_refs(segments, si).filter(|&seg| seg < seg_count)); } 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(); + let sub_anchors: BTreeSet = super::path::subpath_refs(anchors, si).filter(|&a| a < n).collect(); if !sub_anchors.is_empty() { for i in 0..seg_count { let next = (i + 1) % n; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f3befb3462..474a87e457 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1086,7 +1086,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { subpaths.extend(super::path::without_segments(sp, &cuts)); } else { // Picked anchors leave the path with their connections, as Clear removes them. - let gone: BTreeSet = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect(); + let gone: BTreeSet = anchor_refs.into_iter().flat_map(|refs| super::path::subpath_refs(refs, si)).collect(); subpaths.extend(super::path::without_anchors(sp, &gone)); } } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b4639f0de5..ed5aaac79c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1276,7 +1276,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .into_iter() .enumerate() .flat_map(|(si, sp)| { - let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); + let cuts = subpath_refs(refs, si).collect(); without_segments(sp, &cuts) }) .filter(|s| s.anchors.len() >= 2) @@ -1288,7 +1288,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .into_iter() .enumerate() .flat_map(|(si, sp)| { - let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); + let gone = subpath_refs(set, si).collect(); without_anchors(sp, &gone) }) .filter(|s| s.anchors.len() >= 2) @@ -1314,6 +1314,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { ok() } +/// The anchor or segment indexes in `refs` that belong to subpath `si`, in index order. +/// `refs` is ordered by subpath first, so this reads only that subpath's entries. +pub(super) fn subpath_refs(refs: &BTreeSet<(usize, usize)>, si: usize) -> impl Iterator + '_ { + refs.range((si, 0)..=(si, usize::MAX)).map(|&(_, i)| i) +} + /// Retain path `id` from `d` without copying its geometry before deletion takes its subpaths. /// The shared snapshot supplies the original path classification and, only if needed, gradient /// bounds after the working node's geometry has moved. Returns `NoNode` for a missing/non-path ID. @@ -1722,7 +1728,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result { let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue }; let (mut subs, mut starts) = (vec![], vec![]); for (si, sp) in pd.subpaths.iter().enumerate() { - let at: BTreeSet = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect(); + let at: BTreeSet = subpath_refs(set, si).collect(); let pieces = sp.cut_at(&at); // The pieces that start at a cut: every piece of an opened closed subpath, all but the // first of an open one. diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs index 0d8b7171f7..afc304e99f 100644 --- a/crates/engine/tests/it/delete_path_pieces.rs +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -2,7 +2,7 @@ use serde_json::json; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, NodeId, NodeKind, selection::Selection}; use vectorcraft_engine::Session; use vectorcraft_geom::{Affine, Anchor, PathData, Rect, SubPath}; use vectorcraft_testkit::{invariants::check_session, raster::render_artboard}; @@ -226,6 +226,151 @@ fn deletion_split_preserves_gradient_placement() { } } +/// Subpath `i` of a multi-subpath test path: four cubic anchors on their own row, so every piece +/// that survives a deletion can be matched exactly. +fn row(i: usize, closed: bool) -> SubPath { + let y = 10.0 + 3.0 * i as f64; + let anchors = (0..4) + .map(|k| { + let mut a = Anchor::corner((10.0 + 20.0 * f64::from(k), y).into()); + a.h_in = a.p + (-3.0, -1.0); + a.h_out = a.p + (3.0, 1.0); + a + }) + .collect(); + SubPath::new(anchors, closed) +} + +/// An open piece made of `indexes` of `sp`, its unused end handles retracted as deletion leaves them. +fn piece(sp: &SubPath, indexes: &[usize]) -> SubPath { + let mut run: Vec = indexes.iter().map(|&i| sp.anchors[i]).collect(); + if let Some(first) = run.first_mut() { + first.h_in = first.p; + } + if let Some(last) = run.last_mut() { + last.h_out = last.p; + } + SubPath::new(run, false) +} + +/// Put `subpaths` into a new path object and return its ID. +fn multi_subpath_path(s: &mut Session, subpaths: Vec) -> NodeId { + let id = rectangle(s); + *std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap() = PathData::new(subpaths); + id +} + +/// Run `command` on the current selection, check the path's surviving subpaths and that one Undo +/// restores the document and `before_selection` and one Redo repeats the edit, then check that a +/// second Clear removes the survivors. +fn delete_and_check(s: &mut Session, command: &str, id: NodeId, expected: &[SubPath], before_selection: &Selection) { + let before_doc = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let history = s.doc().unwrap().history.undo.len(); + s.execute(command, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths, expected, "{command}"); + assert_eq!(st.selection.objects, [id], "{command}: a multi-subpath path keeps its object"); + assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty()); + assert_eq!(st.history.undo.len(), history + 1); + let after_doc = serde_json::to_value(&st.doc).unwrap(); + let after_selection = st.selection.clone(); + check_session(s).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before_doc); + assert_eq!(&s.doc().unwrap().selection, before_selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), after_doc); + assert_eq!(s.doc().unwrap().selection, after_selection); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none(), "{command}: the second Clear removes the survivors"); +} + +/// Selected anchors on many subpaths each remove only their own subpath's anchors; anchors named on +/// a subpath or index the path does not have remove nothing. +#[test] +fn deleting_anchors_on_many_subpaths_removes_each_subpaths_own_anchors() { + for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [1, 2], [2, 0], [2, 9], [4, 3], [5, 1], [7, 1]]})).unwrap(); + // Subpath 3 has no selected anchor and stays closed and unchanged. + let expected = [ + piece(&subpaths[0], &[2, 3]), + piece(&subpaths[1], &[3, 0, 1]), + piece(&subpaths[2], &[1, 2, 3]), + subpaths[3].clone(), + piece(&subpaths[4], &[0, 1, 2]), + piece(&subpaths[5], &[2, 3, 0]), + ]; + let before = s.doc().unwrap().selection.clone(); + delete_and_check(&mut s, command, id, &expected, &before); + } +} + +/// Selected segments on many subpaths each cut only their own subpath; a segment reference to a +/// subpath or segment the path does not have cuts nothing. +#[test] +fn deleting_segments_on_many_subpaths_cuts_each_subpaths_own_segments() { + for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [1, 3], [4, 0], [5, 1]]})).unwrap(); + // Undo prunes the restored selection (`Selection::prune`), which drops references to + // segments the path does not have, so it restores the selection as it is here. + let before = s.doc().unwrap().selection.clone(); + // `select.segments` refuses references to missing segments, so these are added directly. + let refs = s.doc_mut().unwrap().selection.segments.get_mut(&id).unwrap(); + refs.extend([(2, 9), (7, 0)]); + let expected = [ + piece(&subpaths[0], &[0, 1]), + piece(&subpaths[0], &[2, 3]), + piece(&subpaths[1], &[0, 1, 2, 3]), + subpaths[2].clone(), + subpaths[3].clone(), + piece(&subpaths[4], &[1, 2, 3]), + piece(&subpaths[5], &[2, 3, 0, 1]), + ]; + delete_and_check(&mut s, command, id, &expected, &before); + } +} + +/// One selected interior anchor on each of many subpaths removes exactly that anchor from each, +/// keeping the subpaths in their original order. +#[test] +fn deleting_one_anchor_on_each_of_many_subpaths_keeps_subpath_order() { + let mut s = session(); + let subpaths: Vec = (0..400).map(|i| row(i, false)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + let refs: Vec<[usize; 2]> = (0..400).filter(|i| i % 7 != 3).map(|i| [i, 1]).collect(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": refs})).unwrap(); + let expected: Vec = subpaths.iter().enumerate().map(|(i, sp)| if i % 7 == 3 { sp.clone() } else { piece(sp, &[2, 3]) }).collect(); + let before = s.doc().unwrap().selection.clone(); + delete_and_check(&mut s, "edit.clear", id, &expected, &before); +} + +/// Cut Path cuts each subpath of a multi-subpath path only at that subpath's selected anchors. +#[test] +fn cutting_at_anchors_on_many_subpaths_cuts_each_subpath_at_its_own_anchors() { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + let refs = [(0, 1), (1, 2), (2, 9), (4, 2), (5, 0), (5, 2), (7, 1)]; + s.execute("select.anchors", &json!({"id": id.0, "anchors": refs.map(|(a, b)| [a, b])})).unwrap(); + s.execute("path.cutAtAnchors", &json!({})).unwrap(); + let expected: Vec = subpaths + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.cut_at(&refs.iter().filter(|r| r.0 == si).map(|r| r.1).collect::>())) + .collect(); + // Subpaths 0 and 4 become two pieces each, closed subpath 1 opens into one, closed subpath 5 + // becomes two, and subpaths 2 and 3 stay as they were. + assert_eq!(expected.len(), 9); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths, expected); + check_session(&s).unwrap(); +} + /// A connected remainder keeps automatic fitting; pinning is needed only for separate objects. #[test] fn deleting_one_segment_keeps_unplaced_gradients() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..094697b277 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -569,6 +569,7 @@ since 2026-10-01. - **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged. +- **Selection: deletion on paths with many subpaths:** Clear/Delete, `path.deleteAnchors`, Copy, Cut and Cut Path read each subpath's selected anchors or segments directly from the path's ordered selection, instead of scanning every selected anchor or segment of the path once per subpath. The result is unchanged: `crates/engine/tests/it/delete_path_pieces.rs` checks exact surviving subpaths and their order, open and closed contours, an untouched subpath, references to missing subpaths and indexes, one Undo/Redo step and a second Delete, for anchor and segment selections on one path. Scores and hours unchanged. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo.