Stroke: stroke.set refuses a custom arrowhead path that fills no area

A {Custom: {name, path}} head is refused when its path fills no area under the
non-zero rule: a single anchor, points on one line, or contours that cancel out.
A bow tie and a thin bar are taken. The params text and docs/mcp.md state the rule.
This commit is contained in:
dotCooCoo
2026-11-11 23:33:02 -08:00
parent 264eeec475
commit b42a3d4869
3 changed files with 52 additions and 6 deletions
+14 -3
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -234,9 +234,20 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Object(o)) if o.contains_key("Custom") => {
let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null);
let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?;
if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) {
// The region the outline fills (non-zero rule), down to slivers a millionth of a
// head unit thin. Measured only once `fits` has bounded the anchors.
let fills = |p: &PathData| {
let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6);
area.subpaths.iter().map(|sp| sp.area().abs()).sum::<f64>() > 1e-12
};
if !CustomArrowhead::fits(&path) || !fills(&path) {
let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS);
return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip")));
return Err(bad(
C,
format!(
"{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors, each within {far} head units of its tip"
),
));
}
let head = p.get(k).cloned().unwrap_or(Value::Null);
serde_json::from_value::<Arrowhead>(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}")))
+33
View File
@@ -194,7 +194,40 @@ fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() {
let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap();
empty["Custom"]["path"] = json!({"subpaths": []});
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err());
// One anchor, or anchors on one line, fill nothing.
for pts in [vec![Point::new(1.0, 1.0)], vec![Point::new(0.0, 0.0), Point::new(2.0, 0.0), Point::new(4.0, 0.0)]] {
let mut flat =
serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Flat".into(), PathData::default())))).unwrap();
flat["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&pts, false))).unwrap();
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": flat})).is_err(), "{pts:?}");
}
// A lone anchor draws no segment, whatever its handles.
let mut lone = vectorcraft_geom::Anchor::corner(Point::new(1.0, 0.0));
(lone.h_in, lone.h_out) = (Point::new(0.0, 1.0), Point::new(2.0, -1.0));
let mut handles =
serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Lone".into(), PathData::default())))).unwrap();
handles["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::new(vec![lone], true))).unwrap();
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles.clone()})).is_err(), "a lone anchor");
// Two equal squares winding opposite ways cancel out.
let sq = [Point::new(0.0, -1.0), Point::new(2.0, -1.0), Point::new(2.0, 1.0), Point::new(0.0, 1.0)];
let back: Vec<Point> = sq.iter().rev().copied().collect();
let cancel = PathData::new(vec![SubPath::polyline(&sq, true), SubPath::polyline(&back, true)]);
handles["Custom"]["path"] = serde_json::to_value(cancel).unwrap();
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles})).is_err(), "opposite squares");
// A bow tie's lobes wind opposite ways, so its signed area is 0, and it still fills both.
let tie = [Point::new(0.0, -1.0), Point::new(2.0, 1.0), Point::new(2.0, -1.0), Point::new(0.0, 1.0)];
let mut bow =
serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Bow".into(), PathData::default())))).unwrap();
bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&tie, true))).unwrap();
assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was");
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow.clone()})).unwrap();
assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Bow"));
// So does a very thin bar.
let bar = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 0.001), Point::new(0.0, 0.001)];
bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&bar, true))).unwrap();
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow})).unwrap();
let Some(Arrowhead::Custom(h)) = stroke(&s, id).end_arrow else { panic!("a custom head") };
assert_eq!(h.path.anchor_count(), 4, "the thin bar");
let _ = std::fs::remove_dir_all(&dir);
}
+5 -3
View File
@@ -3968,9 +3968,11 @@ path, the `skipped` symbols and any read `error`.
`stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art,
so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file.
`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. The
help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes
defining custom arrowheads in a separate arrowheads file.
`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It
refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or
has more than 4,096 anchors or a point more than 64 head units from the tip. The help page
[Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom
arrowheads in a separate arrowheads file.
```json
{"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}}