Files
vectorcraft/crates/brush/src/tests.rs
T
Muhammad Saad 674a485145 Brushes: one output budget across tracks, tiles and copies (#3594)
Pattern tiling and scatter cap each factor on its own: 20,000 tiles a
run, MAX_SUB subdivisions a segment, 20,000 copies a track. Those caps
are per track, and a path has as many tracks as it has subpaths, so
they multiply rather than bound.

The audit called the end-to-end impact unverified, so it was measured.
A pattern brush on parallel lines, each long enough to want more tiles
than a run allows:

   1 subpath    20,000 nodes     320,000 anchors    36 ms
   4 subpaths   80,000 nodes   1,280,000 anchors   142 ms
  16 subpaths  320,000 nodes   5,120,000 anchors   571 ms

Exactly 20,000 nodes a subpath, linear with no ceiling. Scatter is the
same shape.

MAX_ANCHORS bounds the total for one brushed stroke, threaded through
both. It is 2,000,000, the figure the stacked-effects budget already
uses (effects::distort::MAX_COPY_ANCHORS, #3578), and bounded the same
way: per stroke rather than per document. One track at its own cap is
well inside it, so the budget bites only where the caps were
multiplying; sixteen subpaths now stop at the budget in 113 ms.

A piece is taken whole or not at all, so art is never half-drawn. The
walk that counts an art tile's anchors is iterative, and a tree it
cannot finish within MAX_WALK nodes is charged the whole budget rather
than undercounted, so nesting buys no room.

The per-factor caps are untouched: the issue asks for a total, and
changing both at once would hide which one did the bounding.
2026-10-23 06:56:56 +07:00

768 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Point, Rect, shapes};
use super::*;
fn line(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
let mut bp = BezPath::new();
bp.move_to((x0, y0));
bp.line_to((x1, y1));
bp
}
fn stroke(w: f64) -> StrokeLayer {
StrokeLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), w)
}
fn calli(angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
fn bounds(nodes: &[Node]) -> Rect {
nodes.iter().fold(None, |a, n| vectorcraft_geom::union_opt(a, n.geometric_bounds())).unwrap()
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn all_points(n: &Node, out: &mut Vec<Point>) {
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
out.extend(p.anchors().map(|(_, _, a)| a.p));
}
});
}
#[test]
fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 1);
let b = bounds(&out);
assert!(near(b.height(), 10.0, 0.05), "{b:?}");
assert!(near(b.x0, -1.0, 0.05) && near(b.x1, 101.0, 0.05), "caps add half the minor axis: {b:?}");
}
#[test]
fn calligraphic_flat_nib_is_thin_along_its_angle() {
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out));
}
#[test]
fn calligraphic_width_at_45_degrees_matches_ellipse_support() {
let (a, b) = (5.0_f64, 1.0_f64);
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt();
assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height());
// A vertical stroke with the same nib has the same width (45° is symmetric).
let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
assert!(near(bounds(&v).width(), expect, 0.05));
}
#[test]
fn calligraphic_scales_with_stroke_weight() {
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
assert!(near(bounds(&out).height(), 6.0, 0.05));
}
#[test]
fn round_nib_is_direction_independent() {
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let b = bounds(&out);
// Round caps: the bounds grow by the radius on every side.
assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}");
}
#[test]
fn closed_calligraphic_makes_a_ring() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let pd = out[0].path_data().unwrap();
assert_eq!(pd.subpaths.len(), 2);
let poly_area = |sp: &vectorcraft_geom::SubPath| {
let a = &sp.anchors;
(0..a.len()).map(|i| a[i].p.x * a[(i + 1) % a.len()].p.y - a[(i + 1) % a.len()].p.x * a[i].p.y).sum::<f64>() / 2.0
};
let (outer, inner) = (poly_area(&pd.subpaths[0]), poly_area(&pd.subpaths[1]));
assert!(outer * inner < 0.0, "opposite windings make a hole");
let area = (outer.abs() - inner.abs()).abs();
let expect = std::f64::consts::PI * (53.0f64.powi(2) - 47.0f64.powi(2));
assert!((area - expect).abs() / expect < 0.03, "{area} vs {expect}");
}
fn art_brush(art: Node, scale: ArtScale) -> Brush {
Brush { name: "a".into(), kind: BrushKind::Art(ArtBrush { art, scale, colorization: Colorization::None, ..Default::default() }) }
}
fn rect_art(w: f64, h: f64) -> Node {
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -h / 2.0, w, h / 2.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))
}
#[test]
fn art_brush_stretches_to_path_length() {
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let b = bounds(&out);
assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}");
assert!(near(b.height(), 4.0, 1e-6));
// 3 pt stroke → 3× wider.
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
assert!(near(bounds(&out).height(), 12.0, 1e-6));
}
#[test]
fn art_brush_arc_length_mapping_is_linear() {
// A marker square at art x ∈ [5, 6] of a 10 wide art lands at 50..60 % of the path.
let marker =
Node::path(NodeId(0), shapes::rectangle(Rect::new(5.0, -1.0, 6.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let frame = rect_art(10.0, 2.0);
let mut spacer = frame.clone();
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let g = &out[0];
let m = g.children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}");
}
#[test]
fn art_brush_proportional_keeps_aspect() {
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
#[test]
fn art_brush_between_guides_keeps_ends() {
// Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path.
let head =
Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let shaft = rect_art(80.0, 2.0);
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]);
let out = stroke_pieces(
&Document::new(1.0, 1.0),
&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }),
&line(0.0, 0.0, 300.0, 0.0),
&stroke(1.0),
);
let h = out[0].children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
}
#[test]
fn art_brush_overlap_splits_at_corners() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((100.0, 0.0));
bp.line_to((100.0, 100.0));
let brush = |overlap: bool| Brush {
name: "a".into(),
kind: BrushKind::Art(ArtBrush {
art: rect_art(10.0, 4.0),
scale: ArtScale::Stretch,
overlap,
colorization: Colorization::None,
..Default::default()
}),
};
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush(false), &bp, &stroke(1.0));
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush(true), &bp, &stroke(1.0));
assert_eq!(one.len(), 1, "one bent copy along the whole path");
assert_eq!(two.len(), 2, "one copy per run between corners");
}
#[test]
fn art_brush_follows_curves() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
let mut bp_open = BezPath::new();
// Upper half of the circle, as an open path.
for (i, el) in bp.elements().iter().enumerate() {
if i < 3 {
bp_open.push(*el);
}
}
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let mut pts = vec![];
all_points(&out[0], &mut pts);
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
for p in pts {
let r = p.distance(Point::new(100.0, 100.0));
assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre");
}
}
fn scatter_brush(spacing: f64) -> Brush {
Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter {
art: rect_art(10.0, 4.0),
spacing: (spacing, spacing),
colorization: Colorization::None,
..Default::default()
}),
}
}
#[test]
fn scatter_count_and_spacing() {
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 10);
let centres: Vec<f64> = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect();
for (i, c) in centres.iter().enumerate() {
assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}");
}
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 5);
}
#[test]
fn scatter_is_deterministic_and_randomised() {
let b = Brush {
name: "r".into(),
kind: BrushKind::Scatter(Scatter {
art: rect_art(4.0, 4.0),
size: (50.0, 150.0),
scatter: (-100.0, 100.0),
rotation: (-90.0, 90.0),
..Default::default()
}),
};
let p = line(0.0, 0.0, 300.0, 0.0);
let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
assert_eq!(a1, a2);
let ys: Vec<f64> = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect();
assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}");
}
fn pattern_brush(fit: PatternFit, corners: bool) -> Brush {
let side = rect_art(10.0, 4.0);
let corner =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -3.0, 6.0, 3.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush {
side,
outer_corner: corners.then(|| corner.clone()),
inner_corner: corners.then_some(corner),
fit,
colorization: Colorization::None,
..Default::default()
}),
}
}
#[test]
fn pattern_tile_count_by_fit() {
let p = line(0.0, 0.0, 100.0, 0.0);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
let p2 = line(0.0, 0.0, 104.0, 0.0);
let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
assert_eq!(st.len(), 10);
assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly");
let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
assert_eq!(sp.len(), 10);
assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size");
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
}
#[test]
fn pattern_places_corner_tiles() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let corners = out
.iter()
.filter(
|n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0),
)
.count();
assert_eq!(corners, 4);
// Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each.
assert_eq!(out.len() - corners, 36);
}
#[test]
fn bristle_makes_translucent_strands() {
let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(out.len() >= 5, "{}", out.len());
assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0));
let bb = bounds(&out);
assert!(bb.height() <= 6.0 * 1.3 && bb.height() >= 3.0, "{bb:?}");
}
#[test]
fn tints_colorization_uses_stroke_colour() {
let b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
a.colorization = Colorization::Tints;
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let mut white = rect_art(1.0, 1.0);
white.appearance.set_fill(Paint::solid(Color::WHITE));
colorize(&mut white, Colorization::Tints, &Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
fn type_art(fill: Paint, stroke: Paint) -> Node {
use vectorcraft_doc::{CharStyle, TextObject};
let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() };
Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style))))
}
/// The characters' fills and strokes of every piece of type in `nodes`.
fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> {
let mut out = vec![];
for n in nodes {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())));
}
});
}
out
}
/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths.
#[test]
fn scatter_colorization_recolors_type() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0);
let mut path = rect_art(10.0, 4.0);
path.appearance.set_fill(red.clone());
let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]);
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
let path_fill = out[0].children().unwrap()[0].appearance.fill_paint();
assert_ne!(path_fill, red);
let paints = run_paints(&out);
assert_eq!(paints.len(), out.len());
assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}");
// Black type takes the stroke color itself.
let black = Paint::solid(Color::BLACK);
let b = Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone())));
}
/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills.
#[test]
fn stain_mixes_type_strokes_too() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut art = type_art(red.clone(), red);
tint_node(&mut art, &blue, 1.0);
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
}
#[test]
fn library_defaults_store_and_find() {
let mut d = Document::new(100.0, 100.0);
let lib = library(&d);
assert!(lib.len() >= 10);
for kind in ["calligraphic", "scatter", "art", "pattern", "bristle"] {
assert!(lib.iter().any(|b| b.kind.type_id() == kind), "{kind}");
}
assert!(find(&d, "Chain").is_some());
let mut lib = lib;
lib.retain(|b| b.name != "Chain");
store(&mut d, &lib);
assert!(find(&d, "Chain").is_none());
assert_eq!(library(&d).len(), lib.len());
assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2");
}
#[test]
fn brushes_serde_round_trip() {
let lib = defaults().to_vec();
let s = serde_json::to_string(&lib).unwrap();
let back: Vec<Brush> = serde_json::from_str(&s).unwrap();
assert_eq!(back, lib);
// Partial definitions fill in defaults.
let b: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "angle": 30})).unwrap();
assert_eq!(b.kind, BrushKind::Calligraphic(Calligraphic { angle: 30.0, ..Default::default() }));
}
#[test]
fn every_default_brush_draws_something() {
let bp = preview_path(120.0, 24.0);
for b in defaults() {
let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0));
assert!(!out.is_empty(), "{}", b.name);
let bb = bounds(&out);
assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name);
}
}
#[test]
fn expand_keeps_fill_and_adds_pieces() {
let mut d = Document::new(200.0, 200.0);
let mut ap = Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), ap);
assert!(has_brush(&n));
let g = expand(&d, &n).unwrap();
let ch = g.children().unwrap();
assert_eq!(ch.len(), 2);
assert!(ch[0].appearance.stroke().is_none() && !ch[0].appearance.fill_paint().is_none());
assert_eq!(ch[1].appearance.fill_paint(), Paint::solid(Color::BLACK));
let b = ch[1].geometric_bounds().unwrap();
assert!(near(b.x0, 8.5, 0.05) && near(b.x1, 91.5, 0.05), "{b:?}");
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
assert!(expand(&d, &plain).is_none());
}
/// A Calligraphic brush (round nib) whose size follows the pen pressure by `var` either way.
fn pressure_calli(size: f64, var: f64) -> Brush {
let c = Calligraphic {
size,
variation: [0.0, 0.0, var],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
..Default::default()
};
Brush { name: "p".into(), kind: BrushKind::Calligraphic(c) }
}
/// The stroke's half-width (largest |y|) among outline points with x in `x0..x1`.
fn half_width(nodes: &[Node], x0: f64, x1: f64) -> f64 {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts.iter().filter(|p| p.x >= x0 && p.x <= x1).map(|p| p.y.abs()).fold(0.0, f64::max)
}
fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) }
}
/// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to
/// size + variation at the heaviest, along the stroke where the pen pressed.
#[test]
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
/// Angle and roundness follow the pressure the same way, clamped to their ranges.
#[test]
fn pressure_varies_angle_and_roundness() {
let c = |modes| Calligraphic { angle: 0.0, roundness: 60.0, size: 10.0, variation: [90.0, 60.0, 0.0], modes: Some(modes) };
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
assert!(near(draw(c([Pressure, Fixed, Fixed]), 0.5), 6.0, 0.05));
assert!(near(draw(c([Pressure, Fixed, Fixed]), 1.0), 10.0, 0.05));
// Roundness 60 ± 60, kept within 1..100: light is a hairline, heavy is round.
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
/// means Fixed, and they are written back without modes.
#[test]
fn brushes_without_modes_vary_randomly_as_before() {
let old: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "size": 8, "variation": [0, 0, 3]})).unwrap();
let BrushKind::Calligraphic(c) = &old.kind else { panic!() };
assert_eq!(c.modes, None);
assert_eq!(c.modes(), [Variation::Fixed, Variation::Fixed, Variation::Random]);
assert!(!c.uses_pressure());
assert!(serde_json::to_value(&old).unwrap().get("modes").is_none(), "written back as it was");
// The same strokes as the brush with its modes spelled out.
let explicit = Brush {
kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed, Variation::Fixed, Variation::Random]), ..c.clone() }),
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0))
);
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
);
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
/// Junk definitions and pressure never panic and never draw non-finite art.
#[test]
fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
}
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{size} {var}");
}
}
}
/// A scatter brush of 10 × 4 pt rectangles whose size goes from `lo` % to `hi` % as `mode` says.
fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
let s = scatter_brush(100.0);
let BrushKind::Scatter(sc) = s.kind else { panic!("not a scatter brush") };
let modes = [mode, Variation::Fixed, Variation::Fixed, Variation::Fixed];
Brush { kind: BrushKind::Scatter(Scatter { size: (lo, hi), modes: Some(modes), ..sc }), ..s }
}
/// Scatter Brush Options: Fixed takes the first value, Pressure goes from the first at the
/// lightest pen pressure to the second at the heaviest; brushes saved before modes keep their look.
#[test]
fn scatter_values_follow_their_modes() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let heights = |b: &Brush, st: &StrokeLayer| -> Vec<f64> {
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect()
};
// Fixed: every copy at 50 %, whatever the second value.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let h = heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &ramp);
let (first, last) = (h[0], h[h.len() - 1]);
assert!(near(first, 2.0, 0.1) && last > 10.0, "{h:?}");
assert!(h.windows(2).all(|w| w[1] >= w[0] - 1e-9), "grows with the pressure: {h:?}");
// Without recorded pressure, half way between the two.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &stroke(1.0)).iter().all(|h| near(*h, 7.0, 1e-6)));
// No modes saved: Random where the values differ, as before; Fixed and Random draw the same.
let old = sized_scatter(50.0, 300.0, Variation::Random);
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0))
);
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
let mut v = serde_json::to_value(sized_scatter(1e308, -1e308, Variation::Random)).unwrap();
v["rotation"] = serde_json::json!([720.0, -1e300]);
v["scatter"] = serde_json::json!([5000.0, 0.0]);
let lib = parse_library(&serde_json::json!([
v,
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width": 1e308, "scale": {"mode": "betweenGuides", "start": -3.0, "end": 9.0}},
{"name": "p", "type": "pattern", "side": rect_art(10.0, 4.0), "scale": 0.0, "spacing": -5.0},
{"name": "c", "type": "calligraphic", "angle": 400.0, "roundness": -1.0, "size": 1e9, "variation": [1e9, -1.0, 2.0]},
{"name": "b", "type": "bristle", "size": 0.0, "length": 1000.0, "density": 0.0},
]));
let [s, a, p, c, b] = lib.as_slice() else { panic!("{lib:?}") };
let BrushKind::Scatter(s) = &s.kind else { panic!() };
assert_eq!((s.size, s.rotation, s.scatter), ((10000.0, 1.0), (180.0, -180.0), (1000.0, 0.0)));
let BrushKind::Art(a) = &a.kind else { panic!() };
assert_eq!((a.width, a.scale), (1000.0, ArtScale::BetweenGuides { start: 0.0, end: 1.0 }));
let BrushKind::Pattern(p) = &p.kind else { panic!() };
assert_eq!((p.scale, p.spacing), (1.0, 0.0));
let BrushKind::Calligraphic(c) = &c.kind else { panic!() };
assert_eq!((c.angle, c.roundness, c.size, c.variation), (180.0, 0.0, 1296.0, [180.0, 0.0, 2.0]));
let BrushKind::Bristle(b) = &b.kind else { panic!() };
assert_eq!((b.size, b.length, b.density), (0.1, 300.0, 1.0));
}
/// Hue Shift's key colour defaults to the art's most used colour.
#[test]
fn art_colors_list_the_most_used_first() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path =
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
// Type counts its characters' fills and drawn strokes.
let text = type_art(Paint::solid(red), Paint::solid(red));
let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]);
assert_eq!(art_colors([&g]), vec![red, blue]);
let NodeKind::Text(mut t) = text.kind else { panic!("not type") };
t.runs[0].style.stroke_width = 0.0;
let text = Node::new(NodeId(0), NodeKind::Text(t));
let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted");
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
/// A Hue Shift definition without a key color takes the art's most used color, or black when the
/// art has no solid color; a key that is given is kept.
#[test]
fn hue_shift_without_a_key_takes_the_most_used_art_color() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0));
let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]);
let lib = parse_library(&serde_json::json!([
{"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}},
{"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}},
{"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}},
{"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}},
]));
let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect();
assert_eq!(
keys,
vec![
("s", Colorization::HueShift { key: blue }),
("a", Colorization::HueShift { key: blue }),
("p", Colorization::HueShift { key: red }),
("n", Colorization::HueShift { key: Color::BLACK }),
]
);
}
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
#[test]
fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
use vectorcraft_doc::{PatternDef, Symbol};
let mut doc = Document::new(100.0, 100.0);
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
let painted = |pattern: &str| {
let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY };
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0))
};
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(!out.is_empty());
for piece in &out {
let children = piece.children().unwrap();
let symbols: Vec<&str> = children
.iter()
.filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None })
.collect();
assert_eq!(symbols, ["plain"]);
let fills: Vec<Paint> =
children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect();
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
}
}
/// Anchors a brushed stroke emitted, which is what the output budget counts.
fn anchors(nodes: &[Node]) -> usize {
nodes
.iter()
.map(|n| match &n.kind {
NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(),
_ => 0,
})
.sum()
}
/// A path of `subpaths` long parallel lines, each long enough to want more tiles than the
/// per-run cap allows.
fn many_long_subpaths(subpaths: usize) -> BezPath {
let mut bp = BezPath::new();
for i in 0..subpaths {
let y = i as f64 * 50.0;
bp.move_to((0.0, y));
bp.line_to((300_000.0, y));
}
bp
}
/// #3594: the per-factor caps are per track, and a path has as many tracks as it has subpaths,
/// so they multiply instead of bounding. Measured before the budget: 20,000 nodes and 320,000
/// anchors for one subpath, and exactly N times that for N, so sixteen gave 5,120,000.
#[test]
fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
let brush = pattern_brush(PatternFit::Stretch, false);
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(1), &stroke(1.0));
assert_eq!(one.len(), 20_000, "one track still fills its own per-run cap");
let a1 = anchors(&one);
assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}");
// Sixteen tracks would be sixteen times that without a total budget.
// Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the
// budget must not bite before the per-factor caps have actually multiplied past it.
let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));
assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");
assert_eq!(anchors(&four), 4 * a1);
// Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));
let an = anchors(&many);
assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");
assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");
assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
}
/// The same for scatter, whose per-track guard is also 20,000 copies.
#[test]
fn scatter_output_is_capped_across_subpaths() {
let b = Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter { art: rect_art(4.0, 4.0), colorization: Colorization::None, ..Default::default() }),
};
let few = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(1), &stroke(1.0));
let per_track = anchors(&few);
assert!(per_track > 0, "one track draws something");
// Enough tracks that the per-track guards alone would pass the budget.
let tracks = 2_000_000 / per_track + 4;
let many = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(tracks), &stroke(1.0));
let an = anchors(&many);
assert!(an <= 2_000_000, "{tracks} tracks must not exceed the budget: {an}");
assert!(an > per_track, "but more is drawn than one track's worth: {an}");
assert!(many.len() < tracks * few.len().max(1), "fewer copies than the per-track guards alone would allow");
}
/// A short, ordinary stroke is untouched by the budget: it is the absurd ones that are cut.
#[test]
fn an_ordinary_stroke_is_unaffected_by_the_budget() {
let brush = pattern_brush(PatternFit::Stretch, false);
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &sq, &stroke(1.0));
assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
assert!(anchors(&out) < 2_000_000);
}