@ifc-lite/export 3.0.1 → 4.0.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.
Files changed (163) hide show
  1. package/README.md +37 -1
  2. package/dist/anonymize-export.d.ts +60 -0
  3. package/dist/anonymize-export.d.ts.map +1 -0
  4. package/dist/anonymize-export.js +217 -0
  5. package/dist/anonymize-export.js.map +1 -0
  6. package/dist/anonymize-placement.d.ts +71 -0
  7. package/dist/anonymize-placement.d.ts.map +1 -0
  8. package/dist/anonymize-placement.js +266 -0
  9. package/dist/anonymize-placement.js.map +1 -0
  10. package/dist/anonymize-scrub.d.ts +64 -0
  11. package/dist/anonymize-scrub.d.ts.map +1 -0
  12. package/dist/anonymize-scrub.js +315 -0
  13. package/dist/anonymize-scrub.js.map +1 -0
  14. package/dist/anonymize-types.d.ts +235 -0
  15. package/dist/anonymize-types.d.ts.map +1 -0
  16. package/dist/anonymize-types.js +5 -0
  17. package/dist/anonymize-types.js.map +1 -0
  18. package/dist/declared-property-type.d.ts +15 -0
  19. package/dist/declared-property-type.d.ts.map +1 -1
  20. package/dist/declared-property-type.js +43 -0
  21. package/dist/declared-property-type.js.map +1 -1
  22. package/dist/entity-type-sets.d.ts +47 -0
  23. package/dist/entity-type-sets.d.ts.map +1 -0
  24. package/dist/entity-type-sets.js +91 -0
  25. package/dist/entity-type-sets.js.map +1 -0
  26. package/dist/georef-closure.d.ts +55 -0
  27. package/dist/georef-closure.d.ts.map +1 -0
  28. package/dist/georef-closure.js +95 -0
  29. package/dist/georef-closure.js.map +1 -0
  30. package/dist/glb.d.ts.map +1 -1
  31. package/dist/glb.js +26 -2
  32. package/dist/glb.js.map +1 -1
  33. package/dist/ifc5-classification.d.ts +36 -0
  34. package/dist/ifc5-classification.d.ts.map +1 -0
  35. package/dist/ifc5-classification.js +46 -0
  36. package/dist/ifc5-classification.js.map +1 -0
  37. package/dist/ifc5-export-helpers.d.ts +32 -0
  38. package/dist/ifc5-export-helpers.d.ts.map +1 -0
  39. package/dist/ifc5-export-helpers.js +84 -0
  40. package/dist/ifc5-export-helpers.js.map +1 -0
  41. package/dist/ifc5-exporter.d.ts.map +1 -1
  42. package/dist/ifc5-exporter.js +14 -86
  43. package/dist/ifc5-exporter.js.map +1 -1
  44. package/dist/ifc5-material.d.ts +38 -0
  45. package/dist/ifc5-material.d.ts.map +1 -0
  46. package/dist/ifc5-material.js +32 -0
  47. package/dist/ifc5-material.js.map +1 -0
  48. package/dist/index.d.ts +5 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +7 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/merged-context.d.ts +100 -0
  53. package/dist/merged-context.d.ts.map +1 -0
  54. package/dist/merged-context.js +211 -0
  55. package/dist/merged-context.js.map +1 -0
  56. package/dist/merged-empty-containers.d.ts +63 -0
  57. package/dist/merged-empty-containers.d.ts.map +1 -0
  58. package/dist/merged-empty-containers.js +354 -0
  59. package/dist/merged-empty-containers.js.map +1 -0
  60. package/dist/merged-exporter.d.ts +28 -67
  61. package/dist/merged-exporter.d.ts.map +1 -1
  62. package/dist/merged-exporter.js +134 -258
  63. package/dist/merged-exporter.js.map +1 -1
  64. package/dist/merged-guid.d.ts +48 -0
  65. package/dist/merged-guid.d.ts.map +1 -0
  66. package/dist/merged-guid.js +128 -0
  67. package/dist/merged-guid.js.map +1 -0
  68. package/dist/merged-rel-aggregates.d.ts +63 -0
  69. package/dist/merged-rel-aggregates.d.ts.map +1 -0
  70. package/dist/merged-rel-aggregates.js +122 -0
  71. package/dist/merged-rel-aggregates.js.map +1 -0
  72. package/dist/merged-remap.d.ts +18 -0
  73. package/dist/merged-remap.d.ts.map +1 -0
  74. package/dist/merged-remap.js +66 -0
  75. package/dist/merged-remap.js.map +1 -0
  76. package/dist/merged-subcontext.d.ts +41 -0
  77. package/dist/merged-subcontext.d.ts.map +1 -0
  78. package/dist/merged-subcontext.js +99 -0
  79. package/dist/merged-subcontext.js.map +1 -0
  80. package/dist/parquet-exporter-ondemand.d.ts +34 -0
  81. package/dist/parquet-exporter-ondemand.d.ts.map +1 -0
  82. package/dist/parquet-exporter-ondemand.js +89 -0
  83. package/dist/parquet-exporter-ondemand.js.map +1 -0
  84. package/dist/parquet-exporter.d.ts +1 -29
  85. package/dist/parquet-exporter.d.ts.map +1 -1
  86. package/dist/parquet-exporter.js +68 -90
  87. package/dist/parquet-exporter.js.map +1 -1
  88. package/dist/parquet-type-strings.d.ts +11 -0
  89. package/dist/parquet-type-strings.d.ts.map +1 -0
  90. package/dist/parquet-type-strings.js +40 -0
  91. package/dist/parquet-type-strings.js.map +1 -0
  92. package/dist/parquet-uint32-columns.d.ts +30 -0
  93. package/dist/parquet-uint32-columns.d.ts.map +1 -0
  94. package/dist/parquet-uint32-columns.js +38 -0
  95. package/dist/parquet-uint32-columns.js.map +1 -0
  96. package/dist/reference-collector.d.ts +47 -51
  97. package/dist/reference-collector.d.ts.map +1 -1
  98. package/dist/reference-collector.js +115 -171
  99. package/dist/reference-collector.js.map +1 -1
  100. package/dist/related-entities.d.ts +10 -0
  101. package/dist/related-entities.d.ts.map +1 -0
  102. package/dist/related-entities.js +274 -0
  103. package/dist/related-entities.js.map +1 -0
  104. package/dist/schema-converter-attr-remap.d.ts +53 -0
  105. package/dist/schema-converter-attr-remap.d.ts.map +1 -0
  106. package/dist/schema-converter-attr-remap.js +106 -0
  107. package/dist/schema-converter-attr-remap.js.map +1 -0
  108. package/dist/schema-converter.d.ts.map +1 -1
  109. package/dist/schema-converter.js +24 -1
  110. package/dist/schema-converter.js.map +1 -1
  111. package/dist/schema-untranslatable.d.ts +35 -0
  112. package/dist/schema-untranslatable.d.ts.map +1 -0
  113. package/dist/schema-untranslatable.js +60 -0
  114. package/dist/schema-untranslatable.js.map +1 -0
  115. package/dist/step-argument-parser.d.ts.map +1 -1
  116. package/dist/step-argument-parser.js +11 -1
  117. package/dist/step-argument-parser.js.map +1 -1
  118. package/dist/step-attribute-mutations.js +14 -11
  119. package/dist/step-attribute-mutations.js.map +1 -1
  120. package/dist/step-collection.d.ts.map +1 -1
  121. package/dist/step-collection.js +51 -8
  122. package/dist/step-collection.js.map +1 -1
  123. package/dist/step-export-types.d.ts +42 -0
  124. package/dist/step-export-types.d.ts.map +1 -1
  125. package/dist/step-export-types.js +10 -0
  126. package/dist/step-export-types.js.map +1 -1
  127. package/dist/step-exporter.d.ts.map +1 -1
  128. package/dist/step-exporter.js +9 -1
  129. package/dist/step-exporter.js.map +1 -1
  130. package/dist/step-header.d.ts +1 -1
  131. package/dist/step-header.d.ts.map +1 -1
  132. package/dist/step-header.js +2 -2
  133. package/dist/step-header.js.map +1 -1
  134. package/dist/step-map-unit.d.ts +2 -1
  135. package/dist/step-map-unit.d.ts.map +1 -1
  136. package/dist/step-map-unit.js.map +1 -1
  137. package/dist/step-serialization.d.ts +10 -30
  138. package/dist/step-serialization.d.ts.map +1 -1
  139. package/dist/step-serialization.js +10 -60
  140. package/dist/step-serialization.js.map +1 -1
  141. package/dist/step-source-iteration.d.ts.map +1 -1
  142. package/dist/step-source-iteration.js +21 -0
  143. package/dist/step-source-iteration.js.map +1 -1
  144. package/dist/style-closure.d.ts +91 -0
  145. package/dist/style-closure.d.ts.map +1 -0
  146. package/dist/style-closure.js +164 -0
  147. package/dist/style-closure.js.map +1 -0
  148. package/dist/subset-entity-reader.d.ts +104 -0
  149. package/dist/subset-entity-reader.d.ts.map +1 -0
  150. package/dist/subset-entity-reader.js +112 -0
  151. package/dist/subset-entity-reader.js.map +1 -0
  152. package/dist/subset-roots.d.ts +74 -0
  153. package/dist/subset-roots.d.ts.map +1 -0
  154. package/dist/subset-roots.js +168 -0
  155. package/dist/subset-roots.js.map +1 -0
  156. package/dist/type-owned-psets.d.ts +19 -0
  157. package/dist/type-owned-psets.d.ts.map +1 -1
  158. package/dist/type-owned-psets.js +31 -0
  159. package/dist/type-owned-psets.js.map +1 -1
  160. package/dist/unit-normalize.d.ts.map +1 -1
  161. package/dist/unit-normalize.js +10 -2
  162. package/dist/unit-normalize.js.map +1 -1
  163. package/package.json +8 -8
