Skip to main content

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                RepairAction::SplitTJunctions => {
245                    let (split, summary) = crate::tjunction::split_t_junctions(&out, tolerance)?;
246                    out = split;
247                    summary.split_triangles > 0
248                }
249            };
250            if changed {
251                report.applied.push(action);
252            } else {
253                report.skipped.push(action);
254            }
255        }
256        // No repair creates a vertex or derives a value, so a channel that
257        // was not dropped is carried unchanged: renumbered, never blended.
258        report.attribute_fates = mesh
259            .attributes
260            .iter()
261            .map(|channel| {
262                let fate = dropped
263                    .iter()
264                    .find(|(name, _)| *name == channel.name)
265                    .map_or(AttributeFate::Preserved, |(_, reason)| {
266                        AttributeFate::Dropped(*reason)
267                    });
268                (channel.name.clone(), fate)
269            })
270            .collect();
271        Ok((out, report))
272    }
273}
274
275/// Merge coincident vertices and repoint the index buffer.
276///
277/// Positions are compacted rather than left orphaned: a welded mesh that
278/// still carries unreferenced vertices reports the same duplicate defects
279/// on the next diagnosis, which would make the repair look ineffective.
280fn weld(mesh: &mut TriMesh, tolerance: Tolerance, dropped: &mut Vec<(String, DropReason)>) -> bool {
281    let groups = coincident_groups(mesh, tolerance);
282    if groups.is_empty() {
283        return false;
284    }
285    let mut remap: Vec<u32> = (0..mesh.positions.len() as u32).collect();
286    for (representative, duplicates) in &groups {
287        for &d in duplicates {
288            remap[d as usize] = *representative;
289        }
290    }
291    let mut keep: Vec<u32> = Vec::new();
292    let mut compact = vec![u32::MAX; mesh.positions.len()];
293    for i in 0..mesh.positions.len() as u32 {
294        if remap[i as usize] == i {
295            compact[i as usize] = keep.len() as u32;
296            keep.push(i);
297        }
298    }
299    mesh.positions = keep.iter().map(|&i| mesh.positions[i as usize]).collect();
300    let before = mesh.indices.clone();
301    for index in &mut mesh.indices {
302        *index = compact[remap[*index as usize] as usize];
303    }
304    crate::carry::weld(mesh, &groups, &keep, &before, dropped);
305    // Welding can collapse a triangle to a line; those are degenerate now,
306    // but removing them is a different action the caller did not request.
307    true
308}
309
310/// Remove triangles at or below the tolerance area.
311fn drop_degenerate(mesh: &mut TriMesh, tolerance: Tolerance) -> bool {
312    let limit = tolerance.linear() * tolerance.linear();
313    let count = mesh.indices.len() / 3;
314    let mut kept = Vec::with_capacity(mesh.indices.len());
315    let mut kept_triangles = Vec::with_capacity(count);
316    for t in 0..count {
317        if area(mesh, t).is_some_and(|a| a > limit) {
318            kept.extend_from_slice(&mesh.indices[t * 3..t * 3 + 3]);
319            kept_triangles.push(t);
320        }
321    }
322    if kept.len() == mesh.indices.len() {
323        return false;
324    }
325    mesh.indices = kept;
326    crate::carry::keep_triangles(mesh, &kept_triangles);
327    true
328}
329
330/// Make connected-face winding consistent by flood fill.
331///
332/// Two triangles sharing an edge agree when they traverse that edge in
333/// OPPOSITE directions. Starting from a seed, each neighbour that
334/// traverses the shared edge the same way is flipped, and the fill
335/// continues. This is the defect that a closed manifold mesh can still
336/// carry: signed volume comes out negative or partly cancelled while
337/// every boundary/manifold count looks perfect.
338///
339/// Orientation is fixed per connected component; the absolute sense of
340/// each component is left as its seed found it, because choosing outward
341/// requires a volume convention this function does not own.
342fn unify_orientation(mesh: &mut TriMesh) -> bool {
343    let count = mesh.indices.len() / 3;
344    if count == 0 {
345        return false;
346    }
347    // Adjacency is derived once by the shared structure. Flipping a triangle
348    // reverses its corner order but not its undirected edge set, so the map
349    // stays valid for the whole traversal.
350    let adjacency = EdgeAdjacency::build(mesh);
351    let mut visited = vec![false; count];
352    let mut flipped = false;
353    for seed in 0..count {
354        if visited[seed] {
355            continue;
356        }
357        visited[seed] = true;
358        let mut queue = VecDeque::from([seed]);
359        while let Some(t) = queue.pop_front() {
360            for (a, b) in corners(mesh, t) {
361                for n in adjacency.triangle_neighbours(t) {
362                    if visited[n] {
363                        continue;
364                    }
365                    // Only the neighbour across THIS corner is judged here;
366                    // the others are reached on their own corner.
367                    let shares = corners(mesh, n)
368                        .into_iter()
369                        .any(|(c, d)| (c.min(d), c.max(d)) == (a.min(b), a.max(b)));
370                    if !shares {
371                        continue;
372                    }
373                    // Same directed edge means the neighbour winds the
374                    // same way round a shared edge, which is inconsistent.
375                    if corners(mesh, n).into_iter().any(|(c, d)| c == a && d == b) {
376                        mesh.indices.swap(n * 3 + 1, n * 3 + 2);
377                        crate::carry::flip_triangle(mesh, n);
378                        flipped = true;
379                    }
380                    visited[n] = true;
381                    queue.push_back(n);
382                }
383            }
384        }
385    }
386    flipped
387}
388
389/// The three directed corner pairs of a triangle.
390fn corners(mesh: &TriMesh, t: usize) -> [(u32, u32); 3] {
391    let i = &mesh.indices[t * 3..t * 3 + 3];
392    [(i[0], i[1]), (i[1], i[2]), (i[2], i[0])]
393}
394
395/// Triangle area, or `None` when a corner index is out of range.
396fn area(mesh: &TriMesh, t: usize) -> Option<Scalar> {
397    let i = &mesh.indices[t * 3..t * 3 + 3];
398    let p = |k: usize| mesh.positions.get(i[k] as usize).copied();
399    let (a, b, c) = (p(0)?, p(1)?, p(2)?);
400    Some((b - a).cross(c - a).length() * 0.5)
401}
402
403/// Flip a closed shell whose faces point inward.
404///
405/// `unify_orientation` makes neighbours agree but leaves the absolute
406/// sense as the seed found it, because choosing outward needs a volume
407/// convention it does not own. `axiolid-measure` owns that convention
408/// now, so this repair completes the pair: unify makes the shell
409/// consistent, this makes it consistent the RIGHT WAY ROUND.
410///
411/// An inside-out shell is structurally perfect -- closed, two-manifold,
412/// consistently wound -- so no topological audit finds it. The boolmesh
413/// provider records the consequence: `Difference` behaves as `Union` and
414/// returns a LARGER mesh with no error.
415///
416/// Only closed shells are eligible. An open surface has no enclosed
417/// volume, so `inward` is not defined for it and it is left untouched.
418fn orient_outward(mesh: &mut TriMesh, tolerance: Tolerance) -> bool {
419    let Ok(properties) = volume_properties(&*mesh, tolerance) else {
420        return false;
421    };
422    if properties.signed_volume >= 0.0 {
423        return false;
424    }
425    for t in 0..mesh.indices.len() / 3 {
426        mesh.indices.swap(t * 3 + 1, t * 3 + 2);
427        crate::carry::flip_triangle(mesh, t);
428    }
429    true
430}