mod boxify;
mod cavities;
mod cluster;
mod ray_parity;
use crate::mesh::Mesh;
#[derive(Debug, Clone)]
pub struct SimplifyOptions {
pub target_ratio: Option<f32>,
pub drop_cavities: bool,
pub boxify: bool,
pub min_triangles: u32,
pub weld_eps: f32,
}
impl SimplifyOptions {
pub fn for_level(level: u8) -> Self {
let target_ratio = match level {
0 | 1 => Some(0.5),
2 => Some(0.25),
3 => Some(0.10),
4 => Some(0.03),
_ => None,
};
Self {
target_ratio,
drop_cavities: level < 5,
boxify: level >= 5,
min_triangles: 32,
weld_eps: 1e-6,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SimplifyStats {
pub tris_before: u32,
pub tris_after: u32,
pub cavity_components_dropped: u32,
pub cavity_triangles_dropped: u32,
pub cell_iterations: u32,
pub passthrough: bool,
}
pub fn simplify_mesh(mesh: &Mesh, opts: &SimplifyOptions) -> (Mesh, SimplifyStats) {
let mut stats = SimplifyStats {
tris_before: (mesh.indices.len() / 3) as u32,
..Default::default()
};
if opts.boxify {
let out = boxify::box_from_positions_aabb(mesh);
stats.tris_after = (out.indices.len() / 3) as u32;
return (out, stats);
}
if mesh.is_empty() || stats.tris_before < opts.min_triangles {
stats.tris_after = stats.tris_before;
stats.passthrough = true;
return (mesh.clone(), stats);
}
let mut out = mesh.clone();
if opts.drop_cavities {
let cavity = cavities::drop_enclosed_cavities(&mut out, opts.weld_eps);
stats.cavity_components_dropped = cavity.components_dropped;
stats.cavity_triangles_dropped = cavity.triangles_dropped;
}
if let Some(ratio) = opts.target_ratio {
let (clustered, iterations) = cluster::cluster_to_ratio(&out, ratio, opts.min_triangles);
out = clustered;
stats.cell_iterations = iterations;
}
out.drop_degenerate_triangles();
out.clean_degenerate();
stats.tris_after = (out.indices.len() / 3) as u32;
(out, stats)
}
#[cfg(test)]
mod tests {
use super::*;
fn soup_box(min: [f32; 3], max: [f32; 3]) -> Mesh {
let mesh = Mesh::new();
let mut boxed = boxify::box_from_corners(&mesh, min, max);
boxed.local_bounds = Some([min[0], min[1], min[2], max[0], max[1], max[2]]);
boxed
}
fn merged(a: &Mesh, b: &Mesh) -> Mesh {
let mut m = a.clone();
m.merge(b);
m
}
fn soup_sphere(center: [f32; 3], radius: f32, rings: u32, segs: u32) -> Mesh {
let mut mesh = Mesh::new();
let pt = |r: u32, s: u32| -> [f32; 3] {
let theta = std::f32::consts::PI * (r as f32) / (rings as f32);
let phi = 2.0 * std::f32::consts::PI * (s as f32) / (segs as f32);
[
center[0] + radius * theta.sin() * phi.cos(),
center[1] + radius * theta.sin() * phi.sin(),
center[2] + radius * theta.cos(),
]
};
let mut push_tri = |a: [f32; 3], b: [f32; 3], c: [f32; 3]| {
let base = (mesh.positions.len() / 3) as u32;
for v in [a, b, c] {
mesh.positions.extend_from_slice(&v);
mesh.normals.extend_from_slice(&[0.0, 0.0, 1.0]);
}
mesh.indices.extend_from_slice(&[base, base + 1, base + 2]);
};
for r in 0..rings {
for s in 0..segs {
let (a, b, c, d) = (pt(r, s), pt(r + 1, s), pt(r + 1, s + 1), pt(r, s + 1));
push_tri(a, b, c);
push_tri(a, c, d);
}
}
mesh
}
#[test]
fn cavity_inside_box_is_dropped_and_outer_kept_bit_identical() {
let outer = soup_box([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
let inner = soup_box([4.0, 4.0, 4.0], [6.0, 6.0, 6.0]);
let combined = merged(&outer, &inner);
let opts = SimplifyOptions {
target_ratio: None,
drop_cavities: true,
boxify: false,
min_triangles: 1,
weld_eps: 1e-6,
};
let (out, stats) = simplify_mesh(&combined, &opts);
assert_eq!(stats.cavity_components_dropped, 1);
assert_eq!(out.indices.len() / 3, 12, "only the outer box survives");
assert_eq!(out.positions, combined.positions);
}
#[test]
fn component_outside_is_kept() {
let a = soup_box([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
let b = soup_box([20.0, 0.0, 0.0], [30.0, 10.0, 10.0]);
let combined = merged(&a, &b);
let opts = SimplifyOptions {
target_ratio: None,
drop_cavities: true,
boxify: false,
min_triangles: 1,
weld_eps: 1e-6,
};
let (out, stats) = simplify_mesh(&combined, &opts);
assert_eq!(stats.cavity_components_dropped, 0);
assert_eq!(out.indices.len() / 3, 24);
}
#[test]
fn non_watertight_outer_disables_cavity_removal() {
let mut outer = soup_box([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
outer.indices.drain(6..12);
let inner = soup_box([4.0, 4.0, 4.0], [6.0, 6.0, 6.0]);
let combined = merged(&outer, &inner);
let opts = SimplifyOptions {
target_ratio: None,
drop_cavities: true,
boxify: false,
min_triangles: 1,
weld_eps: 1e-6,
};
let (_, stats) = simplify_mesh(&combined, &opts);
assert_eq!(
stats.cavity_components_dropped, 0,
"open outer shell must disable cavity removal (conservative-keep)"
);
}
fn soup_l_prism() -> Mesh {
let mut mesh = Mesh::new();
let mut quad = |a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]| {
let base = (mesh.positions.len() / 3) as u32;
for v in [a, b, c, d] {
mesh.positions.extend_from_slice(&v);
mesh.normals.extend_from_slice(&[0.0, 0.0, 1.0]);
}
mesh.indices
.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
};
let rects: [[f32; 4]; 3] = [
[0.0, 0.0, 6.0, 4.0],
[6.0, 0.0, 10.0, 4.0],
[6.0, 4.0, 10.0, 10.0],
];
for z in [0.0f32, 10.0f32] {
for [x0, y0, x1, y1] in rects {
quad([x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z]);
}
}
let ring: [[f32; 2]; 9] = [
[0.0, 0.0],
[6.0, 0.0],
[10.0, 0.0],
[10.0, 4.0],
[10.0, 10.0],
[6.0, 10.0],
[6.0, 4.0],
[0.0, 4.0],
[0.0, 0.0],
];
for w in ring.windows(2) {
let ([ax, ay], [bx, by]) = (w[0], w[1]);
quad([ax, ay, 0.0], [bx, by, 0.0], [bx, by, 10.0], [ax, ay, 10.0]);
}
mesh
}
#[test]
fn partially_protruding_component_is_kept_in_nonconvex_outer() {
let outer = soup_l_prism();
let outer_tris = outer.indices.len() / 3;
let protruding = soup_box([2.0, 0.5, 4.0], [4.0, 5.0, 6.0]);
let combined = merged(&outer, &protruding);
let opts = SimplifyOptions {
target_ratio: None,
drop_cavities: true,
boxify: false,
min_triangles: 1,
weld_eps: 1e-6,
};
let (out, stats) = simplify_mesh(&combined, &opts);
assert_eq!(
stats.cavity_components_dropped, 0,
"a component protruding out of the outer solid must never be dropped"
);
assert_eq!(out.indices.len() / 3, outer_tris + 12);
}
#[test]
fn fully_enclosed_component_in_nonconvex_outer_is_dropped() {
let outer = soup_l_prism();
let outer_tris = outer.indices.len() / 3;
let cavity = soup_box([1.0, 1.0, 4.0], [2.0, 2.0, 5.0]);
let combined = merged(&outer, &cavity);
let opts = SimplifyOptions {
target_ratio: None,
drop_cavities: true,
boxify: false,
min_triangles: 1,
weld_eps: 1e-6,
};
let (out, stats) = simplify_mesh(&combined, &opts);
assert_eq!(stats.cavity_components_dropped, 1);
assert_eq!(out.indices.len() / 3, outer_tris);
}
#[test]
fn clustering_reaches_target_ratio_on_dense_sphere() {
let sphere = soup_sphere([0.0, 0.0, 0.0], 1.0, 48, 48);
let before = sphere.indices.len() / 3;
assert!(before > 4000);
let opts = SimplifyOptions {
target_ratio: Some(0.25),
drop_cavities: false,
boxify: false,
min_triangles: 32,
weld_eps: 1e-6,
};
let (out, stats) = simplify_mesh(&sphere, &opts);
let after = out.indices.len() / 3;
assert!(after > 0, "clustering must not empty the mesh");
assert!(
after as f32 <= before as f32 * 0.25,
"expected <= 25% of {before} triangles, got {after}"
);
assert!(stats.cell_iterations >= 1);
}
#[test]
fn small_mesh_passes_through() {
let small = soup_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
let (out, stats) = simplify_mesh(&small, &SimplifyOptions::for_level(1));
assert!(stats.passthrough);
assert_eq!(out.positions, small.positions);
assert_eq!(out.indices, small.indices);
}
#[test]
fn boxify_emits_12_triangles_matching_positions_aabb() {
let sphere = soup_sphere([5.0, -3.0, 2.0], 2.0, 16, 16);
let (smin, smax) = sphere.bounds();
let (out, stats) = simplify_mesh(&sphere, &SimplifyOptions::for_level(5));
assert_eq!(out.indices.len() / 3, 12);
assert_eq!(stats.tris_after, 12);
let (bmin, bmax) = out.bounds();
assert_eq!((bmin, bmax), (smin, smax));
}
#[test]
fn frame_metadata_is_carried() {
let mut sphere = soup_sphere([0.0, 0.0, 0.0], 1.0, 32, 32);
sphere.origin = [100.0, 200.0, 300.0];
sphere.rtc_applied = true;
sphere.local_bounds = Some([-1.0, -1.0, -1.0, 1.0, 1.0, 1.0]);
sphere.local_to_world = Some([
1.0, 0.0, 0.0, 7.0, 0.0, 1.0, 0.0, 8.0, 0.0, 0.0, 1.0, 9.0, 0.0, 0.0, 0.0, 1.0,
]);
for level in [1u8, 5u8] {
let (out, _) = simplify_mesh(&sphere, &SimplifyOptions::for_level(level));
assert_eq!(out.origin, sphere.origin, "level {level}");
assert!(out.rtc_applied, "level {level}");
assert_eq!(out.local_bounds, sphere.local_bounds, "level {level}");
assert_eq!(out.local_to_world, sphere.local_to_world, "level {level}");
}
}
}