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}