Files
figcraft/crates/pathops/src/brush_tests.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

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:?}");
}