Merge pull request #6241 from dotCooCoo/fix/3d-export-triangle-budget

Asset Export: the 3D mesh triangle limit bounds memory across all exported objects
This commit is contained in:
BFlat
2026-11-09 04:30:20 +08:00
committed by GitHub
9 changed files with 131 additions and 39 deletions
+1 -1
View File
@@ -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;
+7 -4
View File
@@ -228,8 +228,9 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
/// 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<Vec<crate::SolidMesh>, 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<Vec<crate::SolidMesh>, 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<crate::SolidMesh, String> {
@@ -250,7 +251,9 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, 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<Vec<crate::SolidMesh>, 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)
+33 -15
View File
@@ -566,14 +566,15 @@ pub(crate) fn keep_mesh(out: &mut Vec<SolidMesh>, 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<Vec<SolidMesh>, 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<Vec<SolidMesh>, 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<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES);
let mut keep = |out: &mut Vec<SolidMesh>, 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<Vec<
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
// Inflate lays out every puff at once (they share one eye); each mesh, and each Extrude &
// Bevel solid, is built as it is reached.
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
// solid and its mesh are built as the loop reaches them.
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = 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<Vec<
/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes
/// with no triangles are left out. On a group or layer, the effect's solids of its members
/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no
/// such effect, its solids can't be built, or none has a triangle.
/// such effect, its solids can't be built, none has a triangle, or together they have more than
/// [`MAX_MESH_TRIANGLES`].
pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, 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<Item = &'a Node>) -> Result<Vec<SolidMesh>, 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<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, 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<Vec<SolidMesh>, 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<Vec<SolidMesh>, 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
+42 -2
View File
@@ -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::<usize>();
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]
+5 -6
View File
@@ -277,12 +277,11 @@ fn export_3d(s: &mut Session, p: &Value) -> Result<Value> {
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::<Result<Vec<_>>>()?;
// 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;
+1 -1
View File
@@ -72,7 +72,7 @@ pub fn inflate_meshes(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Mesh, String>> + 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
+17
View File
@@ -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![]);
+2
View File
@@ -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)]
+23 -10
View File
@@ -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<Result<Vec<Facet>, 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<Result<Built, String>> {
/// 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<Item = Result<Built, String>> + use<> {
let o = options.sanitized();
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(|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<usize> = 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<Built, String> {
#[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()