Skip to main content

ifc_lite_geometry/simplify/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Per-element mesh simplification ("demesher").
6//!
7//! Reduces an element mesh's triangle count for lightweight re-export:
8//! enclosed-cavity removal (drops shells fully contained inside the outer
9//! shell — bolt holes, internal machining detail), grid vertex-clustering
10//! decimation (Rust port of the renderer's LOD1 `simplifyIndicesByClustering`,
11//! which is proven on this pipeline's unwelded flat-shaded facet soup), and
12//! bounding-box collapse. All passes key on vertex POSITION, never on index
13//! topology, because the pipeline deliberately emits per-face duplicated
14//! vertices (issue #846).
15
16mod boxify;
17mod cavities;
18mod cluster;
19mod ray_parity;
20
21use crate::mesh::Mesh;
22
23/// Simplification tuning for one element mesh.
24#[derive(Debug, Clone)]
25pub struct SimplifyOptions {
26    /// Target triangle ratio for clustering decimation (e.g. 0.5 halves the
27    /// count). `None` skips the clustering pass.
28    pub target_ratio: Option<f32>,
29    /// Drop connected components fully enclosed inside the outer shell.
30    pub drop_cavities: bool,
31    /// Replace the mesh with a 12-triangle axis-aligned bounding box
32    /// (positions-frame AABB). Overrides the other passes.
33    pub boxify: bool,
34    /// Meshes below this triangle count pass through untouched (except
35    /// `boxify`, which always applies).
36    pub min_triangles: u32,
37    /// Position-weld bucket size in metres for cavity connectivity.
38    pub weld_eps: f32,
39}
40
41impl SimplifyOptions {
42    /// Preset for the demesher's escalation levels 1..=5 (each button press
43    /// escalates one level): 1-4 = cavity removal + clustering at a shrinking
44    /// triangle ratio, 5 = bounding-box collapse. Levels above 5 clamp to 5.
45    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/// What `simplify_mesh` did to one element mesh.
64#[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    /// Clustering cell-size growth iterations taken to reach the target ratio
71    /// (0 when the pass was skipped or the input was already at target).
72    pub cell_iterations: u32,
73    /// True when the mesh was returned untouched (below `min_triangles`, or
74    /// empty).
75    pub passthrough: bool,
76}
77
78/// Simplify one element mesh. Returns a new mesh carrying the input's
79/// placement/frame metadata (`origin`, `rtc_applied`, `local_bounds`,
80/// `local_to_world`) so world reconstruction (`world = origin + position
81/// (+ rtc)`) and the inverse-placement export path keep working.
82///
83/// A clustered result comes back with an EMPTY normal buffer (the pass
84/// changes topology, so the input's per-face normals no longer apply);
85/// consumers rebuild normals when `normals.len() != positions.len()`.
86pub 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    /// Axis-aligned box as unwelded facet soup (24 verts, 12 tris), the shape
130    /// the production pipeline emits (issue #846).
131    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        // box_from_corners carries frame metadata from `mesh` (defaults here).
135        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    /// Dense tessellated sphere as facet soup (per-triangle vertices).
146    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        // Index-subset contract: surviving triangles reference the ORIGINAL
193        // vertex buffer untouched.
194        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        // Rip the top face off (last 4 triangles of the box layout are the
219        // left/right faces; drop the two "top" triangles at positions 2,3).
220        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    /// Closed L-shaped prism (non-convex): footprint = [0,10]x[0,4] plus
239    /// [6,10]x[4,10], extruded z 0..10, as quad soup with exactly matching
240    /// corner coordinates (welds into one watertight component, no
241    /// T-junctions). The notch [0,6)x(4,10] is inside the outer AABB but
242    /// OUTSIDE the solid.
243    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        // Caps: three rects per z, split so shared edges match exactly.
255        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        // Walls along the boundary polygon, split at cap-rect corners.
266        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        // Candidate whose AABB centre (3, 2.75, 5) is INSIDE the L's solid
287        // arm but whose +y end reaches into the notch void — the centre-only
288        // classification would drop it and punch away visible geometry.
289        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}