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.
This commit is contained in:
dotCooCoo
2026-11-10 22:17:08 -08:00
parent 0695db3ec0
commit f4496d61aa
4 changed files with 92 additions and 2 deletions
+11 -2
View File
@@ -135,7 +135,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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) {
+76
View File
@@ -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<Point> {
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()
}
+4
View File
@@ -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`.
+1
View File
@@ -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`.