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}