Skip to main content

lindhard/geometry/
mesh.rs

1//! Triangle-mesh solids: loaders (STL, OBJ), the watertightness check, and the
2//! [`MeshGeometry`] target built on a BVH.
3//!
4//! # Solids and regions
5//!
6//! A [`TriMesh`] is one closed, outward-oriented triangle surface (possibly
7//! several shells, for example an outer surface and an inward-facing cavity).
8//! A [`MeshGeometry`] is an ordered list of solids, each tagged with an index
9//! into a shared material list. **One region per solid**, in the order listed.
10//! Space outside every solid is vacuum.
11//!
12//! # Ownership rules
13//!
14//! These are the overlap, boundary and tolerance rules of the mesh target.
15//! Other solid representations of the same target (such as a
16//! constructive-solid-geometry one) are meant to follow them.
17//!
18//! 1. **Overlap.** Where solids overlap, the first listed solid owns the
19//!    space.
20//! 2. **Surface points, directional.** A flight is owned, at each point, by the
21//!    solid it is heading into. Concretely, the state of a solid at the start
22//!    of a flight is its state at `pos + tol * dir`, so a start on a surface
23//!    belongs to the solid on the side the flight enters, as the faces of a
24//!    [`VoxelGrid`](super::VoxelGrid) do. [`Geometry::locate`] has no
25//!    direction; it uses the fixed offset `tol * (1, 1, 1) / sqrt(3)`, so
26//!    lower faces (and edges, vertices) of an axis-aligned solid belong to it
27//!    and upper ones do not, exactly as voxel faces do.
28//! 3. **No re-hit.** Crossings at `t <= tol` along the flight are ignored, so a
29//!    particle that has just crossed a surface and sits on it (to rounding)
30//!    does not cross it again.
31//! 4. **Edges and vertices.** A flight through an edge or vertex shared by
32//!    several triangles crosses the surface once: the ray-triangle test
33//!    resolves exact ties by a fixed rule that is consistent between
34//!    neighbours (see the `bvh` module source), so no crossing is doubled or
35//!    lost. Crossings of different solids within `tol` of each other are one
36//!    event, so a flight through a surface shared by two solids passes
37//!    directly from one to the other and never through vacuum between them.
38//!
39//! The tolerance is `tol = 1e-10 x` the diagonal of the bounding box of all
40//! solids (see [`MeshGeometry::tolerance_m`]); it is far above rounding
41//! (about `1e-16` of a coordinate) and below any feature worth resolving.
42//! Two crossings of one solid closer than `tol` cancel.
43//!
44//! # Events
45//!
46//! [`Geometry::exit`] reports `Enter { region, pos }` when the owner changes
47//! to a solid of a *different material* (a change between solids of the same
48//! material is not an event, as crossing a voxel face inside one block is
49//! not), and `Escape` when the flight leaves every solid. [`Geometry::flight`]
50//! is overridden to report the solid a clear flight ends in. The `Face` of an
51//! escape comes from the outward unit normal `n` of the triangle crossed:
52//! [`Face::Front`] if `n_x` is negative and the largest component in
53//! magnitude, [`Face::Back`] if positive and largest, [`Face::Side`]
54//! otherwise (for flat axis-aligned faces this is the voxel convention; on a
55//! curved surface the target is split by the dominant axis).
56//!
57//! The start state of every other solid costs one unbounded ray query, so a
58//! flight in a scene of `n` solids costs `O(n log m)`. A single solid takes
59//! the fast path.
60//!
61//! # Point in solid
62//!
63//! The parity of the number of crossings of a ray from the point to infinity
64//! (J. Foley et al., *Computer Graphics: Principles and Practice*, 2nd ed.,
65//! Addison-Wesley 1990, section 15.10, ray-casting inside tests). The ray
66//! direction is fixed, and exact edge or vertex hits are resolved by the same
67//! tie-break as above, so the result is deterministic.
68//!
69//! # File formats and units
70//!
71//! Lengths are metres at the API. A file's coordinates are multiplied by the
72//! `unit_m` argument of the loaders (`1e-9` for a file in nm, `1.0` for
73//! metres); neither format records units.
74//!
75//! * **STL** (3D Systems, *StereoLithography Interface Specification*,
76//!   1989): binary (80-byte header, `u32` count `n`, then `n` records of 12
77//!   little-endian `f32` -- normal and three vertices -- and a 2-byte
78//!   attribute) and ASCII (`solid` ... `facet normal` ... `vertex x y z` ...).
79//!   Binary is recognised by the length `84 + 50 n`, not by the header text
80//!   (binary files often begin with `solid`). The stored normals are ignored:
81//!   orientation comes from the vertex order (counter-clockwise seen from
82//!   outside), and is checked.
83//! * **OBJ** (Wavefront; the file format specification of Alias|Wavefront,
84//!   1992): `v x y z [w]` and `f` with `i`, `i/t`, `i//n` or `i/t/n`
85//!   vertices, 1-based or negative (relative to the vertices read so far).
86//!   Polygons are fan-triangulated from the first vertex. All faces of a file
87//!   form one mesh; other statements (`vt`, `vn`, `o`, `g`, `usemtl`, `s`,
88//!   ...) are skipped.
89//!
90//! # Watertightness
91//!
92//! Vertices are welded by exact coordinate equality (the formats repeat
93//! them). Then every undirected edge must belong to exactly two triangles that
94//! traverse it in opposite directions (a closed, consistently oriented
95//! 2-manifold), no triangle may be degenerate (zero area or a repeated
96//! vertex, or a sliver thinner than `1e-12` of its longest edge), and the
97//! signed volume must be positive, which means the normals point outward.
98//! Violations return a [`GeometryError`] naming a bad edge by the positions of
99//! its two vertices.
100
101use std::collections::BTreeMap;
102
103use super::bvh::{Bvh, Ray, V3};
104use super::{Exit, ExitOutcome, Face, Flight, Geometry, GeometryError};
105use crate::material::Material;
106use crate::vec3::{cross, dot, norm, sub};
107
108/// Relative size of the geometric tolerance: `tol = TOL_REL x` the scene
109/// diagonal.
110pub(super) const TOL_REL: f64 = 1e-10;
111
112/// A closed, watertight, outward-oriented triangle mesh with welded vertices.
113#[derive(Debug, Clone, PartialEq)]
114pub struct TriMesh {
115    vertices: Vec<V3>,
116    triangles: Vec<[u32; 3]>,
117    volume_m3: f64,
118    lo: V3,
119    hi: V3,
120}
121
122impl TriMesh {
123    /// Build from a triangle soup (counter-clockwise seen from outside),
124    /// coordinates in metres. Welds vertices and runs the watertightness
125    /// checks of the module docs.
126    pub fn from_triangles(soup: &[[V3; 3]]) -> Result<Self, GeometryError> {
127        if soup.is_empty() {
128            return Err(GeometryError::MeshEmpty);
129        }
130        let mut index: BTreeMap<[u64; 3], u32> = BTreeMap::new();
131        let mut vertices: Vec<V3> = Vec::new();
132        let mut triangles = Vec::with_capacity(soup.len());
133        for (ti, tri) in soup.iter().enumerate() {
134            let mut t = [0u32; 3];
135            for (k, v) in tri.iter().enumerate() {
136                if !v.iter().all(|c| c.is_finite()) {
137                    return Err(GeometryError::MeshNonFinite { triangle: ti });
138                }
139                // `+ 0.0` turns -0.0 into 0.0 so both weld.
140                let key = [
141                    (v[0] + 0.0).to_bits(),
142                    (v[1] + 0.0).to_bits(),
143                    (v[2] + 0.0).to_bits(),
144                ];
145                t[k] = *index.entry(key).or_insert_with(|| {
146                    vertices.push([v[0] + 0.0, v[1] + 0.0, v[2] + 0.0]);
147                    (vertices.len() - 1) as u32
148                });
149            }
150            triangles.push(t);
151        }
152        let pos = |i: u32| vertices[i as usize];
153
154        for (ti, t) in triangles.iter().enumerate() {
155            let [a, b, c] = [pos(t[0]), pos(t[1]), pos(t[2])];
156            let n = norm(cross(sub(b, a), sub(c, a)));
157            let longest = norm(sub(b, a)).max(norm(sub(c, b))).max(norm(sub(a, c)));
158            if t[0] == t[1] || t[1] == t[2] || t[0] == t[2] || n <= 1e-12 * longest * longest {
159                return Err(GeometryError::MeshDegenerateTriangle {
160                    triangle: ti,
161                    vertices: [a, b, c],
162                });
163            }
164        }
165
166        // Directed edges: each at most once, and each with its reverse.
167        let mut directed: BTreeMap<(u32, u32), ()> = BTreeMap::new();
168        for t in &triangles {
169            for k in 0..3 {
170                let e = (t[k], t[(k + 1) % 3]);
171                if directed.insert(e, ()).is_some() {
172                    return Err(GeometryError::MeshInconsistentOrientation {
173                        edge: [pos(e.0), pos(e.1)],
174                    });
175                }
176            }
177        }
178        for &(a, b) in directed.keys() {
179            if !directed.contains_key(&(b, a)) {
180                return Err(GeometryError::MeshEdgeNotShared {
181                    edge: [pos(a), pos(b)],
182                });
183            }
184        }
185
186        let mut lo = [f64::INFINITY; 3];
187        let mut hi = [f64::NEG_INFINITY; 3];
188        for v in &vertices {
189            for k in 0..3 {
190                lo[k] = lo[k].min(v[k]);
191                hi[k] = hi[k].max(v[k]);
192            }
193        }
194        // Signed volume as a sum of tetrahedra from the box centre.
195        let r = [
196            0.5 * (lo[0] + hi[0]),
197            0.5 * (lo[1] + hi[1]),
198            0.5 * (lo[2] + hi[2]),
199        ];
200        let volume_m3 = triangles
201            .iter()
202            .map(|t| {
203                let [a, b, c] = [sub(pos(t[0]), r), sub(pos(t[1]), r), sub(pos(t[2]), r)];
204                dot(a, cross(b, c)) / 6.0
205            })
206            .sum::<f64>();
207        if volume_m3.is_nan() || volume_m3 <= 0.0 {
208            return Err(GeometryError::MeshNotOutward { volume_m3 });
209        }
210        Ok(Self {
211            vertices,
212            triangles,
213            volume_m3,
214            lo,
215            hi,
216        })
217    }
218
219    /// Parse an STL file (ASCII or binary), scaling coordinates by `unit_m`
220    /// (metres per file unit).
221    pub fn from_stl(bytes: &[u8], unit_m: f64) -> Result<Self, GeometryError> {
222        check_unit(unit_m)?;
223        let perr = |line: usize, message: String| GeometryError::MeshParse {
224            format: "STL",
225            line,
226            message,
227        };
228        let is_binary = bytes.len() >= 84 && {
229            let n = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]) as u64;
230            bytes.len() as u64 == 84 + 50 * n
231        };
232        let mut soup: Vec<[V3; 3]> = Vec::new();
233        if is_binary {
234            for rec in bytes[84..].as_chunks::<50>().0 {
235                let f = |o: usize| {
236                    f64::from(f32::from_le_bytes([
237                        rec[o],
238                        rec[o + 1],
239                        rec[o + 2],
240                        rec[o + 3],
241                    ])) * unit_m
242                };
243                let v = |k: usize| [f(12 + 12 * k), f(16 + 12 * k), f(20 + 12 * k)];
244                soup.push([v(0), v(1), v(2)]);
245            }
246        } else {
247            let text = std::str::from_utf8(bytes).map_err(|_| {
248                perr(
249                    0,
250                    "neither a binary STL of the declared length nor UTF-8 text".into(),
251                )
252            })?;
253            if !text.trim_start().starts_with("solid") {
254                return Err(perr(1, "not an STL file: expected a `solid` header".into()));
255            }
256            let mut cur: Vec<V3> = Vec::new();
257            for (ln, line) in text.lines().enumerate() {
258                let mut it = line.split_whitespace();
259                if it.next() != Some("vertex") {
260                    continue;
261                }
262                let mut c = [0.0; 3];
263                for slot in &mut c {
264                    *slot = it
265                        .next()
266                        .and_then(|s| s.parse::<f64>().ok())
267                        .ok_or_else(|| perr(ln + 1, "malformed `vertex` line".into()))?
268                        * unit_m;
269                }
270                cur.push(c);
271                if cur.len() == 3 {
272                    soup.push([cur[0], cur[1], cur[2]]);
273                    cur.clear();
274                }
275            }
276            if !cur.is_empty() {
277                return Err(perr(
278                    text.lines().count(),
279                    "`vertex` lines are not a multiple of three".into(),
280                ));
281            }
282        }
283        Self::from_triangles(&soup)
284    }
285
286    /// Parse a Wavefront OBJ file, scaling coordinates by `unit_m` (metres per
287    /// file unit). Faces with more than three vertices are fan-triangulated.
288    pub fn from_obj(text: &str, unit_m: f64) -> Result<Self, GeometryError> {
289        check_unit(unit_m)?;
290        let perr = |line: usize, message: String| GeometryError::MeshParse {
291            format: "OBJ",
292            line,
293            message,
294        };
295        let mut verts: Vec<V3> = Vec::new();
296        let mut soup: Vec<[V3; 3]> = Vec::new();
297        for (ln, line) in text.lines().enumerate() {
298            let line = line.split('#').next().unwrap_or("");
299            let mut it = line.split_whitespace();
300            match it.next() {
301                Some("v") => {
302                    let mut c = [0.0; 3];
303                    for slot in &mut c {
304                        *slot = it
305                            .next()
306                            .and_then(|s| s.parse::<f64>().ok())
307                            .ok_or_else(|| perr(ln + 1, "malformed `v` line".into()))?
308                            * unit_m;
309                    }
310                    verts.push(c);
311                }
312                Some("f") => {
313                    let mut poly: Vec<V3> = Vec::new();
314                    for tok in it {
315                        let first = tok.split('/').next().unwrap_or("");
316                        let i: i64 = first
317                            .parse()
318                            .map_err(|_| perr(ln + 1, format!("bad face index `{tok}`")))?;
319                        let k = if i > 0 {
320                            i - 1
321                        } else if i < 0 {
322                            verts.len() as i64 + i
323                        } else {
324                            return Err(perr(ln + 1, "face index 0 is invalid".into()));
325                        };
326                        if k < 0 || k as usize >= verts.len() {
327                            return Err(perr(
328                                ln + 1,
329                                format!("face index {i} refers to a vertex not yet defined"),
330                            ));
331                        }
332                        poly.push(verts[k as usize]);
333                    }
334                    if poly.len() < 3 {
335                        return Err(perr(ln + 1, "a face needs at least three vertices".into()));
336                    }
337                    for k in 1..poly.len() - 1 {
338                        soup.push([poly[0], poly[k], poly[k + 1]]);
339                    }
340                }
341                _ => {}
342            }
343        }
344        Self::from_triangles(&soup)
345    }
346
347    /// Welded vertex positions, m.
348    pub fn vertices(&self) -> &[[f64; 3]] {
349        &self.vertices
350    }
351
352    /// Triangles as indices into [`TriMesh::vertices`], counter-clockwise
353    /// seen from outside.
354    pub fn triangles(&self) -> &[[u32; 3]] {
355        &self.triangles
356    }
357
358    /// Enclosed volume, m^3 (positive for an outward-oriented mesh).
359    pub fn volume_m3(&self) -> f64 {
360        self.volume_m3
361    }
362
363    /// Axis-aligned bounds `(min, max)`, m.
364    pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
365        (self.lo, self.hi)
366    }
367
368    fn soup(&self) -> Vec<[V3; 3]> {
369        self.triangles
370            .iter()
371            .map(|t| t.map(|i| self.vertices[i as usize]))
372            .collect()
373    }
374}
375
376fn check_unit(unit_m: f64) -> Result<(), GeometryError> {
377    if unit_m.is_finite() && unit_m > 0.0 {
378        Ok(())
379    } else {
380        Err(GeometryError::MeshInvalidScale { unit_m })
381    }
382}
383
384#[derive(Debug, Clone, PartialEq)]
385struct Solid {
386    mesh: TriMesh,
387    material: u32,
388    bvh: Bvh,
389}
390
391/// A target of closed triangle-mesh solids, each tagged with a material, with
392/// a BVH per solid. See the module docs for the ownership rules.
393#[derive(Debug, Clone, PartialEq)]
394pub struct MeshGeometry {
395    materials: Vec<Material>,
396    solids: Vec<Solid>,
397    tol: f64,
398    lo: V3,
399    hi: V3,
400}
401
402impl MeshGeometry {
403    /// Build from the material list and `(mesh, material index)` solids, in
404    /// priority order (the first listed owns overlaps). Meshes are in metres.
405    pub fn new(
406        materials: Vec<Material>,
407        solids: Vec<(TriMesh, u32)>,
408    ) -> Result<Self, GeometryError> {
409        if materials.is_empty() {
410            return Err(GeometryError::NoMaterials);
411        }
412        if solids.is_empty() {
413            return Err(GeometryError::NoSolids);
414        }
415        let mut lo = [f64::INFINITY; 3];
416        let mut hi = [f64::NEG_INFINITY; 3];
417        for (i, (mesh, m)) in solids.iter().enumerate() {
418            if *m as usize >= materials.len() {
419                return Err(GeometryError::SolidMaterialOutOfRange {
420                    solid: i,
421                    index: *m,
422                    n_materials: materials.len(),
423                });
424            }
425            for k in 0..3 {
426                lo[k] = lo[k].min(mesh.lo[k]);
427                hi[k] = hi[k].max(mesh.hi[k]);
428            }
429        }
430        let tol = TOL_REL * norm(sub(hi, lo));
431        let solids = solids
432            .into_iter()
433            .map(|(mesh, material)| {
434                let bvh = Bvh::build(&mesh.soup(), tol);
435                Solid {
436                    mesh,
437                    material,
438                    bvh,
439                }
440            })
441            .collect();
442        Ok(Self {
443            materials,
444            solids,
445            tol,
446            lo,
447            hi,
448        })
449    }
450
451    /// The solids' meshes, in region order.
452    pub fn mesh(&self, region: usize) -> &TriMesh {
453        &self.solids[region].mesh
454    }
455
456    /// The geometric tolerance of the ownership rules, m.
457    pub fn tolerance_m(&self) -> f64 {
458        self.tol
459    }
460
461    /// Axis-aligned bounds of all solids, `(min, max)`, m.
462    pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
463        (self.lo, self.hi)
464    }
465
466    /// Whether the point `p` is inside solid `i`, by the parity of the
467    /// crossings of a fixed ray (module docs).
468    fn inside_at(&self, i: usize, p: V3, dir: V3) -> bool {
469        let mut n = 0usize;
470        let ray = Ray::new(p, dir);
471        self.solids[i]
472            .bvh
473            .for_each_hit(&ray, 0.0, f64::INFINITY, |_, _| n += 1);
474        n % 2 == 1
475    }
476
477    /// [`Geometry::locate`] with the offset of rule 2 applied with the given
478    /// sign on each axis (all +1 for `locate`).
479    fn locate_shifted(&self, pos: [f64; 3], sign: [f64; 3]) -> Option<usize> {
480        if !pos.iter().all(|c| c.is_finite()) {
481            return None;
482        }
483        // The fixed offset of rule 2 and a fixed, off-axis ray direction.
484        let s = self.tol / 3f64.sqrt();
485        let p = [
486            pos[0] + sign[0] * s,
487            pos[1] + sign[1] * s,
488            pos[2] + sign[2] * s,
489        ];
490        let dir = {
491            let d = [1.0, 2.0, 3.0];
492            let l = norm(d);
493            [d[0] / l, d[1] / l, d[2] / l]
494        };
495        (0..self.solids.len()).find(|&i| {
496            let (lo, hi) = self.solids[i].bvh.bounds().expect("non-empty mesh");
497            (0..3).all(|k| p[k] >= lo[k] && p[k] <= hi[k]) && self.inside_at(i, p, dir)
498        })
499    }
500
501    /// Outward unit normal of triangle `tri` (leaf order) of solid `i`.
502    fn normal(&self, i: usize, tri: u32) -> V3 {
503        let [a, b, c] = *self.solids[i].bvh.triangle(tri);
504        let n = cross(sub(b, a), sub(c, a));
505        let l = norm(n);
506        [n[0] / l, n[1] / l, n[2] / l]
507    }
508}
509
510/// The face class of an outward normal (module docs).
511pub(super) fn face_of(n: V3) -> Face {
512    let (ax, ay, az) = (n[0].abs(), n[1].abs(), n[2].abs());
513    if ax > ay && ax > az {
514        if n[0] < 0.0 {
515            Face::Front
516        } else {
517            Face::Back
518        }
519    } else {
520        Face::Side
521    }
522}
523
524impl Geometry for MeshGeometry {
525    fn n_materials(&self) -> usize {
526        self.materials.len()
527    }
528
529    fn material(&self, index: usize) -> &Material {
530        &self.materials[index]
531    }
532
533    fn n_regions(&self) -> usize {
534        self.solids.len()
535    }
536
537    fn material_index(&self, region: usize) -> usize {
538        self.solids[region].material as usize
539    }
540
541    fn locate(&self, pos: [f64; 3]) -> Option<usize> {
542        self.locate_shifted(pos, [1.0; 3])
543    }
544
545    fn entry_point(&self) -> [f64; 3] {
546        [
547            self.lo[0],
548            0.5 * (self.lo[1] + self.hi[1]),
549            0.5 * (self.lo[2] + self.hi[2]),
550        ]
551    }
552
553    fn exit(&self, region: usize, pos: [f64; 3], dir: [f64; 3], limit: f64) -> Option<Exit> {
554        match self.flight(region, pos, dir, limit) {
555            Flight::Event(e) => Some(e),
556            Flight::Clear { .. } => None,
557        }
558    }
559
560    fn flight(&self, region: usize, pos: [f64; 3], dir: [f64; 3], limit: f64) -> Flight {
561        let clear = Flight::Clear { region };
562        if !dir.iter().all(|d| d.is_finite())
563            || dir == [0.0; 3]
564            || !pos.iter().all(|c| c.is_finite())
565            || limit.is_nan()
566        {
567            return clear;
568        }
569        let tol = self.tol;
570        let ray = Ray::new(pos, dir);
571        let n = self.solids.len();
572
573        // Crossings (t, solid, triangle) in (tol, limit + tol], and the
574        // state of every solid at t = tol. The flight's own region is taken
575        // to be inside.
576        let mut hits: Vec<(f64, u32, u32)> = Vec::new();
577        let mut inside = vec![false; n];
578        inside[region] = true;
579        for (j, solid) in self.solids.iter().enumerate() {
580            let own = j == region;
581            let mut count = 0usize;
582            // Other solids need the whole ray for the parity of their start
583            // state; the flight's own solid only the flight.
584            let tmax = if n == 1 || own {
585                limit + tol
586            } else {
587                f64::INFINITY
588            };
589            solid.bvh.for_each_hit(&ray, tol, tmax, |t, tri| {
590                count += 1;
591                if t <= limit + tol {
592                    hits.push((t, j as u32, tri));
593                }
594            });
595            if !own {
596                inside[j] = count % 2 == 1;
597            }
598        }
599        hits.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
600
601        let owner_of = |inside: &[bool]| inside.iter().position(|&b| b);
602        let mut current = region;
603        // Start-state mismatch (a surface start heading into a solid that
604        // has priority or is not `region`): an event at distance 0.
605        let mut pending_start = true;
606        let mut i = 0usize;
607        loop {
608            let (t0, cluster_end) = if pending_start {
609                (0.0, i)
610            } else {
611                if i >= hits.len() || hits[i].0 > limit {
612                    return Flight::Clear { region: current };
613                }
614                let t0 = hits[i].0;
615                let mut e = i;
616                while e < hits.len() && hits[e].0 - t0 <= tol {
617                    inside[hits[e].1 as usize] ^= true;
618                    e += 1;
619                }
620                (t0, e)
621            };
622            let cluster = &hits[i..cluster_end];
623            pending_start = false;
624            i = cluster_end;
625            let at = [
626                pos[0] + t0 * dir[0],
627                pos[1] + t0 * dir[1],
628                pos[2] + t0 * dir[2],
629            ];
630            match owner_of(&inside) {
631                Some(k) if k == current => {}
632                Some(k) => {
633                    if self.solids[k].material == self.solids[current].material {
634                        current = k;
635                    } else {
636                        return Flight::Event(Exit {
637                            distance: t0,
638                            at,
639                            outcome: ExitOutcome::Enter { region: k, pos: at },
640                        });
641                    }
642                }
643                None => {
644                    // The triangle of the solid being left, else any.
645                    let (_, sol, tri) = cluster
646                        .iter()
647                        .copied()
648                        .find(|h| h.1 as usize == current)
649                        .or_else(|| cluster.first().copied())
650                        .unwrap_or((t0, current as u32, u32::MAX));
651                    let normal = if tri == u32::MAX {
652                        [-dir[0], -dir[1], -dir[2]]
653                    } else {
654                        self.normal(sol as usize, tri)
655                    };
656                    return Flight::Event(Exit {
657                        distance: t0,
658                        at,
659                        outcome: ExitOutcome::Escape {
660                            face: face_of(normal),
661                            normal,
662                        },
663                    });
664                }
665            }
666        }
667    }
668
669    /// A point on the surface of a solid is not in vacuum, whichever of the
670    /// eight diagonal offsets (the one of [`Geometry::locate`] and its
671    /// mirror images) would put it outside.
672    fn in_vacuum(&self, pos: [f64; 3]) -> bool {
673        (0..8).all(|m| {
674            let sign = |k: usize| if m >> k & 1 == 0 { 1.0 } else { -1.0 };
675            self.locate_shifted(pos, [sign(0), sign(1), sign(2)])
676                .is_none()
677        })
678    }
679}