mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
Double-clicking a Scatter, Art or Pattern brush in the Brushes panel (or the panel menu's Brush Options...) now opens its options dialog, as Calligraphic and Bristle brushes already did. OK runs brush.options and every stroke painted with the brush redraws. - Scatter Brush Options: Name; Size (10-1000 %), Spacing (10-10000 %), Scatter (-1000-1000 %) and Rotation (-180-180 deg), each Fixed (the first value), Random (between the two values) or Pressure (the first value at the lightest pen pressure, the second at the heaviest); Rotation relative to Page or Path. Scatter brushes now keep these modes (`modes`); brushes saved before keep their look (Random where the two values differ). - Art Brush Options: Name; Width (1-1000 %); Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' Start and End (% of the art length); the four Directions; Flip Along and Across. - Pattern Brush Options: Name; Scale (1-10000 %), Spacing (0-10000 %); the five tiles (Outer Corner, Side, Inner Corner, Start, End) drawn with their art or None; Flip Along and Across; Stretch to fit, Add space to fit or Approximate path. - All three: Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours, the most used one by default) and a live preview of a stroke painted with the edited brush. Brush definitions from files and commands are now kept within these ranges (Brush::sanitize, also for Calligraphic and Bristle values); unknown modes, directions, fits or colorization methods are refused. The brush.options and ui.brushOptions docs list the new params and fields. Tests: the brush model (modes, pressure, sanitizing, art colours), engine params reaching the brush and strokes redrawing, junk params in command_sweep, and headless dialog tests for each dialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
9.1 KiB
Rust
221 lines
9.1 KiB
Rust
//! The default brush library. All art is original and drawn here in code.
|
||
//!
|
||
//! Art is authored at 1 pt stroke weight, left → right, centred on y = 0.
|
||
|
||
use std::sync::{Arc, OnceLock};
|
||
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId, NodeKind};
|
||
use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath, shapes};
|
||
|
||
use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter};
|
||
|
||
fn fill(c: Color) -> Appearance {
|
||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], ..Default::default() }
|
||
}
|
||
|
||
fn path(p: PathData, c: Color) -> Node {
|
||
Node::path(NodeId(0), p, fill(c))
|
||
}
|
||
|
||
fn group(children: Vec<Node>) -> Node {
|
||
Node::group(NodeId(0), children.into_iter().map(Arc::new).collect())
|
||
}
|
||
|
||
/// Even-odd compound of `paths`, filled `c`.
|
||
fn compound(paths: Vec<PathData>, c: Color) -> Node {
|
||
let children = paths.into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
|
||
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
|
||
n.appearance = fill(c);
|
||
n
|
||
}
|
||
|
||
fn smooth_closed(pts: &[Point]) -> PathData {
|
||
let n = pts.len();
|
||
let anchors = (0..n)
|
||
.map(|i| {
|
||
let (prev, p, next) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]);
|
||
let t = (next - prev) / 6.0;
|
||
Anchor::with_handles(p, p - t, p + t)
|
||
})
|
||
.collect();
|
||
PathData::single(SubPath::new(anchors, true))
|
||
}
|
||
|
||
/// A 4 pt dot (Dots scatter brush, default scatter art).
|
||
pub(crate) fn dot_art() -> Node {
|
||
path(shapes::ellipse(Rect::new(-2.0, -2.0, 2.0, 2.0)), Color::BLACK)
|
||
}
|
||
|
||
/// A smooth lens tapering to points at both ends, 100 × 4 pt.
|
||
pub(crate) fn taper_art() -> Node {
|
||
let sp = SubPath::new(
|
||
vec![
|
||
Anchor::with_handles(Point::new(0.0, 0.0), Point::new(30.0, 2.7), Point::new(30.0, -2.7)),
|
||
Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, -2.7), Point::new(70.0, 2.7)),
|
||
],
|
||
true,
|
||
);
|
||
path(PathData::single(sp), Color::BLACK)
|
||
}
|
||
|
||
/// A rough, tapered charcoal smear, 100 × 6 pt.
|
||
fn charcoal_art() -> Node {
|
||
let n = 24;
|
||
let jitter =
|
||
[0.82, 1.0, 0.9, 0.7, 0.95, 0.85, 1.0, 0.75, 0.92, 0.8, 0.98, 0.72, 0.88, 1.0, 0.78, 0.93, 0.86, 0.7, 0.97, 0.83, 0.9, 0.76, 1.0, 0.85];
|
||
let mut pts = vec![];
|
||
for i in 0..=n {
|
||
let x = 100.0 * i as f64 / n as f64;
|
||
let env = (std::f64::consts::PI * x / 100.0).sin().powf(0.6);
|
||
pts.push(Point::new(x, -3.0 * env * jitter[i % jitter.len()]));
|
||
}
|
||
for i in (1..n).rev() {
|
||
let x = 100.0 * i as f64 / n as f64;
|
||
let env = (std::f64::consts::PI * x / 100.0).sin().powf(0.6);
|
||
pts.push(Point::new(x, 3.0 * env * jitter[(i * 7 + 3) % jitter.len()]));
|
||
}
|
||
let main = path(PathData::single(SubPath::polyline(&pts, true)), Color::gray(0.85));
|
||
// A few grainy streaks inside.
|
||
let streak = |y: f64, x0: f64, x1: f64| path(shapes::ellipse(Rect::new(x0, y - 0.35, x1, y + 0.35)), Color::WHITE);
|
||
group(vec![main, streak(-1.2, 20.0, 45.0), streak(0.9, 55.0, 85.0), streak(0.1, 8.0, 22.0)])
|
||
}
|
||
|
||
/// A shaft with a triangular head at the right end, 100 × 8 pt.
|
||
fn arrow_art() -> Node {
|
||
let shaft = shapes::rectangle(Rect::new(0.0, -1.0, 84.0, 1.0));
|
||
let head = PathData::single(SubPath::polyline(&[Point::new(80.0, -4.0), Point::new(100.0, 0.0), Point::new(80.0, 4.0)], true));
|
||
group(vec![path(shaft, Color::BLACK), path(head, Color::BLACK)])
|
||
}
|
||
|
||
/// A small leaf, 12 × 6 pt, pointing right.
|
||
fn leaf_art() -> Node {
|
||
let body = smooth_closed(&[Point::new(0.0, 0.0), Point::new(6.0, -3.0), Point::new(12.0, 0.0), Point::new(6.0, 3.0)]);
|
||
let vein = shapes::rectangle(Rect::new(1.0, -0.2, 11.0, 0.2));
|
||
group(vec![path(body, Color::rgb8(0x3f, 0x8f, 0x4a)), path(vein, Color::rgb8(0xc9, 0xe8, 0xb5))])
|
||
}
|
||
|
||
/// Two confetti squares of different greys (tinted by the stroke colour).
|
||
fn confetti_art() -> Node {
|
||
group(vec![
|
||
path(shapes::rectangle(Rect::new(-1.5, -1.5, 1.5, 1.5)), Color::BLACK),
|
||
path(shapes::rectangle(Rect::new(0.5, 0.5, 2.5, 2.5)), Color::gray(0.45)),
|
||
])
|
||
}
|
||
|
||
/// One chain link (an oval ring) plus half of the connecting links, 14 × 6 pt.
|
||
fn chain_tile() -> Node {
|
||
let ring = compound(vec![shapes::ellipse(Rect::new(1.0, -3.0, 13.0, 3.0)), shapes::ellipse(Rect::new(3.0, -1.4, 11.0, 1.4))], Color::BLACK);
|
||
let bar_l = path(shapes::rectangle(Rect::new(0.0, -0.6, 1.5, 0.6)), Color::BLACK);
|
||
let bar_r = path(shapes::rectangle(Rect::new(12.5, -0.6, 14.0, 0.6)), Color::BLACK);
|
||
group(vec![ring, bar_l, bar_r])
|
||
}
|
||
|
||
/// A round ring for chain corners, 8 × 8 pt.
|
||
fn chain_corner() -> Node {
|
||
compound(vec![shapes::ellipse(Rect::new(-4.0, -4.0, 4.0, 4.0)), shapes::ellipse(Rect::new(-2.2, -2.2, 2.2, 2.2))], Color::BLACK)
|
||
}
|
||
|
||
/// A dash with a clear gap (running stitches), 10 × 1.5 pt.
|
||
pub(crate) fn stitch_tile() -> Node {
|
||
let dash = path(shapes::rounded_rectangle(Rect::new(1.5, -0.75, 8.5, 0.75), 0.75), Color::BLACK);
|
||
// Invisible spacer that fixes the tile width.
|
||
let mut spacer = path(shapes::rectangle(Rect::new(0.0, -0.75, 10.0, 0.75)), Color::BLACK);
|
||
spacer.appearance = Appearance::default();
|
||
group(vec![spacer, dash])
|
||
}
|
||
|
||
/// A diamond tile for borders, 8 × 6 pt.
|
||
fn diamond_tile() -> Node {
|
||
let d = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(4.0, -3.0), Point::new(8.0, 0.0), Point::new(4.0, 3.0)], true));
|
||
path(d, Color::BLACK)
|
||
}
|
||
|
||
fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
|
||
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
|
||
}
|
||
|
||
/// The default library.
|
||
pub fn defaults() -> &'static [Brush] {
|
||
static LIB: OnceLock<Vec<Brush>> = OnceLock::new();
|
||
LIB.get_or_init(|| {
|
||
vec![
|
||
calli("3 pt. Round", 0.0, 100.0, 3.0),
|
||
calli("6 pt. Flat", 45.0, 15.0, 6.0),
|
||
calli("10 pt. Oval", 30.0, 45.0, 10.0),
|
||
Brush {
|
||
name: "Charcoal".into(),
|
||
kind: BrushKind::Art(ArtBrush { art: charcoal_art(), colorization: Colorization::Tints, ..Default::default() }),
|
||
},
|
||
Brush { name: "Tapered Stroke".into(), kind: BrushKind::Art(ArtBrush::default()) },
|
||
Brush {
|
||
name: "Arrow".into(),
|
||
kind: BrushKind::Art(ArtBrush {
|
||
art: arrow_art(),
|
||
direction: Direction::LeftToRight,
|
||
scale: ArtScale::BetweenGuides { start: 0.0, end: 0.78 },
|
||
colorization: Colorization::Tints,
|
||
..Default::default()
|
||
}),
|
||
},
|
||
Brush {
|
||
name: "Dots".into(),
|
||
kind: BrushKind::Scatter(Scatter { spacing: (160.0, 160.0), colorization: Colorization::Tints, ..Default::default() }),
|
||
},
|
||
Brush {
|
||
name: "Confetti".into(),
|
||
kind: BrushKind::Scatter(Scatter {
|
||
art: confetti_art(),
|
||
size: (60.0, 140.0),
|
||
spacing: (120.0, 220.0),
|
||
scatter: (-150.0, 150.0),
|
||
rotation: (-180.0, 180.0),
|
||
rotation_relative_to_path: false,
|
||
colorization: Colorization::Tints,
|
||
modes: None,
|
||
}),
|
||
},
|
||
Brush {
|
||
name: "Leaves".into(),
|
||
kind: BrushKind::Scatter(Scatter {
|
||
art: leaf_art(),
|
||
size: (80.0, 120.0),
|
||
spacing: (90.0, 130.0),
|
||
scatter: (-60.0, 60.0),
|
||
rotation: (-35.0, 35.0),
|
||
rotation_relative_to_path: true,
|
||
colorization: Colorization::None,
|
||
modes: None,
|
||
}),
|
||
},
|
||
Brush {
|
||
name: "Chain".into(),
|
||
kind: BrushKind::Pattern(PatternBrush {
|
||
side: chain_tile(),
|
||
outer_corner: Some(chain_corner()),
|
||
inner_corner: Some(chain_corner()),
|
||
fit: PatternFit::Stretch,
|
||
..Default::default()
|
||
}),
|
||
},
|
||
Brush { name: "Stitches".into(), kind: BrushKind::Pattern(PatternBrush::default()) },
|
||
Brush {
|
||
name: "Diamonds".into(),
|
||
kind: BrushKind::Pattern(PatternBrush { side: diamond_tile(), spacing: 25.0, fit: PatternFit::AddSpace, ..Default::default() }),
|
||
},
|
||
Brush { name: "Bristle Round".into(), kind: BrushKind::Bristle(Bristle::default()) },
|
||
Brush {
|
||
name: "Bristle Flat Fan".into(),
|
||
kind: BrushKind::Bristle(Bristle {
|
||
shape: BristleShape::FlatFan,
|
||
size: 9.0,
|
||
density: 80.0,
|
||
thickness: 25.0,
|
||
opacity: 55.0,
|
||
..Default::default()
|
||
}),
|
||
},
|
||
]
|
||
})
|
||
}
|