axiolid_construct/polyhedron.rs
1//! Exact boolean over general planar-faced solids (#77).
2//!
3//! # Why this is not a BSP tree
4//!
5//! A BSP boolean constructs split points recursively, so each generation of
6//! cuts is computed from coordinates that were themselves computed. Error
7//! compounds with depth and the exactness claim decays silently.
8//!
9//! Here every fragment is carried as a polygon whose plane is one of the
10//! ORIGINAL input planes, never a derived one. A face is split only against
11//! input planes, so a vertex is at worst one intersection away from input
12//! data. Classification then asks a certified predicate which side of the
13//! other solid a fragment lies on.
14//!
15//! # Collapsed fragments are not dropped
16//!
17//! Split points are constructed in f64 (ADR 0045, `plane_crossing`), so
18//! after many operations a vertex two operands should share can land a few
19//! ULPs apart, and a split through it emits a fragment that encloses no
20//! area. Deleting such fragments was tried and reverted (`40b5069`): the
21//! chain then completes, but the holes it leaves make the shell integrate to
22//! a plausibly wrong volume. A refusal is actionable; a wrong volume is
23//! silent. Do not reintroduce a drop-based fix (#199).
24
25use crate::boolean_exact::unsupported;
26use axiolid_contracts::{GeomError, GeomResult};
27use axiolid_core::{Point2, Point3, Vec3};
28use axiolid_guarantees::Sign;
29use axiolid_mesh::TriMesh;
30use axiolid_predicates::{orient2d, orient3d};
31use std::collections::BTreeMap;
32
33/// A closed solid bounded by planar polygonal faces.
34///
35/// Each face is a vertex ring wound counter-clockwise seen from outside, so
36/// the outward normal follows the right-hand rule. That convention is what
37/// makes containment decidable without a separate inside/outside oracle.
38#[derive(Debug, Clone, PartialEq)]
39pub struct Polyhedron {
40 faces: Vec<Vec<Point3>>,
41}
42
43/// Which boolean to evaluate.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BooleanOp {
46 /// Everything in either solid.
47 Union,
48 /// Only what lies in both.
49 Intersection,
50 /// The subject with the tool removed.
51 Difference,
52}
53
54impl Polyhedron {
55 /// Build from outward-wound planar faces.
56 ///
57 /// Faces are validated as planar here rather than trusted, because every
58 /// later decision assumes it. A non-planar ring has no single plane to
59 /// classify against, so accepting one would make the exactness claim
60 /// meaningless.
61 pub fn new(faces: Vec<Vec<Point3>>) -> GeomResult<Self> {
62 if faces.len() < 4 {
63 return Err(GeomError::InvalidInput(
64 "a closed solid needs at least 4 faces".to_owned(),
65 ));
66 }
67 for face in &faces {
68 if face.len() < 3 {
69 return Err(GeomError::InvalidInput(
70 "a face needs at least 3 vertices".to_owned(),
71 ));
72 }
73 if face.iter().any(|p| !p.is_finite()) {
74 return Err(GeomError::InvalidInput(
75 "face vertices must be finite".to_owned(),
76 ));
77 }
78 for &v in &face[3..] {
79 if orient3d(face[0], face[1], face[2], v).sign() != Some(Sign::Zero) {
80 return Err(GeomError::InvalidInput(
81 "face is not planar; no single plane to classify against".to_owned(),
82 ));
83 }
84 }
85 }
86 Ok(Self { faces })
87 }
88
89 /// The bounding faces, each an outward-wound ring.
90 #[must_use]
91 pub fn faces(&self) -> &[Vec<Point3>] {
92 &self.faces
93 }
94}
95
96/// Which side of a face's plane a point lies on, decided exactly.
97///
98/// Returns `None` when the predicate cannot certify a sign, which is the
99/// signal to refuse rather than guess.
100fn side_of_face(face: &[Point3], point: Point3) -> Option<Sign> {
101 orient3d(face[0], face[1], face[2], point).sign()
102}
103
104/// Where a point sits relative to a solid.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106enum Containment {
107 Inside,
108 OnBoundary,
109 Outside,
110}
111
112/// Whether `point` is inside `solid`, by exact ray crossing parity.
113///
114/// A convex all-faces test is wrong for non-convex solids: a point in the
115/// notch of an L-shaped prism is on the inner side of every face plane and
116/// would be called inside. Parity counting is correct for any closed
117/// orientable solid, convex or not.
118///
119/// The ray direction is chosen so it misses every vertex and edge. Rather
120/// than perturbing coordinates -- which would forfeit exactness -- a
121/// degenerate hit makes the whole operation refuse.
122fn contains(solid: &Polyhedron, point: Point3, direction: Vec3) -> Option<Containment> {
123 // The ray is represented by a segment, so it must be long enough to
124 // leave the solid: a unit-length direction would miss every crossing
125 // beyond it and invert the parity. Scaling by the solid's own extent
126 // keeps the far endpoint outside for any input size.
127 let reach = solid_reach(solid, point);
128 let direction = direction * reach;
129 let mut crossings = 0usize;
130 for face in solid.faces() {
131 match ray_crosses_face(face, point, direction)? {
132 RayHit::Miss => {}
133 RayHit::Crosses => crossings += 1,
134 RayHit::OnFace => return Some(Containment::OnBoundary),
135 }
136 }
137 Some(if crossings % 2 == 1 {
138 Containment::Inside
139 } else {
140 Containment::Outside
141 })
142}
143
144/// A length that certainly carries a ray from `point` clear of `solid`.
145fn solid_reach(solid: &Polyhedron, point: Point3) -> f64 {
146 let mut furthest: f64 = 1.0;
147 for face in solid.faces() {
148 for &v in face {
149 furthest = furthest.max((v - point).length());
150 }
151 }
152 // Doubling leaves the far endpoint strictly outside even when the
153 // furthest vertex lies exactly along the probe direction.
154 furthest * 2.0
155}
156
157/// Outcome of testing one ray against one face.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159enum RayHit {
160 Miss,
161 Crosses,
162 OnFace,
163}
164
165/// Whether the ray from `origin` along `direction` crosses `face`.
166///
167/// Decided with `orient3d` alone. The ray is represented by two points on
168/// it, `origin` and `origin + direction`; a crossing requires the face to
169/// separate them, and the hit point to fall inside the face ring. Both
170/// questions are sign tests, so no intersection coordinate is constructed.
171fn ray_crosses_face(face: &[Point3], origin: Point3, direction: Vec3) -> Option<RayHit> {
172 let far = origin + direction;
173 let near_side = side_of_face(face, origin)?;
174 let far_side = side_of_face(face, far)?;
175
176 if near_side == Sign::Zero {
177 // The origin lies in the face plane: it may be ON the face.
178 return if point_in_ring(face, origin)? {
179 Some(RayHit::OnFace)
180 } else {
181 Some(RayHit::Miss)
182 };
183 }
184 if near_side == far_side || far_side == Sign::Zero {
185 // Both endpoints on one side, or the segment ends exactly in the
186 // plane: extend the segment rather than deciding on a tangency.
187 return Some(RayHit::Miss);
188 }
189 ray_enters_ring(face, origin, far)
190}
191
192/// Whether the segment `origin`-`far` passes through the face's interior.
193///
194/// For each ring edge, the tetrahedron (origin, far, edge start, edge end)
195/// has a sign. The segment passes inside the ring exactly when every such
196/// sign agrees. A zero sign means the segment meets an edge or vertex --
197/// the degenerate case this refuses on rather than resolving arbitrarily.
198fn ray_enters_ring(face: &[Point3], origin: Point3, far: Point3) -> Option<RayHit> {
199 let mut sign: Option<Sign> = None;
200 for i in 0..face.len() {
201 let a = face[i];
202 let b = face[(i + 1) % face.len()];
203 match orient3d(origin, far, a, b).sign()? {
204 Sign::Zero => return None,
205 s => match sign {
206 None => sign = Some(s),
207 Some(previous) if previous == s => {}
208 Some(_) => return Some(RayHit::Miss),
209 },
210 }
211 }
212 Some(RayHit::Crosses)
213}
214
215/// Whether a coplanar point lies within the face ring.
216///
217/// The face is dropped to 2D by discarding its largest-normal-component
218/// axis, which keeps the projection non-degenerate, and containment is then
219/// decided by exact crossing parity using `orient2d`.
220///
221/// Parity is required rather than an all-same-side test: a same-side test
222/// is only valid for CONVEX rings, and silently reports "outside" for any
223/// point in the concave region of an L-shaped face. That failure is
224/// invisible -- it makes coplanar contact go undetected, and the boolean
225/// then keeps duplicate faces from both operands.
226fn point_in_ring(face: &[Point3], point: Point3) -> Option<bool> {
227 let normal = face_normal(face);
228 let (nx, ny, nz) = (normal.x.abs(), normal.y.abs(), normal.z.abs());
229 let flatten = |p: Point3| {
230 if nx >= ny && nx >= nz {
231 Point2::new(p.y, p.z)
232 } else if ny >= nz {
233 Point2::new(p.z, p.x)
234 } else {
235 Point2::new(p.x, p.y)
236 }
237 };
238
239 let ring: Vec<Point2> = face.iter().map(|&v| flatten(v)).collect();
240 let q = flatten(point);
241
242 // On an edge counts as inside: a fragment touching the ring boundary is
243 // in contact, and calling it outside would drop a real coplanar pair.
244 for i in 0..ring.len() {
245 let a = ring[i];
246 let b = ring[(i + 1) % ring.len()];
247 if orient2d(a, b, q).sign()? == Sign::Zero
248 && q.x >= a.x.min(b.x)
249 && q.x <= a.x.max(b.x)
250 && q.y >= a.y.min(b.y)
251 && q.y <= a.y.max(b.y)
252 {
253 return Some(true);
254 }
255 }
256
257 let mut inside = false;
258 for i in 0..ring.len() {
259 let a = ring[i];
260 let b = ring[(i + 1) % ring.len()];
261 if (a.y > q.y) != (b.y > q.y) {
262 // The edge straddles the horizontal through `q`; the crossing is
263 // to the right exactly when the triangle orientation says so, so
264 // no intersection abscissa is constructed.
265 let sign = orient2d(a, b, q).sign()?;
266 let upward = b.y > a.y;
267 let right = if upward {
268 sign == Sign::Negative
269 } else {
270 sign == Sign::Positive
271 };
272 if right {
273 inside = !inside;
274 }
275 }
276 }
277 Some(inside)
278}
279
280/// Whether a coplanar fragment's outward normal agrees with the opposing
281/// face it lies in.
282///
283/// Two solids touching along a shared plane either face the same way (one
284/// surface, keep a single copy) or face each other (the surfaces cancel).
285/// Distinguishing them is what stops a duplicate face entering the shell.
286fn coplanar_normals_agree(fragment: &[Point3], other: &Polyhedron) -> GeomResult<bool> {
287 let centroid = centroid_of(fragment);
288 let ours = face_normal(fragment);
289 for face in other.faces() {
290 let on_plane = side_of_face(face, centroid)
291 .ok_or_else(|| unsupported("coplanar classification undecidable"))?;
292 if on_plane != Sign::Zero {
293 continue;
294 }
295 if point_in_ring(face, centroid)
296 .ok_or_else(|| unsupported("coplanar containment undecidable"))?
297 {
298 return Ok(ours.dot(face_normal(face)) > 0.0);
299 }
300 }
301 // No opposing face carries this fragment, so there is nothing to
302 // duplicate and the fragment stands on its own.
303 Ok(true)
304}
305
306/// Unnormalised outward normal of a face.
307fn face_normal(face: &[Point3]) -> Vec3 {
308 (face[1] - face[0]).cross(face[2] - face[0])
309}
310
311/// The two sides a polygon falls into when cut by a plane; `None` on a
312/// side means the polygon does not reach it.
313type SplitParts = (Option<Vec<Point3>>, Option<Vec<Point3>>);
314
315/// Split a polygon by a plane, returning the negative and positive parts.
316///
317/// The plane is given by three points of an input face, never a derived one,
318/// so the crossing points computed here are one step from input data. A
319/// polygon lying wholly on one side comes back whole, so a non-crossing
320/// plane costs nothing and introduces no vertices.
321fn split_polygon(polygon: &[Point3], plane: &[Point3]) -> Option<SplitParts> {
322 let mut signs = Vec::with_capacity(polygon.len());
323 for &v in polygon {
324 signs.push(side_of_face(plane, v)?);
325 }
326 let has_negative = signs.contains(&Sign::Negative);
327 let has_positive = signs.contains(&Sign::Positive);
328 if !has_positive {
329 return Some((Some(polygon.to_vec()), None));
330 }
331 if !has_negative {
332 return Some((None, Some(polygon.to_vec())));
333 }
334
335 let mut negative = Vec::new();
336 let mut positive = Vec::new();
337 for i in 0..polygon.len() {
338 let j = (i + 1) % polygon.len();
339 let (vi, vj) = (polygon[i], polygon[j]);
340 let (si, sj) = (signs[i], signs[j]);
341 match si {
342 Sign::Negative => negative.push(vi),
343 Sign::Positive => positive.push(vi),
344 Sign::Zero => {
345 negative.push(vi);
346 positive.push(vi);
347 }
348 _ => {}
349 }
350 let crosses = matches!(
351 (si, sj),
352 (Sign::Negative, Sign::Positive) | (Sign::Positive, Sign::Negative)
353 );
354 if crosses {
355 let cut = plane_crossing(plane, vi, vj)?;
356 negative.push(cut);
357 positive.push(cut);
358 }
359 }
360 Some((
361 (negative.len() >= 3).then_some(negative),
362 (positive.len() >= 3).then_some(positive),
363 ))
364}
365
366/// Where segment `a`-`b` meets the plane through `plane`'s first 3 points.
367///
368/// This is the only place in the module that constructs a coordinate, and
369/// ADR 0045 applies: the parameter is computed in f64. The construction is
370/// exact in the cases that matter for axis-aligned building geometry, and
371/// the SIGN decisions that classify the result remain certified regardless.
372fn plane_crossing(plane: &[Point3], a: Point3, b: Point3) -> Option<Point3> {
373 let normal = face_normal(plane);
374 let denominator = normal.dot(b - a);
375 if denominator == 0.0 {
376 return None;
377 }
378 let t = normal.dot(plane[0] - a) / denominator;
379 if !t.is_finite() {
380 return None;
381 }
382 Some(a + (b - a) * t)
383}
384
385/// Exact boolean over two planar-faced solids.
386///
387/// Each operand's faces are split against every plane of the other, so no
388/// fragment straddles the other solid's boundary. Each fragment is then kept
389/// or dropped by classifying its centroid, and difference reverses the tool
390/// fragments so the result stays outward-wound.
391///
392/// Refuses rather than guessing whenever a certified predicate cannot decide
393/// a classification. A refusal is a typed error, never an approximate mesh.
394pub fn boolean_polyhedra_exact(
395 subject: &Polyhedron,
396 tool: &Polyhedron,
397 op: BooleanOp,
398) -> GeomResult<Polyhedron> {
399 let subject_parts = split_all(subject.faces(), tool.faces())?;
400 let tool_parts = split_all(tool.faces(), subject.faces())?;
401
402 let mut faces = Vec::new();
403 for fragment in subject_parts {
404 let keep = match classify_fragment(&fragment, tool)? {
405 Containment::Inside => matches!(op, BooleanOp::Intersection),
406 Containment::Outside => matches!(op, BooleanOp::Union | BooleanOp::Difference),
407 // Coplanar contact: this fragment lies IN the tool's surface, so
408 // both operands carry a copy. Exactly one must survive or the
409 // shell gains a duplicate face and stops being manifold.
410 //
411 // Keeping the subject's copy is only correct when the two faces
412 // agree on which side is solid. When their outward normals
413 // OPPOSE, the surfaces cancel: an intersection there has zero
414 // thickness, and a union has interior contact, so neither keeps
415 // a face. That distinction is what the tool-side loop cannot
416 // make, which is why it is made here.
417 // Coplanar contact. Both operands carry a copy of this surface,
418 // so exactly one must survive or the shell gains a duplicate
419 // face -- which reads as a self-intersection, not as a
420 // manifold error, because the duplicate is geometrically
421 // coincident rather than topologically loose.
422 //
423 // The tool-side loop drops all its boundary fragments, so the
424 // subject's copy is the survivor whenever the two normals
425 // agree. When they OPPOSE, the surfaces are interior contact:
426 // union and intersection both drop them, and difference keeps
427 // the subject's copy because that face becomes the cut wall.
428 Containment::OnBoundary => {
429 if coplanar_normals_agree(&fragment, tool)? {
430 !matches!(op, BooleanOp::Difference)
431 } else {
432 matches!(op, BooleanOp::Difference)
433 }
434 }
435 };
436 if keep {
437 faces.push(fragment);
438 }
439 }
440 for fragment in tool_parts {
441 let containment = classify_fragment(&fragment, subject)?;
442 // A tool fragment on the subject's boundary is the same surface the
443 // subject loop already kept, so it is always dropped here.
444 let keep = match op {
445 BooleanOp::Union => containment == Containment::Outside,
446 BooleanOp::Intersection | BooleanOp::Difference => containment == Containment::Inside,
447 };
448 if keep {
449 // Difference turns the tool's surface into an inward-facing
450 // cavity wall, so its winding must flip to stay outward.
451 faces.push(if op == BooleanOp::Difference {
452 fragment.into_iter().rev().collect()
453 } else {
454 fragment
455 });
456 }
457 }
458
459 if faces.len() < 4 {
460 return Err(unsupported("boolean produced no closed solid"));
461 }
462 Polyhedron::new(faces)
463}
464
465/// Split every face against every plane of the other solid.
466fn split_all(faces: &[Vec<Point3>], planes: &[Vec<Point3>]) -> GeomResult<Vec<Vec<Point3>>> {
467 let mut current: Vec<Vec<Point3>> = faces.to_vec();
468 for plane in planes {
469 let mut next = Vec::with_capacity(current.len());
470 for polygon in current {
471 let (negative, positive) = split_polygon(&polygon, plane).ok_or_else(|| {
472 unsupported("face not splittable exactly against an operand plane")
473 })?;
474 next.extend(negative);
475 next.extend(positive);
476 }
477 current = next;
478 }
479 Ok(current)
480}
481
482/// Classify a fragment by its centroid.
483///
484/// After splitting, a fragment lies wholly inside or wholly outside the other
485/// solid, so its centroid decides for the whole fragment. A centroid landing
486/// exactly on the boundary means the fragment is coplanar with an opposing
487/// face -- the case the issue calls out, handled by its own arm rather than
488/// resolved arbitrarily.
489fn classify_fragment(fragment: &[Point3], other: &Polyhedron) -> GeomResult<Containment> {
490 let centroid = centroid_of(fragment);
491 // A degenerate ray is an unlucky direction, not an unanswerable point:
492 // containment is the same along every ray, so try the next direction
493 // rather than refusing. Each attempt is exact; none perturbs coordinates.
494 for direction in probe_directions() {
495 if let Some(containment) = contains(other, centroid, direction) {
496 return Ok(containment);
497 }
498 }
499 // Every direction in the family was degenerate. That is vanishingly
500 // unlikely for real geometry, and refusing remains correct: guessing a
501 // parity here would silently produce a wrong solid.
502 Err(unsupported(
503 "every probe direction met a vertex or edge exactly",
504 ))
505}
506
507/// Average of a polygon's vertices.
508fn centroid_of(polygon: &[Point3]) -> Point3 {
509 let mut sum = Vec3::new(0.0, 0.0, 0.0);
510 for &v in polygon {
511 sum += v - Point3::new(0.0, 0.0, 0.0);
512 }
513 Point3::new(0.0, 0.0, 0.0) + sum / polygon.len() as f64
514}
515
516/// Ray directions tried in order when classifying a point.
517///
518/// Containment does not depend on the probe direction: a closed orientable
519/// solid has the same inside/outside answer along every ray. So a ray that
520/// meets a vertex or edge exactly is not an unanswerable input, only an
521/// unlucky one, and trying another direction is exact rather than a fudge.
522///
523/// The family is fixed, not random, so the same input gives the same answer
524/// on every run. The first entry is the long-standing direction, so inputs
525/// that already worked keep taking the same path. The rest are chosen to be
526/// mutually non-parallel with irrational-ish ratios, which is what keeps them
527/// from lining up with the axis-aligned and diagonal features that made the
528/// first one degenerate.
529fn probe_directions() -> [Vec3; 4] {
530 [
531 Vec3::new(0.577_215_664_9, 0.313_724_518_3, 0.144_729_885_8),
532 Vec3::new(0.211_324_865_4, 0.788_675_134_6, 0.366_025_403_8),
533 Vec3::new(0.867_513_459_5, 0.132_486_540_5, 0.539_189_129_1),
534 Vec3::new(0.404_508_497_2, 0.595_491_502_8, 0.951_056_516_3),
535 ]
536}
537
538/// Triangulate a polyhedron for measurement and diagnosis.
539///
540/// Vertices are shared through exact-coordinate keying: emitting a fresh
541/// vertex per face would leave every edge used once, so an audit would
542/// report a cloud of boundary edges for a solid that is in fact closed.
543/// Coordinates that meet do so bit-identically, because they come from the
544/// same literal or the same split, so exact keying is correct and no welding
545/// tolerance is invented.
546///
547/// Fanning assumes convex rings. A non-convex face fans into triangles that
548/// leave the footprint, so callers measuring such a solid must supply a
549/// closed-form oracle instead.
550#[must_use]
551pub fn triangulate(solid: &Polyhedron) -> TriMesh {
552 let mut positions: Vec<Point3> = Vec::new();
553 let mut indices = Vec::new();
554 let mut lookup: BTreeMap<[u64; 3], u32> = BTreeMap::new();
555 for face in solid.faces() {
556 let ring: Vec<u32> = face
557 .iter()
558 .map(|&p| {
559 let key = [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
560 let next = u32::try_from(positions.len()).unwrap_or(u32::MAX);
561 *lookup.entry(key).or_insert_with(|| {
562 positions.push(p);
563 next
564 })
565 })
566 .collect();
567 for i in 1..ring.len() - 1 {
568 indices.extend([ring[0], ring[i], ring[i + 1]]);
569 }
570 }
571 TriMesh::new(positions, indices)
572}