//! 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 = 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 { 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 { if !delta.is_finite() || !path_is_finite(path) { return Err(PathOpsError::NonFinite); } if path.is_empty() { return Ok(PathData::default()); } let subpaths: Vec = 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 = 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 = 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 { 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 = None; let mut start = kurbo::Point::ZERO; let edge = |a: kurbo::Point, b: kurbo::Point, xs: &mut Vec| { 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, } 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, /// 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) { 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::>()); } } #[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 { 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 = (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 = (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()); } }