mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 21:02:45 +00:00
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:
1 parent
49f6b85cb9
commit
a337204cfe
3 files changed
+71
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user