@ifc-lite/export 1.21.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /* This Source Code Form is subject to the terms of the Mozilla Public
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
- import { generateHeader } from '@ifc-lite/parser';
4
+ import { generateHeader, deterministicGlobalId } from '@ifc-lite/parser';
5
5
  import { decodeIfcString } from '@ifc-lite/encoding';
6
6
  import { safeUtf8Decode } from '@ifc-lite/data';
7
7
  import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
@@ -14,18 +14,97 @@ const SHARED_INFRASTRUCTURE_TYPES = new Set([
14
14
  'IFCGEOMETRICREPRESENTATIONCONTEXT',
15
15
  'IFCGEOMETRICREPRESENTATIONSUBCONTEXT',
16
16
  ]);
17
+ /**
18
+ * An IfcGloballyUniqueId is exactly 22 characters of the buildingSMART base64
19
+ * alphabet. We use this to recognise a rooted entity (IfcRoot subtype) by its
20
+ * first attribute. Geometry/list entities never carry a string there, but some
21
+ * non-rooted RESOURCE entities lead with a Name/Identifier string that can
22
+ * legitimately be 22 charset chars (e.g. a coded property key). Those are
23
+ * excluded by type ({@link NON_ROOTED_STRING_TYPES}) so their Name is never
24
+ * mistaken for a GlobalId — otherwise the GlobalId reconciliation could drop or
25
+ * rename them.
26
+ */
27
+ const GLOBAL_ID_RE = /^[0-9A-Za-z_$]{22}$/;
28
+ /**
29
+ * Non-IfcRoot entity types whose first attribute is (or can be) a quoted
30
+ * Name/Identifier string. They must NOT be treated as rooted by GlobalId, even
31
+ * when that string happens to be 22 charset characters. (IfcRoot property
32
+ * containers like IFCPROPERTYSET / IFCELEMENTQUANTITY are deliberately absent —
33
+ * they ARE rooted and carry a real GlobalId at attribute 0.)
34
+ *
35
+ * This is a best-effort denylist, not an exhaustive IfcRoot classifier — the
36
+ * merge works off raw STEP text and has no schema table. It covers the resource
37
+ * families that realistically appear in federated models; an unlisted
38
+ * string-leading resource type is only ever a problem if two models share an
39
+ * identical 22-char charset Name for it AND it collides, which is negligible. A
40
+ * miss in the other direction (treating a real root as non-rooted) is safe — it
41
+ * just skips one GlobalId reconciliation.
42
+ */
43
+ const NON_ROOTED_STRING_TYPES = new Set([
44
+ // IfcSimpleProperty / IfcComplexProperty (IfcPropertyAbstraction — not rooted)
45
+ 'IFCPROPERTYSINGLEVALUE', 'IFCPROPERTYENUMERATEDVALUE', 'IFCPROPERTYLISTVALUE',
46
+ 'IFCPROPERTYBOUNDEDVALUE', 'IFCPROPERTYTABLEVALUE', 'IFCPROPERTYREFERENCEVALUE',
47
+ 'IFCCOMPLEXPROPERTY',
48
+ // IfcPhysicalQuantity (not rooted)
49
+ 'IFCQUANTITYLENGTH', 'IFCQUANTITYAREA', 'IFCQUANTITYVOLUME', 'IFCQUANTITYCOUNT',
50
+ 'IFCQUANTITYWEIGHT', 'IFCQUANTITYTIME', 'IFCQUANTITYNUMBER', 'IFCPHYSICALCOMPLEXQUANTITY',
51
+ // Materials & their constituents (IfcMaterialDefinition — not rooted; lead with a Name)
52
+ 'IFCMATERIAL', 'IFCMATERIALPROFILE', 'IFCMATERIALPROFILESET',
53
+ 'IFCMATERIALCONSTITUENT', 'IFCMATERIALCONSTITUENTSET',
54
+ // Classification, library & document refs (IfcExternalInformation/Reference)
55
+ 'IFCCLASSIFICATION', 'IFCCLASSIFICATIONREFERENCE',
56
+ 'IFCLIBRARYINFORMATION', 'IFCLIBRARYREFERENCE', 'IFCEXTERNALREFERENCE',
57
+ 'IFCDOCUMENTINFORMATION', 'IFCDOCUMENTREFERENCE',
58
+ // Constraints & approvals (lead with a Name/Identifier)
59
+ 'IFCMETRIC', 'IFCOBJECTIVE', 'IFCAPPROVAL', 'IFCTABLE',
60
+ // Actors (IfcPerson/IfcOrganization lead with an Identification string)
61
+ 'IFCPERSON', 'IFCORGANIZATION',
62
+ // Presentation layers, styles & text literals (lead with a Name/Literal string)
63
+ 'IFCPRESENTATIONLAYERASSIGNMENT', 'IFCPRESENTATIONLAYERWITHSTYLE',
64
+ 'IFCSURFACESTYLE', 'IFCCURVESTYLE', 'IFCTEXTSTYLE', 'IFCFILLAREASTYLE',
65
+ 'IFCTEXTLITERAL', 'IFCTEXTLITERALWITHEXTENT',
66
+ ]);
67
+ /** True for IfcRelationship subtypes (objectified relationships). */
68
+ function isRelationshipType(typeUpper) {
69
+ return typeUpper.startsWith('IFCREL');
70
+ }
71
+ /** Relative tolerance for comparing two length unit scale factors. */
72
+ const UNIT_SCALE_TOLERANCE = 1e-6;
17
73
  /**
18
74
  * Merges multiple IFC models into a single STEP file.
19
75
  *
20
76
  * Uses the same approach as IfcOpenShell's MergeProjects recipe, extended
21
- * with spatial hierarchy unification:
77
+ * with spatial hierarchy unification and unit-aware federation:
22
78
  * 1. First model's entities use their original IDs
23
79
  * 2. Subsequent models' IDs are offset to avoid collisions
24
- * 3. IfcProject is unified all references remapped to the first model's project
25
- * 4. Spatial structure (Site, Building, Storey) is unified by name/elevation:
26
- * matching entities are remapped to the first model's equivalents so that
27
- * products from all models end up in the same unified tree
28
- * 5. Duplicate entities and shared infrastructure (units, contexts) are skipped
80
+ * 3. A model that shares the first model's length unit is *unified*: its
81
+ * IfcProject is remapped to the first model's, spatial structure (Site,
82
+ * Building, Storey) is unified by name/elevation, and shared infrastructure
83
+ * (units, contexts) is deduplicated.
84
+ * 4. A model with a *different* length unit is *federated*: it keeps its own
85
+ * IfcProject, IfcUnitAssignment and representation contexts, so its raw
86
+ * coordinates remain correctly scaled rather than being reinterpreted under
87
+ * the first model's unit (the mis-scale bug, issue #1332).
88
+ * 5. GlobalIds are reconciled, not blindly duplicated: a non-relationship
89
+ * rooted entity that repeats a GlobalId already emitted *in the same unit
90
+ * space* is unified (references remapped to the one instance). Otherwise —
91
+ * a federated/different-unit instance, or an objectified relationship whose
92
+ * payload (RelatedObjects) may differ — it is kept and re-stamped with a
93
+ * fresh deterministic GlobalId so the file has no duplicate-GlobalId errors
94
+ * and no relationship membership is lost.
95
+ *
96
+ * Conformance trade-off: when federation triggers, the file contains more than
97
+ * one IfcProject, which intentionally relaxes the IfcSingleProjectInstance
98
+ * EXPRESS rule (SIZEOF(IfcProject) <= 1). This is the only way to keep two
99
+ * different length units in one STEP file without rewriting every length-valued
100
+ * coordinate, and it is strictly better than the previous silent mis-scale.
101
+ * `MergeExportResult.stats.warnings` flags it; pass
102
+ * `unitReconciliation: 'assume-shared'` to force a single project when units
103
+ * are already normalised.
104
+ *
105
+ * Limitation: federation only unifies a model against the *first* model's unit
106
+ * group. Two non-first models that share a unit different from the first are
107
+ * each kept as independent projects (correct, just less deduplicated).
29
108
  */
