ifc_lite_geometry/router/content_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//! Structural content hash of an IFC representation ITEM subtree, for geometry
6//! deduplication of the meshing + CSG compute.
7//!
8//! Repeated shapes reuse a local mesh after a structural signature match.
9//! Only completed fast BREP signatures may be shared between model routers.
10//!
11//! This hashes the FULLY RESOLVED item subtree (entity references followed to
12//! their values), so two geometrically identical items with different entity
13//! numbers map to the SAME key. It deliberately covers ONLY geometry-defining
14//! structure: colour/style (`IfcStyledItem` points INTO the item from outside,
15//! so it is never in the closure), the per-instance `geometry_id`, voids and
16//! placement all live OUTSIDE the item and stay per-instance — the cache holds a
17//! colour-free local mesh that every instance reuses with its own attributes.
18//!
19//! Full 128-bit structural hashes avoid the sampled-hash collisions of #833.
20//! Integer splitmix64 and float bit patterns produce target-independent keys.
21
22use crate::geom_hash::mix64;
23use ifc_lite_core::{
24 express_id::parse_express_id, step_encoding::step_string_literal_end, EntityDecoder,
25};
26use rustc_hash::FxHashMap;
27
28/// Defensive recursion bound. IFC geometry is a DAG (item → solids → profiles →
29/// points); deeply NESTED `IfcBooleanResult` chains are the realistic deep case,
30/// so this is set well above any plausible cut chain. Beyond it the hash falls
31/// back to an entity-id-distinct value (see `sig_entity`) so over-depth subtrees
32/// can never COLLIDE — they simply stop deduping rather than risk a false merge.
33const MAX_DEPTH: u32 = 256;
34
35/// Sentinel written into the memo while an entity's hash is being computed, so a
36/// (malformed) cycle resolves to a fixed value instead of recursing forever.
37const CYCLE_SENTINEL: u128 = 0xC1C1_C1C1_C1C1_C1C1_C1C1_C1C1_C1C1_C1C1;
38
39/// Seed for the cheap byte-level `IfcFacetedBrep` signature (see
40/// [`try_faceted_brep_signature`]). Distinct from the generic `0x5EED_5EED` type
41/// seed so a brep hashed via the fast path and one hashed via the recursive
42/// fallback occupy disjoint key space and never false-merge.
43const FACETED_BREP_TAG: u64 = 0xFACE_7B16_5160_0001;
44
45/// Fold a 64-bit value into a 128-bit running state across two independent lanes.
46#[inline]
47fn fold(state: u128, v: u64) -> u128 {
48 let lo = state as u64;
49 let hi = (state >> 64) as u64;
50 let lo2 = mix64(lo.wrapping_add(v).wrapping_mul(0x9E37_79B9_7F4A_7C15));
51 let hi2 = mix64(
52 hi.rotate_left(23) ^ v.wrapping_mul(0xC2B2_AE3D_27D4_EB4F).wrapping_add(0x1656_67B1),
53 );
54 ((hi2 as u128) << 64) | (lo2 as u128)
55}
56
57#[inline]
58fn fold_bytes(mut state: u128, bytes: &[u8]) -> u128 {
59 state = fold(state, bytes.len() as u64);
60 let mut chunks = bytes.chunks_exact(8);
61 for c in &mut chunks {
62 state = fold(state, u64::from_le_bytes(c.try_into().unwrap()));
63 }
64 let rem = chunks.remainder();
65 if !rem.is_empty() {
66 let mut buf = [0u8; 8];
67 buf[..rem.len()].copy_from_slice(rem);
68 state = fold(state, u64::from_le_bytes(buf));
69 }
70 state
71}
72
73/// Whether the raw STEP record `bytes` (`#<id>=IFCTYPE(...)`, possibly with a
74/// leading id or whitespace) names IFC type `name`, compared case-insensitively
75/// up to the opening `(`. Cheap: a byte-prefix scan, no attribute decode.
76#[inline]
77fn type_token_is(bytes: &[u8], name: &[u8]) -> bool {
78 let len = bytes.len();
79 let mut i = 0;
80 while i < len && matches!(bytes[i], b' ' | b'\r' | b'\n' | b'\t') {
81 i += 1;
82 }
83 // Skip an optional leading "#<digits>" entity id and its '=' separator.
84 if i < len && bytes[i] == b'#' {
85 i += 1;
86 while i < len && bytes[i].is_ascii_digit() {
87 i += 1;
88 }
89 while i < len && bytes[i] != b'=' && bytes[i] != b'(' {
90 i += 1;
91 }
92 if i < len && bytes[i] == b'=' {
93 i += 1;
94 }
95 while i < len && matches!(bytes[i], b' ' | b'\r' | b'\n' | b'\t') {
96 i += 1;
97 }
98 }
99 let ty = &bytes[i..];
100 ty.len() >= name.len()
101 && ty[..name.len()].eq_ignore_ascii_case(name)
102 // The type token must END here (next byte is '(' or whitespace), so
103 // "IFCFACETEDBREP" never matches a longer "IFCFACETEDBREPX".
104 && ty.get(name.len()).is_none_or(|&c| matches!(c, b'(' | b' ' | b'\r' | b'\n' | b'\t'))
105}
106
107/// Parse the first `#<digits>` entity reference inside a STEP record's attribute
108/// list — e.g. the single shell ref of `#5=IFCFACETEDBREP(#137924)`. Skips past
109/// the opening `(` first so the record's own `#id` prefix is never matched.
110#[inline]
111fn parse_first_ref(bytes: &[u8]) -> Option<u32> {
112 let len = bytes.len();
113 let mut i = 0;
114 while i < len && bytes[i] != b'(' {
115 i += 1;
116 }
117 while i < len && bytes[i] != b'#' {
118 i += 1;
119 }
120 if i >= len {
121 return None;
122 }
123 i += 1; // skip '#'
124 let start = i;
125 while i < len && bytes[i].is_ascii_digit() {
126 i += 1;
127 }
128 if i > start {
129 // A ref above `u32::MAX` refuses (`None`) rather than wrapping onto a
130 // real low-numbered entity — the same policy `parse_express_id`
131 // establishes for every other reference reader (issue #3421).
132 parse_express_id(&bytes[start..i])
133 } else {
134 None
135 }
136}
137
138/// `true` when the entity at `id` is an `IfcFacetedBrep`, peeked from its raw STEP
139/// bytes without decoding attributes.
140#[inline]
141pub(super) fn is_faceted_brep(decoder: &mut EntityDecoder, id: u32) -> bool {
142 decoder
143 .get_raw_bytes(id)
144 .is_some_and(|b| type_token_is(b, b"IFCFACETEDBREP"))
145}
146
147/// Cheap byte-level structural signature of an `IfcFacetedBrep`, mirroring the
148/// mesher's traversal (shell → faces → bounds → loops → point coords) through the
149/// SAME fast byte paths it uses — zero `decode_by_id`, so no per-point
150/// `AttributeValue` allocation (the ~8 s/model hot path on Tekla steel). Every bit
151/// the mesh depends on is folded in: face/bound counts, each bound's orientation
152/// and outer flag, and every loop's point coordinates in order. Two breps share a
153/// signature iff they mesh identically; any difference diverges the hash.
154///
155/// Returns `None` on any structural surprise (missing ref, malformed loop) so the
156/// caller falls back to the generic recursive signature — correctness preserved,
157/// only the fast dedup is skipped for that one item.
158pub(super) fn try_faceted_brep_signature(decoder: &mut EntityDecoder, brep_id: u32) -> Option<u128> {
159 // IfcFacetedBrep(#shell): a SINGLE bare ref, not a `((...))` list — so
160 // `get_entity_ref_list_fast` (which expects a nested list) can't read it.
161 // Parse the one shell ref straight from the brep's bytes. The shell, faces,
162 // and bounds below ARE ref lists, so the fast list reader handles them.
163 let shell_id = {
164 let bytes = decoder.get_raw_bytes(brep_id)?;
165 parse_first_ref(bytes)?
166 };
167 let face_ids = decoder.get_entity_ref_list_fast(shell_id)?;
168 let (mut bound_ids, mut coords) = (Vec::new(), Vec::new());
169 let mut acc = fold(0, FACETED_BREP_TAG);
170 acc = fold(acc, face_ids.len() as u64);
171 for face_id in face_ids {
172 decoder.get_entity_ref_list_fast_into(face_id, &mut bound_ids)?;
173 acc = fold(acc, bound_ids.len() as u64);
174 for &bound_id in &bound_ids {
175 let (loop_id, orientation, is_outer) = decoder.get_face_bound_fast(bound_id)?;
176 acc = fold(acc, orientation as u64);
177 acc = fold(acc, is_outer as u64);
178 decoder.get_polyloop_coords_cached_into(loop_id, &mut coords)?;
179 acc = fold(acc, coords.len() as u64);
180 for &(x, y, z) in &coords {
181 acc = fold(acc, x.to_bits());
182 acc = fold(acc, y.to_bits());
183 acc = fold(acc, z.to_bits());
184 }
185 }
186 }
187 Some(acc)
188}
189
190/// Face-count threshold above which [`faceted_brep_face_count`] tells the
191/// caller to skip the structural signature walk entirely (#1909). The walk in
192/// [`try_faceted_brep_signature`] mirrors the mesher's own traversal
193/// face-by-face, bound-by-bound, point-by-point — for dedup to pay off there
194/// must be at least one OTHER item sharing this exact signature. Tekla-style
195/// duplication (thousands of connection plates/bolts, the case this cache
196/// exists for) tops out at low hundreds of faces per part; a SINGLE huge
197/// faceted import — a terrain, a scanned mesh, one big BREP with no sibling —
198/// has none, so hashing every point is pure loss that DOUBLES the traversal
199/// cost of an already-expensive mesh with no possible payback. Set well above
200/// any plausible detail-part face count and well below the scale where a
201/// single giant mesh becomes measurably worse — a 2.5 M-triangle model
202/// reported ~30 s where web-ifc took ~2.85 s, traced to this hash.
203///
204/// `pub` (re-exported through `router` and the crate root — see `lib.rs`,
205/// same pattern as `GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION`) rather than
206/// `pub(super)`/`pub(crate)`, specifically so the #1909 regression test
207/// (`tests/issue_1909_large_single_brep_dedup.rs`, an external integration
208/// test crate that only sees this crate's public API) reads the real
209/// threshold instead of hard-coding `20_000` a second time (PR #1977
210/// review: that duplication is exactly the shape of the 341/88 checklist
211/// drift on #1963 — assertion and constant silently disagreeing).
212pub const FACETED_BREP_DEDUP_FACE_LIMIT: usize = 20_000;
213
214/// Cheap pre-check for [`FACETED_BREP_DEDUP_FACE_LIMIT`]: the face count of the
215/// `IfcFacetedBrep` at `brep_id`, read via the SAME fast ref-list path
216/// [`try_faceted_brep_signature`] uses for its first two hops (shell ref, face
217/// list) — no per-point decode, so this stays O(faces) even when the full
218/// signature would be O(faces × points). `None` on any structural surprise
219/// (missing shell ref, malformed list): the caller then falls through to
220/// computing the full signature as before, so a surprise never silently skips
221/// a legitimately dedupable item — only a confirmed large brep does.
222pub(super) fn faceted_brep_face_count(decoder: &mut EntityDecoder, brep_id: u32) -> Option<usize> {
223 let bytes = decoder.get_raw_bytes(brep_id)?;
224 let shell_id = parse_first_ref(bytes)?;
225 decoder.get_entity_ref_list_fast(shell_id).map(|v| v.len())
226}
227
228/// 128-bit structural hash of the representation item rooted at `root_id`. `memo`
229/// caches per-entity hashes so shared sub-entities (a profile reused by many
230/// solids, the representation context) are visited once; it keys on entity ids,
231/// so it must belong to ONE model (the `GeometryRouter` owns one per loaded
232/// file).
233/// `refused` accumulates one count per `#<digits>` child reference this walk
234/// could not parse because it exceeded `u32::MAX` (issue #3421/#3752) — the
235/// caller (a [`super::GeometryRouter`] method) folds it into
236/// [`super::GeometryRouter::take_content_hash_oversized_ref_drops`] so a
237/// maintainer can tell "this model has an unrepresentable reference" apart
238/// from "this subtree happens to hash like a genuinely missing one". The
239/// refusal itself is harmless to the DEDUP KEY (folded as the same fixed
240/// sentinel an unresolvable reference already uses, so renumbering-invariance
241/// holds), it is only the diagnostic that was missing.
242pub fn item_signature(
243 decoder: &mut EntityDecoder,
244 root_id: u32,
245 memo: &mut FxHashMap<u32, u128>,
246 refused: &mut usize,
247) -> u128 {
248 sig_entity(decoder, root_id, memo, 0, refused, false)
249}
250
251/// Continue a root whose fast BREP traversal already failed, without repeating it.
252pub(super) fn item_signature_after_failed_brep(
253 decoder: &mut EntityDecoder, root_id: u32, memo: &mut FxHashMap<u32, u128>, refused: &mut usize,
254) -> u128 {
255 sig_entity(decoder, root_id, memo, 0, refused, true)
256}
257
258/// Combine the pure structural item hash with the router parameters that change
259/// the MESHED output but live outside the IFC structure — tessellation quality
260/// (curved profiles tessellate finer at higher quality), unit scale, and RTC
261/// offset. Without this, a cache shared across routers — or one that outlives a
262/// `setTessellationQuality` change on the same worker — would serve a mesh built
263/// under different parameters (e.g. #976: every quality level returns the
264/// first-cached triangle count).
265pub fn key_with_params(structural: u128, quality_index: u8, unit_scale: f64, rtc: (f64, f64, f64)) -> u128 {
266 let mut s = fold(structural, quality_index as u64);
267 s = fold(s, unit_scale.to_bits());
268 s = fold(s, rtc.0.to_bits());
269 s = fold(s, rtc.1.to_bits());
270 fold(s, rtc.2.to_bits())
271}
272
273fn sig_entity(
274 decoder: &mut EntityDecoder,
275 id: u32,
276 memo: &mut FxHashMap<u32, u128>,
277 depth: u32,
278 refused: &mut usize,
279 skip_fast_brep: bool,
280) -> u128 {
281 if let Some(&s) = memo.get(&id) {
282 return s;
283 }
284 if depth > MAX_DEPTH {
285 // Fold the entity id so two DIFFERENT over-depth subtrees get DIFFERENT
286 // values — they stop deduping (id breaks renumbering-invariance) but can
287 // never false-merge, which matters far more than deduping a pathological
288 // boolean chain.
289 return fold(0xDEAD_BEEF_DEAD_BEEF, id as u64);
290 }
291 // Cheap byte-level fast path for the dominant geometry type: Tekla and other
292 // steel detailers export thousands of IfcFacetedBrep, and the generic
293 // recursive walk below `decode_by_id`s every IfcCartesianPoint (hundreds per
294 // part) — that is the measured ~8 s hash cost that made dedup a net loss. The
295 // fast path mirrors the mesher's traversal with no decode; on any structural
296 // surprise it falls through to the generic walk (correctness preserved).
297 if !skip_fast_brep && is_faceted_brep(decoder, id) {
298 if let Some(s) = try_faceted_brep_signature(decoder, id) {
299 memo.insert(id, s);
300 return s;
301 }
302 }
303 memo.insert(id, CYCLE_SENTINEL); // break cycles (DAG ⇒ unreachable in practice)
304 // Cheap structural hash from the RAW STEP bytes — no `decode_by_id`, so no
305 // per-attribute `AttributeValue` allocation. That decode cost (every
306 // IfcCartesianPoint of every extrusion/boolean operand) is what made dedup a
307 // net loss on procedural geometry (#1177) and kept the gate brep-only. Walk
308 // the record bytes folding literals, and on each `#ref` recurse so the hash
309 // stays structural + renumbering-invariant (the entity's own id is skipped).
310 let raw = match decoder.get_raw_bytes(id) { // #3988: borrows source, not decoder state.
311 Some(b) => b,
312 None => {
313 // Unresolvable reference: a fixed sentinel (NOT the id, so structurally
314 // identical-but-renumbered files still collide).
315 let s = fold(0, 0x00BA_D0BA_D0BA_D000);
316 memo.insert(id, s);
317 return s;
318 }
319 };
320 let acc = sig_walk_bytes(decoder, raw, memo, depth, refused);
321 memo.insert(id, acc);
322 acc
323}
324
325/// Structural signature of a single STEP record from its raw bytes, recursing on
326/// every `#ref`. Folds literal runs verbatim and replaces each ref with its
327/// child signature, so the result is identical regardless of entity-id numbering.
328/// `#` inside a single-quoted string (STEP escapes a literal quote as `''`, and
329/// the PAGE directive `\S\` may take an apostrophe operand) is NOT treated as a
330/// ref. The record's own leading `#<id>=` is skipped.
331fn sig_walk_bytes(
332 decoder: &mut EntityDecoder,
333 bytes: &[u8],
334 memo: &mut FxHashMap<u32, u128>,
335 depth: u32,
336 refused: &mut usize,
337) -> u128 {
338 let len = bytes.len();
339 // Skip a leading `#<id>=` (only when the `=` precedes the first `(`).
340 let mut i = 0;
341 {
342 let mut k = 0;
343 while k < len && bytes[k] != b'(' && bytes[k] != b'=' {
344 k += 1;
345 }
346 if k < len && bytes[k] == b'=' {
347 i = k + 1;
348 }
349 }
350 let mut acc = fold(0, 0x5EED_5EED);
351 let mut lit_start = i;
352 while i < len {
353 let c = bytes[i];
354 if c == b'\'' {
355 // One rule for where a literal ends: `''` and a PAGE directive's
356 // apostrophe operand (`'Para \S\' 5'`) stay inside it (#7589). A
357 // string that never closes swallows the rest as literal bytes.
358 match step_string_literal_end(bytes, i) {
359 Some(end) => {
360 i = end;
361 continue;
362 }
363 None => break,
364 }
365 }
366 if c == b'#' && i + 1 < len && bytes[i + 1].is_ascii_digit() {
367 acc = fold_bytes(acc, &bytes[lit_start..i]);
368 let mut j = i + 1;
369 while j < len && bytes[j].is_ascii_digit() {
370 j += 1;
371 }
372 let child = match parse_express_id(&bytes[i + 1..j]) {
373 Some(rid) => sig_entity(decoder, rid, memo, depth + 1, refused, false),
374 // A ref above `u32::MAX` refuses rather than wrapping onto a
375 // real low-numbered entity (issue #3421) — treated the same
376 // as an unresolvable reference: the fixed sentinel above,
377 // not the id, so structurally identical-but-renumbered files
378 // still collide. Counted (issue #3752): the hash stays
379 // correct, but a refusal here used to leave no trace at all.
380 None => {
381 *refused += 1;
382 fold(0, 0x00BA_D0BA_D0BA_D000)
383 }
384 };
385 acc = fold(fold(fold(acc, 1), child as u64), (child >> 64) as u64);
386 i = j;
387 lit_start = i;
388 continue;
389 }
390 i += 1;
391 }
392 fold_bytes(acc, &bytes[lit_start..len])
393}
394
395#[cfg(test)]
396#[path = "content_hash_tests.rs"]
397mod tests;