Path ops: stroke outlining and unions sweep long, low paths along their length

The overlap sweep moves a horizontal line down the page, and its time
per event grows with the number of segments on that line. A long, low
path, such as rows of a zigzag, puts a whole row on the line at once.

stroke_region, unite_all_with and try_unite_all_with now estimate how
many segments the sweep holds at once in each direction, from up to
4,096 segment boxes (one from each run of segments, at a position that
changes from run to run). When the estimate for the input turned a
quarter turn is under two thirds of the upright one (2,048 segments or
more), the operation runs on the turned input and the result is turned
back. A quarter turn swaps and negates coordinates, so no point moves
by rounding. Offset Path is not turned. Callers of unite_all,
try_unite_all and outline_stroke take the turned sweep through these
functions.

Tidy's lookups of the input curves around a piece and of the kept
anchors can search along y: once lookups with more than 64 candidates
along x have scanned 16 per item, a lookup sorts the list along y once
and uses that window when it holds fewer candidates (under a quarter as
many for curves). Where the y window rarely helps, only one lookup in
32 measures it. is_kept counts the few points within its tolerance
directly instead of searching for the window's end.

In the sweep_axis tests, a turned result has as many subpaths as the
result without the turn, and the area where the two differ is no
larger than the refit precision times the outline's length.

