@ifc-lite/export 2.3.0 → 2.4.1

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.
@@ -9,6 +9,7 @@ import { convertStepLine, needsConversion } from './schema-converter.js';
9
9
  import { assembleStepBytes } from './step-serialization.js';
10
10
  import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
11
11
  import { StepExporter } from './step-exporter.js';
12
+ import { rescaleEntityLengths, computeNormalizeFactor } from './unit-normalize.js';
12
13
  /** Entity types forming shared infrastructure (deduplicated across models). */
13
14
  const SHARED_INFRASTRUCTURE_TYPES = new Set([
14
15
  'IFCUNITASSIGNMENT',
@@ -71,6 +72,14 @@ function isRelationshipType(typeUpper) {
71
72
  }
72
73
  /** Relative tolerance for comparing two length unit scale factors. */
73
74
  const UNIT_SCALE_TOLERANCE = 1e-6;
75
+ /** SI prefix multipliers, for resolving prefixed area/volume units (rarely used). */
76
+ const SI_PREFIX_MULTIPLIERS = {
77
+ ATTO: 1e-18, FEMTO: 1e-15, PICO: 1e-12, NANO: 1e-9, MICRO: 1e-6, MILLI: 1e-3,
78
+ CENTI: 1e-2, DECI: 1e-1, DECA: 1e1, HECTO: 1e2, KILO: 1e3, MEGA: 1e6,
79
+ GIGA: 1e9, TERA: 1e12, PETA: 1e15, EXA: 1e18,
80
+ };
81
+ /** Source schemas the IFC4 length registry does not fully cover (see #1475 review). */
82
+ const NORMALIZE_UNCOVERED_SCHEMAS = new Set(['IFC4X3', 'IFC5']);
74
83
  /**
75
84
  * True when a mutation view carries pending edits the exporter would bake.
76
85
  *
@@ -93,6 +102,19 @@ function viewHasMutations(view) {
93
102
  const created = typeof view.getNewEntities === 'function' ? view.getNewEntities() : [];
94
103
  return created.length > 0;
95
104
  }
105
+ /**
106
+ * Drop models with no usable source (nothing to emit): a cache-restored or
107
+ * metadata-only store can reach the merge with an empty `.source`. The emit loop
108
+ * already skips them, but the primary model (`models[0]`) and the unit/offset
109
+ * setup must be computed over the SAME set — otherwise an empty model at index 0
110
+ * would poison the primary unit/scale that later models normalize against. When
111
+ * every model is empty the original list is kept so a valid (empty) file is still
112
+ * produced rather than throwing.
113
+ */
114
+ function withUsableSource(models) {
115
+ const usable = models.filter(m => m.dataStore.source && m.dataStore.source.length > 0);
116
+ return usable.length > 0 ? usable : models;
117
+ }
96
118
  /**
97
119
  * Merges multiple IFC models into a single STEP file.
98
120
  *
@@ -116,14 +138,15 @@ function viewHasMutations(view) {
116
138
  * fresh deterministic GlobalId so the file has no duplicate-GlobalId errors
117
139
  * and no relationship membership is lost.
118
140
  *
119
- * Conformance trade-off: when federation triggers, the file contains more than
120
- * one IfcProject, which intentionally relaxes the IfcSingleProjectInstance
121
- * EXPRESS rule (SIZEOF(IfcProject) <= 1). This is the only way to keep two
122
- * different length units in one STEP file without rewriting every length-valued
123
- * coordinate, and it is strictly better than the previous silent mis-scale.
124
- * `MergeExportResult.stats.warnings` flags it; pass
125
- * `unitReconciliation: 'assume-shared'` to force a single project when units
126
- * are already normalised.
141
+ * Conformance trade-off: when federation triggers (under the default `'auto'`),
142
+ * the file contains more than one IfcProject, which intentionally relaxes the
143
+ * IfcSingleProjectInstance EXPRESS rule (SIZEOF(IfcProject) <= 1). This preserves
144
+ * both units without rewriting coordinates, and is strictly better than a silent
145
+ * mis-scale. `MergeExportResult.stats.warnings` flags it. To instead get one
146
+ * ordinary single-unit IfcProject, pass `unitReconciliation: 'normalize'` it
147
+ * rescales every length-valued datum of the differing-unit models into the first
148
+ * model's unit (see {@link ./unit-normalize.ts}). Use `'assume-shared'` only when
149
+ * the caller has already normalised units.
127
150
  *
128
151
  * Limitation: federation only unifies a model against the *first* model's unit
129
152
  * group. Two non-first models that share a unit different from the first are
@@ -147,7 +170,7 @@ export class MergedExporter {
147
170
  throw new Error('MergedExporter.export() cannot apply pending edits — baking needs the async parser. ' +
148
171
  'Use exportAsync() for merged export with mutations.');
149
172
  }
150
- const models = this.models;
173
+ const models = withUsableSource(this.models);
151
174
  const setup = this.buildMergeSetup(options, models);
152
175
  const allEntityLines = [];
153
176
  // Tracks every GlobalId already emitted → its final express id + unit scale,
@@ -156,6 +179,8 @@ export class MergedExporter {
156
179
  const guidToFinalId = new Map();
157
180
  let isFirstModel = true;
158
181
  let federatedModelCount = 0;
182
+ let normalizedModelCount = 0;
183
+ const normalizeWarnings = new Set();
159
184
  for (const model of models) {
160
185
  const offset = setup.modelOffsets.get(model.id);
161
186
  const source = model.dataStore.source;
@@ -165,19 +190,21 @@ export class MergedExporter {
165
190
  // walk and the emit loop both reach every entity the source defines.
166
191
  const completeIndex = getCompleteEntityIndex(model.dataStore);
167
192
  const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
168
- const modelScale = this.resolveUnitScale(model);
169
- const compatible = isFirstModel || setup.assumeShared
170
- || this.unitsCompatible(modelScale, setup.primaryScale);
171
- if (!isFirstModel && !compatible)
193
+ const mode = this.resolveModelMode(model, isFirstModel, setup);
194
+ if (!isFirstModel && !mode.compatible)
172
195
  federatedModelCount++;
173
- const plan = this.planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId);
196
+ if (mode.normalized) {
197
+ normalizedModelCount++;
198
+ this.collectNormalizeCaveats(model, normalizeWarnings);
199
+ }
200
+ const plan = this.planModel(model, completeIndex, isFirstModel, mode.compatible, mode.lengthFactor, setup, guidToFinalId);
174
201
  const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
175
202
  for (const [expressId, entityRef] of completeIndex) {
176
203
  if (includedEntityIds !== null && !includedEntityIds.has(expressId))
177
204
  continue;
178
205
  if (plan.skipEntityIds.has(expressId))
179
206
  continue;
180
- const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, modelScale);
207
+ const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode);
181
208
  if (line !== null)
182
209
  allEntityLines.push(line);
183
210
  }
@@ -189,7 +216,7 @@ export class MergedExporter {
189
216
  const content = assembleStepBytes(header, allEntityLines);
190
217
  return {
191
218
  content,
192
- stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
219
+ stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount, normalizedModelCount, normalizeWarnings),
193
220
  };
194
221
  }
195
222
  /**
@@ -206,7 +233,7 @@ export class MergedExporter {
206
233
  // Bake each model's pending edits into its source bytes before merging, so
207
234
  // federated export round-trips mutations like single-model export. Models
208
235
  // without edits pass through unchanged (no export/parse cost).
209
- const models = await this.bakeMutatedModels();
236
+ const models = withUsableSource(await this.bakeMutatedModels());
210
237
  const setup = this.buildMergeSetup(options, models);
211
238
  const allEntityLines = [];
212
239
  const guidToFinalId = new Map();
@@ -218,6 +245,8 @@ export class MergedExporter {
218
245
  let isFirstModel = true;
219
246
  let entitiesProcessed = 0;
220
247
  let federatedModelCount = 0;
248
+ let normalizedModelCount = 0;
249
+ const normalizeWarnings = new Set();
221
250
  const YIELD_INTERVAL = 2000;
222
251
  if (onProgress)
223
252
  onProgress({ phase: 'preparing', percent: 0, entitiesProcessed: 0, entitiesTotal: totalEntities });
@@ -237,12 +266,14 @@ export class MergedExporter {
237
266
  }
238
267
  const completeIndex = getCompleteEntityIndex(model.dataStore);
239
268
  const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
240
- const modelScale = this.resolveUnitScale(model);
241
- const compatible = isFirstModel || setup.assumeShared
242
- || this.unitsCompatible(modelScale, setup.primaryScale);
243
- if (!isFirstModel && !compatible)
269
+ const mode = this.resolveModelMode(model, isFirstModel, setup);
270
+ if (!isFirstModel && !mode.compatible)
244
271
  federatedModelCount++;
245
- const plan = this.planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId);
272
+ if (mode.normalized) {
273
+ normalizedModelCount++;
274
+ this.collectNormalizeCaveats(model, normalizeWarnings);
275
+ }
276
+ const plan = this.planModel(model, completeIndex, isFirstModel, mode.compatible, mode.lengthFactor, setup, guidToFinalId);
246
277
  const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
247
278
  let entityCount = 0;
248
279
  for (const [expressId, entityRef] of completeIndex) {
@@ -250,7 +281,7 @@ export class MergedExporter {
250
281
  continue;
251
282
  if (plan.skipEntityIds.has(expressId))
252
283
  continue;
253
- const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, modelScale);
284
+ const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode);
254
285
  if (line !== null)
255
286
  allEntityLines.push(line);
256
287
  entityCount++;
@@ -282,7 +313,7 @@ export class MergedExporter {
282
313
  }
283
314
  return {
284
315
  content,
285
- stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
316
+ stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount, normalizedModelCount, normalizeWarnings),
286
317
  };
287
318
  }
288
319
  /**
@@ -331,17 +362,37 @@ export class MergedExporter {
331
362
  /**
332
363
  * Assemble the result stats, including any federation conformance warnings.
333
364
  */
334
- buildStats(totalEntityCount, fileSize, federatedModelCount) {
365
+ buildStats(totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, normalizeWarnings) {
335
366
  const warnings = [];
336
367
  if (federatedModelCount > 0) {
337
368
  warnings.push(`${federatedModelCount} model(s) had a length unit differing from the first model and were ` +
338
369
  `federated as separate IfcProject roots to keep their geometry correctly scaled. The output ` +
339
370
  `therefore contains ${federatedModelCount + 1} IfcProject instances, which intentionally relaxes ` +
340
371
  `the IfcSingleProjectInstance rule (SIZEOF(IfcProject) <= 1). Some single-project viewers may ` +
341
- `only show the first project. Pass unitReconciliation:'assume-shared' to force one project when ` +
342
- `units are already normalised.`);
372
+ `only show the first project. Pass unitReconciliation:'normalize' to rescale them into one ` +
373
+ `single-unit project, or 'assume-shared' when units are already normalised.`);
374
+ }
375
+ warnings.push(...normalizeWarnings);
376
+ return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, warnings };
377
+ }
378
+ /**
379
+ * Record advisories for a model being normalized. The rescaler derives its
380
+ * length-attribute map from the IFC4 schema registry, so it may not cover
381
+ * length attributes introduced by newer schemas (IFC4X3 alignment / linear
382
+ * referencing), and it deliberately leaves georeferencing untouched.
383
+ */
384
+ collectNormalizeCaveats(model, warnings) {
385
+ const schema = (model.dataStore.schemaVersion ?? '').toUpperCase();
386
+ if (NORMALIZE_UNCOVERED_SCHEMAS.has(schema)) {
387
+ warnings.add(`Model "${model.name}" (${schema}) was normalized using the IFC4 length-attribute schema; ` +
388
+ `length values on ${schema}-specific entities (e.g. alignment / linear-referencing segment ` +
389
+ `lengths and radii) may not have been rescaled. Verify infrastructure geometry.`);
390
+ }
391
+ if (this.findEntitiesByType(model.dataStore, 'IFCMAPCONVERSION').length > 0) {
392
+ warnings.add(`Model "${model.name}" carries georeferencing (IfcMapConversion), which normalize leaves ` +
393
+ `untouched. If the model was georeferenced in its own unit, review the merged coordinate ` +
394
+ `operation.`);
343
395
  }
344
- return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, warnings };
345
396
  }
