mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:40:57 -06:00
Double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens its options. Calligraphic Brush Options: Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation, over live previews of the nib and of a stroke. Bristle Brush Options: Shape and the bristle sliders. OK runs brush.options; strokes painted with the brush update. The Paintbrush records the pen pressure along a stroke (path.freehand takes [x, y, pressure] points), and a Calligraphic brush with Pressure variation changes its nib along the stroke, from the value less the variation at the lightest pressure to the value plus it at the heaviest. Brushes saved before keep their Random variation. Pen pressure doesn't reach the app on macOS yet (winit 0.30 reports none there). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.0 KiB
Rust
219 lines
9.0 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,
|
||
}),
|
||
},
|
||
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,
|
||
}),
|
||
},
|
||
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()
|
||
}),
|
||
},
|
||
]
|
||
})
|
||
}
|