Brushes: colorization recolors type in scatter brush art

A scatter brush whose art held type drew the type in its own colors
whatever the brush's colorization: Tints, Tints and Shades and Hue
Shift recolored the art's paths but not its characters. The Key Color
list in Brush Options left the type's colors out as well.

Colorization now maps the solid fills and strokes of each run of type
in the art as it maps the fills and strokes of paths. The Key Color
list, whose first color is Hue Shift's default key, counts each run's
fill, and its stroke when the stroke is wider than 0. The Symbol
Stainer now mixes the characters' strokes of type toward the stain,
as it already mixed their fills.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-21 16:55:17 +07:00
committed by bflatastic
parent 6a1c8eda46
commit 2c4d0bac92
3 changed files with 91 additions and 4 deletions
+24 -4
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Node};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use crate::Colorization;
@@ -43,7 +43,8 @@ fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
}
/// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone.
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. Type is
/// recolored like paths: its characters' solid fills and strokes as well as its own.
pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
let key = match (mode, stroke.color()) {
(Colorization::None, _) | (_, None) => None,
@@ -65,6 +66,12 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
}
}
}
if let (Some(k), NodeKind::Text(t)) = (&key, &mut n.kind) {
for r in &mut t.runs {
map_paint(&mut r.style.fill, mode, k);
map_paint(&mut r.style.stroke, mode, k);
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
walk(Arc::make_mut(c), mode, key);
@@ -88,8 +95,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if let Paint::Solid { color: c, .. } = &mut r.style.fill {
*c = c.lerp(color, amount);
for p in [&mut r.style.fill, &mut r.style.stroke] {
if let Paint::Solid { color: c, .. } = p {
*c = c.lerp(color, amount);
}
}
}
}
@@ -119,6 +128,8 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
/// Type's characters count as well: each run's fill, and its stroke when the stroke is wider
/// than 0.
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
for it in &n.appearance.items {
@@ -131,6 +142,15 @@ pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
if let NodeKind::Text(t) = &n.kind {
for r in &t.runs {
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
for c in [Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color) {
let order = seen.len();
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
}
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
+66
View File
@@ -308,6 +308,63 @@ fn tints_colorization_uses_stroke_colour() {
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
fn type_art(fill: Paint, stroke: Paint) -> Node {
use vectorcraft_doc::{CharStyle, TextObject};
let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() };
Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style))))
}
/// The characters' fills and strokes of every piece of type in `nodes`.
fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> {
let mut out = vec![];
for n in nodes {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())));
}
});
}
out
}
/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths.
#[test]
fn scatter_colorization_recolors_type() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0);
let mut path = rect_art(10.0, 4.0);
path.appearance.set_fill(red.clone());
let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]);
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
let path_fill = out[0].children().unwrap()[0].appearance.fill_paint();
assert_ne!(path_fill, red);
let paints = run_paints(&out);
assert_eq!(paints.len(), out.len());
assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}");
// Black type takes the stroke color itself.
let black = Paint::solid(Color::BLACK);
let b = Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone())));
}
/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills.
#[test]
fn stain_mixes_type_strokes_too() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut art = type_art(red.clone(), red);
tint_node(&mut art, &blue, 1.0);
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
}
#[test]
fn library_defaults_store_and_find() {
let mut d = Document::new(100.0, 100.0);
@@ -557,6 +614,15 @@ fn art_colors_list_the_most_used_first() {
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
// Type counts its characters' fills and drawn strokes.
let text = type_art(Paint::solid(red), Paint::solid(red));
let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]);
assert_eq!(art_colors([&g]), vec![red, blue]);
let NodeKind::Text(mut t) = text.kind else { panic!("not type") };
t.runs[0].style.stroke_width = 0.0;
let text = Node::new(NodeId(0), NodeKind::Text(t));
let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted");
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
+1
View File
@@ -705,6 +705,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.
Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page states that "The colors that a scatter, art, or pattern brush paints depend on the current stroke color and the colorization method of the brush" (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).
Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value.
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" (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>). 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}`).