axiolid_topology/
audit.rs

1//! Structural validation of a boundary representation.
2//!
3//! `axiolid-mesh` audits triangle meshes; nothing audited the topology that
4//! produces them. A `BRep` could carry dangling references or an
5//! unclosed shell and tessellate into silent garbage.
6//!
7//! `Shell.closed` is a claim the source made, not a fact. This module
8//! checks it.
9
10use std::collections::BTreeMap;
11
12use crate::{BRep, Orientation};
13
14/// Structural health of one boundary representation.
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub struct BRepHealth {
18    /// References to entities that do not exist.
19    pub dangling_references: usize,
20    /// Loops with no edge uses, so they cannot bound a face.
21    pub empty_loops: usize,
22    /// Loops whose consecutive edges do not share a vertex.
23    pub open_loops: usize,
24    /// Faces with no outer bound.
25    pub faces_without_outer_bound: usize,
26    /// Faces with more than one outer bound.
27    pub faces_with_multiple_outer_bounds: usize,
28    /// Directed edge uses in the shell that lack an opposing use.
29    pub unpaired_edge_uses: usize,
30    /// Edges used more than twice in one shell.
31    pub overused_edges: usize,
32    /// Shells declaring `closed` that are not.
33    pub false_closure_claims: usize,
34}
35
36impl BRepHealth {
37    /// Whether the topology is sound enough to tessellate.
38    ///
39    /// Closure is deliberately excluded: an open shell is a legitimate
40    /// surface model, and refusing it here would reject valid input. What
41    /// must never pass is a reference that does not resolve, a loop that is
42    /// empty or open, or a face whose outer-bound count is not exactly one;
43    /// each can produce silently wrong geometry.
44    pub fn is_tessellable(&self) -> bool {
45        self.dangling_references == 0
46            && self.empty_loops == 0
47            && self.open_loops == 0
48            && self.faces_without_outer_bound == 0
49            && self.faces_with_multiple_outer_bounds == 0
50    }
51
52    /// Whether every shell bounds a volume, so signed-volume reduction and
53    /// containment are meaningful.
54    pub fn is_closed_manifold(&self) -> bool {
55        self.is_tessellable()
56            && self.unpaired_edge_uses == 0
57            && self.overused_edges == 0
58            && self.false_closure_claims == 0
59    }
60}
61
62/// Audit the structure of a boundary representation.
63///
64/// Pure topology: no tolerance, no coordinates. Every check is a statement
65/// about handles and adjacency, so the result is exact and reproducible.
66#[must_use]
67pub fn audit_brep<Curve3, Curve2, Surface>(brep: &BRep<Curve3, Curve2, Surface>) -> BRepHealth {
68    let mut health = BRepHealth::default();
69    let vertices = brep.vertices().len();
70    let edges = brep.edges().len();
71    let loops = brep.loops().len();
72    let faces = brep.faces().len();
73    let shells = brep.shells().len();
74
75    for edge in brep.edges() {
76        if edge.start.index() >= vertices || edge.end.index() >= vertices {
77            health.dangling_references += 1;
78        }
79    }
80
81    // A loop is closed when consecutive oriented edges meet: the head of one
82    // use is the tail of the next. Orientation decides which endpoint is
83    // which, so a reversed use that still connects is correct.
84    for lp in brep.loops() {
85        if lp.edges.is_empty() {
86            health.empty_loops += 1;
87            continue;
88        }
89        let mut open = false;
90        for (k, use_) in lp.edges.iter().enumerate() {
91            if use_.edge.index() >= edges {
92                health.dangling_references += 1;
93                open = true;
94                continue;
95            }
96            let next = &lp.edges[(k + 1) % lp.edges.len()];
97            if next.edge.index() >= edges {
98                continue;
99            }
100            let head = endpoints(brep, use_).1;
101            let tail = endpoints(brep, next).0;
102            if head != tail {
103                open = true;
104            }
105        }
106        if open {
107            health.open_loops += 1;
108        }
109    }
110
111    for face in brep.faces() {
112        match face.bounds.iter().filter(|bound| bound.outer).count() {
113            0 => health.faces_without_outer_bound += 1,
114            1 => {}
115            _ => health.faces_with_multiple_outer_bounds += 1,
116        }
117        for bound in &face.bounds {
118            if bound.loop_id.index() >= loops {
119                health.dangling_references += 1;
120            }
121        }
122    }
123
124    // Edge-use pairing per shell. A closed orientable shell uses every edge
125    // exactly twice, once in each direction: that is what makes the surface
126    // bound a volume. Counting the SIGNED uses catches both a boundary edge
127    // (net non-zero) and two faces wound the same way (net non-zero), which
128    // a plain "used twice" count would miss.
129    for shell in brep.shells() {
130        let mut balance: BTreeMap<usize, i32> = BTreeMap::new();
131        let mut uses: BTreeMap<usize, usize> = BTreeMap::new();
132        for &(face_id, shell_sense) in &shell.faces {
133            if face_id.index() >= faces {
134                health.dangling_references += 1;
135                continue;
136            }
137            let face = &brep.faces()[face_id.index()];
138            let flip = (shell_sense == Orientation::Reversed)
139                ^ (face.orientation == Orientation::Reversed);
140            for bound in &face.bounds {
141                if bound.loop_id.index() >= loops {
142                    continue;
143                }
144                for use_ in &brep.loops()[bound.loop_id.index()].edges {
145                    if use_.edge.index() >= edges {
146                        continue;
147                    }
148                    let mut forward = use_.orientation == Orientation::Forward;
149                    if flip {
150                        forward = !forward;
151                    }
152                    if bound.orientation == Orientation::Reversed {
153                        forward = !forward;
154                    }
155                    *balance.entry(use_.edge.index()).or_default() += if forward { 1 } else { -1 };
156                    *uses.entry(use_.edge.index()).or_default() += 1;
157                }
158            }
159        }
160        let unpaired = balance.values().filter(|v| **v != 0).count();
161        let overused = uses.values().filter(|c| **c > 2).count();
162        health.unpaired_edge_uses += unpaired;
163        health.overused_edges += overused;
164        if shell.closed && (unpaired > 0 || overused > 0) {
165            health.false_closure_claims += 1;
166        }
167    }
168    let _ = shells;
169    health
170}
171
172/// Ordered endpoints of one oriented edge use.
173fn endpoints<Curve3, Curve2, Surface>(
174    brep: &BRep<Curve3, Curve2, Surface>,
175    use_: &crate::EdgeUse<Curve2>,
176) -> (crate::VertexId, crate::VertexId) {
177    let edge = &brep.edges()[use_.edge.index()];
178    match use_.orientation {
179        Orientation::Forward => (edge.start, edge.end),
180        _ => (edge.end, edge.start),
181    }
182}