ifc_lite_export/
ifc5.rs

1// SPDX-License-Identifier: MPL-2.0
2//! **IFC5 / IFCX** exporter — the USD-style node graph (`path` / `children` /
3//! `attributes`) used by ifcx.dev. Ports the structural half of
4//! `packages/export/src/ifc5-exporter.ts`: spatial hierarchy + `bsi::ifc::class` +
5//! Name/Description + known IFC5 properties (`bsi::ifc::prop::*`). USD geometry
6//! (`usd::usdgeom::mesh`) is the geometry follow-on, mirroring how glTF stays separate.
7
8use std::collections::{HashMap, HashSet};
9
10use ifc_lite_core::{DecodedEntity, EntityDecoder, EntityScanner};
11use serde_json::{json, Map, Value};
12
13use crate::json::typed_value;
14use crate::model::{build_export_model, EntityRow};
15
16/// IFC5 schema-package import URIs (ifcx.dev v5a).
17/// The value every IFCX writer in this repo puts in `header.ifcxVersion`.
18///
19/// The TypeScript side owns the same constant (`IFCX_VERSION`, exported from
20/// `@ifc-lite/data` and re-exported by `@ifc-lite/ifcx`). The two are pinned
21/// together by the exportIfcx assertion in `scripts/test-wasm-contract.mjs`,
22/// which reads the header back out of a file this exporter produced — readers
23/// only match the substring `ifcx`, so nothing else would notice a drift.
24const IFCX_VERSION: &str = "ifcx_alpha";
25
26const IMPORT_CORE: &str = "https://ifcx.dev/@standards.buildingsmart.org/ifc/core/ifc@v5a.ifcx";
27const IMPORT_PROP: &str = "https://ifcx.dev/@standards.buildingsmart.org/ifc/core/prop@v5a.ifcx";
28
29/// Property names with official IFC5 schema definitions (prop@v5a.ifcx). IFC4 props
30/// outside this set are dropped — the viewer flags "Missing schema" otherwise.
31const KNOWN_PROPS: &[&str] = &[
32    "UsageType", "TypeName", "IsExternal", "RefElevation", "ElevationOfRefHeight",
33    "ElevationOfTerrain", "NumberOfStoreys", "Height", "Width", "Length", "Depth",
34    "Volume", "NetVolume", "NetArea", "NetSideArea", "CrossSectionArea", "Station",
35];
36
37/// Options for IFC5/IFCX export.
38pub struct Ifc5Options {
39    pub author: String,
40    pub data_version: String,
41    /// Keep only properties with a known IFC5 schema (default true).
42    pub only_known_properties: bool,
43    pub pretty: bool,
44}
45
46impl Default for Ifc5Options {
47    fn default() -> Self {
48        Self {
49            author: "ifc-lite".to_string(),
50            data_version: "1.0.0".to_string(),
51            only_known_properties: true,
52            pretty: false,
53        }
54    }
55}
56
57/// Deterministic UUID-shaped path for an express id (no RNG/clock — wasm-safe).
58/// Fallback only — used when the entity has no `GlobalId` to carry forward
59/// (e.g. the `IfcProject` root, decoded separately from the product rows
60/// `path_for_id` below reads from).
61fn uuid_from_id(id: u32) -> String {
62    let a = (id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xABCD_EF01_2345_6789;
63    let b = (id as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ 0x0F1E_2D3C_4B5A_6978;
64    let s = format!("{a:016x}{b:016x}");
65    format!("{}-{}-{}-{}-{}", &s[0..8], &s[8..12], &s[12..16], &s[16..20], &s[20..32])
66}
67
68/// IFCX node path for an express id: the entity's own `GlobalId` when it has
69/// one, so a BCF topic / diff / external reference keyed on the source
70/// model's GlobalId still resolves to the same element after IFC→IFCX
71/// conversion; the deterministic fallback otherwise. Mirrors the TS
72/// exporter's `buildEntityMaps` (`packages/export/src/ifc5-exporter.ts`),
73/// which already prefers GlobalId — the Rust port had dropped that and
74/// unconditionally minted a fresh identity for every product, discarding the
75/// original on every `ifc-lite export --format ifcx`.
76fn path_for_id(id: u32, by_id: &HashMap<u32, &EntityRow>) -> String {
77    if let Some(row) = by_id.get(&id) {
78        if let Some(g) = &row.global_id {
79            if !g.is_empty() {
80                return g.clone();
81            }
82        }
83    }
84    uuid_from_id(id)
85}
86
87/// Sanitize a USD prim name (the keys in a node's `children` dict).
88fn prim_name(name: &str, fallback_type: &str, id: u32) -> String {
89    let base = if name.trim().is_empty() { fallback_type } else { name };
90    let mut out: String = base
91        .chars()
92        .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
93        .collect();
94    if out.is_empty() || !out.chars().next().unwrap().is_ascii_alphabetic() {
95        out = format!("p_{out}");
96    }
97    // Append the id so siblings with identical names stay unique.
98    format!("{out}_{id}")
99}
100
101/// Spatial parent→children edges plus the first `IfcProject`.
102///
103/// A shim over [`crate::relationships`], which resolves these in the same pass
104/// as the type edges. Kept because the three in-crate callers want only this
105/// pair. Shared with the CSV and USD spatial exports.
106pub(crate) fn spatial_children(content: &[u8]) -> (HashMap<u32, Vec<u32>>, Option<u32>) {
107    let r = crate::relationships::relationships(content);
108    (r.spatial_children, r.project)
109}
110
111/// Decode exactly one entity, found by scanning for its id.
112///
113/// The alternative is `build_entity_index` plus `decode_by_id`, which builds a
114/// map of every entity in the file to answer one question: a full serial scan
115/// and a multi-million-entry allocation on a large model. This walks until it
116/// finds the id and stops, so a project node near the top of `DATA` costs almost
117/// nothing. Worth it only for a handful of lookups, which is what the callers do.
118pub(crate) fn decode_one(content: &[u8], id: u32) -> Option<DecodedEntity> {
119    let mut decoder = EntityDecoder::new(content);
120    let mut scanner = EntityScanner::new(content);
121    while let Some((entity_id, _type_name, start, end)) = scanner.next_entity() {
122        if entity_id == id {
123            return decoder.decode_at(start, end).ok();
124        }
125    }
126    None
127}
128
129/// Decode the IfcProject node (id, name) — it is not an IfcProduct so the export
130/// model doesn't carry it. Shared with the USD exporter (`crate::usd`).
131pub(crate) fn project_name(content: &[u8], project_id: u32) -> String {
132    decode_one(content, project_id)
133        .and_then(|e| e.get(2).and_then(|a| a.as_string()).map(|s| s.to_string()))
134        .filter(|s| !s.is_empty())
135        .unwrap_or_else(|| "Project".to_string())
136}
137
138fn build_node_attributes(row: Option<&EntityRow>, ifc_type: &str, opts: &Ifc5Options) -> Map<String, Value> {
139    let mut attrs = Map::new();
140    attrs.insert("bsi::ifc::class".into(), json!({ "code": ifc_type }));
141    if let Some(r) = row {
142        if let Some(n) = &r.name {
143            attrs.insert("bsi::ifc::prop::Name".into(), json!(n));
144        }
145        if let Some(d) = &r.description {
146            attrs.insert("bsi::ifc::prop::Description".into(), json!(d));
147        }
148        for ps in &r.property_sets {
149            for p in &ps.properties {
150                if opts.only_known_properties && !KNOWN_PROPS.contains(&p.name.as_str()) {
151                    continue;
152                }
153                attrs.insert(format!("bsi::ifc::prop::{}", p.name), typed_value(p));
154            }
155        }
156    }
157    attrs
158}
159
160/// Export the model in `content` as an IFCX (IFC5) document string.
161pub fn export_ifc5(content: &[u8], opts: &Ifc5Options) -> String {
162    let model = build_export_model(content);
163    let by_id: HashMap<u32, &EntityRow> = model.entities.iter().map(|e| (e.express_id, e)).collect();
164    let (children, project) = spatial_children(content);
165
166    // Names/types for prim-name + class. Products come from the model; the project
167    // is decoded separately.
168    let mut name_of: HashMap<u32, (String, String)> = HashMap::new(); // id -> (name, type)
169    for e in &model.entities {
170        name_of.insert(e.express_id, (e.name.clone().unwrap_or_default(), e.ifc_type.clone()));
171    }
172
173    // Determine which nodes to emit: the project + everything reachable through
174    // the spatial children edges (so orphan rels/types don't leak in).
175    let mut emit: Vec<u32> = Vec::new();
176    let mut seen: HashSet<u32> = HashSet::new();
177    if let Some(pid) = project {
178        name_of
179            .entry(pid)
180            .or_insert_with(|| (project_name(content, pid), "IfcProject".to_string()));
181        let mut stack = vec![pid];
182        while let Some(id) = stack.pop() {
183            if !seen.insert(id) {
184                continue;
185            }
186            emit.push(id);
187            if let Some(ch) = children.get(&id) {
188                for &c in ch {
189                    if !seen.contains(&c) {
190                        stack.push(c);
191                    }
192                }
193            }
194        }
195    } else {
196        // No project — emit every product as a flat list.
197        for e in &model.entities {
198            emit.push(e.express_id);
199        }
200    }
201
202    let mut data: Vec<Value> = Vec::with_capacity(emit.len());
203    for id in &emit {
204        let (_name, ifc_type) = name_of
205            .get(id)
206            .cloned()
207            .unwrap_or_else(|| (String::new(), "IfcProduct".to_string()));
208        let mut node = Map::new();
209        node.insert("path".into(), json!(path_for_id(*id, &by_id)));
210
211        if let Some(ch) = children.get(id) {
212            let mut child_map = Map::new();
213            for &c in ch {
214                if !seen.contains(&c) {
215                    continue;
216                }
217                let (cname, ctype) = name_of.get(&c).cloned().unwrap_or_default();
218                child_map.insert(prim_name(&cname, &ctype, c), json!(path_for_id(c, &by_id)));
219            }
220            if !child_map.is_empty() {
221                node.insert("children".into(), Value::Object(child_map));
222            }
223        }
224
225        let attrs = build_node_attributes(by_id.get(id).copied(), &ifc_type, opts);
226        if !attrs.is_empty() {
227            node.insert("attributes".into(), Value::Object(attrs));
228        }
229        data.push(Value::Object(node));
230    }
231
232    let doc = json!({
233        "header": {
234            // `ifcxVersion`, not `version`. That is the key buildingSMART's own
235            // reference files carry, and the one `@ifc-lite/ifcx` requires to
236            // recognise a file at all — so under the old name every file this
237            // exporter produced was rejected by our own parser with
238            // "Invalid IFCX file: missing or invalid header.ifcxVersion".
239            "ifcxVersion": IFCX_VERSION,
240            "author": opts.author,
241            "dataVersion": opts.data_version,
242        },
243        "imports": [ { "uri": IMPORT_CORE }, { "uri": IMPORT_PROP } ],
244        "schemas": {},
245        "data": data,
246    });
247
248    if opts.pretty {
249        serde_json::to_string_pretty(&doc).expect("ifcx serializes")
250    } else {
251        serde_json::to_string(&doc).expect("ifcx serializes")
252    }
253}
254
255#[cfg(test)]
256#[path = "ifc5_tests.rs"]
257mod tests;