mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 02:34:09 +00:00
Merge pull request #4345 from el-j/fix/offset-path-transform-ram-spike
fix(pathops): bound offset path complexity and handle arrangement failure safely (#4145)
This commit is contained in:
@@ -770,7 +770,14 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<PathData, PathOpsError>;
|
||||
pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData;
|
||||
```
|
||||
|
||||
|
||||
@@ -296,11 +296,15 @@ impl Tidy {
|
||||
}
|
||||
|
||||
impl Arrangement {
|
||||
pub(crate) fn new(mut paths: Vec<(BezPath, FillRule)>) -> Result<Self, PathOpsError> {
|
||||
pub(crate) fn new(paths: Vec<(BezPath, FillRule)>) -> Result<Self, PathOpsError> {
|
||||
Self::new_budgeted(paths, crate::budget::MAX_SEGMENT_PAIRS)
|
||||
}
|
||||
|
||||
pub(crate) fn new_budgeted(mut paths: Vec<(BezPath, FillRule)>, cap: usize) -> Result<Self, PathOpsError> {
|
||||
let eps = eps_for(paths.iter().map(|p| &p.0))?;
|
||||
snap_horizontals(&mut paths.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), 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)]
|
||||
|
||||
@@ -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<Item = &'a BezPath>, cap: usize) -> bool {
|
||||
let boxes: Vec<Rect> = 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<Item = &'a BezPath>) -> bool {
|
||||
let boxes: Vec<Rect> = 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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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());
|
||||
@@ -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<bool> =
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user