axiolid_mesh/
audit.rs

1//! Deterministic structural triangle-mesh audit.
2//!
3//! The audit reports defects instead of rejecting dirty geometry. Open meshes can
4//! still support surface distance and intersection; callers that need a watertight
5//! solid must check [`MeshHealth::is_closed_two_manifold`].
6
7use std::collections::BTreeMap;
8use std::fmt;
9
10use axiolid_core::Tolerance;
11
12use crate::TriangleMeshView;
13
14/// Source-neutral structural facts about one triangle mesh.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct MeshHealth {
17    /// Number of addressable positions.
18    pub positions: usize,
19    /// Number of triangle records inspected.
20    pub triangles: usize,
21    /// Triangles with valid, finite, non-degenerate vertices.
22    pub usable_triangles: usize,
23    /// Number of invalid triangle-corner indices.
24    pub invalid_indices: usize,
25    /// Number of non-finite positions.
26    pub non_finite_positions: usize,
27    /// Number of triangles below the explicit area threshold.
28    pub degenerate_triangles: usize,
29    /// Undirected edges with exactly one usable incident triangle.
30    pub boundary_edges: usize,
31    /// Undirected edges with more than two usable incident triangles.
32    pub non_manifold_edges: usize,
33    /// Two-manifold edges whose incident faces use the same directed edge.
34    /// Such a mesh is closed but has inconsistent local winding, so signed
35    /// enclosed-volume reduction is not structurally trustworthy.
36    pub inconsistent_winding_edges: usize,
37    /// First `(triangle, source_index)` which could not address a position.
38    pub first_invalid_index: Option<(usize, u64)>,
39    /// First non-finite position index.
40    pub first_non_finite_position: Option<usize>,
41}
42
43impl MeshHealth {
44    /// Whether at least one triangle can safely support surface algorithms.
45    pub fn is_surface_usable(&self) -> bool {
46        self.usable_triangles > 0 && self.invalid_indices == 0 && self.non_finite_positions == 0
47    }
48
49    /// Whether the usable mesh is closed and two-manifold.
50    pub fn is_closed_two_manifold(&self) -> bool {
51        self.is_surface_usable()
52            && self.degenerate_triangles == 0
53            && self.boundary_edges == 0
54            && self.non_manifold_edges == 0
55            && self.inconsistent_winding_edges == 0
56    }
57}
58
59/// A bounded audit could not reserve its declared edge-record scratch.
60#[derive(Debug)]
61pub enum MeshAuditError {
62    /// `3 * triangle_count` or its byte size overflowed `usize`.
63    CapacityOverflow,
64    /// The allocator refused the exact edge-record reservation.
65    Allocation(std::collections::TryReserveError),
66}
67
68impl fmt::Display for MeshAuditError {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::CapacityOverflow => formatter.write_str("mesh audit edge count overflowed"),
72            Self::Allocation(error) => write!(formatter, "mesh audit allocation failed: {error}"),
73        }
74    }
75}
76
77impl std::error::Error for MeshAuditError {
78    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79        match self {
80            Self::CapacityOverflow => None,
81            Self::Allocation(error) => Some(error),
82        }
83    }
84}
85
86#[derive(Debug, Clone, Copy)]
87struct EdgeRecord {
88    low: u64,
89    high: u64,
90    direction: i8,
91}
92
93impl EdgeRecord {
94    /// Filler for the counting sort's scratch buffer. Every slot is
95    /// overwritten before it is read; this only avoids `unsafe`.
96    const EMPTY: Self = Self {
97        low: 0,
98        high: 0,
99        direction: 0,
100    };
101}
102
103/// Exact requested scratch bytes for [`try_audit_mesh`].
104///
105/// The bounded implementation stores at most three fixed-size edge records per
106/// source triangle. It may reserve a second buffer of the same size to run a
107/// counting sort instead of a comparison sort, so the bound covers two buffers:
108/// reporting only one would let a caller admit an audit this function then
109/// refuses. `None` means the count overflows.
110pub const fn audit_mesh_scratch_bytes(triangle_count: usize) -> Option<usize> {
111    match triangle_count.checked_mul(3) {
112        Some(edges) => match edges.checked_mul(std::mem::size_of::<EdgeRecord>()) {
113            Some(bytes) => bytes.checked_mul(2),
114            None => None,
115        },
116        None => None,
117    }
118}
119
120#[derive(Debug, Default)]
121struct EdgeSummary {
122    boundary: usize,
123    non_manifold: usize,
124    inconsistent_winding: usize,
125}
126
127trait EdgeSink {
128    fn record(&mut self, low: u64, high: u64, direction: i8);
129    fn summarize(&mut self) -> EdgeSummary;
130}
131
132#[derive(Debug)]
133struct VecEdgeSink {
134    edges: Vec<EdgeRecord>,
135    /// Second buffer for the counting sort's ping-pong. Reserved up
136    /// front so `summarize` cannot fail on allocation half way through.
137    scratch: Vec<EdgeRecord>,
138    /// Exclusive upper bound on vertex ids, i.e. the counting-sort key
139    /// space. Zero disables the counting sort.
140    buckets: usize,
141}
142
143impl VecEdgeSink {
144    fn try_new(triangle_count: usize, positions: usize) -> Result<Self, MeshAuditError> {
145        let count = triangle_count
146            .checked_mul(3)
147            .ok_or(MeshAuditError::CapacityOverflow)?;
148        let mut edges = Vec::new();
149        edges
150            .try_reserve_exact(count)
151            .map_err(MeshAuditError::Allocation)?;
152        // The counting sort needs a second buffer and a counts array of
153        // `positions` entries. It only pays when the key space is
154        // comparable to the edge count: a mesh with few triangles over a
155        // huge index space would spend more time clearing counts than
156        // sorting. Falling back to the comparison sort there keeps the
157        // pathological case from regressing.
158        let dense = positions <= count.saturating_mul(2).max(1024);
159        let fits = u32::try_from(count).is_ok();
160        let mut scratch = Vec::new();
161        let buckets = if dense && fits {
162            match scratch.try_reserve_exact(count) {
163                Ok(()) => {
164                    scratch.resize(count, EdgeRecord::EMPTY);
165                    positions
166                }
167                // Scratch is an optimisation, not a requirement: losing
168                // it costs speed, not correctness.
169                Err(_) => 0,
170            }
171        } else {
172            0
173        };
174        Ok(Self {
175            edges,
176            scratch,
177            buckets,
178        })
179    }
180}
181
182/// Group equal edge keys by counting sort on vertex ids.
183///
184/// Worth ~1.3x on `audit_mesh` and ~1.24x on the `measure` entry points
185/// at 81,920 triangles, measured against the comparison sort with a
186/// forced rebuild per arm.
187///
188/// Profiling attributed most of `audit_mesh` to sorting. The keys are
189/// vertex indices, bounded by the position count, so a two-pass counting
190/// sort replaces the comparison sort. Passes run high then low so the
191/// final order is by (low, high) -- the same order the old code produced,
192/// which keeps every downstream count identical rather than merely
193/// grouped.
194fn counting_sort_edges(edges: &mut Vec<EdgeRecord>, scratch: &mut Vec<EdgeRecord>, buckets: usize) {
195    debug_assert_eq!(scratch.len(), edges.len());
196    let mut counts: Vec<u32> = Vec::new();
197    for pass in 0..2 {
198        counts.clear();
199        counts.resize(buckets + 2, 0);
200        for edge in edges.iter() {
201            let key = if pass == 0 { edge.high } else { edge.low } as usize;
202            counts[key + 1] += 1;
203        }
204        for index in 0..=buckets {
205            counts[index + 1] += counts[index];
206        }
207        for edge in edges.iter() {
208            let key = if pass == 0 { edge.high } else { edge.low } as usize;
209            scratch[counts[key] as usize] = *edge;
210            counts[key] += 1;
211        }
212        std::mem::swap(edges, scratch);
213    }
214}
215
216impl EdgeSink for VecEdgeSink {
217    fn record(&mut self, low: u64, high: u64, direction: i8) {
218        self.edges.push(EdgeRecord {
219            low,
220            high,
221            direction,
222        });
223    }
224
225    fn summarize(&mut self) -> EdgeSummary {
226        if self.buckets > 0 && self.scratch.len() == self.edges.len() {
227            counting_sort_edges(&mut self.edges, &mut self.scratch, self.buckets);
228        } else {
229            self.edges
230                .sort_unstable_by_key(|edge| (edge.low, edge.high));
231        }
232        let mut summary = EdgeSummary::default();
233        let mut start = 0;
234        while start < self.edges.len() {
235            let key = (self.edges[start].low, self.edges[start].high);
236            let mut end = start + 1;
237            let mut winding = i128::from(self.edges[start].direction);
238            while end < self.edges.len() && (self.edges[end].low, self.edges[end].high) == key {
239                winding += i128::from(self.edges[end].direction);
240                end += 1;
241            }
242            match end - start {
243                1 => summary.boundary += 1,
244                2 if winding != 0 => summary.inconsistent_winding += 1,
245                count if count > 2 => summary.non_manifold += 1,
246                _ => {}
247            }
248            start = end;
249        }
250        summary
251    }
252}
253
254#[derive(Debug, Default)]
255struct MapEdgeSink {
256    edges: BTreeMap<(u64, u64), (usize, i128)>,
257}
258
259impl EdgeSink for MapEdgeSink {
260    fn record(&mut self, low: u64, high: u64, direction: i8) {
261        let entry = self.edges.entry((low, high)).or_default();
262        entry.0 = entry.0.saturating_add(1);
263        entry.1 += i128::from(direction);
264    }
265
266    fn summarize(&mut self) -> EdgeSummary {
267        EdgeSummary {
268            boundary: self
269                .edges
270                .values()
271                .filter(|&&(count, _)| count == 1)
272                .count(),
273            non_manifold: self.edges.values().filter(|&&(count, _)| count > 2).count(),
274            inconsistent_winding: self
275                .edges
276                .values()
277                .filter(|&&(count, winding)| count == 2 && winding != 0)
278                .count(),
279        }
280    }
281}
282
283/// Audit a triangle mesh with an explicit source-unit tolerance.
284///
285/// A triangle is degenerate when its doubled area is at most
286/// `tolerance.linear()²`; the implementation compares squared values to avoid a
287/// square root. Pass [`Tolerance::ZERO`] for exact-coordinate compatibility.
288///
289/// This compatibility entry point falls back to the historical map-backed
290/// audit if the bounded vector reservation fails. Operations with an explicit
291/// memory budget should use [`try_audit_mesh`] and preflight
292/// [`audit_mesh_scratch_bytes`] instead.
293pub fn audit_mesh<M: TriangleMeshView + ?Sized>(mesh: &M, tolerance: Tolerance) -> MeshHealth {
294    match VecEdgeSink::try_new(mesh.triangle_count(), mesh.position_count()) {
295        Ok(edges) => audit_with_edges(mesh, tolerance, edges),
296        Err(_) => audit_with_edges(mesh, tolerance, MapEdgeSink::default()),
297    }
298}
299
300/// Audit using one fallible, precomputable edge-record allocation.
301///
302/// Callers can refuse before allocation by comparing
303/// [`audit_mesh_scratch_bytes`] with their memory budget.
304pub fn try_audit_mesh<M: TriangleMeshView + ?Sized>(
305    mesh: &M,
306    tolerance: Tolerance,
307) -> Result<MeshHealth, MeshAuditError> {
308    let edges = VecEdgeSink::try_new(mesh.triangle_count(), mesh.position_count())?;
309    Ok(audit_with_edges(mesh, tolerance, edges))
310}
311
312fn audit_with_edges<M: TriangleMeshView + ?Sized, E: EdgeSink>(
313    mesh: &M,
314    tolerance: Tolerance,
315    mut edges: E,
316) -> MeshHealth {
317    let positions = mesh.position_count();
318    let triangles = mesh.triangle_count();
319    let first_non_finite_position = (0..positions).find(|&index| !mesh.position(index).is_finite());
320    let non_finite_positions = (0..positions)
321        .filter(|&index| !mesh.position(index).is_finite())
322        .count();
323    let mut invalid_indices = 0;
324    let mut first_invalid_index = None;
325    let mut degenerate_triangles = 0;
326    let mut usable_triangles = 0;
327    let squared_double_area_limit = tolerance.linear().powi(4);
328
329    for triangle_index in 0..triangles {
330        let triangle = mesh.triangle(triangle_index);
331        let converted = triangle.map(|source_index| usize::try_from(source_index).ok());
332        let [Some(a_index), Some(b_index), Some(c_index)] = converted else {
333            for source_index in triangle {
334                if usize::try_from(source_index).is_err() {
335                    invalid_indices += 1;
336                    first_invalid_index.get_or_insert((triangle_index, source_index));
337                }
338            }
339            continue;
340        };
341        let indices = [a_index, b_index, c_index];
342        let mut valid = true;
343        for (corner, &index) in indices.iter().enumerate() {
344            if index >= positions {
345                invalid_indices += 1;
346                first_invalid_index.get_or_insert((triangle_index, triangle[corner]));
347                valid = false;
348            }
349        }
350        if !valid {
351            continue;
352        }
353
354        let [a, b, c] = indices.map(|index| mesh.position(index));
355        if !a.is_finite() || !b.is_finite() || !c.is_finite() {
356            continue;
357        }
358        let squared_double_area = (b - a).cross(c - a).length_squared();
359        if squared_double_area <= squared_double_area_limit {
360            degenerate_triangles += 1;
361            continue;
362        }
363        usable_triangles += 1;
364        for (left, right) in [
365            (triangle[0], triangle[1]),
366            (triangle[1], triangle[2]),
367            (triangle[2], triangle[0]),
368        ] {
369            let direction = if left < right { 1 } else { -1 };
370            edges.record(left.min(right), left.max(right), direction);
371        }
372    }
373
374    let edge_summary = edges.summarize();
375    MeshHealth {
376        positions,
377        triangles,
378        usable_triangles,
379        invalid_indices,
380        non_finite_positions,
381        degenerate_triangles,
382        boundary_edges: edge_summary.boundary,
383        non_manifold_edges: edge_summary.non_manifold,
384        inconsistent_winding_edges: edge_summary.inconsistent_winding,
385        first_invalid_index,
386        first_non_finite_position,
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::TriMesh;
394    use axiolid_core::Point3;
395
396    /// The bound covers BOTH buffers the counting sort may hold at once.
397    /// Charging for one would let a caller admit an audit that then
398    /// refuses its own allocation.
399    #[test]
400    fn scratch_bound_charges_two_buffers_of_three_edge_records_per_triangle() {
401        assert_eq!(
402            audit_mesh_scratch_bytes(7),
403            7usize
404                .checked_mul(3)
405                .and_then(|count| count.checked_mul(std::mem::size_of::<EdgeRecord>()))
406                .and_then(|bytes| bytes.checked_mul(2))
407        );
408        let sink = VecEdgeSink::try_new(7, 16).expect("small bounded audit allocation");
409        assert!(sink.edges.capacity() >= 21);
410    }
411
412    /// The counting sort must order records exactly as the comparison
413    /// sort did. Grouping alone would be enough for `summarize`, but
414    /// proving full order equality is stronger and catches a pass
415    /// ordering mistake that grouping would hide.
416    #[test]
417    fn counting_sort_matches_comparison_sort() {
418        let buckets = 64usize;
419        let mut seed = 0x9E3779B97F4A7C15u64;
420        let mut next = move || {
421            seed ^= seed << 13;
422            seed ^= seed >> 7;
423            seed ^= seed << 17;
424            seed
425        };
426        let mut edges: Vec<EdgeRecord> = (0..4096)
427            .map(|_| {
428                let a = (next() as usize % buckets) as u64;
429                let b = (next() as usize % buckets) as u64;
430                EdgeRecord {
431                    low: a.min(b),
432                    high: a.max(b),
433                    direction: if next() % 2 == 0 { 1 } else { -1 },
434                }
435            })
436            .collect();
437        let mut expected = edges.clone();
438        expected.sort_unstable_by_key(|e| (e.low, e.high));
439
440        let mut scratch = vec![EdgeRecord::EMPTY; edges.len()];
441        counting_sort_edges(&mut edges, &mut scratch, buckets);
442
443        let keys: Vec<_> = edges.iter().map(|e| (e.low, e.high)).collect();
444        let want: Vec<_> = expected.iter().map(|e| (e.low, e.high)).collect();
445        assert_eq!(
446            keys, want,
447            "counting sort must reproduce the comparison order"
448        );
449    }
450
451    /// Both sinks must agree on a real mesh. The map sink is the
452    /// allocation-failure fallback, so a divergence here would mean the
453    /// audit silently reports different health under memory pressure.
454    #[test]
455    fn both_sinks_agree_on_a_defective_mesh() {
456        // Two triangles sharing an edge, plus a third fin on that same
457        // edge: boundary, non-manifold and winding counts all exercised.
458        let positions = vec![
459            Point3::new(0.0, 0.0, 0.0),
460            Point3::new(1.0, 0.0, 0.0),
461            Point3::new(0.0, 1.0, 0.0),
462            Point3::new(0.0, 0.0, 1.0),
463            Point3::new(0.0, -1.0, 0.0),
464        ];
465        let indices = vec![0, 1, 2, 0, 1, 3, 0, 1, 4];
466        let mesh = TriMesh::new(positions, indices);
467
468        let tolerance = Tolerance::MILLIMETRE;
469        let fast = VecEdgeSink::try_new(mesh.triangle_count(), mesh.position_count())
470            .expect("fixture allocation");
471        assert!(
472            fast.buckets > 0,
473            "counting sort must be active for this fixture"
474        );
475        let via_counting = audit_with_edges(&mesh, tolerance, fast);
476        let via_map = audit_with_edges(&mesh, tolerance, MapEdgeSink::default());
477        assert_eq!(via_counting, via_map, "sinks must report identical health");
478    }
479}