|
| 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 | +//! Tekla (and other steel detailers) export thousands of geometrically identical |
| 9 | +//! parts — connection plates, bolts — each with its OWN representation item |
| 10 | +//! rather than sharing one via `IfcMappedItem`. The Manifold kernel chewed |
| 11 | +//! through the redundant booleans fast; the exact pure-Rust kernel (#1024) is |
| 12 | +//! ~20-40× slower per cut, so re-meshing+re-CSG'ing the duplicates dominates load |
| 13 | +//! time (a 19.5 MB Tekla model: 83% of 15k items are byte-duplicates). |
| 14 | +//! |
| 15 | +//! This hashes the FULLY RESOLVED item subtree (entity references followed to |
| 16 | +//! their values), so two geometrically identical items with different entity |
| 17 | +//! numbers map to the SAME key. It deliberately covers ONLY geometry-defining |
| 18 | +//! structure: colour/style (`IfcStyledItem` points INTO the item from outside, |
| 19 | +//! so it is never in the closure), the per-instance `geometry_id`, voids and |
| 20 | +//! placement all live OUTSIDE the item and stay per-instance — the cache holds a |
| 21 | +//! colour-free local mesh that every instance reuses with its own attributes. |
| 22 | +//! |
| 23 | +//! The hash is 128-bit over the COMPLETE structure (every attribute value, |
| 24 | +//! recursively), unlike the sampled 64-bit mesh hash that collided in #833. The |
| 25 | +//! collision probability across a model's items is ~1e-30, so no post-mesh |
| 26 | +//! equality fallback is needed. Deterministic (integer splitmix64, no float |
| 27 | +//! ordering beyond the bit pattern), so native x86_64/aarch64 and wasm32 produce |
| 28 | +//! identical keys. |
| 29 | +
|
| 30 | +use ifc_lite_core::{AttributeValue, EntityDecoder}; |
| 31 | +use rustc_hash::FxHashMap; |
| 32 | + |
| 33 | +/// Defensive recursion bound. IFC geometry is a DAG (item → solids → profiles → |
| 34 | +/// points); deeply NESTED `IfcBooleanResult` chains are the realistic deep case, |
| 35 | +/// so this is set well above any plausible cut chain. Beyond it the hash falls |
| 36 | +/// back to an entity-id-distinct value (see `sig_entity`) so over-depth subtrees |
| 37 | +/// can never COLLIDE — they simply stop deduping rather than risk a false merge. |
| 38 | +const MAX_DEPTH: u32 = 256; |
| 39 | + |
| 40 | +/// Sentinel written into the memo while an entity's hash is being computed, so a |
| 41 | +/// (malformed) cycle resolves to a fixed value instead of recursing forever. |
| 42 | +const CYCLE_SENTINEL: u128 = 0xC1C1_C1C1_C1C1_C1C1_C1C1_C1C1_C1C1_C1C1; |
| 43 | + |
| 44 | +#[inline] |
| 45 | +fn mix64(mut x: u64) -> u64 { |
| 46 | + // splitmix64 finalizer — strong avalanche, same as `geom_hash::mix64`. |
| 47 | + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); |
| 48 | + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); |
| 49 | + x ^ (x >> 31) |
| 50 | +} |
| 51 | + |
| 52 | +/// Fold a 64-bit value into a 128-bit running state across two independent lanes. |
| 53 | +#[inline] |
| 54 | +fn fold(state: u128, v: u64) -> u128 { |
| 55 | + let lo = state as u64; |
| 56 | + let hi = (state >> 64) as u64; |
| 57 | + let lo2 = mix64(lo.wrapping_add(v).wrapping_mul(0x9E37_79B9_7F4A_7C15)); |
| 58 | + let hi2 = mix64( |
| 59 | + hi.rotate_left(23) ^ v.wrapping_mul(0xC2B2_AE3D_27D4_EB4F).wrapping_add(0x1656_67B1), |
| 60 | + ); |
| 61 | + ((hi2 as u128) << 64) | (lo2 as u128) |
| 62 | +} |
| 63 | + |
| 64 | +#[inline] |
| 65 | +fn fold_bytes(mut state: u128, bytes: &[u8]) -> u128 { |
| 66 | + state = fold(state, bytes.len() as u64); |
| 67 | + let mut chunks = bytes.chunks_exact(8); |
| 68 | + for c in &mut chunks { |
| 69 | + state = fold(state, u64::from_le_bytes(c.try_into().unwrap())); |
| 70 | + } |
| 71 | + let rem = chunks.remainder(); |
| 72 | + if !rem.is_empty() { |
| 73 | + let mut buf = [0u8; 8]; |
| 74 | + buf[..rem.len()].copy_from_slice(rem); |
| 75 | + state = fold(state, u64::from_le_bytes(buf)); |
| 76 | + } |
| 77 | + state |
| 78 | +} |
| 79 | + |
| 80 | +/// 128-bit structural hash of the representation item rooted at `root_id`. `memo` |
| 81 | +/// caches per-entity hashes so shared sub-entities (a profile reused by many |
| 82 | +/// solids, the representation context) are visited once; it keys on entity ids, |
| 83 | +/// so it must belong to ONE model (the `GeometryRouter` owns one per loaded |
| 84 | +/// file). |
| 85 | +pub fn item_signature(decoder: &mut EntityDecoder, root_id: u32, memo: &mut FxHashMap<u32, u128>) -> u128 { |
| 86 | + sig_entity(decoder, root_id, memo, 0) |
| 87 | +} |
| 88 | + |
| 89 | +/// Combine the pure structural item hash with the router parameters that change |
| 90 | +/// the MESHED output but live outside the IFC structure — tessellation quality |
| 91 | +/// (curved profiles tessellate finer at higher quality), unit scale, and RTC |
| 92 | +/// offset. Without this, a cache shared across routers — or one that outlives a |
| 93 | +/// `setTessellationQuality` change on the same worker — would serve a mesh built |
| 94 | +/// under different parameters (e.g. #976: every quality level returns the |
| 95 | +/// first-cached triangle count). |
| 96 | +pub fn key_with_params(structural: u128, quality_index: u8, unit_scale: f64, rtc: (f64, f64, f64)) -> u128 { |
| 97 | + let mut s = fold(structural, quality_index as u64); |
| 98 | + s = fold(s, unit_scale.to_bits()); |
| 99 | + s = fold(s, rtc.0.to_bits()); |
| 100 | + s = fold(s, rtc.1.to_bits()); |
| 101 | + fold(s, rtc.2.to_bits()) |
| 102 | +} |
| 103 | + |
| 104 | +fn sig_entity(decoder: &mut EntityDecoder, id: u32, memo: &mut FxHashMap<u32, u128>, depth: u32) -> u128 { |
| 105 | + if let Some(&s) = memo.get(&id) { |
| 106 | + return s; |
| 107 | + } |
| 108 | + if depth > MAX_DEPTH { |
| 109 | + // Fold the entity id so two DIFFERENT over-depth subtrees get DIFFERENT |
| 110 | + // values — they stop deduping (id breaks renumbering-invariance) but can |
| 111 | + // never false-merge, which matters far more than deduping a pathological |
| 112 | + // boolean chain. |
| 113 | + return fold(0xDEAD_BEEF_DEAD_BEEF, id as u64); |
| 114 | + } |
| 115 | + memo.insert(id, CYCLE_SENTINEL); // break cycles (DAG ⇒ unreachable in practice) |
| 116 | + let entity = match decoder.decode_by_id(id) { |
| 117 | + Ok(e) => e, |
| 118 | + Err(_) => { |
| 119 | + // Unresolvable reference: a fixed sentinel (NOT the id, so structurally |
| 120 | + // identical-but-renumbered files still collide). |
| 121 | + let s = fold(0, 0x00BA_D0BA_D0BA_D000); |
| 122 | + memo.insert(id, s); |
| 123 | + return s; |
| 124 | + } |
| 125 | + }; |
| 126 | + // Hash the stable type NAME (IfcType isn't a primitive-castable enum). |
| 127 | + let mut acc = fold_bytes(fold(0, 0x5EED_5EED), entity.ifc_type.as_str().as_bytes()); |
| 128 | + for attr in &entity.attributes { |
| 129 | + acc = hash_attr(decoder, attr, acc, memo, depth); |
| 130 | + } |
| 131 | + memo.insert(id, acc); |
| 132 | + acc |
| 133 | +} |
| 134 | + |
| 135 | +fn hash_attr( |
| 136 | + decoder: &mut EntityDecoder, |
| 137 | + attr: &AttributeValue, |
| 138 | + acc: u128, |
| 139 | + memo: &mut FxHashMap<u32, u128>, |
| 140 | + depth: u32, |
| 141 | +) -> u128 { |
| 142 | + match attr { |
| 143 | + AttributeValue::EntityRef(r) => { |
| 144 | + let child = sig_entity(decoder, *r, memo, depth + 1); |
| 145 | + // Fold both lanes of the child hash, tagged. |
| 146 | + fold(fold(fold(acc, 1), child as u64), (child >> 64) as u64) |
| 147 | + } |
| 148 | + AttributeValue::String(s) => fold_bytes(fold(acc, 2), s.as_bytes()), |
| 149 | + AttributeValue::Integer(i) => fold(fold(acc, 3), *i as u64), |
| 150 | + AttributeValue::Float(f) => fold(fold(acc, 4), f.to_bits()), |
| 151 | + AttributeValue::Enum(e) => fold_bytes(fold(acc, 5), e.as_bytes()), |
| 152 | + AttributeValue::List(items) => { |
| 153 | + let mut a = fold(fold(acc, 6), items.len() as u64); |
| 154 | + for it in items { |
| 155 | + a = hash_attr(decoder, it, a, memo, depth); |
| 156 | + } |
| 157 | + a |
| 158 | + } |
| 159 | + AttributeValue::Null => fold(acc, 8), |
| 160 | + AttributeValue::Derived => fold(acc, 9), |
| 161 | + } |
| 162 | +} |
0 commit comments