axiolid_heal/
mesh.rs

1//! Diagnosing and repairing triangle meshes.
2//!
3//! `MeshHealth` already counts what is wrong with a mesh. A count is not
4//! actionable: "4444 inconsistent winding edges" does not say which
5//! triangles to fix. This locates each defect against a stable element
6//! index so a caller can act on it, and applies only the repairs it was
7//! explicitly asked for.
8
9use std::collections::{HashMap, VecDeque};
10
11use axiolid_core::{Scalar, Tolerance};
12use axiolid_measure::volume_properties;
13use axiolid_mesh::{AttributeFate, DropReason, EdgeAdjacency, TriMesh};
14
15use crate::diagnosis::{Defect, DefectKind, Diagnosis};
16use crate::repair::{RepairAction, RepairPlan, RepairReport};
17use crate::traits::{Diagnose, Repair};
18
19/// Diagnosis and repair for indexed triangle meshes.
20#[derive(Debug, Default, Clone, Copy)]
21pub struct MeshHealer;
22
23/// Failure to complete diagnosis or repair.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum MeshHealError {
26    /// The index buffer is not a whole number of triangles.
27    RaggedIndices {
28        /// Number of index entries found.
29        indices: usize,
30    },
31}
32
33impl core::fmt::Display for MeshHealError {
34    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
35        match self {
36            Self::RaggedIndices { indices } => {
37                write!(f, "index buffer of {indices} is not a multiple of three")
38            }
39        }
40    }
41}
42
43impl std::error::Error for MeshHealError {}
44
45impl Diagnose<TriMesh> for MeshHealer {
46    type Error = MeshHealError;
47
48    fn diagnose(&self, mesh: &TriMesh, tolerance: Tolerance) -> Result<Diagnosis, Self::Error> {
49        if !mesh.indices.len().is_multiple_of(3) {
50            return Err(MeshHealError::RaggedIndices {
51                indices: mesh.indices.len(),
52            });
53        }
54        let mut defects = Vec::new();
55        let count = mesh.indices.len() / 3;
56        let limit = tolerance.linear() * tolerance.linear();
57
58        // Degenerate triangles first: they are excluded from the edge
59        // topology below, because a sliver's edges are not meaningful
60        // adjacency and would produce misleading manifold defects.
61        let mut usable = Vec::with_capacity(count);
62        for t in 0..count {
63            match area(mesh, t) {
64                Some(a) if a > limit => usable.push(t),
65                Some(_) => defects.push(Defect {
66                    kind: DefectKind::DegenerateElement,
67                    element: Some(t as u32),
68                    detail: None,
69                }),
70                None => defects.push(Defect {
71                    kind: DefectKind::DegenerateElement,
72                    element: Some(t as u32),
73                    detail: Some("triangle corner does not address a position".to_owned()),
74                }),
75            }
76        }
77
78        // Edge topology over usable triangles only. The key is the
79        // undirected edge; the stored direction is what reveals winding.
80        //
81        // This deliberately does NOT use `axiolid_mesh::EdgeAdjacency`.
82        // That type excludes triangles with a repeated corner -- a purely
83        // combinatorial test. Healing excludes triangles whose *area* is
84        // below tolerance, which is strictly stronger: a sliver with three
85        // distinct corners is sound to `EdgeAdjacency` and a defect here.
86        // Sharing the structure would mean diagnosing slivers' edges as
87        // real adjacency and reporting manifold defects that do not exist.
88        //
89        // The duplication is the tolerance-aware filter, not the edge map.
90        let mut edges: HashMap<(u32, u32), Vec<(usize, bool)>> = HashMap::new();
91        for &t in &usable {
92            for (a, b) in corners(mesh, t) {
93                let forward = a < b;
94                let key = if forward { (a, b) } else { (b, a) };
95                edges.entry(key).or_default().push((t, forward));
96            }
97        }
98
99        // Sort for deterministic defect order: HashMap iteration is not
100        // stable, and a diagnosis that reorders between runs is unusable
101        // as an audit record.
102        let mut keys: Vec<_> = edges.keys().copied().collect();
103        keys.sort_unstable();
104
105        for key in keys {
106            let uses = &edges[&key];
107            match uses.len() {
108                1 => {
109                    defects.push(Defect {
110                        kind: DefectKind::OpenShell,
111                        element: Some(uses[0].0 as u32),
112                        detail: Some(format!("edge {}-{} has one incident face", key.0, key.1)),
113                    });
114                }
115                2 => {
116                    // Two faces sharing the SAME directed edge disagree
117                    // about which side is outside. Consistent neighbours
118                    // traverse a shared edge in opposite directions.
119                    if uses[0].1 == uses[1].1 {
120                        defects.push(Defect {
121                            kind: DefectKind::InconsistentOrientation,
122                            element: Some(uses[1].0 as u32),
123                            detail: Some(format!(
124                                "triangles {} and {} traverse edge {}-{} the same way",
125                                uses[0].0, uses[1].0, key.0, key.1
126                            )),
127                        });
128                    }
129                }
130                n => {
131                    defects.push(Defect {
132                        kind: DefectKind::NonManifoldEdge,
133                        element: Some(uses[0].0 as u32),
134                        detail: Some(format!("edge {}-{} has {n} incident faces", key.0, key.1)),
135                    });
136                }
137            }
138        }
139
140        // Coincident positions stored separately. Bucketing by a
141        // tolerance-sized cell finds them in one pass; comparing every
142        // pair would be quadratic on model-scale meshes.
143        for (representative, group) in coincident_groups(mesh, tolerance) {
144            for duplicate in group {
145                defects.push(Defect {
146                    kind: DefectKind::DuplicateVertex,
147                    element: Some(duplicate),
148                    detail: Some(format!("coincident with vertex {representative}")),
149                });
150            }
151        }
152
153        Ok(Diagnosis { defects })
154    }
155}
156
157/// Group coincident vertices, returning `(representative, duplicates)`.
158///
159/// Vertices are bucketed into tolerance-sized cells. A vertex is compared
160/// only against the 27 cells touching its own, so a pair straddling a cell
161/// boundary is still found; comparing all pairs would be quadratic.
162fn coincident_groups(mesh: &TriMesh, tolerance: Tolerance) -> Vec<(u32, Vec<u32>)> {
163    let eps = tolerance.linear();
164    if eps <= 0.0 {
165        return Vec::new();
166    }
167    let cell = |p: axiolid_core::Point3| {
168        (
169            (p.x / eps).floor() as i64,
170            (p.y / eps).floor() as i64,
171            (p.z / eps).floor() as i64,
172        )
173    };
174    let mut buckets: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
175    for (i, p) in mesh.positions.iter().enumerate() {
176        buckets.entry(cell(*p)).or_default().push(i as u32);
177    }
178
179    let eps2 = eps * eps;
180    let mut owner: Vec<Option<u32>> = vec![None; mesh.positions.len()];
181    let mut groups: Vec<(u32, Vec<u32>)> = Vec::new();
182    for i in 0..mesh.positions.len() as u32 {
183        if owner[i as usize].is_some() {
184            continue;
185        }
186        let p = mesh.positions[i as usize];
187        let (cx, cy, cz) = cell(p);
188        let mut mates = Vec::new();
189        for dx in -1..=1 {
190            for dy in -1..=1 {
191                for dz in -1..=1 {
192                    let Some(bucket) = buckets.get(&(cx + dx, cy + dy, cz + dz)) else {
193                        continue;
194                    };
195                    for &j in bucket {
196                        if j > i
197                            && owner[j as usize].is_none()
198                            && (mesh.positions[j as usize] - p).length_squared() <= eps2
199                        {
200                            mates.push(j);
201                        }
202                    }
203                }
204            }
205        }
206        if !mates.is_empty() {
207            mates.sort_unstable();
208            owner[i as usize] = Some(i);
209            for &j in &mates {
210                owner[j as usize] = Some(i);
211            }
212            groups.push((i, mates));
213        }
214    }
215    groups
216}
217
218impl Repair<TriMesh> for MeshHealer {
219    type Error = MeshHealError;
220
221    fn repair(
222        &self,
223        mesh: &TriMesh,
224        plan: &RepairPlan,
225        tolerance: Tolerance,
226    ) -> Result<(TriMesh, RepairReport), Self::Error> {
227        if !mesh.indices.len().is_multiple_of(3) {
228            return Err(MeshHealError::RaggedIndices {
229                indices: mesh.indices.len(),
230            });
231        }
232        let mut out = mesh.clone();
233        let mut report = RepairReport::default();
234        let mut dropped = Vec::new();
235        // Actions run in the caller's order. The plan is ordered on
236        // purpose: welding before orientation gives orientation a
237        // connected mesh to work with, and the reverse does not.
238        for &action in &plan.actions {
239            let changed = match action {
240                RepairAction::WeldVertices => weld(&mut out, tolerance, &mut dropped),
241                RepairAction::DropDegenerateElements => drop_degenerate(&mut out, tolerance),
242                RepairAction::UnifyOrientation => unify_orientation(&mut out),
243                RepairAction::OrientOutward => orient_outward(&mut out, tolerance),
244            };
245            if changed {
246                report.applied.push(action);
247            } else {
248                report.skipped.push(action);
249            }
250        }
251        // No repair creates a vertex or derives a value, so a channel that
252        // was not dropped is carried unchanged: renumbered, never blended.
253        report.attribute_fates = mesh
254            .attributes
255            .iter()
256            .map(|channel| {
257                let fate = dropped
258                    .iter()
259                    .find(|(name, _)| *name == channel.name)
260                    .map_or(AttributeFate::Preserved, |(_, reason)| {
261                        AttributeFate::Dropped(*reason)
262                    });
263                (channel.name.clone(), fate)
264            })
265            .collect();
266        Ok((out, report))
267    }
268}
269
270/// Merge coincident vertices and repoint the index buffer.
271///
272/// Positions are compacted rather than left orphaned: a welded mesh that
273/// still carries unreferenced vertices reports the same duplicate defects
274/// on the next diagnosis, which would make the repair look ineffective.
275fn weld(mesh: &mut TriMesh, tolerance: Tolerance, dropped: &mut Vec<(String, DropReason)>) -> bool {
276    let groups = coincident_groups(mesh, tolerance);
277    if groups.is_empty() {
278        return false;
279    }
280    let mut remap: Vec<u32> = (0..mesh.positions.len() as u32).collect();
281    for (representative, duplicates) in &groups {
282        for &d in duplicates {
283            remap[d as usize] = *representative;
284        }
285    }
286    let mut keep: Vec<u32> = Vec::new();
287    let mut compact = vec![u32::MAX; mesh.positions.len()];
288    for i in 0..mesh.positions.len() as u32 {
289        if remap[i as usize] == i {
290            compact[i as usize] = keep.len() as u32;
291            keep.push(i);
292        }
293    }
294    mesh.positions = keep.iter().map(|&i| mesh.positions[i as usize]).collect();
295    let before = mesh.indices.clone();
296    for index in &mut mesh.indices {
297        *index = compact[remap[*index as usize] as usize];
298    }
299    crate::carry::weld(mesh, &groups, &keep, &before, dropped);
300    // Welding can collapse a triangle to a line; those are degenerate now,
301    // but removing them is a different action the caller did not request.
302    true
303}
304
305/// Remove triangles at or below the tolerance area.
306fn drop_degenerate(mesh: &mut TriMesh, tolerance: Tolerance) -> bool {
307    let limit = tolerance.linear() * tolerance.linear();
308    let count = mesh.indices.len() / 3;
309    let mut kept = Vec::with_capacity(mesh.indices.len());
310    let mut kept_triangles = Vec::with_capacity(count);
311    for t in 0..count {
312        if area(mesh, t).is_some_and(|a| a > limit) {
313            kept.extend_from_slice(&mesh.indices[t * 3..t * 3 + 3]);
314            kept_triangles.push(t);
315        }
316    }
317    if kept.len() == mesh.indices.len() {
318        return false;
319    }
320    mesh.indices = kept;
321    crate::carry::keep_triangles(mesh, &kept_triangles);
322    true
323}
324
325/// Make connected-face winding consistent by flood fill.
326///
327/// Two triangles sharing an edge agree when they traverse that edge in
328/// OPPOSITE directions. Starting from a seed, each neighbour that
329/// traverses the shared edge the same way is flipped, and the fill
330/// continues. This is the defect that a closed manifold mesh can still
331/// carry: signed volume comes out negative or partly cancelled while
332/// every boundary/manifold count looks perfect.
333///
334/// Orientation is fixed per connected component; the absolute sense of
335/// each component is left as its seed found it, because choosing outward
336/// requires a volume convention this function does not own.
337fn unify_orientation(mesh: &mut TriMesh) -> bool {
338    let count = mesh.indices.len() / 3;
339    if count == 0 {
340        return false;
341    }
342    // Adjacency is derived once by the shared structure. Flipping a triangle
343    // reverses its corner order but not its undirected edge set, so the map
344    // stays valid for the whole traversal.
345    let adjacency = EdgeAdjacency::build(mesh);
346    let mut visited = vec![false; count];
347    let mut flipped = false;
348    for seed in 0..count {
349        if visited[seed] {
350            continue;
351        }
352        visited[seed] = true;
353        let mut queue = VecDeque::from([seed]);
354        while let Some(t) = queue.pop_front() {
355            for (a, b) in corners(mesh, t) {
356                for n in adjacency.triangle_neighbours(t) {
357                    if visited[n] {
358                        continue;
359                    }
360                    // Only the neighbour across THIS corner is judged here;
361                    // the others are reached on their own corner.
362                    let shares = corners(mesh, n)
363                        .into_iter()
364                        .any(|(c, d)| (c.min(d), c.max(d)) == (a.min(b), a.max(b)));
365                    if !shares {
366                        continue;
367                    }
368                    // Same directed edge means the neighbour winds the
369                    // same way round a shared edge, which is inconsistent.
370                    if corners(mesh, n).into_iter().any(|(c, d)| c == a && d == b) {
371                        mesh.indices.swap(n * 3 + 1, n * 3 + 2);
372                        crate::carry::flip_triangle(mesh, n);
373                        flipped = true;
374                    }
375                    visited[n] = true;
376                    queue.push_back(n);
377                }
378            }
379        }
380    }
381    flipped
382}
383
384/// The three directed corner pairs of a triangle.
385fn corners(mesh: &TriMesh, t: usize) -> [(u32, u32); 3] {
386    let i = &mesh.indices[t * 3..t * 3 + 3];
387    [(i[0], i[1]), (i[1], i[2]), (i[2], i[0])]
388}
389
390/// Triangle area, or `None` when a corner index is out of range.
391fn area(mesh: &TriMesh, t: usize) -> Option<Scalar> {
392    let i = &mesh.indices[t * 3..t * 3 + 3];
393    let p = |k: usize| mesh.positions.get(i[k] as usize).copied();
394    let (a, b, c) = (p(0)?, p(1)?, p(2)?);
395    Some((b - a).cross(c - a).length() * 0.5)
396}
397
398/// Flip a closed shell whose faces point inward.
399///
400/// `unify_orientation` makes neighbours agree but leaves the absolute
401/// sense as the seed found it, because choosing outward needs a volume
402/// convention it does not own. `axiolid-measure` owns that convention
403/// now, so this repair completes the pair: unify makes the shell
404/// consistent, this makes it consistent the RIGHT WAY ROUND.
405///
406/// An inside-out shell is structurally perfect -- closed, two-manifold,
407/// consistently wound -- so no topological audit finds it. The boolmesh
408/// provider records the consequence: `Difference` behaves as `Union` and
409/// returns a LARGER mesh with no error.
410///
411/// Only closed shells are eligible. An open surface has no enclosed
412/// volume, so `inward` is not defined for it and it is left untouched.
413fn orient_outward(mesh: &mut TriMesh, tolerance: Tolerance) -> bool {
414    let Ok(properties) = volume_properties(&*mesh, tolerance) else {
415        return false;
416    };
417    if properties.signed_volume >= 0.0 {
418        return false;
419    }
420    for t in 0..mesh.indices.len() / 3 {
421        mesh.indices.swap(t * 3 + 1, t * 3 + 2);
422        crate::carry::flip_triangle(mesh, t);
423    }
424    true
425}