mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:33:54 +00:00
810 lines
36 KiB
Rust
810 lines
36 KiB
Rust
//! Offset Path and Outline Stroke.
|
||
|
||
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
|
||
use vectorcraft_geom::{FillRule, PathData, SubPath};
|
||
|
||
pub use kurbo::{Cap, Join};
|
||
|
||
use linesweeper::topology::{ContourIdx, Contours};
|
||
|
||
use crate::PathOpsError;
|
||
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez};
|
||
use crate::edit::remove_redundant_points;
|
||
|
||
/// Flattening/fit tolerance handed to kurbo's stroker.
|
||
const STROKE_TOL: f64 = 1e-3;
|
||
|
||
/// Most overlapping segment pairs allowed for planar arrangement in offset path: 524,288 (1 << 19).
|
||
///
|
||
/// Rationale:
|
||
/// - The global boolean sweep cap is `MAX_SEGMENT_PAIRS = 1 << 22` (4,194,304 pairs).
|
||
/// - An offset operation decomposes paths into outer rings, inner/fill boundaries, and open
|
||
/// subpath strokes. When applied to multi-copy transformations (e.g. 25–50 copies in #4145),
|
||
/// these parallel strokes create dense self- and cross-intersections where pairwise bounding-box
|
||
/// overlaps scale quadratically with the number of segments.
|
||
/// - At 1 << 19 (524,288 segment pairs), the planar sweep memory usage is bounded to tens of MBs
|
||
/// and completes within ~100 ms on typical CPUs, preventing exponential allocation spikes and
|
||
/// application hangs while still generously accommodating legitimate complex artwork.
|
||
pub const MAX_OFFSET_SEGMENT_PAIRS: usize = 1 << 19;
|
||
|
||
/// Drop collinear and coincident spine vertices before stroking (#3446). Uses `tol` only for
|
||
/// redundancy; does not simplify or refit the spine.
|
||
fn compact_stroke_spine(bp: &BezPath, tol: f64) -> BezPath {
|
||
if bp.elements().is_empty() {
|
||
return BezPath::new();
|
||
}
|
||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { STROKE_TOL };
|
||
remove_redundant_points(&PathData::from_bezpath(bp), tol).to_bezpath()
|
||
}
|
||
|
||
fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
|
||
let spine = compact_stroke_spine(bp, STROKE_TOL);
|
||
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
|
||
close_all(&kurbo::stroke(spine.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
|
||
}
|
||
|
||
/// Like [`stroke_bez`] but without [`compact_stroke_spine`] (regression baseline in unit tests).
|
||
#[cfg(test)]
|
||
fn stroke_bez_without_spine_compact(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
|
||
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
|
||
close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
|
||
}
|
||
|
||
/// kurbo's stroker may leave the outline of an open path without a final `ClosePath`.
|
||
fn close_all(bp: &BezPath) -> BezPath {
|
||
let mut out = BezPath::new();
|
||
let mut open = false;
|
||
for el in bp.iter() {
|
||
match el {
|
||
PathEl::MoveTo(_) => {
|
||
if open {
|
||
out.close_path();
|
||
}
|
||
open = true;
|
||
}
|
||
PathEl::ClosePath => open = false,
|
||
_ => {}
|
||
}
|
||
out.push(el);
|
||
}
|
||
if open {
|
||
out.close_path();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Object → Path → Outline Stroke: the filled area painted by stroking `path` with `width`.
|
||
/// Overlaps produced by the stroker are removed, so the result is a clean compound path.
|
||
pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData {
|
||
if width <= 0.0 || !width.is_finite() || path.is_empty() {
|
||
return PathData::default();
|
||
}
|
||
stroke_region(&[stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit)])
|
||
}
|
||
|
||
/// 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[..] {
|
||
return normalize_bez(one, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default();
|
||
}
|
||
// Each outline is normalised on its own first: a hollow arrowhead may wind against the line
|
||
// it overlaps, and the raw windings would cancel there under the non-zero rule.
|
||
let mut all = BezPath::new();
|
||
for p in &parts {
|
||
if let Ok(c) = normalize_bez(p, FillRule::NonZero) {
|
||
for ct in c.contours() {
|
||
all.extend(ct.path.iter());
|
||
}
|
||
}
|
||
}
|
||
if all.elements().is_empty() {
|
||
return PathData::default();
|
||
}
|
||
normalize_bez(&all, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default()
|
||
}
|
||
|
||
/// Validate that all anchor coordinates and control handles of `path` are finite.
|
||
fn path_is_finite(path: &PathData) -> bool {
|
||
path.subpaths.iter().all(|sp| {
|
||
sp.anchors.iter().all(|a| {
|
||
a.p.x.is_finite() && a.p.y.is_finite() && a.h_in.x.is_finite() && a.h_in.y.is_finite() && a.h_out.x.is_finite() && a.h_out.y.is_finite()
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Fallible [`offset_path`] that returns an error when the input is non-finite or exceeds
|
||
/// the complexity bound ([`MAX_OFFSET_SEGMENT_PAIRS`]), or when planar arrangement fails.
|
||
pub fn try_offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> Result<PathData, PathOpsError> {
|
||
let src = std::cell::LazyCell::new(|| SegmentTree::new(boxed_segments(&path.to_bezpath())));
|
||
offset_with_check(path, delta, join, miter_limit, |p, r| any_within(&src, p, r))
|
||
}
|
||
|
||
/// [`try_offset_path`] with the test of whether some segment of `path` lies closer than a radius to a point.
|
||
fn offset_with_check(
|
||
path: &PathData,
|
||
delta: f64,
|
||
join: Join,
|
||
miter_limit: f64,
|
||
within: impl Fn(kurbo::Point, f64) -> bool,
|
||
) -> Result<PathData, PathOpsError> {
|
||
if !delta.is_finite() || !path_is_finite(path) {
|
||
return Err(PathOpsError::NonFinite);
|
||
}
|
||
if path.is_empty() {
|
||
return Ok(PathData::default());
|
||
}
|
||
let subpaths: Vec<SubPath> = path.subpaths.iter().map(close_if_ends_meet).collect();
|
||
let closed = PathData::new(subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());
|
||
let open: Vec<SubPath> = subpaths.into_iter().filter(|s| !s.closed && s.anchors.len() > 1).collect();
|
||
let d = delta.abs();
|
||
let fill = fill_bezpath(&closed);
|
||
let ring = if d > 0.0 { stroke_bez(&closed.to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) } else { BezPath::new() };
|
||
let open_bp = if d > 0.0 && !open.is_empty() {
|
||
stroke_bez(&PathData::new(open).to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit)
|
||
} else {
|
||
BezPath::new()
|
||
};
|
||
let arr = Arrangement::new_budgeted(
|
||
vec![(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)],
|
||
MAX_OFFSET_SEGMENT_PAIRS,
|
||
)?;
|
||
let c = if delta >= 0.0 { arr.contours_covering(|c| !c.is_empty()) } else { arr.contours_covering(|c| c.contains(&0) && !c.contains(&1)) };
|
||
// When `d` exceeds a curvature radius the stroker's inner offset inverts and its loops cancel
|
||
// winding, leaving faces uncovered that are really within `d` of the path. Every face of the
|
||
// true offset is bounded by arrangement edges, so a contour is spurious iff a point deep inside
|
||
// it lies closer than `d`: holes when growing, islands when insetting.
|
||
let drop_outer = delta < 0.0;
|
||
let spurious: Vec<bool> =
|
||
c.contours().map(|k| k.outer == drop_outer && deep_point(&k.path).is_some_and(|p| within(p, d * (1.0 - 1e-4) - 1e-6))).collect();
|
||
if !spurious.contains(&true) {
|
||
return Ok(all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION)));
|
||
}
|
||
let keep = (0..spurious.len()).filter(|&i| !has_marked_ancestor(&c, i, &spurious)).map(ContourIdx);
|
||
Ok(contours_to_path(&c, keep, &Tidy::free(DEFAULT_PRECISION)))
|
||
}
|
||
|
||
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
|
||
/// Closed subpaths are treated as a non-zero filled region; open subpaths are outlined with a
|
||
/// stroke of width `2|delta|` (butt caps). An open subpath whose last anchor sits on its first is
|
||
/// offset as a closed one.
|
||
///
|
||
/// If planar arrangement exceeds complexity or encounters a degenerate arrangement error,
|
||
/// gracefully preserves `path` unchanged to prevent rendering crashes or disappearing artwork (#4145).
|
||
/// Non-finite input returns an empty path.
|
||
pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData {
|
||
try_offset_path(path, delta, join, miter_limit).unwrap_or_else(|e| match e {
|
||
PathOpsError::NonFinite => PathData::default(),
|
||
_ => path.clone(),
|
||
})
|
||
}
|
||
|
||
/// An open subpath of three or more anchors whose last anchor lies within 1e-6 of its first (the
|
||
/// test Object → Path → Join applies to one path) as a closed subpath: the last anchor is merged
|
||
/// into the first, which takes its in-handle. Any other subpath is returned unchanged.
|
||
fn close_if_ends_meet(sp: &SubPath) -> SubPath {
|
||
let mut sp = sp.clone();
|
||
if !sp.closed
|
||
&& sp.anchors.len() > 2
|
||
&& sp.anchors.first().zip(sp.anchors.last()).is_some_and(|(f, l)| f.p.distance(l.p) < 1e-6)
|
||
&& let Some(last) = sp.anchors.pop()
|
||
&& let Some(first) = sp.anchors.first_mut()
|
||
{
|
||
first.h_in = last.h_in;
|
||
sp.closed = true;
|
||
}
|
||
sp
|
||
}
|
||
|
||
fn has_marked_ancestor(c: &Contours, mut i: usize, marked: &[bool]) -> bool {
|
||
loop {
|
||
if marked[i] {
|
||
return true;
|
||
}
|
||
match c[ContourIdx(i)].parent {
|
||
Some(ContourIdx(p)) => i = p,
|
||
None => return false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A point well inside a closed contour: the midpoint of the widest span on the horizontal line
|
||
/// through its vertical centre.
|
||
fn deep_point(bp: &BezPath) -> Option<kurbo::Point> {
|
||
use kurbo::Shape;
|
||
let r = bp.bounding_box();
|
||
if !(r.width() > 0.0 && r.height() > 0.0) {
|
||
return None;
|
||
}
|
||
let mut best: Option<(f64, kurbo::Point)> = None;
|
||
for f in [0.5, 0.3, 0.7] {
|
||
let y = r.y0 + r.height() * f;
|
||
let mut xs = vec![];
|
||
let mut prev: Option<kurbo::Point> = None;
|
||
let mut start = kurbo::Point::ZERO;
|
||
let edge = |a: kurbo::Point, b: kurbo::Point, xs: &mut Vec<f64>| {
|
||
if (a.y <= y) != (b.y <= y) {
|
||
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
|
||
}
|
||
};
|
||
kurbo::flatten(bp.iter(), 0.01 * r.width().min(r.height()).max(1e-6), |el| match el {
|
||
PathEl::MoveTo(p) => {
|
||
start = p;
|
||
prev = Some(p);
|
||
}
|
||
PathEl::LineTo(p) => {
|
||
if let Some(a) = prev {
|
||
edge(a, p, &mut xs);
|
||
}
|
||
prev = Some(p);
|
||
}
|
||
PathEl::ClosePath => {
|
||
if let Some(a) = prev {
|
||
edge(a, start, &mut xs);
|
||
}
|
||
prev = Some(start);
|
||
}
|
||
_ => {}
|
||
});
|
||
xs.sort_by(f64::total_cmp);
|
||
for w in xs.as_chunks::<2>().0 {
|
||
let span = w[1] - w[0];
|
||
if best.is_none_or(|(b, _)| span > b) {
|
||
best = Some((span, kurbo::Point::new((w[0] + w[1]) / 2.0, y)));
|
||
}
|
||
}
|
||
}
|
||
best.map(|(_, p)| p)
|
||
}
|
||
|
||
/// Each segment of `bp` with its bounding box.
|
||
fn boxed_segments(bp: &BezPath) -> Vec<(kurbo::Rect, kurbo::PathSeg)> {
|
||
use kurbo::ParamCurveExtrema;
|
||
bp.segments().map(|s| (s.bounding_box(), s)).collect()
|
||
}
|
||
|
||
/// Whether a segment with bounding box `b` could lie within `r` of `p`: the box is inflated by `r`
|
||
/// and by slack for rounding in the box and in the curve evaluation, which grows with the coordinates.
|
||
fn box_may_reach(b: &kurbo::Rect, p: kurbo::Point, r: f64) -> bool {
|
||
let reach = r + 1e-6 + 1e-9 * p.x.abs().max(p.y.abs());
|
||
p.x >= b.x0 - reach && p.x <= b.x1 + reach && p.y >= b.y0 - reach && p.y <= b.y1 + reach
|
||
}
|
||
|
||
/// Most segments in a leaf of a [`SegmentTree`].
|
||
const TREE_LEAF: usize = 8;
|
||
|
||
/// A path's segments in a tree of bounding boxes, built once and searched for the segments near a point.
|
||
struct SegmentTree {
|
||
/// The segments, ordered so that each leaf holds a run of them.
|
||
segs: Vec<(kurbo::Rect, kurbo::PathSeg)>,
|
||
/// The nodes, each one followed by the nodes below it.
|
||
nodes: Vec<TreeNode>,
|
||
}
|
||
|
||
struct TreeNode {
|
||
/// The union of the boxes below this node. A box holding NaN never passes [`box_may_reach`],
|
||
/// and the union leaves its NaN out.
|
||
bounds: kurbo::Rect,
|
||
/// The segments of a leaf; empty for a node with nodes below it.
|
||
leaf: std::ops::Range<usize>,
|
||
/// The first node after this one that is not below it.
|
||
next: usize,
|
||
}
|
||
|
||
impl SegmentTree {
|
||
fn new(mut segs: Vec<(kurbo::Rect, kurbo::PathSeg)>) -> Self {
|
||
let mut nodes = Vec::new();
|
||
Self::split(&mut segs, 0, &mut nodes);
|
||
Self { segs, nodes }
|
||
}
|
||
|
||
/// Adds the nodes for `segs`, which start at index `start` of the whole list: past
|
||
/// [`TREE_LEAF`] segments, halves them along the wider side of their bounds.
|
||
fn split(segs: &mut [(kurbo::Rect, kurbo::PathSeg)], start: usize, nodes: &mut Vec<TreeNode>) {
|
||
let empty = kurbo::Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||
let bounds = segs.iter().fold(empty, |u, (b, _)| u.union(*b));
|
||
let at = nodes.len();
|
||
let leaf = if segs.len() <= TREE_LEAF { start..start + segs.len() } else { start..start };
|
||
nodes.push(TreeNode { bounds, leaf, next: at + 1 });
|
||
if segs.len() > TREE_LEAF {
|
||
let wide = bounds.width() >= bounds.height();
|
||
let center = |b: &kurbo::Rect| if wide { b.x0 + b.x1 } else { b.y0 + b.y1 };
|
||
let mid = segs.len() / 2;
|
||
segs.select_nth_unstable_by(mid, |a, b| center(&a.0).total_cmp(¢er(&b.0)));
|
||
let (low, high) = segs.split_at_mut(mid);
|
||
Self::split(low, start, nodes);
|
||
Self::split(high, start + mid, nodes);
|
||
let end = nodes.len();
|
||
if let Some(n) = nodes.get_mut(at) {
|
||
n.next = end;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether `test` holds for a segment in a leaf whose bounds [`box_may_reach`] allows. Stops at
|
||
/// the first segment it holds for.
|
||
fn any_near(&self, p: kurbo::Point, r: f64, mut test: impl FnMut(&(kurbo::Rect, kurbo::PathSeg)) -> bool) -> bool {
|
||
let mut i = 0;
|
||
while let Some(n) = self.nodes.get(i) {
|
||
if !box_may_reach(&n.bounds, p, r) {
|
||
i = n.next;
|
||
} else if n.leaf.is_empty() {
|
||
i += 1;
|
||
} else if self.segs.get(n.leaf.clone()).is_some_and(|run| run.iter().any(&mut test)) {
|
||
return true;
|
||
} else {
|
||
i = n.next;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Whether some segment lies closer than `r` to `p`. A segment whose box is too far away can't, so
|
||
/// it is skipped without the costly nearest-point search, and the search stops at the first
|
||
/// segment that does.
|
||
fn any_within(tree: &SegmentTree, p: kurbo::Point, r: f64) -> bool {
|
||
use kurbo::ParamCurveNearest;
|
||
r > 0.0 && tree.any_near(p, r, |(b, s)| box_may_reach(b, p, r) && s.nearest(p, 1e-6).distance_sq.sqrt() < r)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use kurbo::Shape;
|
||
|
||
use super::*;
|
||
|
||
/// The check as it was: the nearest distance over every segment, then compared.
|
||
fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 {
|
||
use kurbo::ParamCurveNearest;
|
||
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
|
||
}
|
||
|
||
struct Lcg(u64);
|
||
impl Lcg {
|
||
fn next(&mut self) -> f64 {
|
||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||
}
|
||
/// A coordinate on a coarse grid, so segments touch, coincide and nest often.
|
||
fn grid(&mut self, n: f64) -> f64 {
|
||
(self.next() * n).floor()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn nearby_segment_check_agrees_with_the_full_scan() {
|
||
let mut rng = Lcg(7);
|
||
let (mut hits, mut skipped) = (0, 0);
|
||
for case in 0..400 {
|
||
let n = [3.0, 6.0, 20.0][case % 3];
|
||
let mut bp = BezPath::new();
|
||
for _ in 0..1 + (rng.next() * 12.0) as usize {
|
||
let pt = |rng: &mut Lcg| kurbo::Point::new(rng.grid(n), rng.grid(n));
|
||
let a = pt(&mut rng);
|
||
bp.move_to(a);
|
||
match (rng.next() * 5.0) as u32 {
|
||
0 => bp.line_to(a), // zero length
|
||
1 => bp.line_to(pt(&mut rng)),
|
||
2 => bp.quad_to(pt(&mut rng), pt(&mut rng)),
|
||
3 => bp.curve_to(pt(&mut rng), pt(&mut rng), pt(&mut rng)),
|
||
_ => {
|
||
// A circle, touching or nested in others.
|
||
let r = 1.0 + rng.grid(n / 2.0);
|
||
bp.extend(kurbo::Circle::new(a, r).to_path(0.01));
|
||
}
|
||
}
|
||
if rng.next() < 0.3 {
|
||
bp.close_path();
|
||
}
|
||
}
|
||
// The same geometry far from the origin too, where the rounding slack matters.
|
||
let shift = [0.0, 1e6, 1e9][case % 3];
|
||
let bp = kurbo::Affine::translate((shift, -shift)) * bp;
|
||
let segs = boxed_segments(&bp);
|
||
let tree = SegmentTree::new(segs.clone());
|
||
for _ in 0..40 {
|
||
// On the grid (on segments and their ends) and off it.
|
||
let p = if rng.next() < 0.5 {
|
||
kurbo::Point::new(rng.grid(n), rng.grid(n))
|
||
} else {
|
||
kurbo::Point::new(rng.next() * (n + 2.0) - 1.0, rng.next() * (n + 2.0) - 1.0)
|
||
};
|
||
let p = kurbo::Point::new(p.x + shift, p.y - shift);
|
||
let nearest = dist_to(&bp, p);
|
||
// Radii around the true distance (where a wrong skip would show), just outside a
|
||
// segment's box, and the usual kind.
|
||
let box_gap =
|
||
segs.iter().map(|(b, _)| (b.x0 - p.x).max(p.x - b.x1).max(b.y0 - p.y).max(p.y - b.y1).max(0.0)).fold(f64::INFINITY, f64::min);
|
||
for r in [
|
||
nearest,
|
||
nearest * (1.0 - 1e-9),
|
||
nearest * (1.0 + 1e-9),
|
||
nearest + 1e-6,
|
||
box_gap + 5e-7,
|
||
box_gap + 2e-6,
|
||
0.0,
|
||
-1.0,
|
||
1e-7,
|
||
rng.next() * 3.0,
|
||
1e6,
|
||
] {
|
||
assert_eq!(any_within(&tree, p, r), nearest < r, "case {case} p {p:?} r {r} nearest {nearest}");
|
||
hits += usize::from(nearest < r);
|
||
// Every segment the box test skips is really out of reach.
|
||
for (b, s) in &segs {
|
||
if r > 0.0 && !box_may_reach(b, p, r) {
|
||
skipped += 1;
|
||
use kurbo::ParamCurveNearest;
|
||
assert!(s.nearest(p, 1e-6).distance_sq.sqrt() >= r, "case {case} skipped a segment within {r} of {p:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(hits > 1000 && skipped > 10_000, "{hits} {skipped}");
|
||
assert!(!any_within(&SegmentTree::new(vec![]), kurbo::Point::ZERO, 1.0));
|
||
}
|
||
|
||
/// The test over every segment in path order, with no tree.
|
||
fn any_within_by_scan(segs: &[(kurbo::Rect, kurbo::PathSeg)], p: kurbo::Point, r: f64) -> bool {
|
||
use kurbo::ParamCurveNearest;
|
||
r > 0.0 && segs.iter().any(|(b, s)| box_may_reach(b, p, r) && s.nearest(p, 1e-6).distance_sq.sqrt() < r)
|
||
}
|
||
|
||
/// A grid of `n` by `n` unit lines, 10 apart.
|
||
fn line_grid(n: usize) -> Vec<(kurbo::Rect, kurbo::PathSeg)> {
|
||
let mut bp = BezPath::new();
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
let (x, y) = (i as f64 * 10.0, j as f64 * 10.0);
|
||
bp.move_to((x, y));
|
||
bp.line_to((x + 1.0, y));
|
||
}
|
||
}
|
||
boxed_segments(&bp)
|
||
}
|
||
|
||
#[test]
|
||
fn the_tree_tests_only_the_segments_near_the_point() {
|
||
let tree = SegmentTree::new(line_grid(100));
|
||
for (p, r) in [((500.5, 500.0), 0.25), ((503.0, 505.0), 2.0), ((-50.0, -50.0), 1.0)] {
|
||
let p = kurbo::Point::new(p.0, p.1);
|
||
let mut tested = 0;
|
||
tree.any_near(p, r, |_| {
|
||
tested += 1;
|
||
false
|
||
});
|
||
assert!(tested <= 4 * TREE_LEAF, "{tested} of 10000 segments tested at {p:?} within {r}");
|
||
}
|
||
// A radius that takes in the whole grid tests every segment.
|
||
let mut tested = 0;
|
||
tree.any_near(kurbo::Point::new(500.0, 500.0), 1e6, |_| {
|
||
tested += 1;
|
||
false
|
||
});
|
||
assert_eq!(tested, 10_000);
|
||
}
|
||
|
||
#[test]
|
||
fn the_tree_holds_every_segment_once_at_any_size() {
|
||
for n in [0, 1, TREE_LEAF - 1, TREE_LEAF, TREE_LEAF + 1, 2 * TREE_LEAF + 1, 100, 1000] {
|
||
let mut bp = BezPath::new();
|
||
for i in 0..n {
|
||
bp.move_to((i as f64, 0.0));
|
||
bp.line_to((i as f64, 1.0));
|
||
}
|
||
let tree = SegmentTree::new(boxed_segments(&bp));
|
||
let mut xs = vec![];
|
||
tree.any_near(kurbo::Point::new(0.0, 0.0), 1e9, |(b, _)| {
|
||
xs.push(b.x0);
|
||
false
|
||
});
|
||
xs.sort_by(f64::total_cmp);
|
||
assert_eq!(xs, (0..n).map(|i| i as f64).collect::<Vec<_>>());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_tree_answers_like_the_scan_when_boxes_are_not_finite() {
|
||
// Boxes with NaN or infinite sides, as extreme coordinates can give, among ordinary ones.
|
||
let line = |x: f64| kurbo::PathSeg::Line(kurbo::Line::new((x, 0.0), (x + 1.0, 0.0)));
|
||
let mut segs: Vec<(kurbo::Rect, kurbo::PathSeg)> =
|
||
(0..40).map(|i| (line(f64::from(i) * 5.0).bounding_box(), line(f64::from(i) * 5.0))).collect();
|
||
for (k, b) in [
|
||
kurbo::Rect::new(f64::NAN, 0.0, 1.0, 1.0),
|
||
kurbo::Rect::new(0.0, f64::NAN, 1.0, 1.0),
|
||
kurbo::Rect::new(0.0, 0.0, f64::NAN, 1.0),
|
||
kurbo::Rect::new(0.0, 0.0, 1.0, f64::NAN),
|
||
kurbo::Rect::new(f64::NEG_INFINITY, -1.0, f64::INFINITY, 1.0),
|
||
kurbo::Rect::new(f64::NAN, f64::NAN, f64::NAN, f64::NAN),
|
||
]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
segs.insert(k * 6, (b, line(1000.0 + k as f64 * 5.0)));
|
||
}
|
||
for every_box_is_nan in [false, true] {
|
||
if every_box_is_nan {
|
||
segs.iter_mut().for_each(|(b, _)| *b = kurbo::Rect::new(f64::NAN, f64::NAN, f64::NAN, f64::NAN));
|
||
}
|
||
let tree = SegmentTree::new(segs.clone());
|
||
for x in [-3.0, 0.5, 7.0, 100.0, 1000.5, 1012.0, 1e6] {
|
||
for r in [0.5, 2.0, 50.0, 1e7] {
|
||
let p = kurbo::Point::new(x, 0.25);
|
||
assert_eq!(any_within(&tree, p, r), any_within_by_scan(&segs, p, r), "{every_box_is_nan} {p:?} {r}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Paths across the kinds of input the offset treats differently.
|
||
fn offset_inputs() -> Vec<PathData> {
|
||
let mut rng = Lcg(31);
|
||
let mut paths = vec![];
|
||
let from = |bp: &BezPath| PathData::from_bezpath(bp);
|
||
// Separate shapes with no overlaps.
|
||
let mut islands = BezPath::new();
|
||
for i in 0..5 {
|
||
for j in 0..5 {
|
||
let (x, y) = (f64::from(i) * 20.0, f64::from(j) * 20.0);
|
||
islands.extend(kurbo::Rect::new(x, y, x + 12.0, y + 12.0).to_path(0.1));
|
||
}
|
||
}
|
||
paths.push(from(&islands));
|
||
// A compound path with holes, some of a radius equal to the offsets below.
|
||
for small in [0.8, 1.0, 2.0] {
|
||
let mut plate = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
|
||
for i in 0..4 {
|
||
for j in 0..4 {
|
||
let r = if (i + j) % 2 == 0 { small } else { 4.0 };
|
||
plate.extend(kurbo::Circle::new((12.5 + 25.0 * f64::from(i), 12.5 + 25.0 * f64::from(j)), r).to_path(0.01).reverse_subpaths());
|
||
}
|
||
}
|
||
paths.push(from(&plate));
|
||
}
|
||
// A self-crossing star, closed and open.
|
||
let star = |close: bool| {
|
||
let mut bp = BezPath::new();
|
||
for i in 0..=7 {
|
||
let t = f64::from(i) * 3.0 / 7.0 * std::f64::consts::TAU;
|
||
let p = (50.0 + 40.0 * t.cos(), 50.0 + 40.0 * t.sin());
|
||
if i == 0 { bp.move_to(p) } else { bp.line_to(p) }
|
||
}
|
||
if close {
|
||
bp.close_path();
|
||
}
|
||
bp
|
||
};
|
||
paths.push(from(&star(true)));
|
||
paths.push(from(&star(false)));
|
||
// Short paths: one line, one curve, a single anchor, and a path whose ends meet.
|
||
for svg in ["M0,0 L10,0", "M0,0 C5,10 10,-10 15,0", "M3,3", "M0,0 L10,0 L10,10 L0,0"] {
|
||
paths.push(from(&BezPath::from_svg(svg).unwrap()));
|
||
}
|
||
// An open zigzag whose stroke leaves many small holes.
|
||
let mut zigzag = BezPath::new();
|
||
for row in 0..4 {
|
||
for col in 0..40 {
|
||
let c = if row % 2 == 0 { col } else { 39 - col };
|
||
let p = (f64::from(c) * 1.6, f64::from(row) * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 });
|
||
if row == 0 && col == 0 { zigzag.move_to(p) } else { zigzag.line_to(p) }
|
||
}
|
||
}
|
||
paths.push(from(&zigzag));
|
||
// Random curves that cross themselves and each other, near the origin and far from it.
|
||
for case in 0..18 {
|
||
let n = [6.0, 40.0][case % 2];
|
||
let mut bp = BezPath::new();
|
||
for _ in 0..1 + case % 4 {
|
||
let pt = |rng: &mut Lcg| kurbo::Point::new(rng.grid(n), rng.grid(n));
|
||
bp.move_to(pt(&mut rng));
|
||
for _ in 0..2 + (rng.next() * 5.0) as usize {
|
||
match (rng.next() * 3.0) as u32 {
|
||
0 => bp.line_to(pt(&mut rng)),
|
||
1 => bp.quad_to(pt(&mut rng), pt(&mut rng)),
|
||
_ => bp.curve_to(pt(&mut rng), pt(&mut rng), pt(&mut rng)),
|
||
}
|
||
}
|
||
if rng.next() < 0.6 {
|
||
bp.close_path();
|
||
}
|
||
}
|
||
let shift = [0.0, 0.0, 1e6][case % 3];
|
||
paths.push(from(&(kurbo::Affine::translate((shift, -shift)) * bp)));
|
||
}
|
||
paths
|
||
}
|
||
|
||
#[test]
|
||
fn offsets_with_the_tree_are_identical_to_offsets_with_the_scan() {
|
||
let (mut checks, mut hits, mut misses) = (0, 0, 0);
|
||
for path in offset_inputs() {
|
||
let segs = boxed_segments(&path.to_bezpath());
|
||
for delta in [0.0, 0.05, 1.0, 5.0, 60.0, -0.05, -1.0, -5.0, -60.0] {
|
||
for join in [Join::Round, Join::Miter, Join::Bevel] {
|
||
let found = std::cell::Cell::new((0, 0));
|
||
let by_scan = offset_with_check(&path, delta, join, 4.0, |p, r| {
|
||
let v = any_within_by_scan(&segs, p, r);
|
||
found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v)));
|
||
v
|
||
});
|
||
checks += 1;
|
||
hits += found.get().0;
|
||
misses += found.get().1;
|
||
assert_eq!(try_offset_path(&path, delta, join, 4.0), by_scan, "{path:?} by {delta} with {join:?}");
|
||
}
|
||
}
|
||
}
|
||
assert!(checks > 500 && hits > 200 && misses > 200, "{checks} {hits} {misses}");
|
||
}
|
||
|
||
#[test]
|
||
fn growing_a_plate_with_many_holes_keeps_the_holes_that_stay() {
|
||
// A plate with a grid of round holes: growing by 1 closes the narrow ones (radius <= 1)
|
||
// and keeps the wide ones.
|
||
let mut plate = BezPath::new();
|
||
plate.extend(kurbo::Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1));
|
||
for i in 0..10 {
|
||
for j in 0..10 {
|
||
let r = if (i + j) % 2 == 0 { 0.8 } else { 4.0 };
|
||
plate.extend(kurbo::Circle::new((10.0 + 20.0 * f64::from(i), 10.0 + 20.0 * f64::from(j)), r).to_path(0.01).reverse_subpaths());
|
||
}
|
||
}
|
||
let out = offset_path(&PathData::from_bezpath(&plate), 1.0, Join::Round, 4.0);
|
||
// The outer contour and the 50 wide holes; the 50 narrow ones are gone.
|
||
assert_eq!(out.subpaths.len(), 1 + 50);
|
||
// The holes left are the wide ones, shrunk to radius 3 and still centred on their grid point.
|
||
let mut holes = 0;
|
||
for sp in out.subpaths.iter().skip(1) {
|
||
let b = PathData::single(sp.clone()).to_bezpath().bounding_box();
|
||
let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0);
|
||
assert!((b.width() - 6.0).abs() < 0.05 && (b.height() - 6.0).abs() < 0.05, "{b:?}");
|
||
let (i, j) = (((cx - 10.0) / 20.0).round(), ((cy - 10.0) / 20.0).round());
|
||
assert!((cx - 10.0 - 20.0 * i).abs() < 0.05 && (cy - 10.0 - 20.0 * j).abs() < 0.05 && (i + j) % 2.0 != 0.0, "{b:?}");
|
||
holes += 1;
|
||
}
|
||
assert_eq!(holes, 50);
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_region_unites_outlines_whatever_their_winding() {
|
||
// A bar, and a square ring wound the other way that the bar runs into.
|
||
let bar = kurbo::Rect::new(0.0, 0.0, 20.0, 4.0).to_path(0.1);
|
||
let mut ring = kurbo::Rect::new(10.0, -8.0, 26.0, 12.0).to_path(0.1).reverse_subpaths();
|
||
ring.extend(kurbo::Rect::new(14.0, -4.0, 22.0, 8.0).to_path(0.1).iter());
|
||
let region = stroke_region(&[bar, ring]);
|
||
let area = crate::area(®ion, FillRule::NonZero);
|
||
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
|
||
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
|
||
}
|
||
|
||
#[test]
|
||
fn miter_offset_finishes_where_the_sweep_used_to_loop() {
|
||
// Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and
|
||
// its memory grew without bound, freezing the app. A synthetic outline (found by search)
|
||
// with the same failure as the reported glyph; the exact coordinates matter.
|
||
const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \
|
||
L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \
|
||
L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \
|
||
Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \
|
||
Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \
|
||
Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z";
|
||
let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap());
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
let input = path.clone();
|
||
std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0)));
|
||
let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else {
|
||
// the stuck sweep keeps allocating on its thread: end the test binary now
|
||
eprintln!("Offset Path didn't finish in 30 s (issue #1028)");
|
||
std::process::exit(101);
|
||
};
|
||
let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero));
|
||
assert_eq!(out.subpaths.len(), 1);
|
||
assert!(after > before, "{before} → {after}");
|
||
}
|
||
|
||
fn outline_max_deviation(a: &BezPath, b: &BezPath) -> f64 {
|
||
use kurbo::ParamCurve;
|
||
use vectorcraft_geom::Point;
|
||
let pa = PathData::from_bezpath(a);
|
||
let pb = PathData::from_bezpath(b);
|
||
let mut worst: f64 = 0.0;
|
||
for (src, dst) in [(a, &pb), (b, &pa)] {
|
||
for seg in src.segments() {
|
||
for i in 0..=12 {
|
||
let p = seg.eval(i as f64 / 12.0);
|
||
let pt = Point::new(p.x, p.y);
|
||
worst = worst.max(dst.nearest(pt).unwrap().4);
|
||
}
|
||
}
|
||
}
|
||
worst
|
||
}
|
||
|
||
#[test]
|
||
fn compact_stroke_spine_drops_colinear_points_without_moving_curved_outlines() {
|
||
let width = 10.0;
|
||
let cap = Cap::Round;
|
||
let join = Join::Round;
|
||
let miter = 4.0;
|
||
// Dense colinear spine: compact outline should match the uncompacted one closely.
|
||
let dense: Vec<vectorcraft_geom::Point> = (0..=40).map(|i| vectorcraft_geom::Point::new(i as f64 * 2.5, 50.0)).collect();
|
||
let dense_path = PathData::single(SubPath::polyline(&dense, false));
|
||
let dense_bez = dense_path.to_bezpath();
|
||
let compact = stroke_bez(&dense_bez, width, cap, join, miter);
|
||
let raw = stroke_bez_without_spine_compact(&dense_bez, width, cap, join, miter);
|
||
assert!(outline_max_deviation(&compact, &raw) <= 1e-3, "colinear compaction must not refit the stroke outline");
|
||
let sparse =
|
||
PathData::single(SubPath::polyline(&[vectorcraft_geom::Point::new(0.0, 50.0), vectorcraft_geom::Point::new(100.0, 50.0)], false));
|
||
let sparse_outline = outline_stroke(&sparse, width, cap, join, miter);
|
||
let compact_region = stroke_region(&[compact]);
|
||
assert!(
|
||
outline_max_deviation(&compact_region.to_bezpath(), &sparse_outline.to_bezpath()) <= 1e-3,
|
||
"compacted colinear spine should outline like the endpoints alone"
|
||
);
|
||
// Sampled arc: only redundant vertices are removed; outline must track the raw stroke.
|
||
let arc: Vec<vectorcraft_geom::Point> = (0..=32)
|
||
.map(|i| {
|
||
let t = i as f64 / 32.0 * std::f64::consts::PI;
|
||
vectorcraft_geom::Point::new(50.0 + 50.0 * t.cos(), 50.0 + 8.0 * t.sin())
|
||
})
|
||
.collect();
|
||
let arc_bez = PathData::single(SubPath::polyline(&arc, false)).to_bezpath();
|
||
let arc_compact = stroke_bez(&arc_bez, width, cap, join, miter);
|
||
let arc_raw = stroke_bez_without_spine_compact(&arc_bez, width, cap, join, miter);
|
||
assert!(outline_max_deviation(&arc_compact, &arc_raw) <= 1e-3, "curved spine compaction must not refit the stroke outline");
|
||
}
|
||
|
||
#[test]
|
||
fn offset_path_recovers_gracefully_when_complexity_exceeds_budget() {
|
||
// Issue #4145: dense overlapping geometry must not hang or panic; if complexity passes
|
||
// the offset cap, try_offset_path reports PathOpsError::TooComplex and offset_path
|
||
// preserves the input path unchanged.
|
||
let mut bp = BezPath::new();
|
||
for i in 0..300 {
|
||
let offset = i as f64 * 0.1;
|
||
let rect = kurbo::Rect::new(offset, offset, offset + 100.0, offset + 100.0);
|
||
bp.extend(rect.to_path(0.1).iter());
|
||
}
|
||
let path = PathData::from_bezpath(&bp);
|
||
assert_eq!(try_offset_path(&path, 5.0, Join::Round, 4.0), Err(PathOpsError::TooComplex));
|
||
let res = offset_path(&path, 5.0, Join::Round, 4.0);
|
||
assert_eq!(res, path);
|
||
}
|
||
|
||
#[test]
|
||
fn offset_path_returns_empty_on_non_finite() {
|
||
let rect = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0);
|
||
let path = PathData::from_bezpath(&rect.to_path(0.1));
|
||
assert_eq!(try_offset_path(&path, f64::NAN, Join::Round, 4.0), Err(PathOpsError::NonFinite));
|
||
assert_eq!(try_offset_path(&path, f64::INFINITY, Join::Round, 4.0), Err(PathOpsError::NonFinite));
|
||
assert!(offset_path(&path, f64::NAN, Join::Round, 4.0).is_empty());
|
||
assert!(offset_path(&path, f64::INFINITY, Join::Round, 4.0).is_empty());
|
||
|
||
// Even with dense overlapping geometry, non-finite points return NonFinite error and empty rather than fallback.
|
||
let mut bp = BezPath::new();
|
||
for i in 0..300 {
|
||
let offset = i as f64 * 0.1;
|
||
let rect = kurbo::Rect::new(offset, offset, offset + 100.0, offset + 100.0);
|
||
bp.extend(rect.to_path(0.1).iter());
|
||
}
|
||
let mut dense_non_finite = PathData::from_bezpath(&bp);
|
||
dense_non_finite
|
||
.subpaths
|
||
.push(SubPath::polyline(&[vectorcraft_geom::Point::new(f64::NAN, 0.0), vectorcraft_geom::Point::new(10.0, 10.0)], true));
|
||
assert_eq!(try_offset_path(&dense_non_finite, 5.0, Join::Round, 4.0), Err(PathOpsError::NonFinite));
|
||
assert!(offset_path(&dense_non_finite, 5.0, Join::Round, 4.0).is_empty());
|
||
}
|
||
}
|