Files
figcraft/crates/pathops/src/brush.rs
T
BFlat cb5435d5ff M4: stretch and scatter brushes, pen pressure (#120)
Strokes can use a brush: eight presets drawn in code (four stretched along the path, four scattered with spacing, size, rotation and offset jitter from a fixed seed), drawn by the shared stroke-outline code in canvas, PNG, SVG, PDF and outline stroke, with caps on copies and steps. Pointer events carry pen pressure; the Pencil records it as the stroke's width profile, which brushes follow (Windows pens and browser styluses; constant width elsewhere).
2026-10-11 13:43:48 +07:00

200 lines
8.1 KiB
Rust

//! Brushes: strokes drawn with a shape instead of a band. A stretch brush bends one shape along
//! each subpath; a scatter brush drops copies of a shape along it, spaced by the stroke weight and
//! jittered in size, rotation and distance from the path by a seeded (repeatable) sequence. The
//! shapes are the built-in presets below, drawn in code. A width profile (pen pressure) scales the
//! brush along the path. The result may overlap itself; fill it with the non-zero rule.
use crate::width::{Polyline, polylines};
use figcraft_doc::{Brush, StrokeProps};
use figcraft_geom::kurbo::{self, Affine, BezPath, PathEl, Point, Vec2};
/// What a preset does with its shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BrushKind {
Stretch,
Scatter,
}
/// A built-in brush: id (saved in files), label, kind.
pub struct Preset {
pub id: &'static str,
pub label: &'static str,
pub kind: BrushKind,
}
/// The built-in brushes, stretch first.
pub const PRESETS: &[Preset] = &[
Preset { id: "taper", label: "Taper", kind: BrushKind::Stretch },
Preset { id: "ink", label: "Ink", kind: BrushKind::Stretch },
Preset { id: "ribbon", label: "Ribbon", kind: BrushKind::Stretch },
Preset { id: "bristle", label: "Bristle", kind: BrushKind::Stretch },
Preset { id: "dots", label: "Dots", kind: BrushKind::Scatter },
Preset { id: "stars", label: "Stars", kind: BrushKind::Scatter },
Preset { id: "confetti", label: "Confetti", kind: BrushKind::Scatter },
Preset { id: "leaves", label: "Leaves", kind: BrushKind::Scatter },
];
/// The most copies a scatter brush makes along a whole path; denser spacing spreads them out.
pub const MAX_COPIES: usize = 5_000;
/// Along-path steps of a stretched shape, per subpath.
const STRETCH_STEPS: usize = 512;
/// Along-path steps of a stretched shape over the whole path (many subpaths get fewer each).
const MAX_STRETCH_STEPS: usize = 65_536;
/// The built-in brush `id`.
pub fn preset(id: &str) -> Option<&'static Preset> {
PRESETS.iter().find(|p| p.id == id)
}
/// A preset's shape: stretch shapes span x 0..1 along the path and y -0.5..0.5 across it (one
/// stroke weight); scatter shapes are about one unit wide, centred on the origin, pointing along x.
fn shape(id: &str) -> Option<BezPath> {
let mut p = BezPath::new();
match id {
"taper" => {
p.move_to((0.0, 0.0));
p.curve_to((0.3, -0.5), (0.7, -0.5), (1.0, 0.0));
p.curve_to((0.7, 0.5), (0.3, 0.5), (0.0, 0.0));
p.close_path();
}
// A blunt start, uneven edges and a long tail.
"ink" => band(&mut p, |x| {
let w = (x / 0.04).min(1.0) * ((1.0 - x) / 0.3).min(1.0);
(-0.5 * w + 0.04 * (9.0 * std::f64::consts::PI * x).sin() * w, 0.45 * w + 0.04 * (13.0 * std::f64::consts::PI * x + 1.0).sin() * w)
}),
// Edges that cross three times: a twisting ribbon.
"ribbon" => band(&mut p, |x| {
let c = 0.5 * (3.0 * std::f64::consts::PI * x).cos();
(-c, c)
}),
// Four thin strands with ragged ends.
"bristle" => {
for k in 0..4 {
let (k, y) = (f64::from(k), -0.375 + 0.25 * f64::from(k));
let (x0, x1) = (0.03 * k, 1.0 - 0.04 * (3.0 - k));
let mut s = BezPath::new();
band(&mut s, |x| {
let u = ((x - x0) / (x1 - x0)).clamp(0.0, 1.0);
let h = 0.09 * (std::f64::consts::PI * u).sin().max(0.0).sqrt();
(y - h, y + h)
});
p.extend(s);
}
}
"dots" => p.extend(kurbo::Shape::path_elements(&kurbo::Circle::new((0.0, 0.0), 0.5), 0.01)),
"stars" => {
let pts = figcraft_geom::star_points(1.0, 1.0, 5, 0.4);
for (i, q) in pts.iter().enumerate() {
let q = Point::new(q.x - 0.5, q.y - 0.5);
if i == 0 { p.move_to(q) } else { p.line_to(q) }
}
p.close_path();
}
"confetti" => p.extend(kurbo::Shape::path_elements(&kurbo::Rect::new(-0.3, -0.3, 0.3, 0.3), 0.01)),
"leaves" => {
p.move_to((-0.5, 0.0));
p.curve_to((-0.2, -0.3), (0.2, -0.3), (0.5, 0.0));
p.curve_to((0.2, 0.3), (-0.2, 0.3), (-0.5, 0.0));
p.close_path();
}
_ => return None,
}
Some(p)
}
/// A closed band from x 0 to 1 between edges `f(x) = (top, bottom)`.
fn band(p: &mut BezPath, f: impl Fn(f64) -> (f64, f64)) {
const N: usize = 64;
let xs = (0..=N).map(|i| i as f64 / N as f64);
for (i, x) in xs.clone().enumerate() {
let q = (x, f(x).0);
if i == 0 { p.move_to(q) } else { p.line_to(q) }
}
for x in xs.rev() {
p.line_to((x, f(x).1));
}
p.close_path();
}
/// A repeatable pseudo-random number in 0..1 for copy `i`, channel `k` (splitmix64).
fn rand(seed: u32, i: u64, k: u64) -> f64 {
let mut z = u64::from(seed) ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ k.wrapping_mul(0xD1B5_4A32_D192_ED03);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 / (1u64 << 53) as f64
}
/// The stroke of `path` drawn with `brush` at `width` (the profile, if any, scales it along each
/// subpath). `None` for an unknown preset or a path too long to measure.
pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) -> Option<BezPath> {
let p = preset(&brush.preset)?;
let unit = shape(p.id)?;
let lines = polylines(path)?;
let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t));
let mut out = BezPath::new();
match p.kind {
BrushKind::Stretch => {
let steps = (MAX_STRETCH_STEPS / lines.len().max(1)).clamp(8, STRETCH_STEPS);
for line in &lines {
stretch(&mut out, &unit, line, steps, &scale);
}
}
BrushKind::Scatter => scatter(&mut out, &unit, &lines, width, brush, &scale),
}
Some(out)
}
/// `unit` bent along `line` in at most `steps` steps along it: x by length, y across at the scaled
/// width.
fn stretch(out: &mut BezPath, unit: &BezPath, line: &Polyline, steps: usize, scale: &dyn Fn(f64) -> f64) {
let map = |q: Point| line.at(q.x).map(|(p, n)| p + n * (q.y * scale(q.x)));
let mut last = Point::ZERO;
kurbo::flatten(unit.iter(), 0.002, |el| match el {
PathEl::MoveTo(q) => {
last = q;
if let Some(m) = map(q) {
out.move_to(m);
}
}
PathEl::LineTo(q) => {
// Short steps along x so the shape bends with the path.
let n = (((q.x - last.x).abs() * steps as f64).ceil() as usize).clamp(1, steps);
for k in 1..=n {
if let Some(m) = map(last.lerp(q, k as f64 / n as f64)) {
out.line_to(m);
}
}
last = q;
}
PathEl::ClosePath => out.close_path(),
_ => {}
});
}
/// Copies of `unit` along `lines`, `brush.spacing` weights apart, jittered.
fn scatter(out: &mut BezPath, unit: &BezPath, lines: &[Polyline], width: f64, brush: &Brush, scale: &dyn Fn(f64) -> f64) {
let total: f64 = lines.iter().map(Polyline::length).sum();
let b = brush.sanitized();
let step = (b.spacing * width).max(total / MAX_COPIES as f64).max(1e-3);
let mut i = 0u64;
for line in lines {
let len = line.length();
let mut s = step / 2.0;
while s < len {
let t = s / len;
if let Some((p, n)) = line.at(t) {
let r = |k| rand(b.seed, i, k) * 2.0 - 1.0;
let size = scale(t) * (1.0 + b.size_jitter * r(0)).max(0.05);
let dir = Vec2::new(n.y, -n.x);
let angle = dir.atan2() + b.rotation_jitter * std::f64::consts::PI * r(1);
let at = p + n * (b.scatter * width * r(2));
let xf = Affine::translate(at.to_vec2()) * Affine::rotate(angle) * Affine::scale(size);
out.extend(unit.iter().map(|el| xf * el));
}
i += 1;
s += step;
}
}
}