Merge pull request #7467 from dotCooCoo/fix/inflate-lazy-puffs

3D: Inflate with no volume solves each puff only when its solid is taken
This commit is contained in:
BFlat
2026-11-15 00:39:20 +07:00
committed by GitHub
7 changed files with 260 additions and 27 deletions
+11 -7
View File
@@ -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<Result<Range<usize>, String>> {
inflate_faces(shapes, bounds, options, view, Keep::Faces)
.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, Keep::Faces);
(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![], Ok(vec![]), None)))?;
self.add(faces?, projection)
})
.collect()
}
+32
View File
@@ -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());
}
}
+2 -2
View File
@@ -29,9 +29,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};
+111 -18
View File
@@ -21,12 +21,35 @@ struct Puff {
height: f64,
}
/// 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<Option<Puff>, String> {
/// 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<u32, String> {
// 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<Option<Puff>, String> {
#[cfg(test)]
PUFFED.with(|n| n.set(n.get() + 1));
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);
@@ -66,7 +89,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 +100,98 @@ pub fn inflate_each(
}
/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as
/// `keep` asks. 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,
keep: Keep,
) -> impl Iterator<Item = Result<Built, String>> + use<> {
/// `keep` asks. 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, keep: Keep) -> 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 {
// 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<Result<(PathData, FillRule), String>> = 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<Result<Option<Puff>, 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, keep),
Ok(None) => Ok((vec![], Ok(vec![]), None)),
Err(e) => Err(e),
})
Solids { pending: Pending::Solved(puffs.into_iter()), bounds, o, view, reach, keep }
}
/// The shapes' puffs still to take: every one solved when the shapes were laid out, or each
/// 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<Result<Option<Puff>, String>>),
Later(std::vec::IntoIter<Result<(PathData, FillRule), String>>),
}
/// 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,
keep: Keep,
}
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<Built, String>;
fn next(&mut self) -> Option<Self::Item> {
let p = match &mut self.pending {
Pending::Solved(puffs) => puffs.next()?,
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),
Ok(None) => Ok((vec![], Ok(vec![]), None)),
Err(e) => Err(e),
})
}
fn size_hint(&self) -> (usize, Option<usize>) {
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<usize> = 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<bool> = const { std::cell::Cell::new(false) };
/// How many inflated solids this thread has built, for the tests.
pub(crate) static INFLATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// The grid work of the last solid this thread inflated in full, for the tests.
+99
View File
@@ -1102,6 +1102,105 @@ 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<PathData> = (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 (keep, facets, mesh) in [(build::Keep::Facets, true, false), (build::Keep::Faces, false, true), (build::Keep::Both, true, true)] {
let solids = |first: bool| {
SOLVE_FIRST.with(|s| s.set(first));
let out: Vec<Result<build::Built, String>> = inflate_faces(&shapes, b, o, view, keep).collect();
SOLVE_FIRST.with(|s| s.set(false));
out
};
let (taken, first) = (solids(false), solids(true));
assert_eq!(taken.len(), 5);
// The solid keeps its facets, its faces before projection, or both, as asked.
let built = |r: &Result<build::Built, String>| {
r.as_ref().is_ok_and(|(f, m, _)| (!facets || !f.is_empty()) && (!mesh || m.as_ref().is_ok_and(|m| !m.is_empty())))
};
assert!(built(&taken[0]) && built(&taken[1]) && built(&taken[4]), "{o:?} {view:?} facets {facets} mesh {mesh}");
assert!(
taken[2].as_ref().is_ok_and(|(f, m, p)| f.is_empty() && m.as_ref().is_ok_and(Vec::is_empty) && p.is_none()) && taken[3].is_err()
);
assert_eq!(format!("{taken:?}"), format!("{first:?}"), "{o:?} {view:?} facets {facets} mesh {mesh}");
}
}
}
}
/// 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<Point> = (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<Result<build::Built, String>> = 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::<Vec<_>>());
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.
+1
View File
@@ -1291,6 +1291,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)).
- Export 3D Object builds each Extrude & Bevel (both kinds) and Inflate solid once: the same build gives the faces that count toward the object's limit and the faces of its mesh, and the meshes match those of a separate build bit for bit. Tests `mesh::tests::a_meshed_solid_is_built_once`, `an_extruded_solid_and_its_mesh_match_the_two_built_apart`, `inflated_solids_and_their_meshes_match_the_two_built_apart`, `solids::tests_mesh::one_build_exports_the_meshes_built_apart`, `a_stroke_past_the_limits_is_left_out_of_the_export`.
+4
View File
@@ -171,6 +171,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