mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user