pub const DEFAULT_GEOM_HASH_TOLERANCE: f64 = 1.0e-3;
#[inline]
fn mix64(mut x: u64) -> u64 {
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^ (x >> 31)
}
#[inline]
fn fold_i64(acc: u64, v: i64) -> u64 {
mix64(acc ^ (v as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
}
#[inline]
fn quantize(world: f64, inv_tol: f64) -> i64 {
(world * inv_tol).round() as i64
}
#[derive(Clone, Debug)]
pub struct GeometryHasher {
inv_tol: f64,
rtc: [f64; 3],
triangle_accum: u64,
triangle_count: u64,
}
impl GeometryHasher {
pub fn new(tolerance: f64, rtc_offset: [f64; 3]) -> Self {
debug_assert!(tolerance > 0.0, "geometry hash tolerance must be positive");
Self {
inv_tol: 1.0 / tolerance,
rtc: rtc_offset,
triangle_accum: 0,
triangle_count: 0,
}
}
#[inline]
fn corner(&self, positions: &[f32], vi: usize) -> [i64; 3] {
let base = vi * 3;
[
quantize(positions[base] as f64 + self.rtc[0], self.inv_tol),
quantize(positions[base + 1] as f64 + self.rtc[1], self.inv_tol),
quantize(positions[base + 2] as f64 + self.rtc[2], self.inv_tol),
]
}
pub fn add_mesh(&mut self, positions: &[f32], indices: &[u32]) {
let vertex_limit = positions.len() / 3;
let triangle_end = indices.len() - (indices.len() % 3);
let mut i = 0;
while i < triangle_end {
let i0 = indices[i] as usize;
let i1 = indices[i + 1] as usize;
let i2 = indices[i + 2] as usize;
i += 3;
if i0 >= vertex_limit || i1 >= vertex_limit || i2 >= vertex_limit {
continue;
}
let mut tri = [
self.corner(positions, i0),
self.corner(positions, i1),
self.corner(positions, i2),
];
tri.sort_unstable();
let e1 = [
tri[1][0] as i128 - tri[0][0] as i128,
tri[1][1] as i128 - tri[0][1] as i128,
tri[1][2] as i128 - tri[0][2] as i128,
];
let e2 = [
tri[2][0] as i128 - tri[0][0] as i128,
tri[2][1] as i128 - tri[0][1] as i128,
tri[2][2] as i128 - tri[0][2] as i128,
];
let cross_x = e1[1] * e2[2] - e1[2] * e2[1];
let cross_y = e1[2] * e2[0] - e1[0] * e2[2];
let cross_z = e1[0] * e2[1] - e1[1] * e2[0];
if cross_x == 0 && cross_y == 0 && cross_z == 0 {
continue;
}
let mut h = 0x5bd1_e995_u64; for corner in tri {
for c in corner {
h = fold_i64(h, c);
}
}
self.triangle_accum = self.triangle_accum.wrapping_add(mix64(h));
self.triangle_count = self.triangle_count.wrapping_add(1);
}
}
pub fn is_empty(&self) -> bool {
self.triangle_count == 0
}
pub fn finish(&self) -> u64 {
let mut h = self.triangle_accum;
h = fold_i64(h, self.triangle_count as i64);
mix64(h)
}
}
pub fn hash_mesh_world(
positions: &[f32],
indices: &[u32],
rtc_offset: [f64; 3],
tolerance: f64,
) -> u64 {
let mut hasher = GeometryHasher::new(tolerance, rtc_offset);
hasher.add_mesh(positions, indices);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(origin: [f32; 3]) -> (Vec<f32>, Vec<u32>) {
let [ox, oy, oz] = origin;
let mut positions = Vec::with_capacity(8 * 3);
for &x in &[0.0_f32, 1.0] {
for &y in &[0.0_f32, 1.0] {
for &z in &[0.0_f32, 1.0] {
positions.extend_from_slice(&[ox + x, oy + y, oz + z]);
}
}
}
let indices = vec![
0, 1, 3, 0, 3, 2, 4, 6, 7, 4, 7, 5, 0, 4, 5, 0, 5, 1, 2, 3, 7, 2, 7, 6, 0, 2, 6, 0, 6,
4, 1, 5, 7, 1, 7, 3,
];
(positions, indices)
}
const TOL: f64 = 1.0e-3;
#[test]
fn rtc_invariance_same_world_geometry() {
let world_origin = [1234.5_f32, -67.25, 8.5];
let (pos_a, idx) = cube(world_origin);
let a = hash_mesh_world(&pos_a, &idx, [0.0, 0.0, 0.0], TOL);
let shift = [999_000.0_f64, -2_000.0, 5_000.0];
let pos_b: Vec<f32> = pos_a
.chunks_exact(3)
.flat_map(|c| {
[
(c[0] as f64 - shift[0]) as f32,
(c[1] as f64 - shift[1]) as f32,
(c[2] as f64 - shift[2]) as f32,
]
})
.collect();
let b = hash_mesh_world(&pos_b, &idx, shift, TOL);
assert_eq!(a, b, "RTC offset must not change the geometry hash");
}
#[test]
fn translation_is_detected() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let moved: Vec<f32> = pos.chunks_exact(3).flat_map(|c| [c[0] + 1.0, c[1], c[2]]).collect();
assert_ne!(
hash_mesh_world(&pos, &idx, [0.0; 3], TOL),
hash_mesh_world(&moved, &idx, [0.0; 3], TOL),
"a 1 m move must change the hash"
);
}
#[test]
fn degenerate_triangles_do_not_affect_hash() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let base = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let mut noisy = idx.clone();
noisy.extend_from_slice(&[0, 0, 1]);
noisy.extend_from_slice(&[2, 2, 2]);
let with_noise = hash_mesh_world(&pos, &noisy, [0.0; 3], TOL);
assert_eq!(base, with_noise, "zero-area triangles must not change the hash");
}
#[test]
fn sub_tolerance_jitter_is_ignored() {
let cell = TOL * 10.0;
let base: Vec<f32> = (0..24).map(|i| (i as f32) * (cell as f32)).collect();
let idx: Vec<u32> = (0..(base.len() as u32 / 3) - 2)
.flat_map(|i| [i, i + 1, i + 2])
.collect();
let jitter = (TOL as f32) * 0.1;
let perturbed: Vec<f32> = base.iter().map(|v| v + jitter).collect();
assert_eq!(
hash_mesh_world(&base, &idx, [0.0; 3], TOL),
hash_mesh_world(&perturbed, &idx, [0.0; 3], TOL),
"jitter below the quantization grid must not change the hash"
);
}
#[test]
fn triangle_and_vertex_order_invariant() {
let (pos, idx) = cube([3.0, 3.0, 3.0]);
let canonical = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let mut shuffled = Vec::with_capacity(idx.len());
for tri in idx.chunks_exact(3).rev() {
shuffled.extend_from_slice(&[tri[1], tri[2], tri[0]]);
}
assert_eq!(
canonical,
hash_mesh_world(&pos, &shuffled, [0.0; 3], TOL),
"reordering triangles / rotating corners must not change the hash"
);
}
#[test]
fn winding_invariant() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let canonical = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let flipped: Vec<u32> =
idx.chunks_exact(3).flat_map(|t| [t[0], t[2], t[1]]).collect();
assert_eq!(
canonical,
hash_mesh_world(&pos, &flipped, [0.0; 3], TOL),
"reversing winding must not change the hash"
);
}
#[test]
fn segment_split_matches_single_segment() {
let (pos, idx) = cube([10.0, 0.0, -4.0]);
let single = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let (first, second) = idx.split_at(idx.len() / 2);
let mut hasher = GeometryHasher::new(TOL, [0.0; 3]);
hasher.add_mesh(&pos, first);
hasher.add_mesh(&pos, second);
assert_eq!(single, hasher.finish(), "split segments must match a single mesh");
}
#[test]
fn distinct_shapes_differ() {
let (cube_pos, cube_idx) = cube([0.0, 0.0, 0.0]);
let (big_pos, big_idx) = cube([0.0, 0.0, 0.0]);
let scaled: Vec<f32> = big_pos.iter().map(|v| v * 2.0).collect();
assert_ne!(
hash_mesh_world(&cube_pos, &cube_idx, [0.0; 3], TOL),
hash_mesh_world(&scaled, &big_idx, [0.0; 3], TOL),
"a 2x-scaled cube must hash differently"
);
}
#[test]
fn tolerance_sweep_sensitivity() {
let (pos, idx) = cube([100.0, 50.0, 25.0]);
for &tol in &[1.0e-4_f64, 1.0e-3, 1.0e-2, 1.0e-1] {
let baseline = hash_mesh_world(&pos, &idx, [0.0; 3], tol);
let one_cell = tol as f32;
let moved: Vec<f32> =
pos.chunks_exact(3).flat_map(|c| [c[0] + one_cell, c[1], c[2]]).collect();
assert_ne!(
baseline,
hash_mesh_world(&moved, &idx, [0.0; 3], tol),
"tol={tol}: a one-cell move must be detected"
);
let tiny = (tol as f32) * 1.0e-3;
let nudged: Vec<f32> = pos.iter().map(|v| v + tiny).collect();
assert_eq!(
baseline,
hash_mesh_world(&nudged, &idx, [0.0; 3], tol),
"tol={tol}: sub-grid jitter must be absorbed"
);
}
}
}