diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 54bc0c5a6..88843cb4b 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -508,14 +508,23 @@ pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } } -/// Most path elements the search for holes tests a point against, each winding test counting its -/// contour's elements. +/// Most work the search for holes does: each winding test counts its contour's elements, and each +/// contour placed in a cell of the grid or looked at as a candidate for a point counts one. const MAX_HOLE_WORK: usize = 16_000_000; +/// Most cells along each side of the grid the search for holes sorts contours into. +const MAX_HOLE_GRID: usize = 256; + /// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is /// outside its closed contour at the segment's middle and not inside. An error past /// [`MAX_HOLE_WORK`]. fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + hole_segments_counted(path, rule).map(|(holes, _)| holes) +} + +/// [`hole_segments`] and the work it did. A point is tested only against the closed contours whose +/// bounds reach into its cell of a grid over the bounds of them all. +fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { let contours: Vec<(BezPath, Rect)> = path .subpaths .iter() @@ -527,6 +536,33 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri .collect(); let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); let mut work = 0usize; + let spend = |n: usize, work: &mut usize| { + *work = work.saturating_add(n); + if *work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + }; + let area = contours.iter().enumerate().filter(|(j, _)| closed(*j)).map(|(_, (_, b))| *b).reduce(|a, b| a.union(b)); + let count = (0..contours.len()).filter(|j| closed(*j)).count(); + let side = ((count as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let cell = |p: Point| area.map(|a| at(p.y, a.y0, a.y1) * side + at(p.x, a.x0, a.x1)); + let mut grid: Vec> = vec![vec![]; side * side]; + if let Some(a) = area { + for (j, (_, b)) in contours.iter().enumerate().filter(|(j, _)| closed(*j)) { + let (x0, x1) = (at(b.x0, a.x0, a.x1), at(b.x1, a.x0, a.x1)); + let (y0, y1) = (at(b.y0, a.y0, a.y1), at(b.y1, a.y0, a.y1)); + spend((x1 - x0 + 1).saturating_mul(y1 - y0 + 1), &mut work)?; + for y in y0..=y1 { + for x in x0..=x1 { + if let Some(c) = grid.get_mut(y * side + x) { + c.push(j); + } + } + } + } + } let mut holes = Vec::with_capacity(contours.len()); for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { if !sp.closed { @@ -538,11 +574,11 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri for segment in 0..sp.anchors.len() { let p = sp.segment(segment).eval(0.5); let mut others = 0i32; - for (_, (contour, _)) in contours.iter().enumerate().filter(|(j, (_, bounds))| *j != i && closed(*j) && bounds.contains(p)) { - work = work.saturating_add(contour.elements().len()); - if work > MAX_HOLE_WORK { - return Err(TOO_COMPLEX_PROFILE.into()); - } + let candidates = cell(p).and_then(|c| grid.get(c)).map_or(&[][..], Vec::as_slice); + spend(candidates.len(), &mut work)?; + for &j in candidates { + let Some((contour, _)) = contours.get(j).filter(|(_, bounds)| j != i && bounds.contains(p)) else { continue }; + spend(contour.elements().len(), &mut work)?; others += contour.winding(p); } segments.push(match rule { @@ -552,7 +588,7 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri } holes.push(segments); } - Ok(holes) + Ok((holes, work)) } /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 025f2d9df..5eeb10f2a 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -105,6 +105,21 @@ fn deeply_nested_contours_stop_the_search_for_holes() { assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); } +#[test] +fn a_point_is_tested_only_against_the_contours_near_it() { + // 3,000 separate squares: each segment's middle lies in the bounds of its own square only. + let squares = (0..3000) + .map(|i| { + let (x, y) = ((i % 60) as f64 * 20.0, (i / 60) as f64 * 20.0); + let corners = [(x, y), (x + 10.0, y), (x + 10.0, y + 10.0), (x, y + 10.0)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }) + .collect(); + let (holes, work) = hole_segments_counted(&PathData::new(squares), FillRule::NonZero).unwrap(); + assert!(holes.iter().flatten().all(|hole| !hole)); + assert!(work < 200_000, "{work}"); +} + #[test] fn overlapping_contours_are_holes_only_where_they_overlap() { let square = |x: f64, y: f64, side: f64| { diff --git a/docs/mcp.md b/docs/mcp.md index 499d02bd4..0e73231dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2388,7 +2388,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export tests each segment's middle against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle`