#[cfg(feature = "application")]
use ahash::AHasher;
#[cfg(feature = "application")]
use std::hash::{Hash, Hasher};
#[cfg(feature = "application")]
use std::sync::RwLock;
use axiolid_core::Aabb;
use axiolid_core::{Point3, Ray3, Tolerance};
use axiolid_mesh::TriMesh;
use axiolid_ray_mesh::{nearest_hit, nearest_hit_among, RayHit3, RayMeshError};
use axiolid_spatial::{Bvh, SpatialIndex, SpatialItem};
use std::ops::ControlFlow;
pub struct MeshRayIndex<'m> {
mesh: &'m TriMesh,
bvh: Bvh<usize>,
margin: f64,
}
impl<'m> MeshRayIndex<'m> {
#[must_use]
pub fn build(mesh: &'m TriMesh, tolerance: Tolerance) -> Self {
let margin = tolerance.linear();
Self {
bvh: build_bvh_with_margin(mesh, margin),
mesh,
margin,
}
}
pub fn nearest_hit(
&self,
ray: &Ray3,
tolerance: Tolerance,
) -> Result<Option<RayHit3>, RayMeshError> {
if tolerance.linear() > self.margin {
return nearest_hit(self.mesh, ray, tolerance);
}
accelerated(self.mesh, ray, tolerance, &self.bvh)
}
}
#[cfg(feature = "application")]
const CAPACITY: usize = 8;
#[cfg(feature = "application")]
const WARMUP_CASTS: u32 = 1;
#[cfg(feature = "application")]
struct Entry {
digest: u64,
margin: f64,
bvh: Option<Bvh<usize>>,
casts: u32,
touched: u64,
}
#[cfg(feature = "application")]
#[derive(Default)]
pub(crate) struct RayIndexCache {
inner: RwLock<Inner>,
}
#[cfg(feature = "application")]
#[derive(Default)]
struct Inner {
entries: Vec<Entry>,
clock: u64,
}
#[cfg(feature = "application")]
fn digest(mesh: &TriMesh) -> u64 {
let mut hasher = AHasher::default();
mesh.indices.hash(&mut hasher);
for point in &mesh.positions {
let folded = point.x.to_bits()
^ point.y.to_bits().rotate_left(21)
^ point.z.to_bits().rotate_left(42);
hasher.write_u64(folded);
}
hasher.finish()
}
fn build_bvh_with_margin(mesh: &TriMesh, margin: f64) -> Bvh<usize> {
let points = &mesh.positions;
let items = (0..mesh.indices.len() / 3).map(|triangle| {
let corners = &mesh.indices[triangle * 3..triangle * 3 + 3];
let first = points[corners[0] as usize];
let (mut low, mut high) = (first, first);
for corner in &corners[1..] {
let point = points[*corner as usize];
low = Point3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
high = Point3::new(
high.x.max(point.x),
high.y.max(point.y),
high.z.max(point.z),
);
}
SpatialItem::new(
triangle,
Aabb {
min: Point3::new(low.x - margin, low.y - margin, low.z - margin),
max: Point3::new(high.x + margin, high.y + margin, high.z + margin),
},
)
});
Bvh::build(items)
}
#[cfg(feature = "application")]
impl RayIndexCache {
pub(crate) fn nearest_hit(
&self,
mesh: &TriMesh,
ray: &Ray3,
tolerance: Tolerance,
) -> Result<Option<RayHit3>, RayMeshError> {
let digest = digest(mesh);
{
let inner = self.inner.read().expect("ray index cache poisoned");
if let Some(entry) = inner.entries.iter().find(|e| e.digest == digest) {
if let Some(bvh) = &entry.bvh {
if entry.margin >= tolerance.linear() {
return accelerated(mesh, ray, tolerance, bvh);
}
}
}
}
let mut inner = self.inner.write().expect("ray index cache poisoned");
inner.clock += 1;
let tick = inner.clock;
let capacity_reached = inner.entries.len() >= CAPACITY;
match inner.entries.iter_mut().find(|e| e.digest == digest) {
Some(entry) => {
entry.casts += 1;
entry.touched = tick;
let stale_margin = entry.margin < tolerance.linear();
if (entry.bvh.is_none() || stale_margin) && entry.casts > WARMUP_CASTS {
entry.margin = tolerance.linear();
entry.bvh = Some(build_bvh_with_margin(mesh, entry.margin));
}
}
None => {
if capacity_reached {
if let Some(position) = inner
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.touched)
.map(|(i, _)| i)
{
inner.entries.remove(position);
}
}
inner.entries.push(Entry {
digest,
margin: 0.0,
bvh: None,
casts: 1,
touched: tick,
});
}
}
nearest_hit(mesh, ray, tolerance)
}
}
fn accelerated(
mesh: &TriMesh,
ray: &Ray3,
tolerance: Tolerance,
bvh: &Bvh<usize>,
) -> Result<Option<RayHit3>, RayMeshError> {
let mut candidates = Vec::new();
bvh.visit_ray(ray, &mut |hit| {
candidates.push(*hit.key);
ControlFlow::Continue(())
});
if candidates.is_empty() {
return Ok(None);
}
candidates.sort_unstable();
nearest_hit_among(mesh, ray, tolerance, candidates)
}
#[cfg(feature = "application")]
impl Clone for RayIndexCache {
fn clone(&self) -> Self {
Self::default()
}
}
#[cfg(feature = "application")]
impl std::fmt::Debug for RayIndexCache {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let entries = self.inner.read().map(|i| i.entries.len()).unwrap_or(0);
formatter
.debug_struct("RayIndexCache")
.field("entries", &entries)
.finish()
}
}
#[cfg(all(test, feature = "ray-mesh", feature = "spatial"))]
mod ray_index_tests {
use super::*;
use axiolid_core::{Point3, Ray3, Tolerance, Vec3};
use axiolid_mesh::TriMesh;
fn tol() -> Tolerance {
Tolerance::new(1e-6, 1e-9).expect("tolerance")
}
fn slab() -> TriMesh {
let mut positions = Vec::new();
let mut indices = Vec::new();
for (level, z) in [0.0_f64, 1.0].into_iter().enumerate() {
let base = (level * 4) as u32;
positions.push(Point3::new(-1.0, -1.0, z));
positions.push(Point3::new(1.0, -1.0, z));
positions.push(Point3::new(1.0, 1.0, z));
positions.push(Point3::new(-1.0, 1.0, z));
indices.extend_from_slice(&[base, base + 1, base + 2]);
indices.extend_from_slice(&[base, base + 2, base + 3]);
}
TriMesh::new(positions, indices)
}
#[cfg(feature = "application")]
#[test]
fn cached_casts_match_the_full_scan() {
let cache = RayIndexCache::default();
let mesh = slab();
for step in 0..32 {
let offset = f64::from(step) * 0.05 - 0.8;
let ray = Ray3 {
origin: Point3::new(offset, 0.1, -3.0),
direction: Vec3::new(0.0, 0.0, 1.0),
};
let expected = nearest_hit(&mesh, &ray, tol()).expect("scan");
let actual = cache.nearest_hit(&mesh, &ray, tol()).expect("cached");
match (expected, actual) {
(None, None) => {}
(Some(want), Some(got)) => {
assert!((want.t - got.t).abs() < 1e-12, "step {step}: t differs");
assert_eq!(want.triangle, got.triangle, "step {step}: triangle differs");
}
(a, b) => panic!("step {step}: hit disagreement {a:?} vs {b:?}"),
}
}
}
fn grid(n: usize, z: f64) -> TriMesh {
let mut positions = Vec::new();
let mut indices = Vec::new();
for i in 0..n {
for j in 0..n {
let (x, y) = (i as f64 * 0.1 - 2.0, j as f64 * 0.1 - 2.0);
let base = positions.len() as u32;
positions.push(Point3::new(x, y, z));
positions.push(Point3::new(x + 0.09, y, z));
positions.push(Point3::new(x, y + 0.09, z));
indices.extend_from_slice(&[base, base + 1, base + 2]);
}
}
TriMesh::new(positions, indices)
}
#[cfg(feature = "application")]
#[test]
fn mutating_the_mesh_does_not_serve_a_stale_index() {
let cache = RayIndexCache::default();
let mut mesh = grid(40, 1.0);
let ray = Ray3 {
origin: Point3::new(-1.97, -1.97, -3.0),
direction: Vec3::new(0.0, 0.0, 1.0),
};
for _ in 0..4 {
cache.nearest_hit(&mesh, &ray, tol()).expect("warm");
}
let far = mesh.positions.len() - 3;
for k in 0..3 {
mesh.positions[far + k].x -= 3.9;
mesh.positions[far + k].y -= 3.9;
mesh.positions[far + k].z -= 2.0;
}
let after = cache.nearest_hit(&mesh, &ray, tol()).expect("after");
let truth = nearest_hit(&mesh, &ray, tol()).expect("scan");
assert_eq!(
after.map(|h| h.t.to_bits()),
truth.map(|h| h.t.to_bits()),
"stale index served after an in-place edit",
);
}
#[cfg(feature = "application")]
#[test]
fn the_digest_is_sensitive_to_coordinate_order() {
let base = TriMesh::new(
vec![
Point3::new(1.0, 2.0, 3.0),
Point3::new(4.0, 5.0, 6.0),
Point3::new(7.0, 8.0, 9.0),
],
vec![0, 1, 2],
);
let swapped = TriMesh::new(
vec![
Point3::new(2.0, 1.0, 3.0),
Point3::new(4.0, 5.0, 6.0),
Point3::new(7.0, 8.0, 9.0),
],
vec![0, 1, 2],
);
assert_ne!(
digest(&base),
digest(&swapped),
"swapping x and y must change the key"
);
let reordered = TriMesh::new(
vec![
Point3::new(4.0, 5.0, 6.0),
Point3::new(1.0, 2.0, 3.0),
Point3::new(7.0, 8.0, 9.0),
],
vec![0, 1, 2],
);
assert_ne!(
digest(&base),
digest(&reordered),
"reordering vertices must change the key"
);
}
#[test]
fn handle_matches_the_full_scan() {
let mesh = grid(40, 1.0);
let index = MeshRayIndex::build(&mesh, tol());
for step in 0..64 {
let t = step as f64 * 0.03;
let ray = Ray3 {
origin: Point3::new(-1.9 + t, -1.9 + t, -3.0),
direction: Vec3::new(0.0, 0.0, 1.0),
};
let want = nearest_hit(&mesh, &ray, tol()).expect("scan");
let got = index.nearest_hit(&ray, tol()).expect("handle");
match (want, got) {
(None, None) => {}
(Some(a), Some(b)) => {
assert!((a.t - b.t).abs() < 1e-12, "step {step}: t differs");
assert_eq!(a.triangle, b.triangle, "step {step}: face differs");
}
(a, b) => panic!("step {step}: {a:?} vs {b:?}"),
}
}
}
#[cfg(feature = "application")]
#[test]
fn cached_casts_agree_on_grazing_rays() {
let cache = RayIndexCache::default();
let mesh = grid(40, 1.0);
for step in 0..64 {
let t = step as f64 * 0.03;
let ray = Ray3 {
origin: Point3::new(-1.9 + t, -1.9 + t, -3.0),
direction: Vec3::new(0.0, 0.0, 1.0),
};
let _ = cache.nearest_hit(&mesh, &ray, tol()).expect("warm");
let _ = cache.nearest_hit(&mesh, &ray, tol()).expect("warm");
let want = nearest_hit(&mesh, &ray, tol()).expect("scan");
let got = cache.nearest_hit(&mesh, &ray, tol()).expect("cached");
match (want, got) {
(None, None) => {}
(Some(a), Some(b)) => {
assert!((a.t - b.t).abs() < 1e-12, "step {step}: t differs");
assert_eq!(a.triangle, b.triangle, "step {step}: face differs");
}
(a, b) => panic!("step {step}: {a:?} vs {b:?}"),
}
}
}
#[cfg(feature = "application")]
#[test]
fn the_cache_is_bounded() {
let cache = RayIndexCache::default();
let ray = Ray3 {
origin: Point3::new(0.0, 0.0, -3.0),
direction: Vec3::new(0.0, 0.0, 1.0),
};
for step in 0..(CAPACITY * 4) {
let mut mesh = slab();
for point in &mut mesh.positions {
point.z += step as f64 * 0.25;
}
cache.nearest_hit(&mesh, &ray, tol()).expect("cast");
}
let held = cache.inner.read().expect("lock").entries.len();
assert!(held <= CAPACITY, "cache grew to {held}");
}
}