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;