From f4496d61aafda82ee93df5d46cd3336537a1451d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:17:08 -0800 Subject: [PATCH 1/2] Paths: Add Anchor Point keeps the same anchors and segments selected path.insertAnchor (the Add Anchor Point tool, the Pen's Auto Add, the Smart Pen's Alt-click on a segment and Alt-click with the Scissors) added the anchor without changing the selection, so the direct-selected anchors and selected segments after it on the subpath kept their old numbers and the selection shifted onto the next points along. It now renumbers them with renumber_after_insert, the helper path.reshape uses. A selected segment that the new anchor splits stays selected as its two halves, and the new anchor is direct-selected with them. Undo restores the path and the selection in one step. Docs: params text, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/path.rs | 13 +++++- crates/engine/src/tests/draw2.rs | 76 ++++++++++++++++++++++++++++++++ docs/mcp.md | 4 ++ docs/target-app-parity.md | 1 + 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e249a23fc..9ee6b61bb 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -135,7 +135,15 @@ pub fn specs() -> Vec { has_doc, reshape ), - cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor), + cmd!( + "path.insertAnchor", + "Add Anchor Point", + [], + None, + "{id, subpath?, segment, t: 0..1} add an anchor at t on the segment → {anchor}; the direct-selected anchors and selected segments keep the same points (a selected segment it splits stays selected as its two halves), and a path that isn't selected becomes the selection with the new anchor direct-selected", + has_doc, + insert_anchor + ), cmd!( "path.cutAtAnchors", "Cut Path at Selected Anchor Points", @@ -1526,7 +1534,7 @@ fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[ /// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and /// selected segments after it on that subpath one higher, so the selection keeps the same points. /// A selected segment that the new anchor splits stays selected as its two halves, and the new -/// anchor joins its ends in the anchors that move with it. +/// anchor is added to the direct-selected anchors. fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) { if let Some(set) = sel.anchors.get_mut(&id) { // A selected anchor index can name no anchor (`select.anchors` takes any index), so the @@ -1669,6 +1677,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no such segment".into())); } let idx = sp.insert_anchor(seg, t); + renumber_after_insert(sel, id, si, idx); // A path that wasn't selected becomes the selection, its new anchor direct-selected, so // the point added shows and can be edited at once (#4237). if !sel.contains(id) { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index df3a5f066..fcf9b6363 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2380,6 +2380,82 @@ fn add_anchor_point_selects_the_path_it_was_added_to() { assert!(selected_anchors(&s, kept).is_empty(), "a path selected as a whole stays so"); } +fn path_from(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap()) +} + +fn selected_segments(s: &Session, id: NodeId) -> Vec<(usize, usize)> { + s.doc().unwrap().selection.segments.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default() +} + +fn selected_points(s: &Session, id: NodeId) -> Vec { + let pd = path(s, id); + selected_anchors(s, id).iter().filter_map(|&(si, ai)| pd.subpaths.get(si)?.anchors.get(ai)).map(|a| a.p).collect() +} + +#[test] +fn add_anchor_point_keeps_the_same_anchors_selected() { + // The direct-selected anchors after the one added are numbered one higher, so the selection + // stays on the same points; one undo step restores the path and the selection. + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 5]], "mode": "set"})).unwrap(); + let before = selected_points(&s, id); + let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap(); + assert_eq!(r["anchor"], 3); + assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 6)], "the anchor at 350 is now number 6"); + assert_eq!(selected_points(&s, id), before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7); + assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 5)]); +} + +#[test] +fn add_anchor_point_on_a_selected_segment_keeps_both_halves_and_the_new_anchor_selected() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0"); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 2], [0, 4]], "mode": "set"})).unwrap(); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap(); + assert_eq!(selected_segments(&s, id), [(0, 2), (0, 3), (0, 5)]); + assert_eq!(selected_anchors(&s, id), [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6)], "the new anchor is selected with the segment's ends"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7); + assert_eq!(selected_segments(&s, id), [(0, 2), (0, 4)]); +} + +#[test] +fn add_anchor_point_on_the_closing_segment_keeps_it_selected_as_two_halves() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L100 0 L100 100 L0 100 Z"); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 3]], "mode": "set"})).unwrap(); + let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 3, "t": 0.5})).unwrap(); + assert_eq!(r["anchor"], 4); + assert_eq!(selected_segments(&s, id), [(0, 3), (0, 4)]); + assert_eq!(selected_anchors(&s, id), [(0, 0), (0, 3), (0, 4)]); + assert_eq!(selected_points(&s, id), [Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(0.0, 50.0)]); +} + +#[test] +fn add_anchor_point_renumbers_only_the_subpath_it_was_added_to() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [1, 1], [1, 2]], "mode": "set"})).unwrap(); + let before = selected_points(&s, id); + s.execute("path.insertAnchor", &json!({"id": id.0, "subpath": 1, "segment": 0, "t": 0.5})).unwrap(); + assert_eq!(selected_anchors(&s, id), [(0, 2), (1, 2), (1, 3)]); + assert_eq!(selected_points(&s, id), before); +} + +#[test] +fn add_anchor_point_with_a_selected_anchor_index_past_the_end_does_not_panic() { + // `select.anchors` takes any index, so a selected one can name no anchor. + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, usize::MAX]], "mode": "set"})).unwrap(); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.5})).unwrap(); + assert_eq!(selected_anchors(&s, id), [(0, 2), (0, usize::MAX)]); +} + fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> { s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default() } diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205c..fda4c0012 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3675,6 +3675,10 @@ the canvas (coordinates out of range)". `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. +`path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those +after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays +selected as its two halves, and the new anchor is direct-selected with them. Undo restores the path and the selection +in one step. `path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8fc74c47..ea62257e0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -943,6 +943,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group. A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included). With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies). + When the Add Anchor Point tool, the Pen's Auto Add, the Smart Pen's Alt-click on a segment or Alt-click with the Scissors adds an anchor to a path with direct-selected anchors or segments (`path.insertAnchor`), the same points stay selected: the anchors and segments after the new anchor are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves. `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0. `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. From 9bfd6b7be5d5c99e2151cb5485889ecfea91f57c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:00:40 -0800 Subject: [PATCH 2/2] Paths: describe the anchor added on a selected segment as an end of its halves A selected segment that path.insertAnchor splits stays selected as its two halves. The new anchor is the end both halves share, so it moves with them, and Delete cuts both halves. docs/mcp.md and the comment on renumber_after_insert called it a direct-selected anchor; they now describe it as an end of the halves that moves with them. --- crates/engine/src/cmd/path.rs | 2 +- docs/mcp.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9ee6b61bb..12d76470c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1534,7 +1534,7 @@ fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[ /// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and /// selected segments after it on that subpath one higher, so the selection keeps the same points. /// A selected segment that the new anchor splits stays selected as its two halves, and the new -/// anchor is added to the direct-selected anchors. +/// anchor joins its ends in the anchors that move with it. fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) { if let Some(set) = sel.anchors.get_mut(&id) { // A selected anchor index can name no anchor (`select.anchors` takes any index), so the diff --git a/docs/mcp.md b/docs/mcp.md index fda4c0012..88a485d83 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3677,8 +3677,8 @@ is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `su an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. `path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays -selected as its two halves, and the new anchor is direct-selected with them. Undo restores the path and the selection -in one step. +selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and +the selection in one step. `path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`.