diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index ca5ef0909..5016e7405 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1515,7 +1515,7 @@ fn blob(s: &mut Session, p: &Value) -> Result { } let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); - let merged = po::unite_all(&inputs); + let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?; let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); }; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index f898ff40e..dbe1c868e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -452,7 +452,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect(); let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone())); - push(po::unite_all(&refs), FillRule::NonZero, style_of(top), name); + push(po::try_unite_all(&refs).map_err(|e| EngineError::Other(e.to_string()))?, FillRule::NonZero, style_of(top), name); } else { for l in &lv { if let Some((p, r)) = node_path(l) { diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 79f2e5329..654368177 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -282,6 +282,10 @@ impl Arrangement { pub(crate) fn new(mut paths: Vec<(BezPath, FillRule)>) -> 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)) { + return Err(PathOpsError::TooComplex); + } // The sweep can panic on rare near-degenerate input; that must never take the app down, // so it becomes an error (callers fall back to leaving the art unchanged). let sweep = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -640,6 +644,10 @@ pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result::from_path(&bp, eps).map_err(|_| PathOpsError::OpenPath)?; Ok(top.contours(|w| inside(rule, *w))) } @@ -657,8 +665,17 @@ pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRul Ok(all_contours_to_path(&c, &tidy)) } +/// [`boolean_n`] that reports a refused input ([`PathOpsError::TooComplex`], non-finite +/// coordinates) instead of returning an empty path. +pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> Result { + let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); + let arr = Arrangement::new(bps)?; + Ok(all_contours_to_path(&arr.contours(pred), &arr.tidy)) +} + /// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a -/// point is inside `paths[i]` under its fill rule. +/// point is inside `paths[i]` under its fill rule. A refused input gives an empty path; callers that +/// can report an error use [`try_boolean_n`]. pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData { let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); match Arrangement::new(bps) { @@ -667,6 +684,26 @@ pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool } } +/// [`unite_all`] that reports a refused input instead of leaving it out of the union or returning +/// an empty path. +pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result { + let mut all = BezPath::new(); + for (p, r) in paths { + let bp = fill_bezpath(p); + if bp.elements().is_empty() { + continue; + } + for ct in normalize_bez(&bp, *r)?.contours() { + all.extend(ct.path.iter()); + } + } + if all.elements().is_empty() { + return Ok(PathData::default()); + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::>().iter()); + Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy)) +} + /// Union of many filled paths. Each input is first normalised on its own, then all are swept /// together with a single integer winding number, which keeps this fast for thousands of shapes. pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { @@ -691,6 +728,12 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { } } +/// [`area`] that reports a refused input instead of an area of zero. +pub fn try_area(path: &PathData, rule: FillRule) -> Result { + let c = normalize_bez(&fill_bezpath(path), rule)?; + Ok(c.contours().map(|ct| ct.path.area()).sum::().abs()) +} + /// Unsigned filled area of a path under a fill rule (normalises first, so it is exact for /// self-overlapping input). pub fn area(path: &PathData, rule: FillRule) -> f64 { diff --git a/crates/pathops/src/budget.rs b/crates/pathops/src/budget.rs new file mode 100644 index 000000000..ab7ead8a6 --- /dev/null +++ b/crates/pathops/src/budget.rs @@ -0,0 +1,114 @@ +//! What the sweep that builds a planar map may be given. The topology it builds holds every +//! crossing of the input's segments, and a crossing can only be where two segments' bounds +//! overlap (two cubics cross at most 9 times). The pairs of overlapping bounds are counted before +//! the sweep, exactly and in O(n log n), and past [`MAX_SEGMENT_PAIRS`] the operation fails as +//! [`crate::PathOpsError::TooComplex`]: one compact self-crossing path could otherwise make +//! millions of crossings, and a live effect rebuilt them on every draw. + +use kurbo::{BezPath, ParamCurveExtrema, Rect}; + +/// Most pairs of segments whose bounds overlap that a planar map is built from. Neighbouring +/// segments of a path always overlap at their shared end; real art of 65,536 segments (the live +/// Pathfinder's cap) overlaps a few times per segment, far below this. +pub const MAX_SEGMENT_PAIRS: usize = 1 << 22; + +/// Fenwick tree of counts over its slots. +struct Counts(Vec); + +// `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version). +#[allow(clippy::manual_isolate_lowest_one)] +impl Counts { + fn add(&mut self, slot: usize, d: isize) { + let mut i = slot + 1; + while let Some(c) = self.0.get_mut(i) { + *c += d; + i += i & i.wrapping_neg(); + } + } + + /// Sum of the slots before `end`. + fn before(&self, end: usize) -> isize { + let (mut i, mut s) = (end.min(self.0.len().saturating_sub(1)), 0); + while i > 0 { + s += self.0.get(i).copied().unwrap_or(0); + i -= i & i.wrapping_neg(); + } + s + } +} + +/// Do at most `cap` pairs of `boxes` overlap (touching counts)? A sweep in x keeps the open boxes +/// in two Fenwick trees over their y extents, so this costs O(n log n) however they lie, and stops +/// as soon as the count passes `cap`. +pub(crate) fn overlaps_within(boxes: &[Rect], cap: usize) -> bool { + // `f64::total_cmp` puts -0.0 before +0.0 although they are equal, which would hide an overlap + // between a box ending at -0.0 and one starting at +0.0; adding 0.0 maps -0.0 to +0.0. + let boxes: Vec = boxes.iter().map(|b| Rect::new(b.x0 + 0.0, b.y0 + 0.0, b.x1 + 0.0, b.y1 + 0.0)).collect(); + let boxes = boxes.as_slice(); + let mut ys: Vec = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| a.total_cmp(b).is_eq()); + let below = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_lt()); + let upto = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_le()); + // (x, is end, box); starts sort before ends at the same x since touching boxes overlap. + let mut events: Vec<(f64, bool, usize)> = boxes.iter().enumerate().flat_map(|(i, b)| [(b.x0, false, i), (b.x1, true, i)]).collect(); + events.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + let mut by_y0 = Counts(vec![0; ys.len() + 1]); + let mut by_y1 = Counts(vec![0; ys.len() + 1]); + let (mut active, mut overlaps) = (0isize, 0usize); + for (_, end, i) in events { + let Some(b) = boxes.get(i) else { continue }; + if end { + by_y0.add(below(b.y0), -1); + by_y1.add(below(b.y1), -1); + active -= 1; + continue; + } + // Open boxes neither entirely above b nor entirely below it. + let above = active - by_y0.before(upto(b.y1)); + let under = by_y1.before(below(b.y0)); + overlaps = overlaps.saturating_add(usize::try_from(active - above - under).unwrap_or(0)); + if overlaps > cap { + return false; + } + by_y0.add(below(b.y0), 1); + by_y1.add(below(b.y1), 1); + active += 1; + } + true +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pairs of overlapping boxes are counted exactly: a grid of n crossing bars makes n² pairs. + #[test] + fn crossing_bars_count_as_their_pairs() { + let bars = |n: usize| -> Vec { + let across = (0..n).map(|i| Rect::new(0.0, i as f64 * 2.0, 100.0, i as f64 * 2.0 + 1.0)); + let down = (0..n).map(|i| Rect::new(i as f64 * 2.0, 0.0, i as f64 * 2.0 + 1.0, 100.0)); + across.chain(down).collect() + }; + assert!(overlaps_within(&bars(10), 100)); + assert!(!overlaps_within(&bars(10), 99)); + // Disjoint boxes, many of them: none overlap. + let apart: Vec = (0..10_000).map(|i| Rect::new(i as f64 * 3.0, 0.0, i as f64 * 3.0 + 1.0, 1.0)).collect(); + assert!(overlaps_within(&apart, 0)); + } + + /// Boxes that touch at a signed zero still overlap: -0.0 and +0.0 are the same position. + #[test] + fn touching_at_a_signed_zero_counts_as_an_overlap() { + let below = Rect::new(0.0, -1.0, 1.0, -0.0); + let above = Rect::new(0.0, 0.0, 1.0, 1.0); + assert!(!overlaps_within(&[below, above], 0)); + assert!(overlaps_within(&[below, above], 1)); + } +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 12fafac2b..74f5ac02d 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod boolean; +mod budget; mod edit; mod fit; mod offset; @@ -14,7 +15,9 @@ mod pathfinder; mod planar; mod trap; -pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; +pub use boolean::{ + BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_area, try_boolean, try_boolean_n, try_normalize, try_unite_all, unite_all, +}; pub use edit::{ AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid, @@ -36,4 +39,6 @@ pub enum PathOpsError { OpenPath, #[error("the shapes are too degenerate to combine")] Degenerate, + #[error("the shapes cross themselves and each other too often to combine")] + TooComplex, } diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index 70517a411..799ef421e 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -32,23 +32,18 @@ const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); /// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s /// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one -/// to each, so its edges never cancel out of the map. -#[derive(Clone, Debug, Default)] -struct Crossings(Vec); +/// to each, so its edges never cancel out of the map. Only the slots that aren't 0 are kept, in +/// order: the sweep keeps one of these for every edge, and a vector as long as the input's number +/// (as this was) made disjoint shapes cost the square of their count in memory. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Crossings(Vec<(usize, u32)>); impl Crossings { fn get(&self, slot: usize) -> u32 { - self.0.get(slot).copied().unwrap_or(0) + self.0.binary_search_by_key(&slot, |(s, _)| *s).map_or(0, |i| self.0.get(i).map_or(0, |(_, c)| *c)) } } -impl PartialEq for Crossings { - fn eq(&self, other: &Self) -> bool { - (0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i)) - } -} -impl Eq for Crossings {} - impl std::ops::Add for Crossings { type Output = Crossings; fn add(mut self, rhs: Self) -> Crossings { @@ -59,33 +54,35 @@ impl std::ops::Add for Crossings { impl std::ops::AddAssign for Crossings { fn add_assign(&mut self, rhs: Self) { - if self.0.len() < rhs.0.len() { - self.0.resize(rhs.0.len(), 0); + if rhs.0.is_empty() { + return; } - for (a, b) in self.0.iter_mut().zip(rhs.0) { - *a = a.saturating_add(b); + let mut out = Vec::with_capacity(self.0.len() + rhs.0.len()); + let (mut a, mut b) = (std::mem::take(&mut self.0).into_iter().peekable(), rhs.0.into_iter().peekable()); + loop { + let next = match (a.peek(), b.peek()) { + (Some(x), Some(y)) if x.0 == y.0 => { + let (x, y) = (a.next(), b.next()); + x.zip(y).map(|(x, y)| (x.0, x.1.saturating_add(y.1))) + } + (Some(x), Some(y)) if x.0 < y.0 => a.next(), + (Some(_), Some(_)) | (None, Some(_)) => b.next(), + (Some(_), None) => a.next(), + (None, None) => break, + }; + out.extend(next.filter(|(_, c)| *c != 0)); } + self.0 = out; } } impl WindingNumber for Crossings { type Tag = usize; fn single(tag: usize, positive: bool) -> Self { - let slot = 2 * tag + usize::from(!positive); - let mut v = vec![0; slot + 1]; - if let Some(c) = v.get_mut(slot) { - *c = 1; - } - Crossings(v) + Crossings(vec![(2 * tag + usize::from(!positive), 1)]) } fn of_tag(&self, tag: usize) -> Self { - let mut v = vec![0; 2 * tag + 2]; - for slot in [2 * tag, 2 * tag + 1] { - if let Some(c) = v.get_mut(slot) { - *c = self.get(slot); - } - } - Crossings(v) + Crossings([2 * tag, 2 * tag + 1].into_iter().map(|slot| (slot, self.get(slot))).filter(|(_, c)| *c != 0).collect()) } } @@ -629,9 +626,10 @@ fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, m let o = tracks.get(own)?; let reach = GAP_DETOUR * max_gap; let mut best: Option<(f64, Point)> = None; - // `around` holds the segments a way of up to `reach` from `e` can cross, and `crossed` the - // crossings with each track. - let mut around: Option> = None; + // `around` holds the segments a way of up to `reach` from `e` can cross, by track (grouped + // once: filtering the whole list again for each track near `e` made clusters of parallel + // paths cubic), and `crossed` the crossings with each track. + let mut around: Option>> = None; let mut crossed: HashMap> = HashMap::new(); for (j, k) in grid.near(e, max_gap) { let Some(q) = tracks.get(j) else { continue }; @@ -650,8 +648,14 @@ fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, m o.along(from, at) } else { let xs = crossed.entry(j).or_insert_with(|| { - let segs = around.get_or_insert_with(|| grid.near(e, reach + max_gap)); - crossings(o, from, reach, q, segs.iter().filter(|c| c.0 == j).map(|c| c.1)) + let by_track = around.get_or_insert_with(|| { + let mut by_track: HashMap> = HashMap::new(); + for (t, k) in grid.near(e, reach + max_gap) { + by_track.entry(t).or_default().push(k); + } + by_track + }); + crossings(o, from, reach, q, by_track.get(&j).into_iter().flatten().copied()) }); xs.iter().map(|&(a, b)| o.along(from, a) + q.along(b, at)).fold(f64::INFINITY, f64::min) }; @@ -795,7 +799,7 @@ pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> Builde }) .filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty()); let Some((faces, pieces)) = planar else { - return BuilderArrangement { regions: crate::regions(shapes), ..Default::default() }; + return BuilderArrangement { regions: filled_regions(shapes), ..Default::default() }; }; let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed)); let (lines, outline): (Vec, Vec) = pieces.into_iter().partition(|e| is_line(e.key)); @@ -810,6 +814,25 @@ pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> Builde BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } } } +/// The regions of the shapes' filled areas. A shape that fills nothing (open paths that enclose no +/// area, such as cutting lines) is left out, so a flood of them cannot push the arrangement past its +/// size budget and take the filled shapes down with it; the regions keep the original indices. +fn filled_regions(shapes: &[Shape]) -> Vec { + let fills = |s: &Shape| s.path.subpaths.iter().any(|sp| sp.closed || encloses_area(sp)); + let keep: Vec = (0..shapes.len()).filter(|&i| shapes.get(i).is_some_and(fills)).collect(); + if keep.len() == shapes.len() { + return crate::regions(shapes); + } + let kept: Vec = keep.iter().filter_map(|&i| shapes.get(i).cloned()).collect(); + crate::regions(&kept) + .into_iter() + .map(|mut r| { + r.sources = r.sources.iter().filter_map(|&s| keep.get(s).copied()).collect(); + r + }) + .collect() +} + /// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the /// open subpaths of filled paths that do. pub fn encloses_area(sp: &SubPath) -> bool { @@ -1041,6 +1064,10 @@ pub(crate) fn map(shapes: &[Shape]) -> Option { } let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?; snap_horizontals(&mut inputs.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(inputs.iter().map(|p| &p.0)) { + return None; + } // The sweep can panic on rare near-degenerate input; that must never take the app down. let sweep = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); @@ -1471,6 +1498,19 @@ mod tests { use proptest::prelude::*; use vectorcraft_geom::{Rect, shapes}; + /// Sparse crossings add, compare and read like the dense counts they stand for. + #[test] + fn crossings_are_sparse_counts() { + let mut c = Crossings::single(7, true); + c += Crossings::single(2, false); + c += Crossings::single(7, true); + assert_eq!((c.get(14), c.get(5), c.get(15), c.get(0)), (2, 1, 0, 0)); + assert_eq!(c.0.len(), 2, "only the slots that aren't 0"); + assert_eq!(c.of_tag(7), Crossings(vec![(14, 2)])); + assert_eq!(Crossings::single(2, false) + Crossings::single(7, true) + Crossings::single(7, true), c); + assert_eq!(Crossings::default() + Crossings::default(), Crossings::default()); + } + fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape { Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key) } @@ -1887,9 +1927,9 @@ mod tests { fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() { let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() }); - // A broken line can't be swept: what the filled areas give (nothing here, without a panic). + // A broken line can't be swept and fills nothing: the filled areas still give their regions. let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)]; - assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() }); + assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s[..1]), ..Default::default() }); // Zero-length and doubled-back lines don't break it either. let s = [ rect(0.0, 0.0, 100.0, 100.0, 0), diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 216513f2e..140f2a1ef 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -349,27 +349,45 @@ fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result], bounds: &[Rect], i: usize| -> Result, String> { + let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); + let mut held_by = vec![]; + for (j, (o, b)) in out.iter().zip(bounds).enumerate() { + if j == i || !(b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1) { + continue; + } + work = work.saturating_add(o.len()); + if work > MAX_CONTAINMENT_EDGES { + return Err(too_complex()); + } + if contains(o, p) { + held_by.push(j); + } + } + Ok(held_by) + }; + let bounds_of = |out: &[Vec]| -> Vec { + out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() + }; if rule == FillRule::NonZero { let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; + let bounds = bounds_of(&out); let mut edge = Vec::with_capacity(out.len()); for (i, r) in out.iter().enumerate() { - let p = r.first().copied().unwrap_or_default(); // The winding number just outside the ring, and just inside it. - let outside: i32 = out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).map(|(_, o)| turn(o)).sum(); + let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o)).sum(); edge.push((outside != 0) != (outside + turn(r) != 0)); } let mut edge = edge.into_iter(); out.retain(|_| edge.next().unwrap_or(true)); } // A ring inside an odd number of others is a hole. - let holes: Vec = out - .iter() - .enumerate() - .map(|(i, r)| { - let p = r.first().copied().unwrap_or_default(); - out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).count() % 2 == 1 - }) - .collect(); + let bounds = bounds_of(&out); + let holes: Vec = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::>()?; for (r, hole) in out.iter_mut().zip(holes) { if (signed_area(r) > 0.0) == hole { r.reverse(); @@ -378,6 +396,10 @@ fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result f64 { r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 } @@ -1262,6 +1284,9 @@ fn cell_triangles(pieces: &[Vec]) -> Option> { Some(triangles) } +/// Most outline segments the booleans of one inflated object's mixed cells take together. +const MAX_MATERIAL_SEGMENTS: usize = 4_000_000; + /// The part of `outline` (its rings, each with its bounds) inside `cell`, as rings (holes running /// the other way). Only the rings whose bounds meet the cell's go into the boolean. fn material(outline: &[(Rect, vectorcraft_geom::SubPath)], cell: Rect) -> Option>> { @@ -1390,6 +1415,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result let unit = cell / FINE as f64; let at = |qx: usize, qy: usize| Point::new(bounds.x0 + qx as f64 * unit, bounds.y0 + qy as f64 * unit); let base = |i: usize, j: usize| Rect::from_points(at(i * FINE, j * FINE), at((i + 1) * FINE, (j + 1) * FINE)); + // Segments the booleans of mixed cells may take in all ([`MAX_MATERIAL_SEGMENTS`]): each one + // builds the topology of every outline ring near its cell, so a self-crossing outline cost its + // crossings again in every cell it spans. Past it, cells take their clipped pieces. + let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); // The shape's part of a rectangle, as rings. let cut = |r: Rect| -> Vec> { // A cell no edge crosses is wholly inside the shape or wholly outside it. @@ -1404,7 +1433,18 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result // Where a hole's piece cancels part of an outline's, that only holds flat: lifted onto // the surface the two no longer meet. Such a cell takes the shape's own part of it. let mixed = pieces.iter().any(|p| signed_area(p) < 0.0) && pieces.iter().any(|p| signed_area(p) > 0.0); - if mixed { material(&outline, r).unwrap_or(pieces) } else { pieces } + if !mixed { + return pieces; + } + let near: usize = + outline.iter().filter(|(b, _)| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1).map(|(_, sp)| sp.anchors.len()).sum(); + match material_left.get().checked_sub(near) { + Some(left) => { + material_left.set(left); + material(&outline, r).unwrap_or(pieces) + } + None => pieces, + } }; // How many times each cell is halved each way (0, 1 or 2), from how steeply it rises. let levels: Vec = (0..nx * ny)