ifc_lite_export/
model.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Shared **export data model** — one parse pass that yields the entities,
3//! property sets and quantity sets the tabular / semantic exporters (CSV, JSON,
4//! JSON-LD, Parquet) all consume.
5//!
6//! Built directly on `ifc_lite_core`'s `EntityDecoder` / `AttributeValue` model
7//! (the Rust source of truth), so property + quantity extraction lives in Rust
8//! rather than the TS `columnar-parser`. Covers `IfcProduct` occurrences and their
9//! directly-attached `IfcRelDefinesByProperties` property/quantity sets.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use ifc_lite_core::{
15    keyword_eq, DecodedEntity, EntityDecoder, EntityIndex, EntityScanner, IfcType,
16    EXPORTER_STRATUM_ALIASES,
17};
18use ifc_lite_geometry::GeometryRouter;
19use ifc_lite_processing::element::{plan_type_geometry, TypeGeometryMode};
20use ifc_lite_processing::prepass::{resolve_unit_scales, UnitScales};
21use rustc_hash::FxHashSet;
22
23use crate::source_header::declared_schemas;
24
25#[path = "model_options.rs"]
26mod options;
27pub use options::{ModelOptions, Placement};
28
29#[path = "model_props.rs"]
30pub(crate) mod props;
31pub use props::fmt_num;
32use props::{opt_string, ref_list, render_attributes, resolve_pset_defs};
33
34#[path = "model_inherit.rs"]
35mod inherit;
36use inherit::merge_inherited;
37
38#[path = "model_types.rs"]
39mod types;
40pub use types::{EntityRow, ExportModel, PropValue, PropertySet, QuantitySet, QuantityValue};
41
42/// Build the export model from raw IFC/STEP bytes.
43///
44/// Collects every row into an [`ExportModel`]. This is fine for normal models,
45/// but for very large ones (tens of millions of entities) the retained
46/// `Vec<EntityRow>` is itself multiple GB — prefer [`stream_export_model`], which
47/// yields rows one at a time and never holds them all. `build_export_model` is a
48/// thin `collect` over `stream_export_model`, so the two share one code path.
49pub fn build_export_model(content: &[u8]) -> ExportModel {
50    let mut entities = Vec::new();
51    let units = stream_export_model(content, |row| entities.push(row));
52    ExportModel { entities, units }
53}
54
55/// [`build_export_model`] with options. Same memory caveat: this collects.
56pub fn build_export_model_with_options(content: &[u8], opts: &ModelOptions) -> ExportModel {
57    let entity_index = Arc::new(ifc_lite_processing::build_entity_index_parallel(content));
58    let mut entities = Vec::new();
59    let units =
60        stream_export_model_with_options(content, &entity_index, opts, |row, _| entities.push(row));
61    ExportModel { entities, units }
62}
63
64/// Stream one [`EntityRow`] per `IfcProduct` occurrence, in file order, invoking
65/// `f` for each row and then dropping it.
66///
67/// Unlike [`build_export_model`], this never retains all rows and never caches the
68/// non-product entities (cartesian points, directions, index lists, …) that make
69/// up the bulk of a STEP file. Peak working set is bounded by the entity index
70/// plus the property side-map (both O(products)), so a model with tens of millions
71/// of entities extracts in a few GB instead of exhausting memory. Output is the
72/// caller's responsibility to stream onwards (e.g. to S3/Parquet) and drop.
73pub fn stream_export_model(content: &[u8], f: impl FnMut(EntityRow)) -> UnitScales {
74    // Parallel on native (byte-identical to `build_entity_index`), serial on wasm.
75    let entity_index = Arc::new(ifc_lite_processing::build_entity_index_parallel(content));
76    stream_export_model_with_index(content, &entity_index, f)
77}
78
79/// An `IfcTypeProduct` (e.g. `IfcBoilerType`) that carries its own
80/// `RepresentationMaps` — a #957 "Route B" mesh candidate. The geometry pass
81/// meshes the ORPHAN ones (no occurrence instantiates them) under the type's own
82/// expressId; without a matching [`EntityRow`] those GLB nodes render with no
83/// attributes (#1518). Everything needed to emit that row is captured here in the
84/// pass-1 scan so the emission phase never re-decodes the type.
85struct TypeProductCandidate {
86    express_id: u32,
87    ifc_type: IfcType,
88    /// The raw STEP keyword (e.g. `"IFCDOORSTYLE"`), kept ONLY so a later
89    /// `render_attributes` call can look up a legacy entity's own attribute
90    /// names (#4203) — `ifc_type` above is already legacy-aware-resolved
91    /// (`IfcDoorType`, not `Unknown`) and is what every other consumer of
92    /// this candidate wants.
93    type_name: String,
94    global_id: Option<String>,
95    name: Option<String>,
96    description: Option<String>,
97    /// `IfcTypeProduct.RepresentationMaps` (attr 6) — non-empty by construction.
98    rep_map_ids: Vec<u32>,
99    /// `IfcTypeObject.HasPropertySets` (attr 5): the type's directly-attached
100    /// property/quantity set definitions (types do NOT use
101    /// `IfcRelDefinesByProperties`).
102    pset_def_ids: Vec<u32>,
103}
104
105/// Like [`stream_export_model`] but reuses a pre-built entity index. A caller also
106/// running the geometry pass ([`crate::try_export_glb_with_stats_with_index`]) over the
107/// same bytes builds the index once with [`build_entity_index`] and shares it across
108/// both, skipping the duplicate scan. `entity_index` MUST be built from the same
109/// `content`; output is identical to `stream_export_model`.
110/// Returns the model's [`UnitScales`], so a caller consuming attribute values can
111/// interpret them without a second pass over the file: the scales are resolved
112/// from the decoder this function already builds.
113pub fn stream_export_model_with_index(
114    content: &[u8],
115    entity_index: &Arc<EntityIndex>,
116    mut f: impl FnMut(EntityRow),
117) -> UnitScales {
118    stream_export_model_with_options(content, entity_index, &ModelOptions::default(), |row, _| {
119        f(row)
120    })
121}
122
123/// [`stream_export_model_with_index`], plus the decoded entity behind each row
124/// and whatever [`ModelOptions`] asked to resolve.
125///
126/// The second callback argument is the `DecodedEntity` the row was built from,
127/// **borrowed**: a caller that wants an attribute this crate does not surface
128/// reads it here, without this crate having to guess which attributes matter
129/// and without anything being retained. It is `None` for the type-product rows emitted in
130/// pass 3, which are assembled from the pass-1 scan and have no occurrence
131/// entity behind them.
132///
133/// Storing the attributes on [`EntityRow`] instead would be the obvious shape
134/// and is the wrong one twice over: `AttributeValue` is not `PartialEq`, so it
135/// would break the derive that `stream == build` equality rests on, and
136/// [`build_export_model`] collects, so every product's attribute vector would
137/// stay resident — which is the exact bound this function exists to keep.
138pub fn stream_export_model_with_options(
139    content: &[u8],
140    entity_index: &Arc<EntityIndex>,
141    opts: &ModelOptions,
142    mut f: impl FnMut(EntityRow, Option<&DecodedEntity>),
143) -> UnitScales {
144    // Attribute positions are schema-specific. Unlike STEP re-export, where
145    // IFC4 is an established fallback for a missing declaration, attribute
146    // export must fail closed rather than assign IFC4 names to unknown slots.
147    let source_schema = declared_schemas(content).into_iter().find(|schema| {
148        // IfcProject exists in every bundled registry, so this is a schema
149        // recognition probe rather than an entity-specific fallback.
150        ifc_lite_core::attribute_names_for_schema(schema, "IFCPROJECT").is_some()
151    });
152    // Property resolution memoizes the shared `IfcPropertySet`/leaf entities for
153    // speed. Cap that cache so it can't grow without bound across millions of
154    // products; clearing only forces a re-decode of a shared set, never affects
155    // correctness.
156    const PSET_CACHE_CAP: usize = 1 << 18; // 262_144 entries
157
158    let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
159
160    // Pass 1 — one scan that collects, uncached (each entity visited once here):
161    //   • object → attached property/quantity definitions (IfcRelDefinesByProperties),
162    //   • the #957/#1518 type-product orphan-geometry bookkeeping, mirroring the
163    //     geometry pass exactly so both agree on which IfcTypeProducts are meshed.
164    // IfcRelDefinesByProperties: [GlobalId, OwnerHistory, Name, Description,
165    //                             RelatedObjects(4, list), RelatingPropertyDefinition(5, ref)]
166    let mut defs_by_object: HashMap<u32, Vec<u32>> = HashMap::new();
167    // The file's single IFCPROJECT, for the unit resolution below. First wins:
168    // the schema allows exactly one, and a malformed file with two would
169    // otherwise make which units apply depend on scan order.
170    let mut project_id: Option<u32> = None;
171    // #957 "Route B": an IfcTypeProduct that carries its own RepresentationMaps
172    // renders directly under the type's expressId when NO occurrence draws it. The
173    // geometry pass (processor::process_geometry) decides this with three sets —
174    // reproduce them here so a type-product row is emitted for exactly the meshed
175    // set (#1518), reusing the CANONICAL `plan_type_geometry` gate for the decision.
176    let mut referenced_representation_maps: FxHashSet<u32> = FxHashSet::default();
177    let mut instantiated_type_ids: FxHashSet<u32> = FxHashSet::default();
178    let mut type_product_candidates: Vec<TypeProductCandidate> = Vec::new();
179    // Occurrence → its `IfcTypeObject`, from `IfcRelDefinesByType.RelatedObjects`.
180    // Populated only when `opts.inherit_type_properties` asks for it.
181    let mut type_by_object: HashMap<u32, u32> = HashMap::new();
182    // Type id → its resolved sets, so one IfcWallType typing 5000 walls is
183    // decoded once. Bounded by the file's distinct types, which is orders of
184    // magnitude below its occurrences.
185    let mut type_pset_cache: HashMap<u32, (Vec<PropertySet>, Vec<QuantitySet>)> = HashMap::new();
186    {
187        let mut scanner = EntityScanner::new(content);
188        while let Some((id, type_name, start, end)) = scanner.next_entity() {
189            if keyword_eq(type_name, "IFCRELDEFINESBYPROPERTIES") {
190                let rel = match decoder.decode_at_uncached(start, end) {
191                    Ok(e) => e,
192                    Err(_) => continue,
193                };
194                let def_id = match rel.get(5).and_then(|a| a.as_entity_ref()) {
195                    Some(d) => d,
196                    None => continue,
197                };
198                if let Some(objs) = rel.get(4).and_then(|a| a.as_list()) {
199                    for o in objs {
200                        if let Some(oid) = o.as_entity_ref() {
201                            defs_by_object.entry(oid).or_default().push(def_id);
202                        }
203                    }
204                }
205            } else if keyword_eq(type_name, "IFCMAPPEDITEM") {
206                // IfcMappedItem.MappingSource (attr 0) → the RepresentationMap an
207                // occurrence instances; such maps draw through the occurrence, so
208                // they are NOT orphan type geometry.
209                if let Ok(mi) = decoder.decode_at_uncached(start, end) {
210                    if let Some(src) = mi.get(0).and_then(|a| a.as_entity_ref()) {
211                        referenced_representation_maps.insert(src);
212                    }
213                }
214            } else if keyword_eq(type_name, "IFCRELDEFINESBYTYPE") {
215                // IfcRelDefinesByType.RelatingType (attr 5) → a type WITH occurrences;
216                // its geometry is drawn by those occurrences, never as orphan type
217                // geometry (the AC20/ArchiCAD duplicate-boxes guard).
218                //
219                // RelatedObjects (attr 4) is read only when property inheritance
220                // is on: it is the occurrence → type edge that makes a type's
221                // HasPropertySets reachable from the occurrence, and building the
222                // map costs an allocation per typed occurrence that the geometry
223                // bookkeeping above has no use for.
224                if let Ok(rel) = decoder.decode_at_uncached(start, end) {
225                    if let Some(tid) = rel.get(5).and_then(|a| a.as_entity_ref()) {
226                        instantiated_type_ids.insert(tid);
227                        if opts.inherit_type_properties {
228                            if let Some(objs) = rel.get(4).and_then(|a| a.as_list()) {
229                                for o in objs {
230                                    if let Some(oid) = o.as_entity_ref() {
231                                        // FIRST relationship wins if a file
232                                        // types one object twice (a schema
233                                        // violation, but exports do it).
234                                        // `typeIds[0]` is what the TS
235                                        // extractor takes, and scan order
236                                        // here is file order, so the two
237                                        // pick the same type rather than
238                                        // disagreeing per engine.
239                                        type_by_object.entry(oid).or_insert(tid);
240                                    }
241                                }
242                            }
243                        }
244                    }
245                }
246            } else if keyword_eq(type_name, "IFCPROJECT") {
247                project_id = project_id.or(Some(id));
248            } else {
249                // An IfcTypeProduct subtype carrying RepresentationMaps (attr 6).
250                // `type_product_ifc_type` keeps its own cheap suffix pre-filter, so
251                // the non-type majority still pays only that.
252                //
253                // Asked once, in the branch body rather than in its condition: a
254                // condition cannot bind, so testing `.is_some()` there forced a
255                // second identical call to get the value. Shares `type_product_ifc_type` with the
256                // processor's type-geometry gate, so this pass cannot admit a
257                // different set than the one that gets meshed (#1518, #3187).
258                let Some(type_ty) = ifc_lite_core::type_product_ifc_type(type_name) else {
259                    continue;
260                };
261                let t = match decoder.decode_at_uncached(start, end) {
262                    Ok(e) => e,
263                    Err(_) => continue,
264                };
265                let rep_map_ids = ref_list(t.get(6));
266                if rep_map_ids.is_empty() {
267                    continue;
268                }
269                type_product_candidates.push(TypeProductCandidate {
270                    express_id: id,
271                    ifc_type: type_ty,
272                    type_name: type_name.to_string(),
273                    global_id: opt_string(t.get(0)),
274                    name: opt_string(t.get(2)),
275                    description: opt_string(t.get(3)),
276                    rep_map_ids,
277                    pset_def_ids: ref_list(t.get(5)),
278                });
279            }
280        }
281    }
282
283    // Units, resolved from the `IFCPROJECT` pass 1 just walked past. The
284    // resolver falls back to a SIMD substring search for that entity when it is
285    // not told which one it is; handing it the id makes this an O(1) decode
286    // against the index this function already holds, and pass 1 visits every
287    // entity anyway, so recording it costs nothing.
288    //
289    // Resolved here rather than left to the caller because a consumer of the
290    // rows needs it to interpret them — attribute values are in the file's own
291    // units, unlike the geometry exporters' output — and returning it means
292    // they cannot forget to ask.
293    let units = resolve_unit_scales(content, project_id, &mut decoder);
294
295    // Not drained: meshes nothing. Pinned by rust/geometry/tests/issue_3821_auxiliary_routers_mesh_nothing.rs.
296    // Built with the file's scale, NOT `GeometryRouter::new()`: `new` defaults
297    // `unit_scale` to 1.0 and `scale_transform` only scales when it was given
298    // the real one, so the difference is silently-millimetre translations.
299    // Constructed only when asked — it registers its processor table.
300    let router = opts
301        .placements
302        .then(|| GeometryRouter::with_scale(units.length_unit_scale));
303
304    // Pass 2 — emit a row per IfcProduct occurrence, resolving its property/quantity sets.
305    let mut scanner = EntityScanner::new(content);
306    while let Some((id, type_name, start, end)) = scanner.next_entity() {
307        // Filter on the STEP keyword before decoding, skipping non-product geometry primitives. The three exporter-only stratum
308        // spellings are explicit product compatibility aliases: they keep their
309        // owned exact keyword rather than being relabelled as a nearby EXPRESS
310        // entity, while still receiving the row their geometry needs.
311        let ty = ifc_lite_core::ifc_type_from_keyword(type_name);
312        if !ty.is_subtype_of(IfcType::IfcProduct)
313            && !EXPORTER_STRATUM_ALIASES
314                .iter()
315                .any(|alias| keyword_eq(type_name, alias))
316        {
317            continue;
318        }
319        let entity = match decoder.decode_at_uncached(start, end) {
320            Ok(e) => e,
321            Err(_) => continue,
322        };
323        // PascalCase canonical name (IfcWall), not the STEP keyword (IFCWALL).
324        // An owned unknown compatibility alias intentionally retains its exact
325        // upper-case STEP spelling, matching the geometry label.
326        let ifc_type = ty.name().to_string();
327        let global_id = opt_string(entity.get(0));
328        let name = opt_string(entity.get(2));
329        let description = opt_string(entity.get(3));
330        let object_type = opt_string(entity.get(4));
331        let has_geometry = entity.get(6).is_some_and(|a| !a.is_null());
332
333        // `None` is decided from attribute 5, not from the resolver's `Result`:
334        // the resolver answers `Ok(identity)` for an absent placement as well as
335        // for a broken one, so asking it would report every unplaced product as
336        // sitting at the origin.
337        let placement = router.as_ref().and_then(|r| {
338            entity.get(5).filter(|a| !a.is_null())?;
339            r.resolve_scaled_placement(&entity, &mut decoder)
340                .ok()
341                .map(|matrix| Placement { matrix })
342        });
343
344        let def_ids = defs_by_object.get(&id).cloned().unwrap_or_default();
345        let (mut property_sets, mut quantity_sets) = resolve_pset_defs(&mut decoder, &def_ids);
346
347        // Fold in whatever this occurrence inherits from its type. Resolution is
348        // memoized per type id, not per occurrence: a Revit export types
349        // thousands of walls off one IfcWallType, and re-decoding its sets for
350        // each would turn a constant cost into a linear one.
351        if opts.inherit_type_properties {
352            if let Some(&type_id) = type_by_object.get(&id) {
353                let inherited = type_pset_cache.entry(type_id).or_insert_with_key(|&tid| {
354                    // `HasPropertySets` is attr 5 on IfcTypeObject. Resolved
355                    // from the type entity itself rather than the pass-1
356                    // candidate list, because that list holds only types with
357                    // RepresentationMaps and the common inheriting type has
358                    // none.
359                    let def_ids = decoder
360                        .decode_by_id(tid)
361                        .ok()
362                        .map(|t| ref_list(t.get(5)))
363                        .unwrap_or_default();
364                    resolve_pset_defs(&mut decoder, &def_ids)
365                });
366                property_sets = merge_inherited(property_sets, inherited.0.clone());
367                quantity_sets = merge_inherited(quantity_sets, inherited.1.clone());
368            }
369        }
370
371        f(
372            EntityRow {
373                express_id: id,
374                ifc_type,
375                global_id,
376                name,
377                description,
378                object_type,
379                has_geometry,
380                placement,
381                property_sets,
382                quantity_sets,
383                attributes: if opts.attributes {
384                    render_attributes(&entity, type_name, source_schema.as_deref())
385                } else {
386                    Vec::new()
387                },
388            },
389            Some(&entity),
390        );
391
392        // Keep the property-resolution cache bounded across the whole file.
393        // `clear_entity_cache`, not `clear_cache`: the latter also drops the
394        // placement memo, and resolving placements is what makes this trigger
395        // fire in the first place. Dropping the memo here would re-resolve the
396        // site/building/storey chain that every product under it shares — the
397        // output stays correct and the run gets slower the larger the file,
398        // which is the opposite of what the cap is for.
399        if decoder.cache_size() > PSET_CACHE_CAP {
400            decoder.clear_entity_cache();
401        }
402    }
403
404    // Pass 3 — emit a row for each IfcTypeProduct whose ORPHAN RepresentationMaps
405    // the geometry pass meshes (#957 class 1) under the type's own expressId. Ties
406    // the exact same canonical gate (`plan_type_geometry`, SuppressInstanced — the
407    // export/native profile) the processor uses, so the geometry and attribute
408    // passes agree on the meshed set: no more type-product GLB nodes without an
409    // EntityRow (#1518). Emitted after all product rows, matching the geometry
410    // pass appending its type-geometry jobs after the product jobs. On a normal
411    // model (no orphan type geometry) `type_product_candidates` is empty, so this
412    // is a no-op and output is byte-identical to before.
413    for cand in &type_product_candidates {
414        let meshed = !plan_type_geometry(
415            &cand.rep_map_ids,
416            &referenced_representation_maps,
417            instantiated_type_ids.contains(&cand.express_id),
418            TypeGeometryMode::SuppressInstanced,
419        )
420        .is_empty();
421        if !meshed {
422            continue;
423        }
424
425        // Types attach their sets via HasPropertySets (attr 5), not
426        // IfcRelDefinesByProperties.
427        let (property_sets, quantity_sets) = resolve_pset_defs(&mut decoder, &cand.pset_def_ids);
428
429        f(
430            EntityRow {
431                express_id: cand.express_id,
432                // PascalCase canonical name (IfcBoilerType) — equals the node's
433                // `ifcType` extra the geometry pass emits.
434                ifc_type: cand.ifc_type.name().to_string(),
435                global_id: cand.global_id.clone(),
436                name: cand.name.clone(),
437                description: cand.description.clone(),
438                // IfcTypeObject attr 4 is ApplicableOccurrence, not ObjectType.
439                object_type: None,
440                has_geometry: true,
441                placement: None,
442                property_sets,
443                quantity_sets,
444                // Pass 3 has no type handle, so honouring the attributes option
445                // costs one decode per rare orphan-geometry type.
446                attributes: if opts.attributes {
447                    decoder
448                        .decode_by_id(cand.express_id)
449                        .ok()
450                        .map(|t| render_attributes(&t, &cand.type_name, source_schema.as_deref()))
451                        .unwrap_or_default()
452                } else {
453                    Vec::new()
454                },
455            },
456            None,
457        );
458        if decoder.cache_size() > PSET_CACHE_CAP {
459            decoder.clear_entity_cache();
460        }
461    }
462
463    units
464}
465
466#[cfg(test)]
467#[path = "model_tests.rs"]
468mod tests;