Skip to main content

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}