Merge pull request #7408 from dotCooCoo/fix/3d-export-builds-once

3D: Export 3D Object builds each Extrude and Inflate solid once
This commit is contained in:
BFlat
2026-11-14 16:53:56 +07:00
committed by GitHub
11 changed files with 479 additions and 77 deletions
+25 -21
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2};
use vectorcraft_pathops::BoolOp;
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View};
#[cfg(test)]
use vectorcraft_doc::color::Color;
@@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
#[cfg(test)]
thread_local! {
/// How many solids [`built_solids`] has built on this thread, for the tests.
/// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests.
static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
@@ -2039,31 +2039,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
// The solids the art draws, past the limits of the object's solids together as it is
// ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped.
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
// 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.
// Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel
// solid is built once, as the loop reaches it: its facets decide whether the art draws it, as
// [`built_solids`] does, and its faces before projection make its mesh.
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)
vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view)
});
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
let mut kept = 0usize;
for (item, member, area, rule, _, _) in &parts {
let item: &AppearanceItem = item;
if !drawn? {
// A stroke the art leaves out is left out of the meshes too; Inflate's next puff
// still belongs to the next part.
if let Some(built) = inflated.as_mut() {
built.next();
}
continue;
}
let mesh = match inflated.as_mut() {
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())),
None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view),
};
match mesh {
// A stroke the art leaves out is left out of the meshes too. The faces of the solids kept
// count together against the object's limit.
let solid = match built {
Ok(solid) => solid,
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue,
Err(e) => return Err(e),
};
kept = kept.saturating_add(solid.facets.len());
if kept > MAX_OBJECT_FACES {
return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string());
}
match solid.mesh() {
Ok(mesh) => {
let (paint, opacity, _) = paint_of(item, *member);
keep(&mut out, mesh, mesh_color(&paint, opacity))?;
+148
View File
@@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() {
let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY };
assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5]));
}
/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that
/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or
/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`].
fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
let p = crate::merged_params(&fx.id, &fx.params);
let view = view_options(&p);
let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap());
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
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)
});
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
let mut out = vec![];
for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
let item: &AppearanceItem = item;
if !drawn? {
if let Some(built) = inflated.as_mut() {
built.next();
}
continue;
}
let mesh = match inflated.as_mut() {
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
};
match mesh {
Ok(mesh) => {
let (paint, opacity, _) = paint_of(item, *opacity);
keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?;
}
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
Err(e) => return Err(e),
}
}
Ok(out)
}
/// The bits of every number in `meshes`, for comparing two exports exactly.
type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>;
fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits {
meshes
.iter()
.map(|m| {
let (positions, normals) = (&m.mesh.positions, &m.mesh.normals);
(
m.color.map(f32::to_bits),
positions.iter().map(|p| p.map(f64::to_bits)).collect(),
normals.iter().map(|n| n.map(f64::to_bits)).collect(),
m.mesh.triangles.clone(),
)
})
.collect()
}
/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black.
fn star_node(id: u64, x: f64) -> Node {
let mut b = BezPath::new();
for k in 0..10 {
let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos();
let r = if k % 2 == 0 { 60.0 } else { 25.0 };
let p = (x + r * c, 150.0 + r * s);
if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) }
}
b.close_path();
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0))
}
/// The shapes `meshes_for` builds from: one node's, or each of several.
fn shapes_of(nodes: &[&Node]) -> Vec<Profile> {
nodes
.iter()
.map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] })
.collect()
}
/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as
/// building its facets and its mesh apart, within and past the triangle budget.
#[test]
fn one_build_exports_the_meshes_built_apart() {
let mut second_stroke = square_node(3, 400.0);
if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() {
second_stroke.appearance.items.push(stroke);
}
let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0));
let cases = [
(EXTRUDE, json!({})),
(EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })),
(EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })),
(EXTRUDE, json!({ "depth": 0.0 })),
(CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })),
(INFLATE, json!({})),
(INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })),
(INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })),
(INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })),
];
let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]];
for (id, params) in cases {
let effect = fx(id, params.clone());
for nodes in &sets {
let shapes = shapes_of(nodes);
let (mut once, mut apart) = (0, 0);
let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap();
assert!(!reference.is_empty());
assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}");
assert_eq!(once, apart);
// Past the budget, at the same mesh, with the same count.
let limit = once - 1;
let (mut once, mut apart) = (0, 0);
let built = meshes_for(&effect, &shapes, &mut once, limit);
assert!(built.is_err(), "{id} {params}");
assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}");
assert_eq!(once, apart, "{id} {params}");
}
}
}
/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports,
/// as when the facets and the mesh are built apart.
#[test]
fn a_stroke_past_the_limits_is_left_out_of_the_export() {
// 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..40_000)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos();
let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 };
Point::new(20_000.0 + r * c, 20_000.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0));
for id in [EXTRUDE, INFLATE] {
// Without caps: a cap of 40,000 corners takes more work to cut into triangles than an
// export allows.
let effect = fx(id, json!({ "cap": false }));
let shapes = shapes_of(&[&n]);
// The fill's mesh alone has more triangles than an export takes.
let (mut once, mut apart) = (0, 0);
let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}"));
assert_eq!(built.len(), 1, "{id}: the fill's mesh alone");
assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}");
let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap();
assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}");
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ mod lights;
pub use lights::{Light, Lights, MAX_LIGHTS};
mod caps;
mod mesh;
pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh};
pub use mesh::{Mesh, MeshedSolid, extrude_mesh, extrude_meshed, inflate_meshed, inflate_meshes, plane_mesh, revolve_mesh};
mod shadow;
pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW};
+47 -5
View File
@@ -10,8 +10,8 @@
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Inflate, Revolve, V3, View};
use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Facet, Inflate, Revolve, V3, View};
/// Most steps the triangulation of one solid may take: a face's ears and bridges each test the
/// face's other corners.
@@ -56,12 +56,12 @@ impl Mesh {
/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh.
pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Mesh, String> {
extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces))
extrude_faces(path, rule, bounds, options, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?))
}
/// Rotate's plane ([`crate::plane`]) as a mesh.
pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Mesh, String> {
plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces))
plane_faces(path, rule, bounds, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?))
}
/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one
@@ -72,7 +72,49 @@ pub fn inflate_meshes(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Mesh, String>> + use<> {
inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces)))
inflate_faces(shapes, bounds, options, view, Keep::Faces).map(|r| r.and_then(|(_, faces, _)| mesh(&faces?)))
}
/// One solid built once for both its drawing and its mesh: the facets [`crate::extrude`] or
/// [`crate::inflate_each`] gives, and the faces before projection that [`MeshedSolid::mesh`] cuts
/// into the mesh [`extrude_mesh`] or [`inflate_meshes`] gives.
#[derive(Clone, Debug)]
pub struct MeshedSolid {
pub facets: Vec<Facet>,
/// The faces before projection, or the error the mesh alone gives when there are too many.
faces: Result<Vec<Face3>, String>,
}
/// A solid with no facets and an empty mesh.
impl Default for MeshedSolid {
fn default() -> Self {
Self { facets: vec![], faces: Ok(vec![]) }
}
}
impl MeshedSolid {
/// The solid as a mesh. The faces are cut into triangles on each call.
pub fn mesh(&self) -> Result<Mesh, String> {
mesh(self.faces.as_ref().map_err(String::clone)?)
}
}
/// Extrude & Bevel's solid ([`crate::extrude`]) and its mesh ([`extrude_mesh`]) from one build. An
/// error where [`crate::extrude`] gives one.
pub fn extrude_meshed(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<MeshedSolid, String> {
extrude_faces(path, rule, bounds, options, view, Keep::Both).map(|(facets, faces, _)| MeshedSolid { facets, faces })
}
/// Inflate's solids for several shapes of one object ([`crate::inflate_each`]) and their meshes
/// ([`inflate_meshes`]), each solid built once, as the iterator reaches it. An error where
/// [`crate::inflate_each`] gives one.
pub fn inflate_meshed(
shapes: &[(&PathData, FillRule)],
bounds: Rect,
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<MeshedSolid, String>> + use<> {
inflate_faces(shapes, bounds, options, view, Keep::Both).map(|r| r.map(|(facets, faces, _)| MeshedSolid { facets, faces }))
}
/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than
+128 -1
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
use super::*;
use crate::{Bevel, Edge};
use crate::{Bevel, BevelShape, Edge};
fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) {
b.move_to((x, y));
@@ -372,3 +372,130 @@ fn join_numbers_each_parts_corners_after_the_last() {
assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len())));
assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32);
}
/// A mesh's positions and normals as bits, and its triangles.
type MeshBits = (Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>);
/// The bits of every number in `m`, for comparing two meshes exactly.
fn bits(m: &Mesh) -> MeshBits {
(m.positions.iter().map(|p| p.map(f64::to_bits)).collect(), m.normals.iter().map(|n| n.map(f64::to_bits)).collect(), m.triangles.clone())
}
/// A star of `points` points about (100, 100), with a square hole: curved and straight edges both.
fn star(points: usize) -> (PathData, Rect) {
let mut b = BezPath::new();
for k in 0..2 * points {
let (s, c) = (k as f64 * std::f64::consts::PI / points as f64).sin_cos();
let r = if k % 2 == 0 { 90.0 } else { 45.0 };
let p = (100.0 + r * c, 100.0 + r * s);
if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 3.0, p.1), (p.0, p.1 + 3.0), p) }
}
b.close_path();
square(&mut b, 90.0, 90.0, 20.0, true);
shape(&b)
}
/// The views the solids are compared in: straight on, turned, with perspective, and with colored
/// lights (Inflate's smooth triangles).
fn views() -> Vec<View> {
let turned = View { rotation_x: -25.0, rotation_y: 35.0, rotation_z: 10.0, ..View::default() };
vec![front(), turned, View { perspective: 60.0, ..turned }, View { light_color: [1.0, 0.5, 0.25], ..turned }]
}
/// Extrude & Bevel built once for its facets and its mesh gives what [`crate::extrude`] and
/// [`extrude_mesh`] give apart, to the bit, errors included.
#[test]
fn an_extruded_solid_and_its_mesh_match_the_two_built_apart() {
let bevel = Bevel { shape: BevelShape::Rounded, width: 6.0, height: 5.0, ..Bevel::default() };
let options = [
Extrude::default(),
Extrude { depth: 0.0, ..Extrude::default() },
Extrude { cap: false, ..Extrude::default() },
Extrude { hidden: true, ..Extrude::default() },
Extrude { twist: 90.0, taper: 0.6, ..Extrude::default() },
Extrude { bevel: Some(bevel), ..Extrude::default() },
Extrude { bevel: Some(Bevel { both_sides: true, inside: true, ..bevel }), twist: 45.0, ..Extrude::default() },
];
let (p, r) = star(7);
for view in views() {
for o in options {
let meshed = extrude_meshed(&p, FillRule::NonZero, r, o, view).unwrap();
let facets = crate::extrude(&p, FillRule::NonZero, r, o, view).unwrap();
assert_eq!(format!("{:?}", meshed.facets), format!("{facets:?}"), "{o:?} {view:?}");
let apart = extrude_mesh(&p, FillRule::NonZero, r, o, view).unwrap();
assert!(!apart.is_empty());
assert_eq!(bits(&meshed.mesh().unwrap()), bits(&apart), "{o:?} {view:?}");
}
}
// A solid that can't be built gives the same error.
let flat = Rect::new(0.0, 0.0, f64::INFINITY, 10.0);
let o = Extrude::default();
let error = crate::extrude(&p, FillRule::NonZero, flat, o, front()).err();
assert!(error.is_some());
assert_eq!(extrude_meshed(&p, FillRule::NonZero, flat, o, front()).err(), error);
}
/// Inflate's solids built once for their facets and meshes give what [`crate::inflate_each`] and
/// [`inflate_meshes`] give apart, to the bit: several puffs under one eye, both sides, open
/// backs, and twisted sides cut finer than room is left for.
#[test]
fn inflated_solids_and_their_meshes_match_the_two_built_apart() {
let (star, _) = star(5);
let mut b = BezPath::new();
square(&mut b, 200.0, 20.0, 60.0, false);
let (box_, _) = shape(&b);
let ring: Vec<vectorcraft_geom::Point> = (0..600)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 600.0).sin_cos();
vectorcraft_geom::Point::new(100.0 + 90.0 * c, 100.0 + 90.0 * s)
})
.collect();
let ring = PathData::single(vectorcraft_geom::SubPath::polyline(&ring, true));
let sets = [vec![(&star, FillRule::NonZero), (&box_, FillRule::NonZero), (&star, FillRule::EvenOdd)], vec![(&ring, FillRule::NonZero)]];
let bounds = Rect::new(0.0, 0.0, 280.0, 200.0);
let options = [
Inflate::default(),
Inflate { depth: 30.0, both_sides: true, ..Inflate::default() },
Inflate { depth: 20.0, cap: false, volume: 80.0, ..Inflate::default() },
Inflate { depth: 40.0, twist: 360.0, taper: 0.7, both_sides: true, ..Inflate::default() },
// Thin and barely twisted: the star's sides want more pieces than room is left after its
// front and back, and the mesh, which keeps the faces turned away, has less room left.
Inflate { depth: 0.1, twist: 0.1, both_sides: true, ..Inflate::default() },
];
for shapes in &sets {
for view in views() {
for o in options {
let meshed: Vec<_> = inflate_meshed(shapes, bounds, o, view).collect();
let facets: Vec<_> = crate::inflate_each(shapes, bounds, o, view).collect();
let apart: Vec<_> = inflate_meshes(shapes, bounds, o, view).collect();
assert_eq!(meshed.len(), shapes.len());
for ((m, f), a) in meshed.iter().zip(&facets).zip(&apart) {
let (m, f, a) = (m.as_ref().unwrap(), f.as_ref().unwrap(), a.as_ref().unwrap());
assert_eq!(format!("{:?}", m.facets), format!("{f:?}"), "{o:?} {view:?}");
assert!(!a.is_empty());
assert_eq!(bits(&m.mesh().unwrap()), bits(a), "{o:?} {view:?}");
}
}
}
}
}
/// One build gives both the facets and the mesh: each solid is built once.
#[test]
fn a_meshed_solid_is_built_once() {
let (p, r) = star(5);
let extruded = || crate::solid::EXTRUDED.with(|n| n.get());
let start = extruded();
let solid = extrude_meshed(&p, FillRule::NonZero, r, Extrude::default(), front()).unwrap();
assert!(!solid.facets.is_empty() && solid.mesh().is_ok_and(|m| !m.is_empty()));
assert_eq!(extruded(), start + 1);
let inflated = || crate::solid::INFLATED.with(|n| n.get());
let start = inflated();
let mut b = BezPath::new();
square(&mut b, 200.0, 20.0, 60.0, false);
let (q, _) = shape(&b);
for solid in inflate_meshed(&[(&p, FillRule::NonZero), (&q, FillRule::NonZero)], Rect::new(0.0, 0.0, 280.0, 200.0), Inflate::default(), front()) {
assert!(solid.is_ok_and(|s| !s.facets.is_empty() && s.mesh().is_ok_and(|m| !m.is_empty())));
}
assert_eq!(inflated(), start + 2);
}
+7 -7
View File
@@ -11,7 +11,7 @@ use std::ops::Range;
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces};
use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale};
/// Where the shadow falls.
@@ -160,14 +160,14 @@ impl ShadowCaster {
/// Add Extrude & Bevel's solid ([`crate::extrude`]).
pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?;
self.add(faces, projection)
let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Faces)?;
self.add(faces?, projection)
}
/// Add Rotate's plane ([`crate::plane`]).
pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?;
self.add(faces, projection)
let (_, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Faces)?;
self.add(faces?, projection)
}
/// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each
@@ -182,11 +182,11 @@ impl ShadowCaster {
options: Inflate,
view: View,
) -> Vec<Result<Range<usize>, String>> {
inflate_faces(shapes, bounds, options, view, true)
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()) }
if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) }
})
.collect()
}
+3 -1
View File
@@ -20,9 +20,11 @@ mod membrane;
mod outline;
mod rings;
#[cfg(test)]
pub(crate) use build::EXTRUDED;
#[cfg(test)]
use build::{Camera, cuts, edge_distance, newell, section};
pub(crate) use build::{Face3, extrude_faces, plane_faces};
pub(crate) use build::{Face3, Keep, extrude_faces, plane_faces};
pub use build::{extrude, plane};
pub use face_map::{FaceMap, extrude_face_map, plane_face_map};
#[cfg(test)]
+76 -23
View File
@@ -64,9 +64,25 @@ impl Camera {
/// and its outward normal.
pub(crate) type Face3 = (Vec<Vec<V3>>, V3);
/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection,
/// and the projection it used (`None` when nothing was built).
pub(crate) type Built = (Vec<Facet>, Vec<Face3>, Option<Projection>);
/// What a solid's builder makes: its facets, its faces before projection when a mesh is asked for
/// (an error when they pass [`MAX_SOLID_FACES`] while the facets are built too), and the
/// projection it used (`None` when nothing was built).
pub(crate) type Built = (Vec<Facet>, Result<Vec<Face3>, String>, Option<Projection>);
#[cfg(test)]
thread_local! {
/// How many Extrude & Bevel solids this thread has built, for the tests.
pub(crate) static EXTRUDED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// What a solid's builder keeps: its facets, its faces before projection for a mesh, or both
/// from one build.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Keep {
Facets,
Faces,
Both,
}
/// Facets being collected for one solid.
pub(super) struct Builder<'a> {
@@ -80,6 +96,11 @@ pub(super) struct Builder<'a> {
pub(super) facets: Vec<Facet>,
/// With `Some`, every face goes here as it is, none hidden or projected, for a mesh.
pub(super) mesh: Option<Vec<Face3>>,
/// The faces are projected into `facets`; without it only `mesh` is kept.
pub(super) draw: bool,
/// The faces kept for the mesh passed [`MAX_SOLID_FACES`] while the facets were built too: the
/// mesh is too complex, and `mesh` keeps no more faces.
pub(super) lost: bool,
}
/// Where a face goes in the order the facets are drawn, back to front.
@@ -98,20 +119,46 @@ pub(super) enum Order {
Last,
}
impl Builder<'_> {
impl<'a> Builder<'a> {
/// A builder for one solid seen by `camera`, keeping what `keep` asks for.
pub(super) fn new(camera: &'a Camera, closed: bool, hidden: bool, keep: Keep) -> Self {
Self { camera, closed, hidden, facets: vec![], mesh: (keep != Keep::Facets).then(Vec::new), draw: keep != Keep::Faces, lost: false }
}
/// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`.
pub(super) fn face(&mut self, contours: Vec<Vec<V3>>, n: V3, order: Order) -> Result<(), String> {
let count: usize = contours.iter().map(Vec::len).sum();
if count < 3 {
return Ok(());
}
if let Some(mesh) = &mut self.mesh {
mesh.push((contours, n.unit()));
if mesh.len() > MAX_SOLID_FACES {
if self.draw {
self.facet(&contours, count, n, order)?;
}
let Some(mesh) = &mut self.mesh else { return Ok(()) };
mesh.push((contours, n.unit()));
if mesh.len() > MAX_SOLID_FACES {
if !self.draw {
return Err(TOO_COMPLEX_SURFACE.into());
}
return Ok(());
self.lose_mesh();
}
Ok(())
}
/// Keep no more faces for the mesh, which is too complex.
pub(super) fn lose_mesh(&mut self) {
self.mesh = None;
self.lost = true;
}
/// How many faces the mesh holds, while the facets are built too and the mesh is kept.
pub(super) fn faces_kept(&self) -> Option<usize> {
self.mesh.as_ref().filter(|_| self.draw).map(Vec::len)
}
/// Project a face of `count` view-space corners with outward normal `n` into a facet, drawn in
/// `order`, unless it is hidden or has no area.
fn facet(&mut self, contours: &[Vec<V3>], count: usize, n: V3, order: Order) -> Result<(), String> {
let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2));
let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64);
let n = n.unit();
@@ -138,7 +185,7 @@ impl Builder<'_> {
let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3);
let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye });
#[cfg(test)]
let view = contours.clone();
let view = contours.to_vec();
let contours: Vec<Vec<Point>> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect();
if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return Err("The 3D projection is not finite".into());
@@ -262,7 +309,7 @@ impl Builder<'_> {
/// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the
/// outline `outline` makes from its projected contours.
pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec<Point>]) -> Option<PathData>) {
if self.mesh.is_some() || self.facets.len() <= before {
if !self.draw || self.facets.len() <= before {
return;
}
if let Some(f) = self.facets.last_mut() {
@@ -273,7 +320,7 @@ impl Builder<'_> {
/// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the
/// planar end `end` of the solid.
pub(super) fn end(&mut self, before: usize, end: PlanarFace) {
if self.mesh.is_some() || self.facets.len() <= before {
if !self.draw || self.facets.len() <= before {
return;
}
if let Some(f) = self.facets.last_mut() {
@@ -281,15 +328,19 @@ impl Builder<'_> {
}
}
/// How many faces the builder holds: facets, or faces kept for a mesh.
/// How many faces the builder holds: facets, or without them the faces kept for a mesh.
pub(super) fn len(&self) -> usize {
self.mesh.as_ref().map_or(self.facets.len(), Vec::len)
match &self.mesh {
Some(mesh) if !self.draw => mesh.len(),
_ => self.facets.len(),
}
}
/// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]).
pub(super) fn finish(mut self) -> Built {
self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth));
(self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection()))
let faces = if self.lost { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(self.mesh.unwrap_or_default()) };
(self.facets, faces, Some(self.camera.projection()))
}
}
@@ -536,16 +587,18 @@ fn steps(o: &Extrude) -> Vec<Step> {
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
/// and caps.
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets)
extrude_faces(path, rule, bounds, options, view, Keep::Facets).map(|(facets, ..)| facets)
}
/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets.
pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result<Built, String> {
/// [`extrude`], keeping the solid's facets, its faces before projection, or both, as `keep` asks.
pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, keep: Keep) -> Result<Built, String> {
#[cfg(test)]
EXTRUDED.with(|n| n.set(n.get() + 1));
check_bounds(bounds)?;
let o = options.sanitized();
// No depth: the flat shape, seen from either side.
if o.depth <= 0.0 {
return plane_faces(path, rule, bounds, view, mesh);
return plane_faces(path, rule, bounds, view, keep);
}
let steps = steps(&o);
// One face for each edge of the outline in each band between two steps, and the two caps:
@@ -561,7 +614,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
return Err(TOO_COMPLEX_SURFACE.into());
}
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder::new(&camera, o.cap, o.hidden, keep);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let ring_at =
|ring: &[Point], s: &Step| -> Vec<V3> { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() };
@@ -647,15 +700,15 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
/// shape's own, so that every part of one object turns about the same point).
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets)
plane_faces(path, rule, bounds, view, Keep::Facets).map(|(facets, ..)| facets)
}
/// [`plane`], or with `mesh` its face before projection instead of its facet.
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
/// [`plane`], keeping its facet, its face before projection, or both, as `keep` asks.
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, keep: Keep) -> Result<Built, String> {
check_bounds(bounds)?;
let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder::new(&camera, false, false, keep);
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
// A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so
// it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in.
+41 -16
View File
@@ -1,4 +1,4 @@
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
use super::build::{Builder, Built, Camera, Keep, Order, Turn, check_bounds, cuts, fit, newell, outward};
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material};
use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings};
use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View};
@@ -73,26 +73,26 @@ pub fn inflate_each(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Vec<Facet>, String>> + use<> {
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets))
inflate_faces(shapes, bounds, options, view, Keep::Facets).map(|r| r.map(|(facets, ..)| facets))
}
/// [`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.
/// [`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,
mesh: bool,
keep: Keep,
) -> 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(move |p| match p {
Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh),
Ok(None) => Ok((vec![], vec![], None)),
Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep),
Ok(None) => Ok((vec![], Ok(vec![]), None)),
Err(e) => Err(e),
})
}
@@ -106,9 +106,9 @@ thread_local! {
}
/// 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> {
/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. Its faces
/// before projection instead, or both, as `keep` asks.
fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, keep: Keep) -> Result<Built, String> {
#[cfg(test)]
INFLATED.with(|n| n.set(n.get() + 1));
let Puff { rings, edges, cell, nx, ny, m, height } = p;
@@ -149,7 +149,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
let smooth = view.shade && view.has_colored_lights();
let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach));
// Without its back cap the object is a shell open behind, its inside showing.
let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder::new(&camera, o.cap, false, keep);
let half = o.depth / 2.0;
// The back: turned and scaled by the twist and taper, its puff too.
let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0);
@@ -375,15 +375,40 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
let turn = Turn::new(bounds, o.twist, o.taper, t);
ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect()
};
let mut pieces: Vec<Vec<usize>> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect();
let cut: Vec<Vec<usize>> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect();
let mut pieces = cut.clone();
fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len()));
let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add);
if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
for (ring, pieces) in rings.iter().zip(&pieces) {
for k in 0..slices {
b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?;
let sides = |b: &mut Builder<'_>, pieces: &[Vec<usize>]| -> Result<(), String> {
for (ring, pieces) in rings.iter().zip(pieces) {
for k in 0..slices {
b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?;
}
}
Ok(())
};
// A mesh kept with the facets has the sides a mesh alone has: limited, and with a twist
// cut, by the faces the mesh holds, which can be more than the facets.
let mut own_sides = None;
if let Some(kept) = b.faces_kept() {
let mut own = cut;
fit(&mut own, slices, MAX_FACES.saturating_sub(kept));
if kept.saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES {
b.lose_mesh();
} else if own != pieces {
own_sides = Some((own, b.mesh.take()));
}
}
sides(&mut b, &pieces)?;
if let Some((own, faces)) = own_sides {
let mut m = Builder::new(&camera, o.cap, false, Keep::Faces);
m.mesh = faces;
match sides(&mut m, &own) {
Ok(()) => b.mesh = m.mesh,
Err(_) => b.lose_mesh(),
}
}
}
+2 -2
View File
@@ -225,12 +225,12 @@ fn a_flat_facet_measures_its_depth_on_its_plane() {
/// eye, has no plane: its drawing no longer matches the plane's projection there.
#[test]
fn a_face_reaching_past_the_eye_has_no_plane() {
use super::build::{Builder, Order};
use super::build::{Builder, Keep, Order};
let (_, b) = shape(&square(0.0, 0.0, 100.0));
let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0);
let eye = camera.center_and_eye().1.unwrap();
for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] {
let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None };
let mut builder = Builder::new(&camera, false, false, Keep::Facets);
let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)];
builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap();
assert_eq!(builder.facets.len(), 1, "{far}");
+1
View File
@@ -1270,6 +1270,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- 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`.
- 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`.
- 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)).
- Revolve takes graphics on its swept side, shaded with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)). By VectorCraft's own rule a graphic wraps around the axis at its own size at every distance from it, narrows toward the silhouette, follows the profile along its length and is hidden where the side faces away (`threeD.mapGraphic`; `revolve::tests_graphics`, `tests::side_faces_lie_on_the_unrolled_surface_and_caps_do_not`, `map_graphic_puts_a_graphic_on_the_side_of_a_revolve`).
- Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617).