30
109
  export class MergedExporter {
31
110
  models;
@@ -38,117 +117,39 @@ export class MergedExporter {
38
117
  export(options) {
39
118
  const onProgress = options.onProgress;
40
119
  const schema = (options.schema || 'IFC4');
41
- // Generate header. Policy: a federated/merged file has no single source
42
- // header to round-trip, so we deliberately emit an ifc-lite provenance
43
- // header rather than picking one model's FILE_DESCRIPTION arbitrarily.
44
- const header = generateHeader({
45
- schema,
46
- description: options.description || `Merged export of ${this.models.length} models from ifc-lite`,
47
- author: options.author || '',
48
- organization: options.organization || '',
49
- application: options.application || 'ifc-lite',
50
- filename: options.filename || 'merged.ifc',
51
- });
120
+ const header = this.buildHeader(options, schema);
121
+ const setup = this.buildMergeSetup(options);
52
122
  const allEntityLines = [];
53
- // Track ID offsets per model
54
- let nextAvailableId = 1;
55
- const modelOffsets = new Map();
56
- // First pass: determine ID offsets. Span the COMPLETE entity set (incl.
57
- // deferred property atoms) so the next model's offset clears every id this
58
- // model will emit — otherwise a deferred atom at a high id collides.
59
- for (const model of this.models) {
60
- modelOffsets.set(model.id, nextAvailableId - 1); // offset = nextAvailableId - 1 so IDs start at nextAvailableId
61
- nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
62
- }
63
- // Collect first model's info for deduplication
64
- const firstModel = this.models[0];
65
- const firstModelOffset = modelOffsets.get(firstModel.id);
66
- const firstModelInfraMap = this.findInfrastructureEntities(firstModel.dataStore);
67
- const firstProjectIds = this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT');
68
- // Build spatial lookup from first model for Site/Building/Storey unification
69
- const spatialLookup = this.buildSpatialLookup(firstModel.dataStore);
70
- // Process each model
123
+ // Tracks every GlobalId already emitted → its final express id + unit scale,
124
+ // so later models can unify against (shared unit) or stay unique from
125
+ // (federated / different unit) it.
126
+ const guidToFinalId = new Map();
71
127
  let isFirstModel = true;
128
+ let federatedModelCount = 0;
72
129
  for (const model of this.models) {
73
- const offset = modelOffsets.get(model.id);
130
+ const offset = setup.modelOffsets.get(model.id);
74
131
  const source = model.dataStore.source;
75
132
  if (!source || source.length === 0)
76
133
  continue;
77
134
  // Complete view over byId + any deferred property atoms, so the closure
78
135
  // walk and the emit loop both reach every entity the source defines.
79
136
  const completeIndex = getCompleteEntityIndex(model.dataStore);
80
- // Determine which entities to include
81
- let includedEntityIds = null;
82
- if (options.visibleOnly) {
83
- const hiddenIds = options.hiddenEntityIdsByModel?.get(model.id) ?? new Set();
84
- const isolatedIds = options.isolatedEntityIdsByModel?.get(model.id) ?? null;
85
- const { roots, hiddenProductIds } = getVisibleEntityIds(model.dataStore, hiddenIds, isolatedIds);
86
- includedEntityIds = collectReferencedEntityIds(roots, source, completeIndex, hiddenProductIds);
87
- // Second pass: collect style entities that reference included geometry
88
- collectStyleEntities(includedEntityIds, source, {
89
- byId: completeIndex,
90
- byType: model.dataStore.entityIndex.byType,
91
- });
92
- }
93
- // Build remap table (references to remap) and skip set (entities to omit)
94
- const sharedRemap = new Map();
95
- const skipEntityIds = new Set();
96
- if (!isFirstModel) {
97
- // Remap this model's IfcProject references → first model's IfcProject.
98
- const projectIds = this.findEntitiesByType(model.dataStore, 'IFCPROJECT');
99
- if (firstProjectIds.length > 0) {
100
- for (const pid of projectIds) {
101
- sharedRemap.set(pid, firstProjectIds[0] + firstModelOffset);
102
- skipEntityIds.add(pid);
103
- }
104
- }
105
- // Remap and skip duplicate infrastructure (units, contexts)
106
- const modelInfra = this.findInfrastructureEntities(model.dataStore);
107
- for (const [type, firstIds] of firstModelInfraMap) {
108
- const thisIds = modelInfra.get(type);
109
- if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
110
- sharedRemap.set(thisIds[0], firstIds[0] + firstModelOffset);
111
- skipEntityIds.add(thisIds[0]);
112
- }
113
- }
114
- // Unify spatial hierarchy: match Site, Building, Storey to first model
115
- this.unifySpatialEntities(model.dataStore, spatialLookup, firstModelOffset, sharedRemap, skipEntityIds);
116
- // Skip IfcRelAggregates that become fully redundant after unification.
117
- // e.g. Model2's Project→Site becomes FirstProject→FirstSite which
118
- // already exists from Model1, causing duplicate tree nodes.
119
- this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
120
- }
121
- // Emit entities for this model
137
+ const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
138
+ const modelScale = this.resolveUnitScale(model);
139
+ const compatible = isFirstModel || setup.assumeShared
140
+ || this.unitsCompatible(modelScale, setup.primaryScale);
141
+ if (!isFirstModel && !compatible)
142
+ federatedModelCount++;
143
+ const plan = this.planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId);
144
+ const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
122
145
  for (const [expressId, entityRef] of completeIndex) {
123
- // Skip entities outside the visible closure
124
- if (includedEntityIds !== null && !includedEntityIds.has(expressId)) {
146
+ if (includedEntityIds !== null && !includedEntityIds.has(expressId))
125
147
  continue;
126
- }
127
- // Skip duplicate entities (project, infrastructure)
128
- if (skipEntityIds.has(expressId)) {
148
+ if (plan.skipEntityIds.has(expressId))
129
149
  continue;
130
- }
131
- // Get original entity text — safeUtf8Decode handles SAB-backed sources
132
- const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
133
- // Remap IDs if this is not the first model or if offset is non-zero
134
- let finalText;
135
- if (offset === 0 && sharedRemap.size === 0) {
136
- finalText = entityText;
137
- }
138
- else {
139
- finalText = this.remapEntityText(entityText, offset, sharedRemap);
140
- }
141
- // Apply schema conversion if target differs from source
142
- const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
143
- if (needsConversion(sourceSchema, schema)) {
144
- const converted = convertStepLine(finalText, sourceSchema, schema);
145
- if (converted !== null) {
146
- allEntityLines.push(converted);
147
- }
148
- }
149
- else {
150
- allEntityLines.push(finalText);
151
- }
150
+ const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, modelScale);
151
+ if (line !== null)
152
+ allEntityLines.push(line);
152
153
  }
153
154
  isFirstModel = false;
154
155
  }