@@ -1,14 +1,21 @@
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, deterministicGlobalId, IfcParser, asSourceBytes, getInheritanceChainAcrossSchemas, } from '@ifc-lite/parser';
4
+ import { generateHeader, IfcParser, asSourceBytes, } from '@ifc-lite/parser';
5
5
  import { decodeIfcString } from '@ifc-lite/encoding';
6
- import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities, filterHiddenRefsFromRelationshipLine, } from './reference-collector.js';
6
+ import { collectReferencedEntityIds, getVisibleEntityIds, filterHiddenRefsFromRelationshipLine, } from './reference-collector.js';
7
+ import { collectStyleEntities, STYLE_RESCUE_TYPES } from './style-closure.js';
8
+ import { collectGeoreferencingEntities } from './georef-closure.js';
7
9
  import { convertStepLine, needsConversion } from './schema-converter.js';
8
10
  import { assembleStepBytes, assembleStepBlob } from './step-file-assembly.js';
9
11
  import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
10
12
  import { StepExporter } from './step-exporter.js';
11
13
  import { rescaleEntityLengths, computeNormalizeFactor } from './unit-normalize.js';
14
+ import { planInfrastructureUnify, resolvePrimaryContextState } from './merged-context.js';
15
+ import { remapEntityText } from './merged-remap.js';
16
+ import { extractGlobalIdFast, mintUniqueGuid, readLeadingGuid, replaceGlobalId, } from './merged-guid.js';
17
+ import { planEmptyContainerDrops, EMPTY_MODEL_VIEW, } from './merged-empty-containers.js';
18
+ import { skipRedundantRelAggregates, applyRelAggregateStrip, collectRelAggregatePairs } from './merged-rel-aggregates.js';
12
19
  /**
13
20
  * UTF-8 decode of `[start, end)` of a model's source, accepting either the raw
14
21
  * bytes or the {@link IfcSourceBytes} accessor (#2183). Replaces the direct
@@ -25,38 +32,6 @@ const SHARED_INFRASTRUCTURE_TYPES = new Set([
25
32
  'IFCGEOMETRICREPRESENTATIONCONTEXT',
26
33
  'IFCGEOMETRICREPRESENTATIONSUBCONTEXT',
27
34
  ]);
28
- /**
29
- * An IfcGloballyUniqueId is exactly 22 characters of the buildingSMART base64
30
- * alphabet. We use this to recognise a rooted entity (IfcRoot subtype) by its
31
- * first attribute. Geometry/list entities never carry a string there, but some
32
- * non-rooted RESOURCE entities lead with a Name/Identifier string that can
33
- * legitimately be 22 charset chars (e.g. a coded property key). Those are
34
- * excluded with a schema-derived rootedness check ({@link isRootedType}) so
35
- * their Name is never mistaken for a GlobalId — otherwise the GlobalId
36
- * reconciliation could drop or rename them.
37
- */
38
- const GLOBAL_ID_RE = /^[0-9A-Za-z_$]{22}$/;
39
- /**
40
- * Whether `type` is an IfcRoot subtype — a schema-derived replacement for a
41
- * hand-maintained denylist of "non-rooted types whose first attribute happens
42
- * to be a string". A denylist has to be told about every such type by hand and
43
- * silently under-covers as the schema grows — `IfcMaterialProfileWithOffsets`
44
- * and several other resource types were missing and got their leading Name
45
- * treated as a GlobalId, corrupting ordinary model data on a collision.
46
- *
47
- * `getInheritanceChainAcrossSchemas` walks the bundled IFC2X3/IFC4/IFC4X3
48
- * schema union to the entity's root ancestor; a rooted entity's chain always
49
- * ends in `IfcRoot`. This mirrors the Rust side's `IfcType::is_subtype_of`
50
- * schema check, so the two implementations of "is this rooted" agree instead
51
- * of drifting apart as separate hand-maintained lists.
52
- *
53
- * A type unknown to every bundled schema (typo, vendor extension) yields an
54
- * empty chain and is treated as non-rooted — the same safe-miss direction the
55
- * old denylist documented: it just skips one GlobalId reconciliation.
56
- */
57
- export function isRootedType(type) {
58
- return getInheritanceChainAcrossSchemas(type).includes('IfcRoot');
59
- }
60
35
  /** True for IfcRelationship subtypes (objectified relationships). */
