ifc_lite_export/merged/mod.rs
1// SPDX-License-Identifier: MPL-2.0
2//! **Merged** multi-model STEP exporter. A native (Rust) port of
3//! `packages/export/src/merged-exporter.ts`: combine several IFC files into one
4//! by offsetting each later model's express ids and rewriting every
5//! `#`-reference, while unifying the shared spatial/infrastructure tree so the
6//! result is one coherent model rather than N stacked copies.
7//!
8//! Feature parity with the JS `MergedExporter` (issue #2951):
9//! - **Project / infrastructure unification** — later models' `IfcProject` and
10//! the first `IfcUnitAssignment` / representation contexts are dropped and
11//! their references redirected to the first model's (compatible models only).
12//! - **Spatial unification** — `IfcSite` / `IfcBuilding` / `IfcBuildingStorey`
13//! are matched onto the first model's by name / elevation ([`spatial`]).
14//! - **GlobalId reconciliation** — a rooted entity that duplicates a prior
15//! GlobalId in the same unit space is unified (refs remapped to the first
16//! instance); otherwise it is re-stamped with a deterministic fresh GlobalId
17//! ([`guid`]), so a federated file never carries duplicate GlobalIds.
18//! - **Visibility filtering** — a per-model `included` allowlist is honored via
19//! the forward-reference closure ([`plan::resolve_included`]).
20//!
21//! Genuine cross-unit rescaling (`unitReconciliation: 'normalize'`) is deferred:
22//! a model whose length unit is incompatible with the first is **federated**
23//! (kept as its own project, never mis-scaled) and [`MergedStats::unit_rescale_required`]
24//! is set so the caller can gate that case to the JS path.
25
26mod empty;
27mod guid;
28mod header;
29mod line_edit;
30mod plan;
31mod single_parents;
32mod spatial;
33mod units;
34mod warnings;
35
36use std::collections::{HashMap, HashSet};
37
38use crate::schema_detect::detect_schema;
39
40use guid::{read_leading_guid, replace_global_id, GuidMinter};
41pub use guid::{deterministic_global_id, leading_rooted_global_id};
42use line_edit::{rewrite_refs, LineDecision};
43use plan::{build_plan, model_salt, ModelIndex, PlanCtx};
44use crate::schema_ifc2x3_slots::Ifc2x3SlotFill;
45use crate::schema_enum::ConversionChecks;
46use units::resolve_model_modes;
47pub use units::UnitReconciliation;
48
49pub use spatial::{ContainerMergeStrategy, StoreyMergeStrategy};
50
51/// A single input model for [`export_merged_models`].
52pub struct MergedModel<'a> {
53 /// Raw IFC/STEP bytes.
54 pub content: &'a [u8],
55 /// Stable identifier, used to salt re-stamped GlobalIds so they are
56 /// reproducible and do not churn when an unrelated model changes size. When
57 /// empty, the model's index is used.
58 pub id: String,
59 /// Express ids to include (visibility). `None` ⇒ the whole model; when set,
60 /// the forward-reference closure is added so every emitted `#ref` resolves.
61 pub included: Option<Vec<u32>>,
62}
63
64impl<'a> MergedModel<'a> {
65 /// A model that exports in full with a default (empty) id.
66 pub fn new(content: &'a [u8]) -> Self {
67 Self { content, id: String::new(), included: None }
68 }
69}
70
71/// Options for merged export.
72pub struct MergedOptions {
73 /// FILE_SCHEMA label to write. `None` ⇒ the first model's schema; each model
74 /// whose source schema differs is converted to it.
75 pub schema: Option<String>,
76 pub description: String,
77 pub application: String,
78 /// Cross-unit reconciliation policy (see [`UnitReconciliation`]).
79 pub unit_reconciliation: UnitReconciliation,
80 /// `IfcSite` matching strategy.
81 pub merge_sites: ContainerMergeStrategy,
82 /// `IfcBuilding` matching strategy.
83 pub merge_buildings: ContainerMergeStrategy,
84 /// `IfcBuildingStorey` matching strategy.
85 pub merge_storeys: StoreyMergeStrategy,
86 /// Drop spatial containers (`IfcSite` / `IfcBuilding` / `IfcBuildingStorey` /
87 /// `IfcSpace`) that the merge leaves holding nothing — the "Merge Projects"
88 /// recipe step that container matching alone does not cover (#3643).
89 /// Emptiness is judged on the MERGED model, after visibility filtering and
90 /// spatial unification, and an emptied container is simply never planned, so
91 /// nothing is written referencing it. Off by default: output is byte-identical
92 /// to a merge that never asked for it.
93 pub drop_empty_containers: bool,
94}
95
96impl Default for MergedOptions {
97 fn default() -> Self {
98 Self {
99 schema: None,
100 description: "ViewDefinition [CoordinationView]".to_string(),
101 application: "ifc-lite".to_string(),
102 unit_reconciliation: UnitReconciliation::default(),
103 merge_sites: ContainerMergeStrategy::default(),
104 merge_buildings: ContainerMergeStrategy::default(),
105 merge_storeys: StoreyMergeStrategy::default(),
106 drop_empty_containers: false,
107 }
108 }
109}
110
111/// Coverage stats for a merged export.
112pub struct MergedStats {
113 /// Number of input models.
114 pub models: usize,
115 /// Entity lines written.
116 pub written: usize,
117 /// Models federated as their own `IfcProject` (incompatible units under
118 /// `Auto` / `Normalize`).
119 pub federated_model_count: usize,
120 /// Models left unmerged because placing them would overflow the u32 EXPRESS-id
121 /// space. Non-zero means the merge stopped early to avoid wrapping ids; the
122 /// caller should route the whole set to a wider-id path if this is hit.
123 pub unmerged_model_count: usize,
124 /// True when a `Normalize` export encountered an incompatible-unit model
125 /// that was federated rather than rescaled — the caller should route that
126 /// case to the JS path if true single-project normalization is required.
127 pub unit_rescale_required: bool,
128 /// Spatial containers dropped for holding nothing, counted in the merged
129 /// model (a container unified across three inputs counts once). Always 0
130 /// unless [`MergedOptions::drop_empty_containers`] is set.
131 pub dropped_container_count: usize,
132 /// Human-readable notes (e.g. federation relaxing `IfcSingleProjectInstance`).
133 pub warnings: Vec<String>,
134}
135
136/// Merge `models` (raw IFC byte slices) into one STEP/IFC string. Convenience
137/// wrapper over [`export_merged_models`] that exports each model in full.
138pub fn export_merged(models: &[&[u8]], opts: &MergedOptions) -> String {
139 export_merged_with_stats(models, opts).0
140}
141
142/// Like [`export_merged`] but also returns coverage stats.
143pub fn export_merged_with_stats(models: &[&[u8]], opts: &MergedOptions) -> (String, MergedStats) {
144 let inputs: Vec<MergedModel> = models
145 .iter()
146 .enumerate()
147 .map(|(i, &content)| MergedModel { content, id: i.to_string(), included: None })
148 .collect();
149 export_merged_models(&inputs, opts)
150}
151
152/// Merge several parsed models into one STEP/IFC string, honoring per-model
153/// visibility, spatial-merge strategies, and unit reconciliation.
154pub fn export_merged_models(models: &[MergedModel], opts: &MergedOptions) -> (String, MergedStats) {
155 let schema = header::output_schema(models, opts);
156
157 let mut out = header::merged_header(opts, &schema);
158
159 let mut stats = MergedStats {
160 models: models.len(),
161 written: 0,
162 federated_model_count: 0,
163 unmerged_model_count: 0,
164 unit_rescale_required: false,
165 dropped_container_count: 0,
166 warnings: Vec::new(),
167 };
168
169 if models.is_empty() {
170 out.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
171 return (out, stats);
172 }
173
174 // First-model facts every later model unifies against. Restrict them to the
175 // first model's VISIBLE set: a redirect target that visibility excludes from
176 // emission would leave later models dangling a `#ref` to a line never written
177 // (Greptile P1 / CR). An excluded canonical simply isn't a target — later
178 // models then keep their own project/infra/containers (less dedup, still valid).
179 // Total `#<digits>` references above `u32::MAX` refused (issue #3421)
180 // while resolving every model's reachable set below. Surfaced in
181 // `stats.warnings` once the loop finishes — see `resolve_included`'s doc
182 // for why this is a diagnostic about the source file, not a completeness
183 // gap in the merge (#3752).
184 let mut refused_refs_total = 0usize;
185 // #5116: a non-rooted type with no representation in the target schema
186 // (e.g. `IfcCartesianPointList3D` downgraded to IFC2X3) cannot become an
187 // `IFCPROXY` either, so `convert_step_line` errs instead of guessing.
188 // `export_merged_models` has no fallible signature to propagate that
189 // through today, and this is common enough real-world input (any IFC4
190 // model with tessellated geometry) that panicking here would abort a
191 // merge that the SINGLE-model path only ever silently mis-converted —
192 // a regression, not an improvement. Counted here and kept as the prior
193 // (pass-through-unchanged) behavior instead; see the fallback below.
194 let mut unrepresented_types_kept: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
195 // `None`: the emit loop below re-resolves model 0 at i == 0 and is the
196 // scan that gets reported (CodeRabbit, PR #3766).
197 let first = ModelIndex::build(models[0].content);
198 let first_included = plan::resolve_included(&first, &models[0].included, None);
199 let canonical_project = first.projects.iter().copied().find(|id| first_included.contains(id));
200 let first_infra: HashMap<&'static str, u32> = first
201 .first_infra
202 .iter()
203 .filter(|(_, id)| first_included.contains(id))
204 .map(|(&ty, &id)| (ty, id))
205 .collect();
206 let first_order: Vec<u32> =
207 first.order.iter().copied().filter(|id| first_included.contains(id)).collect();
208 let spatial_lookup = spatial::SpatialLookup::build(
209 &first_order,
210 &|id| first.line_str(id),
211 &|id| first.type_of.get(&id).cloned(),
212 );
213 let primary_scale = units::primary_scale(models);
214 drop(first);
215
216 // Unit verdicts for every model, resolved once: the empty-container pre-pass
217 // has to know which models unify before anything is written, and the emit
218 // loop below must reach the same verdict — so both read this one answer.
219 let modes = resolve_model_modes(models, opts.unit_reconciliation, primary_scale);
220 let drop_plan = empty::plan_drops(models, opts, &spatial_lookup, &modes);
221 stats.dropped_container_count = drop_plan.as_ref().map_or(0, |p| p.count);
222
223 // Running cross-model state.
224 let mut guid_to_final: HashMap<String, (u32, f64)> = HashMap::new();
225 let mut emitted_guids: HashSet<String> = HashSet::new();
226 let mut minter = GuidMinter::new();
227 let mut offset: u32 = 0;
228 let mut slot_fill = Ifc2x3SlotFill::new(None);
229 let mut checks = ConversionChecks::new(); // IFC4-required `$` slots (#5307), enums (#5365)
230 let mut parents = single_parents::ParentClaims::for_schema(&schema); // one rel per single-valued inverse (#5727, #5802, #5774, #5923)
231
232 for (i, model) in models.iter().enumerate() {
233 let is_first = i == 0;
234 let index = ModelIndex::build(model.content);
235 let included = plan::resolve_included(&index, &model.included, Some(&mut refused_refs_total));
236 // Placing this model would push the merged EXPRESS-id space past u32::MAX,
237 // so stop here instead: the file emitted so far is valid, and the unmerged
238 // tail is reported for the caller to gate. `plan::next_offset` is the single
239 // home for that bound — the drop pre-pass stops at the same model.
240 let Some(next) = plan::next_offset(offset, &included) else {
241 stats.unmerged_model_count = models.len() - i;
242 stats.warnings.push(format!(
243 "merged EXPRESS id space would exceed u32::MAX; stopped after {i} model(s), {} model(s) not merged.",
244 models.len() - i
245 ));
246 break;
247 };
248
249 // Unit mode. Counted here, not where the verdicts are resolved, so an
250 // id-space break above still reports only the models actually merged.
251 let mode = &modes[i];
252 let (compatible, effective_scale) = (mode.compatible, mode.scale);
253 if mode.federated {
254 stats.federated_model_count += 1;
255 }
256 if mode.rescale_required {
257 stats.unit_rescale_required = true;
258 }
259
260 // Empty spatial containers this model must not write (#3643). The plan
261 // covers only the models that will actually be emitted, so a model past
262 // the id-space cut has no entry — `get` rather than index.
263 let dropped_containers =
264 drop_plan.as_ref().and_then(|p| p.per_model.get(i)).filter(|d| !d.is_empty());
265
266 let salt = model_salt(model, i);
267 let plan = build_plan(&index, is_first, compatible, PlanCtx {
268 canonical_project,
269 first_infra: &first_infra,
270 spatial_lookup: &spatial_lookup,
271 merge_sites: opts.merge_sites,
272 merge_buildings: opts.merge_buildings,
273 merge_storeys: opts.merge_storeys,
274 primary_scale,
275 guid_to_final: &guid_to_final,
276 emitted_guids: &emitted_guids,
277 minter: &mut minter,
278 salt: salt.clone(),
279 });
280
281 // IFC2X3 `$` OwnerHistory: this model's first written one, else an earlier model's (#4686).
282 if crate::schema_convert::targets_ifc2x3(&schema) {
283 slot_fill.prefer(index.order.iter().copied().find(|&id| {
284 index.type_of.get(&id).is_some_and(|t| t == "IFCOWNERHISTORY")
285 && included.contains(&id)
286 && !plan.skip.contains(&id)
287 }).map(|id| id.saturating_add(offset)));
288 }
289
290 // Emit.
291 let source_schema = detect_schema(model.content);
292 let converting = crate::schema_convert::needs_conversion(&source_schema, &schema);
293 for &id in &index.order {
294 if !included.contains(&id) || plan.skip.contains(&id) {
295 continue;
296 }
297 let Some(bytes) = index.line_bytes(id) else { continue };
298 // A dropped container is never written, and any line naming one is
299 // rewritten without it (or dropped with it), so the merge emits no
300 // reference to a line it did not write — no clean-up pass needed.
301 let mut pruned: Option<String> = None;
302 if let Some(dropped) = dropped_containers {
303 if dropped.contains(&id) {
304 continue;
305 }
306 match line_edit::decide_line(&String::from_utf8_lossy(bytes), dropped) {
307 LineDecision::Skip => continue,
308 LineDecision::Rewrite(text) => pruned = Some(text),
309 LineDecision::Keep => {}
310 }
311 }
312 let bytes: &[u8] = pruned.as_deref().map_or(bytes, str::as_bytes);
313 let remapped = if offset == 0 && plan.shared_remap.is_empty() {
314 String::from_utf8_lossy(bytes).into_owned()
315 } else {
316 rewrite_refs(bytes, offset, &|n| plan.shared_remap.get(&n).copied())
317 };
318 let after_guid = match plan.guid_rewrite.get(&id) {
319 Some(g) => replace_global_id(&remapped, g),
320 None => remapped,
321 };
322 let final_text = if converting {
323 // Pass the GLOBAL id (offset applied): a schema downgrade with no
324 // target type falls back to IFCPROXY with a `placeholder_guid(id)`
325 // GlobalId, so two models sharing a source-local id must not seed the
326 // same placeholder (Greptile P1). rewrite_refs already offset the
327 // line's own `#id`, so this keeps the proxy guid consistent with it.
328 //
329 // Falls back to the UNCONVERTED line on `Err`, not `?`/panic:
330 // unlike the single-model `step.rs` path (which already threads a
331 // `Result` out through `std::io::Result`), `export_merged`/
332 // `export_merged_models` have no fallible signature at all, and
333 // giving them one is a separate, deliberate API change this fix
334 // does not make -- the same scope choice the TypeScript twin's
335 // `merged-exporter.ts` documents for its own withholding
336 // optimization (#5114). Unlike a rare edge case, a non-rooted type
337 // with no target representation is COMMON real-world input (any
338 // IFC4 model with tessellated geometry -- IfcCartesianPointList3D,
339 // IfcTriangulatedFaceSet, IfcIndexedPolyCurve -- has one), so
340 // panicking here would abort a merge the single-model path only
341 // ever silently mis-converted before #5116: a regression, not an
342 // improvement, and unrecoverable through the wasm ABI (no
343 // unwinding). Keeping the prior pass-through behavior and
344 // reporting it in `stats.warnings` is a real fix for every OTHER
345 // type (rooted-proxy and renamed types both now convert
346 // correctly here too) without a new abort path for the one this
347 // signature genuinely cannot express yet.
348 match crate::schema_convert::convert_step_line(
349 &after_guid,
350 &source_schema,
351 &schema,
352 id.saturating_add(offset),
353 &mut slot_fill,
354 Some(&mut checks),
355 ) {
356 Ok(text) => text,
357 Err(e) => {
358 unrepresented_types_kept.insert(e.entity_type);
359 after_guid.clone()
360 }
361 }
362 } else {
363 after_guid
364 };
365
366 let ty = index.type_of.get(&id).map_or("", String::as_str);
367 let Some(mut final_text) = parents.claim(ty, final_text, !is_first && compatible) else { continue };
368
369 if let Some(local_guid) = plan.local_guids.get(&id) {
370 let mut emitted = read_leading_guid(&final_text)
371 .or_else(|| plan.guid_rewrite.get(&id).cloned())
372 .unwrap_or_else(|| local_guid.clone());
373 // A schema-conversion placeholder (an alignment/linear entity
374 // downgraded to IFCPROXY) is minted at emit time, AFTER GlobalId
375 // reconciliation, so it can still collide with a rooted GlobalId an
376 // earlier model already emitted. Reconciliation never saw it —
377 // re-stamp it here so the merged file keeps no duplicate GlobalIds
378 // (Greptile P1 #2952).
379 if emitted_guids.contains(&emitted) {
380 let minted = minter.mint(&emitted, &salt, &emitted_guids, &HashSet::new());
381 final_text = replace_global_id(&final_text, &minted);
382 emitted = minted;
383 }
384 let final_id = id.saturating_add(offset);
385 guid_to_final.insert(emitted.clone(), (final_id, effective_scale));
386 emitted_guids.insert(emitted);
387 // Also key reconciliation on the SOURCE GlobalId: schema conversion
388 // may have replaced this rooted entity's GlobalId with a proxy
389 // placeholder, so `emitted` is no longer the id a later compatible
390 // model would carry. Without this, that later model could not unify
391 // and would emit a second proxy for the same entity (CR #2952).
392 // `or_insert` never overwrites — a re-stamped duplicate keeps
393 // pointing at its first instance.
394 guid_to_final.entry(local_guid.clone()).or_insert((final_id, effective_scale));
395 }
396
397 out.push_str(&final_text);
398 out.push('\n');
399 stats.written += 1;
400 }
401
402 offset = next;
403 }
404
405 warnings::push_merge_warnings(&mut stats, refused_refs_total, unrepresented_types_kept, &schema);
406 stats.warnings.extend(slot_fill.warnings());
407 stats.warnings.extend(checks.warnings());
408 stats.warnings.extend(parents.apply_folds(&mut out)); // #5774 folds, after every model is written
409 out.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
410 (out, stats)
411}
412
413#[cfg(test)]
414mod tests;