ifc_lite_geometry/geom_hash.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Per-entity geometry fingerprinting for model diffing.
6//!
7//! The viewer's "compare two revisions" feature needs a stable per-entity
8//! signature so an unchanged element hashes identically across two files,
9//! while a genuine edit (moved, reshaped, retriangulated) hashes differently.
10//!
11//! ## Design invariants
12//!
13//! * **RTC-invariant.** Each file independently shifts world coordinates toward
14//! the origin (Relative-To-Center) to preserve `f32` precision. That shift is
15//! a property of the *file*, not the element, and the base and head files may
16//! pick different offsets. We therefore hash in reconstructed **world**
17//! coordinates (`local + rtc_offset`), so the same wall in the same world
18//! spot hashes the same regardless of each file's RTC choice.
19//! * **Translation-sensitive.** Because we hash absolute world position, an
20//! element that genuinely *moved* hashes differently — a moved element is an
21//! edit ("orange"), not "unchanged".
22//! * **Order/winding-invariant.** Triangle order, vertex-buffer order, and
23//! winding are implementation details of the geometry kernel, not the shape.
24//! Each triangle's three quantized vertices are sorted before hashing, and
25//! triangles are combined commutatively, so reordering/rewinding does not move
26//! the hash.
27//! * **Tolerance-quantized.** Positions are snapped to a grid of `tolerance`
28//! metres before hashing. Larger tolerance absorbs float noise (fewer false
29//! "changed") at the cost of missing sub-tolerance edits. See
30//! [`DEFAULT_GEOM_HASH_TOLERANCE`] and the `tolerance_sweep` test for the
31//! trade-off — the effective floor is the `f32` precision of the local
32//! positions (~1e-4 m near origin), so tolerances below ~1 mm mostly hash
33//! float noise.
34//!
35//! All inputs must be in a single consistent frame for both files (i.e. unit
36//! scaled to metres, and either both pre- or both post- any axis convention
37//! swap). The caller is responsible for feeding `positions` and `rtc_offset`
38//! in the same frame.
39
40/// Default quantization grid in metres (1 mm). Chosen as a starting point near
41/// the `f32` precision floor of RTC-local coordinates; tune empirically with
42/// the `tolerance_sweep` test against real revision pairs.
43pub const DEFAULT_GEOM_HASH_TOLERANCE: f64 = 1.0e-3;
44
45/// splitmix64 finalizer — strong avalanche for a single `u64`. Shared with
46/// `router::content_hash`'s 128-bit content hash, which uses this SAME
47/// finalizer per lane.
48#[inline]
49pub(crate) fn mix64(mut x: u64) -> u64 {
50 x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
51 x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
52 x ^ (x >> 31)
53}
54
55/// Fold one signed integer into a running hash (order-dependent).
56#[inline]
57fn fold_i64(acc: u64, v: i64) -> u64 {
58 mix64(acc ^ (v as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
59}
60
61/// Snap a world coordinate to the quantization grid.
62///
63/// `inv_tol` is `1.0 / tolerance`, hoisted out of the per-vertex loop.
64#[inline]
65fn quantize(world: f64, inv_tol: f64) -> i64 {
66 // round-half-away-from-zero; `f64::round` is symmetric about 0 so the grid
67 // is stable under sign changes.
68 (world * inv_tol).round() as i64
69}
70
71/// Accumulates a single entity's geometry signature across one or more mesh
72/// segments. Segments are combined commutatively, so the order in which the
73/// kernel emits an entity's pieces does not affect the result.
74#[derive(Clone, Debug)]
75pub struct GeometryHasher {
76 inv_tol: f64,
77 rtc: [f64; 3],
78 /// Commutative running sum of per-triangle hashes.
79 triangle_accum: u64,
80 triangle_count: u64,
81}
82
83impl GeometryHasher {
84 /// Create a hasher for one entity.
85 ///
86 /// * `tolerance` — quantization grid in metres (must be `> 0`).
87 /// * `rtc_offset` — the file's RTC offset, added back to local positions to
88 /// reconstruct world coordinates. Pass `[0.0; 3]` if positions are
89 /// already in world space.
90 pub fn new(tolerance: f64, rtc_offset: [f64; 3]) -> Self {
91 debug_assert!(tolerance > 0.0, "geometry hash tolerance must be positive");
92 Self {
93 inv_tol: 1.0 / tolerance,
94 rtc: rtc_offset,
95 triangle_accum: 0,
96 triangle_count: 0,
97 }
98 }
99
100 /// Hash the quantized world position of one vertex into a per-corner value.
101 /// `origin` is the per-mesh local-frame origin (`world = origin + position`);
102 /// pass `[0.0; 3]` for absolute-coordinate positions.
103 #[inline]
104 fn corner(&self, positions: &[f32], vi: usize, origin: &[f64; 3]) -> [i64; 3] {
105 let base = vi * 3;
106 [
107 quantize(positions[base] as f64 + origin[0] + self.rtc[0], self.inv_tol),
108 quantize(positions[base + 1] as f64 + origin[1] + self.rtc[1], self.inv_tol),
109 quantize(positions[base + 2] as f64 + origin[2] + self.rtc[2], self.inv_tol),
110 ]
111 }
112
113 /// Add one mesh segment (a flat `[x,y,z, ...]` position buffer and a
114 /// triangle index buffer). Indices that run past the position buffer or
115 /// trailing non-triangle remainder are skipped defensively.
116 pub fn add_mesh(&mut self, positions: &[f32], indices: &[u32]) {
117 self.add_mesh_with_origin(positions, indices, [0.0; 3]);
118 }
119
120 /// Like [`add_mesh`] but for positions stored in a per-element LOCAL frame:
121 /// `origin` (the per-mesh AABB-centre origin) is folded back so the hash is
122 /// over absolute world coordinates. This keeps the fingerprint identical
123 /// whether the producer emitted absolute positions (native) or local +
124 /// origin (the wasm local-frame path), and still detects element MOVES.
125 pub fn add_mesh_with_origin(&mut self, positions: &[f32], indices: &[u32], origin: [f64; 3]) {
126 let vertex_limit = positions.len() / 3;
127 let triangle_end = indices.len() - (indices.len() % 3);
128 let mut i = 0;
129 while i < triangle_end {
130 let i0 = indices[i] as usize;
131 let i1 = indices[i + 1] as usize;
132 let i2 = indices[i + 2] as usize;
133 i += 3;
134 if i0 >= vertex_limit || i1 >= vertex_limit || i2 >= vertex_limit {
135 continue;
136 }
137
138 // Sort the three quantized corners so triangle winding and the
139 // starting vertex don't affect the hash — only the (multiset of)
140 // positions and their adjacency as a triangle.
141 let mut tri = [
142 self.corner(positions, i0, &origin),
143 self.corner(positions, i1, &origin),
144 self.corner(positions, i2, &origin),
145 ];
146 tri.sort_unstable();
147
148 // Skip degenerate (zero-area) triangles. After quantization,
149 // coincident or colinear corners carry no shape signal, and
150 // counting them lets triangulation noise (sliver/zero-area faces)
151 // flip the fingerprint even when the rendered geometry is
152 // unchanged. The cross product of two edges is the zero vector
153 // exactly when the three quantized corners are colinear (which
154 // includes the coincident case). i128 avoids overflow on the
155 // quantized-coordinate products.
156 let e1 = [
157 tri[1][0] as i128 - tri[0][0] as i128,
158 tri[1][1] as i128 - tri[0][1] as i128,
159 tri[1][2] as i128 - tri[0][2] as i128,
160 ];
161 let e2 = [
162 tri[2][0] as i128 - tri[0][0] as i128,
163 tri[2][1] as i128 - tri[0][1] as i128,
164 tri[2][2] as i128 - tri[0][2] as i128,
165 ];
166 let cross_x = e1[1] * e2[2] - e1[2] * e2[1];
167 let cross_y = e1[2] * e2[0] - e1[0] * e2[2];
168 let cross_z = e1[0] * e2[1] - e1[1] * e2[0];
169 if cross_x == 0 && cross_y == 0 && cross_z == 0 {
170 continue;
171 }
172
173 let mut h = 0x5bd1_e995_u64; // arbitrary non-zero seed
174 for corner in tri {
175 for c in corner {
176 h = fold_i64(h, c);
177 }
178 }
179 // Commutative combine across triangles within and across segments.
180 self.triangle_accum = self.triangle_accum.wrapping_add(mix64(h));
181 self.triangle_count = self.triangle_count.wrapping_add(1);
182 }
183 }
184
185 /// `true` until at least one (non-degenerate, in-range) triangle has been
186 /// hashed. Lets callers skip emitting a fingerprint for entities that
187 /// produced no geometry.
188 pub fn is_empty(&self) -> bool {
189 self.triangle_count == 0
190 }
191
192 /// Finalize the entity's geometry hash. Folds in the triangle count so two
193 /// distinct shapes that happen to collide on the commutative triangle sum
194 /// are still separated by their cardinality. Vertex count is intentionally
195 /// excluded: it is ambiguous under shared-vs-duplicated vertices and under
196 /// segment splitting (the same entity may arrive as one mesh or several
197 /// sharing a position buffer), whereas the triangle count is intrinsic and
198 /// additive across segments.
199 pub fn finish(&self) -> u64 {
200 let mut h = self.triangle_accum;
201 h = fold_i64(h, self.triangle_count as i64);
202 mix64(h)
203 }
204}
205
206/// Convenience: hash a single-segment entity in one call.
207pub fn hash_mesh_world(
208 positions: &[f32],
209 indices: &[u32],
210 rtc_offset: [f64; 3],
211 tolerance: f64,
212) -> u64 {
213 let mut hasher = GeometryHasher::new(tolerance, rtc_offset);
214 hasher.add_mesh(positions, indices);
215 hasher.finish()
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 /// A unit cube (8 verts, 12 triangles) centred near `origin` in world
223 /// coordinates. Returns positions already in world space.
224 fn cube(origin: [f32; 3]) -> (Vec<f32>, Vec<u32>) {
225 let [ox, oy, oz] = origin;
226 let mut positions = Vec::with_capacity(8 * 3);
227 for &x in &[0.0_f32, 1.0] {
228 for &y in &[0.0_f32, 1.0] {
229 for &z in &[0.0_f32, 1.0] {
230 positions.extend_from_slice(&[ox + x, oy + y, oz + z]);
231 }
232 }
233 }
234 // 12 triangles over the 8 corners (not a watertight ordering — only
235 // needs to be a deterministic, non-degenerate triangle soup).
236 let indices = vec![
237 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,
238 4, 1, 5, 7, 1, 7, 3,
239 ];
240 (positions, indices)
241 }
242
243 const TOL: f64 = 1.0e-3;
244
245 #[test]
246 fn rtc_invariance_same_world_geometry() {
247 // Same wall at world position (1_000_000, 0, 0), expressed two ways:
248 // file A: local = world, rtc = [0,0,0]
249 // file B: local = world - 999_000, rtc = [999_000,0,0]
250 // f32 can't hold 1e6 + sub-metre detail, so build the geometry at a
251 // realistic magnitude where the two encodings reconstruct the same
252 // world coords within f32 precision.
253 let world_origin = [1234.5_f32, -67.25, 8.5];
254 let (pos_a, idx) = cube(world_origin);
255 let a = hash_mesh_world(&pos_a, &idx, [0.0, 0.0, 0.0], TOL);
256
257 let shift = [999_000.0_f64, -2_000.0, 5_000.0];
258 let pos_b: Vec<f32> = pos_a
259 .chunks_exact(3)
260 .flat_map(|c| {
261 [
262 (c[0] as f64 - shift[0]) as f32,
263 (c[1] as f64 - shift[1]) as f32,
264 (c[2] as f64 - shift[2]) as f32,
265 ]
266 })
267 .collect();
268 let b = hash_mesh_world(&pos_b, &idx, shift, TOL);
269
270 assert_eq!(a, b, "RTC offset must not change the geometry hash");
271 }
272
273 #[test]
274 fn translation_is_detected() {
275 let (pos, idx) = cube([0.0, 0.0, 0.0]);
276 let moved: Vec<f32> = pos.chunks_exact(3).flat_map(|c| [c[0] + 1.0, c[1], c[2]]).collect();
277 assert_ne!(
278 hash_mesh_world(&pos, &idx, [0.0; 3], TOL),
279 hash_mesh_world(&moved, &idx, [0.0; 3], TOL),
280 "a 1 m move must change the hash"
281 );
282 }
283
284 #[test]
285 fn degenerate_triangles_do_not_affect_hash() {
286 let (pos, idx) = cube([0.0, 0.0, 0.0]);
287 let base = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
288
289 // Append zero-area triangles (repeated/coincident corners) — the kind
290 // of triangulation noise that must not move the fingerprint.
291 let mut noisy = idx.clone();
292 noisy.extend_from_slice(&[0, 0, 1]);
293 noisy.extend_from_slice(&[2, 2, 2]);
294 let with_noise = hash_mesh_world(&pos, &noisy, [0.0; 3], TOL);
295
296 assert_eq!(base, with_noise, "zero-area triangles must not change the hash");
297 }
298
299 #[test]
300 fn sub_tolerance_jitter_is_ignored() {
301 // `round(v/tol)` puts cell *centres* at integer multiples of `tol` and
302 // cell *boundaries* at the half-grid `(k+0.5)*tol`. Place verts at
303 // centres (here `10*tol` apart, well clear of boundaries) so a jitter
304 // below half a cell stays inside the same quantization cell.
305 let cell = TOL * 10.0;
306 let base: Vec<f32> = (0..24).map(|i| (i as f32) * (cell as f32)).collect();
307 let idx: Vec<u32> = (0..(base.len() as u32 / 3) - 2)
308 .flat_map(|i| [i, i + 1, i + 2])
309 .collect();
310
311 let jitter = (TOL as f32) * 0.1;
312 let perturbed: Vec<f32> = base.iter().map(|v| v + jitter).collect();
313
314 assert_eq!(
315 hash_mesh_world(&base, &idx, [0.0; 3], TOL),
316 hash_mesh_world(&perturbed, &idx, [0.0; 3], TOL),
317 "jitter below the quantization grid must not change the hash"
318 );
319 }
320
321 #[test]
322 fn triangle_and_vertex_order_invariant() {
323 let (pos, idx) = cube([3.0, 3.0, 3.0]);
324 let canonical = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
325
326 // Reverse triangle order and rotate each triangle's corners.
327 let mut shuffled = Vec::with_capacity(idx.len());
328 for tri in idx.chunks_exact(3).rev() {
329 shuffled.extend_from_slice(&[tri[1], tri[2], tri[0]]);
330 }
331 assert_eq!(
332 canonical,
333 hash_mesh_world(&pos, &shuffled, [0.0; 3], TOL),
334 "reordering triangles / rotating corners must not change the hash"
335 );
336 }
337
338 #[test]
339 fn winding_invariant() {
340 let (pos, idx) = cube([0.0, 0.0, 0.0]);
341 let canonical = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
342 let flipped: Vec<u32> =
343 idx.chunks_exact(3).flat_map(|t| [t[0], t[2], t[1]]).collect();
344 assert_eq!(
345 canonical,
346 hash_mesh_world(&pos, &flipped, [0.0; 3], TOL),
347 "reversing winding must not change the hash"
348 );
349 }
350
351 #[test]
352 fn segment_split_matches_single_segment() {
353 // Hashing an entity as one 12-triangle mesh must equal hashing it as
354 // two 6-triangle segments (entities arrive split across submeshes).
355 let (pos, idx) = cube([10.0, 0.0, -4.0]);
356 let single = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
357
358 let (first, second) = idx.split_at(idx.len() / 2);
359 let mut hasher = GeometryHasher::new(TOL, [0.0; 3]);
360 hasher.add_mesh(&pos, first);
361 hasher.add_mesh(&pos, second);
362 assert_eq!(single, hasher.finish(), "split segments must match a single mesh");
363 }
364
365 #[test]
366 fn distinct_shapes_differ() {
367 let (cube_pos, cube_idx) = cube([0.0, 0.0, 0.0]);
368 let (big_pos, big_idx) = cube([0.0, 0.0, 0.0]);
369 let scaled: Vec<f32> = big_pos.iter().map(|v| v * 2.0).collect();
370 assert_ne!(
371 hash_mesh_world(&cube_pos, &cube_idx, [0.0; 3], TOL),
372 hash_mesh_world(&scaled, &big_idx, [0.0; 3], TOL),
373 "a 2x-scaled cube must hash differently"
374 );
375 }
376
377 /// Documents the tolerance trade-off empirically: a move of exactly one
378 /// grid cell is always detected; the same geometry under pure
379 /// reconstruction noise stays stable. This is the harness to extend with
380 /// real revision pairs when tuning `DEFAULT_GEOM_HASH_TOLERANCE`.
381 #[test]
382 fn tolerance_sweep_sensitivity() {
383 let (pos, idx) = cube([100.0, 50.0, 25.0]);
384 for &tol in &[1.0e-4_f64, 1.0e-3, 1.0e-2, 1.0e-1] {
385 let baseline = hash_mesh_world(&pos, &idx, [0.0; 3], tol);
386
387 // A move of one full grid cell must always register as changed.
388 let one_cell = tol as f32;
389 let moved: Vec<f32> =
390 pos.chunks_exact(3).flat_map(|c| [c[0] + one_cell, c[1], c[2]]).collect();
391 assert_ne!(
392 baseline,
393 hash_mesh_world(&moved, &idx, [0.0; 3], tol),
394 "tol={tol}: a one-cell move must be detected"
395 );
396
397 // A move of one thousandth of a cell must be absorbed. The cube
398 // sits at integer coords; for every tolerance here those land on
399 // cell centres (integer multiples of `tol`), so a tiny nudge stays
400 // in-cell.
401 let tiny = (tol as f32) * 1.0e-3;
402 let nudged: Vec<f32> = pos.iter().map(|v| v + tiny).collect();
403 assert_eq!(
404 baseline,
405 hash_mesh_world(&nudged, &idx, [0.0; 3], tol),
406 "tol={tol}: sub-grid jitter must be absorbed"
407 );
408 }
409 }
410}