Geom: step budget in Pencil fit, stripe band cap, balanced Pathfinder union (#2251)

* geom: step budget in freehand fit, stripe band cap, balanced unions

* move test
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-08 22:56:03 +00:00
1 parent 49f6b85cb9
commit a337204cfe
3 files changed
+71 -1

No files matched your search

+18
View File
@@ -3,6 +3,10 @@
use crate::Point;
use crate::path::{Anchor, AnchorKind};
/// Distance tests [`simplify`] may make. Worst-case input splits one point off at a time (quadratic);
/// past this the spans still waiting keep only their end points, a coarser fit rather than a stall.
const MAX_SIMPLIFY_STEPS: usize = 16 * 1024 * 1024;
/// Ramer–Douglas–Peucker: the points that keep the polyline within `tol`.
fn simplify(pts: &[Point], tol: f64) -> Vec<Point> {
if pts.len() < 3 {
@@ -12,7 +16,12 @@ fn simplify(pts: &[Point], tol: f64) -> Vec<Point> {
keep[0] = true;
keep[pts.len() - 1] = true;
let mut stack = vec![(0, pts.len() - 1)];
let mut steps = 0usize;
while let Some((a, b)) = stack.pop() {
steps = steps.saturating_add(b - a);
if steps > MAX_SIMPLIFY_STEPS {
break;
}
let (pa, pb) = (pts[a], pts[b]);
let d = pb - pa;
let len = d.hypot();
@@ -113,4 +122,13 @@ mod tests {
assert!(c.iter().all(|x| x.kind == AnchorKind::Smooth));
assert!((c[0].p - c[c.len() - 1].p).hypot() > 1.0);
}
/// A convex curve splits one point at a time (quadratic): fitting stops at the step budget.
#[test]
fn unbalanced_input_stops_at_the_step_budget() {
let n = 100_000;
let curve: Vec<Point> = (0..n).map(|i| Point::new(f64::from(i), (f64::from(i) * 1e-3).powi(4))).collect();
let a = fit(&curve, 1e-9, false);
assert!(a.len() >= 2 && a.len() < n as usize);
}
}
+33
View File
@@ -92,11 +92,31 @@ fn from_flo(paths: &[SimpleBezierPath]) -> PathData {
PathData::new(subpaths)
}
/// Unions of more shapes than this combine in pairs; up to it each is added in turn (as always).
const BALANCED_UNION_FROM: usize = 16;
/// Combine `shapes` (back to front, all in one coordinate space). `None` when nothing is left.
pub fn combine(op: Op, shapes: &[PathData]) -> Option<PathData> {
let flo: Vec<Vec<SimpleBezierPath>> = shapes.iter().map(to_flo).filter(|p| !p.is_empty()).collect();
let (first, rest) = flo.split_first()?;
let union = |v: &[Vec<SimpleBezierPath>]| -> Vec<SimpleBezierPath> {
if v.len() > BALANCED_UNION_FROM {
// Pairs, then pairs of pairs: adding each shape to everything so far reprocesses the
// growing result every time (quadratic for many shapes).
let mut level: Vec<Vec<SimpleBezierPath>> = v.to_vec();
while level.len() > 1 {
level = level
.chunks(2)
.map(|c| match c {
[a, b] if a.is_empty() => b.clone(),
[a, b] if b.is_empty() => a.clone(),
[a, b] => path_add(a, b, ACCURACY),
_ => c.first().cloned().unwrap_or_default(),
})
.collect();
}
return level.pop().unwrap_or_default();
}
let mut acc: Vec<SimpleBezierPath> = Vec::new();
for p in v {
acc = if acc.is_empty() { p.clone() } else { path_add(&acc, p, ACCURACY) };
@@ -147,6 +167,19 @@ mod tests {
p.subpaths.iter().map(|s| s.area()).sum::<f64>().abs()
}
#[test]
fn a_union_of_many_shapes_adds_up() {
// 40 squares in a row, each overlapping the next by 5: width 40 * 10 + 5, height 10.
let shapes: Vec<PathData> = (0..40).map(|i| shapes::rectangle(Rect::new(f64::from(i) * 5.0, 0.0, f64::from(i) * 5.0 + 10.0, 10.0))).collect();
let u = combine(Op::Add, &shapes).unwrap();
assert!((area(&u) - 205.0 * 10.0).abs() < 1.0, "{}", area(&u));
// Far-apart squares stay separate.
let apart: Vec<PathData> =
(0..40).map(|i| shapes::rectangle(Rect::new(f64::from(i) * 50.0, 0.0, f64::from(i) * 50.0 + 10.0, 10.0))).collect();
let u = combine(Op::Add, &apart).unwrap();
assert_eq!(u.subpaths.len(), 40);
}
#[test]
fn boolean_ops_on_overlapping_squares() {
let a = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
+20 -1
View File
@@ -57,10 +57,18 @@ fn offset_subpath(bp: &BezPath, d: f64, tol: f64, out: &mut BezPath) {
/// Bands (start, width) across a stroke, as fractions of the weight from its left edge.
pub type Bands = [(f64, f64)];
/// Most bands of a striped stroke that are drawn, and most path elements its fill may have:
/// every band strokes the whole path again, so both multiply the path's size.
pub const MAX_STRIPE_BANDS: usize = 64;
const MAX_STRIPE_ELEMENTS: usize = 2_000_000;
/// The filled area of a striped stroke of `weight` along `bp`.
pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath {
let mut out = BezPath::new();
for &(start, width) in bands {
for &(start, width) in bands.iter().take(MAX_STRIPE_BANDS) {
if out.elements().len() > MAX_STRIPE_ELEMENTS {
break;
}
if width <= 0.0 {
continue;
}
@@ -139,6 +147,17 @@ pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stripes_draw_at_most_the_band_cap() {
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((100.0, 0.0));
let one = stripes(&line, 4.0, &[(0.0, 0.1)], 0.1).elements().len();
let many = vec![(0.0, 0.1); 100_000];
let all = stripes(&line, 4.0, &many, 0.1).elements().len();
assert_eq!(all, one * MAX_STRIPE_BANDS);
}
use kurbo::Shape;
fn line() -> BezPath {