61
36
  function isRelationshipType(typeUpper) {
62
37
  return typeUpper.startsWith('IFCREL');
@@ -163,6 +138,7 @@ export class MergedExporter {
163
138
  }
164
139
  const models = withUsableSource(this.models);
165
140
  const setup = this.buildMergeSetup(options, models);
141
+ const containerDrops = this.planContainerDrops(options, models, setup);
166
142
  const allEntityLines = [];
167
143
  // Tracks every GlobalId already emitted → its final express id + unit scale,
168
144
  // so later models can unify against (shared unit) or stay unique from
@@ -189,13 +165,14 @@ export class MergedExporter {
189
165
  this.collectNormalizeCaveats(model, normalizeWarnings);
190
166
  }
191
167
  const plan = this.planModel(model, completeIndex, isFirstModel, mode.compatible, mode.lengthFactor, setup, guidToFinalId);
168
+ this.applyContainerDrops(plan, containerDrops?.byModel.get(model.id));
192
169
  const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
193
170
  for (const [expressId, entityRef] of completeIndex) {
194
171
  if (visibility !== null && !visibility.included.has(expressId))
195
172
  continue;
196
173
  if (plan.skipEntityIds.has(expressId))
197
174
  continue;
198
- const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode, visibility?.hiddenProductIds ?? null, completeIndex);
175
+ const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode, visibility?.hiddenProductIds ?? null, completeIndex, visibility?.included ?? null);
199
176
  if (line !== null)
200
177
  allEntityLines.push(line);
201
178
  }
@@ -207,7 +184,7 @@ export class MergedExporter {
207
184
  const content = assembleStepBytes(header, allEntityLines);
208
185
  return {
209
186
  content,
210
- stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount, normalizedModelCount, normalizeWarnings),
187
+ stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount, normalizedModelCount, normalizeWarnings, containerDrops?.count ?? 0),
211
188
  };
212
189
  }
