From 787592ff0e4dd44ecc338991ef319462c9430ab3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 22:40:42 -0700 Subject: [PATCH] Brushes: Hue Shift without a key uses the most-used art color brush.new and brush.options refused a Hue Shift colorization given without a key color ("invalid brush: missing field `key`"), and only Brush Options picked a default. The reference app's Brushes page says that by default the key color is the most prominent color in the art. A Hue Shift colorization without a key now takes the solid color that the art's fills and strokes use most, the first one found on a tie, or black when the art has no solid color, as Brush Options already does. Brush definitions read from a document get the same default. brush.options replaced each field its params named, so an edit that gave only the colorization method or only an art brush's start guide was refused because the key or the end guide was missing. It now merges colorization and an art brush's scale field by field. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/brush/src/lib.rs | 34 ++++++++++++---- crates/brush/src/tests.rs | 26 ++++++++++++ crates/engine/src/cmd/brushsym.rs | 24 ++++++++--- crates/engine/src/tests_brushsym.rs | 63 +++++++++++++++++++++++++++++ docs/mcp.md | 6 ++- docs/target-app-parity.md | 1 + 6 files changed, 140 insertions(+), 14 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index a53d20519..221be4536 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -456,6 +456,31 @@ impl Brush { } } + /// A brush definition from JSON (a stored library entry or a command's params), its values + /// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key` + /// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or + /// black when they use no solid color. + pub fn from_json(mut v: serde_json::Value) -> Result { + let keyless = v.get("colorization").is_some_and(|c| { + c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null) + }); + if keyless { + // Read the art with the colorization field left out. + let mut plain = v.clone(); + if let Some(o) = plain.as_object_mut() { + o.remove("colorization"); + } + let b: Brush = serde_json::from_value(plain)?; + let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK); + if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) { + c.insert("key".into(), serde_json::to_value(key)?); + } + } + let mut b: Brush = serde_json::from_value(v)?; + b.sanitize(); + Ok(b) + } + /// Keep every value of the definition within its range (definitions come from files and /// commands, so they are untrusted): numbers are clamped, non-numbers take the default. pub fn sanitize(&mut self) { @@ -517,14 +542,9 @@ pub fn library(doc: &Document) -> Vec { } /// Parse a stored library value (malformed entries are skipped, values kept within their -/// ranges — see [`Brush::sanitize`]). +/// ranges; see [`Brush::from_json`]). pub fn parse_library(v: &serde_json::Value) -> Vec { - let parse = |b: &serde_json::Value| { - let mut b: Brush = serde_json::from_value(b.clone()).ok()?; - b.sanitize(); - Some(b) - }; - v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default() + v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default() } /// Store `lib` as the document's brush library. diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 3dcdbf9bc..c4b1c5a5b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -562,6 +562,32 @@ fn art_colors_list_the_most_used_first() { assert!(calli(0.0, 100.0, 3.0).art().is_empty()); } +/// A Hue Shift definition without a key color takes the art's most used color, or black when the +/// art has no solid color; a key that is given is kept. +#[test] +fn hue_shift_without_a_key_takes_the_most_used_art_color() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0)); + let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]); + let lib = parse_library(&serde_json::json!([ + {"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}}, + {"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}}, + {"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}}, + {"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}}, + ])); + let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect(); + assert_eq!( + keys, + vec![ + ("s", Colorization::HueShift { key: blue }), + ("a", Colorization::HueShift { key: blue }), + ("p", Colorization::HueShift { key: red }), + ("n", Colorization::HueShift { key: Color::BLACK }), + ] + ); +} + /// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed /// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay. #[test] diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8e168522d..8287d087f 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -358,10 +358,24 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { }) } +/// The definition fields that `params` change field by field when the brush has them as objects: +/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only +/// `start` keeps `end`. +const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; + fn merge(base: &mut Value, patch: &Value) { if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { for (k, v) in pt { - b.insert(k.clone(), v.clone()); + match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { + (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { + for (f, fv) in fields { + inner.insert(f.clone(), fv.clone()); + } + } + _ => { + b.insert(k.clone(), v.clone()); + } + } } } } @@ -434,11 +448,9 @@ fn brush_new(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name })) } -/// A brush definition from command params, its values kept within their ranges. +/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). fn parse_brush(cmd: &str, v: Value) -> Result { - let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?; - b.sanitize(); - Ok(b) + Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) } fn brush_delete(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs index ac286c5db..82bbefbd8 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests_brushsym.rs @@ -510,6 +510,69 @@ fn scatter_art_and_pattern_brush_options_update_strokes() { assert_eq!((&b["scale"], &b["spacing"]), (&json!(10000.0), &json!(0.0))); } +/// Hue Shift given without a key color takes the color the art's fills and strokes use most, in +/// New Brush and in Brush Options, and a Brush Options edit that gives only the method keeps the +/// brush's key color. +#[test] +fn hue_shift_without_a_key_takes_the_most_used_art_color() { + let mut s = session(); + let paint = |s: &mut Session, id: NodeId, fill: &str, stroke: Option<&str>| { + s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap(); + match stroke { + Some(c) => s.execute("paint.setStroke", &json!({"color": c, "ids": [id.0]})).unwrap(), + None => s.execute("paint.setStroke", &json!({"none": true, "ids": [id.0]})).unwrap(), + }; + }; + // Blue comes first; red paints two fills and a stroke. + let a = rect(&mut s, 10.0, 200.0, 20.0, 8.0); + paint(&mut s, a, "#0000ff", None); + let b = rect(&mut s, 40.0, 200.0, 20.0, 8.0); + paint(&mut s, b, "#ff0000", Some("#ff0000")); + let c = rect(&mut s, 70.0, 200.0, 20.0, 8.0); + paint(&mut s, c, "#ff0000", None); + let color = |r: f32, g: f32, b: f32| serde_json::to_value(vectorcraft_color::Color::rgb(r, g, b)).unwrap(); + let (red, green) = (color(1.0, 0.0, 0.0), color(0.0, 1.0, 0.0)); + let hue_shift = json!({"colorization": {"method": "hueShift"}}); + let r = s.execute("brush.new", &json!({"type": "scatter", "name": "Key", "ids": [a.0, b.0, c.0], "params": hue_shift})).unwrap(); + assert_eq!(r["name"], "Key"); + let colorization = |s: &mut Session| s.execute("brush.get", &json!({"name": "Key"})).unwrap()["colorization"].clone(); + assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": red})); + let options = |s: &mut Session, params: Value| s.execute("brush.options", &json!({"name": "Key", "params": params})).unwrap(); + // Brush Options switches from Tints to Hue Shift without a key. + options(&mut s, json!({"colorization": {"method": "tints"}})); + assert_eq!(colorization(&mut s), json!({"method": "tints"})); + options(&mut s, hue_shift.clone()); + assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": red})); + // A key color that was set stays when an edit gives only the method. + options(&mut s, json!({"colorization": {"method": "hueShift", "key": green}})); + options(&mut s, hue_shift.clone()); + assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": green})); + // Art with no solid color takes black. + let d = rect(&mut s, 100.0, 200.0, 20.0, 8.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [d.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [d.0]})).unwrap(); + let r = s.execute("brush.new", &json!({"type": "art", "name": "Plain", "ids": [d.0], "params": hue_shift})).unwrap(); + assert_eq!(r["name"], "Plain"); + let b = s.execute("brush.get", &json!({"name": "Plain"})).unwrap(); + assert_eq!(b["colorization"], json!({"method": "hueShift", "key": color(0.0, 0.0, 0.0)})); +} + +/// Brush Options merges an art brush's `scale` field by field: an edit that gives only the start +/// guide keeps the end guide. +#[test] +fn art_brush_options_keep_the_scale_fields_an_edit_leaves_out() { + let mut s = session(); + let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap(); + s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "betweenGuides", "start": 0.3}}})).unwrap(); + let b = s.execute("brush.get", &json!({"name": "Bar"})).unwrap(); + assert_eq!(b["scale"], json!({"mode": "betweenGuides", "start": 0.3, "end": 0.6})); + // A scale that becomes a plain mode drops the guides. + s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "stretch"}}})).unwrap(); + assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap()["scale"], json!({"mode": "stretch"})); +} + // ---------- symbols ---------- fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) { diff --git a/docs/mcp.md b/docs/mcp.md index 642ca8f12..3a7e11bea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1232,7 +1232,11 @@ already walks Image Trace. Short agent entry points: `brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. `brush.new`, `brush.duplicate` and `brush.options` trim the white space around the name they are given. A blank `name` gives the type's default name ("New Calligraphic Brush" for a calligraphic brush), and a blank `newName` gives - "`` copy" in `brush.duplicate` and keeps the name in `brush.options`. + "`` copy" in `brush.duplicate` and keeps the name in `brush.options`. A Hue Shift `colorization` given to + `brush.new` or `brush.options` without a `key` takes the color that the art's fills and strokes use most (on a tie, + the one found first), or black when they use no solid color. `brush.options` changes only the fields of + `colorization` and of an art brush's `scale` that `params` gives, so `{colorization: {method: "hueShift"}}` keeps a + Hue Shift brush's key. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`, `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cd7da9584..1a3e52e4b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -689,6 +689,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. + Hue Shift set by `brush.new` or `brush.options` without a key color takes the color the art's fills and strokes use most, as Brush Options already offers it, following the Brushes page: "By default, the key color is the most prominent color in the art" (). The page does not state how prominence is measured. Counting the fills and strokes that use each solid color, with the first one found winning a tie and black for art with no solid color, is VectorCraft's own rule. A `brush.options` edit that gives only the method keeps the brush's key color, and one that gives only an art brush's start guide keeps its end guide. Evidence: `tests_brushsym::hue_shift_without_a_key_takes_the_most_used_art_color`, `tests_brushsym::art_brush_options_keep_the_scale_fields_an_edit_leaves_out`. In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`).