From ba9acf808eed995b085cd8d3f563c56ed7e09bd5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:31:16 -0800 Subject: [PATCH 1/2] Brushes: brush.freehand is one undo step and refuses strokeless paths and unknown keys brush.freehand drew the path with one edit and set the brush with a second, so one Undo left the path without its brush. The brush is now set in the same edit that draws, continues or redraws the path, and the step is named Paintbrush whatever the style. A brush for a path with no stroke (a continued or redrawn path without one, or a new filled "pencil" path while the stroke is None) was skipped and the call still succeeded. It is now refused with "cannot apply brush ``: path has no stroke" before anything changes. The Paintbrush tool sends brush.freehand when a brush is current, so continuing or redrawing such a path with it now shows this error. This rule is VectorCraft's own. brush.freehand is no longer in UNCHECKED: it takes the path.freehand keys and `brush`, and any other key is refused with "unknown parameter". --- crates/engine/src/cmd/brushsym.rs | 6 +- crates/engine/src/cmd/brushsym/brushes.rs | 21 ++--- crates/engine/src/cmd/draw2.rs | 1 + crates/engine/src/cmd/draw2/freehand.rs | 76 +++++++++++++++-- crates/engine/src/cmd/params.rs | 1 - crates/engine/src/tests/brushsym.rs | 99 +++++++++++++++++++++++ docs/mcp.md | 7 +- docs/target-app-parity.md | 1 + 8 files changed, 186 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 1046cada1c..db6e4e28a1 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -19,8 +19,8 @@ use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, - str_param, switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, named_ids, number_or, ok, str_param, + switch_or, }; use crate::EngineError; @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Paintbrush", [], None, - "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, or a `style` other than \"pencil\" or \"brush\", is an error and nothing is drawn → {id}", + "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, a `style` other than \"pencil\" or \"brush\", a `brush` for a path with no stroke (a continued or redrawn path without one, or a new filled \"pencil\" path while the stroke is None) and an unknown key are errors and nothing is drawn; the path and its brush are one undo step → {id}", has_doc, brush_freehand ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 7fd8c9aa9a..89f3893b24 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -1,7 +1,6 @@ use super::{ AppearanceItem, Arc, BTreeSet, Bristle, Brush, BrushKind, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, Result, Session, Value, - anchor, bad, brush, check_numbers, find_command, id_param, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, - str_param, + anchor, bad, brush, check_numbers, find_command, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, str_param, }; // ---------- brushes ---------- @@ -729,9 +728,10 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&out) })) } -/// Paints a `path.freehand` stroke with the brush `brush` names. A `brush` left out or null draws -/// the plain path; a `brush` that is not a string or names no brush in the document, and a `style` -/// other than "pencil" or "brush", is an error before anything is drawn. +/// Paints a `path.freehand` stroke with the brush `brush` names, in one undo step with the path. +/// A `brush` left out or null draws the plain path; a `brush` that is not a string or names no +/// brush in the document, a `style` other than "pencil" or "brush", and a `brush` for a path with +/// no stroke are errors before anything is drawn. pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.freehand"; let name = match crate::cmd::text_param(p, "brush", C)? { @@ -745,13 +745,6 @@ pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { Some(v) => return Err(bad(C, format!("`style` must be \"pencil\" or \"brush\", not {v}"))), } let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = crate::cmd::run_checked(s, spec, p)?; - let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; - s.edit("Paintbrush", |d, _| { - if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { - st.brush = Some(name.clone()); - } - Ok(()) - })?; - Ok(r) + let checked = crate::schemas::check(spec, p, s.replaying())?; + crate::cmd::draw2::freehand_brushed(s, checked.as_ref().unwrap_or(p), name.as_deref()) } diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index d469c7a613..9055e83844 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -33,3 +33,4 @@ mod smooth; pub use commands::specs; pub(super) use common::{ids_json, replace_with_pieces, usize_opt}; pub(super) use cut::{components, erase_outline}; +pub(super) use freehand::freehand_brushed; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 401bdd2977..45c25eb72c 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -157,6 +157,34 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) - } pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { + freehand_brushed(s, p, None) +} + +/// The object a brush on path `id` paints: the compound path a member path is in, else the path. +fn brush_target(d: &Document, id: NodeId) -> NodeId { + d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// Whether `ap` has a stroke a brush can paint: a stroke whose paint is not None. +fn can_brush(ap: &Appearance) -> bool { + ap.stroke().is_some_and(|st| !st.paint.is_none()) +} + +fn no_stroke(name: &str) -> EngineError { + bad("brush.freehand", format!("cannot apply brush `{name}`: path has no stroke")) +} + +/// Sets brush `name` on the stroke of object `id` of `d`. +fn set_brush(d: &mut Document, id: NodeId, name: &str) { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.to_string()); + } +} + +/// `path.freehand`, with the stroke painted with brush `brush_name` (one `brush.freehand` has +/// checked) in the same undo step as the path. A path without a stroke is an error before +/// anything changes. +pub(crate) fn freehand_brushed(s: &mut Session, p: &Value, brush_name: Option<&str>) -> Result { const C: &str = "path.freehand"; let samples = samples_param(p, "points", C)?; if samples.iter().any(|q| !within_input_bound(q.pt)) { @@ -173,6 +201,8 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); let closed = switch_or(p, "closed", false, C)?; let brush = str_param(p, "style") == Some("brush"); + // The undo step's name: a stroke painted with a brush is a Paintbrush stroke, whatever its style. + let label = if brush || brush_name.is_some() { "Paintbrush" } else { "Pencil" }; let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { @@ -180,6 +210,12 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; + let target = brush_target(&s.doc()?.doc, id); + if let Some(name) = brush_name + && !s.doc()?.doc.node(target).is_some_and(|n| can_brush(&n.appearance)) + { + return Err(no_stroke(name)); + } let mut sp = pd.subpaths[si].clone(); let old_len = subpath_len(&sp); if at_start { @@ -191,6 +227,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; *p0 = end; if pts.len() < 2 { + // No stroke to add: the brush alone changes, when it is not the path's already. + if let Some(name) = brush_name + && s.doc()?.doc.node(target).and_then(|n| n.appearance.stroke()).is_some_and(|st| st.brush.as_deref() != Some(name)) + { + s.edit("Paintbrush", |d, _| { + set_brush(d, target, name); + Ok(()) + })?; + } return Ok(json!({ "id": id.0 })); } // A stroke that ends on an open end of another path connects the two: (that path, its last @@ -290,7 +335,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let drawn = old.extended(old_len, &pen, drawn_len - old_len); if connect.is_some() { drawn.extended(drawn_len, &other, other_len) } else { drawn } }); - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + s.edit(label, |d, sel| { let reverse = at_start && !sp.closed; if reverse { sp.reverse(); @@ -326,6 +371,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { { if reverse { joined.reversed() } else { joined }.write(st); } + if let Some(name) = brush_name { + set_brush(d, target, name); + } if keep_selected { sel.set([id]); } else { @@ -346,11 +394,19 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // were, measured along the old path; fit them to the new length if strokes drawn with a // pen get redrawn. if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + // A member path of a compound path is redrawn in it: the compound path is what is + // selected, and what a brush paints. + let whole = brush_target(&s.doc()?.doc, id); + if let Some(name) = brush_name + && !s.doc()?.doc.node(whole).is_some_and(|n| can_brush(&n.appearance)) + { + return Err(no_stroke(name)); + } + s.edit(label, |d, sel| { *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; - // A member path of a compound path is redrawn in it: the compound path is what is - // selected. - let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); + if let Some(name) = brush_name { + set_brush(d, whole, name); + } if keep_selected { sel.set([whole]); } else { @@ -373,7 +429,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { if let Some(st) = look.appearance.stroke_mut() { pen.write(st); } - let out = insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)?; + if let Some(name) = brush_name { + if !can_brush(&look.appearance) { + return Err(no_stroke(name)); + } + if let Some(st) = look.appearance.stroke_mut() { + st.brush = Some(name.to_string()); + } + } + let out = insert_look(s, label, path_kind(PathData::single(sp)), look, None, keep_selected)?; if !keep_selected { // A new stroke with Keep selected off leaves nothing selected: Illustrator starts the stroke // by dropping the earlier selection, it does not keep it (#5752). diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 4bb6bcba1d..5e23910560 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -512,7 +512,6 @@ pub const UNCHECKED: &[&str] = &[ "assets.settings.set", "attributes.info", "attributes.set", - "brush.freehand", "css.export", "css.generate", "css.selection", diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index bc4ce76c83..a2e9eb3012 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1002,6 +1002,105 @@ fn brush_freehand_refuses_a_brush_or_style_it_cannot_use() { } } +/// A `brush.freehand` call outside an interaction draws the path and paints it with the brush in +/// one undo step: one Undo removes the path and gives back the earlier selection, and one Redo +/// brings back the brushed path. Continuing a path with a brush is one step too. +#[test] +fn brush_freehand_direct_call_has_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[a]); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("brush.freehand", &json!({"points": [[10, 10], [60, 40], [120, 10]], "style": "brush", "brush": "Charcoal"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(*s.doc().unwrap().doc == *before, "one Undo removes the brushed path"); + assert_eq!(selected(&s), [a], "Undo gives back the earlier selection"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"), "Redo brings back the brush"); + assert_eq!(selected(&s), [id]); + + let undo = s.doc().unwrap().history.undo.len(); + let p = json!({"points": [[120, 10], [160, 60], [200, 10]], "extend": {"id": id.0, "end": "end"}, "brush": "Dots"}); + assert_eq!(s.execute("brush.freehand", &p).unwrap()["id"], json!(id.0)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "continuing is one undo step"); + assert_eq!(brush_of(&s, id).as_deref(), Some("Dots")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"), "one Undo takes back the stroke and its brush"); + + let undo = s.doc().unwrap().history.undo.len(); + s.execute("brush.freehand", &json!({"points": [[10, 100], [60, 140], [120, 100]], "style": "pencil", "brush": "Charcoal"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paintbrush"), "a stroke with a brush is a Paintbrush step"); +} + +/// `brush.freehand` refuses a brush for a path with no stroke (a continued path, a redrawn one +/// and a new filled pencil path while the stroke is None) instead of reporting it painted: the +/// document, the selection and the undo history stay as they were. Without a brush the path is +/// drawn as `path.freehand` draws it. +#[test] +fn brush_freehand_refuses_a_brush_on_a_path_without_a_stroke() { + let mut s = session(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let drawn = json!([[10, 10], [60, 20], [90, 80], [20, 90]]); + let p = NodeId(s.execute("path.freehand", &json!({"points": drawn, "fill": true})).unwrap()["id"].as_u64().unwrap()); + assert!(node(&s, p).appearance.stroke_paint().is_none(), "the path has no stroke"); + // The redraw below replaces a section of the path rather than drawing a new one. + let redraw = json!({"points": [[10, 10], [40, 30], [60, 20]], "redraw": {"id": p.0}}); + assert_eq!(s.execute("path.freehand", &redraw).unwrap()["id"], json!(p.0)); + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[p]); + let extend = json!({"id": p.0, "end": "end"}); + for (what, mut params) in [ + ("continued", json!({"points": [[20, 90], [0, 120], [-10, 150]], "extend": extend})), + ("continued by one point", json!({"points": [[20, 90]], "extend": extend})), + ("redrawn", redraw.clone()), + ("new filled pencil", json!({"points": [[100, 100], [150, 120], [180, 180]], "style": "pencil", "fill": true})), + ] { + params["brush"] = json!("Charcoal"); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("brush.freehand", ¶ms).expect_err(&format!("{what}: refused")); + assert_eq!(e.to_string(), "invalid parameters for `brush.freehand`: cannot apply brush `Charcoal`: path has no stroke", "{what}"); + assert!(*s.doc().unwrap().doc == *before, "{what}: the document stays"); + assert_eq!(selected(&s), [p], "{what}: the selection stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{what}: no undo step"); + } + let r = s.execute("brush.freehand", &json!({"points": [[20, 90], [0, 120], [-10, 150]], "extend": extend, "brush": null})).unwrap(); + assert_eq!(r["id"], json!(p.0)); + assert_eq!(brush_of(&s, p), None, "without a brush the path is continued as it is"); +} + +/// `brush.freehand` takes the keys of `path.freehand` and `brush`, and refuses any other key with +/// the nearest key it takes, before it draws. +#[test] +fn brush_freehand_refuses_unknown_keys() { + let spec = crate::cmd::find_command("brush.freehand").unwrap(); + let mut keys = crate::cmd::params::accepted_keys(spec.params).expect("the description names every key"); + keys.sort_unstable(); + assert_eq!(keys, ["brush", "closed", "connect", "extend", "fidelity", "fill", "fit", "keepSelected", "points", "redraw", "style"]); + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[a]); + let points = json!([[10, 10], [60, 40], [120, 10]]); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("brush.freehand", &json!({"points": points, "brsuh": "Charcoal"})).expect_err("a misspelled key is refused"); + assert!(e.to_string().contains("unknown parameter `brsuh`; did you mean `brush`?"), "{e}"); + assert!(*s.doc().unwrap().doc == *before, "the document stays"); + assert_eq!(selected(&s), [a], "the selection stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + let every = json!({ + "points": points, "fidelity": 2.0, "fit": true, "closed": false, "style": "brush", "fill": false, "keepSelected": true, + "extend": null, "connect": null, "redraw": null, "brush": "Charcoal" + }); + let id = NodeId(s.execute("brush.freehand", &every).unwrap()["id"].as_u64().unwrap()); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); +} + /// The Paintbrush skips a brush deleted after it was chosen: a deleted tool brush gives way to /// the document's current brush, and with neither the stroke is drawn without a brush. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 99b0bfef38..7491549915 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3238,8 +3238,11 @@ already walks Image Trace. Short agent entry points: `brush.duplicate {name, newName?} | {names}`, `brush.freehand {…path.freehand params, brush?}`, `object.expandBrush {ids?}`. `brush.freehand` draws the plain path when `brush` is left out or null. It refuses a `brush` that is not a string with "`brush` must be a string", a name that no brush in the document has (an empty - name included) with "no brush named ``", and a `style` other than "pencil" or "brush"; the document does not - change and no undo step is recorded. The Paintbrush skips a tool brush or current brush that has been deleted and + name included) with "no brush named ``", a `style` other than "pencil" or "brush", a `brush` for a path + with no stroke (a continued or redrawn path without one, or a new filled "pencil" path while the stroke is None) + with "cannot apply brush ``: path has no stroke", and a key that neither `path.freehand` nor `brush` names + with "unknown parameter"; the document does not change and no undo step is recorded. A stroke drawn with a brush + is one undo step: Undo removes the path and its brush together. The Paintbrush skips a tool brush or current brush that has been deleted and paints with the other, or draws the plain path when neither is in the document. A document or brush library file whose brushes repeat a name is read with the first brush of that name as stored and each later one numbered with the first "`` 2", "`` 3"… the file does not have; strokes of the name keep the first diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..714c97d0fb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1200,6 +1200,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). `brush.freehand` refuses a `brush` that names no brush in the document, or is not a string, and a `style` other than "pencil" or "brush", with an error and no change or undo step, instead of drawing the plain path; the Paintbrush skips a chosen brush that has since been deleted (engine tests `brush_freehand_refuses_a_brush_or_style_it_cannot_use` and `paintbrush_tool_skips_a_deleted_brush`). + A `brush.freehand` stroke and its brush are one undo step, so one Undo removes both. A `brush` for a path with no stroke (a continued or redrawn path without one, or a new filled "pencil" path while the stroke is None) is refused with "cannot apply brush ``: path has no stroke", and a key the command does not take with "unknown parameter", with no change or undo step; the no-stroke refusal is VectorCraft's own rule (engine tests `brush_freehand_direct_call_has_one_undo_step`, `brush_freehand_refuses_a_brush_on_a_path_without_a_stroke` and `brush_freehand_refuses_unknown_keys`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. When a Smooth tool drag reaches the first anchor of a closed path, the smoothed path starts at its point nearest where that anchor was: at an anchor there, or at a new anchor split into the curve without changing its outline. Dashes and type on the path start from that point. A drag that does not reach the first anchor leaves it first. This is VectorCraft's own rule (engine tests `smoothing_over_a_closed_paths_first_anchor_keeps_its_start` and `smoothing_a_whole_closed_path_with_a_spike_keeps_its_start`). The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths or two anchors joined by a curve), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`). From c1ad30afec755eb4d1d7d2f3fceaed18d071631f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 19:34:10 -0800 Subject: [PATCH 2/2] Docs: rewrap the brush.freehand bullet in docs/mcp.md --- docs/mcp.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 1f2804a6b1..aae1e491da 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3260,8 +3260,8 @@ already walks Image Trace. Short agent entry points: with no stroke (a continued or redrawn path without one, or a new filled "pencil" path while the stroke is None) with "cannot apply brush ``: path has no stroke", and a key that neither `path.freehand` nor `brush` names with "unknown parameter"; the document does not change and no undo step is recorded. A stroke drawn with a brush - is one undo step: Undo removes the path and its brush together. The Paintbrush skips a tool brush or current brush that has been deleted and - paints with the other, or draws the plain path when neither is in the document. A document or brush + is one undo step: Undo removes the path and its brush together. The Paintbrush skips a tool brush or current + brush that has been deleted and paints with the other, or draws the plain path when neither is in the document. A document or brush library file whose brushes repeat a name is read with the first brush of that name as stored and each later one numbered with the first "`` 2", "`` 3"… the file does not have; strokes of the name keep the first brush. `brush.new`,