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;