ifc_lite_export/merged/
guid.rs

1// SPDX-License-Identifier: MPL-2.0
2//! GlobalId reconciliation for merged export. Ports the GUID machinery of
3//! `merged-exporter.ts`: a deterministic 22-char IfcGloballyUniqueId minter
4//! (byte-identical to `@ifc-lite/parser`'s `deterministicGlobalId`), the
5//! schema-derived rooted-entity check, and the read/replace helpers used to
6//! unify or re-stamp a duplicated GlobalId across federated models.
7
8use std::collections::HashSet;
9
10use crate::rooted_type::is_rooted_type;
11
12/// buildingSMART base64 alphabet (64 chars) used by IfcGloballyUniqueId.
13const GLOBAL_ID_CHARS: &[u8; 64] =
14    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
15
16/// Deterministic 22-char GlobalId from an arbitrary seed. A faithful port of
17/// `@ifc-lite/parser`'s `deterministicGlobalId` (four independent 32-bit rolling
18/// hashes cross-mixed into a 128-bit state, stamped MSB-first as `2 + 21*6` bits)
19/// so a merged file re-stamped natively carries the same ids the JS path would.
20///
21/// The input is iterated as UTF-16 code units to match JavaScript's `charCodeAt`
22/// semantics exactly (seeds are ASCII in practice, but this keeps parity if a
23/// non-ASCII model id ever reaches the mint salt).
24pub fn deterministic_global_id(seed: &str) -> String {
25    let mut h0: u32 = 0x811c_9dc5;
26    let mut h1: u32 = 0x9e37_79b9;
27    let mut h2: u32 = 0x6c07_8965;
28    let mut h3: u32 = 0xb529_7a4d;
29    for unit in seed.encode_utf16() {
30        let c = unit as u32;
31        h0 = (h0 ^ c).wrapping_mul(0x0100_0193);
32        h1 = (h1 ^ c ^ (h1 >> 11)).wrapping_mul(0x85eb_ca6b);
33        h2 = h2.wrapping_add(c).wrapping_add(h2 >> 7).wrapping_mul(0xc2b2_ae35);
34        h3 = (h3 ^ ((c << 3) | (c >> 5)) ^ (h3 >> 13)).wrapping_mul(0x27d4_eb2f);
35    }
36    let mix = |x: u32, y: u32| -> u32 {
37        ((x ^ y).wrapping_add((x >> 7) | (y << 25))).wrapping_mul(0x85eb_ca6b)
38    };
39    let m0 = mix(h0, h2);
40    let m1 = mix(h1, h3);
41    let m2 = mix(h2, m1);
42    let m3 = mix(h3, m0);
43    let words = [m0, m1, m2, m3];
44
45    // Read the 128-bit state as a plain MSB-first bit string.
46    let bit = |idx: usize| -> u32 {
47        let word = words[idx / 32];
48        let shift = 31 - (idx % 32);
49        (word >> shift) & 1
50    };
51    let mut out = String::with_capacity(22);
52    out.push(GLOBAL_ID_CHARS[((bit(0) << 1) | bit(1)) as usize] as char);
53    for i in 0..21 {
54        let mut v = 0u32;
55        for b in 0..6 {
56            v = (v << 1) | bit(2 + i * 6 + b);
57        }
58        out.push(GLOBAL_ID_CHARS[v as usize] as char);
59    }
60    out
61}
62
63/// True for a 22-char IfcGloballyUniqueId (`^[0-9A-Za-z_$]{22}$`).
64pub fn is_global_id(s: &str) -> bool {
65    s.len() == 22
66        && s.bytes()
67            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$')
68}
69
70/// True for IfcRelationship subtypes (objectified relationships): a shared GUID
71/// on two of these does NOT imply the same membership, so they are re-stamped
72/// (never unified/dropped) during reconciliation.
73pub fn is_relationship_type(type_upper: &str) -> bool {
74    type_upper.starts_with("IFCREL")
75}
76
77/// True when `type_upper` is a **rooted** entity (an `IfcRoot` subtype). Delegates
78/// to the crate's shared, schema-driven [`crate::rooted_type::is_rooted_type`],
79/// which decides rootedness from the EXPRESS inheritance graph (plus the legacy
80/// IFC2X3/IFC4 rooted set for types the IFC4X3-only generated schema dropped)
81/// rather than a hand-maintained denylist — so a non-rooted resource that happens
82/// to lead with a 22-char Name/Identifier string (`IfcColourRgb`,
83/// `IfcMaterialLayer`, `IfcRegularTimeSeries`, an `IfcSimpleProperty`) is never
84/// misread as carrying a GlobalId. A single source of truth shared with the JS
85/// exporter's `isRootedType` and cross-language parity-gated (#3015/#3124), so the
86/// two sides cannot drift.
87pub fn is_rooted_entity_type(type_upper: &str) -> bool {
88    is_rooted_type(type_upper)
89}
90
91/// The leading 22-char GlobalId of a rooted entity's raw STEP line, or `None` if
92/// the type is not rooted or the first attribute is not a GlobalId string.
93/// Only inspects the first 128 bytes (a GlobalId is always the first attribute).
94pub fn extract_global_id_fast(type_upper: &str, line: &[u8]) -> Option<String> {
95    if !is_rooted_entity_type(type_upper) {
96        return None;
97    }
98    let window = &line[..line.len().min(128)];
99    let open = window.iter().position(|&b| b == b'(')?;
100    let mut i = open + 1;
101    while i < window.len() && window[i].is_ascii_whitespace() {
102        i += 1;
103    }
104    if i >= window.len() || window[i] != b'\'' {
105        return None;
106    }
107    let start = i + 1;
108    let close = window[start..].iter().position(|&b| b == b'\'')? + start;
109    let inner = std::str::from_utf8(&window[start..close]).ok()?;
110    is_global_id(inner).then(|| inner.to_string())
111}
112
113/// The uppercase entity type token of a `#id=TYPE(...)` line, tolerating
114/// whitespace after `=` (some exporters write `#1= IFCPROJECT(`).
115pub fn entity_type_upper(line: &[u8]) -> Option<String> {
116    let eq = line.iter().position(|&b| b == b'=')?;
117    let mut i = eq + 1;
118    while i < line.len() && line[i].is_ascii_whitespace() {
119        i += 1;
120    }
121    let start = i;
122    while i < line.len() && line[i] != b'(' && !line[i].is_ascii_whitespace() {
123        i += 1;
124    }
125    if i == start {
126        return None;
127    }
128    std::str::from_utf8(&line[start..i]).ok().map(|s| s.to_ascii_uppercase())
129}
130
131/// The leading GlobalId of a raw STEP entity line, classified by TYPE (not just
132/// the 22-char shape): reads the type from the line and applies the rooted-entity
133/// rule, so a non-rooted entity leading with a 22-char charset Name (e.g. an
134/// `IfcColourRgb`) is not counted as a rooted GlobalId. For callers that only
135/// have raw text — the merge harness and duplicate-GlobalId diagnostics — and
136/// want the same classification the merge itself uses.
137pub fn leading_rooted_global_id(line: &[u8]) -> Option<String> {
138    let type_upper = entity_type_upper(line)?;
139    extract_global_id_fast(&type_upper, line)
140}
141
142/// Read the GlobalId of an already-rendered STEP line (first `'…'` after the
143/// first `(`). Used to register the *actually emitted* GUID after remap /
144/// re-stamp / schema conversion. Returns `None` when the first string is not a
145/// GlobalId (safe — the caller then falls back to the known local GUID).
146pub fn read_leading_guid(text: &str) -> Option<String> {
147    let b = text.as_bytes();
148    let open = b.iter().position(|&c| c == b'(')?;
149    let q1 = b[open..].iter().position(|&c| c == b'\'')? + open;
150    let q2 = b[q1 + 1..].iter().position(|&c| c == b'\'')? + q1 + 1;
151    let inner = &text[q1 + 1..q2];
152    is_global_id(inner).then(|| inner.to_string())
153}
154
155/// Rewrite the leading 22-char GlobalId field of a STEP line with `new_guid`.
156/// Safe because the GUID charset excludes the apostrophe, so the first `'…'`
157/// after `(` is exactly the GlobalId slot. Returns the input unchanged if no
158/// quoted first attribute is found.
159pub fn replace_global_id(text: &str, new_guid: &str) -> String {
160    let b = text.as_bytes();
161    let Some(open) = b.iter().position(|&c| c == b'(') else {
162        return text.to_string();
163    };
164    let Some(q1rel) = b[open..].iter().position(|&c| c == b'\'') else {
165        return text.to_string();
166    };
167    let q1 = open + q1rel;
168    let Some(q2rel) = b[q1 + 1..].iter().position(|&c| c == b'\'') else {
169        return text.to_string();
170    };
171    let q2 = q1 + 1 + q2rel;
172    let mut out = String::with_capacity(text.len() + new_guid.len());
173    out.push_str(&text[..q1 + 1]);
174    out.push_str(new_guid);
175    out.push_str(&text[q2..]);
176    out
177}
178
179/// Mints deterministic replacement GlobalIds that avoid collisions with both the
180/// GUIDs already emitted into the merge and the ones minted so far. Seeded from
181/// the source GUID + stable model id so the result is reproducible and does not
182/// churn when an unrelated earlier model changes size.
183#[derive(Default)]
184pub struct GuidMinter {
185    pending: HashSet<String>,
186}
187
188impl GuidMinter {
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// A fresh GlobalId for `original` (seeded by `model_id`), guaranteed not to
194    /// collide with anything in `emitted`, in `also` (e.g. the current model's own
195    /// unchanged GlobalIds, which are not yet in `emitted`), or previously minted here.
196    pub fn mint(
197        &mut self,
198        original: &str,
199        model_id: &str,
200        emitted: &HashSet<String>,
201        also: &HashSet<String>,
202    ) -> String {
203        let mut candidate = deterministic_global_id(&format!("{original}#{model_id}"));
204        let mut n: u32 = 0;
205        while emitted.contains(&candidate)
206            || also.contains(&candidate)
207            || self.pending.contains(&candidate)
208        {
209            candidate = deterministic_global_id(&format!("{original}#{model_id}#{n}"));
210            n = n.wrapping_add(1);
211        }
212        self.pending.insert(candidate.clone());
213        candidate
214    }
215}
216
217#[cfg(test)]
218#[path = "guid_tests.rs"]
219mod guid_tests;