Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/merged-export-globalid-collisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@ifc-lite/wasm": patch
---

Fix the native (Rust) merged/federated IFC exporter emitting duplicate GlobalIds.

`export_merged_with_stats` (`rust/export/src/merged.rs`) only ID-offsets each
subsequent model's STEP entity instance names (`#123`) and rewrites the
`#`-references that point at them. `GlobalId` is a separate 22-character IFC
GUID attribute on every `IfcRoot` entity, untouched by that offset. Federating
two models that share an element -- the same file merged twice, a shared
grid, a linked type -- emitted that element's GlobalId twice into one file, a
spec violation independent of the exporter's other parity gaps (tracked in
#2951).

Every model after the first is now checked for a GlobalId already emitted by
an earlier model; a collision gets a fresh, deterministic GlobalId minted for
it (seeded from the original id and the source model's index, so output is
reproducible) rather than being written through unchanged. This mirrors the
"keep + re-stamp" branch of `MergedExporter`'s GlobalId reconciliation in
`packages/export/src/merged-exporter.ts` -- the branch that always applies
here, since the Rust path does not yet do the unit/spatial unification that
lets the JS path's other branch drop-and-remap a duplicate onto one shared
instance instead. That unification work remains open under #2951; this change
only removes the duplicate-GlobalId defect for the offsetting path Rust
already has.
25 changes: 25 additions & 0 deletions .changeset/merged-export-guid-misidentification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@ifc-lite/wasm": patch
---

Fix the native (Rust) merged/federated IFC exporter's GlobalId-collision fix
mistaking ordinary model strings for GlobalIds and corrupting them.

`leading_guid` in `rust/export/src/merged.rs` identified an entity's GlobalId
by scanning for the first quoted token anywhere on its STEP line, then
excluding a fixed list of non-`IfcRoot` entity types whose first attribute is
itself a string. Both parts were unsound: a non-rooted entity whose first
attribute is not a string (e.g. `IFCMATERIALLAYER`, whose 4th attribute is
`Name`) could still expose a later quoted string to the scan regardless of
the denylist, and the denylist itself was missing several non-rooted types
(`IFCMATERIALLAYER`, `IFCMATERIALLAYERSET`, and others). When that
coincidentally 22-character, GlobalId-charset string collided with a real
GlobalId already emitted, the exporter silently rewrote it -- corrupting
ordinary model data such as a material layer's `Name`.

GlobalId identification is now positional and type-checked instead: the
quoted token must be the entity's true first attribute (only whitespace
allowed between `(` and the quote), and the entity's type must actually
derive from `IfcRoot`, checked against `rust/core`'s generated schema
(`IfcType::is_subtype_of(IfcType::IfcRoot)`) rather than a hand-maintained
denylist that can drift out of sync with it.
26 changes: 26 additions & 0 deletions .changeset/merged-export-guid-schema-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@ifc-lite/wasm": patch
---

Fix the native (Rust) merged/federated IFC exporter still missing GlobalIds
on older-schema (IFC2X3/IFC4) models after the recent GlobalId-misidentification
fix.

`leading_guid` in `rust/export/src/merged.rs` checks whether an entity's type
derives from `IfcRoot` via `IfcType::is_subtype_of`, resolved against
`rust/core`'s generated schema table -- which is generated from IFC4X3 only.
A rooted entity type that exists in IFC2X3 and/or IFC4 but was dropped or
renamed in IFC4X3 (`IFCPROXY`, `IFCDOORSTYLE`, `IFCWINDOWSTYLE`, the IFC4
`*STANDARDCASE`/`*ELEMENTEDCASE` variants, and others -- 54 in total)
resolves to `IfcType::Unknown`, which is never a subtype of anything, so its
GlobalId was skipped entirely. Merging two such models sharing one of these
entities (a shared door/window style, a shared `IFCPROXY`, etc.) emitted that
GlobalId twice into one file -- the same defect the schema-derived check was
meant to close, just on older files.

`leading_guid` now also treats an `Unknown`-resolved type as rooted when it
matches a small supplemental table of IFC2X3/IFC4-only rooted types (derived
by diffing `@ifc-lite/data`'s per-schema entity tables against the
IFC4X3-only generated schema). Anything genuinely unrecognised is still
treated as non-rooted, so the corruption the misidentification fix closed
stays closed.
15 changes: 15 additions & 0 deletions .changeset/merged-export-visibility-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@ifc-lite/wasm": patch
---

