From cb086ef1b7d3417c174ffb1b1ca3e232d8cbb559 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:03:43 -0800 Subject: [PATCH 1/3] Part of #6361: Cut takes direct-selected anchors out of the path With anchors picked with Direct Selection, Edit > Cut removed only the segments between them, so the picked anchors stayed in the path. Cut now removes picked anchors and the segments attached to them from a path or a compound path's member, as Clear does. Picked segments still lose only those segments. When a path has picked segments, Copy and Cut leave out anchors picked on its other contours, as Clear does. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/clipboard.rs | 3 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/path.rs | 2 +- crates/engine/src/tests/draw2.rs | 12 +-- .../engine/tests/it/copy_direct_selection.rs | 90 +++++++++++++++++-- docs/target-app-parity.md | 1 + 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 6d1e7284c..92d01bb7e 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments( 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)); + // Segments picked anywhere on the path leave anchors out of it, as Clear does. + 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 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5809835e3..80574d2d5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1063,8 +1063,12 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs); if cuts.is_empty() { subpaths.push(sp); - } else { + } else if seg_refs.is_some_and(|segments| !segments.is_empty()) { 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(); + subpaths.extend(super::path::without_anchors(sp, &gone)); } } subpaths.retain(|sp| sp.anchors.len() >= 2); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index cb82cfe3f..5d7f93581 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1329,7 +1329,7 @@ pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, orig /// Remove anchors and their incident segments, keeping each surviving stretch as an open path. /// Only a closed path can have a surviving stretch that wraps past the last anchor. -fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { let n = sp.anchors.len(); let gone: BTreeSet = removed.iter().copied().filter(|&i| i < n).collect(); if gone.is_empty() { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 7a05a5896..c0946a465 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2698,19 +2698,15 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s), [2]); - // Cut takes the segment between them. The two surviving runs of an ordinary path - // become independent objects, and this path keeps the first run (#3380). + // Cut takes the two anchors out of the path, as Clear does (#6361): each side keeps one + // anchor, which makes no path, so nothing is left. s.execute("edit.undo", &json!({})).unwrap(); let original = path(&s, id); let picked = s.doc().unwrap().selection.clone(); s.execute("edit.cut", &json!({})).unwrap(); let left = paths(&s); - assert_eq!(left.len(), 2); - assert_eq!(left[0].0, id); - assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed)); - assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]); - assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]); - assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::>()); + assert!(left.is_empty()); + assert!(s.doc().unwrap().selection.objects.is_empty()); assert_eq!(s.clipboard.nodes.len(), 1); assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]); s.execute("edit.undo", &json!({})).unwrap(); diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index 957d77829..c2713befe 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -192,7 +192,7 @@ fn pen_tool_drawing_copies_whole_path_not_single_anchor() { } #[test] -fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { +fn cut_adjacent_anchors_takes_them_out_of_the_source() { 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); @@ -207,15 +207,93 @@ fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { 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. + // The source keeps anchors 3 and 0 and the one segment between them. 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_eq!(src_path.subpaths[0].segment_count(), 1); + assert_eq!(src_path.subpaths[0].anchors.len(), 2); 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); +/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor +/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path. +fn contours_after(command: &str, picked: Value, compound: bool) -> Vec> { + 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, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80})); + if compound { + let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [id.0, other.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + } + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + contours(&s) +} + +/// Every contour in the document, as `[x, y]` lists. +fn contours(s: &Session) -> Vec> { + let mut left = vec![]; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |n| { + if let Some(path) = n.path_data() { + left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>())); + } + }); + } + left +} + +#[test] +fn cut_end_anchors_leaves_the_source_as_clear_does() { + for compound in [false, true] { + let picked = || json!([[0, 0], [0, 1]]); + let left = contours_after("edit.cut", picked(), compound); + assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}"); + assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}"); + } +} + +#[test] +fn cut_interior_anchors_leaves_the_source_as_clear_does() { + let picked = || json!([[0, 1], [0, 2]]); + let left = contours_after("edit.cut", picked(), true); + assert_eq!(left, contours_after("edit.clear", picked(), true)); + assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}"); +} + +#[test] +fn cut_one_end_anchor_of_a_compound_member_shortens_it() { + let left = contours_after("edit.cut", json!([[0, 3]]), true); + assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]); +} + +#[test] +fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() { + let after = |command: &str| { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 400})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false}); + run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]})); + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"})); + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + s + }; + let cut = after("edit.cut"); + assert_eq!(contours(&cut), contours(&after("edit.clear"))); + assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut)); + // The clipboard holds what left the source: the picked segment alone. + assert_eq!(cut.clipboard.nodes.len(), 1); + let clip = cut.clipboard.nodes[0].path_data().unwrap(); + assert_eq!(clip.subpaths.len(), 1); + assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>(), [[60.0, 120.0], [110.0, 120.0]]); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fb59612c6..e55f41eaf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -578,6 +578,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. Regression tests: `cut_*_anchors_*` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From 965471d0da3ad01b2db9ba2f9cc423dfe723fe72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:57:48 -0800 Subject: [PATCH 2/3] Docs: Cut's progress line states the segment rule for mixed selections --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e09a51774..8fb376813 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,7 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. -- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. Regression tests: `cut_*_anchors_*` in `crates/engine/tests/it/copy_direct_selection.rs`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and leaves its picked anchors in place, on every contour, as Clear does. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From 1847f40fbea14f06e79e6184b0c0dd9f92bef335 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:28:16 -0800 Subject: [PATCH 3/3] Docs: Cut's progress line says anchors left without a segment go too --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8fb376813..fc7324408 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,7 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. -- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and leaves its picked anchors in place, on every contour, as Clear does. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.