Paths: anchor commands refuse a subpath that is not a whole number

path.curvatureEdit, path.removeAnchor, path.convertAnchor,
path.reshapeSegment, path.split, path.setHandle and path.insertAnchor
read subpath as an unsigned integer and fell back to 0 for any other
value. A subpath of 1.5, -1 or "1" edited subpath 0 and the command
reported success. path.split read anchor and segment with as_u64 too
and treated any other value as absent, so a bad anchor beside a good
segment split at the segment.

These commands now refuse a subpath that is not a whole number of 0 or
more with "missing integer `subpath`", the error path.curvatureEdit,
path.removeAnchor, path.convertAnchor and path.reshapeSegment already
give for a bad anchor or segment, and path.split refuses such an anchor
or segment too. Without a subpath, or with null, they edit subpath 0 as
before. The document and the undo history stay as they were after each
error. The params text of the six commands that listed subpath as
required now marks it optional.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-19 00:03:47 +07:00
committed by bflatastic
parent 89a73abc38
commit c0ec14b86d
5 changed files with 155 additions and 15 deletions
+20 -11
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Anchor Point",
[],
None,
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
has_doc,
remove_anchor
),
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Point",
[],
None,
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
has_doc,
convert_anchor
),
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
has_doc,
reshape_segment
),
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Split Path",
[],
None,
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
has_doc,
split
),
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
/// value is [`usize_req`]'s error.
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => usize_req(p, key, cmd).map(Some),
}
}
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
@@ -795,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
@@ -859,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -966,7 +975,7 @@ fn remove_anchors_of(
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
@@ -1001,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
@@ -1026,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
if anchor.is_none() && seg.is_none() {
return Err(bad(C, "need `anchor` or `segment` + `t`"));
}
+5 -4
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::draw2::usize_opt;
use super::pathops::Beside;
use super::*;
use crate::EngineError;
@@ -37,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Direction Handle",
[],
None,
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
has_doc,
set_handle
),
@@ -104,7 +105,7 @@ 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}", has_doc, insert_anchor),
cmd!(
"path.cutAtAnchors",
"Cut Path at Selected Anchor Points",
@@ -215,7 +216,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
@@ -715,7 +716,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
+125
View File
@@ -867,6 +867,131 @@ fn draw2_commands_reject_bad_params() {
}
}
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
fn two_subpaths(s: &mut Session) -> NodeId {
let id = curve(s);
let sub = |y: f64| {
let anchors = json!([
{"x": 100, "y": y, "out": [130, y - 30.0]},
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
{"x": 300, "y": y, "in": [270, y - 30.0]},
]);
json!({"anchors": anchors, "closed": false})
};
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
id
}
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
/// names `key`, and the document and the undo history stay as they were.
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
for v in [json!(1.5), json!(-1), json!("1")] {
let mut p = p.clone();
p[key] = v;
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
}
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
/// subpath 0 alone.
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
let before = path(s, id).subpaths;
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
s.execute(cmd, &p).unwrap();
let after = path(s, id).subpaths;
assert_eq!(after.len(), 2, "{cmd} {p}");
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
}
}
#[test]
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
}
#[test]
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 1});
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
}
#[test]
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
}
#[test]
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
}
#[test]
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
// `anchor`.
let mut s = session();
let id = two_subpaths(&mut s);
let at_anchor = json!({"id": id.0, "anchor": 1});
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
let before = path(&s, id).subpaths;
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
s.execute("path.split", &p).unwrap();
let after = path(&s, id).subpaths;
// The subpath split becomes two of two anchors each, in its place.
let kept = if si == 1 { &after[0] } else { &after[2] };
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
assert_eq!(kept, &before[1 - si], "{p}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
}
}
#[test]
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
}
#[test]
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
}
#[test]
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
+4
View File
@@ -1962,6 +1962,10 @@ does. `path.convertAnchor` (its `x`, `y`) and `path.freehand` (each of its `poin
path by its control points when one of them has an x or y that is infinite or larger in size than `f64::MAX / 16`
(about 1.1e307), so an edit that leaves such a path selected, `path.create` included, fails with "result would exceed
the canvas (coordinates out of range)".
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`,
`path.setHandle` 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.
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
+1
View File
@@ -538,6 +538,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character.
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).
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle` 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.
- **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path.
With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).