Skip to main content

axiolid_decompose/
lib.rs

1//! Convex decomposition: a solid as a set of convex parts.
2//!
3//! # Two strategies, one contract
4//!
5//! There is no single right answer here, so the caller picks:
6//!
7//! - [`Strategy::Exact`] splits at reflex features until every part is
8//!   genuinely convex. The union reproduces the input exactly, and the part
9//!   count can be large.
10//! - [`Strategy::Approximate`] stops once each part is convex to within a
11//!   stated concavity bound. Far fewer parts, and the union is close to but
12//!   not identical to the input.
13//!
14//! Both are legitimate. Collision detection and Minkowski sums usually want
15//! the approximate one; anything claiming to reproduce the original solid
16//! needs the exact one. What is NOT legitimate is returning an approximate
17//! decomposition that presents itself as exact, so [`Decomposition`] always
18//! reports which it is, and the approximate path reports the concavity it
19//! actually reached rather than the one that was requested.
20//!
21//! # Method
22//!
23//! Both strategies share one loop: measure the worst concavity of a part,
24//! and if it exceeds the bound, split the part by a plane and recurse.
25//! They differ only in the bound -- exact uses zero (to tolerance).
26//!
27//! Concavity is measured as the largest distance from a vertex of the part
28//! to its own convex hull. That is a direct measurement of the property the
29//! caller cares about, rather than a proxy like volume ratio: a thin deep
30//! notch barely changes volume but is exactly what breaks a convexity
31//! assumption downstream.
32//!
33//! The split plane is the plane of the face the reflex vertex sticks out
34//! past. Extending an existing face makes progress by definition, whereas
35//! a bounding-box axis through the same point need not separate the notch.
36//!
37//! # Capping a cut: measure, do not predict
38//!
39//! Closing the cut is the hard part, and the first four attempts all
40//! failed in the same shape. Each tried to PREDICT the cross-section from
41//! the input mesh, deciding per triangle whether an edge bounded the cut.
42//! Measured on an L-shaped solid:
43//!
44//! | rule | boundary edges | non-manifold edges |
45//! |---|---|---|
46//! | strict sign changes only | 5 | 0 |
47//! | plus vertices lying on the plane | 0 | 3 |
48//! | plus a straddling filter | 3 | 0 |
49//! | plus the two-vertices-on-plane case | 1 | 3 |
50//!
51//! Every rule fixed one defect and reintroduced the other, which is the
52//! signature of the wrong question rather than a missing case: whether a
53//! wall standing ON the cut plane bounds THIS part depends on which side
54//! the material lies, and a single triangle cannot see that.
55//!
56//! The fix is to stop predicting. Clip first, then look at what the shell
57//! actually left open: in a closed mesh every undirected edge is used
58//! exactly twice, so the edges used ONCE are precisely the hole. The cap
59//! fills exactly that, and can be neither too generous nor too strict
60//! whatever the clipping did upstream.
61//!
62//! # Two splitters
63//!
64//! The hand-rolled clipper above needs no boolean backend, which matters
65//! because this crate should be usable without one. A caller that already
66//! has a boolean provider can pass it instead, per call.
67//!
68//! Because `algorithms` may not depend on `providers` -- the architecture
69//! gate enforces it -- the provider arrives through the mesh-boolean
70//! CONTRACT, which both layers may depend on. See [`split::Splitter`].
71//! The two paths are independent implementations of the same contract, so
72//! each is evidence about the other, and the tests check they agree on the
73//! resulting solid rather than merely on their own claims.
74
75pub mod split;
76
77use ahash::AHashMap;
78use std::collections::BTreeMap;
79
80use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
81use axiolid_mesh::{audit_mesh, EdgeAdjacency, TriMesh};
82use thiserror::Error;
83
84/// Why a decomposition could not be produced.
85#[derive(Debug, Clone, PartialEq, Error)]
86#[non_exhaustive]
87pub enum DecomposeError {
88    /// The index buffer is not a whole number of triangles.
89    #[error("index buffer length {0} is not a multiple of 3")]
90    RaggedIndices(usize),
91    /// A triangle references a vertex that does not exist.
92    #[error("triangle {0} references vertex {1}, which is out of range")]
93    IndexOutOfRange(usize, u32),
94    /// The input is not a closed two-manifold solid.
95    ///
96    /// Refused rather than decomposed: the parts of an open surface do not
97    /// have a union that reproduces it, so any answer would be a fiction.
98    #[error("input is not a closed two-manifold solid: {boundary} boundary and {non_manifold} non-manifold edges")]
99    NotASolid {
100        /// Edges with a single incident triangle.
101        boundary: usize,
102        /// Edges with more than two incident triangles.
103        non_manifold: usize,
104    },
105    /// A concavity bound must be a positive, finite length.
106    #[error("concavity bound {0} is not a positive finite length")]
107    InvalidBound(Scalar),
108    /// A splitter failed to cut a part.
109    #[error("splitting a part failed: {0}")]
110    SplitFailed(String),
111    /// Decomposition did not converge within the part budget.
112    ///
113    /// Reported rather than returning a partial decomposition, whose union
114    /// would silently differ from the input.
115    #[error("decomposition exceeded the {limit} part budget")]
116    BudgetExceeded {
117        /// The cap that was not raised.
118        limit: usize,
119    },
120}
121
122/// How hard to work at making each part convex.
123#[derive(Debug, Clone, Copy, PartialEq)]
124#[non_exhaustive]
125pub enum Strategy {
126    /// Split until every part is convex to within `tolerance`.
127    ///
128    /// The union of the parts reproduces the input. Part count is whatever
129    /// the geometry demands, which for a deeply non-convex solid is large.
130    Exact,
131    /// Stop once every part is convex to within `max_concavity`.
132    ///
133    /// Trades fidelity for part count. The union approximates the input:
134    /// concave pockets shallower than the bound are filled in.
135    Approximate {
136        /// Largest tolerated distance from a part's vertex to its own hull.
137        max_concavity: Scalar,
138    },
139}
140
141/// Whether the parts reproduce the input or merely approximate it.
142#[derive(Debug, Clone, Copy, PartialEq)]
143#[non_exhaustive]
144pub enum Fidelity {
145    /// Every part is convex to within tolerance; the union is the input.
146    Exact,
147    /// Parts are convex to within a bound larger than tolerance.
148    Approximate {
149        /// The bound that was requested.
150        requested: Scalar,
151        /// The largest concavity actually left in any part.
152        ///
153        /// Reported because it is the honest answer: a caller that asked for
154        /// 10mm and got 2mm knows the result is better than it required,
155        /// and one that reads this field cannot mistake the request for the
156        /// outcome.
157        achieved: Scalar,
158    },
159}
160
161/// A solid expressed as convex parts, with the evidence to judge it.
162#[derive(Debug, Clone, PartialEq)]
163#[non_exhaustive]
164pub struct Decomposition {
165    /// The convex parts, in deterministic order.
166    pub parts: Vec<TriMesh>,
167    /// Whether the parts reproduce the input exactly.
168    pub fidelity: Fidelity,
169    /// Splits performed to reach this result.
170    pub splits: usize,
171}
172
173impl Decomposition {
174    /// Whether the input was already convex.
175    pub fn is_single_part(&self) -> bool {
176        self.parts.len() == 1
177    }
178}
179
180/// Largest number of parts before the search is abandoned.
181const MAX_PARTS: usize = 4096;
182
183/// Decompose a closed two-manifold solid into convex parts.
184///
185/// # Errors
186///
187/// Refuses a ragged index buffer, an out-of-range index, an input that is
188/// not a closed two-manifold solid, a non-positive concavity bound, and a
189/// decomposition that exceeds the part budget.
190pub fn convex_decompose(
191    mesh: &TriMesh,
192    strategy: Strategy,
193    tolerance: Tolerance,
194) -> Result<Decomposition, DecomposeError> {
195    convex_decompose_with(mesh, strategy, tolerance, &split::Splitter::HandRolled)
196}
197
198/// Decompose a solid, choosing how parts are cut.
199///
200/// Identical to [`convex_decompose`] except that the caller supplies the
201/// [`Splitter`](split::Splitter). Passing a boolean provider is how an
202/// `algorithms` crate reaches a `providers` one: through the mesh-boolean
203/// contract, which both layers may depend on.
204///
205/// # Errors
206///
207/// As [`convex_decompose`], plus [`DecomposeError::SplitFailed`] when the
208/// supplied splitter cannot cut a part.
209pub fn convex_decompose_with(
210    mesh: &TriMesh,
211    strategy: Strategy,
212    tolerance: Tolerance,
213    splitter: &split::Splitter<'_>,
214) -> Result<Decomposition, DecomposeError> {
215    if mesh.indices.len() % 3 != 0 {
216        return Err(DecomposeError::RaggedIndices(mesh.indices.len()));
217    }
218    let vertex_count = mesh.positions.len();
219    for (triangle, chunk) in mesh.indices.chunks_exact(3).enumerate() {
220        for &index in chunk {
221            if index as usize >= vertex_count {
222                return Err(DecomposeError::IndexOutOfRange(triangle, index));
223            }
224        }
225    }
226
227    // A decomposition only means anything for a solid: the union of parts
228    // reproduces a volume, not a surface. Checking here turns a meaningless
229    // answer into a named refusal.
230    let health = audit_mesh(mesh, tolerance);
231    if !health.is_closed_two_manifold() {
232        return Err(DecomposeError::NotASolid {
233            boundary: health.boundary_edges,
234            non_manifold: health.non_manifold_edges,
235        });
236    }
237
238    let bound = match strategy {
239        Strategy::Exact => tolerance.linear(),
240        Strategy::Approximate { max_concavity } => {
241            if !max_concavity.is_finite() || max_concavity <= 0.0 {
242                return Err(DecomposeError::InvalidBound(max_concavity));
243            }
244            max_concavity
245        }
246    };
247
248    // Work on meshes rather than point sets. A part is the actual solid on
249    // one side of every split, produced by clipping; taking the hull of a
250    // point subset instead would fill in any notch the subset still spans,
251    // and the parts would sum to more volume than the input.
252    let mut pending = vec![mesh.clone()];
253    let mut finished: Vec<TriMesh> = Vec::new();
254    let mut splits = 0usize;
255    let mut achieved: Scalar = 0.0;
256
257    while let Some(part) = pending.pop() {
258        if finished.len() + pending.len() + 1 > MAX_PARTS {
259            return Err(DecomposeError::BudgetExceeded { limit: MAX_PARTS });
260        }
261
262        let Some(reflex) = worst_concavity(&part.positions, &part.indices, tolerance) else {
263            finished.push(part);
264            continue;
265        };
266        if reflex.depth <= bound {
267            achieved = achieved.max(reflex.depth);
268            finished.push(part);
269            continue;
270        }
271
272        // Split on the plane of the face the reflex vertex sticks out past.
273        // Extending an existing face is the standard construction and it
274        // makes progress by definition: everything in front of that plane
275        // is separated from the face that could not see it.
276        let (normal, offset) = (reflex.normal, reflex.offset);
277        let (front, back) = splitter.split(&part, normal, offset, tolerance)?;
278
279        match (front, back) {
280            (Some(front), Some(back))
281                if front.triangle_count() > 0 && back.triangle_count() > 0 =>
282            {
283                splits += 1;
284                pending.push(front);
285                pending.push(back);
286            }
287            // The plane failed to separate the part. Keeping it whole with
288            // its concavity reported is honest; looping on a split that
289            // makes no progress is not.
290            _ => {
291                achieved = achieved.max(reflex.depth);
292                finished.push(part);
293            }
294        }
295    }
296
297    // Deterministic ordering: parts are keyed by their extreme corner, which
298    // is a property of the geometry rather than of the traversal, so the
299    // same solid decomposes to the same sequence on every run.
300    finished.sort_by(|a, b| {
301        let ka = order_key(&a.positions);
302        let kb = order_key(&b.positions);
303        ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
304    });
305
306    let parts = finished;
307
308    let fidelity = match strategy {
309        Strategy::Exact => Fidelity::Exact,
310        Strategy::Approximate { max_concavity } => Fidelity::Approximate {
311            requested: max_concavity,
312            achieved,
313        },
314    };
315
316    Ok(Decomposition {
317        parts,
318        fidelity,
319        splits,
320    })
321}
322
323/// Sort key: the lexicographically smallest corner of a part.
324fn order_key(points: &[Point3]) -> (Scalar, Scalar, Scalar) {
325    let mut best = (Scalar::INFINITY, Scalar::INFINITY, Scalar::INFINITY);
326    for p in points {
327        let key = (p.x, p.y, p.z);
328        if key < best {
329            best = key;
330        }
331    }
332    best
333}
334
335/// A reflex feature: a vertex sticking out past one of the solid's own faces.
336struct Reflex {
337    /// How far the vertex lies in front of the face plane.
338    depth: Scalar,
339    /// The offending vertex.
340    apex: Point3,
341    /// Outward normal of the face it sticks out past.
342    normal: Vec3,
343    /// Plane offset of that face.
344    offset: Scalar,
345}
346
347/// Depth and location of the worst reflex feature in a solid.
348///
349/// A solid is convex exactly when every vertex lies behind every face
350/// plane. Where a vertex lies IN FRONT of some face plane, the solid
351/// bulges past that face -- a reflex feature -- and the distance in front
352/// is how deep the offending notch is.
353///
354/// Measuring against face planes rather than against the convex hull is
355/// what makes this work. A reflex vertex generally lies exactly ON the
356/// hull surface (the hull spans the notch with a face THROUGH that
357/// vertex), so hull distance reports zero concavity for the very feature
358/// that needs splitting.
359///
360/// `None` when the part is convex to within `tolerance`.
361fn worst_concavity(positions: &[Point3], indices: &[u32], tolerance: Tolerance) -> Option<Reflex> {
362    let linear = tolerance.linear();
363    let mut worst: Option<Reflex> = None;
364
365    // Bounding sphere over the vertices. For a unit normal `n`, no
366    // vertex can satisfy dot(v, n) > centre.dot(n) + radius, so the
367    // deepest a vertex could sit past a face plane is bounded without
368    // touching a single vertex.
369    //
370    // The bound is CONSERVATIVE: it can only skip a face when no vertex
371    // could qualify, so the result is identical to scanning every
372    // vertex of every face -- including which face and vertex win a
373    // tie. An AABB corner was tried first and prunes nothing on a
374    // round mesh: it overshoots the true extent by up to sqrt(3).
375    let &first = positions.first()?;
376    let (mut low, mut high) = (first, first);
377    for point in positions {
378        low = Point3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
379        high = Point3::new(
380            high.x.max(point.x),
381            high.y.max(point.y),
382            high.z.max(point.z),
383        );
384    }
385    let centre = (low + high) * 0.5;
386    let radius = positions
387        .iter()
388        .fold(0.0, |m: Scalar, p| m.max((*p - centre).length()));
389
390    for chunk in indices.chunks_exact(3) {
391        let a = positions[chunk[0] as usize];
392        let b = positions[chunk[1] as usize];
393        let c = positions[chunk[2] as usize];
394
395        let normal = (b - a).cross(c - a);
396        let area = normal.length();
397        // A degenerate face has no plane to test against; skip rather than
398        // divide by a vanishing length and invent a direction.
399        if area <= linear * linear {
400            continue;
401        }
402        let unit = normal / area;
403
404        // Deepest any vertex could sit past this plane, from the
405        // bounding sphere alone -- no vertex touched.
406        let reach = centre.dot(unit) + radius - a.dot(unit);
407
408        // A vertex must clear `linear` to be a candidate at all. Against
409        // an incumbent it must also reach the bottom of the tie window,
410        // `depth - linear`, since an equal-depth vertex can still win on
411        // coordinate order.
412        //
413        // Instrumented: the window is entered often (729 faces across
414        // this suite) but no face inside it ever held a tie-breaking
415        // winner, so pruning at `depth` behaves identically on every
416        // input tried. The wider bound is kept because it CANNOT drop a
417        // tie, not because a test distinguishes the two.
418        let threshold = worst
419            .as_ref()
420            .map_or(linear, |current| (current.depth - linear).max(linear));
421        if reach <= threshold {
422            continue;
423        }
424
425        for (index, &point) in positions.iter().enumerate() {
426            let ahead = (point - a).dot(unit);
427            if ahead <= linear {
428                continue;
429            }
430            // Deeper wins; equal depth breaks toward the lower index so the
431            // choice is reproducible rather than dependent on iteration
432            // order over an unordered structure.
433            let better = match &worst {
434                None => true,
435                Some(current) => {
436                    ahead > current.depth + linear
437                        || ((ahead - current.depth).abs() <= linear
438                            && (point.x, point.y, point.z)
439                                < (current.apex.x, current.apex.y, current.apex.z))
440                }
441            };
442            if better {
443                let _ = index;
444                // Record the FACE the vertex sticks out past, not just how
445                // far. Splitting on that face's own plane is what removes
446                // the reflex feature; a bounding-box axis through the same
447                // point need not separate the notch at all.
448                worst = Some(Reflex {
449                    depth: ahead,
450                    apex: point,
451                    normal: unit,
452                    offset: a.dot(unit),
453                });
454            }
455        }
456    }
457    worst
458}
459
460/// Clip a closed solid by a plane, keeping the side the normal points away
461/// from and capping the opening so the result is closed again.
462///
463/// Sutherland-Hodgman per triangle: each face is clipped to the half-space
464/// and re-fanned into triangles. The opening is then capped by measuring
465/// which edges the clipped shell left used only once, which is what keeps
466/// the part a solid rather than an open shell.
467fn clip(mesh: &TriMesh, normal: Vec3, offset: Scalar, tolerance: Tolerance) -> Option<TriMesh> {
468    let linear = tolerance.linear();
469    let mut positions: Vec<Point3> = Vec::new();
470    let mut indices: Vec<u32> = Vec::new();
471    let mut lookup: AHashMap<(u64, u64, u64), u32> = AHashMap::new();
472
473    let intern = |point: Point3, positions: &mut Vec<Point3>, lookup: &mut AHashMap<_, _>| {
474        let key = (
475            quantise(point.x, linear),
476            quantise(point.y, linear),
477            quantise(point.z, linear),
478        );
479        *lookup.entry(key).or_insert_with(|| {
480            positions.push(point);
481            (positions.len() - 1) as u32
482        })
483    };
484
485    for chunk in mesh.indices.chunks_exact(3) {
486        let triangle = [
487            mesh.positions[chunk[0] as usize],
488            mesh.positions[chunk[1] as usize],
489            mesh.positions[chunk[2] as usize],
490        ];
491        let distances = [
492            triangle[0].dot(normal) - offset,
493            triangle[1].dot(normal) - offset,
494            triangle[2].dot(normal) - offset,
495        ];
496
497        // A face lying IN the clip plane belongs to exactly one side, and
498        // its distances cannot say which: every corner reads as "on the
499        // plane", so both sides would keep it, duplicating the face and
500        // double-counting its volume. Its own normal settles it -- a
501        // coplanar face bounds the material on the side it faces away from.
502        if distances.iter().all(|d| d.abs() <= linear) {
503            let face = (triangle[1] - triangle[0]).cross(triangle[2] - triangle[0]);
504            if face.dot(normal) > 0.0 {
505                let a = intern(triangle[0], &mut positions, &mut lookup);
506                let b = intern(triangle[1], &mut positions, &mut lookup);
507                let c = intern(triangle[2], &mut positions, &mut lookup);
508                if a != b && b != c && c != a {
509                    indices.extend_from_slice(&[a, b, c]);
510                }
511            }
512            continue;
513        }
514
515        // Sutherland-Hodgman: walk the triangle's edges, keeping corners
516        // behind the plane and the points where an edge crosses it.
517        let mut kept: Vec<Point3> = Vec::new();
518        for corner in 0..3 {
519            let current = triangle[corner];
520            let next = triangle[(corner + 1) % 3];
521            let d_current = distances[corner];
522            let d_next = distances[(corner + 1) % 3];
523
524            if d_current <= linear {
525                kept.push(current);
526            }
527            // The crossing point is shared by both parts, which is what
528            // makes their union seamless along the cut.
529            if (d_current < -linear && d_next > linear) || (d_current > linear && d_next < -linear)
530            {
531                let t = d_current / (d_current - d_next);
532                kept.push(current + (next - current) * t);
533            }
534        }
535        if kept.len() < 3 {
536            continue;
537        }
538
539        let anchor = intern(kept[0], &mut positions, &mut lookup);
540        for corner in 1..kept.len() - 1 {
541            let b = intern(kept[corner], &mut positions, &mut lookup);
542            let c = intern(kept[corner + 1], &mut positions, &mut lookup);
543            if anchor != b && b != c && c != anchor {
544                indices.extend_from_slice(&[anchor, b, c]);
545            }
546        }
547    }
548
549    if indices.is_empty() {
550        return None;
551    }
552
553    // Cap whatever the clipping actually left open.
554    //
555    // Earlier versions PREDICTED the cut boundary from the input mesh,
556    // deciding per triangle whether an edge belonged to the cross-section.
557    // That oscillated between leaving holes and laying the cap over
558    // existing walls, because whether a face standing on the plane bounds
559    // THIS part is a global question a single triangle cannot answer.
560    //
561    // Measuring instead of predicting removes the question entirely. In a
562    // closed shell every undirected edge is used exactly twice, so the
563    // edges used ONCE are precisely the hole -- whatever the clipping did
564    // upstream. The cap can then be neither too generous nor too strict.
565    let shell = TriMesh::new(positions.clone(), indices.clone());
566    let adjacency = EdgeAdjacency::build(&shell);
567    let open_edges: Vec<(Point3, Point3)> = adjacency
568        .boundary_edges()
569        .map(|edge| {
570            let (a, b) = edge.endpoints();
571            (positions[a as usize], positions[b as usize])
572        })
573        .collect();
574
575    if open_edges.is_empty() {
576        return Some(TriMesh::new(positions, indices));
577    }
578
579    for loop_points in stitch_loops(&open_edges, linear) {
580        if loop_points.len() < 3 {
581            continue;
582        }
583        // Ear clipping works in 2D, so express the loop in the cut plane's
584        // own basis. Any orthonormal pair perpendicular to the normal does.
585        let (axis_u, axis_v) = plane_basis(normal);
586        let origin = loop_points[0];
587        let flat: Vec<Point2> = loop_points
588            .iter()
589            .map(|p| {
590                let d = *p - origin;
591                Point2::new(d.dot(axis_u), d.dot(axis_v))
592            })
593            .collect();
594
595        // Ear clipping needs a counter-clockwise ring; a clockwise one is
596        // reversed rather than refused, since the winding of a cut loop is
597        // an artefact of traversal, not of the geometry.
598        let area: Scalar = flat
599            .iter()
600            .enumerate()
601            .map(|(k, p)| {
602                let q = flat[(k + 1) % flat.len()];
603                p.x * q.y - q.x * p.y
604            })
605            .sum();
606        let (flat, loop_points) = if area < 0.0 {
607            let mut f = flat;
608            let mut l = loop_points;
609            f.reverse();
610            l.reverse();
611            (f, l)
612        } else {
613            (flat, loop_points)
614        };
615
616        let Ok(fan) = axiolid_reference::polygon::triangulate_simple(&flat) else {
617            continue;
618        };
619        for triple in fan {
620            let a = intern(loop_points[triple[0] as usize], &mut positions, &mut lookup);
621            let b = intern(loop_points[triple[1] as usize], &mut positions, &mut lookup);
622            let c = intern(loop_points[triple[2] as usize], &mut positions, &mut lookup);
623            if a == b || b == c || c == a {
624                continue;
625            }
626            // The cap faces along the clip normal, opposite the material
627            // that was removed, so the shell stays consistently outward.
628            let wound = (positions[b as usize] - positions[a as usize])
629                .cross(positions[c as usize] - positions[a as usize]);
630            if wound.dot(normal) >= 0.0 {
631                indices.extend_from_slice(&[a, b, c]);
632            } else {
633                indices.extend_from_slice(&[a, c, b]);
634            }
635        }
636    }
637
638    Some(TriMesh::new(positions, indices))
639}
640
641/// Chain unordered cut edges into closed loops.
642///
643/// Clipping produces the cut edges one triangle at a time, in no
644/// particular order. A cap can only be triangulated once those edges are
645/// walked into a ring, so each edge is joined to the next one sharing an
646/// endpoint until the loop closes.
647///
648/// Endpoints are matched on a tolerance lattice: the same crossing point
649/// computed from two adjacent triangles differs in the last few bits, and
650/// exact comparison would leave every loop broken.
651fn stitch_loops(edges: &[(Point3, Point3)], linear: Scalar) -> Vec<Vec<Point3>> {
652    let key = |p: &Point3| {
653        (
654            quantise(p.x, linear),
655            quantise(p.y, linear),
656            quantise(p.z, linear),
657        )
658    };
659
660    let mut adjacency: BTreeMap<(u64, u64, u64), Vec<usize>> = BTreeMap::new();
661    for (index, (from, to)) in edges.iter().enumerate() {
662        adjacency.entry(key(from)).or_default().push(index);
663        adjacency.entry(key(to)).or_default().push(index);
664    }
665
666    let mut used = vec![false; edges.len()];
667    let mut loops = Vec::new();
668
669    for start in 0..edges.len() {
670        if used[start] {
671            continue;
672        }
673        used[start] = true;
674        let mut ring = vec![edges[start].0, edges[start].1];
675        let mut tail = edges[start].1;
676
677        while let Some(candidates) = adjacency.get(&key(&tail)) {
678            let mut advanced = false;
679            for &next in candidates {
680                if used[next] {
681                    continue;
682                }
683                let (from, to) = edges[next];
684                let other = if key(&from) == key(&tail) {
685                    to
686                } else if key(&to) == key(&tail) {
687                    from
688                } else {
689                    continue;
690                };
691                used[next] = true;
692                // Closing the ring: stop rather than repeat the first point.
693                if key(&other) == key(&ring[0]) {
694                    advanced = false;
695                    break;
696                }
697                ring.push(other);
698                tail = other;
699                advanced = true;
700                break;
701            }
702            if !advanced {
703                break;
704            }
705        }
706        if ring.len() >= 3 {
707            loops.push(ring);
708        }
709    }
710    loops
711}
712
713/// Any orthonormal basis of the plane perpendicular to `normal`.
714fn plane_basis(normal: Vec3) -> (Vec3, Vec3) {
715    // Seed against the axis the normal is least aligned with, so the cross
716    // product is well conditioned rather than near zero.
717    let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
718        Vec3::X
719    } else if normal.y.abs() <= normal.z.abs() {
720        Vec3::Y
721    } else {
722        Vec3::Z
723    };
724    let u = normal.cross(seed).normalize();
725    let v = normal.cross(u);
726    (u, v)
727}
728
729/// Snap a coordinate to a tolerance-sized lattice for welding.
730///
731/// Two clipped faces meeting at a cut must agree on the crossing vertex, or
732/// the part is not closed. Comparing raw bits is too strict: the same point
733/// computed from two different edges differs in the last ulp.
734fn quantise(value: Scalar, linear: Scalar) -> u64 {
735    let step = linear.max(Scalar::EPSILON);
736    let snapped = (value / step).round();
737    snapped.to_bits()
738}
739
740#[cfg(test)]
741mod concavity_tests {
742    use super::*;
743
744    fn tol() -> Tolerance {
745        Tolerance::new(1e-9, 1e-12).expect("tolerance")
746    }
747
748    /// The pre-prune implementation, kept verbatim as the oracle. The
749    /// prune is only correct if it agrees with this on every input.
750    fn unpruned(positions: &[Point3], indices: &[u32], tolerance: Tolerance) -> Option<Reflex> {
751        let linear = tolerance.linear();
752        let mut worst: Option<Reflex> = None;
753        for chunk in indices.chunks_exact(3) {
754            let a = positions[chunk[0] as usize];
755            let b = positions[chunk[1] as usize];
756            let c = positions[chunk[2] as usize];
757            let normal = (b - a).cross(c - a);
758            let area = normal.length();
759            if area <= linear * linear {
760                continue;
761            }
762            let unit = normal / area;
763            for &point in positions.iter() {
764                let ahead = (point - a).dot(unit);
765                if ahead <= linear {
766                    continue;
767                }
768                let better = match &worst {
769                    None => true,
770                    Some(current) => {
771                        ahead > current.depth + linear
772                            || ((ahead - current.depth).abs() <= linear
773                                && (point.x, point.y, point.z)
774                                    < (current.apex.x, current.apex.y, current.apex.z))
775                    }
776                };
777                if better {
778                    worst = Some(Reflex {
779                        depth: ahead,
780                        apex: point,
781                        normal: unit,
782                        offset: a.dot(unit),
783                    });
784                }
785            }
786        }
787        worst
788    }
789
790    fn agree(label: &str, mesh: &TriMesh) {
791        let want = unpruned(&mesh.positions, &mesh.indices, tol());
792        let got = worst_concavity(&mesh.positions, &mesh.indices, tol());
793        match (want, got) {
794            (None, None) => {}
795            (Some(w), Some(g)) => {
796                assert!((w.depth - g.depth).abs() < 1e-12, "{label}: depth");
797                // Same apex AND same plane: the caller splits on this
798                // plane, so a different face changes the decomposition.
799                assert_eq!(w.apex, g.apex, "{label}: apex");
800                assert_eq!(w.normal, g.normal, "{label}: normal");
801                assert!((w.offset - g.offset).abs() < 1e-12, "{label}: offset");
802            }
803            (a, b) => panic!(
804                "{label}: presence differs, {} vs {}",
805                a.is_some(),
806                b.is_some()
807            ),
808        }
809    }
810
811    fn cube() -> TriMesh {
812        let p = vec![
813            Point3::new(-1.0, -1.0, -1.0),
814            Point3::new(1.0, -1.0, -1.0),
815            Point3::new(1.0, 1.0, -1.0),
816            Point3::new(-1.0, 1.0, -1.0),
817            Point3::new(-1.0, -1.0, 1.0),
818            Point3::new(1.0, -1.0, 1.0),
819            Point3::new(1.0, 1.0, 1.0),
820            Point3::new(-1.0, 1.0, 1.0),
821        ];
822        let i = vec![
823            0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 2, 3, 7, 2, 7, 6, 1, 2, 6, 1, 6,
824            5, 0, 4, 7, 0, 7, 3u32,
825        ];
826        TriMesh::new(p, i)
827    }
828
829    /// Extruded L: a genuine reflex corner, and enough symmetry that
830    /// several faces report the same depth.
831    fn l_shape() -> TriMesh {
832        let footprint = [
833            (0.0, 0.0),
834            (2.0, 0.0),
835            (2.0, 1.0),
836            (1.0, 1.0),
837            (1.0, 2.0),
838            (0.0, 2.0),
839        ];
840        let mut positions = Vec::new();
841        for &(x, y) in &footprint {
842            positions.push(Point3::new(x, y, 0.0));
843        }
844        for &(x, y) in &footprint {
845            positions.push(Point3::new(x, y, 1.0));
846        }
847        let n = footprint.len() as u32;
848        let mut indices = Vec::new();
849        for &(a, b, c) in &[(0u32, 1, 2), (0, 2, 3), (0, 3, 4), (0, 4, 5)] {
850            indices.extend_from_slice(&[a, c, b]);
851            indices.extend_from_slice(&[a + n, b + n, c + n]);
852        }
853        for i in 0..n {
854            let j = (i + 1) % n;
855            indices.extend_from_slice(&[i, j, j + n]);
856            indices.extend_from_slice(&[i, j + n, i + n]);
857        }
858        TriMesh::new(positions, indices)
859    }
860
861    #[test]
862    fn prune_agrees_on_a_convex_solid() {
863        agree("cube", &cube());
864    }
865
866    /// Pull one corner inward so a genuine reflex feature exists: the
867    /// convex case alone would let a prune that skips EVERYTHING pass.
868    #[test]
869    fn prune_agrees_on_a_dented_solid() {
870        let mut mesh = cube();
871        mesh.positions[6] = Point3::new(0.1, 0.1, 0.1);
872        agree("dented", &mesh);
873        assert!(
874            worst_concavity(&mesh.positions, &mesh.indices, tol()).is_some(),
875            "the dent must register as concavity, or this proves nothing"
876        );
877    }
878
879    /// Many shapes, deterministic pseudo-random. A handcrafted fixture
880    /// exercises one path through the tie-break; this sweeps enough
881    /// geometry to hit equal-depth cases the prune must not skip.
882    #[test]
883    fn prune_agrees_across_many_dents() {
884        let mut seed = 0x9E3779B97F4A7C15u64;
885        let mut next = move || {
886            seed ^= seed << 13;
887            seed ^= seed >> 7;
888            seed ^= seed << 17;
889            (seed >> 11) as f64 / (1u64 << 53) as f64
890        };
891        for trial in 0..200 {
892            let mut mesh = cube();
893            for _ in 0..3 {
894                let which = (next() * 8.0) as usize % 8;
895                let scale = 0.2 + next() * 1.4;
896                mesh.positions[which] *= scale;
897            }
898            agree(&format!("trial {trial}"), &mesh);
899        }
900    }
901
902    /// Equal depths across several faces are what the tie-break exists
903    /// to resolve, and what a prune clamped to the incumbent depth
904    /// would skip. A symmetric dent produces them exactly; a coarse
905    /// tolerance widens the tie window enough to be reachable.
906    #[test]
907    fn prune_respects_the_tie_window() {
908        let coarse = Tolerance::new(0.05, 1e-12).expect("tolerance");
909        // Pull four top corners inward by the SAME amount: several
910        // faces then report identical reflex depth.
911        // Push four corners OUTWARD symmetrically: spikes give several
912        // faces an identical, genuinely-reflex depth.
913        let mesh = l_shape();
914        let want = unpruned(&mesh.positions, &mesh.indices, coarse);
915        let got = worst_concavity(&mesh.positions, &mesh.indices, coarse);
916        let (want, got) = (want.expect("reflex"), got.expect("reflex"));
917        assert!((want.depth - got.depth).abs() < 1e-12, "depth differs");
918        assert!(
919            (want.apex - got.apex).length() < 1e-12,
920            "same depth, different apex: the tie-break was not preserved"
921        );
922    }
923
924    /// Sweep tolerance so the tie window spans the gap between the
925    /// bounding-sphere reach and the true depth. Somewhere in that
926    /// sweep a face is skipped by a prune clamped to the incumbent
927    /// depth but kept by one that honours the window -- if the two
928    /// ever differ, this finds it.
929    #[test]
930    fn prune_matches_the_oracle_across_tolerances() {
931        let meshes = [("l", l_shape()), ("cube", cube())];
932        for (name, mesh) in &meshes {
933            let mut linear = 1e-12;
934            while linear < 2.0 {
935                let t = Tolerance::new(linear, 1e-12).expect("tolerance");
936                let want = unpruned(&mesh.positions, &mesh.indices, t);
937                let got = worst_concavity(&mesh.positions, &mesh.indices, t);
938                match (want, got) {
939                    (None, None) => {}
940                    (Some(a), Some(b)) => {
941                        assert!(
942                            (a.depth - b.depth).abs() < 1e-12 && (a.apex - b.apex).length() < 1e-12,
943                            "{name} at linear={linear:e}: prune changed the answer"
944                        );
945                    }
946                    (a, b) => panic!(
947                        "{name} at linear={linear:e}: presence differs, {} vs {}",
948                        a.is_some(),
949                        b.is_some()
950                    ),
951                }
952                linear *= 1.5;
953            }
954        }
955    }
956
957    /// Randomised search for an input where a prune clamped to the
958    /// incumbent depth differs from one honouring the tie window.
959    /// Coarse tolerances widen the window; random point sets give the
960    /// bounding-sphere bound a chance to be tight.
961    #[test]
962    fn prune_matches_the_oracle_on_random_solids() {
963        let mut seed = 0xD1B54A32D192ED03u64;
964        let mut next = move || {
965            seed ^= seed << 13;
966            seed ^= seed >> 7;
967            seed ^= seed << 17;
968            (seed >> 11) as f64 / (1u64 << 53) as f64
969        };
970        for trial in 0..400 {
971            // Quantised coordinates: exact ties are then reachable,
972            // which continuous random values would never produce.
973            let mut mesh = cube();
974            for slot in 0..8 {
975                let q = |v: f64| (v * 4.0).round() / 4.0;
976                let p = mesh.positions[slot];
977                let s = 0.25 + (next() * 8.0).floor() / 4.0;
978                mesh.positions[slot] = Point3::new(q(p.x * s), q(p.y * s), q(p.z * s));
979            }
980            for step in 0..6 {
981                let linear = 0.01 * 4.0_f64.powi(step);
982                let t = Tolerance::new(linear, 1e-12).expect("tolerance");
983                let want = unpruned(&mesh.positions, &mesh.indices, t);
984                let got = worst_concavity(&mesh.positions, &mesh.indices, t);
985                match (want, got) {
986                    (None, None) => {}
987                    (Some(a), Some(b)) => assert!(
988                        (a.depth - b.depth).abs() < 1e-12 && (a.apex - b.apex).length() < 1e-12,
989                        "trial {trial} linear={linear}: prune changed the answer"
990                    ),
991                    (a, b) => panic!(
992                        "trial {trial} linear={linear}: presence differs, {} vs {}",
993                        a.is_some(),
994                        b.is_some()
995                    ),
996                }
997            }
998        }
999    }
1000
1001    /// Constructed, not searched: two spikes at equal depth, where the
1002    /// second face has a bounding-sphere reach just below the
1003    /// incumbent depth. A prune clamped to that depth skips it and
1004    /// loses the tie-break; one honouring the window keeps it.
1005    #[test]
1006    fn prune_keeps_faces_inside_the_tie_window() {
1007        // Coarse tolerance so the window has real width.
1008        let t = Tolerance::new(0.25, 1e-12).expect("tolerance");
1009        // Sweep asymmetric spikes: some trial puts a tie-breaking
1010        // vertex behind a face whose reach sits inside the window.
1011        for a in 1..14 {
1012            for b in 1..14 {
1013                let mut mesh = cube();
1014                let sa = 1.0 + a as Scalar * 0.125;
1015                let sb = 1.0 + b as Scalar * 0.125;
1016                let p4 = mesh.positions[4];
1017                let p6 = mesh.positions[6];
1018                mesh.positions[4] = Point3::new(p4.x * sa, p4.y * sa, p4.z * sa);
1019                mesh.positions[6] = Point3::new(p6.x * sb, p6.y * sb, p6.z * sb);
1020                let want = unpruned(&mesh.positions, &mesh.indices, t);
1021                let got = worst_concavity(&mesh.positions, &mesh.indices, t);
1022                match (want, got) {
1023                    (None, None) => {}
1024                    (Some(x), Some(y)) => assert!(
1025                        (x.depth - y.depth).abs() < 1e-12 && (x.apex - y.apex).length() < 1e-12,
1026                        "a={a} b={b}: prune changed the answer"
1027                    ),
1028                    (x, y) => panic!("a={a} b={b}: {} vs {}", x.is_some(), y.is_some()),
1029                }
1030            }
1031        }
1032    }
1033
1034    /// The bounding sphere is computed from the first vertex, so an
1035    /// empty mesh must not index it.
1036    #[test]
1037    fn empty_input_is_none() {
1038        assert!(worst_concavity(&[], &[], tol()).is_none());
1039    }
1040
1041    /// Degenerate faces are skipped before the plane is formed; the
1042    /// prune must not change that.
1043    #[test]
1044    fn degenerate_faces_are_still_skipped() {
1045        let p = vec![Point3::ZERO, Point3::ZERO, Point3::ZERO];
1046        assert!(worst_concavity(&p, &[0, 1, 2], tol()).is_none());
1047    }
1048}