1mod boxify;
17mod cavities;
18mod cluster;
19mod ray_parity;
20
21use crate::mesh::Mesh;
22
23#[derive(Debug, Clone)]
25pub struct SimplifyOptions {
26 pub target_ratio: Option<f32>,
29 pub drop_cavities: bool,
31 pub boxify: bool,
34 pub min_triangles: u32,
37 pub weld_eps: f32,
39}
40
41impl SimplifyOptions {
42 pub fn for_level(level: u8) -> Self {
46 let target_ratio = match level {
47 0 | 1 => Some(0.5),
48 2 => Some(0.25),
49 3 => Some(0.10),
50 4 => Some(0.03),
51 _ => None,
52 };
53 Self {
54 target_ratio,
55 drop_cavities: level < 5,
56 boxify: level >= 5,
57 min_triangles: 32,
58 weld_eps: 1e-6,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct SimplifyStats {
66 pub tris_before: u32,
67 pub tris_after: u32,
68 pub cavity_components_dropped: u32,
69 pub cavity_triangles_dropped: u32,
70 pub cell_iterations: u32,
73 pub passthrough: bool,
76}
77
78pub fn simplify_mesh(mesh: &Mesh, opts: &SimplifyOptions) -> (Mesh, SimplifyStats) {
87 let mut stats = SimplifyStats {
88 tris_before: (mesh.indices.len() / 3) as u32,
89 ..Default::default()
90 };
91
92 if opts.boxify {
93 let out = boxify::box_from_positions_aabb(mesh);
94 stats.tris_after = (out.indices.len() / 3) as u32;
95 return (out, stats);
96 }
97
98 if mesh.is_empty() || stats.tris_before < opts.min_triangles {
99 stats.tris_after = stats.tris_before;
100 stats.passthrough = true;
101 return (mesh.clone(), stats);
102 }
103
104 let mut out = mesh.clone();
105
106 if opts.drop_cavities {
107 let cavity = cavities::drop_enclosed_cavities(&mut out, opts.weld_eps);
108 stats.cavity_components_dropped = cavity.components_dropped;
109 stats.cavity_triangles_dropped = cavity.triangles_dropped;
110 }
111
112 if let Some(ratio) = opts.target_ratio {
113 let (clustered, iterations) = cluster::cluster_to_ratio(&out, ratio, opts.min_triangles);
114 out = clustered;
115 stats.cell_iterations = iterations;
116 }
117
118 out.drop_degenerate_triangles();
119 out.clean_degenerate();
120
121 stats.tris_after = (out.indices.len() / 3) as u32;
122 (out, stats)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn soup_box(min: [f32; 3], max: [f32; 3]) -> Mesh {
132 let mesh = Mesh::new();
133 let mut boxed = boxify::box_from_corners(&mesh, min, max);
134 boxed.local_bounds = Some([min[0], min[1], min[2], max[0], max[1], max[2]]);
136 boxed
137 }
138
139 fn merged(a: &Mesh, b: &Mesh) -> Mesh {
140 let mut m = a.clone();
141 m.merge(b);
142 m
143 }
144
145 fn soup_sphere(center: [f32; 3], radius: f32, rings: u32, segs: u32) -> Mesh {
147 let mut mesh = Mesh::new();
148 let pt = |r: u32, s: u32| -> [f32; 3] {
149 let theta = std::f32::consts::PI * (r as f32) / (rings as f32);
150 let phi = 2.0 * std::f32::consts::PI * (s as f32) / (segs as f32);
151 [
152 center[0] + radius * theta.sin() * phi.cos(),
153 center[1] + radius * theta.sin() * phi.sin(),
154 center[2] + radius * theta.cos(),
155 ]
156 };
157 let mut push_tri = |a: [f32; 3], b: [f32; 3], c: [f32; 3]| {
158 let base = (mesh.positions.len() / 3) as u32;
159 for v in [a, b, c] {
160 mesh.positions.extend_from_slice(&v);
161 mesh.normals.extend_from_slice(&[0.0, 0.0, 1.0]);
162 }
163 mesh.indices.extend_from_slice(&[base, base + 1, base + 2]);
164 };
165 for r in 0..rings {
166 for s in 0..segs {
167 let (a, b, c, d) = (pt(r, s), pt(r + 1, s), pt(r + 1, s + 1), pt(r, s + 1));
168 push_tri(a, b, c);
169 push_tri(a, c, d);
170 }
171 }
172 mesh
173 }
174
175 #[test]
176 fn cavity_inside_box_is_dropped_and_outer_kept_bit_identical() {
177 let outer = soup_box([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
178 let inner = soup_box([4.0, 4.0, 4.0], [6.0, 6.0, 6.0]);
179 let combined = merged(&outer, &inner);
180
181 let opts = SimplifyOptions {
182 target_ratio: None,
183 drop_cavities: true,
184 boxify: false,
185 min_triangles: 1,
186 weld_eps: 1e-6,
187 };
188 let (out, stats) = simplify_mesh(&combined, &opts);
189
190 assert_eq!(stats.cavity_components_dropped, 1);
191 assert_eq!(out.indices.len() / 3, 12, "only the outer box survives");
192 assert_eq!(out.positions, combined.positions);
195 }
196
197 #[test]
198 fn component_outside_is_kept() {
199 let a = soup_box([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
200 let b = soup_box([20.0, 0.0, 0.0], [30.0, 10.0, 10.0]);
201 let combined = merged(&a, &b);
202
203 let opts = SimplifyOptions {
204 target_ratio: None,
205 drop_cavities: true,
206 boxify: false,
207 min_triangles: 1,
208 weld_eps: 1e-6,
209 };
210 let (out, stats) = simplify_mesh(&combined, &opts);
211 assert_eq!(stats.cavity_components_dropped, 0);
212 assert_eq!(out.indices.len() / 3, 24);
213 }
214
215 #[test]
216 fn non_watertight_outer_disables_cavity_removal() {
217 let mut outer = soup_box([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
218 outer.indices.drain(6..12);
221 let inner = soup_box([4.0, 4.0, 4.0], [6.0, 6.0, 6.0]);
222 let combined = merged(&outer, &inner);
223
224 let opts = SimplifyOptions {
225 target_ratio: None,
226 drop_cavities: true,
227 boxify: false,
228 min_triangles: 1,
229 weld_eps: 1e-6,
230 };
231 let (_, stats) = simplify_mesh(&combined, &opts);
232 assert_eq!(
233 stats.cavity_components_dropped, 0,
234 "open outer shell must disable cavity removal (conservative-keep)"
235 );
236 }
237
238 fn soup_l_prism() -> Mesh {
244 let mut mesh = Mesh::new();
245 let mut quad = |a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]| {
246 let base = (mesh.positions.len() / 3) as u32;
247 for v in [a, b, c, d] {
248 mesh.positions.extend_from_slice(&v);
249 mesh.normals.extend_from_slice(&[0.0, 0.0, 1.0]);
250 }
251 mesh.indices
252 .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
253 };
254 let rects: [[f32; 4]; 3] = [
256 [0.0, 0.0, 6.0, 4.0],
257 [6.0, 0.0, 10.0, 4.0],
258 [6.0, 4.0, 10.0, 10.0],
259 ];
260 for z in [0.0f32, 10.0f32] {
261 for [x0, y0, x1, y1] in rects {
262 quad([x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z]);
263 }
264 }
265 let ring: [[f32; 2]; 9] = [
267 [0.0, 0.0],
268 [6.0, 0.0],
269 [10.0, 0.0],
270 [10.0, 4.0],
271 [10.0, 10.0],
272 [6.0, 10.0],
273 [6.0, 4.0],
274 [0.0, 4.0],
275 [0.0, 0.0],
276 ];
277 for w in ring.windows(2) {
278 let ([ax, ay], [bx, by]) = (w[0], w[1]);
279 quad([ax, ay, 0.0], [bx, by, 0.0], [bx, by, 10.0], [ax, ay, 10.0]);
280 }
281 mesh
282 }
283
284 #[test]
285 fn partially_protruding_component_is_kept_in_nonconvex_outer() {
286 let outer = soup_l_prism();
290 let outer_tris = outer.indices.len() / 3;
291 let protruding = soup_box([2.0, 0.5, 4.0], [4.0, 5.0, 6.0]);
292 let combined = merged(&outer, &protruding);
293
294 let opts = SimplifyOptions {
295 target_ratio: None,
296 drop_cavities: true,
297 boxify: false,
298 min_triangles: 1,
299 weld_eps: 1e-6,
300 };
301 let (out, stats) = simplify_mesh(&combined, &opts);
302 assert_eq!(
303 stats.cavity_components_dropped, 0,
304 "a component protruding out of the outer solid must never be dropped"
305 );
306 assert_eq!(out.indices.len() / 3, outer_tris + 12);
307 }
308
309 #[test]
310 fn fully_enclosed_component_in_nonconvex_outer_is_dropped() {
311 let outer = soup_l_prism();
312 let outer_tris = outer.indices.len() / 3;
313 let cavity = soup_box([1.0, 1.0, 4.0], [2.0, 2.0, 5.0]);
314 let combined = merged(&outer, &cavity);
315
316 let opts = SimplifyOptions {
317 target_ratio: None,
318 drop_cavities: true,
319 boxify: false,
320 min_triangles: 1,
321 weld_eps: 1e-6,
322 };
323 let (out, stats) = simplify_mesh(&combined, &opts);
324 assert_eq!(stats.cavity_components_dropped, 1);
325 assert_eq!(out.indices.len() / 3, outer_tris);
326 }
327
328 #[test]
329 fn clustering_reaches_target_ratio_on_dense_sphere() {
330 let sphere = soup_sphere([0.0, 0.0, 0.0], 1.0, 48, 48);
331 let before = sphere.indices.len() / 3;
332 assert!(before > 4000);
333
334 let opts = SimplifyOptions {
335 target_ratio: Some(0.25),
336 drop_cavities: false,
337 boxify: false,
338 min_triangles: 32,
339 weld_eps: 1e-6,
340 };
341 let (out, stats) = simplify_mesh(&sphere, &opts);
342 let after = out.indices.len() / 3;
343 assert!(after > 0, "clustering must not empty the mesh");
344 assert!(
345 after as f32 <= before as f32 * 0.25,
346 "expected <= 25% of {before} triangles, got {after}"
347 );
348 assert!(stats.cell_iterations >= 1);
349 }
350
351 #[test]
352 fn small_mesh_passes_through() {
353 let small = soup_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
354 let (out, stats) = simplify_mesh(&small, &SimplifyOptions::for_level(1));
355 assert!(stats.passthrough);
356 assert_eq!(out.positions, small.positions);
357 assert_eq!(out.indices, small.indices);
358 }
359
360 #[test]
361 fn boxify_emits_12_triangles_matching_positions_aabb() {
362 let sphere = soup_sphere([5.0, -3.0, 2.0], 2.0, 16, 16);
363 let (smin, smax) = sphere.bounds();
364 let (out, stats) = simplify_mesh(&sphere, &SimplifyOptions::for_level(5));
365 assert_eq!(out.indices.len() / 3, 12);
366 assert_eq!(stats.tris_after, 12);
367 let (bmin, bmax) = out.bounds();
368 assert_eq!((bmin, bmax), (smin, smax));
369 }
370
371 #[test]
372 fn frame_metadata_is_carried() {
373 let mut sphere = soup_sphere([0.0, 0.0, 0.0], 1.0, 32, 32);
374 sphere.origin = [100.0, 200.0, 300.0];
375 sphere.rtc_applied = true;
376 sphere.local_bounds = Some([-1.0, -1.0, -1.0, 1.0, 1.0, 1.0]);
377 sphere.local_to_world = Some([
378 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,
379 ]);
380
381 for level in [1u8, 5u8] {
382 let (out, _) = simplify_mesh(&sphere, &SimplifyOptions::for_level(level));
383 assert_eq!(out.origin, sphere.origin, "level {level}");
384 assert!(out.rtc_applied, "level {level}");
385 assert_eq!(out.local_bounds, sphere.local_bounds, "level {level}");
386 assert_eq!(out.local_to_world, sphere.local_to_world, "level {level}");
387 }
388 }
389}