mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
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).
75 lines
3.1 KiB
Rust
75 lines
3.1 KiB
Rust
//! Brushes: stretch shapes bend along the path, scatter copies are spaced, capped and repeatable,
|
|
//! and a width profile (pen pressure) scales them.
|
|
|
|
use figcraft_doc::{Brush, StrokeAlign, StrokeProps, WidthProfile};
|
|
use figcraft_geom::kurbo::{BezPath, Point, Shape};
|
|
|
|
use crate::brush::{MAX_COPIES, PRESETS, outline};
|
|
|
|
fn line(len: f64) -> BezPath {
|
|
let mut p = BezPath::new();
|
|
p.move_to((0.0, 0.0));
|
|
p.line_to((len, 0.0));
|
|
p
|
|
}
|
|
|
|
fn brush(preset: &str) -> Brush {
|
|
Brush { preset: preset.into(), ..Default::default() }
|
|
}
|
|
|
|
fn props(weight: f64, b: &Brush) -> StrokeProps {
|
|
StrokeProps { weight, align: StrokeAlign::Center, brush: Some(b.clone()), ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn every_preset_draws_inside_its_reach() {
|
|
for p in PRESETS {
|
|
let b = brush(p.id);
|
|
let out = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap();
|
|
let bb = out.bounding_box();
|
|
assert!(!out.elements().is_empty(), "{}", p.id);
|
|
assert!(bb.y0 >= -5.5 && bb.y1 <= 5.5 && bb.x0 >= -5.5 && bb.x1 <= 205.5, "{}: {bb:?}", p.id);
|
|
}
|
|
assert!(outline(&line(10.0), 1.0, &props(1.0, &brush("nope")), &brush("nope")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn stretch_bends_with_the_path() {
|
|
// Down a vertical line, the taper's widest point sits halfway down, across x.
|
|
let mut p = BezPath::new();
|
|
p.move_to((0.0, 0.0));
|
|
p.line_to((0.0, 100.0));
|
|
let b = brush("taper");
|
|
let out = outline(&p, 20.0, &props(20.0, &b), &b).unwrap();
|
|
let bb = out.bounding_box();
|
|
assert!((bb.height() - 100.0).abs() < 1.0 && (bb.width() - 15.0).abs() < 1.5, "{bb:?}");
|
|
assert_ne!(out.winding(Point::new(0.0, 50.0)), 0, "filled in the middle");
|
|
assert_eq!(out.winding(Point::new(6.0, 2.0)), 0, "pointed at the start");
|
|
}
|
|
|
|
#[test]
|
|
fn scatter_is_spaced_capped_and_repeatable() {
|
|
let b = Brush { spacing: 2.0, size_jitter: 0.5, rotation_jitter: 1.0, scatter: 1.0, seed: 7, ..brush("dots") };
|
|
let a = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap();
|
|
assert_eq!(a, outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap(), "the same seed, the same copies");
|
|
let c = Brush { seed: 8, ..b.clone() };
|
|
assert_ne!(a, outline(&line(200.0), 10.0, &props(10.0, &c), &c).unwrap());
|
|
// 200 long, a copy every 20: ten circles (each a move-to).
|
|
let moves = |p: &BezPath| p.elements().iter().filter(|e| matches!(e, figcraft_geom::kurbo::PathEl::MoveTo(_))).count();
|
|
assert_eq!(moves(&a), 10);
|
|
// A tiny spacing on a long path stops at the cap.
|
|
let dense = Brush { spacing: 0.0, ..brush("dots") };
|
|
assert!(moves(&outline(&line(1e6), 1.0, &props(1.0, &dense), &dense).unwrap()) <= MAX_COPIES + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_width_profile_scales_the_brush() {
|
|
let b = brush("dots");
|
|
let mut pr = props(10.0, &b);
|
|
pr.width_profile = Some(WidthProfile::new([(0.0, 0.2), (1.0, 2.0)]));
|
|
let out = outline(&line(300.0), 10.0, &pr, &b).unwrap();
|
|
let parts = figcraft_geom::split_subpaths(&out);
|
|
let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box());
|
|
assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}");
|
|
}
|