213
190
  /**
@@ -223,7 +200,7 @@ export class MergedExporter {
223
200
  }
224
201
  return {
225
202
  content,
226
- stats: this.buildStats(doc.allEntityLines.length, content.byteLength, doc.federatedModelCount, doc.normalizedModelCount, doc.normalizeWarnings),
203
+ stats: this.buildStats(doc.allEntityLines.length, content.byteLength, doc.federatedModelCount, doc.normalizedModelCount, doc.normalizeWarnings, doc.droppedContainerCount),
227
204
  };
228
205
  }
229
206
  /**
@@ -244,7 +221,7 @@ export class MergedExporter {
244
221
  }
245
222
  return {
246
223
  content,
247
- stats: this.buildStats(doc.allEntityLines.length, content.size, doc.federatedModelCount, doc.normalizedModelCount, doc.normalizeWarnings),
224
+ stats: this.buildStats(doc.allEntityLines.length, content.size, doc.federatedModelCount, doc.normalizedModelCount, doc.normalizeWarnings, doc.droppedContainerCount),
248
225
  };
249
226
  }
250
227
  /**
@@ -267,6 +244,7 @@ export class MergedExporter {
267
244
  // without edits pass through unchanged (no export/parse cost).
268
245
  const models = withUsableSource(await this.bakeMutatedModels());
269
246
  const setup = this.buildMergeSetup(options, models);
247
+ const containerDrops = this.planContainerDrops(options, models, setup);
270
248
  const allEntityLines = [];
271
249
  const guidToFinalId = new Map();
272
250
  // First pass: count total entities for progress
@@ -306,6 +284,7 @@ export class MergedExporter {
306
284
  this.collectNormalizeCaveats(model, normalizeWarnings);
307
285
  }
308
286
  const plan = this.planModel(model, completeIndex, isFirstModel, mode.compatible, mode.lengthFactor, setup, guidToFinalId);
287
+ this.applyContainerDrops(plan, containerDrops?.byModel.get(model.id));
309
288
  const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
310
289
  let entityCount = 0;
311
290
  for (const [expressId, entityRef] of completeIndex) {
@@ -313,7 +292,7 @@ export class MergedExporter {
313
292
  continue;
314
293
  if (plan.skipEntityIds.has(expressId))
315
294
  continue;
316
- const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode, visibility?.hiddenProductIds ?? null, completeIndex);
295
+ const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode, visibility?.hiddenProductIds ?? null, completeIndex, visibility?.included ?? null);
317
296
  if (line !== null)
318
297
  allEntityLines.push(line);
319
298
  entityCount++;
@@ -339,7 +318,7 @@ export class MergedExporter {
339
318
  onProgress({ phase: 'assembling', percent: 0.9, entitiesProcessed: totalEntities, entitiesTotal: totalEntities });
340
319
  }
341
320
  await new Promise(r => setTimeout(r, 0));
342
- return { header, allEntityLines, federatedModelCount, normalizedModelCount, normalizeWarnings, totalEntities };
321
+ return { header, allEntityLines, federatedModelCount, normalizedModelCount, normalizeWarnings, droppedContainerCount: containerDrops?.count ?? 0, totalEntities };
343
322
  }
344
323
  /**
345
324
  * Bake each model's pending edits into its source bytes before merging.
@@ -387,7 +366,7 @@ export class MergedExporter {
387
366
  /**
388
367
  * Assemble the result stats, including any federation conformance warnings.
389
368
  */
390
- buildStats(totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, normalizeWarnings) {
369
+ buildStats(totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, normalizeWarnings, droppedContainerCount) {
391
370
  const warnings = [];
392
371
  if (federatedModelCount > 0) {
393
372
  warnings.push(`${federatedModelCount} model(s) had a length unit differing from the first model and were ` +
@@ -398,7 +377,50 @@ export class MergedExporter {
398
377
  `single-unit project, or 'assume-shared' when units are already normalised.`);
399
378
  }
400
379
  warnings.push(...normalizeWarnings);
401
- return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, warnings };
380
+ return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, droppedContainerCount, warnings };
381
+ }
382
+ /**
383
+ * Plan the empty spatial containers of the whole merge (#3643), or `null` when
384
+ * the caller did not ask for the drop. Reproduces the same spatial unification
385
+ * {@link planModel} will, so emptiness is judged on the containers the merge
386
+ * actually keeps rather than on each file in isolation.
387
+ */
388
+ planContainerDrops(options, models, setup) {
389
+ if (!options.dropEmptyContainers)
390
+ return null;
391
+ const views = models.map((model, index) => {
392
+ const source = model.dataStore.source;
393
+ if (!source || source.length === 0)
394
+ return EMPTY_MODEL_VIEW;
395
+ const entities = getCompleteEntityIndex(model.dataStore);
396
+ const mode = this.resolveModelMode(model, index === 0, setup);
397
+ const sharedRemap = new Map();
398
+ if (index > 0 && mode.compatible) {
399
+ this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, mode.lengthFactor, sharedRemap, new Set(), setup);
400
+ }
401
+ return {
402
+ entities,
403
+ source: asSourceBytes(source),
404
+ included: this.computeIncludedEntityIds(model, options, entities, source)?.included ?? null,
405
+ sharedRemap,
406
+ offset: setup.modelOffsets.get(model.id),
407
+ compatible: mode.compatible,
408
+ };
409
+ });
410
+ const plan = planEmptyContainerDrops(views);
411
+ return {
412
+ byModel: new Map(models.map((model, index) => [model.id, plan.droppedByModel[index]])),
413
+ count: plan.droppedCount,
414
+ };
415
+ }
416
+ /** Fold a model's dropped containers into its plan: the container lines are
417
+ * skipped outright, and {@link renderEntity} narrows every line naming one. */
418
+ applyContainerDrops(plan, dropped) {
419
+ if (dropped === undefined || dropped.size === 0)
420
+ return;
421
+ plan.droppedContainerIds = dropped;
422
+ for (const id of dropped)
423
+ plan.skipEntityIds.add(id);
402
424
  }