Add per-model visibility filtering to the native (Rust) merged/federated IFC exporter (#2951).

`export_merged_with_stats` (`rust/export/src/merged.rs`) previously had no way to exclude entities from a federated export -- every entity from every input model was always written, unlike `step.rs`'s single-model exporter, which already honors a `StepOptions.included` allowlist. `MergedOptions.included` closes that gap for the merged path: an optional, per-model (index-aligned with the `models` slice) `VisibilityFilter { roots, excluded }` (`rust/export/src/merged_visibility.rs`), mirroring `computeIncludedEntityIds`'s role in `packages/export/src/merged-exporter.ts`.

An absent `included` field, or an absent per-model entry, includes that model in full (unchanged default behavior). An explicit empty filter (`roots: vec![]`) includes nothing from that model -- deliberately a different outcome from omitting the filter, pinned by dedicated tests, since collapsing the two would silently ship either an empty or a complete file.

The closure never walks into an `excluded` id, so a hidden product's geometry cannot re-enter through a relationship that also names it. A kept `IFCREL*` entity that still names an excluded id is **narrowed**, not dropped whole: `narrow_relationship_line` (`rust/export/src/merged_visibility.rs`) mirrors `filterHiddenRefsFromRelationshipLine` (`packages/export/src/reference-collector.ts`) -- a SET/LIST attribute is narrowed to its surviving members via `step_text::split_top_level_args`, and the whole line is withheld only when an excluded id sits in a single-valued slot (no spelling for "omitted"), or was a SET/LIST's only surviving member. Excluding just one sibling out of a storey's `IFCRELCONTAINEDINSPATIALSTRUCTURE` no longer drops that storey's containment for every other, still-visible element. The one schema-optional exception JS carries -- `IfcRelConnectsStructuralMember.ConditionCoordinateSystem`, rewritten to `$` instead of withholding -- is ported too.

Also fixes a latent interaction: a visibility filter that excludes model 0's own `IfcProject` used to leave every later model's own `IfcProject` dropped anyway (in favor of a "canonical" project that was never actually written), producing a merged file with no `IfcProject` at all. `canonical_project` is now invalidated when model 0's filter excludes it, so later models keep their own project instead of redirecting to nothing.

`merged_visibility.rs`'s own doc no longer claims the narrowing approach "can only under-connect, never dangle" -- true only for the `IFCREL*` shape it inspects. A non-`IFCREL*` entity referencing an excluded id (an `IFCSTYLEDITEM.Item`, a product's `Representation`/`ObjectPlacement`) can still dangle; that gap is inherited from the JS reference, which documents it openly (`step-exporter.ts`, 80 dangling refs measured before and after on `tests/models/AB22.ifc`), not introduced here.
2 changes: 2 additions & 0 deletions rust/export/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ mod json;
mod jsonld;
mod kmz;
mod merged;
mod merged_guid;
mod merged_visibility;
mod model;
mod obj;
mod relationships;
Expand Down
97 changes: 89 additions & 8 deletions rust/export/src/merged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,31 @@
//! valid `IfcProject` tree. Deeper shared-infrastructure dedup (units, contexts) and
//! spatial unification by name/elevation are the P2 follow-on.

use crate::merged_guid::{leading_guid, mint_unique_guid, replace_leading_guid};
use crate::merged_visibility::{compute_keep_set, narrow_for_emission, VisibilityFilter};
use crate::step_text::{detect_schema, escape};
use ifc_lite_core::EntityScanner;
use std::collections::HashSet;

/// Options for merged export.
pub struct MergedOptions {
pub schema: Option<String>,
pub description: String,
pub application: String,
/// Per-model visibility filter, index-aligned with the `models` slice
/// passed to [`export_merged_with_stats`]. `None` (the whole field) ⇒ no
/// filtering: every model is included in full, unchanged from before
/// this option existed.
///
/// `Some(per_model)` may be shorter than `models`; a missing or `None`
/// entry for model `i` includes that model in full. `Some(filter)`
/// keeps only `filter.roots` plus their forward-reference closure, minus
/// anything the closure refuses to walk into or dedangle — see
/// [`VisibilityFilter`] and [`compute_keep_set`] for the exact contract,
/// including why an explicitly empty filter (`roots: vec![]`) means
/// "nothing from this model", a different outcome from omitting the
/// entry (or the whole field) entirely.
pub included: Option<Vec<Option<VisibilityFilter>>>,
}

