From d64ba43e50cf90e3ab0055b8e8325f85e87864a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:17:21 -0800 Subject: [PATCH 1/2] 3D: Inflate with no volume solves each puff only when its solid is taken At a Volume of 0 % no shape puffs, so the eye does not depend on the other shapes. Each shape's membrane is now solved as its solid is taken, and a caller that stops at the object's face limit solves none of the shapes after it. With any volume every puff is still solved first, since the highest one places the eye, and the solids are unchanged bit for bit. The shadow no longer builds the solid of a fully transparent fill or stroke: its puff still places the eye, and at 0 % volume it is not solved either. --- crates/three-d/src/shadow.rs | 18 +++--- crates/three-d/src/shadow/tests.rs | 32 ++++++++++ crates/three-d/src/solid.rs | 4 +- crates/three-d/src/solid/inflate.rs | 93 ++++++++++++++++++++++++----- crates/three-d/src/solid/tests.rs | 63 +++++++++++++++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 4 ++ 7 files changed, 191 insertions(+), 24 deletions(-) diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index d7cd01a869..9c2139bfc2 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -172,8 +172,8 @@ impl ShadowCaster { /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each /// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids - /// drawn, but the faces of the others are not kept (their range is empty). A shape that does - /// not inflate is left out, as its solid is; the result says which, in order. + /// drawn, but the solids of the others are not built (their range is empty). A casting shape + /// that does not inflate is left out, as its solid is; the result says which, in order. pub fn inflate( &mut self, shapes: &[(&PathData, FillRule)], @@ -182,11 +182,15 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, true) - .enumerate() - .map(|(i, r)| { - let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + let mut solids = inflate_faces(shapes, bounds, options, view, true); + (0..shapes.len()) + .map(|i| { + if !casts.get(i).copied().unwrap_or(true) { + solids.pass(); + return Ok(self.faces.len()..self.faces.len()); + } + let (_, faces, projection) = solids.next().unwrap_or_else(|| Ok((vec![], vec![], None)))?; + self.add(faces, projection) }) .collect() } diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs index 3fb3767177..00aedc3a53 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -185,3 +185,35 @@ fn every_effect_casts_a_shadow() { // The surface turns about the square's left edge, reaching 100 to either side of it. assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}"); } + +/// An Inflate shape that casts nothing still places the eye with its puff, but its solid is not +/// built (nor, with no volume, its puff solved); the shapes that cast keep the very faces and +/// projection they have when every shape casts. +#[test] +fn an_inflate_shape_that_casts_nothing_builds_no_solid() { + let (a, b) = (square(100.0, 100.0, 100.0), square(150.0, 150.0, 30.0)); + let shapes = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)]; + let bounds = Rect::new(100.0, 100.0, 200.0, 200.0); + let view = View { perspective: 60.0, rotation_y: 30.0, ..front() }; + let built = || crate::solid::INFLATED.with(std::cell::Cell::get); + let puffed = || crate::solid::PUFFED.with(std::cell::Cell::get); + for o in [Inflate::default(), Inflate { volume: 0.0, depth: 20.0, ..Inflate::default() }] { + let mut all = ShadowCaster::new(); + let every = all.inflate(&shapes, &[true, true], bounds, o, view); + let (start, solved) = (built(), puffed()); + let mut some = ShadowCaster::new(); + let kept = some.inflate(&shapes, &[false, true], bounds, o, view); + assert_eq!(built(), start + 1, "{o:?}: only the casting shape's solid is built"); + if o.volume == 0.0 { + assert_eq!(puffed(), solved + 1, "{o:?}: only the casting shape's puff is solved"); + } + let (Ok(first), Ok(second)) = (&every[0], &every[1]) else { panic!("{every:?}") }; + assert!(!first.is_empty() && !second.is_empty()); + assert_eq!(kept, vec![Ok(0..0), Ok(0..second.len())]); + assert_eq!(format!("{:?}", some.faces), format!("{:?}", &all.faces[second.clone()])); + assert_eq!(some.projection, all.projection); + let mut none = ShadowCaster::new(); + assert_eq!(none.inflate(&shapes, &[false, false], bounds, o, view), vec![Ok(0..0), Ok(0..0)]); + assert!(none.is_empty() && none.projection.is_none()); + } +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..652f46a3f1 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -27,9 +27,9 @@ pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] use inflate::GRID_WORK; -#[cfg(test)] -pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; +#[cfg(test)] +pub(crate) use inflate::{INFLATED, PUFFED, SOLVE_FIRST}; pub use inflate::{inflate, inflate_each, inflate_together}; #[cfg(test)] use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d3773a92f9..2500af48c3 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -23,6 +23,8 @@ struct Puff { /// 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()); @@ -66,7 +68,7 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: } /// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early -/// builds none of the solids after. +/// builds none of the solids after (and with no volume, solves none of their puffs). pub fn inflate_each( shapes: &[(&PathData, FillRule)], bounds: Rect, @@ -77,28 +79,89 @@ pub fn inflate_each( } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is -/// built as the iterator reaches it. -pub(crate) fn inflate_faces( - shapes: &[(&PathData, FillRule)], - bounds: Rect, - options: Inflate, - view: View, - mesh: bool, -) -> impl Iterator> + use<> { +/// Every shape's membrane is solved at once (the eye depends on the highest puff), except with no +/// volume: nothing puffs then, and each shape's membrane is solved when its solid is taken. Each +/// solid is built as the iterator reaches it. +pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, mesh: bool) -> Solids { let o = options.sanitized(); + let later = o.volume == 0.0; + #[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(); + return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, mesh }; + } let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![], None)), - Err(e) => Err(e), - }) + Solids { pending: Pending::Solved(puffs.into_iter()), bounds, o, view, reach, mesh } +} + +/// 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. +enum Pending { + Solved(std::vec::IntoIter, String>>), + Later(std::vec::IntoIter<(PathData, FillRule)>), +} + +/// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken. +pub(crate) struct Solids { + pending: Pending, + bounds: Rect, + o: Inflate, + view: View, + /// The highest puff of the shapes drawn together. + reach: f64, + mesh: bool, +} + +impl Solids { + /// Pass over the next shape without building its solid, or solving its puff if that waits. + pub(crate) fn pass(&mut self) { + match &mut self.pending { + Pending::Solved(puffs) => { + puffs.next(); + } + Pending::Later(shapes) => { + shapes.next(); + } + } + } +} + +impl Iterator for Solids { + type Item = Result; + + 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)) + } + }; + Some(match p { + Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.mesh), + Ok(None) => Ok((vec![], vec![], None)), + Err(e) => Err(e), + }) + } + + fn size_hint(&self) -> (usize, Option) { + match &self.pending { + Pending::Solved(puffs) => puffs.size_hint(), + Pending::Later(shapes) => shapes.size_hint(), + } + } } #[cfg(test)] thread_local! { + /// How many puffs this thread has solved, for the tests. + pub(crate) static PUFFED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Solve every shape's puff before the first solid is taken even with no volume, as with + /// volume, for the tests that compare the two. + pub(crate) static SOLVE_FIRST: std::cell::Cell = const { std::cell::Cell::new(false) }; /// How many inflated solids this thread has built, for the tests. pub(crate) static INFLATED: std::cell::Cell = const { std::cell::Cell::new(0) }; /// The grid work of the last solid this thread inflated in full, for the tests. diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index e89a29afea..2a6d4786e9 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1030,6 +1030,69 @@ fn a_flat_twisted_inflate_matches_the_extrusion() { } } +/// With no volume nothing puffs, so each shape's membrane is solved only when its solid is taken: +/// a caller that stops early (past the object's face limit) solves none after. With volume, the +/// eye waits on the highest puff, so every membrane is solved first. +#[test] +fn a_flat_inflate_solves_each_puff_as_its_solid_is_taken() { + let shapes: Vec = (0..3).map(|i| shape(&square(120.0 * i as f64, 0.0, 100.0)).0).collect(); + let shapes: Vec<(&PathData, FillRule)> = shapes.iter().map(|p| (p, FillRule::NonZero)).collect(); + let b = Rect::new(0.0, 0.0, 340.0, 100.0); + let puffed = || PUFFED.with(std::cell::Cell::get); + let start = puffed(); + let mut each = inflate_each(&shapes, b, Inflate { depth: 20.0, volume: 0.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start, "no puff is solved before the first solid is taken"); + assert!(each.next().is_some_and(|f| f.is_ok_and(|f| !f.is_empty()))); + assert_eq!(puffed(), start + 1, "the later puffs wait until their solids are taken"); + drop(each); + assert_eq!(puffed(), start + 1); + let start = puffed(); + let each = inflate_each(&shapes, b, Inflate { depth: 20.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start + 3, "with volume, every puff places the eye"); + assert_eq!(each.count(), 3); +} + +/// With no volume, solving each puff as its solid is taken gives the very solids that solving +/// every puff first gives: their facets, their faces before projection and their projection, bit +/// for bit, with an empty shape and one that cannot be inflated among them. +#[test] +fn a_flat_inflate_solved_as_taken_matches_one_solved_first() { + let mut frame = square(-10.0, -10.0, 260.0); + frame.extend(square(-8.0, -8.0, 256.0)); + let mut circle = BezPath::new(); + circle.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((180.0, 60.0), 50.0), 0.01)); + let mut bad = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true); + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let paths = [shape(&square(0.0, 0.0, 100.0)).0, shape(&frame).0, PathData::new(vec![]), PathData::new(vec![bad]), shape(&circle).0]; + 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 turned = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; + let options = [ + Inflate { volume: 0.0, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 40.0, twist: 90.0, taper: 0.5, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 10.0, both_sides: true, cap: false, ..Inflate::default() }, + ]; + for view in [front(), turned, View { perspective: 60.0, ..turned }, View { perspective: 100.0, rotation_y: 150.0, ..turned }] { + for o in options { + for mesh in [false, true] { + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec> = inflate_faces(&shapes, b, o, view, mesh).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert_eq!(taken.len(), 5); + // Without a mesh the solid keeps its facets, with one its faces before projection. + let built = |r: &Result| r.as_ref().is_ok_and(|(f, m, _)| if mesh { !m.is_empty() } else { !f.is_empty() }); + assert!(built(&taken[0]) && built(&taken[1]) && built(&taken[4]), "{o:?} {view:?} mesh {mesh}"); + assert!(taken[2].as_ref().is_ok_and(|(f, m, p)| f.is_empty() && m.is_empty() && p.is_none()) && taken[3].is_err()); + assert_eq!(format!("{taken:?}"), format!("{first:?}"), "{o:?} {view:?} mesh {mesh}"); + } + } + } +} + /// #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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..c57e4f3cce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1250,6 +1250,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - The solids of one Extrude & Bevel (both kinds) or Inflate effect on one object, its fill's, its strokes' and a group's members' together, keep up to 163,840 faces (VectorCraft's own limit). Each solid is built in turn; once their faces pass the limit, no later solid is built, the object draws its source art, and applying or editing the effect, Export 3D Object and Expand as Wireframes report that the 3D surface is too complex. Tests `solids::tests::an_objects_solids_count_their_faces_together`, `a_groups_solids_together_past_the_limit_draw_the_source`, `a_fill_and_strokes_together_past_the_limit_draw_the_source`. + - Inflate at a Volume of 0 % works out each shape's puff only when its solid is built, so no puff is worked out for the shapes after the face limit stops, and the solids are the same bit for bit; with any volume every puff is worked out first, as the highest one places the eye. The shadow builds no solid for a fully transparent fill or stroke. Tests `solid::tests::a_flat_inflate_solves_each_puff_as_its_solid_is_taken`, `a_flat_inflate_solved_as_taken_matches_one_solved_first`, `shadow::tests::an_inflate_shape_that_casts_nothing_builds_no_solid`. - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). diff --git a/docs/three-d.md b/docs/three-d.md index 6de07fc2fe..6375fe9ec7 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -169,6 +169,10 @@ The rest is our reading: once their faces pass it no later solid is built: the object draws its source art, and applying or editing the effect, Export 3D Object and **Expand as Wireframes** report that the 3D surface is too complex. A stroke left out for its own limits does not count. +- Inflate works out how high every shape puffs before it builds the first solid, because the highest puff sets + how far away the eye is. At a Volume of 0 % no shape puffs, so each shape's puff is worked out only when its + solid is built, and none is worked out for the solids after the limit stops. The shadow builds no solid for a + fill or stroke that is fully transparent. ## Groups From 36bccf0f34a4d0d8e5f4c6c0502bca4f6d253607 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:24:40 -0800 Subject: [PATCH 2/2] 3D: Inflate with no volume copies only shapes that might fit its limits With no volume, each shape waits to be solved until its solid is taken, so its path is copied first. A shape with more contours or anchors than the solid's point limit allows is now refused before it is copied, with the error solving it would give, instead of being copied in full. The check counts each contour's anchors without reading them. --- crates/three-d/src/solid/inflate.rs | 50 +++++++++++++++++++++++------ crates/three-d/src/solid/tests.rs | 32 ++++++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) 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.