@@ -158,11 +159,7 @@ export class MergedExporter {
158
159
  const content = assembleStepBytes(header, allEntityLines);
159
160
  return {
160
161
  content,
161
- stats: {
162
- modelCount: this.models.length,
163
- totalEntityCount: allEntityLines.length,
164
- fileSize: content.byteLength,
165
- },
162
+ stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
166
163
  };
167
164
  }
168
165
  /**
@@ -175,38 +172,23 @@ export class MergedExporter {
175
172
  const schema = (options.schema || 'IFC4');
176
173
  // See export(): merged files emit an ifc-lite provenance header by policy
177
174
  // (no single source header to preserve across federated models).
178
- const header = generateHeader({
179
- schema,
180
- description: options.description || `Merged export of ${this.models.length} models from ifc-lite`,
181
- author: options.author || '',
182
- organization: options.organization || '',
183
- application: options.application || 'ifc-lite',
184
- filename: options.filename || 'merged.ifc',
185
- });
175
+ const header = this.buildHeader(options, schema);
176
+ const setup = this.buildMergeSetup(options);
186
177
  const allEntityLines = [];
178
+ const guidToFinalId = new Map();
187
179
  // First pass: count total entities for progress
188
180
  let totalEntities = 0;
189
181
  for (const model of this.models) {
190
182
  totalEntities += getCompleteEntityIndex(model.dataStore).size;
191
183
  }
192
- let nextAvailableId = 1;
193
- const modelOffsets = new Map();
194
- for (const model of this.models) {
195
- modelOffsets.set(model.id, nextAvailableId - 1);
196
- nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
197
- }
198
- const firstModel = this.models[0];
199
- const firstModelOffset = modelOffsets.get(firstModel.id);
200
- const firstModelInfraMap = this.findInfrastructureEntities(firstModel.dataStore);
201
- const firstProjectIds = this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT');
202
- const spatialLookup = this.buildSpatialLookup(firstModel.dataStore);
203
184
  let isFirstModel = true;
204
185
  let entitiesProcessed = 0;
186
+ let federatedModelCount = 0;
205
187
  const YIELD_INTERVAL = 2000;
206
188
  if (onProgress)
207
189
  onProgress({ phase: 'preparing', percent: 0, entitiesProcessed: 0, entitiesTotal: totalEntities });
208
190
  for (const model of this.models) {
209
- const offset = modelOffsets.get(model.id);
191
+ const offset = setup.modelOffsets.get(model.id);
210
192
  const source = model.dataStore.source;
211
193
  if (!source || source.length === 0)
212
194
  continue;
@@ -220,61 +202,23 @@ export class MergedExporter {
220
202
  });
221
203
  }
222
204
  const completeIndex = getCompleteEntityIndex(model.dataStore);
223
- let includedEntityIds = null;
224
- if (options.visibleOnly) {
225
- const hiddenIds = options.hiddenEntityIdsByModel?.get(model.id) ?? new Set();
226
- const isolatedIds = options.isolatedEntityIdsByModel?.get(model.id) ?? null;
227
- const { roots, hiddenProductIds } = getVisibleEntityIds(model.dataStore, hiddenIds, isolatedIds);
228
- includedEntityIds = collectReferencedEntityIds(roots, source, completeIndex, hiddenProductIds);
229
- collectStyleEntities(includedEntityIds, source, {
230
- byId: completeIndex,
231
- byType: model.dataStore.entityIndex.byType,
232
- });
233
- }
234
- const sharedRemap = new Map();
235
- const skipEntityIds = new Set();
236
- if (!isFirstModel) {
237
- const projectIds = this.findEntitiesByType(model.dataStore, 'IFCPROJECT');
238
- if (firstProjectIds.length > 0) {
239
- for (const pid of projectIds) {
240
- sharedRemap.set(pid, firstProjectIds[0] + firstModelOffset);
241
- skipEntityIds.add(pid);
242
- }
243
- }
244
- const modelInfra = this.findInfrastructureEntities(model.dataStore);
245
- for (const [type, firstIds] of firstModelInfraMap) {
246
- const thisIds = modelInfra.get(type);
247
- if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
248
- sharedRemap.set(thisIds[0], firstIds[0] + firstModelOffset);
249
- skipEntityIds.add(thisIds[0]);
250
- }
251
- }
252
- this.unifySpatialEntities(model.dataStore, spatialLookup, firstModelOffset, sharedRemap, skipEntityIds);
253
- this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
254
- }
205
+ const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
206
+ const modelScale = this.resolveUnitScale(model);
207
+ const compatible = isFirstModel || setup.assumeShared
208
+ || this.unitsCompatible(modelScale, setup.primaryScale);
209
+ if (!isFirstModel && !compatible)
210
+ federatedModelCount++;
211
+ const plan = this.planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId);
212
+ const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
255
213
  let entityCount = 0;
256
214
  for (const [expressId, entityRef] of completeIndex) {
257
215
  if (includedEntityIds !== null && !includedEntityIds.has(expressId))
258
216
  continue;
259
- if (skipEntityIds.has(expressId))
217
+ if (plan.skipEntityIds.has(expressId))
260
218
  continue;
261
- const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
262
- let finalText;
263
- if (offset === 0 && sharedRemap.size === 0) {
264
- finalText = entityText;
265
- }
266
- else {
267
- finalText = this.remapEntityText(entityText, offset, sharedRemap);
268
- }
269
- const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
270
- if (needsConversion(sourceSchema, schema)) {
271
- const converted = convertStepLine(finalText, sourceSchema, schema);
272
- if (converted !== null)
273
- allEntityLines.push(converted);
274
- }
275
- else {
276
- allEntityLines.push(finalText);
277
- }
219
+ const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, modelScale);
220
+ if (line !== null)
221
+ allEntityLines.push(line);
278
222
  entityCount++;
279
223
  entitiesProcessed++;
280
224
  // Yield to event loop every YIELD_INTERVAL entities
@@ -304,13 +248,311 @@ export class MergedExporter {
304
248
  }
305
249
  return {
306
250
  content,
307
- stats: {
308
- modelCount: this.models.length,
309
- totalEntityCount: allEntityLines.length,
310
- fileSize: content.byteLength,
311
- },
251
+ stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
252
+ };
253
+ }
254
+ /**
255
+ * Assemble the result stats, including any federation conformance warnings.
256
+ */
257
+ buildStats(totalEntityCount, fileSize, federatedModelCount) {
258
+ const warnings = [];
259
+ if (federatedModelCount > 0) {
260
+ warnings.push(`${federatedModelCount} model(s) had a length unit differing from the first model and were ` +
261
+ `federated as separate IfcProject roots to keep their geometry correctly scaled. The output ` +
262
+ `therefore contains ${federatedModelCount + 1} IfcProject instances, which intentionally relaxes ` +
263
+ `the IfcSingleProjectInstance rule (SIZEOF(IfcProject) <= 1). Some single-project viewers may ` +
264
+ `only show the first project. Pass unitReconciliation:'assume-shared' to force one project when ` +
265
+ `units are already normalised.`);
266
+ }
267
+ return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, warnings };
268
+ }
269
+ /**
270
+ * Build the ifc-lite provenance header. Merged files have no single source
271
+ * header to round-trip, so we deliberately emit our own rather than picking
272
+ * one model's FILE_DESCRIPTION arbitrarily.
273
+ */
274
+ buildHeader(options, schema) {
275
+ return generateHeader({
276
+ schema,
277
+ description: options.description || `Merged export of ${this.models.length} models from ifc-lite`,
278
+ author: options.author || '',
279
+ organization: options.organization || '',
280
+ application: options.application || 'ifc-lite',
281
+ filename: options.filename || 'merged.ifc',
282
+ });
283
+ }
284
+ /**
285
+ * Compute the model-independent state shared by export()/exportAsync():
286
+ * per-model id offsets and the primary model's project/infra/spatial/unit info.
287
+ */
288
+ buildMergeSetup(options) {
289
+ // Determine ID offsets. Span the COMPLETE entity set (incl. deferred
290
+ // property atoms) so the next model's offset clears every id this model
291
+ // will emit — otherwise a deferred atom at a high id collides.
292
+ let nextAvailableId = 1;
293
+ const modelOffsets = new Map();
294
+ for (const model of this.models) {
295
+ modelOffsets.set(model.id, nextAvailableId - 1); // start at nextAvailableId
296
+ nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
297
+ }
298
+ const firstModel = this.models[0];
299
+ return {
300
+ modelOffsets,
301
+ firstModelOffset: modelOffsets.get(firstModel.id),
302
+ firstModelInfraMap: this.findInfrastructureEntities(firstModel.dataStore),
303
+ firstProjectIds: this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT'),
304
+ spatialLookup: this.buildSpatialLookup(firstModel.dataStore),
305
+ primaryScale: this.resolveUnitScale(firstModel),
306
+ assumeShared: options.unitReconciliation === 'assume-shared',
312
307
  };
313
308
  }