impl Default for MergedOptions {
Expand All @@ -25,6 +42,7 @@ impl Default for MergedOptions {
schema: None,
description: "ViewDefinition [CoordinationView]".to_string(),
application: "ifc-lite".to_string(),
included: None,
}
}
}
Expand Down Expand Up @@ -106,7 +124,11 @@ pub fn export_merged_with_stats(models: &[&[u8]], opts: &MergedOptions) -> (Stri
.or_else(|| models.first().map(|m| detect_schema(m)))
.unwrap_or_else(|| "IFC4".to_string());

let canonical_project = models.first().and_then(|m| find_project(m));
// Mutable: a visibility filter on model 0 that excludes its own
// `IfcProject` invalidates this below (see the `is_first` branch in the
// loop) -- there is then nothing valid to redirect later models' project
// references onto, so their own project must NOT be dropped either.
let mut canonical_project = models.first().and_then(|m| find_project(m));

let mut out = String::new();
out.push_str("ISO-10303-21;\nHEADER;\n");
Expand All @@ -118,19 +140,46 @@ pub fn export_merged_with_stats(models: &[&[u8]], opts: &MergedOptions) -> (Stri
out.push_str(&format!("FILE_SCHEMA(('{}'));\n", escape(&schema)));
out.push_str("ENDSEC;\nDATA;\n");

// GlobalId → seen, across every model emitted so far. A `IfcRoot` entity
// (by type + GlobalId-shaped first attribute) that repeats a GlobalId
// already in this set gets a fresh deterministic id minted for it before
// being written (see `leading_guid`/`mint_unique_guid`) -- ID-offsetting
// `#`-refs, done above, never touches this attribute, so without this
// step two models sharing an element (or the same file merged twice)
// would emit the same 22-char GlobalId twice, an IFC spec violation.
let mut emitted_guids: HashSet<String> = HashSet::new();

let mut offset: u32 = 0;
let mut written = 0usize;
for (i, content) in models.iter().enumerate() {
let model_project = find_project(content);
let mut local_max = 0u32;
let mut scanner = EntityScanner::new(content);
let mut lines: Vec<(u32, &[u8])> = Vec::new();
while let Some((id, _t, s, e)) = scanner.next_entity() {
let mut lines: Vec<(u32, &str, &[u8])> = Vec::new();
while let Some((id, t, s, e)) = scanner.next_entity() {
local_max = local_max.max(id);
lines.push((id, &content[s..e]));
lines.push((id, t, &content[s..e]));
}

// Visibility filter for this model: `None` ⇒ keep everything (no
// per-entity check below), `Some(keep)` ⇒ only ids in `keep` survive.
// Computed on the model-LOCAL (pre-offset) ids `lines` already holds.
let keep_set: Option<HashSet<u32>> = opts.included.as_ref().and_then(|per_model| {
per_model.get(i).cloned().flatten().map(|filter| compute_keep_set(&lines, &filter))
});

let is_first = i == 0;
// If model 0's own visibility filter excludes its `IfcProject`, there
// is nothing to unify subsequent models onto: invalidate the
// canonical project BEFORE it's captured by `remap` or checked by
// the "drop this model's own project" test below.
if is_first {
if let (Some(cp), Some(keep)) = (canonical_project, &keep_set) {
if !keep.contains(&cp) {
canonical_project = None;
}
}
}
let remap = |n: u32| -> Option<u32> {
// Subsequent models: redirect their project reference to model 0's project.
if !is_first {
Expand All @@ -143,12 +192,44 @@ pub fn export_merged_with_stats(models: &[&[u8]], opts: &MergedOptions) -> (Stri
None
};

for (id, line) in &lines {
// Drop later models' IfcProject lines (the project is unified to model 0's).
if !is_first && Some(*id) == model_project {
// GlobalIds minted for THIS model's collisions, so two collisions
// within the same model can't mint the same fresh id as each other
// (checked in addition to `emitted_guids`).
let mut pending_minted: HashSet<String> = HashSet::new();

for (id, type_name, line) in &lines {
// Visibility filter: an id not in this model's kept set is
// dropped before anything else (GlobalId bookkeeping included --
// an excluded entity was never emitted, so it must not occupy or
// reserve a GlobalId either).
if let Some(keep) = &keep_set {
if !keep.contains(id) {
continue;
}
}
// Drop later models' IfcProject lines (the project is unified to
// model 0's) -- only when model 0's project actually survived its
// own visibility filter (see the `is_first` block above); if it
// didn't, this model's project is kept, subject only to its own
// filter, same as any other entity.
if !is_first && canonical_project.is_some() && Some(*id) == model_project {
continue;
}
out.push_str(&rewrite_refs(line, offset, &remap));
// Narrow (not just gate on) a kept IFCREL* line -- see `narrow_for_emission`.
let narrowed = keep_set.as_ref().map(|k| narrow_for_emission(type_name, line, k));
let mut rewritten = rewrite_refs(narrowed.as_deref().unwrap_or(line), offset, &remap);

if let Some(guid) = leading_guid(line, type_name) {
if emitted_guids.contains(&guid) {
let fresh = mint_unique_guid(&guid, i, &emitted_guids, &mut pending_minted);
rewritten = replace_leading_guid(&rewritten, &fresh);
emitted_guids.insert(fresh);
} else {
emitted_guids.insert(guid);
}
}

out.push_str(&rewritten);
out.push('\n');
written += 1;
}
Expand Down
Loading
Loading