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}