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