Skip to main content

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    /// Edges whose net direction over all usable incident triangles is not zero.
126    unbalanced: usize,
127    /// Smallest `(low, high)` among the unbalanced edges.
128    first_unbalanced: Option<(u64, u64)>,
129}
130
131trait EdgeSink {
132    fn record(&mut self, low: u64, high: u64, direction: i8);
133    fn summarize(&mut self) -> EdgeSummary;
134}
135
136#[derive(Debug)]
137struct VecEdgeSink {
138    edges: Vec<EdgeRecord>,
139    /// Second buffer for the counting sort's ping-pong. Reserved up
140    /// front so `summarize` cannot fail on allocation half way through.
141    scratch: Vec<EdgeRecord>,
142    /// Exclusive upper bound on vertex ids, i.e. the counting-sort key
143    /// space. Zero disables the counting sort.
144    buckets: usize,
145}
146
147impl VecEdgeSink {
148    fn try_new(triangle_count: usize, positions: usize) -> Result<Self, MeshAuditError> {
149        let count = triangle_count
150            .checked_mul(3)
151            .ok_or(MeshAuditError::CapacityOverflow)?;
152        let mut edges = Vec::new();
153        edges
154            .try_reserve_exact(count)
155            .map_err(MeshAuditError::Allocation)?;
156        // The counting sort needs a second buffer and a counts array of
157        // `positions` entries. It only pays when the key space is
158        // comparable to the edge count: a mesh with few triangles over a
159        // huge index space would spend more time clearing counts than
160        // sorting. Falling back to the comparison sort there keeps the
161        // pathological case from regressing.
162        let dense = positions <= count.saturating_mul(2).max(1024);
163        let fits = u32::try_from(count).is_ok();
164        let mut scratch = Vec::new();
165        let buckets = if dense && fits {
166            match scratch.try_reserve_exact(count) {
167                Ok(()) => {
168                    scratch.resize(count, EdgeRecord::EMPTY);
169                    positions
170                }
171                // Scratch is an optimisation, not a requirement: losing
172                // it costs speed, not correctness.
173                Err(_) => 0,
174            }
175        } else {
176            0
177        };
178        Ok(Self {
179            edges,
180            scratch,
181            buckets,
182        })
183    }
184}
185
186/// Group equal edge keys by counting sort on vertex ids.
187///
188/// Worth ~1.3x on `audit_mesh` and ~1.24x on the `measure` entry points
189/// at 81,920 triangles, measured against the comparison sort with a
190/// forced rebuild per arm.
191///
192/// Profiling attributed most of `audit_mesh` to sorting. The keys are
193/// vertex indices, bounded by the position count, so a two-pass counting
194/// sort replaces the comparison sort. Passes run high then low so the
195/// final order is by (low, high) -- the same order the old code produced,
196/// which keeps every downstream count identical rather than merely
197/// grouped.
198fn counting_sort_edges(edges: &mut Vec<EdgeRecord>, scratch: &mut Vec<EdgeRecord>, buckets: usize) {
199    debug_assert_eq!(scratch.len(), edges.len());
200    let mut counts: Vec<u32> = Vec::new();
201    for pass in 0..2 {
202        counts.clear();
203        counts.resize(buckets + 2, 0);
204        for edge in edges.iter() {
205            let key = if pass == 0 { edge.high } else { edge.low } as usize;
206            counts[key + 1] += 1;
207        }
208        for index in 0..=buckets {
209            counts[index + 1] += counts[index];
210        }
211        for edge in edges.iter() {
212            let key = if pass == 0 { edge.high } else { edge.low } as usize;
213            scratch[counts[key] as usize] = *edge;
214            counts[key] += 1;
215        }
216        std::mem::swap(edges, scratch);
217    }
218}
219
220impl EdgeSink for VecEdgeSink {
221    fn record(&mut self, low: u64, high: u64, direction: i8) {
222        self.edges.push(EdgeRecord {
223            low,
224            high,
225            direction,
226        });
227    }
228
229    fn summarize(&mut self) -> EdgeSummary {
230        if self.buckets > 0 && self.scratch.len() == self.edges.len() {
231            counting_sort_edges(&mut self.edges, &mut self.scratch, self.buckets);
232        } else {
233            self.edges
234                .sort_unstable_by_key(|edge| (edge.low, edge.high));
235        }
236        let mut summary = EdgeSummary::default();
237        let mut start = 0;
238        while start < self.edges.len() {
239            let key = (self.edges[start].low, self.edges[start].high);
240            let mut end = start + 1;
241            let mut winding = i128::from(self.edges[start].direction);
242            while end < self.edges.len() && (self.edges[end].low, self.edges[end].high) == key {
243                winding += i128::from(self.edges[end].direction);
244                end += 1;
245            }
246            if winding != 0 {
247                summary.unbalanced += 1;
248                summary.first_unbalanced.get_or_insert(key);
249            }
250            match end - start {
251                1 => summary.boundary += 1,
252                2 if winding != 0 => summary.inconsistent_winding += 1,
253                count if count > 2 => summary.non_manifold += 1,
254                _ => {}
255            }
256            start = end;
257        }
258        summary
259    }
260}
261
262#[derive(Debug, Default)]
263struct MapEdgeSink {
264    edges: BTreeMap<(u64, u64), (usize, i128)>,
265}
266
267impl EdgeSink for MapEdgeSink {
268    fn record(&mut self, low: u64, high: u64, direction: i8) {
269        let entry = self.edges.entry((low, high)).or_default();
270        entry.0 = entry.0.saturating_add(1);
271        entry.1 += i128::from(direction);
272    }
273
274    fn summarize(&mut self) -> EdgeSummary {
275        let mut unbalanced = self.edges.iter().filter(|(_, &(_, winding))| winding != 0);
276        let first_unbalanced = unbalanced.next().map(|(&key, _)| key);
277        let unbalanced = usize::from(first_unbalanced.is_some()) + unbalanced.count();
278        EdgeSummary {
279            unbalanced,
280            first_unbalanced,
281            boundary: self
282                .edges
283                .values()
284                .filter(|&&(count, _)| count == 1)
285                .count(),
286            non_manifold: self.edges.values().filter(|&&(count, _)| count > 2).count(),
287            inconsistent_winding: self
288                .edges
289                .values()
290                .filter(|&&(count, winding)| count == 2 && winding != 0)
291                .count(),
292        }
293    }
294}
295
296/// Audit a triangle mesh with an explicit source-unit tolerance.
297///
298/// A triangle is degenerate when its doubled area is at most
299/// `tolerance.linear()²`; the implementation compares squared values to avoid a
300/// square root. Pass [`Tolerance::ZERO`] for exact-coordinate compatibility.
301///
302/// This compatibility entry point falls back to the historical map-backed
303/// audit if the bounded vector reservation fails. Operations with an explicit
304/// memory budget should use [`try_audit_mesh`] and preflight
305/// [`audit_mesh_scratch_bytes`] instead.
306pub fn audit_mesh<M: TriangleMeshView + ?Sized>(mesh: &M, tolerance: Tolerance) -> MeshHealth {
307    audit_mesh_cycle(mesh, tolerance).health
308}
309
310/// Audit using one fallible, precomputable edge-record allocation.
311///
312/// Callers can refuse before allocation by comparing
313/// [`audit_mesh_scratch_bytes`] with their memory budget.
314pub fn try_audit_mesh<M: TriangleMeshView + ?Sized>(
315    mesh: &M,
316    tolerance: Tolerance,
317) -> Result<MeshHealth, MeshAuditError> {
318    try_audit_mesh_cycle(mesh, tolerance).map(|audit| audit.health)
319}
320
321/// [`MeshHealth`] plus the oriented-cycle facts of the same pass.
322///
323/// A usable mesh is a closed oriented cycle when every undirected edge is
324/// used equally often in each direction: the triangles then form a 2-cycle,
325/// so the divergence sum of a signed volume does not depend on its base
326/// point. This is weaker than [`MeshHealth::is_closed_two_manifold`]: it also
327/// admits edges shared by four triangles (two per direction) and shells that
328/// touch along an edge, and it refuses a T-junction, whose long edge is
329/// matched by two short edges the audit cannot pair.
330#[non_exhaustive]
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct MeshCycleHealth {
333    /// The structural facts [`audit_mesh`] reports for the same mesh.
334    pub health: MeshHealth,
335    /// Undirected edges among usable triangles whose net direction is not zero.
336    pub unbalanced_edges: usize,
337    /// Smallest unbalanced edge as ascending `(low, high)` source indices.
338    pub first_unbalanced_edge: Option<(u64, u64)>,
339}
340
341impl MeshCycleHealth {
342    /// Whether the usable mesh is a closed oriented cycle: surface-usable,
343    /// no degenerate triangle and no unbalanced edge.
344    ///
345    /// Every closed two-manifold mesh is one. Enclosed volume additionally
346    /// needs the winding number to be 0 or 1 everywhere, which no edge audit
347    /// can see.
348    pub fn is_closed_oriented_cycle(&self) -> bool {
349        self.health.is_surface_usable()
350            && self.health.degenerate_triangles == 0
351            && self.unbalanced_edges == 0
352    }
353}
354
355/// [`audit_mesh`] with the oriented-cycle facts, in the same pass.
356///
357/// Falls back to the map-backed audit like [`audit_mesh`] when the bounded
358/// reservation fails.
359pub fn audit_mesh_cycle<M: TriangleMeshView + ?Sized>(
360    mesh: &M,
361    tolerance: Tolerance,
362) -> MeshCycleHealth {
363    match VecEdgeSink::try_new(mesh.triangle_count(), mesh.position_count()) {
364        Ok(edges) => audit_with_edges(mesh, tolerance, edges),
365        Err(_) => audit_with_edges(mesh, tolerance, MapEdgeSink::default()),
366    }
367}
368
369/// [`audit_mesh_cycle`] with one fallible, precomputable edge-record
370/// allocation; [`audit_mesh_scratch_bytes`] bounds it like [`try_audit_mesh`].
371pub fn try_audit_mesh_cycle<M: TriangleMeshView + ?Sized>(
372    mesh: &M,
373    tolerance: Tolerance,
374) -> Result<MeshCycleHealth, MeshAuditError> {
375    let edges = VecEdgeSink::try_new(mesh.triangle_count(), mesh.position_count())?;
376    Ok(audit_with_edges(mesh, tolerance, edges))
377}
378
379fn audit_with_edges<M: TriangleMeshView + ?Sized, E: EdgeSink>(
380    mesh: &M,
381    tolerance: Tolerance,
382    mut edges: E,
383) -> MeshCycleHealth {
384    let positions = mesh.position_count();
385    let triangles = mesh.triangle_count();
386    let first_non_finite_position = (0..positions).find(|&index| !mesh.position(index).is_finite());
387    let non_finite_positions = (0..positions)
388        .filter(|&index| !mesh.position(index).is_finite())
389        .count();
390    let mut invalid_indices = 0;
391    let mut first_invalid_index = None;
392    let mut degenerate_triangles = 0;
393    let mut usable_triangles = 0;
394    let squared_double_area_limit = tolerance.linear().powi(4);
395
396    for triangle_index in 0..triangles {
397        let triangle = mesh.triangle(triangle_index);
398        let converted = triangle.map(|source_index| usize::try_from(source_index).ok());
399        let [Some(a_index), Some(b_index), Some(c_index)] = converted else {
400            for source_index in triangle {
401                if usize::try_from(source_index).is_err() {
402                    invalid_indices += 1;
403                    first_invalid_index.get_or_insert((triangle_index, source_index));
404                }
405            }
406            continue;
407        };
408        let indices = [a_index, b_index, c_index];
409        let mut valid = true;
410        for (corner, &index) in indices.iter().enumerate() {
411            if index >= positions {
412                invalid_indices += 1;
413                first_invalid_index.get_or_insert((triangle_index, triangle[corner]));
414                valid = false;
415            }
416        }
417        if !valid {
418            continue;
419        }
420
421        let [a, b, c] = indices.map(|index| mesh.position(index));
422        if !a.is_finite() || !b.is_finite() || !c.is_finite() {
423            continue;
424        }
425        let squared_double_area = (b - a).cross(c - a).length_squared();
426        if squared_double_area <= squared_double_area_limit {
427            degenerate_triangles += 1;
428            continue;
429        }
430        usable_triangles += 1;
431        for (left, right) in [
432            (triangle[0], triangle[1]),
433            (triangle[1], triangle[2]),
434            (triangle[2], triangle[0]),
435        ] {
436            let direction = if left < right { 1 } else { -1 };
437            edges.record(left.min(right), left.max(right), direction);
438        }
439    }
440
441    let edge_summary = edges.summarize();
442    MeshCycleHealth {
443        health: MeshHealth {
444            positions,
445            triangles,
446            usable_triangles,
447            invalid_indices,
448            non_finite_positions,
449            degenerate_triangles,
450            boundary_edges: edge_summary.boundary,
451            non_manifold_edges: edge_summary.non_manifold,
452            inconsistent_winding_edges: edge_summary.inconsistent_winding,
453            first_invalid_index,
454            first_non_finite_position,
455        },
456        unbalanced_edges: edge_summary.unbalanced,
457        first_unbalanced_edge: edge_summary.first_unbalanced,
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::TriMesh;
465    use axiolid_core::Point3;
466
467    /// The bound covers BOTH buffers the counting sort may hold at once.
468    /// Charging for one would let a caller admit an audit that then
469    /// refuses its own allocation.
470    #[test]
471    fn scratch_bound_charges_two_buffers_of_three_edge_records_per_triangle() {
472        assert_eq!(
473            audit_mesh_scratch_bytes(7),
474            7usize
475                .checked_mul(3)
476                .and_then(|count| count.checked_mul(std::mem::size_of::<EdgeRecord>()))
477                .and_then(|bytes| bytes.checked_mul(2))
478        );
479        let sink = VecEdgeSink::try_new(7, 16).expect("small bounded audit allocation");
480        assert!(sink.edges.capacity() >= 21);
481    }
482
483    /// The counting sort must order records exactly as the comparison
484    /// sort did. Grouping alone would be enough for `summarize`, but
485    /// proving full order equality is stronger and catches a pass
486    /// ordering mistake that grouping would hide.
487    #[test]
488    fn counting_sort_matches_comparison_sort() {
489        let buckets = 64usize;
490        let mut seed = 0x9E3779B97F4A7C15u64;
491        let mut next = move || {
492            seed ^= seed << 13;
493            seed ^= seed >> 7;
494            seed ^= seed << 17;
495            seed
496        };
497        let mut edges: Vec<EdgeRecord> = (0..4096)
498            .map(|_| {
499                let a = (next() as usize % buckets) as u64;
500                let b = (next() as usize % buckets) as u64;
501                EdgeRecord {
502                    low: a.min(b),
503                    high: a.max(b),
504                    direction: if next() % 2 == 0 { 1 } else { -1 },
505                }
506            })
507            .collect();
508        let mut expected = edges.clone();
509        expected.sort_unstable_by_key(|e| (e.low, e.high));
510
511        let mut scratch = vec![EdgeRecord::EMPTY; edges.len()];
512        counting_sort_edges(&mut edges, &mut scratch, buckets);
513
514        let keys: Vec<_> = edges.iter().map(|e| (e.low, e.high)).collect();
515        let want: Vec<_> = expected.iter().map(|e| (e.low, e.high)).collect();
516        assert_eq!(
517            keys, want,
518            "counting sort must reproduce the comparison order"
519        );
520    }
521
522    /// Both sinks must agree on a real mesh. The map sink is the
523    /// allocation-failure fallback, so a divergence here would mean the
524    /// audit silently reports different health under memory pressure.
525    #[test]
526    fn both_sinks_agree_on_a_defective_mesh() {
527        // Two triangles sharing an edge, plus a third fin on that same
528        // edge: boundary, non-manifold and winding counts all exercised.
529        let positions = vec![
530            Point3::new(0.0, 0.0, 0.0),
531            Point3::new(1.0, 0.0, 0.0),
532            Point3::new(0.0, 1.0, 0.0),
533            Point3::new(0.0, 0.0, 1.0),
534            Point3::new(0.0, -1.0, 0.0),
535        ];
536        let indices = vec![0, 1, 2, 0, 1, 3, 0, 1, 4];
537        let mesh = TriMesh::new(positions, indices);
538
539        let tolerance = Tolerance::MILLIMETRE;
540        let fast = VecEdgeSink::try_new(mesh.triangle_count(), mesh.position_count())
541            .expect("fixture allocation");
542        assert!(
543            fast.buckets > 0,
544            "counting sort must be active for this fixture"
545        );
546        let via_counting = audit_with_edges(&mesh, tolerance, fast);
547        let via_map = audit_with_edges(&mesh, tolerance, MapEdgeSink::default());
548        assert_eq!(via_counting, via_map, "sinks must report identical health");
549        assert!(via_counting.unbalanced_edges > 0);
550        assert!(via_counting.first_unbalanced_edge.is_some());
551    }
552}