Effects: stacked geometry effects keep to an anchor and line budget (#3578)

A chain of geometry effects makes at most 2 million anchors of a path (of a
type, symbol or image art's paths together) and about 8 million lines for the
renderer to draw at 1 px/pt, or what the path costs when that is more.
Roughen spaces its points further apart, Zig Zag makes fewer ridges a
segment, Free Distort, Twist and the warps split segments into longer pieces
(map_nonlinear_within) and Transform makes fewer copies where they would pass
it; a result drawn with too many lines is built again with fewer points, and
an effect that still doesn't fit is left out.

Three Roughens of a rectangle in a 25 KB file needed gigabytes of geometry,
and 2 million anchors of them flattened into 141 million lines (7 GB, past
vello's 2^27 lines a path); the file now opens in 0.4 s at about 450 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brian Torres
2026-10-23 02:44:52 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 1673b3b747
commit 54c8a7f2bd
9 changed files with 419 additions and 46 deletions
+60 -2
View File
@@ -467,11 +467,49 @@ fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
cum
}
/// Most pieces [`map_nonlinear`] splits one segment into.
const MAX_PIECES: usize = 64;
/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`.
pub fn pieces(len: f64, piece: f64, most: usize) -> usize {
((len / piece).ceil() as usize).clamp(1, most.max(1))
}
/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so
/// that the pieces past each segment's first number at most `room`: `piece` when they already do,
/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path,
/// so pieces of the total length over `room` fit; with no room every segment stays one piece.
pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 {
let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add);
if added <= room {
return piece;
}
if room == 0 {
return f64::INFINITY;
}
// A NaN length is always one piece.
piece.max(lens.iter().filter(|len| !len.is_nan()).sum::<f64>() / room as f64)
}
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
let max_piece = max_piece.max(1e-3);
map_nonlinear_within(path, max_piece, usize::MAX, f)
}
/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more:
/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]),
/// a coarser approximation of the image curve. Geometry effects map within their anchor budget.
pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData {
let mut max_piece = max_piece.max(1e-3);
// Each segment adds one anchor per piece past its first.
let room = max.saturating_sub(path.anchor_count());
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
if segs.saturating_mul(MAX_PIECES - 1) > room {
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect();
max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room);
}
let mut subs = Vec::with_capacity(path.subpaths.len());
for sp in &path.subpaths {
let n = sp.anchors.len();
@@ -487,7 +525,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
if i < segs {
let c = seg_cubic(sp, i);
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
let k = pieces(poly_len(&c), max_piece, MAX_PIECES);
for j in 0..k {
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
if let Some(last) = res.last_mut() {
@@ -1884,6 +1922,26 @@ mod tests {
}
}
/// #3578: a non-linear map within an anchor budget splits its segments into longer pieces,
/// down to one a segment, and matches the plain map where the budget leaves room.
#[test]
fn map_nonlinear_within_keeps_to_its_anchor_budget() {
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let f = |p: Point| Point::new(p.x + (p.y / 10.0).sin(), p.y);
let full = map_nonlinear(&square, 1.0, f);
assert_eq!(full.anchor_count(), 4 * 64);
assert_eq!(map_nonlinear_within(&square, 1.0, 4 * 64, f), full);
// Every side takes the same pieces: of 400 / (max - 4) pt, so 12 a side for 50 anchors.
for (max, want) in [(0, 4), (4, 4), (5, 4), (50, 4 * 12), (255, 4 * 63)] {
assert_eq!(map_nonlinear_within(&square, 1.0, max, f).anchor_count(), want, "{max}");
}
// Lengths that are not numbers stay one piece; with no room every segment is one piece.
assert_eq!(fit_piece(&[f64::NAN, 10.0], 1.0, 64, 5), 2.0);
assert_eq!(fit_piece(&[f64::INFINITY, 10.0], 1.0, 64, 0), f64::INFINITY);
assert_eq!(pieces(f64::INFINITY, f64::INFINITY, 64), 1);
assert_eq!(pieces(10.0, 1.0, 0), 1);
}
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
/// real distance from the centre line; an inner edge that would pass the centre stops there.