403
425
  /**
404
426
  * Record advisories for a model being normalized. The rescaler derives its
@@ -451,12 +473,16 @@ export class MergedExporter {
451
473
  }
452
474
  const firstModel = models[0];
453
475
  const primaryScale = this.resolveUnitScale(firstModel);
476
+ const firstModelOffset = modelOffsets.get(firstModel.id);
477
+ const firstModelInfraMap = this.findInfrastructureEntities(firstModel.dataStore);
454
478
  return {
455
479
  modelOffsets,
456
- firstModelOffset: modelOffsets.get(firstModel.id),
457
- firstModelInfraMap: this.findInfrastructureEntities(firstModel.dataStore),
480
+ firstModelOffset,
481
+ firstModelInfraMap,
482
+ firstModelContext: resolvePrimaryContextState(firstModel.dataStore, firstModelInfraMap.get('IFCGEOMETRICREPRESENTATIONSUBCONTEXT') ?? [], primaryScale),
458
483
  firstProjectIds: this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT'),
459
484
  spatialLookup: this.buildSpatialLookup(firstModel.dataStore),
485
+ primaryAggregatePairs: collectRelAggregatePairs(firstModel.dataStore, this.findEntitiesByType.bind(this), this.extractStepAttribute.bind(this), firstModelOffset),
460
486
  primaryScale,
461
487
  primaryAreaScale: this.resolveDerivedUnitScale(firstModel.dataStore, 'AREAUNIT', primaryScale, 2),
462
488
  primaryVolumeScale: this.resolveDerivedUnitScale(firstModel.dataStore, 'VOLUMEUNIT', primaryScale, 3),
@@ -511,8 +537,8 @@ export class MergedExporter {
511
537
  * Resolve a model's declared AREAUNIT / VOLUMEUNIT scale (SI m² / m³ per unit)
512
538
  * by walking IfcProject → IfcUnitAssignment. Falls back to the length-derived
513
539
  * unit (`lengthScale ** power`) when the model declares no explicit area/volume
514
- * unit — the IFC default. A prefixed SI area/volume unit (rare) applies the
515
- * prefix once (buildingSMART / IfcOpenShell convention).
540
+ * unit — the IFC default. A prefixed SI area/volume unit (rare) raises the
541
+ * prefix to `power` (area = prefix², volume = prefix³), matching `rust/core`.
516
542
  */
517
543
  resolveDerivedUnitScale(dataStore, wantType, lengthScale, power) {
518
544
  const fallback = Math.pow(lengthScale, power);
@@ -539,8 +565,8 @@ export class MergedExporter {
539
565
  const prefixRaw = this.extractStepAttribute(uid, dataStore, 2);
540
566
  if (!prefixRaw || prefixRaw === '$' || prefixRaw === '*')
541
567
  return 1.0; // square/cubic metre
542
- const mult = SI_PREFIX_MULTIPLIERS[this.normalizeEnum(prefixRaw)];
543
- return mult !== undefined ? mult : 1.0;
568
+ const mult = SI_PREFIX_MULTIPLIERS[this.normalizeEnum(prefixRaw)]; // see doc: raised to `power`
569
+ return mult !== undefined ? Math.pow(mult, power) : 1.0;
544
570
  }
545
571
  if (utype === 'IFCCONVERSIONBASEDUNIT') {
546
572
  // IfcConversionBasedUnit: [1] UnitType, [2] Name, [3] ConversionFactor.
@@ -614,11 +640,13 @@ export class MergedExporter {
614
640
  const isolatedIds = options.isolatedEntityIdsByModel?.get(model.id) ?? null;
615
641
  const { roots, hiddenProductIds } = getVisibleEntityIds(model.dataStore, hiddenIds, isolatedIds);
616
642
  const included = collectReferencedEntityIds(roots, source, completeIndex, hiddenProductIds);
617
- // Second pass: collect style entities that reference included geometry.
643
+ // Second pass: style entities referencing included geometry (see style-closure.ts).
618
644
  collectStyleEntities(included, source, {
619
645
  byId: completeIndex,
620
646
  byType: model.dataStore.entityIndex.byType,
621
- });
647
+ }, hiddenProductIds);
648
+ // Third pass: rescue IFCMAPCONVERSION/IFCPROJECTEDCRS — see georef-closure.ts.
649
+ collectGeoreferencingEntities(included, source, { byId: completeIndex, byType: model.dataStore.entityIndex.byType }, hiddenProductIds);
622
650
  return { included, hiddenProductIds };
623
651
  }
