From b9d999fe26534a462bdc295226eb8300f910a51b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 10:18:13 -0800 Subject: [PATCH] Asset Export: the 3D mesh triangle limit bounds memory across objects The 2,000,000-triangle limit on a 3D object's meshes is now checked as each mesh is built, so an object past it is refused before the rest of its meshes are built: - Revolve counts each fill's and stroke's mesh as it builds it, instead of building every one first. - Inflate still solves every shape's membrane at once (the eye depends on the highest puff), but builds each solid only when its mesh is reached (inflate_meshes), so a refused object leaves the later solids unbuilt. - All the objects of one 3D asset share one budget (three_d_meshes_of), so assets.export3D refuses an asset whose objects pass the limit together. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/revolve.rs | 11 +++--- crates/effects/src/solids.rs | 48 +++++++++++++++++-------- crates/effects/src/solids/tests_mesh.rs | 44 +++++++++++++++++++++-- crates/engine/src/cmd/assets.rs | 11 +++--- crates/three-d/src/mesh.rs | 2 +- crates/three-d/src/mesh/tests.rs | 17 +++++++++ crates/three-d/src/solid.rs | 2 ++ crates/three-d/src/solid/inflate.rs | 33 +++++++++++------ 9 files changed, 131 insertions(+), 39 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e4ca42a4e..7bba3e8dd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -86,7 +86,7 @@ pub use reshape::{ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, - three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options, + three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ba4b0e657..946c6532b 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -228,8 +228,9 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on /// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for -/// each of them. -pub(crate) fn revolve_meshes(n: &Node) -> Result, String> { +/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes +/// `total` past `limit` stops with an error before the next is built. +pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; let ctx = GeomContext::of(n); let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { @@ -250,7 +251,9 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result, String> .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) .ok_or("the revolved object has no fill or stroke")?; - return Ok(vec![mesh(&path, fx, item)?]); + let mut out = vec![]; + crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?; + return Ok(out); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; @@ -259,7 +262,7 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result, String> && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); - out.push(mesh(&p, fx, item)?); + crate::solids::keep_mesh(&mut out, total, mesh(&p, fx, item)?, limit)?; } } Ok(out) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 09a2fc8ec..6f8eac9be 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -566,14 +566,15 @@ pub(crate) fn keep_mesh(out: &mut Vec, total: &mut usize, mesh: Solid /// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns /// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one -/// from the outline of each stroke. A stroke whose solid is past the limits is left out. -fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result, String> { +/// from the outline of each stroke. A stroke whose solid is past the limits is left out. Each mesh +/// is counted against `limit` ([`keep_mesh`]) before the next is built. +fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); let view = view_options(&p); let mut out = vec![]; - let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES); + let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit); if fx.id == ROTATE { for s in shapes { for item in &s.items { @@ -585,8 +586,8 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) @@ -614,16 +615,33 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result Result, String> { - // Triangles of every solid together, against [`MAX_MESH_TRIANGLES`]. + meshes_within(n, &mut 0, MAX_MESH_TRIANGLES) +} + +/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports +/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`]. +pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator) -> Result, String> { + meshes_of_within(nodes, MAX_MESH_TRIANGLES) +} + +/// [`three_d_meshes_of`] with a budget of `limit` triangles. +fn meshes_of_within<'a>(nodes: impl IntoIterator, limit: usize) -> Result, String> { let mut total = 0usize; + let mut out = vec![]; + for n in nodes { + out.extend(meshes_within(n, &mut total, limit)?); + } + Ok(out) +} + +/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an +/// error as soon as `total` passes `limit`. +fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let meshes = if crate::has_revolve(n) { - let mut out = vec![]; - for m in crate::revolve::revolve_meshes(n)? { - keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?; - } - out + crate::revolve::revolve_meshes(n, total, limit)? } else if !has_solid(n) { return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); } else if is_container(n) { @@ -631,21 +649,21 @@ pub fn three_d_meshes(n: &Node) -> Result, String> { let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); let mut shapes = members(n, label)?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); - meshes_for(fx, &shapes, &mut total)? + meshes_for(fx, &shapes, total, limit)? } else { let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?; let ctx = GeomContext::of(n); let paints = solid_paints(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); - meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)? + meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)? } else { let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); - out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?); + out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?); } } out diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 9aca2ab8c..70706e068 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -54,9 +54,49 @@ fn the_meshes_of_one_object_share_one_triangle_budget() { let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }]; let Some(effect) = n.appearance.effects.first() else { panic!("no effect") }; let mut total = 0; - assert!(meshes_for(effect, &shapes, &mut total).is_ok()); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok()); let mut total = MAX_MESH_TRIANGLES - first + 1; - assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex"))); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex"))); +} + +#[test] +fn revolve_stops_at_the_first_mesh_past_the_budget() { + // The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to + // revolve at all. + let mut n = square_node(1, 100.0); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.get_mut(0) { + f.effects.push(fx(crate::REVOLVE, json!({}))); + } + let fill_only = n.clone(); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) { + s.effects.push(fx("distort.transform", json!({ "moveH": 1.0, "copies": 1000 }))); + s.effects.push(fx(crate::REVOLVE, json!({}))); + } + let fill = three_d_meshes(&fill_only).unwrap(); + let triangles = fill[0].mesh.triangles.len(); + assert!(triangles > 1); + let whole = three_d_meshes(&n); + assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}"); + // With room for less than the fill's mesh, the stroke's is never built. + let mut total = 0; + let capped = meshes_within(&n, &mut total, triangles - 1); + assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}"); + assert_eq!(total, triangles); +} + +#[test] +fn the_objects_of_one_asset_share_one_triangle_budget() { + let mut a = square_node(1, 0.0); + a.appearance.effects.push(fx(EXTRUDE, json!({}))); + let mut b = square_node(2, 300.0); + b.appearance.effects.push(fx(EXTRUDE, json!({}))); + let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::(); + let (ta, tb) = (triangles(&a), triangles(&b)); + assert!(meshes_of_within([&a], ta).is_ok()); + assert!(meshes_of_within([&b], tb).is_ok()); + assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); + assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4)); + assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4)); } #[test] diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index fd273e067..8c225b877 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -277,12 +277,11 @@ fn export_3d(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(a) = d.asset(id) else { continue }; let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); - let mut parts = vec![]; - for node in &a.nodes { - let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?; - let what = &a.name; - parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?); - } + let nodes = + a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; + // The asset's objects share one budget of triangles. + let what = &a.name; + let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); let mut stem = base.clone(); let mut k = 1; diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 04b5db626..45b559f77 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -72,7 +72,7 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).into_iter().map(|r| r.and_then(|(_, faces)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces)| mesh(&faces))) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 4ee38fe1d..1247926de 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -262,6 +262,23 @@ fn inflate_puffs_the_front_toward_the_viewer() { assert!(hi[2] > 0.0 && lo[2] < hi[2], "the front puffs toward the viewer: {lo:?} {hi:?}"); } +#[test] +fn inflate_builds_each_solid_as_it_is_reached() { + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + square(&mut a, 0.0, 0.0, 100.0, false); + square(&mut b, 120.0, 0.0, 100.0, false); + let ((pa, _), (pb, _)) = (shape(&a), shape(&b)); + let built = || crate::solid::INFLATED.with(|n| n.get()); + let start = built(); + let mut meshes = + inflate_meshes(&[(&pa, FillRule::NonZero), (&pb, FillRule::NonZero)], Rect::new(0.0, 0.0, 220.0, 100.0), Inflate::default(), front()); + assert_eq!(built(), start, "no solid is built before the first mesh is asked for"); + assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty()))); + assert_eq!(built(), start + 1, "the second solid waits until it is reached"); + assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty()))); + assert_eq!(built(), start + 2); +} + #[test] fn an_empty_shape_makes_an_empty_mesh() { let p = PathData::new(vec![]); diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 20beda4ec..e51e259c4 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -22,6 +22,8 @@ mod rings; use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; +#[cfg(test)] +pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 40d70a79d..984017593 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -51,29 +51,42 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, false).into_iter().map(|r| r.map(|(facets, _)| facets)).collect() + inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, _)| facets)).collect() } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, mesh: bool) -> Vec> { +/// 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<> { let o = options.sanitized(); 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(|p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![])), - Err(e) => Err(e), - }) - .collect() + puffs.into_iter().map(move |p| match p { + Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), + Ok(None) => Ok((vec![], vec![])), + Err(e) => Err(e), + }) +} + +#[cfg(test)] +thread_local! { + /// 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 facets of one inflated shape, for a camera that allows for a puff `reach` high (or the /// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, /// its faces before projection instead. fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { + #[cfg(test)] + INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings .iter()