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, or reshaped so the surface itself changes)
10//! hashes differently. Re-cutting an unchanged surface over the SAME corners is
11//! *not* an edit and deliberately does not move the hash — see
12//! **Retriangulation-invariant** below for the exact scope of that guarantee,
13//! and [`GeometryHasher::finish`] for what the fingerprint can and cannot
14//! distinguish.
15//!
16//! ## Design invariants
17//!
18//! * **RTC-invariant.** Each file independently shifts world coordinates toward
19//! the origin (Relative-To-Center) to preserve `f32` precision. That shift is
20//! a property of the *file*, not the element, and the base and head files may
21//! pick different offsets. We therefore hash in reconstructed **world**
22//! coordinates (`local + rtc_offset`), so the same wall in the same world
23//! spot hashes the same regardless of each file's RTC choice.
24//! * **Translation-sensitive.** Because we hash absolute world position, an
25//! element that genuinely *moved* hashes differently — a moved element is an
26//! edit ("orange"), not "unchanged".
27//! * **Order/winding-invariant.** Triangle order, vertex-buffer order, and
28//! winding are implementation details of the geometry kernel, not the shape.
29//! Each triangle's three quantized vertices are sorted before hashing, and
30//! triangles are combined commutatively, so reordering/rewinding does not move
31//! the hash.
32//! * **Retriangulation-invariant, over a fixed vertex set.** So is the
33//! triangulator's DIAGONAL CHOICE. The hash is therefore taken over the
34//! SURFACE, in two channels re-cutting cannot move — the SET of distinct
35//! quantized vertices, and the total area within each supporting PLANE (see
36//! [`surface`]).
37//!
38//! The guarantee is exactly this: re-cutting a region over the corners it
39//! already has (a re-split diagonal, a re-rooted fan) does not move the hash.
40//! It does **not** extend to a tessellation that INTRODUCES vertices — a quad
41//! refanned through a new centre point, or an edge split at a new midpoint,
42//! adds a member to the vertex-set channel and so does hash differently, even
43//! though the surface and its per-plane area are unchanged. Distinguishing
44//! that from a genuine edit needs a channel this fingerprint does not have.
45//! See [`GeometryHasher::finish`] for the rest of the limits.
46//! * **Tolerance-quantized.** Positions are snapped to a grid of `tolerance`
47//! metres before hashing. Larger tolerance absorbs float noise (fewer false
48//! "changed") at the cost of missing sub-tolerance edits. See
49//! [`DEFAULT_GEOM_HASH_TOLERANCE`] and the `tolerance_sweep` test for the
50//! trade-off — the effective floor is the `f32` precision of the local
51//! positions (~1e-4 m near origin), so tolerances below ~1 mm mostly hash
52//! float noise. A request finer than [`MIN_GEOM_HASH_TOLERANCE`] is clamped
53//! up to it: below that grid, [`surface::plane_of`]'s `i128` plane-offset
54//! arithmetic is an overflow surface on a georeferenced model, not a
55//! precision win — see that constant for the measured bound.
56//!
57//! All inputs must be in a single consistent frame for both files (i.e. unit
58//! scaled to metres, and either both pre- or both post- any axis convention
59//! swap). The caller is responsible for feeding `positions` and `rtc_offset`
60//! in the same frame.
61//!
62//! ## World AABB (#1891 follow-on)
63//!
64//! The same pass also accumulates an UNQUANTIZED `f64` world axis-aligned
65//! bounding box ([`GeometryHasher::world_aabb`]). The hash alone cannot say
66//! WHY two revisions differ — "hash changed" conflates moved, reshaped and
67//! re-tessellated — so the diff engine needs a second, interpretable signal.
68//! The box is free here: `add_mesh_with_origin` already reconstructs the exact
69//! `f64` world coordinate of every triangle corner in order to quantize it.
70//!
71//! ## Volume, and its gate (#1891)
72//!
73//! [`GeometryHasher::volume`] is the divergence-theorem volume of the same
74//! geometry — but only for entities whose produced mesh is PROVABLY a single
75//! closed orientable solid. That proof comes from
76//! [`crate::orient_mesh_outward_verdict`], which the producer runs on each
77//! segment immediately before feeding it here; the hasher cannot derive it
78//! itself, because the adjacency needed to decide closedness is exactly what
79//! that pass builds.
80//!
81//! Everything else gets `None`. Read [`GeometryHasher::volume`] before
82//! loosening any clause of that gate — each one is there because a specific,
83//! measured class of element reports a confidently wrong number without it,
84//! and none of the wrong numbers look wrong. [`GeometryClosure`] rides along so
85//! a consumer can say WHICH clause refused.
86
87/// The volume gate (`GeometryClosure` + `GeometryHasher::volume`). A CHILD
88/// module, not a sibling, so it can read this module's private accumulators
89/// without widening their visibility.
90#[path = "geom_closure.rs"]
91mod closure;
92pub use closure::GeometryClosure;
93
94/// The world AABB (`GeometryHasher::extend_bounds` + `::world_aabb`). A CHILD
95/// module, not a sibling, so it can read this module's private accumulators
96/// without widening their visibility.
97#[path = "geom_bounds.rs"]
98mod bounds;
99
100/// The two surface channels the fingerprint is built from. A CHILD module, not
101/// a sibling, so it can use this module's private `mix64`/`fold_i64`.
102#[path = "geom_surface.rs"]
103mod surface;
104
105/// The per-segment triangle fold (`add_mesh` / `add_mesh_with_origin` /
106/// `add_oriented_mesh`). A CHILD module, not a sibling, so it can read this
107/// module's private accumulators without widening their visibility.
108#[path = "geom_accumulate.rs"]
109mod accumulate;
110
111/// Default quantization grid in metres (1 mm). Chosen as a starting point near
112/// the `f32` precision floor of RTC-local coordinates; `tolerance_sweep` only
113/// exercises a synthetic cube today; real-revision-pair calibration is still
114/// open (see that test's doc comment).
115///
116/// Safety margin is narrower than the "near-origin" framing above suggests:
117/// measured `f32` ULP is 9.77e-4 m (98% of this bucket) at both 8192 m and
118/// 10 km, only just under 1 mm before crossing it at 16384 m.
119///
120/// What it actually depends on is the **largest absolute post-rebase
121/// coordinate** staying under ~16384 m (where the `f32` ULP crosses 1 mm) — an
122/// incidental dependency, not a designed one. Note that is a magnitude, not a
123/// span: a model centred on the origin can span ~32 km and still satisfy it,
124/// while one sitting 20 km out fails it however small it is. RTC re-centring
125/// makes the condition typical but does not guarantee it, in two ways worth
126/// stating rather than implying:
127///
128/// - `rtc_offset_from_translations` takes the **median** element translation
129/// and returns `(0,0,0)` unless it exceeds
130/// [`LARGE_COORD_THRESHOLD_METERS`](crate::LARGE_COORD_THRESHOLD_METERS).
131/// A model whose bulk sits near the origin but whose outlying elements sit
132/// on a national grid is therefore not re-centred at all, and those vertices
133/// are hashed from `f32` world coordinates already past this bucket.
134/// - Even when the rebase does fire, the offset it subtracts is the median
135/// element translation, not the model's centre. A model is therefore not
136/// centred on it, so an outlier can still land past 16384 m even when the
137/// overall span would have fitted had the rebase been centred.
138///
139/// So raising that threshold is not the only thing that would need this
140/// tolerance revisited; a far-flung or widely spread model reaches the same
141/// place without any constant changing.
142pub const DEFAULT_GEOM_HASH_TOLERANCE: f64 = 1.0e-3;
143
144/// Floor on the quantization tolerance ([`GeometryHasher::new`] clamps any
145/// smaller request up to this).
146///
147/// `plane_of`'s plane offset `d = n·point` is an `i128` product of a quantized
148/// normal and a quantized corner, both scaled by `1/tolerance`; it grows
149/// roughly as `1/tolerance²`. Measured on a georeferenced point (~2.6e6 m) and
150/// a 100 m triangle, `tolerance = 1e-9` pushes `d` to ~1.6e38 — within a factor
151/// of ~1 of `i128::MAX` (1.7e38), i.e. one differently-shaped input away from
152/// overflow (debug builds panic, release wraps and two unrelated planes can
153/// alias to the same key). At this floor the same inputs land `d` around
154/// 1.6e26 — six orders of magnitude of headroom. It is also three orders of
155/// magnitude finer than the documented useful floor (~1 mm, the `f32`
156/// precision limit of RTC-local coordinates — see the module docs'
157/// "Tolerance-quantized" bullet), so no real caller loses precision by being
158/// clamped to it.
159pub const MIN_GEOM_HASH_TOLERANCE: f64 = 1.0e-6;
160
161/// splitmix64 finalizer — strong avalanche for a single `u64`. Shared with
162/// `router::content_hash`'s 128-bit content hash, which uses this SAME
163/// finalizer per lane.
164#[inline]
165pub(crate) fn mix64(mut x: u64) -> u64 {
166 x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
167 x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
168 x ^ (x >> 31)
169}
170
171/// Fold one signed integer into a running hash (order-dependent).
172#[inline]
173fn fold_i64(acc: u64, v: i64) -> u64 {
174 mix64(acc ^ (v as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
175}
176
177/// Snap a world coordinate to the quantization grid.
178///
179/// `inv_tol` is `1.0 / tolerance`, hoisted out of the per-vertex loop.
180#[inline]
181fn quantize(world: f64, inv_tol: f64) -> i64 {
182 // round-half-away-from-zero; `f64::round` is symmetric about 0 so the grid
183 // is stable under sign changes.
184 (world * inv_tol).round() as i64
185}
186
187/// Accumulates a single entity's geometry signature across one or more mesh
188/// segments. Segments are combined commutatively, so the order in which the
189/// kernel emits an entity's pieces does not affect the result.
190#[derive(Clone, Debug)]
191pub struct GeometryHasher {
192 inv_tol: f64,
193 rtc: [f64; 3],
194 /// The distinct quantized world vertices seen so far, over every
195 /// non-degenerate triangle (a degenerate one's corners are triangulation
196 /// noise, and are excluded here for the same reason they are excluded from
197 /// the hash). Membership only — [`Self::vertex_accum`] carries the hash.
198 vertices: rustc_hash::FxHashSet<[i64; 3]>,
199 /// Commutative running sum over the DISTINCT vertices in [`Self::vertices`]
200 /// (one term added on first insertion), so vertex-buffer order, duplicated
201 /// corners and segment splitting cannot move it.
202 vertex_accum: u64,
203 /// Commutative running sum of `plane_key * (twice-area)` over every
204 /// triangle. Multiplication distributes over the wrapping sum, so this is
205 /// exactly `Σ_planes plane_key * (that plane's total twice-area)`: a
206 /// per-plane area total in O(1) space, invariant to how each plane's region
207 /// was cut into triangles.
208 plane_area_accum: u64,
209 triangle_count: u64,
210 /// Unquantized `f64` world bounds over every in-range triangle corner.
211 /// An axis still holding its `INFINITY..NEG_INFINITY` sentinel never
212 /// accumulated; axes can diverge here, so [`Self::world_aabb`] tests all
213 /// three rather than assuming they move together.
214 min: [f64; 3],
215 max: [f64; 3],
216 /// Running Σ 6·V over every contributing segment. Only read through
217 /// [`GeometryHasher::volume`], which decides whether it means anything.
218 volume6: f64,
219 /// Folded [`GeometryClosure`] over the segments seen so far.
220 closure: GeometryClosure,
221}
222
223impl GeometryHasher {
224 /// Create a hasher for one entity.
225 ///
226 /// * `tolerance` — quantization grid in metres (must be `> 0`). Clamped up
227 /// to [`MIN_GEOM_HASH_TOLERANCE`] — see that constant for why a smaller
228 /// request is an `i128` overflow surface in [`surface::plane_of`], not a
229 /// precision win.
230 /// * `rtc_offset` — the file's RTC offset, added back to local positions to
231 /// reconstruct world coordinates. Pass `[0.0; 3]` if positions are
232 /// already in world space.
233 pub fn new(tolerance: f64, rtc_offset: [f64; 3]) -> Self {
234 debug_assert!(tolerance > 0.0, "geometry hash tolerance must be positive");
235 let tolerance = tolerance.max(MIN_GEOM_HASH_TOLERANCE);
236 Self {
237 inv_tol: 1.0 / tolerance,
238 rtc: rtc_offset,
239 vertices: rustc_hash::FxHashSet::default(),
240 vertex_accum: 0,
241 plane_area_accum: 0,
242 triangle_count: 0,
243 min: [f64::INFINITY; 3],
244 max: [f64::NEG_INFINITY; 3],
245 volume6: 0.0,
246 closure: GeometryClosure::EMPTY,
247 }
248 }
249
250 /// `true` until at least one (non-degenerate, in-range) triangle has been
251 /// hashed. Lets callers skip emitting a fingerprint for entities that
252 /// produced no geometry.
253 pub fn is_empty(&self) -> bool {
254 self.triangle_count == 0
255 }
256
257 /// Finalize the entity's geometry hash: the distinct-vertex sum and the
258 /// per-plane area total.
259 ///
260 /// ## What a difference here means, and what it does not
261 ///
262 /// Two entities hash the same when they use the same set of quantized world
263 /// vertices AND every plane carries the same total area. That covers the
264 /// invariances the surface actually has — retriangulation, a re-rooted fan,
265 /// triangle/segment order, winding — and still separates every genuine edit
266 /// measured against it: a move, a scale, a face lifted out of its plane (new
267 /// plane key), and faces deleted, whether or not their corners survive
268 /// elsewhere in the mesh (the area falls either way).
269 ///
270 /// It is deliberately a weaker discriminator than the triangle set it
271 /// replaced. What it can no longer separate: two arrangements over the SAME
272 /// vertex set giving every plane the same total area (retriangulation is
273 /// the benign member of that family; a re-cut into a different region of
274 /// equal area on the same corners is the malign one, and is not something a
275 /// re-export produces), and a change of TRIANGLE COUNT alone — the count is
276 /// no longer folded in, being exactly what a retriangulation changes.
277 ///
278 /// Unchanged from before: winding is invisible, as is anything below the
279 /// quantization grid.
280 pub fn finish(&self) -> u64 {
281 let h = fold_i64(self.vertex_accum, self.plane_area_accum as i64);
282 mix64(h)
283 }
284}
285
286/// Convenience: hash a single-segment entity in one call.
287pub fn hash_mesh_world(
288 positions: &[f32],
289 indices: &[u32],
290 rtc_offset: [f64; 3],
291 tolerance: f64,
292) -> u64 {
293 let mut hasher = GeometryHasher::new(tolerance, rtc_offset);
294 hasher.add_mesh(positions, indices);
295 hasher.finish()
296}
297
298#[cfg(test)]
299#[path = "geom_hash_tests.rs"]
300mod tests;