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}