diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 5ef7ba6d4d..5edf57a894 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -21,14 +21,35 @@ struct Puff { height: f64, } +/// The pieces each curve of `path` is sampled in for Inflate's solid ([`curve_pieces`]); an error +/// when its outline does not fit the solid's limits. +fn outline_pieces(path: &PathData, bounds: Rect, o: &Inflate) -> Result { + // The sides take one face for each edge of the outline in each slice; the grid's faces are + // counted as they are built. + curve_pieces(path, bounds.width().max(bounds.height()), if o.depth > 0.0 { slices(o) } else { 0 }, 0) +} + +/// Whether `path` has few enough anchors that [`curve_pieces`] might take it: fewer than +/// [`MAX_SOLID_POINTS`] contours, and at most three times as many anchors (a contour of one anchor +/// adds one, and every other takes at most two anchors for each segment it adds). Counts the +/// contours' anchors without reading them. +pub(super) fn might_fit(path: &PathData) -> bool { + let mut anchors = 0usize; + for (contours, sp) in path.subpaths.iter().enumerate() { + anchors = anchors.saturating_add(sp.anchors.len()); + if contours >= MAX_SOLID_POINTS || anchors > MAX_SOLID_POINTS.saturating_mul(3) { + return false; + } + } + true +} + /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { #[cfg(test)] PUFFED.with(|n| n.set(n.get() + 1)); - // The sides take one face for each edge of the outline in each slice; the grid's faces are - // counted as they are built. let size = bounds.width().max(bounds.height()); - let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let most = outline_pieces(path, bounds, o)?; let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); @@ -88,7 +109,18 @@ pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, opti #[cfg(test)] let later = later && !SOLVE_FIRST.with(std::cell::Cell::get); if later { - let shapes: Vec<(PathData, FillRule)> = shapes.iter().map(|(path, rule)| ((*path).clone(), *rule)).collect(); + // A shape is copied to be solved later only when its anchors are few enough for the + // solid's point limit; a larger one is refused now, with the error its puff gives. + let shapes: Vec> = shapes + .iter() + .map(|(path, rule)| { + check_bounds(bounds)?; + if !might_fit(path) { + outline_pieces(path, bounds, &o)?; + } + Ok(((*path).clone(), *rule)) + }) + .collect(); return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, keep }; } let puffs: Vec, String>> = @@ -98,10 +130,11 @@ pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, opti } /// The shapes' puffs still to take: every one solved when the shapes were laid out, or each -/// shape's path and fill rule, its puff solved when its solid is taken. +/// shape's path and fill rule (or the error its bounds or outline already gives), its puff solved +/// when its solid is taken. enum Pending { Solved(std::vec::IntoIter, String>>), - Later(std::vec::IntoIter<(PathData, FillRule)>), + Later(std::vec::IntoIter>), } /// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken. @@ -135,10 +168,7 @@ impl Iterator for Solids { fn next(&mut self) -> Option { let p = match &mut self.pending { Pending::Solved(puffs) => puffs.next()?, - Pending::Later(shapes) => { - let (path, rule) = shapes.next()?; - check_bounds(self.bounds).and_then(|()| puff(&path, rule, self.bounds, &self.o)) - } + Pending::Later(shapes) => shapes.next()?.and_then(|(path, rule)| puff(&path, rule, self.bounds, &self.o)), }; Some(match p { Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.keep), diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 3e516f7a57..a5984321ee 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1097,6 +1097,38 @@ fn a_flat_inflate_solved_as_taken_matches_one_solved_first() { } } +/// With no volume, a shape with too many anchors or contours for the solid is refused before it +/// is kept for later, with the error that solving every puff first gives it. +#[test] +fn a_flat_inflate_refuses_a_shape_past_the_point_limit_as_one_solved_first() { + use super::inflate::might_fit; + let many = MAX_SOLID_POINTS * 3 + 10; + let anchors: Vec = (0..many).map(|i| Point::new((i % 200) as f64, (i / 200) as f64 / 10.0)).collect(); + let long = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&anchors, true)]); + let mut nan = anchors.clone(); + if let Some(last) = nan.last_mut() { + *last = Point::new(f64::NAN, 0.0); + } + let invalid = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&nan, true)]); + let dots = PathData::new((0..=MAX_SOLID_POINTS).map(|i| vectorcraft_geom::SubPath::polyline(&[Point::new(i as f64, 0.0)], false)).collect()); + let square = shape(&square(0.0, 0.0, 100.0)).0; + assert!(might_fit(&square) && !might_fit(&long) && !might_fit(&invalid) && !might_fit(&dots)); + let paths = [square, long, invalid, dots]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let o = Inflate { volume: 0.0, ..Inflate::default() }; + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec> = inflate_faces(&shapes, b, o, front(), build::Keep::Facets).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert!(taken[0].as_ref().is_ok_and(|(f, ..)| !f.is_empty())); + assert!(taken[1..].iter().all(Result::is_err), "{:?}", taken[1..].iter().map(|r| r.as_ref().err()).collect::>()); + assert_eq!(format!("{taken:?}"), format!("{first:?}")); +} + /// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole /// (the middle is filled, as the art is drawn); one running the other way does, and so does /// either under the even-odd rule.