346
397
  /**
347
398
  * Build the ifc-lite provenance header. Merged files have no single source
@@ -374,16 +425,126 @@ export class MergedExporter {
374
425
  nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
375
426
  }
376
427
  const firstModel = models[0];
428
+ const primaryScale = this.resolveUnitScale(firstModel);
377
429
  return {
378
430
  modelOffsets,
379
431
  firstModelOffset: modelOffsets.get(firstModel.id),
380
432
  firstModelInfraMap: this.findInfrastructureEntities(firstModel.dataStore),
381
433
  firstProjectIds: this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT'),
382
434
  spatialLookup: this.buildSpatialLookup(firstModel.dataStore),
383
- primaryScale: this.resolveUnitScale(firstModel),
435
+ primaryScale,
436
+ primaryAreaScale: this.resolveDerivedUnitScale(firstModel.dataStore, 'AREAUNIT', primaryScale, 2),
437
+ primaryVolumeScale: this.resolveDerivedUnitScale(firstModel.dataStore, 'VOLUMEUNIT', primaryScale, 3),
384
438
  assumeShared: options.unitReconciliation === 'assume-shared',
439
+ normalize: options.unitReconciliation === 'normalize',
440
+ mergeSites: options.mergeSites,
441
+ mergeBuildings: options.mergeBuildings,
442
+ mergeStoreys: options.mergeStoreys,
385
443
  };
386
444
  }
445
+ /**
446
+ * Decide how one model folds into the merge from its length unit and the
447
+ * reconciliation mode.
448
+ *
449
+ * - The primary model, `assume-shared`, and any model that already shares the
450
+ * primary unit are unified with no rescale.
451
+ * - Under `normalize`, a differing-unit model is unified *and* rescaled: every
452
+ * length-valued datum is multiplied by `primaryScale`-relative factor so its
453
+ * geometry stays correct under the single shared unit.
454
+ * - Otherwise (`auto`) a differing-unit model is federated (kept as its own
455
+ * project + units), leaving its raw coordinates untouched.
456
+ *
457
+ * Area/volume reconciliation is gated on the length unit differing: a model that
458
+ * shares the primary's length unit is treated as fully compatible (factors 1).
459
+ * A model that pairs a matching length unit with a *divergent* area/volume unit
460
+ * (a non-conformant combination no mainstream exporter emits) is not rescaled.
461
+ */
462
+ resolveModelMode(model, isFirstModel, setup) {
463
+ const modelScale = this.resolveUnitScale(model);
464
+ if (isFirstModel || setup.assumeShared || this.unitsCompatible(modelScale, setup.primaryScale)) {
465
+ return { compatible: true, lengthFactor: 1, areaFactor: 1, volumeFactor: 1, effectiveScale: modelScale, normalized: false };
466
+ }
467
+ if (setup.normalize) {
468
+ // Each dimension is converted by the ratio of its own declared unit, not by
469
+ // powers of the length factor — IFC declares area/volume units independently
470
+ // (Revit: millimetre lengths but square-/cubic-metre areas/volumes).
471
+ const modelArea = this.resolveDerivedUnitScale(model.dataStore, 'AREAUNIT', modelScale, 2);
472
+ const modelVolume = this.resolveDerivedUnitScale(model.dataStore, 'VOLUMEUNIT', modelScale, 3);
473
+ return {
474
+ compatible: true,
475
+ lengthFactor: computeNormalizeFactor(modelScale, setup.primaryScale),
476
+ areaFactor: computeNormalizeFactor(modelArea, setup.primaryAreaScale),
477
+ volumeFactor: computeNormalizeFactor(modelVolume, setup.primaryVolumeScale),
478
+ effectiveScale: setup.primaryScale,
479
+ normalized: true,
480
+ };
481
+ }
482
+ // auto: federate the differing-unit model, coordinates untouched.
483
+ return { compatible: false, lengthFactor: 1, areaFactor: 1, volumeFactor: 1, effectiveScale: modelScale, normalized: false };
484
+ }
485
+ /**
486
+ * Resolve a model's declared AREAUNIT / VOLUMEUNIT scale (SI m² / m³ per unit)
487
+ * by walking IfcProject → IfcUnitAssignment. Falls back to the length-derived
488
+ * unit (`lengthScale ** power`) when the model declares no explicit area/volume
489
+ * unit — the IFC default. A prefixed SI area/volume unit (rare) applies the
490
+ * prefix once (buildingSMART / IfcOpenShell convention).
491
+ */
492
+ resolveDerivedUnitScale(dataStore, wantType, lengthScale, power) {
493
+ const fallback = Math.pow(lengthScale, power);
494
+ const projectIds = this.findEntitiesByType(dataStore, 'IFCPROJECT');
495
+ if (projectIds.length === 0)
496
+ return fallback;
497
+ // IfcProject.UnitsInContext = attr 8 → IfcUnitAssignment.
498
+ const unitsAttr = this.extractStepAttribute(projectIds[0], dataStore, 8);
499
+ const assignMatch = unitsAttr?.match(/^#(\d+)$/);
500
+ if (!assignMatch)
501
+ return fallback;
502
+ // IfcUnitAssignment.Units = attr 0 (a list of unit refs).
503
+ const listAttr = this.extractStepAttribute(parseInt(assignMatch[1], 10), dataStore, 0);
504
+ if (!listAttr)
505
+ return fallback;
506
+ for (const m of listAttr.matchAll(/#(\d+)/g)) {
507
+ const uid = parseInt(m[1], 10);
508
+ const uref = dataStore.entityIndex.byId.get(uid);
509
+ const utype = (uref?.type ?? '').toUpperCase();
510
+ if (utype === 'IFCSIUNIT') {
511
+ // IfcSIUnit: [1] UnitType, [2] Prefix, [3] Name.
512
+ if (this.normalizeEnum(this.extractStepAttribute(uid, dataStore, 1)) !== wantType)
513
+ continue;
514
+ const prefixRaw = this.extractStepAttribute(uid, dataStore, 2);
515
+ if (!prefixRaw || prefixRaw === '$' || prefixRaw === '*')
516
+ return 1.0; // square/cubic metre
517
+ const mult = SI_PREFIX_MULTIPLIERS[this.normalizeEnum(prefixRaw)];
518
+ return mult !== undefined ? mult : 1.0;
519
+ }
520
+ if (utype === 'IFCCONVERSIONBASEDUNIT') {
521
+ // IfcConversionBasedUnit: [1] UnitType, [2] Name, [3] ConversionFactor.
522
+ if (this.normalizeEnum(this.extractStepAttribute(uid, dataStore, 1)) !== wantType)
523
+ continue;
524
+ const convMatch = this.extractStepAttribute(uid, dataStore, 3)?.match(/^#(\d+)$/);
525
+ if (convMatch) {
526
+ // IfcMeasureWithUnit.ValueComponent = attr 0 (e.g. IFCAREAMEASURE(0.0929)).
527
+ const num = this.parseMeasureNumber(this.extractStepAttribute(parseInt(convMatch[1], 10), dataStore, 0));
528
+ if (num !== undefined && num > 0)
529
+ return num;
530
+ }
531
+ return fallback;
532
+ }
533
+ }
534
+ return fallback;
535
+ }
536
+ /** Uppercase an enum token, stripping the STEP `.ENUM.` dots. `''` for nullish. */
537
+ normalizeEnum(raw) {
538
+ return (raw ?? '').replace(/\./g, '').trim().toUpperCase();
539
+ }
540
+ /** Extract the number from a bare real or a typed measure token (`IFCAREAMEASURE(0.09)`). */
541
+ parseMeasureNumber(raw) {
542
+ if (!raw)
543
+ return undefined;
544
+ const typed = raw.match(/\(([^)]*)\)\s*$/);
545
+ const n = Number((typed ? typed[1] : raw).trim());
546
+ return Number.isFinite(n) ? n : undefined;
547
+ }
387
548
  /**
388
549
  * Resolve a model's length unit scale (raw IFC length → metres). Prefers an
389
550
  * explicit `lengthUnitScale` on the input, else the value the parser stamped
@@ -429,10 +590,10 @@ export class MergedExporter {
429
590
  * Plan how a model's entities are remapped, skipped, or re-stamped, given
430
591
  * whether it shares the primary model's length unit (`compatible`).
431
592
  *
432
- * Compatible (or `assume-shared`) models are unified into the primary project:
433
- * their IfcProject, shared infrastructure, and matching spatial structure are
434
- * deduplicated, and a rooted entity repeating an already-emitted GlobalId is
435
- * unified to that one instance.
593
+ * Compatible models (same unit, `assume-shared`, or `normalize`d into the
594
+ * primary unit) are unified into the primary project: their IfcProject, shared
595
+ * infrastructure, and matching spatial structure are deduplicated, and a rooted
596
+ * entity repeating an already-emitted GlobalId is unified to that one instance.
436
597
  *
437
598
  * Incompatible (federated) models keep their own project, units, contexts and
438
599
  * spatial structure so their coordinates stay correctly scaled; a rooted
@@ -440,7 +601,7 @@ export class MergedExporter {
440
601
  * deterministic GlobalId, since the two cannot be the same instance across
441
602
  * different unit spaces.
442
603
  */
443
- planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId) {
604
+ planModel(model, completeIndex, isFirstModel, compatible, lengthFactor, setup, guidToFinalId) {
444
605
  const source = model.dataStore.source;
445
606
  const sharedRemap = new Map();
446
607
  const skipEntityIds = new Set();
@@ -471,7 +632,9 @@ export class MergedExporter {
471
632
  }
472
633
  }
473
634
  // Unify spatial hierarchy: match Site, Building, Storey to first model.
474
- this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, sharedRemap, skipEntityIds);
635
+ // Under normalize, this model's raw elevations are in its own unit, so the
636
+ // elevation match is done in the primary unit (rawElevation * lengthFactor).
637
+ this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, lengthFactor, sharedRemap, skipEntityIds, setup);
475
638
  // Skip IfcRelAggregates that become fully redundant after unification.