624
652
  /**
@@ -641,10 +669,11 @@ export class MergedExporter {
641
669
  const sharedRemap = new Map();
642
670
  const skipEntityIds = new Set();
643
671
  const guidRewrite = new Map();
672
+ const relAggregateStrip = new Map();
644
673
  // One cheap pass to read each rooted entity's GlobalId (first attribute).
645
674
  const localGuids = new Map();
646
675
  for (const [id, ref] of completeIndex) {
647
- const guid = this.extractGlobalIdFast(ref, source);
676
+ const guid = extractGlobalIdFast(ref, source);
648
677
  if (guid !== null)
649
678
  localGuids.set(id, guid);
650
679
  }
@@ -657,21 +686,15 @@ export class MergedExporter {
657
686
  skipEntityIds.add(pid);
658
687
  }
659
688
  }
660
- // Remap and skip duplicate infrastructure (units, contexts).
661
- const modelInfra = this.findInfrastructureEntities(model.dataStore);
662
- for (const [type, firstIds] of setup.firstModelInfraMap) {
663
- const thisIds = modelInfra.get(type);
664
- if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
665
- sharedRemap.set(thisIds[0], firstIds[0] + setup.firstModelOffset);
666
- skipEntityIds.add(thisIds[0]);
667
- }
668
- }
689
+ // Remap and skip duplicate infrastructure (units, contexts) — WCS-gated; scale choice documented on planInfrastructureUnify (merged-context.ts).
690
+ planInfrastructureUnify(model.dataStore, this.findInfrastructureEntities(model.dataStore), setup.firstModelInfraMap, setup.firstModelContext, setup.firstModelOffset, setup.assumeShared ? setup.primaryScale : this.resolveUnitScale(model), sharedRemap, skipEntityIds);
669
691
  // Unify spatial hierarchy: match Site, Building, Storey to first model.
670
692
  // Under normalize, this model's raw elevations are in its own unit, so the
671
693
  // elevation match is done in the primary unit (rawElevation * lengthFactor).
672
694
  this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, lengthFactor, sharedRemap, skipEntityIds, setup);
673
- // Skip IfcRelAggregates that become fully redundant after unification.
674
- this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
695
+ // Skip IfcRelAggregates that become fully redundant after unification,
696
+ // and strip individually-duplicated members from ones only partially so.
697
+ skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds, relAggregateStrip, setup.primaryAggregatePairs, this.findEntitiesByType.bind(this), this.extractStepAttribute.bind(this));
675
698
  }
676
699
  if (!isFirstModel) {
677
700
  // GlobalId reconciliation against everything emitted by earlier models.
@@ -699,11 +722,11 @@ export class MergedExporter {
699
722
  skipEntityIds.add(id);
700
723
  }
701
724
  else {
702
- guidRewrite.set(id, this.mintUniqueGuid(guid, model.id, guidToFinalId, pendingMinted));
725
+ guidRewrite.set(id, mintUniqueGuid(guid, model.id, guidToFinalId, pendingMinted));
703
726
  }
704
727
  }
705
728
  }
706
- return { sharedRemap, skipEntityIds, guidRewrite, localGuids };
729
+ return { sharedRemap, skipEntityIds, guidRewrite, localGuids, relAggregateStrip };
707
730
  }
708
731
  /**
709
732
  * Render one source entity into its final STEP line: apply id offset + shared
@@ -713,43 +736,69 @@ export class MergedExporter {
713
736
  * `hiddenProductIds` withholds a relationship whose every named subject was
714
737
  * hidden (below).
715
738
  */
