diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index e1fecc6da..37e2cd803 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -770,7 +770,14 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { let Some((path, rule)) = node_path(l) else { continue }; paths = true; let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path }; - let off = po::offset_path(&base, delta, join, miter); + let off = match po::try_offset_path(&base, delta, join, miter) { + Ok(p) => p, + Err(po::PathOpsError::NonFinite) => continue, + Err(po::PathOpsError::TooComplex) => { + return Err(EngineError::Other("Offset Path: the path is too complex to offset".into())); + } + Err(e) => return Err(EngineError::Other(format!("Offset Path: {e}"))), + }; if off.is_empty() { continue; } diff --git a/crates/pathops/README.md b/crates/pathops/README.md index 09e3f00c4..2a3d36c18 100644 --- a/crates/pathops/README.md +++ b/crates/pathops/README.md @@ -66,6 +66,7 @@ Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` t ```rust pub use kurbo::{Cap, Join}; pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData; // delta < 0 insets +pub fn try_offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> Result; pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData; ``` diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index f61ac5e48..163fe7e7c 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -296,11 +296,15 @@ impl Tidy { } impl Arrangement { - pub(crate) fn new(mut paths: Vec<(BezPath, FillRule)>) -> Result { + pub(crate) fn new(paths: Vec<(BezPath, FillRule)>) -> Result { + Self::new_budgeted(paths, crate::budget::MAX_SEGMENT_PAIRS) + } + + pub(crate) fn new_budgeted(mut paths: Vec<(BezPath, FillRule)>, cap: usize) -> Result { let eps = eps_for(paths.iter().map(|p| &p.0))?; snap_horizontals(&mut paths.iter_mut().map(|p| &mut p.0).collect::>(), eps); // Counted on the snapped geometry, which is what the sweep sees: snapping can bring boxes into contact. - if !crate::budget::sweep_fits(paths.iter().map(|p| &p.0)) { + if !crate::budget::sweep_fits_within(paths.iter().map(|p| &p.0), cap) { return Err(PathOpsError::TooComplex); } // The sweep can panic on rare near-degenerate input; that must never take the app down, @@ -835,6 +839,23 @@ mod tests { assert_eq!(tidy.repair(&lens), lens); } + #[test] + fn arrangement_new_budgeted_fails_on_complexity_and_open_paths() { + let mut bp = BezPath::new(); + for i in 0..100 { + let r = kurbo::Rect::new(0.0, 0.0, 10.0 + i as f64, 10.0 + i as f64); + bp.extend(r.to_path(0.1).iter()); + } + let too_complex = Arrangement::new_budgeted(vec![(bp, FillRule::NonZero)], 5); + assert_eq!(too_complex.err(), Some(PathOpsError::TooComplex)); + + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((10.0, 10.0)); + let failed_arr = Arrangement::new_budgeted(vec![(open, FillRule::NonZero)], 1000); + assert_eq!(failed_arr.err(), Some(PathOpsError::OpenPath)); + } + fn square(x: f64, y: f64, size: f64) -> PathData { PathData::single(vectorcraft_geom::SubPath::new( [(x, y), (x + size, y), (x + size, y + size), (x, y + size)] diff --git a/crates/pathops/src/budget.rs b/crates/pathops/src/budget.rs index 01559ce8a..824af9fc2 100644 --- a/crates/pathops/src/budget.rs +++ b/crates/pathops/src/budget.rs @@ -82,10 +82,15 @@ pub(crate) fn overlaps_within(boxes: &[Rect], cap: usize) -> bool { true } +/// Can a planar map be built of `paths` within `cap` segment pairs? +pub(crate) fn sweep_fits_within<'a>(paths: impl IntoIterator, cap: usize) -> bool { + let boxes: Vec = paths.into_iter().flat_map(|p| p.segments().map(|s| s.bounding_box())).collect(); + overlaps_within(&boxes, cap) +} + /// Can a planar map be built of `paths` within [`MAX_SEGMENT_PAIRS`]? pub(crate) fn sweep_fits<'a>(paths: impl IntoIterator) -> bool { - let boxes: Vec = paths.into_iter().flat_map(|p| p.segments().map(|s| s.bounding_box())).collect(); - overlaps_within(&boxes, MAX_SEGMENT_PAIRS) + sweep_fits_within(paths, MAX_SEGMENT_PAIRS) } #[cfg(test)] diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 980509a87..3d1b17fef 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -25,7 +25,7 @@ pub use edit::{ remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, smooth_options, split_into_grid, }; -pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; +pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions}; pub use planar::{ BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index ec6b517f7..f028c0704 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -7,12 +7,26 @@ 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 { @@ -92,13 +106,23 @@ pub fn stroke_region(outlines: &[BezPath]) -> PathData { normalize_bez(&all, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default() } -/// 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. -pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData { - if !delta.is_finite() || path.is_empty() { - return PathData::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 { + 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()); @@ -111,9 +135,10 @@ pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> } else { BezPath::new() }; - let Ok(arr) = Arrangement::new(vec![(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)]) else { - return PathData::default(); - }; + 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(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[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 @@ -124,10 +149,25 @@ pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> let spurious: Vec = c.contours().map(|k| k.outer == drop_outer && deep_point(&k.path).is_some_and(|p| any_within(&src, p, d * (1.0 - 1e-4) - 1e-6))).collect(); if !spurious.contains(&true) { - return all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION)); + 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); - contours_to_path(&c, keep, &Tidy::free(DEFAULT_PRECISION)) + 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 @@ -442,4 +482,45 @@ mod tests { 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()); + } }