476
639
  this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
477
640
  }
@@ -513,7 +676,7 @@ export class MergedExporter {
513
676
  * and register the emitted GlobalId so later models can reconcile against it.
514
677
  * Returns `null` when schema conversion drops the entity.
515
678
  */
516
- renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, modelScale) {
679
+ renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, mode) {
517
680
  const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
518
681
  // Remap ids. Fast path: the first model (offset 0, no remaps) is byte-identical.
519
682
  let finalText;
@@ -528,6 +691,13 @@ export class MergedExporter {
528
691
  if (mintedGuid !== undefined) {
529
692
  finalText = this.replaceGlobalId(finalText, mintedGuid);
530
693
  }
694
+ // Normalize units: rescale every length/area/volume-valued datum into the
695
+ // primary unit. Done on the SOURCE-schema text (entityRef.type), before any
696
+ // schema conversion, so the schema-derived attribute indices line up. Touches
697
+ // only numeric literals, so id remaps and the GlobalId re-stamp above are safe.
698
+ if (mode.lengthFactor !== 1 || mode.areaFactor !== 1 || mode.volumeFactor !== 1) {
699
+ finalText = rescaleEntityLengths(finalText, entityRef.type.toUpperCase(), mode.lengthFactor, mode.areaFactor, mode.volumeFactor);
700
+ }
531
701
  if (needsConversion(sourceSchema, targetSchema)) {
532
702
  const converted = convertStepLine(finalText, sourceSchema, targetSchema);
533
703
  if (converted === null)
@@ -544,7 +714,7 @@ export class MergedExporter {
544
714
  const emittedGuid = this.readLeadingGuid(finalText)
545
715
  ?? mintedGuid ?? plan.localGuids.get(localId);
546
716
  if (emittedGuid !== undefined) {
547
- guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale: modelScale });
717
+ guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale: mode.effectiveScale });
548
718
  }
