axiolid/
ray_index.rs

1//! Cached broad phase for repeated ray casts against the same mesh.
2//!
3//! `nearest_hit` scans every triangle by design: the broad phase belongs
4//! to the caller, and a one-shot caller must not pay for an index it
5//! never reuses. Measured here: the BVH only repays after ~22 rays, and
6//! at a single ray it is ~20x slower than scanning.
7//!
8//! So the index is built lazily on the SECOND cast against a given mesh
9//! and reused afterwards. A first cast costs what it always did.
10//!
11//! The cache key is a content digest, not an address. `TriMesh` exposes
12//! `positions`/`indices` as public `Vec`s with no version counter, so a
13//! caller can mutate a mesh in place; an address or (ptr, len) key would
14//! then serve a stale index and return hits for geometry that no longer
15//! exists. Digesting costs ~1.2% of a build and ~0.01% of a full scan.
16
17#[cfg(feature = "application")]
18use ahash::AHasher;
19#[cfg(feature = "application")]
20use std::hash::{Hash, Hasher};
21#[cfg(feature = "application")]
22use std::sync::RwLock;
23
24use axiolid_core::Aabb;
25use axiolid_core::{Point3, Ray3, Tolerance};
26use axiolid_mesh::TriMesh;
27use axiolid_ray_mesh::{nearest_hit, nearest_hit_among, RayHit3, RayMeshError};
28use axiolid_spatial::{Bvh, SpatialIndex, SpatialItem};
29use std::ops::ControlFlow;
30
31/// A broad-phase index a caller builds once and casts against many
32/// times.
33///
34/// This is the zero-bookkeeping form of the cache below. It borrows the
35/// mesh for its whole life, so the borrow checker -- not a runtime
36/// digest -- guarantees the geometry cannot move underneath it:
37///
38/// ```compile_fail,E0502
39/// # use axiolid::ray_index::MeshRayIndex;
40/// # use axiolid::mesh::TriMesh;
41/// # use axiolid::core::Point3;
42/// let mut mesh = TriMesh::new(
43///     vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)],
44///     vec![0, 1, 2],
45/// );
46/// # let tol = axiolid::core::Tolerance::new(1e-9, 1e-9).unwrap();
47/// let index = MeshRayIndex::build(&mesh, tol);
48/// mesh.positions[0].x = 5.0; // E0502: index still borrows `mesh`
49/// let _ = &index;
50/// ```
51///
52/// Use this when casting many rays at one mesh. Prefer
53/// `Application::nearest_mesh_hit` when casts are incidental: it keeps
54/// its own cache and needs no lifetime plumbing, at the cost of a
55/// content digest per call.
56pub struct MeshRayIndex<'m> {
57    mesh: &'m TriMesh,
58    bvh: Bvh<usize>,
59    margin: f64,
60}
61
62impl<'m> MeshRayIndex<'m> {
63    /// Build the broad phase for queries at `tolerance`.
64    ///
65    /// The tolerance is taken at BUILD time because the bounds are padded
66    /// by it: the narrow phase accepts a hit within tolerance of a
67    /// triangle, so bounds tight to the vertices can prune a triangle the
68    /// full scan would accept. Casting with a larger tolerance than the
69    /// index was built for is rejected rather than silently answered from
70    /// bounds that are too tight.
71    ///
72    /// O(triangles); repays after ~22 casts.
73    #[must_use]
74    pub fn build(mesh: &'m TriMesh, tolerance: Tolerance) -> Self {
75        let margin = tolerance.linear();
76        Self {
77            bvh: build_bvh_with_margin(mesh, margin),
78            mesh,
79            margin,
80        }
81    }
82
83    /// Nearest hit, identical in result to a full scan.
84    ///
85    /// # Errors
86    ///
87    /// Propagates whatever `nearest_hit_among` refuses.
88    pub fn nearest_hit(
89        &self,
90        ray: &Ray3,
91        tolerance: Tolerance,
92    ) -> Result<Option<RayHit3>, RayMeshError> {
93        if tolerance.linear() > self.margin {
94            // Falling back to the scan is correct and slow; answering
95            // from bounds that are too tight is fast and wrong.
96            return nearest_hit(self.mesh, ray, tolerance);
97        }
98        accelerated(self.mesh, ray, tolerance, &self.bvh)
99    }
100}
101
102#[cfg(feature = "application")]
103/// Entries retained. Each holds one BVH over a mesh, so this bounds
104/// memory: an unbounded map would leak an index per distinct mesh ever
105/// cast against, which for a caller streaming meshes is every mesh.
106const CAPACITY: usize = 8;
107
108#[cfg(feature = "application")]
109/// Casts against a mesh before its index is built.
110///
111/// 1 means "build on the second cast". Building on the first would
112/// penalise the one-shot caller the scanning API exists to serve.
113const WARMUP_CASTS: u32 = 1;
114
115#[cfg(feature = "application")]
116struct Entry {
117    digest: u64,
118    /// Margin the bounds were padded by. A later call with a LARGER
119    /// tolerance could accept a hit this index prunes away, so the
120    /// index is rebuilt rather than reused.
121    margin: f64,
122    /// `None` until the mesh has been cast against `WARMUP_CASTS` times.
123    bvh: Option<Bvh<usize>>,
124    casts: u32,
125    /// Monotonic tick of last use, for least-recently-used eviction.
126    touched: u64,
127}
128
129#[cfg(feature = "application")]
130/// Ray index cache. Not part of the public API surface: it changes only
131/// how `nearest_mesh_hit` finds its answer, never what that answer is.
132#[derive(Default)]
133pub(crate) struct RayIndexCache {
134    inner: RwLock<Inner>,
135}
136
137#[cfg(feature = "application")]
138#[derive(Default)]
139struct Inner {
140    entries: Vec<Entry>,
141    clock: u64,
142}
143
144#[cfg(feature = "application")]
145/// Content digest of the geometry a ray can hit.
146///
147/// Positions are hashed by bit pattern: two meshes that differ only by
148/// -0.0 vs 0.0 are geometrically identical here, but hashing bits is the
149/// conservative direction (a spurious miss rebuilds; a spurious HIT
150/// would serve the wrong index). Attributes and normals are excluded:
151/// they cannot change which triangle a ray strikes.
152/// Content key for a mesh.
153///
154/// This runs on EVERY cast, so it is the cache's standing cost, not a
155/// one-off. Two things make it affordable: ahash rather than SipHash
156/// (`DefaultHasher` is hardened against collision attacks nobody is
157/// mounting against local geometry), and one `write_u64` per vertex
158/// instead of three. Measured together: 0.52 ms -> 0.14 ms on 40,962
159/// vertices.
160///
161/// Sampling a subset was measured and rejected: with 512 of 40,962
162/// vertices sampled it detects 1.3% of single-vertex edits, so it
163/// would serve a stale index almost every time one vertex moved.
164fn digest(mesh: &TriMesh) -> u64 {
165    let mut hasher = AHasher::default();
166    mesh.indices.hash(&mut hasher);
167    for point in &mesh.positions {
168        // Rotations keep the fold order-sensitive, so swapping two
169        // coordinates changes the key.
170        let folded = point.x.to_bits()
171            ^ point.y.to_bits().rotate_left(21)
172            ^ point.z.to_bits().rotate_left(42);
173        hasher.write_u64(folded);
174    }
175    hasher.finish()
176}
177
178/// Build the broad phase with bounds padded by `margin`.
179///
180/// The narrow phase accepts a hit within tolerance of a triangle, so a
181/// broad phase pruning on EXACT bounds can discard a triangle the full
182/// scan would accept: a ray passing 1.3e-16 outside a tight box was
183/// rejected before the tolerant test ever ran. A broad phase may
184/// over-include -- the narrow phase rejects -- but must never
185/// under-include.
186fn build_bvh_with_margin(mesh: &TriMesh, margin: f64) -> Bvh<usize> {
187    let points = &mesh.positions;
188    let items = (0..mesh.indices.len() / 3).map(|triangle| {
189        let corners = &mesh.indices[triangle * 3..triangle * 3 + 3];
190        let first = points[corners[0] as usize];
191        let (mut low, mut high) = (first, first);
192        for corner in &corners[1..] {
193            let point = points[*corner as usize];
194            low = Point3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
195            high = Point3::new(
196                high.x.max(point.x),
197                high.y.max(point.y),
198                high.z.max(point.z),
199            );
200        }
201        SpatialItem::new(
202            triangle,
203            Aabb {
204                min: Point3::new(low.x - margin, low.y - margin, low.z - margin),
205                max: Point3::new(high.x + margin, high.y + margin, high.z + margin),
206            },
207        )
208    });
209    Bvh::build(items)
210}
211
212#[cfg(feature = "application")]
213impl RayIndexCache {
214    /// Nearest hit, using a cached broad phase once one exists.
215    ///
216    /// # Errors
217    ///
218    /// Propagates whatever `nearest_hit`/`nearest_hit_among` refuse.
219    pub(crate) fn nearest_hit(
220        &self,
221        mesh: &TriMesh,
222        ray: &Ray3,
223        tolerance: Tolerance,
224    ) -> Result<Option<RayHit3>, RayMeshError> {
225        let digest = digest(mesh);
226
227        // Fast path: a read lock is enough when the index already exists,
228        // so concurrent casts against one mesh do not serialise.
229        {
230            let inner = self.inner.read().expect("ray index cache poisoned");
231            if let Some(entry) = inner.entries.iter().find(|e| e.digest == digest) {
232                if let Some(bvh) = &entry.bvh {
233                    // Only reuse when the padding still covers this call.
234                    if entry.margin >= tolerance.linear() {
235                        return accelerated(mesh, ray, tolerance, bvh);
236                    }
237                }
238            }
239        }
240
241        // Slow path: record the cast and build once warm.
242        let mut inner = self.inner.write().expect("ray index cache poisoned");
243        inner.clock += 1;
244        let tick = inner.clock;
245        let capacity_reached = inner.entries.len() >= CAPACITY;
246        match inner.entries.iter_mut().find(|e| e.digest == digest) {
247            Some(entry) => {
248                entry.casts += 1;
249                entry.touched = tick;
250                let stale_margin = entry.margin < tolerance.linear();
251                if (entry.bvh.is_none() || stale_margin) && entry.casts > WARMUP_CASTS {
252                    entry.margin = tolerance.linear();
253                    entry.bvh = Some(build_bvh_with_margin(mesh, entry.margin));
254                }
255            }
256            None => {
257                if capacity_reached {
258                    // Evict least recently used, not the first entry: a
259                    // hot mesh must survive a burst of one-shot casts.
260                    if let Some(position) = inner
261                        .entries
262                        .iter()
263                        .enumerate()
264                        .min_by_key(|(_, e)| e.touched)
265                        .map(|(i, _)| i)
266                    {
267                        inner.entries.remove(position);
268                    }
269                }
270                inner.entries.push(Entry {
271                    digest,
272                    margin: 0.0,
273                    bvh: None,
274                    casts: 1,
275                    touched: tick,
276                });
277            }
278        }
279
280        // Whether or not an index now exists, answer THIS cast by the
281        // path that defines the contract. Using a freshly built index
282        // here would make the first accelerated answer untested against
283        // the scan it must agree with.
284        nearest_hit(mesh, ray, tolerance)
285    }
286}
287
288/// Nearest hit via the broad phase.
289///
290/// Candidates arrive nearest-bound-first, but a nearer BOUND does not
291/// mean a nearer HIT, so this cannot stop at the first success. It
292/// collects every candidate whose bounds the ray enters and hands the
293/// whole set to `nearest_hit_among`, which applies the same ordering
294/// rule as the full scan -- including which face wins a shared edge.
295/// Stopping early here produced a different owning triangle on 28 of
296/// 2000 rays in the probe.
297fn accelerated(
298    mesh: &TriMesh,
299    ray: &Ray3,
300    tolerance: Tolerance,
301    bvh: &Bvh<usize>,
302) -> Result<Option<RayHit3>, RayMeshError> {
303    let mut candidates = Vec::new();
304    bvh.visit_ray(ray, &mut |hit| {
305        candidates.push(*hit.key);
306        ControlFlow::Continue(())
307    });
308    if candidates.is_empty() {
309        return Ok(None);
310    }
311    candidates.sort_unstable();
312    nearest_hit_among(mesh, ray, tolerance, candidates)
313}
314
315// `Application` is Debug + Clone. A cache is derived state, so cloning
316// an application must NOT share or copy indices: the clone starts cold
317// and rebuilds what it needs. Sharing would let one clone observe
318// another's evictions.
319#[cfg(feature = "application")]
320impl Clone for RayIndexCache {
321    fn clone(&self) -> Self {
322        Self::default()
323    }
324}
325
326#[cfg(feature = "application")]
327impl std::fmt::Debug for RayIndexCache {
328    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        let entries = self.inner.read().map(|i| i.entries.len()).unwrap_or(0);
330        formatter
331            .debug_struct("RayIndexCache")
332            .field("entries", &entries)
333            .finish()
334    }
335}
336
337#[cfg(all(test, feature = "ray-mesh", feature = "spatial"))]
338mod ray_index_tests {
339    use super::*;
340    use axiolid_core::{Point3, Ray3, Tolerance, Vec3};
341    use axiolid_mesh::TriMesh;
342
343    fn tol() -> Tolerance {
344        Tolerance::new(1e-6, 1e-9).expect("tolerance")
345    }
346
347    /// Two stacked quads so a ray crosses several triangles: a broad
348    /// phase that returned only the nearest BOUND would pick wrong here.
349    fn slab() -> TriMesh {
350        let mut positions = Vec::new();
351        let mut indices = Vec::new();
352        for (level, z) in [0.0_f64, 1.0].into_iter().enumerate() {
353            let base = (level * 4) as u32;
354            positions.push(Point3::new(-1.0, -1.0, z));
355            positions.push(Point3::new(1.0, -1.0, z));
356            positions.push(Point3::new(1.0, 1.0, z));
357            positions.push(Point3::new(-1.0, 1.0, z));
358            indices.extend_from_slice(&[base, base + 1, base + 2]);
359            indices.extend_from_slice(&[base, base + 2, base + 3]);
360        }
361        TriMesh::new(positions, indices)
362    }
363
364    #[cfg(feature = "application")]
365    /// The cache changes HOW the answer is found, never WHAT it is.
366    /// Casts repeatedly so the comparison spans cold, warming and warm
367    /// states -- a test that stopped at one cast would never exercise
368    /// the accelerated path at all.
369    #[test]
370    fn cached_casts_match_the_full_scan() {
371        let cache = RayIndexCache::default();
372        let mesh = slab();
373        for step in 0..32 {
374            let offset = f64::from(step) * 0.05 - 0.8;
375            let ray = Ray3 {
376                origin: Point3::new(offset, 0.1, -3.0),
377                direction: Vec3::new(0.0, 0.0, 1.0),
378            };
379            let expected = nearest_hit(&mesh, &ray, tol()).expect("scan");
380            let actual = cache.nearest_hit(&mesh, &ray, tol()).expect("cached");
381            match (expected, actual) {
382                (None, None) => {}
383                (Some(want), Some(got)) => {
384                    assert!((want.t - got.t).abs() < 1e-12, "step {step}: t differs");
385                    // Not just distance: the owning face must match too,
386                    // or a shared-edge tie silently changes which
387                    // triangle the caller is told it hit.
388                    assert_eq!(want.triangle, got.triangle, "step {step}: triangle differs");
389                }
390                (a, b) => panic!("step {step}: hit disagreement {a:?} vs {b:?}"),
391            }
392        }
393    }
394
395    /// `TriMesh` fields are public, so a caller can move geometry under
396    /// a cached index. Keying on content means the edit produces a new
397    /// key and the stale BVH is never consulted. An address-based key
398    /// would pass every other test here and fail this one.
399    /// A grid big enough that the BVH really prunes: with only a handful
400    /// of triangles every leaf is a candidate anyway, so a stale index
401    /// still yields the right answer and the test proves nothing.
402    fn grid(n: usize, z: f64) -> TriMesh {
403        let mut positions = Vec::new();
404        let mut indices = Vec::new();
405        for i in 0..n {
406            for j in 0..n {
407                let (x, y) = (i as f64 * 0.1 - 2.0, j as f64 * 0.1 - 2.0);
408                let base = positions.len() as u32;
409                positions.push(Point3::new(x, y, z));
410                positions.push(Point3::new(x + 0.09, y, z));
411                positions.push(Point3::new(x, y + 0.09, z));
412                indices.extend_from_slice(&[base, base + 1, base + 2]);
413            }
414        }
415        TriMesh::new(positions, indices)
416    }
417
418    #[cfg(feature = "application")]
419    #[test]
420    fn mutating_the_mesh_does_not_serve_a_stale_index() {
421        let cache = RayIndexCache::default();
422        // 1600 triangles at z = 1, so the BVH prunes hard.
423        let mut mesh = grid(40, 1.0);
424        let ray = Ray3 {
425            origin: Point3::new(-1.97, -1.97, -3.0),
426            direction: Vec3::new(0.0, 0.0, 1.0),
427        };
428        for _ in 0..4 {
429            cache.nearest_hit(&mesh, &ray, tol()).expect("warm");
430        }
431
432        // Move ONE triangle -- the one the ray actually hits -- nearer.
433        // Indices never change, so a shape-only digest keeps the stale
434        // BVH, whose box for this triangle still sits at the old z. The
435        // pruned traversal then never offers it as a candidate.
436        let far = mesh.positions.len() - 3;
437        for k in 0..3 {
438            mesh.positions[far + k].x -= 3.9;
439            mesh.positions[far + k].y -= 3.9;
440            mesh.positions[far + k].z -= 2.0;
441        }
442
443        let after = cache.nearest_hit(&mesh, &ray, tol()).expect("after");
444        let truth = nearest_hit(&mesh, &ray, tol()).expect("scan");
445        assert_eq!(
446            after.map(|h| h.t.to_bits()),
447            truth.map(|h| h.t.to_bits()),
448            "stale index served after an in-place edit",
449        );
450    }
451
452    #[cfg(feature = "application")]
453    /// The fold is XOR-based, so it must not be symmetric in x/y/z:
454    /// without the rotations, swapping two coordinates would give the
455    /// same key and a moved vertex could go unnoticed.
456    #[test]
457    fn the_digest_is_sensitive_to_coordinate_order() {
458        let base = TriMesh::new(
459            vec![
460                Point3::new(1.0, 2.0, 3.0),
461                Point3::new(4.0, 5.0, 6.0),
462                Point3::new(7.0, 8.0, 9.0),
463            ],
464            vec![0, 1, 2],
465        );
466        let swapped = TriMesh::new(
467            vec![
468                Point3::new(2.0, 1.0, 3.0),
469                Point3::new(4.0, 5.0, 6.0),
470                Point3::new(7.0, 8.0, 9.0),
471            ],
472            vec![0, 1, 2],
473        );
474        assert_ne!(
475            digest(&base),
476            digest(&swapped),
477            "swapping x and y must change the key"
478        );
479
480        // Reordering whole vertices must also change the key: ahash
481        // mixes sequentially, so position in the buffer matters.
482        let reordered = TriMesh::new(
483            vec![
484                Point3::new(4.0, 5.0, 6.0),
485                Point3::new(1.0, 2.0, 3.0),
486                Point3::new(7.0, 8.0, 9.0),
487            ],
488            vec![0, 1, 2],
489        );
490        assert_ne!(
491            digest(&base),
492            digest(&reordered),
493            "reordering vertices must change the key"
494        );
495    }
496
497    /// The handle must give the same answer as the scan, including
498    /// which face owns a shared edge.
499    #[test]
500    fn handle_matches_the_full_scan() {
501        let mesh = grid(40, 1.0);
502        let index = MeshRayIndex::build(&mesh, tol());
503        for step in 0..64 {
504            let t = step as f64 * 0.03;
505            let ray = Ray3 {
506                origin: Point3::new(-1.9 + t, -1.9 + t, -3.0),
507                direction: Vec3::new(0.0, 0.0, 1.0),
508            };
509            let want = nearest_hit(&mesh, &ray, tol()).expect("scan");
510            let got = index.nearest_hit(&ray, tol()).expect("handle");
511            match (want, got) {
512                (None, None) => {}
513                (Some(a), Some(b)) => {
514                    assert!((a.t - b.t).abs() < 1e-12, "step {step}: t differs");
515                    assert_eq!(a.triangle, b.triangle, "step {step}: face differs");
516                }
517                (a, b) => panic!("step {step}: {a:?} vs {b:?}"),
518            }
519        }
520    }
521
522    #[cfg(feature = "application")]
523    /// The cache path shares the broad phase, so it had the same
524    /// grazing-ray gap: a ray passing within tolerance of a triangle
525    /// but outside its exact bounds. Pinned separately from the handle
526    /// so a regression in either is attributable.
527    #[test]
528    fn cached_casts_agree_on_grazing_rays() {
529        let cache = RayIndexCache::default();
530        let mesh = grid(40, 1.0);
531        for step in 0..64 {
532            let t = step as f64 * 0.03;
533            let ray = Ray3 {
534                origin: Point3::new(-1.9 + t, -1.9 + t, -3.0),
535                direction: Vec3::new(0.0, 0.0, 1.0),
536            };
537            // Cast twice: the second goes through the built index.
538            let _ = cache.nearest_hit(&mesh, &ray, tol()).expect("warm");
539            let _ = cache.nearest_hit(&mesh, &ray, tol()).expect("warm");
540            let want = nearest_hit(&mesh, &ray, tol()).expect("scan");
541            let got = cache.nearest_hit(&mesh, &ray, tol()).expect("cached");
542            match (want, got) {
543                (None, None) => {}
544                (Some(a), Some(b)) => {
545                    assert!((a.t - b.t).abs() < 1e-12, "step {step}: t differs");
546                    assert_eq!(a.triangle, b.triangle, "step {step}: face differs");
547                }
548                (a, b) => panic!("step {step}: {a:?} vs {b:?}"),
549            }
550        }
551    }
552
553    #[cfg(feature = "application")]
554    /// The cache must not grow without bound: a caller streaming meshes
555    /// would otherwise retain a BVH for every one it ever cast against.
556    #[test]
557    fn the_cache_is_bounded() {
558        let cache = RayIndexCache::default();
559        let ray = Ray3 {
560            origin: Point3::new(0.0, 0.0, -3.0),
561            direction: Vec3::new(0.0, 0.0, 1.0),
562        };
563        for step in 0..(CAPACITY * 4) {
564            let mut mesh = slab();
565            // Distinct geometry per iteration -> distinct digest.
566            for point in &mut mesh.positions {
567                point.z += step as f64 * 0.25;
568            }
569            cache.nearest_hit(&mesh, &ray, tol()).expect("cast");
570        }
571        let held = cache.inner.read().expect("lock").entries.len();
572        assert!(held <= CAPACITY, "cache grew to {held}");
573    }
574}