716
- renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, mode, hiddenProductIds, completeIndex) {
739
+ renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, mode, hiddenProductIds, completeIndex, includedIds) {
717
740
  let entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
718
741
  // A `visibleOnly` export must narrow — or entirely withhold — a
719
- // relationship's own OUTPUT line the same way `StepExporter` does
720
- // (`isOmittedFromOutput`, consumed at its two
721
- // `filterHiddenRefsFromRelationshipLine` call sites named rather than
722
- // cited by line number, which went stale the first time either file moved).
723
- // NOTE the predicates are not identical: `StepExporter`'s also answers for
724
- // an unreadable source ref and a geometry exclusion, which this one — a
725
- // hidden product or an id absent from the complete index — does not.
742
+ // relationship's (and, below, a rescued style/layer entity's) own OUTPUT
743
+ // line the same way `StepExporter` does (`isOmittedFromOutput`, its two
744
+ // `filterHiddenRefsFromRelationshipLine` call sites). NOTE the predicates
745
+ // are not identical: `StepExporter`'s also answers for an unreadable
746
+ // source ref and a geometry exclusion, which these do not.
726
747
  // `collectReferencedEntityIds` already refuses to WALK INTO a relationship
727
748
  // whose sole subject is hidden (#2548), but a root's own bytes are still
728
749
  // copied to the output verbatim unless narrowed here too — without this,
729
- // a hidden id survived as a dangling `#N` with no `#N=` line (the #2398
730
- // shape), which is exactly what closing the #2548 closure leak would
731
- // otherwise have traded it for. Runs in LOCAL id space, before the remap
732
- // below, because `hiddenProductIds` and `completeIndex` are both local to
733
- // this model.
734
- if (hiddenProductIds !== null && isRelationshipType(entityRef.type.toUpperCase())) {
735
- const isExcluded = (id) => hiddenProductIds.has(id) || !completeIndex.has(id);
736
- const filtered = filterHiddenRefsFromRelationshipLine(entityText, isExcluded);
737
- if (filtered === null)
750
+ // a hidden id survives as a dangling `#N` with no `#N=` line (#2398).
751
+ // Runs in LOCAL id space, before the remap below.
752
+ if (hiddenProductIds !== null) {
753
+ const entityTypeUpper = entityRef.type.toUpperCase();
754
+ // Same narrowing for a rescued style/layer entity (`STYLE_RESCUE_TYPES`,
755
+ // `style-closure.ts`): its line can name a kept AND an excluded id (a
756
+ // shared CAD layer) `hiddenProductIds` misses that id (exclusively-
757
+ // owned geometry, not a hidden PRODUCT), so this arm uses `includedIds`.
758
+ const isExcluded = isRelationshipType(entityTypeUpper)
759
+ ? (id) => hiddenProductIds.has(id) || !completeIndex.has(id)
760
+ : STYLE_RESCUE_TYPES.has(entityTypeUpper) && includedIds !== null
761
+ ? (id) => !includedIds.has(id) || !completeIndex.has(id)
762
+ : null;
763
+ if (isExcluded) {
764
+ const filtered = filterHiddenRefsFromRelationshipLine(entityText, isExcluded);
765
+ if (filtered === null)
766
+ return null;
767
+ entityText = filtered;
768
+ }
769
+ }
770
+ // A dropped empty spatial container (#3643) is never written, so any line
771
+ // naming one is narrowed the same way — or withheld with it. Runs in LOCAL
772
+ // id space, before the remap below, like the visibility narrowing above.
773
+ if (plan.droppedContainerIds !== undefined) {
774
+ const kept = filterHiddenRefsFromRelationshipLine(entityText, id => plan.droppedContainerIds.has(id));
775
+ if (kept === null)
738
776
  return null;
739
- entityText = filtered;
777
+ entityText = kept;
740
778
  }
779
+ // Drop RelatedObjects members a partially redundant IFCRELAGGREGATES
780
+ // already shares with the first model's OWN relationship to the same
781
+ // (now-unified) RelatingObject — see skipRedundantRelAggregates /
782
+ // applyRelAggregateStrip (merged-rel-aggregates.ts). Runs in LOCAL id
783
+ // space, before the remap below. `null` (the filter would withhold the
784
+ // whole line) propagates like the two passes above: every edge the line
785
+ // declared already exists in the primary model.
786
+ const stripped = applyRelAggregateStrip(entityText, localId, plan.relAggregateStrip);
787
+ if (stripped === null)
788
+ return null;
789
+ entityText = stripped;
741
790
  // Remap ids. Fast path: the first model (offset 0, no remaps) is byte-identical.
742
791
  let finalText;
743
792
  if (offset === 0 && plan.sharedRemap.size === 0) {
744
793
  finalText = entityText;
745
794
  }
746
795
  else {
747
- finalText = this.remapEntityText(entityText, offset, plan.sharedRemap);
796
+ finalText = remapEntityText(entityText, offset, plan.sharedRemap);
748
797
  }
749
798
  // Re-stamp the GlobalId for a federated entity whose id collides.
750
799
  const mintedGuid = plan.guidRewrite.get(localId);
751
800
  if (mintedGuid !== undefined) {
752
- finalText = this.replaceGlobalId(finalText, mintedGuid);
801
+ finalText = replaceGlobalId(finalText, mintedGuid);
753
802
  }
754
803
  // Normalize units: rescale every length/area/volume-valued datum into the
755
804
  // primary unit. Done on the SOURCE-schema text (entityRef.type), before any
@@ -771,7 +820,7 @@ export class MergedExporter {
771
820
  // Emitted entities are not sharedRemap keys, so their final id is
772
821
  // localId + offset.
773
822
  if (plan.localGuids.has(localId)) {
774
- const emittedGuid = this.readLeadingGuid(finalText)
823
+ const emittedGuid = readLeadingGuid(finalText)
775
824
  ?? mintedGuid ?? plan.localGuids.get(localId);
776
825
  if (emittedGuid !== undefined) {
777
826
  guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale: mode.effectiveScale });
@@ -779,142 +828,6 @@ export class MergedExporter {
779
828
  }
780
829
  return finalText;
781
830
  }
782
- /**
783
- * Read the GlobalId (first quoted attribute) from an already-rendered STEP
784
- * line. Used to register the id that was actually emitted, after any id
785
- * remap, GlobalId re-stamp, or schema conversion. Returns null if the first
786
- * quoted token is not a 22-char GlobalId.
787
- */
788
- readLeadingGuid(entityText) {
789
- const open = entityText.indexOf('(');
790
- if (open === -1)
791
- return null;
792
- const q1 = entityText.indexOf("'", open + 1);
793
- if (q1 === -1)
794
- return null;
795
- const q2 = entityText.indexOf("'", q1 + 1);
796
- if (q2 === -1)
797
- return null;
798
- const raw = entityText.slice(q1 + 1, q2);
799
- return GLOBAL_ID_RE.test(raw) ? raw : null;
800
- }
801
- /**
802
- * Mint a fresh, deterministic, collision-free GlobalId for an entity whose id
803
- * collides. Seeded from the original GlobalId and the model's stable id so the
804
- * output is reproducible and does not churn when an unrelated earlier model
805
- * changes size; checked against both already-emitted ids and the ids minted
806
- * so far for this model.
807
- */
808
- mintUniqueGuid(original, modelId, guidToFinalId, pendingMinted) {
809
- let candidate = deterministicGlobalId(`${original}#${modelId}`);
810
- let n = 0;
811
- while (guidToFinalId.has(candidate) || pendingMinted.has(candidate)) {
812
- candidate = deterministicGlobalId(`${original}#${modelId}#${n++}`);
813
- }
814
- pendingMinted.add(candidate);
815
- return candidate;
816
- }
817
- /**
818
- * Read an entity's GlobalId (first attribute) by decoding only its head.
819
- * Returns the 22-char id for a rooted entity, or `null` for any entity whose
820
- * first attribute is not a GlobalId (geometry, lists, property atoms, …).
821
- */
822
- extractGlobalIdFast(ref, source) {
823
- // Non-rooted resource entities (property/quantity/material/style/actor …)
824
- // lead with a Name string that can itself be 22 charset chars; never treat
825
- // those as a GlobalId or reconciliation would drop/rename them.
826
- if (!isRootedType(ref.type ?? ''))
827
- return null;
828
- // 128 bytes comfortably spans `#<id>=<LONGEST_TYPE_NAME>('<22-char id>'`,
829
- // so the GlobalId is always fully inside the window.
830
- const end = Math.min(ref.byteOffset + 128, ref.byteOffset + ref.byteLength);
831
- const head = decodeRange(source, ref.byteOffset, end);
832
- const open = head.indexOf('(');
833
- if (open === -1)
834
- return null;
835
- let i = open + 1;
836
- while (i < head.length && (head[i] === ' ' || head[i] === '\t' || head[i] === '\n' || head[i] === '\r'))
837
- i++;
838
- if (head[i] !== "'")
839
- return null;
840
- // A GlobalId never contains a quote (charset excludes it), so the next
841
- // quote closes it.
842
- const close = head.indexOf("'", i + 1);
843
- if (close === -1)
844
- return null;
845
- const raw = head.slice(i + 1, close);
846
- return GLOBAL_ID_RE.test(raw) ? raw : null;
847
- }
848
- /**
849
- * Replace an entity's GlobalId (first quoted attribute) with `newGuid`.
850
- * `newGuid` is a 22-char IFC id (no quote in its charset), so this is safe.
851
- */
852
- replaceGlobalId(entityText, newGuid) {
853
- const open = entityText.indexOf('(');
854
- if (open === -1)
855
- return entityText;
856
- const q1 = entityText.indexOf("'", open + 1);
857
- if (q1 === -1)
858
- return entityText;
859
- const q2 = entityText.indexOf("'", q1 + 1);
860
- if (q2 === -1)
861
- return entityText;
862
- return entityText.slice(0, q1 + 1) + newGuid + entityText.slice(q2);
863
- }
864
- /**
865
- * Remap all #ID references in a STEP entity line.
866
- * Applies offset to all IDs, then overrides with specific remappings.
867
- *
868
- * Only `#<digits>` tokens in code positions are rewritten; tokens inside
869
- * single-quoted STEP strings (e.g. a 'Room #205' Name or a 'http://x#42'
870
- * URL) are left untouched so string attribute values are not corrupted.
871
- */
872
- remapEntityText(entityText, offset, sharedRemap) {
873
- const remapId = (originalId) => {
874
- // Check if this ID has a specific remap (project, shared infrastructure)
875
- const remapped = sharedRemap.get(originalId);
876
- if (remapped !== undefined) {
877
- return `#${remapped}`;
878
- }
879
- // Apply offset
880
- return `#${originalId + offset}`;
881
- };
882
- let out = '';
883
- let inString = false;
884
- for (let i = 0; i < entityText.length; i++) {
885
- const char = entityText[i];
886
- if (inString) {
887
- out += char;
888
- if (char === "'") {
889
- // STEP escapes a literal quote by doubling it ('').
890
- if (entityText[i + 1] === "'") {
891
- out += entityText[i + 1];
892
- i++;
893
- }
894
- else {
895
- inString = false;
896
- }
897
- }
898
- continue;
899
- }
900
- if (char === "'") {
901
- inString = true;
902
- out += char;
903
- continue;
904
- }
905
- if (char === '#' && entityText[i + 1] >= '0' && entityText[i + 1] <= '9') {
906
- let j = i + 1;
907
- while (j < entityText.length && entityText[j] >= '0' && entityText[j] <= '9')
908
- j++;
909
- const originalId = parseInt(entityText.slice(i + 1, j), 10);
910
- out += remapId(originalId);
911
- i = j - 1;
912
- continue;
913
- }
914
- out += char;
915
- }
916
- return out;
917
- }
918
831
  /**
919
832
  * Find entity IDs of shared infrastructure types in a data store.
920
833
  * Returns a map of uppercase type name → array of expressIds.
@@ -1083,43 +996,6 @@ export class MergedExporter {
1083
996
  return byName();
1084
997
  return byName() ?? bySingle();
1085
998
  }
1086
- /**
1087
- * Skip IfcRelAggregates that become fully redundant after spatial unification.
1088
- *
1089
- * When Model2's `IfcRelAggregates(Project, (Site))` gets remapped to
1090
- * `IfcRelAggregates(FirstProject, (FirstSite))`, it duplicates Model1's
1091
- * existing relationship, causing viewers to show Site multiple times.
1092
- *
1093
- * An IfcRelAggregates is redundant if both its RelatingObject (attr 4)
1094
- * and ALL its RelatedObjects (attr 5) were remapped via sharedRemap.
1095
- */
1096
- skipRedundantRelAggregates(dataStore, sharedRemap, skipEntityIds) {
1097
- for (const relId of this.findEntitiesByType(dataStore, 'IFCRELAGGREGATES')) {
1098
- // RelatingObject is attr 4 — single #ref
1099
- const relatingAttr = this.extractStepAttribute(relId, dataStore, 4);
1100
- if (!relatingAttr)
1101
- continue;
1102
- const relatingRef = relatingAttr.match(/^#(\d+)$/);
1103
- if (!relatingRef || !sharedRemap.has(parseInt(relatingRef[1], 10)))
1104
- continue;
1105
- // RelatedObjects is attr 5 — list of #refs like (#2,#3)
1106
- const relatedAttr = this.extractStepAttribute(relId, dataStore, 5);
1107
- if (!relatedAttr)
1108
- continue;
1109
- const refs = [];
1110
- const refRegex = /#(\d+)/g;
1111
- let m;
1112
- while ((m = refRegex.exec(relatedAttr)) !== null) {
1113
- refs.push(parseInt(m[1], 10));
1114
- }
1115
- if (refs.length === 0)
1116
- continue;
1117
- // If ALL related objects were also remapped, this rel is fully redundant
1118
- if (refs.every(ref => sharedRemap.has(ref))) {
1119
- skipEntityIds.add(relId);
1120
- }
1121
- }
1122
- }
1123
999
  /**
1124
1000
  * Extract the Name attribute (index 2) from a STEP entity.
1125
1001
  */