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