549
719
  }
550
720
  return finalText;
@@ -746,58 +916,62 @@ export class MergedExporter {
746
916
  * to the first model's equivalents. Matched entities are remapped and
747
917
  * their duplicate entity is skipped from output.
748
918
  *
749
- * Matching strategy:
750
- * - Sites/Buildings: by name (case-insensitive), or if only one in each model
751
- * - Storeys: by name first, then by elevation (tolerance ±0.5 model units)
919
+ * Matching strategy per container type is driven by
920
+ * {@link MergeExportOptions.mergeSites} / `mergeBuildings` / `mergeStoreys`
921
+ * (all optional; omitted keeps the pre-existing combined heuristic):
922
+ * - Sites/Buildings: `'single'` (ignore name, unify iff exactly one in each
923
+ * model), `'by-name'` (name only, no fallback), or omitted — name first,
924
+ * else single-instance fallback.
925
+ * - Storeys: `'by-name'`, `'by-elevation'` (tolerance ±0.5 model units), or
926
+ * `'by-name-then-elevation'` (default, also the omitted behavior).
752
927
  */
753
- unifySpatialEntities(dataStore, lookup, firstModelOffset, sharedRemap, skipEntityIds) {
754
- // Unify IfcSite
928
+ unifySpatialEntities(dataStore, lookup, firstModelOffset, elevationFactor, sharedRemap, skipEntityIds, mergeModes) {
929
+ // Unify IfcSite. matchedFirstSites guards against two of this model's
930
+ // sites (e.g. duplicate/identically-named) both matching the same
931
+ // first-model target — only the first claims it, the second is kept as
932
+ // its own root instead of silently losing its spatial sub-tree.
755
933
  const sites = this.findEntitiesByType(dataStore, 'IFCSITE');
934
+ const matchedFirstSites = new Set();
756
935
  for (const id of sites) {
757
- const name = this.extractEntityName(id, dataStore);
758
- let match;
759
- if (name)
760
- match = lookup.sitesByName.get(name.toLowerCase());
761
- // If single site in both models, unify regardless of name
762
- if (match === undefined && sites.length === 1 && lookup.siteIds.length === 1) {
763
- match = lookup.siteIds[0];
764
- }
936
+ const match = this.matchRootContainer(id, dataStore, mergeModes.mergeSites, sites.length, lookup.sitesByName, lookup.siteIds, matchedFirstSites);
765
937
  if (match !== undefined) {
938
+ matchedFirstSites.add(match);
766
939
  sharedRemap.set(id, match + firstModelOffset);
767
940
  skipEntityIds.add(id);
768
941
  }
769
942
  }
770
- // Unify IfcBuilding
943
+ // Unify IfcBuilding — same already-matched guard as sites.
771
944
  const buildings = this.findEntitiesByType(dataStore, 'IFCBUILDING');
945
+ const matchedFirstBuildings = new Set();
772
946
  for (const id of buildings) {
773
- const name = this.extractEntityName(id, dataStore);
774
- let match;
775
- if (name)
776
- match = lookup.buildingsByName.get(name.toLowerCase());
777
- if (match === undefined && buildings.length === 1 && lookup.buildingIds.length === 1) {
778
- match = lookup.buildingIds[0];
779
- }
947
+ const match = this.matchRootContainer(id, dataStore, mergeModes.mergeBuildings, buildings.length, lookup.buildingsByName, lookup.buildingIds, matchedFirstBuildings);
780
948
  if (match !== undefined) {
949
+ matchedFirstBuildings.add(match);
781
950
  sharedRemap.set(id, match + firstModelOffset);
782
951
  skipEntityIds.add(id);
783
952
  }
784
953
  }
785
- // Unify IfcBuildingStorey — name match first, then elevation fallback
954
+ // Unify IfcBuildingStorey — mode-driven name/elevation matching
955
+ const storeyMode = mergeModes.mergeStoreys ?? 'by-name-then-elevation';
786
956
  const matchedFirstStoreys = new Set();
787
957
  for (const id of this.findEntitiesByType(dataStore, 'IFCBUILDINGSTOREY')) {
788
958
  const name = this.extractEntityName(id, dataStore);
789
959
  let match;
790
- // Try name match
791
- if (name) {
960
+ // Name match (skipped entirely under 'by-elevation')
961
+ if (storeyMode !== 'by-elevation' && name) {
792
962
  const candidate = lookup.storeysByName.get(name.toLowerCase());
793
963
  if (candidate !== undefined && !matchedFirstStoreys.has(candidate)) {
794
964
  match = candidate;
795
965
  }
796
966
  }
797
- // Fallback: match by elevation
798
- if (match === undefined) {
799
- const elevation = this.extractStoreyElevation(id, dataStore);
800
- if (elevation !== undefined) {
967
+ // Elevation match (skipped entirely under 'by-name'). The candidate's raw
968
+ // elevation is in this model's unit; scale it into the primary unit so the
969
+ // comparison (and the ±0.5 m tolerance) is unit-consistent under normalize
970
+ // (factor 1 otherwise).
971
+ if (match === undefined && storeyMode !== 'by-name') {
972
+ const rawElevation = this.extractStoreyElevation(id, dataStore);
973
+ if (rawElevation !== undefined) {
974
+ const elevation = rawElevation * elevationFactor;
801
975
  for (const entry of lookup.storeysByElevation) {
802
976
  if (matchedFirstStoreys.has(entry.expressId))
803
977
  continue;
@@ -816,6 +990,39 @@ export class MergedExporter {
816
990
  }
817
991
  }
818
992
  }
993
+ /**
994
+ * Match one IfcSite/IfcBuilding instance against the first model's
995
+ * equivalents, per {@link mergeMode}:
996
+ * - `'single'`: ignore name — unify iff both models contribute exactly one.
997
+ * - `'by-name'`: name match only, no single-instance fallback.
998
+ * - omitted: name match, else single-instance fallback (pre-existing heuristic).
999
+ *
1000
+ * `matchedFirst` excludes first-model targets already claimed by an earlier
1001
+ * entity in this same model's loop — without it, two of this model's sites
1002
+ * (or buildings) sharing a name/being the sole instance would both resolve
1003
+ * to the same target, and the second would be dropped (skipped + remapped)
1004
+ * rather than kept as its own root.
1005
+ */
1006
+ matchRootContainer(id, dataStore, mergeMode, countInThisModel, firstModelByName, firstModelIds, matchedFirst) {
1007
+ const bySingle = () => {
1008
+ if (countInThisModel !== 1 || firstModelIds.length !== 1)
1009
+ return undefined;
1010
+ const candidate = firstModelIds[0];
1011
+ return matchedFirst.has(candidate) ? undefined : candidate;
1012
+ };
1013
+ const byName = () => {
1014
+ const name = this.extractEntityName(id, dataStore);
1015
+ if (!name)
1016
+ return undefined;
1017
+ const candidate = firstModelByName.get(name.toLowerCase());
1018
+ return candidate !== undefined && !matchedFirst.has(candidate) ? candidate : undefined;
1019
+ };
1020
+ if (mergeMode === 'single')
1021
+ return bySingle();
1022
+ if (mergeMode === 'by-name')
1023
+ return byName();
1024
+ return byName() ?? bySingle();
1025
+ }
819
1026
  /**
820
1027
  * Skip IfcRelAggregates that become fully redundant after spatial unification.
821
1028
  *