Skip to main content

axiolid_decimate/
collapse.rs

1//! Edge collapse with validity checks and a measured deviation bound.
2
3use axiolid_core::{Point3, Tolerance};
4use axiolid_mesh::TriMesh;
5use std::collections::{BTreeMap, BTreeSet};
6use thiserror::Error;
7
8/// What the caller wants back.
9#[derive(Debug, Clone, Copy, PartialEq)]
10#[non_exhaustive]
11pub enum DecimateTarget {
12    /// Reduce until at most this many triangles remain.
13    ///
14    /// The deviation bound still applies: the budget is honoured only as far
15    /// as it can be without exceeding `max_deviation`.
16    TriangleBudget(usize),
17    /// Collapse every edge whose removal stays within this deviation.
18    MaxDeviation(f64),
19}
20
21/// Why a decimation could not run.
22#[derive(Debug, Clone, PartialEq, Error)]
23#[non_exhaustive]
24pub enum DecimateError {
25    /// The index buffer is not a whole number of triangles.
26    #[error("index buffer length {0} is not a multiple of 3")]
27    RaggedIndices(usize),
28    /// A triangle references a vertex that does not exist.
29    #[error("triangle {0} references vertex {1}, which is out of range")]
30    IndexOutOfRange(usize, u32),
31    /// A deviation bound must be a positive, finite length.
32    #[error("deviation bound {0} is not a positive finite length")]
33    InvalidBound(f64),
34}
35
36/// What a decimation actually did.
37///
38/// The deviation is measured, not estimated: it is the largest distance any
39/// collapsed vertex moved from its original position. A caller that asked
40/// for 1mm accuracy can check it got it.
41#[derive(Debug, Clone, PartialEq)]
42#[non_exhaustive]
43pub struct DecimateReport {
44    /// Triangles before.
45    pub input_triangles: usize,
46    /// Triangles after.
47    pub output_triangles: usize,
48    /// Edge collapses performed.
49    pub collapses: usize,
50    /// Collapses rejected because they would have created a defect.
51    ///
52    /// A non-zero count is not a failure: it is the reason the result is
53    /// still a valid manifold rather than a faster, broken one.
54    pub rejected_unsafe: usize,
55    /// Collapses rejected because they would have exceeded the bound.
56    pub rejected_deviation: usize,
57    /// Largest distance any vertex moved, in model units.
58    pub max_deviation: f64,
59}
60
61impl DecimateReport {
62    /// Whether the mesh was left untouched.
63    pub fn is_noop(&self) -> bool {
64        self.collapses == 0
65    }
66}
67
68/// Decimate a triangle mesh by edge collapse.
69///
70/// Deterministic: candidate edges are ordered by length then by vertex
71/// index, so the same input and target produce the same output on every
72/// run, matching the determinism discipline the plan contract established
73/// in v0.6.
74///
75/// # Errors
76///
77/// Refuses a ragged index buffer, out-of-range indices, and a non-positive
78/// deviation bound.
79pub fn decimate(
80    mesh: &TriMesh,
81    target: DecimateTarget,
82    tolerance: Tolerance,
83) -> Result<(TriMesh, DecimateReport), DecimateError> {
84    if !mesh.indices.len().is_multiple_of(3) {
85        return Err(DecimateError::RaggedIndices(mesh.indices.len()));
86    }
87    let vertex_count = mesh.positions.len();
88    for (t, chunk) in mesh.indices.as_chunks::<3>().0.iter().enumerate() {
89        for &index in chunk {
90            if index as usize >= vertex_count {
91                return Err(DecimateError::IndexOutOfRange(t, index));
92            }
93        }
94    }
95
96    let bound = match target {
97        DecimateTarget::MaxDeviation(d) => {
98            if !d.is_finite() || d <= 0.0 {
99                return Err(DecimateError::InvalidBound(d));
100            }
101            d
102        }
103        // A budget still needs a ceiling, or "reduce to N triangles" would
104        // licence arbitrary damage. The caller's tolerance is that ceiling.
105        DecimateTarget::TriangleBudget(_) => tolerance.linear(),
106    };
107
108    let budget = match target {
109        DecimateTarget::TriangleBudget(n) => n,
110        DecimateTarget::MaxDeviation(_) => 0,
111    };
112
113    run(mesh, budget, bound)
114}
115
116/// The collapse loop.
117///
118/// Each candidate edge is collapsed to its midpoint. The move is accepted
119/// only when it neither exceeds the deviation bound nor creates a defect.
120fn run(
121    mesh: &TriMesh,
122    budget: usize,
123    bound: f64,
124) -> Result<(TriMesh, DecimateReport), DecimateError> {
125    let input_triangles = mesh.indices.len() / 3;
126    let mut positions = mesh.positions.clone();
127    let mut triangles: Vec<[u32; 3]> = mesh
128        .indices
129        .as_chunks::<3>()
130        .0
131        .iter()
132        .map(|c| [c[0], c[1], c[2]])
133        .collect();
134
135    // Deterministic candidate order: shortest edges first, ties broken by
136    // vertex index. Sorting by a float alone would leave equal-length edges
137    // in hash order and make the output depend on iteration chance.
138    let mut candidates: Vec<(u32, u32)> = unique_edges(&triangles).into_iter().collect();
139    candidates.sort_by(|a, b| {
140        let la = (positions[a.0 as usize] - positions[a.1 as usize]).length();
141        let lb = (positions[b.0 as usize] - positions[b.1 as usize]).length();
142        la.partial_cmp(&lb)
143            .unwrap_or(std::cmp::Ordering::Equal)
144            .then(a.cmp(b))
145    });
146
147    let mut report = DecimateReport {
148        input_triangles,
149        output_triangles: input_triangles,
150        collapses: 0,
151        rejected_unsafe: 0,
152        rejected_deviation: 0,
153        max_deviation: 0.0,
154    };
155    let mut moved = vec![0.0_f64; positions.len()];
156    let mut alive: Vec<bool> = vec![true; positions.len()];
157
158    for (u, v) in candidates {
159        if triangles.len() <= budget.max(4) && budget > 0 {
160            break;
161        }
162        if !alive[u as usize] || !alive[v as usize] {
163            continue;
164        }
165        let midpoint = (positions[u as usize] + positions[v as usize]) / 2.0;
166
167        // Deviation is cumulative: a vertex that already moved carries that
168        // history, so repeated collapses cannot drift past the bound one
169        // small step at a time.
170        let deviation = (midpoint - positions[u as usize])
171            .length()
172            .max((midpoint - positions[v as usize]).length())
173            + moved[u as usize].max(moved[v as usize]);
174        if deviation > bound {
175            report.rejected_deviation += 1;
176            continue;
177        }
178
179        match try_collapse(&triangles, &positions, u, v, midpoint) {
180            Some(next) => {
181                triangles = next;
182                positions[u as usize] = midpoint;
183                moved[u as usize] = deviation;
184                alive[v as usize] = false;
185                report.collapses += 1;
186                report.max_deviation = report.max_deviation.max(deviation);
187            }
188            None => report.rejected_unsafe += 1,
189        }
190    }
191
192    report.output_triangles = triangles.len();
193    Ok((compact(&triangles, &positions), report))
194}
195
196/// Attempt one collapse, returning the new triangle list or nothing.
197///
198/// Rejects the two ways a collapse damages a mesh:
199///
200/// - **Inversion.** A triangle whose normal flips has turned inside out. A
201///   decimator that permits this produces exactly the defect
202///   `axiolid-heal`'s `OrientOutward` exists to repair.
203/// - **Non-manifold edges.** Collapsing an edge whose endpoints share
204///   neighbours other than the two triangles on it welds unrelated sheets
205///   together.
206///
207/// Boundary vertices are not pinned: a collapse may move a vertex on an open
208/// boundary to an edge midpoint like any other vertex, and the deviation
209/// bound is what limits how far the silhouette moves.
210fn try_collapse(
211    triangles: &[[u32; 3]],
212    positions: &[Point3],
213    u: u32,
214    v: u32,
215    midpoint: Point3,
216) -> Option<Vec<[u32; 3]>> {
217    // The link condition: the shared neighbourhood of u and v must be
218    // exactly the two triangles on the edge. More than that, and the
219    // collapse creates a non-manifold edge.
220    let nu = neighbours(triangles, u);
221    let nv = neighbours(triangles, v);
222    let shared = nu.intersection(&nv).count();
223    if shared != 2 {
224        return None;
225    }
226
227    let mut next = Vec::with_capacity(triangles.len());
228    for &t in triangles {
229        let touches_u = t.contains(&u);
230        let touches_v = t.contains(&v);
231        if touches_u && touches_v {
232            // Degenerates to a line: this is the triangle being removed.
233            continue;
234        }
235        let mapped = t.map(|c| if c == v { u } else { c });
236        if touches_u || touches_v {
237            let before = normal(positions, t);
238            let after = normal_with(positions, mapped, u, midpoint);
239            // A flipped normal means the triangle turned inside out. Zero
240            // area after the move is equally unacceptable: it contributes
241            // nothing and confuses every downstream audit.
242            if after.length_squared() == 0.0 || before.dot(after) <= 0.0 {
243                return None;
244            }
245        }
246        next.push(mapped);
247    }
248    Some(next)
249}
250
251/// Vertices sharing a triangle with `vertex`.
252fn neighbours(triangles: &[[u32; 3]], vertex: u32) -> BTreeSet<u32> {
253    let mut set = BTreeSet::new();
254    for t in triangles {
255        if t.contains(&vertex) {
256            for &c in t {
257                if c != vertex {
258                    set.insert(c);
259                }
260            }
261        }
262    }
263    set
264}
265
266/// Every undirected edge, each appearing once.
267fn unique_edges(triangles: &[[u32; 3]]) -> BTreeSet<(u32, u32)> {
268    let mut set = BTreeSet::new();
269    for t in triangles {
270        for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
271            set.insert((a.min(b), a.max(b)));
272        }
273    }
274    set
275}
276
277/// Unnormalised triangle normal.
278fn normal(positions: &[Point3], t: [u32; 3]) -> axiolid_core::Vec3 {
279    let a = positions[t[0] as usize];
280    let b = positions[t[1] as usize];
281    let c = positions[t[2] as usize];
282    (b - a).cross(c - a)
283}
284
285/// Triangle normal with one vertex moved to a candidate position.
286fn normal_with(
287    positions: &[Point3],
288    t: [u32; 3],
289    moved_index: u32,
290    moved_to: Point3,
291) -> axiolid_core::Vec3 {
292    let at = |i: u32| {
293        if i == moved_index {
294            moved_to
295        } else {
296            positions[i as usize]
297        }
298    };
299    let (a, b, c) = (at(t[0]), at(t[1]), at(t[2]));
300    (b - a).cross(c - a)
301}
302
303/// Drop unreferenced vertices and renumber.
304///
305/// Collapsed vertices leave holes in the position array. Emitting them would
306/// leave unreferenced vertices that make the result look defective to the
307/// v0.7 diagnosis.
308fn compact(triangles: &[[u32; 3]], positions: &[Point3]) -> TriMesh {
309    let mut remap: BTreeMap<u32, u32> = BTreeMap::new();
310    let mut kept = Vec::new();
311    let mut indices = Vec::with_capacity(triangles.len() * 3);
312    for t in triangles {
313        for &corner in t {
314            let next = u32::try_from(kept.len()).unwrap_or(u32::MAX);
315            let slot = *remap.entry(corner).or_insert_with(|| {
316                kept.push(positions[corner as usize]);
317                next
318            });
319            indices.push(slot);
320        }
321    }
322    TriMesh::new(kept, indices)
323}