axiolid_construct/hull.rs
1//! Convex hull of a 3D point set, decided by certified predicates (#76).
2//!
3//! An incremental hull needs one geometric decision: is a point outside a
4//! face's plane? `orient3d` answers exactly, so no epsilon appears here.
5//! When the predicate cannot decide, the hull refuses.
6
7use axiolid_contracts::{GeomError, GeomResult};
8use axiolid_core::Point3;
9use axiolid_guarantees::Sign;
10use axiolid_mesh::TriMesh;
11use axiolid_predicates::orient3d;
12use std::collections::BTreeMap;
13
14/// Convex hull of a point set as a closed, outward-oriented `TriMesh`.
15///
16/// Interior points are absorbed: adding a point inside the hull cannot
17/// change it, which is the property the tests assert.
18///
19/// # Errors
20///
21/// Refuses non-finite coordinates, fewer than four points, and inputs whose
22/// points are all collinear or all coplanar. Each is a distinct variant.
23pub fn convex_hull(points: &[Point3]) -> GeomResult<TriMesh> {
24 for (index, point) in points.iter().enumerate() {
25 if !point.is_finite() {
26 return Err(GeomError::InvalidInput(format!(
27 "hull point {index} is not finite"
28 )));
29 }
30 }
31 if points.len() < 4 {
32 return Err(GeomError::InvalidInput(format!(
33 "a hull needs at least 4 points, got {}",
34 points.len()
35 )));
36 }
37
38 let seed = initial_tetrahedron(points)?;
39 let mut faces = seed_faces(&seed, points);
40
41 for (index, &point) in points.iter().enumerate() {
42 if seed.contains(&index) {
43 continue;
44 }
45 add_point(&mut faces, points, point);
46 }
47
48 Ok(assemble(&faces, points))
49}
50
51/// Four input indices spanning a non-degenerate tetrahedron.
52///
53/// Built in stages so each degeneracy is named: two distinct points, then a
54/// third off that line, then a fourth off that plane. Failing at a stage
55/// tells the caller exactly which dimension the input collapsed into.
56fn initial_tetrahedron(points: &[Point3]) -> GeomResult<[usize; 4]> {
57 let a = 0;
58 let b = (1..points.len())
59 .find(|&i| points[i] != points[a])
60 .ok_or_else(|| GeomError::Degenerate("all hull points are collinear".to_owned()))?;
61
62 // A third point off the line ab: collinear triples leave orient3d
63 // undecided for every fourth point, so they must be excluded here.
64 let c = (0..points.len())
65 .find(|&i| i != a && i != b && !collinear(points[a], points[b], points[i]))
66 .ok_or_else(|| GeomError::Degenerate("all hull points are collinear".to_owned()))?;
67
68 let d = (0..points.len())
69 .find(|&i| {
70 i != a
71 && i != b
72 && i != c
73 && orient3d(points[a], points[b], points[c], points[i]).sign() != Some(Sign::Zero)
74 })
75 .ok_or_else(|| GeomError::Degenerate("all hull points are coplanar".to_owned()))?;
76
77 Ok([a, b, c, d])
78}
79
80/// Whether three points lie on one line, decided without an epsilon.
81///
82/// Collinearity in 3D means the triangle they span has zero area in every
83/// projection. Testing `orient3d` against two independent off-plane probes
84/// would still miss cases, so the cross product's exact zero is used: the
85/// coordinates are the caller's own, and no arithmetic is performed beyond
86/// one subtraction and one cross.
87fn collinear(a: Point3, b: Point3, c: Point3) -> bool {
88 (b - a).cross(c - a).length_squared() == 0.0
89}
90
91/// The seed tetrahedron's four faces, each wound to face outward.
92///
93/// `orient3d(a, b, c, d) == Positive` means `d` is above the plane of
94/// `abc`, so `abc` viewed from outside is wound the other way. The base
95/// triangle is flipped when needed so every seed face points away from the
96/// fourth vertex, establishing the outward convention the incremental step
97/// then preserves.
98fn seed_faces(seed: &[usize; 4], points: &[Point3]) -> Vec<[usize; 3]> {
99 let [a, b, c, d] = *seed;
100 // Establish the outward convention explicitly rather than assuming the
101 // input order supplies it. If `d` is above `abc`, then `abc` as written
102 // faces INWARD, and every face built from it would too — which makes the
103 // whole hull inside-out and deletes itself on the first interior point.
104 let (a, b, c) =
105 if orient3d(points[a], points[b], points[c], points[d]).sign() == Some(Sign::Positive) {
106 (a, c, b)
107 } else {
108 (a, b, c)
109 };
110 vec![[a, b, c], [a, d, c], [a, b, d], [b, c, d]]
111 .into_iter()
112 .map(|face| orient_outward(face, points, d, a, b, c))
113 .collect()
114}
115
116/// Wind one seed face so it faces away from the tetrahedron's interior.
117///
118/// The interior is represented by the centroid of the four seed vertices:
119/// a face is correctly wound when the centroid is strictly behind it.
120fn orient_outward(
121 face: [usize; 3],
122 points: &[Point3],
123 d: usize,
124 a: usize,
125 b: usize,
126 c: usize,
127) -> [usize; 3] {
128 let centroid = (points[a] + points[b] + points[c] + points[d]) / 4.0;
129 if orient3d(points[face[0]], points[face[1]], points[face[2]], centroid).sign()
130 == Some(Sign::Negative)
131 {
132 [face[0], face[2], face[1]]
133 } else {
134 face
135 }
136}
137
138/// Absorb one point: delete the faces it can see, re-cover the hole.
139///
140/// The faces a point sees form a connected patch. Their boundary — edges
141/// used by exactly one deleted face — is the horizon, and joining the point
142/// to each horizon edge closes the hull again. Interior points see nothing,
143/// so this is a no-op for them, which is why adding interior points cannot
144/// change the result.
145fn add_point(faces: &mut Vec<[usize; 3]>, points: &[Point3], point: Point3) {
146 let mut visible = Vec::new();
147 let mut kept = Vec::new();
148 for &face in faces.iter() {
149 if sees(points, face, point) {
150 visible.push(face);
151 } else {
152 kept.push(face);
153 }
154 }
155 if visible.is_empty() {
156 return;
157 }
158
159 // Horizon edges are used once among the visible faces; edges used twice
160 // are interior to the deleted patch and must not be re-covered.
161 let mut usage: BTreeMap<(usize, usize), i32> = BTreeMap::new();
162 for face in &visible {
163 for (u, v) in edges_of(*face) {
164 *usage.entry((u.min(v), u.max(v))).or_insert(0) += 1;
165 }
166 }
167
168 let index = points.iter().position(|p| *p == point).unwrap_or(0);
169 for face in &visible {
170 for (u, v) in edges_of(*face) {
171 if usage[&(u.min(v), u.max(v))] == 1 {
172 kept.push([u, v, index]);
173 }
174 }
175 }
176 *faces = kept;
177}
178
179/// Whether `point` lies strictly outside `face`'s plane.
180///
181/// This is the whole geometric content of the algorithm, and it is exactly
182/// decided. A point exactly ON the plane is NOT visible: treating it as
183/// visible would delete a face and re-cover it with a zero-area triangle.
184fn sees(points: &[Point3], face: [usize; 3], point: Point3) -> bool {
185 orient3d(points[face[0]], points[face[1]], points[face[2]], point).sign()
186 == Some(Sign::Negative)
187}
188
189/// The three directed edges of a face, in winding order.
190fn edges_of(face: [usize; 3]) -> [(usize, usize); 3] {
191 [(face[0], face[1]), (face[1], face[2]), (face[2], face[0])]
192}
193
194/// Pack faces into a `TriMesh`, keeping only the vertices actually used.
195///
196/// Interior points were absorbed without contributing a vertex, so emitting
197/// every input position would leave unreferenced vertices that make the mesh
198/// look defective to the v0.7 diagnosis.
199fn assemble(faces: &[[usize; 3]], points: &[Point3]) -> TriMesh {
200 let mut remap: BTreeMap<usize, u32> = BTreeMap::new();
201 let mut positions = Vec::new();
202 let mut indices = Vec::new();
203 for face in faces {
204 for &corner in face {
205 let next = u32::try_from(positions.len()).unwrap_or(u32::MAX);
206 let slot = *remap.entry(corner).or_insert_with(|| {
207 positions.push(points[corner]);
208 next
209 });
210 indices.push(slot);
211 }
212 }
213 TriMesh::new(positions, indices)
214}