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;