309
+ /**
310
+ * Resolve a model's length unit scale (raw IFC length → metres). Prefers an
311
+ * explicit `lengthUnitScale` on the input, else the value the parser stamped
312
+ * on the data store, else metres.
313
+ */
314
+ resolveUnitScale(model) {
315
+ const explicit = model.lengthUnitScale;
316
+ if (typeof explicit === 'number' && explicit > 0)
317
+ return explicit;
318
+ const fromStore = model.dataStore.lengthUnitScale;
319
+ if (typeof fromStore === 'number' && fromStore > 0)
320
+ return fromStore;
321
+ return 1.0;
322
+ }
323
+ /** True when two length unit scales are equal within relative tolerance. */
324
+ unitsCompatible(a, b) {
325
+ if (a === b)
326
+ return true;
327
+ const max = Math.max(Math.abs(a), Math.abs(b));
328
+ if (max === 0)
329
+ return true;
330
+ return Math.abs(a - b) <= max * UNIT_SCALE_TOLERANCE;
331
+ }
332
+ /**
333
+ * Resolve the set of express ids to include for a model under visibility
334
+ * filtering, or `null` when no filtering is requested (include everything).
335
+ */
336
+ computeIncludedEntityIds(model, options, completeIndex, source) {
337
+ if (!options.visibleOnly)
338
+ return null;
339
+ const hiddenIds = options.hiddenEntityIdsByModel?.get(model.id) ?? new Set();
340
+ const isolatedIds = options.isolatedEntityIdsByModel?.get(model.id) ?? null;
341
+ const { roots, hiddenProductIds } = getVisibleEntityIds(model.dataStore, hiddenIds, isolatedIds);
342
+ const included = collectReferencedEntityIds(roots, source, completeIndex, hiddenProductIds);
343
+ // Second pass: collect style entities that reference included geometry.
344
+ collectStyleEntities(included, source, {
345
+ byId: completeIndex,
346
+ byType: model.dataStore.entityIndex.byType,
347
+ });
348
+ return included;
349
+ }
350
+ /**
351
+ * Plan how a model's entities are remapped, skipped, or re-stamped, given
352
+ * whether it shares the primary model's length unit (`compatible`).
353
+ *
354
+ * Compatible (or `assume-shared`) models are unified into the primary project:
355
+ * their IfcProject, shared infrastructure, and matching spatial structure are
356
+ * deduplicated, and a rooted entity repeating an already-emitted GlobalId is
357
+ * unified to that one instance.
358
+ *
359
+ * Incompatible (federated) models keep their own project, units, contexts and
360
+ * spatial structure so their coordinates stay correctly scaled; a rooted
361
+ * entity whose GlobalId collides with one already emitted is given a fresh
362
+ * deterministic GlobalId, since the two cannot be the same instance across
363
+ * different unit spaces.
364
+ */
365
+ planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId) {
366
+ const source = model.dataStore.source;
367
+ const sharedRemap = new Map();
368
+ const skipEntityIds = new Set();
369
+ const guidRewrite = new Map();
370
+ // One cheap pass to read each rooted entity's GlobalId (first attribute).
371
+ const localGuids = new Map();
372
+ for (const [id, ref] of completeIndex) {
373
+ const guid = this.extractGlobalIdFast(ref, source);
374
+ if (guid !== null)
375
+ localGuids.set(id, guid);
376
+ }
377
+ if (!isFirstModel && compatible) {
378
+ // Remap this model's IfcProject references → first model's IfcProject.
379
+ const projectIds = this.findEntitiesByType(model.dataStore, 'IFCPROJECT');
380
+ if (setup.firstProjectIds.length > 0) {
381
+ for (const pid of projectIds) {
382
+ sharedRemap.set(pid, setup.firstProjectIds[0] + setup.firstModelOffset);
383
+ skipEntityIds.add(pid);
384
+ }
385
+ }
386
+ // Remap and skip duplicate infrastructure (units, contexts).
387
+ const modelInfra = this.findInfrastructureEntities(model.dataStore);
388
+ for (const [type, firstIds] of setup.firstModelInfraMap) {
389
+ const thisIds = modelInfra.get(type);
390
+ if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
391
+ sharedRemap.set(thisIds[0], firstIds[0] + setup.firstModelOffset);
392
+ skipEntityIds.add(thisIds[0]);
393
+ }
394
+ }
395
+ // Unify spatial hierarchy: match Site, Building, Storey to first model.
396
+ this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, sharedRemap, skipEntityIds);
397
+ // Skip IfcRelAggregates that become fully redundant after unification.
398
+ this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
399
+ }
400
+ if (!isFirstModel) {
401
+ // GlobalId reconciliation against everything emitted by earlier models.
402
+ const pendingMinted = new Set();
403
+ for (const [id, guid] of localGuids) {
404
+ if (skipEntityIds.has(id))
405
+ continue; // already unified/deduped above
406
+ const prior = guidToFinalId.get(guid);
407
+ if (prior === undefined)
408
+ continue; // first occurrence — kept as-is
409
+ // Unify (drop + remap refs to the one instance) ONLY when this is a
410
+ // physical/spatial root AND both this model and the emitter share the
411
+ // primary unit. Two conditions force "keep + re-stamp" instead:
412
+ // - Objectified relationships (IfcRel*): same GlobalId does not imply
413
+ // the same membership (e.g. a storey-containment listing different
414
+ // elements per discipline), so dropping one would orphan elements.
415
+ // - The colliding instance was emitted in a different unit space
416
+ // (a federated model), so unifying would reinterpret coordinates —
417
+ // the very mis-scale this fix prevents, reached transitively.
418
+ const type = (completeIndex.get(id)?.type ?? '').toUpperCase();
419
+ const emitterIsPrimaryUnit = this.unitsCompatible(prior.scale, setup.primaryScale);
420
+ const canUnify = compatible && emitterIsPrimaryUnit && !isRelationshipType(type);
421
+ if (canUnify) {
422
+ sharedRemap.set(id, prior.finalId);
423
+ skipEntityIds.add(id);
424
+ }
425
+ else {
426
+ guidRewrite.set(id, this.mintUniqueGuid(guid, model.id, guidToFinalId, pendingMinted));
427
+ }
428
+ }
429
+ }
430
+ return { sharedRemap, skipEntityIds, guidRewrite, localGuids };
431
+ }
432
+ /**
433
+ * Render one source entity into its final STEP line: apply id offset + shared
434
+ * remaps, re-stamp a federated GlobalId if needed, apply schema conversion,
435
+ * and register the emitted GlobalId so later models can reconcile against it.
436
+ * Returns `null` when schema conversion drops the entity.
437
+ */
438
+ renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, modelScale) {
439
+ const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
440
+ // Remap ids. Fast path: the first model (offset 0, no remaps) is byte-identical.
441
+ let finalText;
442
+ if (offset === 0 && plan.sharedRemap.size === 0) {
443
+ finalText = entityText;
444
+ }
445
+ else {
446
+ finalText = this.remapEntityText(entityText, offset, plan.sharedRemap);
447
+ }
448
+ // Re-stamp the GlobalId for a federated entity whose id collides.
449
+ const mintedGuid = plan.guidRewrite.get(localId);
450
+ if (mintedGuid !== undefined) {
451
+ finalText = this.replaceGlobalId(finalText, mintedGuid);
452
+ }
453
+ if (needsConversion(sourceSchema, targetSchema)) {
454
+ const converted = convertStepLine(finalText, sourceSchema, targetSchema);
455
+ if (converted === null)
456
+ return null;
457
+ finalText = converted;
458
+ }
459
+ // Record the emitted GlobalId → final express id + unit scale, for rooted
460
+ // entities only. Read it from the FINAL line, not the source: schema
461
+ // conversion can replace an unsupported rooted type with an IFCPROXY that
462
+ // carries a freshly-minted GlobalId, so the source guid would be stale.
463
+ // Emitted entities are not sharedRemap keys, so their final id is
464
+ // localId + offset.
465
+ if (plan.localGuids.has(localId)) {
466
+ const emittedGuid = this.readLeadingGuid(finalText)
467
+ ?? mintedGuid ?? plan.localGuids.get(localId);
468
+ if (emittedGuid !== undefined) {
469
+ guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale: modelScale });
470
+ }
471
+ }
472
+ return finalText;
473
+ }
474
+ /**
475
+ * Read the GlobalId (first quoted attribute) from an already-rendered STEP
476
+ * line. Used to register the id that was actually emitted, after any id
477
+ * remap, GlobalId re-stamp, or schema conversion. Returns null if the first
478
+ * quoted token is not a 22-char GlobalId.
479
+ */
480
+ readLeadingGuid(entityText) {
481
+ const open = entityText.indexOf('(');
482
+ if (open === -1)
483
+ return null;
484
+ const q1 = entityText.indexOf("'", open + 1);
485
+ if (q1 === -1)
486
+ return null;
487
+ const q2 = entityText.indexOf("'", q1 + 1);
488
+ if (q2 === -1)
489
+ return null;
490
+ const raw = entityText.slice(q1 + 1, q2);
491
+ return GLOBAL_ID_RE.test(raw) ? raw : null;
492
+ }
493
+ /**
494
+ * Mint a fresh, deterministic, collision-free GlobalId for an entity whose id
495
+ * collides. Seeded from the original GlobalId and the model's stable id so the
496
+ * output is reproducible and does not churn when an unrelated earlier model
497
+ * changes size; checked against both already-emitted ids and the ids minted
498
+ * so far for this model.
499
+ */
500
+ mintUniqueGuid(original, modelId, guidToFinalId, pendingMinted) {
501
+ let candidate = deterministicGlobalId(`${original}#${modelId}`);
502
+ let n = 0;
503
+ while (guidToFinalId.has(candidate) || pendingMinted.has(candidate)) {
504
+ candidate = deterministicGlobalId(`${original}#${modelId}#${n++}`);
505
+ }
506
+ pendingMinted.add(candidate);
507
+ return candidate;
508
+ }
509
+ /**
510
+ * Read an entity's GlobalId (first attribute) by decoding only its head.
511
+ * Returns the 22-char id for a rooted entity, or `null` for any entity whose
512
+ * first attribute is not a GlobalId (geometry, lists, property atoms, …).
513
+ */
514
+ extractGlobalIdFast(ref, source) {
515
+ // Non-rooted resource entities (property/quantity/material/style/actor …)
516
+ // lead with a Name string that can itself be 22 charset chars; never treat
517
+ // those as a GlobalId or reconciliation would drop/rename them.
518
+ if (NON_ROOTED_STRING_TYPES.has((ref.type ?? '').toUpperCase()))
519
+ return null;
520
+ // 128 bytes comfortably spans `#<id>=<LONGEST_TYPE_NAME>('<22-char id>'`,
521
+ // so the GlobalId is always fully inside the window.
522
+ const end = Math.min(ref.byteOffset + 128, ref.byteOffset + ref.byteLength);
523
+ const head = safeUtf8Decode(source, ref.byteOffset, end);
524
+ const open = head.indexOf('(');
525
+ if (open === -1)
526
+ return null;
527
+ let i = open + 1;
528
+ while (i < head.length && (head[i] === ' ' || head[i] === '\t' || head[i] === '\n' || head[i] === '\r'))
529
+ i++;
530
+ if (head[i] !== "'")
531
+ return null;
532
+ // A GlobalId never contains a quote (charset excludes it), so the next
533
+ // quote closes it.
534
+ const close = head.indexOf("'", i + 1);
535
+ if (close === -1)
536
+ return null;
537
+ const raw = head.slice(i + 1, close);
538
+ return GLOBAL_ID_RE.test(raw) ? raw : null;
539
+ }
540
+ /**
541
+ * Replace an entity's GlobalId (first quoted attribute) with `newGuid`.
542
+ * `newGuid` is a 22-char IFC id (no quote in its charset), so this is safe.
543
+ */
544
+ replaceGlobalId(entityText, newGuid) {
545
+ const open = entityText.indexOf('(');
546
+ if (open === -1)
547
+ return entityText;
548
+ const q1 = entityText.indexOf("'", open + 1);
549
+ if (q1 === -1)
550
+ return entityText;
551
+ const q2 = entityText.indexOf("'", q1 + 1);
552
+ if (q2 === -1)
553
+ return entityText;
554
+ return entityText.slice(0, q1 + 1) + newGuid + entityText.slice(q2);
555
+ }
314
556
  /**
315
557
  * Remap all #ID references in a STEP entity line.
316
558
  * Applies offset to all IDs, then overrides with specific remappings.