Pattern Brush: keep the tile's curves on a straight run (#3926)

Bending flattens every path it maps: warp_path runs kurbo::flatten and
emits SubPath::polyline, so a circle tile is drawn as a polygon. That
shows in the live stroke, not only after Expand Brush Strokes, and it
happens even on a perfectly straight path where the tile needs nothing
more than an affine.

Bending is only non-linear where the track bends. On a straight run the
arc-length map and the normal offset are both linear, so affine_fit
derives the affine from three corners of the art and then checks it
against a five by five grid, including those corners. Within the
tolerance the flattening would have used, the art is transformed
instead, which keeps its Beziers. Outside it, nothing changes: the old
path runs and the result is what it was.

Strokes are scaled by the same factor the bent path uses rather than by
the affine's own, so curves are the only thing this changes.

Corner tiles already took this route, which is why they kept their
curves while the tiles between them did not.

Two existing tests moved with it, both because they read anchor counts
that only held while straight runs were flattened:

  pattern_places_corner_tiles told corner tiles from side tiles by
  "four anchors", true only because the side rectangles arrived as
  polylines. It now tells them apart by a kept handle, which is what
  actually distinguishes an ellipse from a rectangle, and additionally
  asserts the side tiles keep their own four anchors.

  pattern_output_is_capped_across_subpaths_not_only_per_run counted on
  sixteen tracks passing the #3594 anchor budget. A tile is about four
  times cheaper in anchors now, so the budget engages later; the test
  derives how many tracks it needs from what one costs.
This commit is contained in:
Muhammad Saad
2026-10-29 13:00:44 +07:00
committed by bflatastic
parent 7a79935c10
commit d1b9bc2b3d
2 changed files with 138 additions and 18 deletions
+78 -18
View File
@@ -273,15 +273,21 @@ fn pattern_tile_count_by_fit() {
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);
// The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them
// apart. Anchor count no longer does: the side tiles keep their own four anchors now that a
// straight run is not flattened (#3926), where they used to arrive as many-anchor polylines.
let curved = |n: &Node| {
matches!(&n.kind, NodeKind::Path { path, .. }
if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p)))
};
let corners = out.iter().filter(|n| curved(n)).count();
assert_eq!(corners, 4, "the four corner ellipses, still curves");
// Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each.
assert_eq!(out.len() - corners, 36);
for n in out.iter().filter(|n| !curved(n)) {
let NodeKind::Path { path, .. } = &n.kind else { continue };
assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors");
}
}
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
@@ -1141,19 +1147,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
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);
// Two tracks are well inside the budget, so nothing is cut: it must not bite before the
// per-factor caps have actually multiplied past it.
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0));
assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece");
assert_eq!(anchors(&two), 2 * 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));
// Enough tracks that the per-run caps alone would pass the budget. Derived from what one
// track costs rather than written down, because how many anchors a tile holds is not fixed:
// a tile on a straight run keeps its own anchors instead of being flattened (#3926), which
// moved this by a factor of four.
let tracks = 2_000_000 / a1 + 2;
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &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());
assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}");
assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}");
assert!(many.len() < tracks * 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.
@@ -1184,3 +1193,54 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() {
assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
assert!(anchors(&out) < 2_000_000);
}
/// Anchors under `n`, and how many of them keep a handle (so the segment is a curve).
fn anchors_and_curved(n: &Node) -> (usize, usize) {
let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) };
let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect();
(all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count())
}
/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the
/// tile's curves survive. They used to be flattened into chords by the bending code, which is
/// visible in the stroke itself and leaves no handles after Expand Brush Strokes.
#[test]
fn a_pattern_tile_keeps_its_curves_on_a_straight_path() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let (tile_anchors, tile_curved) = anchors_and_curved(&circle);
assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle");
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
assert!(!out.is_empty(), "the brush drew something");
for n in &out {
assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon");
}
// The ink is in the same place: a circle tile still covers the path it was laid along.
let b = bounds(&out);
assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width());
assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height());
}
/// The other half of the same behaviour: where the path really does bend, the tile is still
/// subdivided, because an affine cannot reproduce a bend. The fit is checked before it is used,
/// so a bent run falls back to exactly what it did before.
#[test]
fn a_pattern_tile_on_a_bent_path_is_still_subdivided() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
// A circle to run along: every tile sits on a curving section.
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0));
assert!(!out.is_empty());
let bent = out.iter().filter(|n| anchors_and_curved(n).0 > 8).count();
assert!(bent > out.len() / 2, "tiles on a bend are still densified: {bent} of {}", out.len());
}
+60
View File
@@ -161,6 +161,58 @@ impl AlongMap {
}
}
/// The affine that reproduces `f` over `b` to within `tol`, if one does.
///
/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the
/// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it
/// into chords throws its curves away for nothing (#3926).
///
/// Derived from three corners and then *checked* against a grid: it is used only where it
/// reproduces the real map at least as closely as the flattening would have, so where it does not
/// hold the result is exactly what it was before.
fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
let (w, h) = (b.width(), b.height());
if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) {
return None;
}
let origin = Point::new(b.x0, b.y0);
let q0 = f(origin);
// Images of the unit x and y directions.
let u = (f(Point::new(b.x1, b.y0)) - q0) / w;
let v = (f(Point::new(b.x0, b.y1)) - q0) / h;
if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) {
return None;
}
let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2());
// Five by five including the corners the fit came from, so a map that merely agrees at those
// three is rejected.
for i in 0..=4 {
for j in 0..=4 {
let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0);
let (want, got) = (f(p), a * p);
if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol {
return None;
}
}
}
Some(a)
}
/// Scale every stroke under `n`, as [`warp_node`] does on the way down.
fn scale_strokes_deep(n: &mut Node, s: f64) {
n.appearance.scale_strokes(s);
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut n.kind {
*children = children
.iter()
.map(|c| {
let mut c = (**c).clone();
scale_strokes_deep(&mut c, s);
Arc::new(c)
})
.collect();
}
}
/// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`.
fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<Node> {
let cy = b.center().y;
@@ -168,6 +220,14 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
let f = move |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
// A straight run needs no bending: move the art and keep its curves. Strokes are scaled the
// way the bent path scales them, so this changes the curves and nothing else.
if let Some(a) = affine_fit(&f, b, flat_tol.max(1e-9)) {
let mut out = art.clone();
out.transform(a, false);
scale_strokes_deep(&mut out, cross.abs());
return out.geometric_bounds().is_some().then_some(out);
}
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
}