Skip to main content

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}