diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e249a23fc..12d76470c 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", @@ -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 363f5494a..b656eb28e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3759,6 +3759,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, 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`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5066b1b62..e7f6abee9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -946,6 +946,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`.