use crate::csg::{calculate_normals, smooth_normals_with_creases};
use crate::diagnostics::BoolFailureReason;
use crate::mesh::Mesh;
use manifold_csg::Manifold;
use rustc_hash::FxHashMap;
const WELD_QUANTIZATION: f32 = 1.0e6;
#[inline]
fn quantize(v: f32) -> i64 {
(v * WELD_QUANTIZATION).round() as i64
}
fn weld_vertices(mesh: &Mesh) -> (Vec<f64>, Vec<u64>, usize) {
let n_verts = mesh.positions.len() / 3;
if n_verts == 0 {
return (Vec::new(), Vec::new(), 0);
}
let mut bucket_to_canonical: FxHashMap<(i64, i64, i64), u32> =
FxHashMap::default();
let mut old_to_new: Vec<u32> = Vec::with_capacity(n_verts);
let mut welded_pos: Vec<f64> = Vec::with_capacity(n_verts * 3);
for i in 0..n_verts {
let x = mesh.positions[i * 3];
let y = mesh.positions[i * 3 + 1];
let z = mesh.positions[i * 3 + 2];
let key = (quantize(x), quantize(y), quantize(z));
let canonical = *bucket_to_canonical.entry(key).or_insert_with(|| {
let idx = (welded_pos.len() / 3) as u32;
welded_pos.push(x as f64);
welded_pos.push(y as f64);
welded_pos.push(z as f64);
idx
});
old_to_new.push(canonical);
}
let dedup_count = n_verts.saturating_sub(welded_pos.len() / 3);
let mut welded_tris: Vec<u64> = Vec::with_capacity(mesh.indices.len());
for chunk in mesh.indices.chunks_exact(3) {
let i0_raw = chunk[0] as usize;
let i1_raw = chunk[1] as usize;
let i2_raw = chunk[2] as usize;
if i0_raw >= n_verts || i1_raw >= n_verts || i2_raw >= n_verts {
continue;
}
let i0 = old_to_new[i0_raw];
let i1 = old_to_new[i1_raw];
let i2 = old_to_new[i2_raw];
if i0 == i1 || i1 == i2 || i0 == i2 {
continue;
}
welded_tris.push(u64::from(i0));
welded_tris.push(u64::from(i1));
welded_tris.push(u64::from(i2));
}
(welded_pos, welded_tris, dedup_count)
}
fn reorient_outward(positions: &[f64], tris: &mut [u64]) {
let n_verts = positions.len() / 3;
let n_tris = tris.len() / 3;
if n_verts == 0 || n_tris == 0 {
return;
}
let mut edge_map: FxHashMap<(u32, u32), smallvec::SmallVec<[(u32, bool); 4]>> =
FxHashMap::default();
edge_map.reserve(n_tris * 3);
for t_idx in 0..n_tris {
let t = &tris[t_idx * 3..t_idx * 3 + 3];
for k in 0..3 {
let a = t[k] as u32;
let b = t[(k + 1) % 3] as u32;
let key = if a < b { (a, b) } else { (b, a) };
let low_to_high = a < b;
edge_map.entry(key).or_default().push((t_idx as u32, low_to_high));
}
}
let mut parent: Vec<u32> = (0..n_tris as u32).collect();
fn find(parent: &mut [u32], mut x: u32) -> u32 {
while parent[x as usize] != x {
let p = parent[x as usize];
parent[x as usize] = parent[p as usize]; x = parent[x as usize];
}
x
}
fn union(parent: &mut [u32], a: u32, b: u32) {
let ra = find(parent, a);
let rb = find(parent, b);
if ra != rb {
parent[ra as usize] = rb;
}
}
for entries in edge_map.values() {
if entries.len() != 2 {
continue;
}
union(&mut parent, entries[0].0, entries[1].0);
}
let mut components: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
for t in 0..n_tris as u32 {
let root = find(&mut parent, t);
components.entry(root).or_default().push(t);
}
let mut visited = vec![false; n_tris];
for component in components.values() {
if component.is_empty() {
continue;
}
let seed = component[0];
let mut queue = std::collections::VecDeque::new();
queue.push_back(seed);
visited[seed as usize] = true;
while let Some(curr) = queue.pop_front() {
let curr_tri = [
tris[curr as usize * 3] as u32,
tris[curr as usize * 3 + 1] as u32,
tris[curr as usize * 3 + 2] as u32,
];
for k in 0..3 {
let a = curr_tri[k];
let b = curr_tri[(k + 1) % 3];
let key = if a < b { (a, b) } else { (b, a) };
let curr_dir = a < b;
let Some(adjs) = edge_map.get(&key) else {
continue;
};
if adjs.len() != 2 {
continue;
}
for &(other_idx, _other_dir) in adjs {
if other_idx == curr || visited[other_idx as usize] {
continue;
}
visited[other_idx as usize] = true;
let ot = [
tris[other_idx as usize * 3] as u32,
tris[other_idx as usize * 3 + 1] as u32,
tris[other_idx as usize * 3 + 2] as u32,
];
let mut neighbour_dir = None;
for j in 0..3 {
let oa = ot[j];
let ob = ot[(j + 1) % 3];
if (oa.min(ob), oa.max(ob)) == key {
neighbour_dir = Some(oa < ob);
break;
}
}
let Some(neighbour_dir) = neighbour_dir else {
continue;
};
if neighbour_dir == curr_dir {
let base = other_idx as usize * 3;
tris.swap(base, base + 2);
}
queue.push_back(other_idx);
}
}
}
}
let total_volume = signed_volume_6x(positions, tris);
if total_volume < 0.0 {
for t_idx in 0..n_tris {
let base = t_idx * 3;
tris.swap(base, base + 2);
}
}
}
#[inline]
fn signed_volume_of(positions: &[f64], tris: &[u64], t_idx: usize) -> f64 {
let base = t_idx * 3;
let a = &positions[tris[base] as usize * 3..tris[base] as usize * 3 + 3];
let b = &positions[tris[base + 1] as usize * 3..tris[base + 1] as usize * 3 + 3];
let c = &positions[tris[base + 2] as usize * 3..tris[base + 2] as usize * 3 + 3];
let cross_x = b[1] * c[2] - b[2] * c[1];
let cross_y = b[2] * c[0] - b[0] * c[2];
let cross_z = b[0] * c[1] - b[1] * c[0];
a[0] * cross_x + a[1] * cross_y + a[2] * cross_z
}
#[inline]
fn signed_volume_6x(positions: &[f64], tris: &[u64]) -> f64 {
let mut sum = 0.0;
let n_tris = tris.len() / 3;
for t in 0..n_tris {
sum += signed_volume_of(positions, tris, t);
}
sum
}
#[cfg(test)]
fn triangle_normal_axis(positions: &[f64], tri: &[u64], axis: usize) -> f64 {
let a = &positions[tri[0] as usize * 3..tri[0] as usize * 3 + 3];
let b = &positions[tri[1] as usize * 3..tri[1] as usize * 3 + 3];
let c = &positions[tri[2] as usize * 3..tri[2] as usize * 3 + 3];
let ux = b[0] - a[0];
let uy = b[1] - a[1];
let uz = b[2] - a[2];
let vx = c[0] - a[0];
let vy = c[1] - a[1];
let vz = c[2] - a[2];
match axis {
0 => uy * vz - uz * vy,
1 => uz * vx - ux * vz,
_ => ux * vy - uy * vx,
}
}
fn mesh_to_manifold(mesh: &Mesh) -> Result<Manifold, BoolFailureReason> {
if mesh.is_empty() {
return Err(BoolFailureReason::EmptyOperand);
}
let (vert_props, mut tri_indices, _dedup) = weld_vertices(mesh);
if tri_indices.is_empty() {
return Err(BoolFailureReason::DegenerateOperand);
}
reorient_outward(&vert_props, &mut tri_indices);
Manifold::from_mesh_f64(&vert_props, 3, &tri_indices)
.map_err(|e| BoolFailureReason::KernelError(format!("mesh_to_manifold: {e}")))
}
fn mesh_to_manifold_perturbed(mesh: &Mesh) -> Result<Manifold, BoolFailureReason> {
if mesh.is_empty() {
return Err(BoolFailureReason::EmptyOperand);
}
let (mut vert_props, mut tri_indices, _dedup) = weld_vertices(mesh);
if tri_indices.is_empty() {
return Err(BoolFailureReason::DegenerateOperand);
}
reorient_outward(&vert_props, &mut tri_indices);
perturb_around_centroid(&mut vert_props);
Manifold::from_mesh_f64(&vert_props, 3, &tri_indices)
.map_err(|e| BoolFailureReason::KernelError(format!("mesh_to_manifold_perturbed: {e}")))
}
fn perturb_around_centroid(positions: &mut [f64]) {
const TARGET_CORNER_DISPLACEMENT: f64 = 1.0e-5;
let n = positions.len() / 3;
if n == 0 {
return;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for i in 0..n {
for axis in 0..3 {
let v = positions[i * 3 + axis];
if v < min[axis] {
min[axis] = v;
}
if v > max[axis] {
max[axis] = v;
}
}
}
let center = [
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
(min[2] + max[2]) * 0.5,
];
let half = [
(max[0] - min[0]).abs() * 0.5,
(max[1] - min[1]).abs() * 0.5,
(max[2] - min[2]).abs() * 0.5,
];
let max_half = half[0].max(half[1]).max(half[2]);
if max_half <= 0.0 {
return; }
let scale_delta = (TARGET_CORNER_DISPLACEMENT / max_half).max(1.0e-6);
let scale = 1.0 + scale_delta;
for i in 0..n {
positions[i * 3] = center[0] + (positions[i * 3] - center[0]) * scale;
positions[i * 3 + 1] = center[1] + (positions[i * 3 + 1] - center[1]) * scale;
positions[i * 3 + 2] = center[2] + (positions[i * 3 + 2] - center[2]) * scale;
}
}
fn manifold_to_mesh(m: &Manifold) -> Mesh {
let (vert_props, n_props, tri_indices) = m.to_mesh_f64();
if n_props < 3 || vert_props.is_empty() || tri_indices.is_empty() {
return Mesh::new();
}
let n_verts = vert_props.len() / n_props;
let mut mesh = Mesh::with_capacity(n_verts, tri_indices.len());
mesh.positions.reserve(n_verts * 3);
for i in 0..n_verts {
let base = i * n_props;
mesh.positions.push(vert_props[base] as f32);
mesh.positions.push(vert_props[base + 1] as f32);
mesh.positions.push(vert_props[base + 2] as f32);
}
mesh.normals.resize(n_verts * 3, 0.0);
mesh.indices.reserve(tri_indices.len());
for &i in &tri_indices {
mesh.indices.push(i as u32);
}
calculate_normals(&mut mesh);
let mut welded = mesh.welded(1e-6, 1e-3);
smooth_normals_with_creases(&mut welded, 0.866);
welded
}
pub fn difference(host: &Mesh, void: &Mesh) -> Result<Mesh, BoolFailureReason> {
let host_m = mesh_to_manifold(host)?;
let void_m = mesh_to_manifold_perturbed(void)?;
let result = host_m.difference(&void_m);
Ok(manifold_to_mesh(&result))
}
pub fn union(a: &Mesh, b: &Mesh) -> Result<Mesh, BoolFailureReason> {
let a_m = mesh_to_manifold(a)?;
let b_m = mesh_to_manifold(b)?;
let result = a_m.union(&b_m);
Ok(manifold_to_mesh(&result))
}
pub fn intersection(a: &Mesh, b: &Mesh) -> Result<Mesh, BoolFailureReason> {
let a_m = mesh_to_manifold(a)?;
let b_m = mesh_to_manifold(b)?;
let result = a_m.intersection(&b_m);
Ok(manifold_to_mesh(&result))
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::{Point3, Vector3};
fn unit_box_at(origin: Point3<f64>) -> Mesh {
let mut m = Mesh::with_capacity(8, 36);
let n = Vector3::new(0.0, 0.0, 0.0);
let v = |dx: f64, dy: f64, dz: f64| {
Point3::new(origin.x + dx, origin.y + dy, origin.z + dz)
};
let p = [
v(0.0, 0.0, 0.0),
v(1.0, 0.0, 0.0),
v(1.0, 1.0, 0.0),
v(0.0, 1.0, 0.0),
v(0.0, 0.0, 1.0),
v(1.0, 0.0, 1.0),
v(1.0, 1.0, 1.0),
v(0.0, 1.0, 1.0),
];
for pt in &p {
m.add_vertex(*pt, n);
}
let faces: [[u32; 6]; 6] = [
[0, 2, 1, 0, 3, 2],
[4, 5, 6, 4, 6, 7],
[0, 4, 7, 0, 7, 3],
[1, 2, 6, 1, 6, 5],
[0, 1, 5, 0, 5, 4],
[3, 7, 6, 3, 6, 2],
];
for face in &faces {
m.add_triangle(face[0], face[1], face[2]);
m.add_triangle(face[3], face[4], face[5]);
}
m
}
fn polygon_soup_cube() -> Mesh {
let mut m = Mesh::new();
let n = Vector3::new(0.0, 0.0, 0.0);
let face = |verts: &[(f64, f64, f64); 4], mesh: &mut Mesh| {
let base = mesh.vertex_count() as u32;
for &(x, y, z) in verts {
mesh.add_vertex(Point3::new(x, y, z), n);
}
mesh.add_triangle(base, base + 1, base + 2);
mesh.add_triangle(base, base + 2, base + 3);
};
face(&[(0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (1.0, 1.0, 0.0), (1.0, 0.0, 0.0)], &mut m);
face(&[(0.0, 0.0, 1.0), (1.0, 0.0, 1.0), (1.0, 1.0, 1.0), (0.0, 1.0, 1.0)], &mut m);
face(&[(0.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 1.0), (0.0, 1.0, 0.0)], &mut m);
face(&[(1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (1.0, 1.0, 1.0), (1.0, 0.0, 1.0)], &mut m);
face(&[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 0.0, 1.0), (0.0, 0.0, 1.0)], &mut m);
face(&[(0.0, 1.0, 0.0), (0.0, 1.0, 1.0), (1.0, 1.0, 1.0), (1.0, 1.0, 0.0)], &mut m);
m
}
#[test]
fn weld_collapses_polygon_soup_corners() {
let soup = polygon_soup_cube();
assert_eq!(soup.vertex_count(), 24);
assert_eq!(soup.triangle_count(), 12);
let (verts, tris, dedup) = weld_vertices(&soup);
assert_eq!(verts.len() / 3, 8, "cube has 8 unique corners");
assert_eq!(tris.len() / 3, 12, "no degenerate triangles after weld");
assert_eq!(dedup, 16, "24 raw verts - 8 canonical = 16 deduped");
}
#[test]
fn weld_drops_degenerate_triangles() {
let mut m = Mesh::new();
let n = Vector3::new(0.0, 0.0, 0.0);
m.add_vertex(Point3::new(1.0, 2.0, 3.0), n);
m.add_vertex(Point3::new(1.0, 2.0, 3.0), n);
m.add_vertex(Point3::new(1.0, 2.0, 3.0), n);
m.add_triangle(0, 1, 2);
let (verts, tris, _) = weld_vertices(&m);
assert_eq!(verts.len() / 3, 1);
assert!(tris.is_empty(), "collapsed triangle must be dropped");
}
#[test]
fn weld_skips_out_of_range_triangle_index() {
let mut m = Mesh::new();
let n = Vector3::new(0.0, 0.0, 0.0);
m.add_vertex(Point3::new(0.0, 0.0, 0.0), n);
m.add_vertex(Point3::new(1.0, 0.0, 0.0), n);
m.add_vertex(Point3::new(0.0, 1.0, 0.0), n);
m.add_triangle(0, 1, 2);
m.indices.extend_from_slice(&[0, 1, 99]);
let (verts, tris, _) = weld_vertices(&m);
assert_eq!(verts.len() / 3, 3);
assert_eq!(tris.len() / 3, 1, "only the in-range triangle survives");
let _ = mesh_to_manifold(&m);
}
#[test]
fn weld_makes_polygon_soup_manifold() {
let soup = polygon_soup_cube();
let m = mesh_to_manifold(&soup).expect("polygon-soup cube must be welded into a manifold");
let back = manifold_to_mesh(&m);
assert!(!back.is_empty());
assert!(back.triangle_count() >= 12);
}
#[test]
fn round_trip_preserves_solid() {
let cube = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let manifold = mesh_to_manifold(&cube).expect("box -> manifold");
let back = manifold_to_mesh(&manifold);
assert!(!back.is_empty(), "round-trip mesh empty");
assert!(back.triangle_count() >= 12, "cube must remain 12+ tri");
}
#[test]
fn difference_cuts_a_hole() {
let host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let cutter = unit_box_at(Point3::new(0.25, 0.25, -0.5));
let result = difference(&host, &cutter).expect("difference ok");
assert!(!result.is_empty(), "difference produced empty mesh");
assert!(
result.triangle_count() > host.triangle_count(),
"expected difference to create new boundary triangles, got {}",
result.triangle_count()
);
}
#[test]
fn union_removes_overlap() {
let a = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let b = unit_box_at(Point3::new(0.5, 0.0, 0.0));
let result = union(&a, &b).expect("union ok");
assert!(!result.is_empty());
assert!(
result.triangle_count() > 12,
"union of two overlapping boxes must add boundary triangles"
);
}
#[test]
fn intersection_returns_overlap_volume() {
let a = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let b = unit_box_at(Point3::new(0.5, 0.0, 0.0));
let result = intersection(&a, &b).expect("intersection ok");
assert!(!result.is_empty(), "intersection of overlapping boxes must be non-empty");
}
#[test]
fn empty_operand_reports_failure() {
let host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let void = Mesh::new();
let err = difference(&host, &void).unwrap_err();
assert!(matches!(err, BoolFailureReason::EmptyOperand));
}
#[test]
fn no_operand_size_cap() {
let mut host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
for i in 1..5 {
host.merge(&unit_box_at(Point3::new(i as f64 * 0.1, 0.0, 0.0)));
}
assert_eq!(host.triangle_count(), 60);
let cutter = unit_box_at(Point3::new(0.05, 0.05, -0.5));
let result = difference(&host, &cutter).expect("difference ok past 24-poly cap");
assert!(!result.is_empty());
}
fn unit_box_inside_out_at(origin: Point3<f64>) -> Mesh {
let mut m = unit_box_at(origin);
for tri in m.indices.chunks_exact_mut(3) {
tri.swap(0, 2);
}
m
}
fn unit_box_mixed_winding_at(origin: Point3<f64>) -> Mesh {
let mut m = unit_box_at(origin);
for face in [0, 1, 3, 4] {
let base = face * 2;
for tri in &mut [base, base + 1] {
let t = *tri;
m.indices.swap(t * 3, t * 3 + 2);
}
}
m
}
#[test]
fn reorient_outward_fixes_inside_out_box() {
let bad = unit_box_inside_out_at(Point3::new(0.0, 0.0, 0.0));
let (verts, mut tris, _) = weld_vertices(&bad);
reorient_outward(&verts, &mut tris);
let mut any_positive_top_normal = false;
for chunk in tris.chunks_exact(3) {
let on_top = chunk.iter().all(|&i| {
let z = verts[i as usize * 3 + 2];
(z - 1.0).abs() < 1e-6
});
if !on_top {
continue;
}
if triangle_normal_axis(&verts, chunk, 2) > 0.0 {
any_positive_top_normal = true;
break;
}
}
assert!(
any_positive_top_normal,
"after reorient, the +Z face must have an outward-facing triangle"
);
}
#[test]
fn difference_survives_inside_out_cutter() {
let host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let bad_cutter = unit_box_inside_out_at(Point3::new(0.25, 0.25, -0.5));
let good_cutter = unit_box_at(Point3::new(0.25, 0.25, -0.5));
let bad_result = difference(&host, &bad_cutter).expect("difference ok");
let good_result = difference(&host, &good_cutter).expect("difference ok");
assert_eq!(
bad_result.triangle_count(),
good_result.triangle_count(),
"reorient-fixed cutter must produce the same triangle count as a correctly-oriented one",
);
let (bad_min, bad_max) = bad_result.bounds();
let (good_min, good_max) = good_result.bounds();
assert!((bad_min - good_min).abs().max() < 1e-5);
assert!((bad_max - good_max).abs().max() < 1e-5);
}
#[test]
fn difference_survives_mixed_winding_cutter() {
let host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let mixed_cutter = unit_box_mixed_winding_at(Point3::new(0.25, 0.25, -0.5));
let good_cutter = unit_box_at(Point3::new(0.25, 0.25, -0.5));
let mixed_result = difference(&host, &mixed_cutter).expect("difference ok");
let good_result = difference(&host, &good_cutter).expect("difference ok");
assert_eq!(
mixed_result.triangle_count(),
good_result.triangle_count(),
"reorient must reconcile mixed-winding cutter to outward-facing",
);
}
#[test]
fn difference_survives_face_coincident_cutter() {
let host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let mut cutter = Mesh::with_capacity(8, 36);
let n = Vector3::new(0.0, 0.0, 0.0);
let p = |x: f64, y: f64, z: f64| Point3::new(x, y, z);
let cs = [
p(0.0, 0.0, 0.5),
p(1.0, 0.0, 0.5),
p(1.0, 1.0, 0.5),
p(0.0, 1.0, 0.5),
p(0.0, 0.0, 1.0),
p(1.0, 0.0, 1.0),
p(1.0, 1.0, 1.0),
p(0.0, 1.0, 1.0),
];
for pt in &cs {
cutter.add_vertex(*pt, n);
}
let faces: [[u32; 6]; 6] = [
[0, 2, 1, 0, 3, 2],
[4, 5, 6, 4, 6, 7],
[0, 4, 7, 0, 7, 3],
[1, 2, 6, 1, 6, 5],
[0, 1, 5, 0, 5, 4],
[3, 7, 6, 3, 6, 2],
];
for face in &faces {
cutter.add_triangle(face[0], face[1], face[2]);
cutter.add_triangle(face[3], face[4], face[5]);
}
let result = difference(&host, &cutter).expect("difference ok");
assert!(!result.is_empty(), "coincident-face difference returned empty");
let (rmin, rmax) = result.bounds();
assert!(
rmin.z < 0.1,
"result must contain the host's bottom (z=0), got z_min={}",
rmin.z,
);
assert!(
rmax.z > 0.4 && rmax.z < 0.6,
"result must be cut at the coincident face (z≈0.5), got z_max={}",
rmax.z,
);
}
#[test]
fn difference_output_is_welded_and_smoothable() {
let host = unit_box_at(Point3::new(0.0, 0.0, 0.0));
let mut cutter = Mesh::with_capacity(8, 36);
let n = Vector3::new(0.0, 0.0, 0.0);
let p = |x: f64, y: f64, z: f64| Point3::new(x, y, z);
let cs = [
p(-0.5, 0.25, 0.25),
p(1.5, 0.25, 0.25),
p(1.5, 0.75, 0.25),
p(-0.5, 0.75, 0.25),
p(-0.5, 0.25, 0.75),
p(1.5, 0.25, 0.75),
p(1.5, 0.75, 0.75),
p(-0.5, 0.75, 0.75),
];
for c in cs.iter() {
cutter.add_vertex(*c, n);
}
for face in [
[0, 1, 2],
[0, 2, 3],
[4, 6, 5],
[4, 7, 6],
[0, 4, 5],
[0, 5, 1],
[2, 6, 7],
[2, 7, 3],
[1, 5, 6],
[1, 6, 2],
[3, 7, 4],
[3, 4, 0],
] {
cutter.add_triangle(face[0], face[1], face[2]);
}
let result = difference(&host, &cutter).expect("difference ok");
assert!(!result.is_empty(), "cut wall must produce output");
let tri_count = result.indices.len() / 3;
let vert_count = result.positions.len() / 3;
assert!(
vert_count <= tri_count * 3,
"vert count {vert_count} exceeds flat-shading upper bound 3*{tri_count}",
);
assert!(
vert_count >= 8,
"post-process must keep at least the cube's 8 corner verts, got {vert_count}",
);
let (lo, hi) = result.bounds();
let bbox_volume =
(hi.x - lo.x) * (hi.y - lo.y) * (hi.z - lo.z);
assert!(
bbox_volume > 0.99 && bbox_volume < 1.01,
"post-process must preserve the cube's bounding box ≈ 1 m³, got {:.4} m³",
bbox_volume,
);
}
}