On one open path of 200,000 anchors in rows of 1,000, Outline Stroke
takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s
instead of 6.5 s in a release build. Unite of 2,000 circles takes the
same time as before.
This commit is contained in:
dotCooCoo
2026-11-08 12:22:04 +07:00
committed by bflatastic
parent d495b4a378
commit 8a7011a9d3
5 changed files with 652 additions and 4 deletions
+249 -4
View File
@@ -255,12 +255,60 @@ pub(crate) struct Arrangement {
pub(crate) struct Tidy {
pub(crate) precision: f64,
keep: Vec<Point>,
/// `keep` by y, sorted on first use.
keep_by_y: std::sync::OnceLock<Vec<Point>>,
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
curves: Vec<(kurbo::Rect, CubicBez)>,
/// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose
/// bounds can hold a point, without testing them all.
by_x: Vec<usize>,
max_width: f64,
/// The top edge of their bounds and their index, in order of that edge, with the tallest
/// bounds, made on first use.
by_y: std::sync::OnceLock<(Vec<(f64, usize)>, f64)>,
/// The wide lookups in `curves` and in `keep` so far.
curve_lookups: WideLookups,
point_lookups: WideLookups,
}
/// The lookups in one list of [`Tidy`] with more than [`WIDE_WINDOW`] candidates along x: the
/// candidates they counted, a score that rises by [`Y_WIN`] when the window along y was the one
/// used and falls by one when it was not (within [`Y_SCORE_LIMIT`] either way), and the lookups
/// made while the score was below zero.
#[derive(Debug, Default)]
struct WideLookups {
scanned: std::sync::atomic::AtomicUsize,
score: std::sync::atomic::AtomicIsize,
below: std::sync::atomic::AtomicUsize,
}
impl Clone for WideLookups {
fn clone(&self) -> Self {
let get = |n: &std::sync::atomic::AtomicUsize| n.load(std::sync::atomic::Ordering::Relaxed).into();
Self { scanned: get(&self.scanned), score: self.score().into(), below: get(&self.below) }
}
}
impl WideLookups {
fn score(&self) -> isize {
self.score.load(std::sync::atomic::Ordering::Relaxed)
}
/// Does a lookup with `window` candidates along x measure the window along y? It does past
/// [`WIDE_WINDOW`] candidates, once wide lookups have scanned [`SORT_COST`] candidates for each
/// of the `len` items that the sort along y orders, while the score is zero or more, and in
/// one of every [`Y_RETRY`] lookups while it is below.
fn search_y(&self, window: usize, len: usize) -> bool {
use std::sync::atomic::Ordering::Relaxed;
window > WIDE_WINDOW
&& self.scanned.fetch_add(window, Relaxed) + window >= len.saturating_mul(SORT_COST)
&& (self.score() >= 0 || self.below.fetch_add(1, Relaxed) % Y_RETRY == Y_RETRY - 1)
}
/// Counts a lookup that measured along y, and whether that window was the one used.
fn used_y(&self, used: bool) {
let step = if used { Y_WIN } else { -1 };
let next = (self.score() + step).clamp(-Y_SCORE_LIMIT, Y_SCORE_LIMIT);
self.score.store(next, std::sync::atomic::Ordering::Relaxed);
}
}
impl Tidy {
@@ -286,12 +334,46 @@ impl Tidy {
let mut by_x: Vec<usize> = (0..curves.len()).collect();
by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0));
let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max);
Self { precision, keep, curves, by_x, max_width }
Self { precision, keep, curves, by_x, max_width, ..Default::default() }
}
fn keep_by_y(&self) -> &[Point] {
self.keep_by_y.get_or_init(|| {
let mut keep = self.keep.clone();
keep.sort_unstable_by(|a, b| a.y.total_cmp(&b.y));
keep
})
}
fn by_y(&self) -> (&[(f64, usize)], f64) {
let (by_y, max_height) = self.by_y.get_or_init(|| {
let mut by_y: Vec<(f64, usize)> = self.curves.iter().enumerate().map(|(i, (r, _))| (r.y0, i)).collect();
by_y.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
(by_y, self.curves.iter().map(|(r, _)| r.height()).fold(0.0, f64::max))
});
(by_y, *max_height)
}
/// Is a kept point within the tolerance of `p`? The points within it along x are tested, or
/// those along y when [`WideLookups::search_y`] holds and there are fewer along y.
fn is_kept(&self, p: Point) -> bool {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
let lo = self.keep.partition_point(|q| q.x < p.x - tol);
self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol)
// Usually one or two points lie within the tolerance along x; count them one by one up to
// the wide window, and search past it.
let rest = &self.keep[lo..];
let mut within = rest.iter().take(WIDE_WINDOW + 1).take_while(|q| q.x <= p.x + tol).count();
if within > WIDE_WINDOW {
within = rest.partition_point(|q| q.x <= p.x + tol);
}
let hi = lo + within;
if self.point_lookups.search_y(hi - lo, self.keep.len()) {
let keep_by_y = self.keep_by_y();
let lo_y = keep_by_y.partition_point(|q| q.y < p.y - tol);
let hi_y = lo_y + keep_by_y[lo_y..].partition_point(|q| q.y <= p.y + tol);
self.point_lookups.used_y(hi_y - lo_y < hi - lo);
if hi_y - lo_y < hi - lo {
return keep_by_y[lo_y..hi_y].iter().any(|q| (q.x - p.x).abs() <= tol);
}
}
self.keep[lo..hi].iter().any(|q| (q.y - p.y).abs() <= tol)
}
}
@@ -579,13 +661,52 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
None
}
/// Past this many candidates along x, a lookup in [`Tidy`] adds them to the count of scanned ones.
const WIDE_WINDOW: usize = 64;
/// The candidates scanned per item of a list before [`Tidy`] sorts that list along y: about the
/// comparisons per item of sorting 100,000 items.
const SORT_COST: usize = 16;
/// How much a lookup that used the window along y raises the score in [`WideLookups`].
const Y_WIN: isize = 4;
/// The highest and the lowest score in [`WideLookups`] (negated).
const Y_SCORE_LIMIT: isize = 256;
/// While the score in [`WideLookups`] is below zero, one lookup in this many measures along y.
const Y_RETRY: usize = 32;
impl Tidy {
/// The input curves whose bounds hold both `a` and `b`.
/// The input curves whose bounds hold both `a` and `b`, in the order of `by_x`. The candidates
/// are the curves whose bounds start within the widest bounds' reach of the points along x, or
/// the same along y when [`WideLookups::search_y`] holds and there are under a quarter as many
/// along y.
fn curves_around(&self, a: Point, b: Point) -> impl Iterator<Item = &CubicBez> {
let holds = move |(r, _): &&(kurbo::Rect, CubicBez)| r.contains(a) && r.contains(b);
let x = a.x.min(b.x);
let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x);
let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width);
self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c)
let along_y = self.curve_lookups.search_y(hi - lo, self.curves.len()).then(|| {
let (by_y, max_height) = self.by_y();
let y = a.y.min(b.y);
let hi_y = by_y.partition_point(|&(y0, _)| y0 <= y);
let lo_y = by_y[..hi_y].partition_point(|&(y0, _)| y0 < y - max_height);
&by_y[lo_y..hi_y]
});
let along_y = along_y.filter(|ys| {
let used = ys.len().saturating_mul(4) < hi - lo;
self.curve_lookups.used_y(used);
used
});
let along_y = along_y.map(|ys| {
let mut found: Vec<(usize, &(kurbo::Rect, CubicBez))> = ys.iter().map(|&(_, i)| (i, &self.curves[i])).filter(|(_, c)| holds(c)).collect();
// `by_x` was sorted stably from index order, so this puts them in its order.
found.sort_by(|(i, p), (j, q)| p.0.x0.total_cmp(&q.0.x0).then(i.cmp(j)));
found.into_iter().map(|(_, c)| c)
});
let along_x = along_y.is_none().then(|| self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(holds));
along_x.into_iter().flatten().chain(along_y.into_iter().flatten()).map(|(_, c)| c)
}
/// `bp` with each curve piece that strays from the one input curve through both its ends
@@ -821,6 +942,17 @@ pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result<PathData, PathOp
/// [`try_unite_all`] refit to `precision`, dropping slivers thinner than it.
pub fn try_unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> Result<PathData, PathOpsError> {
match crate::sweep_axis::turned_fills(paths) {
Some(turned) => {
let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect();
try_unite_all_upright(&refs, precision).map(crate::sweep_axis::unturn)
}
None => try_unite_all_upright(paths, precision),
}
}
/// [`try_unite_all_with`] with the sweeps run on `paths` as they are.
pub(crate) fn try_unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> Result<PathData, PathOpsError> {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
@@ -846,6 +978,17 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
/// [`unite_all`] refit to `precision`, dropping slivers thinner than it.
pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
match crate::sweep_axis::turned_fills(paths) {
Some(turned) => {
let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect();
crate::sweep_axis::unturn(unite_all_upright(&refs, precision))
}
None => unite_all_upright(paths, precision),
}
}
/// [`unite_all_with`] with the sweeps run on `paths` as they are.
pub(crate) fn unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
@@ -920,6 +1063,108 @@ mod tests {
assert_eq!(tidy.repair(&on), on);
}
#[test]
fn input_curves_and_kept_points_are_found_in_wide_and_tall_art() {
// Rows of short curves across the page, and the same stood on end: one lookup has a
// narrow window along x, the other along y.
let mut wide = BezPath::new();
for row in 0..4 {
let y = row as f64 * 3.0;
wide.move_to((0.0, y));
for k in 0..200 {
let x = k as f64;
wide.curve_to((x + 0.3, y + 1.0), (x + 0.7, y + 1.0), (x + 1.0, y));
}
}
let tall = kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]) * &wide;
for art in [&wide, &tall] {
let tidy = Tidy::keeping(DEFAULT_PRECISION, [art]);
let mut sorted: Vec<usize> = (0..tidy.curves.len()).collect();
sorted.sort_by(|&i, &j| tidy.curves[i].0.x0.total_cmp(&tidy.curves[j].0.x0).then(i.cmp(&j)));
for (_, c) in tidy.curves.iter().step_by(7) {
use kurbo::ParamCurve;
// A curve's start point is also held by the curve before it, whose bounds start a
// whole curve further back.
for (a, b) in [(c.p0, c.p3), (c.p0, c.p0), (c.eval(0.25), c.eval(0.75)), (c.p0, c.p0 + kurbo::Vec2::new(2.5, 2.5))] {
let by_hand: Vec<&CubicBez> =
sorted.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c).collect();
assert_eq!(tidy.curves_around(a, b).collect::<Vec<_>>(), by_hand, "{a:?} {b:?}");
}
for p in [c.p0, c.p0 + kurbo::Vec2::new(1e-7, -1e-7), c.p0 + kurbo::Vec2::new(0.0, 1e-3), c.p0 + kurbo::Vec2::new(1e-3, 0.0)] {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
let by_hand = tidy.keep.iter().any(|q| (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol);
assert_eq!(tidy.is_kept(p), by_hand, "{p:?}");
}
}
// The tall art's lookups reach the sorted lists along y; the wide art's never do.
let tall_art = std::ptr::eq(art, &tall);
assert_eq!(tidy.by_y.get().is_some(), tall_art);
assert_eq!(tidy.keep_by_y.get().is_some(), tall_art);
}
}
#[test]
fn a_few_wide_lookups_do_not_sort_along_y() {
let mut tall = BezPath::new();
for x in 0..2 {
let x = f64::from(x) * 3.0;
tall.move_to((x, 0.0));
for k in 0..200 {
let y = f64::from(k);
tall.curve_to((x + 1.0, y + 0.3), (x + 1.0, y + 0.7), (x, y + 1.0));
}
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&tall]);
let a = Point::new(0.0, 50.0);
let found = |tidy: &Tidy| tidy.curves_around(a, a).count();
// Bounds at x = 0: the column's 200 curves are candidates along x, and two of them hold `a`.
assert_eq!(found(&tidy), 2);
assert!(tidy.is_kept(a));
assert!(tidy.by_y.get().is_none());
assert!(tidy.keep_by_y.get().is_none());
// Enough of them to pay for the sort, and the answers stay the same.
let scanned = |l: &WideLookups| l.scanned.load(std::sync::atomic::Ordering::Relaxed);
while scanned(&tidy.curve_lookups) < SORT_COST * tidy.curves.len() || scanned(&tidy.point_lookups) < SORT_COST * tidy.keep.len() {
assert_eq!(found(&tidy), 2);
assert!(tidy.is_kept(a));
}
assert_eq!(found(&tidy), 2);
assert!(tidy.is_kept(a));
assert!(tidy.by_y.get().is_some());
assert!(tidy.keep_by_y.get().is_some());
// Along y the windows are narrow, so the lookups keep measuring there.
assert!(tidy.curve_lookups.score() > 0 && tidy.point_lookups.score() > 0);
}
#[test]
fn lookups_measure_along_y_rarely_where_it_narrows_nothing() {
// A square grid of short curves: about as many candidates lie along y as along x.
let mut grid = BezPath::new();
for row in 0..100 {
let y = f64::from(row) * 2.0;
grid.move_to((0.0, y));
for k in 0..100 {
let x = f64::from(k);
grid.curve_to((x + 0.3, y + 0.5), (x + 0.7, y + 0.5), (x + 1.0, y));
}
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&grid]);
let lookups: u32 = 20_000;
for k in 0..lookups {
let a = Point::new(f64::from(k % 97) + 0.5, f64::from(k % 89) * 2.0 + 0.2);
let found: Vec<&CubicBez> = tidy.curves_around(a, a).collect();
if k % 500 == 0 {
let by_hand: Vec<&CubicBez> = tidy.by_x.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a)).map(|(_, c)| c).collect();
assert_eq!(found, by_hand, "{a:?}");
}
}
// The window along y was measured until the score fell below zero, then once in Y_RETRY lookups.
assert!(tidy.by_y.get().is_some());
assert_eq!(tidy.curve_lookups.score(), -Y_SCORE_LIMIT);
let below = tidy.curve_lookups.below.load(std::sync::atomic::Ordering::Relaxed);
assert!(below > lookups as usize / 2, "{below} of {lookups} lookups below zero");
}
#[test]
fn a_lens_between_two_curves_through_the_same_points_stays() {
// Two input curves meeting at both ends bound a real face: neither piece is "repaired"
+1
View File
@@ -14,6 +14,7 @@ mod fit;
mod offset;
mod pathfinder;
mod planar;
mod sweep_axis;
mod trap;
pub use boolean::{
+8
View File
@@ -85,6 +85,14 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l
/// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths
/// closed) as one clean path: overlaps removed and the stroker's pieces refitted.
pub fn stroke_region(outlines: &[BezPath]) -> PathData {
match crate::sweep_axis::turned_outlines(outlines) {
Some(turned) => crate::sweep_axis::unturn(stroke_region_upright(&turned)),
None => stroke_region_upright(outlines),
}
}
/// [`stroke_region`] with the sweep run on `outlines` as they are.
pub(crate) fn stroke_region_upright(outlines: &[BezPath]) -> PathData {
let tidy = Tidy::free(DEFAULT_PRECISION);
let parts: Vec<BezPath> = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect();
if let [one] = &parts[..] {
+393
View File
@@ -0,0 +1,393 @@
//! Which way the overlap sweep runs. `linesweeper` moves a horizontal line down the page, and the
//! time it spends at each event grows with the number of segments on that line at the time. A long,
//! low path (rows of a zigzag, a stroked line of type) can put thousands of segments on the line at
//! once, where the same path turned a quarter turn puts far fewer on it. [`turned_outlines`] and
//! [`turned_fills`] estimate both directions before the sweep; when the turned one is clearly
//! cheaper they return the input turned, the caller runs its operation on that, and [`unturn`]
//! turns the result back. A quarter turn swaps and negates coordinates, so no point moves by
//! rounding.
use kurbo::{Affine, BezPath, PathSeg, Rect};
use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath};
/// Inputs with fewer segments than this are always swept as they are.
const MIN_SEGMENTS: usize = 2048;
/// The turned direction is used when its estimate is below the upright one divided by this.
const MIN_GAIN: f64 = 1.5;
/// The most segments the estimate measures.
const MAX_SAMPLE: usize = 4096;
/// (x, y) to (−y, x).
const TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
/// (x, y) to (y, −x): the inverse of [`TURN`].
const UNTURN: Affine = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]);
#[cfg(test)]
thread_local! {
/// The inputs turned on this thread.
pub(crate) static TURNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// For each of the spans from `starts[i]` to `ends[i]`, the number of spans that overlap it
/// (touching counts, itself included), summed: the segments a sweep across these extents has on
/// its line while each one is there. Sorts both lists.
fn load(starts: &mut [f64], ends: &mut [f64]) -> u64 {
starts.sort_unstable_by(f64::total_cmp);
ends.sort_unstable_by(f64::total_cmp);
// Spans that start at or before each end, less spans that end before each start: every span
// that ends before a start also starts before it, so it was counted in the first sum.
let (mut begun, mut i) = (0u64, 0usize);
for &e in ends.iter() {
while starts.get(i).is_some_and(|&s| s <= e) {
i += 1;
}
begun += i as u64;
}
let (mut over, mut j) = (0u64, 0usize);
for &s in starts.iter() {
while ends.get(j).is_some_and(|&e| e < s) {
j += 1;
}
over += j as u64;
}
begun.saturating_sub(over)
}
/// The segments of `outlines`.
fn outline_segments(outlines: &[BezPath]) -> impl Iterator<Item = PathSeg> + '_ {
outlines.iter().flat_map(BezPath::segments)
}
/// The box of a segment's control points.
fn segment_box(s: PathSeg) -> Rect {
match s {
PathSeg::Line(l) => Rect::from_points(l.p0, l.p1),
PathSeg::Quad(q) => Rect::from_points(q.p0, q.p2).union_pt(q.p1),
PathSeg::Cubic(c) => Rect::from_points(c.p0, c.p3).union_pt(c.p1).union_pt(c.p2),
}
}
/// The subpaths of `paths` that have a fill: those of two or more anchors.
fn filled_subpaths<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator<Item = &'a SubPath> + 'a {
paths.iter().flat_map(|(p, _)| &p.subpaths).filter(|sp| sp.anchors.len() >= 2)
}
/// The start and end anchor of each segment in the fills of `paths`, every subpath of two or more
/// anchors closed, as [`fill_bezpath`](crate::boolean::fill_bezpath) builds them.
fn fill_segments<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator<Item = (&'a Anchor, &'a Anchor)> + 'a {
filled_subpaths(paths).flat_map(|sp| {
let n = sp.anchors.len();
sp.anchors.iter().enumerate().map(move |(i, a)| (a, &sp.anchors[(i + 1) % n]))
})
}
/// The box of the control points of the fill segment from `a` to `b`.
fn fill_segment_box((a, b): (&Anchor, &Anchor)) -> Rect {
Rect::from_points(a.p, b.p).union_pt(a.h_out).union_pt(b.h_in)
}
/// Is the sweep over the `count` `segments`, each with the box `bbox` gives it, clearly cheaper
/// turned a quarter turn? Past [`MAX_SAMPLE`] segments, one segment from each run of
/// `count / MAX_SAMPLE` (rounded up) stands for the run, at a position within it that changes from
/// run to run, and only those segments' boxes are built.
fn turn_pays<T>(count: usize, segments: impl Iterator<Item = T>, bbox: impl Fn(T) -> Rect) -> bool {
if count < MIN_SEGMENTS {
return false;
}
let stride = count.div_ceil(MAX_SAMPLE);
let mut pick = 0;
let boxes: Vec<Rect> = segments
.enumerate()
.filter(|&(i, _)| {
if i % stride == 0 {
pick = sample_offset(i / stride, stride);
}
i % stride == pick
})
.map(|(_, s)| bbox(s))
.collect();
if !boxes.iter().all(Rect::is_finite) {
return false;
}
let (mut x0, mut x1): (Vec<f64>, Vec<f64>) = boxes.iter().map(|b| (b.x0, b.x1)).unzip();
let (mut y0, mut y1): (Vec<f64>, Vec<f64>) = boxes.iter().map(|b| (b.y0, b.y1)).unzip();
let pays = load(&mut x0, &mut x1) as f64 * MIN_GAIN < load(&mut y0, &mut y1) as f64;
#[cfg(test)]
if pays {
TURNS.with(|n| n.set(n.get() + 1));
}
pays
}
/// The position, below `stride`, of the segment measured in run `run`: a hash of `run` (the
/// SplitMix64 finalizer), so input that repeats every few segments is measured at every position.
fn sample_offset(run: usize, stride: usize) -> usize {
let mut z = (run as u64).wrapping_add(0x9e37_79b9_7f4a_7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
((z ^ (z >> 31)) % stride as u64) as usize
}
/// `outlines` turned a quarter turn, when sweeping them that way is clearly cheaper.
pub(crate) fn turned_outlines(outlines: &[BezPath]) -> Option<Vec<BezPath>> {
let count = outlines.iter().map(|o| o.segments().count()).sum();
turn_pays(count, outline_segments(outlines), segment_box).then(|| outlines.iter().map(|o| TURN * o).collect())
}
/// `paths` turned a quarter turn, when sweeping their fills that way is clearly cheaper.
pub(crate) fn turned_fills(paths: &[(&PathData, FillRule)]) -> Option<Vec<(PathData, FillRule)>> {
let count = filled_subpaths(paths).map(|sp| sp.anchors.len()).sum();
turn_pays(count, fill_segments(paths), fill_segment_box).then(|| paths.iter().map(|(p, r)| (p.transformed(TURN), *r)).collect())
}
/// A result computed on turned input, turned back.
pub(crate) fn unturn(mut path: PathData) -> PathData {
path.transform(UNTURN);
path
}
#[cfg(test)]
mod tests {
use kurbo::{Join, Stroke, StrokeOpts};
use vectorcraft_geom::{Anchor, Point, Vec2};
use super::*;
use crate::boolean::{BoolOp, DEFAULT_PRECISION, fill_bezpath, try_area, try_boolean, try_unite_all_upright, unite_all_upright};
use crate::offset::stroke_region_upright;
use crate::{stroke_region, try_unite_all_with, unite_all_with};
/// A small xorshift generator, so the random cases are the same on every run.
struct Rng(u64);
impl Rng {
fn unit(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
}
/// `rows` rows of a zigzag `cols` anchors long, each anchor moved by up to `jitter`: a long, low path.
fn zigzag(rows: usize, cols: usize, jitter: f64, rng: &mut Rng, closed: bool) -> PathData {
let mut anchors = Vec::new();
for row in 0..rows {
for col in 0..cols {
let c = if row % 2 == 0 { col } else { cols - 1 - col };
let x = c as f64 * 1.6 + (rng.unit() - 0.5) * jitter;
let y = row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 } + (rng.unit() - 0.5) * jitter;
let p = Point::new(x, y);
let dir = if row % 2 == 0 { 0.5 } else { -0.5 };
anchors.push(Anchor::smooth(p, p + Vec2::new(dir, 0.0)));
}
}
PathData::single(SubPath::new(anchors, closed))
}
/// The same zigzag stood on end: a tall, narrow path.
fn upright(p: &PathData) -> PathData {
p.transformed(Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]))
}
/// A unit square with its lower left corner at (`x`, `y`).
fn square(x: f64, y: f64) -> PathData {
let corners = [(x, y), (x + 1.0, y), (x + 1.0, y + 1.0), (x, y + 1.0)];
PathData::single(SubPath::new(corners.map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), true))
}
fn outline(p: &PathData) -> BezPath {
kurbo::stroke(p.to_bezpath().iter(), &Stroke::new(1.0).with_join(Join::Round), &StrokeOpts::default(), 1e-3)
}
fn segments(p: &BezPath) -> usize {
p.segments().count()
}
fn turns_during(f: impl FnOnce()) -> usize {
let before = TURNS.with(std::cell::Cell::get);
f();
TURNS.with(std::cell::Cell::get) - before
}
/// `a` and `b` have the same pieces and cover the same area to within the refit precision: the
/// area in one and not the other is less than a band [`DEFAULT_PRECISION`] wide along the outline.
fn assert_same_region(a: &PathData, b: &PathData, what: &str) {
use kurbo::Shape;
assert_eq!(a.subpaths.len(), b.subpaths.len(), "{what}: subpaths");
let band = DEFAULT_PRECISION * a.to_bezpath().perimeter(1e-3);
let area_a = try_area(a, FillRule::NonZero).unwrap();
let area_b = try_area(b, FillRule::NonZero).unwrap();
assert!((area_a - area_b).abs() <= band, "{what}: area {area_a} against {area_b}, band {band}");
let apart = try_boolean(a, FillRule::NonZero, b, FillRule::NonZero, BoolOp::Xor, DEFAULT_PRECISION).unwrap();
let apart = try_area(&apart, FillRule::NonZero).unwrap();
assert!(apart <= band, "{what}: {apart} of {area_a} differs, band {band}");
}
#[test]
fn a_long_low_path_is_swept_turned_and_a_tall_or_small_one_as_it_is() {
let mut rng = Rng(7);
let low = zigzag(3, 300, 0.0, &mut rng, false);
let stroke = outline(&low);
// More segments than the estimate measures, so it decides from a sample.
assert!(segments(&stroke) > MAX_SAMPLE, "{} segments", segments(&stroke));
assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&stroke)))), 1);
let fill = zigzag(3, 800, 0.0, &mut rng, true);
assert_eq!(turns_during(|| drop(unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1);
assert_eq!(turns_during(|| drop(try_unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1);
// Stood on end, the same paths are already narrow across the sweep.
let tall = outline(&upright(&low));
assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&tall)))), 0);
assert_eq!(turns_during(|| drop(unite_all_with(&[(&upright(&fill), FillRule::NonZero)], DEFAULT_PRECISION))), 0);
// A square grid costs the same either way.
let grid: Vec<PathData> = (0..900).map(|i| square((i % 30) as f64 * 3.0, (i / 30) as f64 * 3.0)).collect();
let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect();
assert_eq!(turns_during(|| drop(unite_all_with(&grid, DEFAULT_PRECISION))), 0);
// Below the size where the sweep costs much, nothing is turned.
let short = outline(&zigzag(1, 40, 0.0, &mut rng, false));
assert!(segments(&short) < MIN_SEGMENTS);
assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&short)))), 0);
}
#[test]
fn a_repeating_path_is_measured_at_every_position() {
// Short segments in a row along the bottom, with a long one stacked above the row after
// every `period - 1` of them. The short ones alone are cheaper to sweep turned; with the
// long ones, the path is cheaper upright.
let add = |path: &mut BezPath, a: (f64, f64), b: (f64, f64)| {
path.move_to(a);
path.line_to(b);
};
for period in [2, 4] {
let n = MAX_SAMPLE;
let width = (n * period) as f64 * 3.0;
let mut both = BezPath::new();
let mut shorts = BezPath::new();
for i in 0..n {
for k in 0..period - 1 {
let x = (i * period + k) as f64 * 3.0;
add(&mut both, (x, 0.0), (x + 1.0, 0.0));
add(&mut shorts, (x, 0.0), (x + 1.0, 0.0));
}
add(&mut both, (0.0, 10.0 + i as f64), (width, 10.0 + i as f64));
}
assert_eq!(segments(&both), period * MAX_SAMPLE, "period {period}");
assert!(turned_outlines(&[shorts]).is_some(), "period {period}: shorts");
assert!(turned_outlines(&[both]).is_none(), "period {period}: both");
}
}
#[test]
fn the_load_counts_every_overlapping_pair() {
let mut rng = Rng(5);
for n in [0, 1, 2, 7, 60] {
// Coarse values, so many spans share ends and touch.
let spans: Vec<(f64, f64)> = (0..n)
.map(|_| {
let a = (rng.unit() * 10.0).round();
let b = (rng.unit() * 10.0).round();
(a.min(b), a.max(b))
})
.collect();
let by_hand = spans.iter().map(|&(lo, hi)| spans.iter().filter(|&&(s, e)| s <= hi && e >= lo).count() as u64).sum::<u64>();
let (mut starts, mut ends): (Vec<f64>, Vec<f64>) = spans.iter().copied().unzip();
assert_eq!(load(&mut starts, &mut ends), by_hand, "{n} spans");
}
}
#[test]
fn fill_boxes_are_the_boxes_of_the_fills_segments() {
let mut rng = Rng(9);
let open = zigzag(2, 30, 1.0, &mut rng, false);
let closed = zigzag(1, 20, 1.0, &mut rng, true);
let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(4.0, 4.0))], false));
let paths = [(&open, FillRule::NonZero), (&closed, FillRule::EvenOdd), (&lone, FillRule::NonZero)];
let fills: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
assert_eq!(fill_segments(&paths).map(fill_segment_box).collect::<Vec<_>>(), outline_segments(&fills).map(segment_box).collect::<Vec<_>>());
assert_eq!(filled_subpaths(&paths).map(|sp| sp.anchors.len()).sum::<usize>(), fills.iter().map(|f| f.segments().count()).sum::<usize>());
}
#[test]
fn turning_and_turning_back_moves_no_point() {
let mut rng = Rng(11);
let p = zigzag(2, 50, 3.0, &mut rng, true);
assert_eq!(unturn(p.transformed(TURN)), p);
let bp = outline(&p);
assert_eq!(UNTURN * (TURN * &bp), bp);
}
#[test]
fn turned_results_match_the_upright_sweep_on_random_paths() {
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
for case in 0..3 {
let rows = 2 + case;
let low = zigzag(rows, 120 + 40 * case, 0.8, &mut rng, false);
let stroke = [outline(&low)];
assert!(turned_outlines(&stroke).is_some(), "case {case}: not turned");
assert_same_region(&stroke_region(&stroke), &stroke_region_upright(&stroke), &format!("case {case}: stroke"));
let fill = zigzag(rows, 1100, 0.8, &mut rng, true);
let one = [(&fill, FillRule::NonZero)];
assert!(turned_fills(&one).is_some(), "case {case}: fill not turned");
assert_same_region(&unite_all_with(&one, DEFAULT_PRECISION), &unite_all_upright(&one, DEFAULT_PRECISION), &format!("case {case}: unite"));
assert_same_region(
&try_unite_all_with(&one, DEFAULT_PRECISION).unwrap(),
&try_unite_all_upright(&one, DEFAULT_PRECISION).unwrap(),
&format!("case {case}: try_unite"),
);
}
}
#[test]
fn tall_and_ordinary_art_give_exactly_the_upright_result() {
let mut rng = Rng(13);
let tall = [outline(&upright(&zigzag(3, 300, 0.4, &mut rng, false)))];
assert_eq!(stroke_region(&tall), stroke_region_upright(&tall), "tall stroke");
let tall_fill = upright(&zigzag(3, 800, 0.4, &mut rng, true));
let one = [(&tall_fill, FillRule::NonZero)];
assert_eq!(unite_all_with(&one, DEFAULT_PRECISION), unite_all_upright(&one, DEFAULT_PRECISION), "tall unite");
// A square grid of overlapping squares, and a small path below the size that is measured.
let grid: Vec<PathData> = (0..900).map(|i| square((i % 30) as f64 * 0.75, (i / 30) as f64 * 0.75)).collect();
let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect();
assert_eq!(unite_all_with(&grid, DEFAULT_PRECISION), unite_all_upright(&grid, DEFAULT_PRECISION), "grid");
assert_eq!(try_unite_all_with(&grid, DEFAULT_PRECISION).unwrap(), try_unite_all_upright(&grid, DEFAULT_PRECISION).unwrap(), "grid, try");
let small = [outline(&zigzag(1, 40, 0.4, &mut rng, false))];
assert_eq!(stroke_region(&small), stroke_region_upright(&small), "small");
}
#[test]
fn edge_cases_give_the_upright_result() {
// Nothing to sweep.
assert!(turned_outlines(&[]).is_none());
assert!(stroke_region(&[]).is_empty());
assert!(unite_all_with(&[], DEFAULT_PRECISION).is_empty());
// A single segment.
let line = BezPath::from_vec(vec![kurbo::PathEl::MoveTo((0.0, 0.0).into()), kurbo::PathEl::LineTo((5_000.0, 0.0).into())]);
let one = [outline(&PathData::from_bezpath(&line))];
assert!(turned_outlines(&one).is_none());
assert_same_region(&stroke_region(&one), &stroke_region_upright(&one), "one segment");
// No overlaps: a row of separate squares.
let squares: Vec<PathData> = (0..600).map(|i| square(i as f64 * 3.0, 0.0)).collect();
let refs: Vec<(&PathData, FillRule)> = squares.iter().map(|p| (p, FillRule::NonZero)).collect();
assert!(turned_fills(&refs).is_some());
let apart = unite_all_with(&refs, DEFAULT_PRECISION);
assert_eq!(apart.subpaths.len(), 600);
assert_same_region(&apart, &unite_all_upright(&refs, DEFAULT_PRECISION), "separate squares");
// All anchors on one line: no area either way.
let flat = PathData::single(SubPath::new((0..2100).map(|i| Anchor::corner(Point::new(i as f64, 0.0))).collect(), true));
let flat = [(&flat, FillRule::NonZero)];
assert!(turned_fills(&flat).is_some());
assert!(unite_all_with(&flat, DEFAULT_PRECISION).is_empty());
assert!(unite_all_upright(&flat, DEFAULT_PRECISION).is_empty());
// Coincident segments: the same path twice unites to that path.
let mut rng = Rng(3);
let fill = zigzag(2, 1100, 0.0, &mut rng, true);
let twice = [(&fill, FillRule::NonZero), (&fill, FillRule::NonZero)];
assert!(turned_fills(&twice).is_some());
assert_same_region(&unite_all_with(&twice, DEFAULT_PRECISION), &unite_all_upright(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION), "twice");
}
}