@ifc-lite/export 2.7.1 → 2.8.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.
@@ -1,16 +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 { EntityExtractor, generateHeader, parseSourceHeader, getAttributeNames, serializeValue, ref, } from '@ifc-lite/parser';
4
+ import { EntityExtractor, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
5
5
  import { safeUtf8Decode } from '@ifc-lite/data';
6
6
  import { generateIfcGuid } from '@ifc-lite/encoding';
7
7
  import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
8
8
  import { convertStepLine, needsConversion } from './schema-converter.js';
9
9
  import { retypeStepLine, retypeArgTokens } from './retype.js';
10
10
  import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
11
+ import { authoredEntityRefs, getEffectiveEntityIndex } from './effective-index.js';
12
+ import { HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken, isTypeClass, resolveTypeOwnedPsetIds, } from './type-owned-psets.js';
11
13
  import { escapeStepString, toStepReal, quantityTypeToIfcType, serializePropertyValue, serializeAttributeValue, serializeStepValue, tokenIsRealLiteral, splitTopLevelArgs, splitTopLevelStepArguments, assembleStepBytes, } from './step-serialization.js';
12
14
  import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
15
+ import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
13
16
  import { serializeQualifiedSelectSlot } from './select-qualification.js';
17
+ /** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
18
+ const OWNER_HISTORY_SLOT = 1;
14
19
  /**
15
20
  * IFC STEP file exporter
16
21
  */
@@ -41,6 +46,12 @@ export class StepExporter {
41
46
  const entities = [];
42
47
  let newEntityCount = 0;
43
48
  let modifiedEntityCount = 0;
49
+ // Both owner-history caches are per-EXPORT, not per-exporter: they now
50
+ // depend on `willBeEmitted`, which depends on this call's options. Reusing
51
+ // one exporter for a `visibleOnly` export and then a full one would
52
+ // otherwise answer the second from the first one's closure.
53
+ this.ownerHistoryFallbackRef = undefined;
54
+ this.ownerHistoryByEntity.clear();
44
55
  // Determine target schema from options, source schema from data store
45
56
  const schema = options.schema || this.dataStore.schemaVersion || 'IFC4';
46
57
  const sourceSchema = this.dataStore.schemaVersion || 'IFC4';
@@ -95,6 +106,15 @@ export class StepExporter {
95
106
  timeStamp: options.timeStamp,
96
107
  });
97
108
  };
109
+ // The one authority for exists / class / deleted, overlay first and source
110
+ // buffer second. Every pass below asks this instead of `this.dataStore`,
111
+ // which answers only for the file as parsed (#2012).
112
+ const effective = getEffectiveEntityIndex(this.dataStore, this.mutationView, options.applyMutations !== false);
113
+ // Does this id belong to an entity the OVERLAY created (`createEntity` /
114
+ // `store.addEntity`) rather than to a record in the source buffer? Such an
115
+ // entity has no source bytes, so the source-iteration pass below never sees
116
+ // it and the new-entities pass at the end owns its line entirely (#2006).
117
+ const isOverlayCreated = (entityId) => effective.isOverlayCreated(entityId);
98
118
  // Collect entities that need to be modified or created
99
119
  const modifiedEntities = new Set();
100
120
  const modifiedPsets = new Map(); // entityId -> psetNames being modified
@@ -105,9 +125,13 @@ export class StepExporter {
105
125
  const typeOwnedPsetIdsByEntity = new Map();
106
126
  const rewrittenEntityIds = new Set();
107
127
  const rewrittenEntityLines = new Map();
128
+ /** HasPropertySets slot value for an OVERLAY-CREATED type object, applied
129
+ * by the new-entities pass (there is no source line to rewrite). */
130
+ const overlayTypeOwnedPsets = new Map();
108
131
  // Track property set IDs and relationship IDs to skip
109
132
  const skipPropertySetIds = new Set();
110
133
  const skipRelationshipIds = new Set();
134
+ const overlayActive = !!this.mutationView && (options.applyMutations !== false);
111
135
  // Process mutations if we have a mutation view
112
136
  if (this.mutationView && (options.applyMutations !== false)) {
113
137
  const mutations = this.mutationView.getMutations();
@@ -152,12 +176,48 @@ export class StepExporter {
152
176
  // below previously walked every entity in `entityIndex.byId` per
153
177
  // modified entity (O(E·N)); the index keeps the per-entity step
154
178
  // O(K) where K is the number of rels referencing that entity.
155
- const relDefinesByEntity = this.buildRelDefinesByPropertiesIndex();
179
+ const { byEntity: relDefinesByEntity, relatedByRel } = this.buildRelDefinesByPropertiesIndex();
180
+ // A source IfcRelDefinesByProperties whose EVERY related object the
181
+ // session deleted has nothing left to relate, and emitting it leaves a
182
+ // `#id` pointing at a record the export skipped. Dropped only when all of
183
+ // them are gone: a rel that still names a live entity is that entity's
184
+ // only link to its psets, and nothing here rewrites a RelatedObjects list.
185
+ for (const [relId, related] of relatedByRel) {
186
+ if (related.length > 0 && related.every((id) => effective.isDeleted(id))) {
187
+ skipRelationshipIds.add(relId);
188
+ }
189
+ }
156
190
  // Collect modified property sets and find original psets to skip
157
191
  for (const [entityId, psetNames] of entityPropMutations) {
192
+ // A deleted entity must not cause the exporter to REMOVE anything.
193
+ //
194
+ // This is the other half of the dangling-reference class, and the half
195
+ // `willBeEmitted` cannot reach: that predicate guards what gets ADDED,
196
+ // and this loop's real work is deciding what gets SKIPPED. An edited
197
+ // pset is replaced wholesale, so its original id goes into
198
+ // `skipPropertySetIds` — but IFC exporters share one IfcPropertySet
199
+ // between entities, and once the host is deleted there is no
200
+ // replacement to take its place. The surviving entity's relation then
201
+ // points at a container nobody wrote. Verified against main at
202
+ // e6516991 (#2030's own merge): edit `Pset_WallCommon` on one of two
203
+ // walls sharing it, delete that wall, and the export drops #11 while
204
+ // #12 still names it. `retainSharedAtoms` rescues a shared ATOM one
205
+ // level down; nothing rescues the shared container.
206
+ //
207
+ // Leaving the pset alone makes it an orphan when nothing else
208
+ // references it, which is valid IFC. Its relation is dropped by the
209
+ // sweep above, which handles a plain delete too — no pset edit needed.
210
+ if (effective.isDeleted(entityId))
211
+ continue;
158
212
  modifiedEntities.add(entityId);
159
213
  modifiedPsets.set(entityId, psetNames);
160
- modifiedEntityCount++;
214
+ // Same rule as the attribute loop below: an overlay-CREATED entity is
215
+ // emitted once, by the new-entities pass, and already counted in
216
+ // `newEntityCount` — as are the pset entities this loop goes on to
217
+ // generate. Only the COUNT is guarded; the entity still records its
218
+ // pset edits and still emits them.
219
+ if (!isOverlayCreated(entityId))
220
+ modifiedEntityCount++;
161
221
  // Get the FULL mutated property sets for this entity (merged base + mutations)
162
222
  const allPsets = this.mutationView.getForEntity(entityId);
163
223
  const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
@@ -186,8 +246,8 @@ export class StepExporter {
186
246
  }
187
247
  }
188
248
  }
189
- if (this.isTypeEntity(entityId)) {
190
- const typeOwnedPsetIds = this.getTypeOwnedHasPropertySetIds(entityId);
249
+ if (isTypeClass(effective.typeOf(entityId))) {
250
+ const typeOwnedPsetIds = this.getTypeOwnedHasPropertySetIds(entityId, effective);
191
251
  const typeOwnedAffected = new Set();
192
252
  for (const psetId of typeOwnedPsetIds) {
193
253
  const psetName = this.getPropertySetName(psetId);
@@ -216,8 +276,13 @@ export class StepExporter {
216
276
  if (options.includeQuantities === false)
217
277
  entityQuantMutations.clear();
218
278
  for (const [entityId, qsetNames] of entityQuantMutations) {
279
+ // Same rule as the property loop above: a deleted entity removes nothing.
280
+ if (effective.isDeleted(entityId))
281
+ continue;
219
282
  modifiedEntities.add(entityId);
220
- if (!modifiedPsets.has(entityId))
283
+ // See the property loop above — an overlay-created entity is counted as
284
+ // new, not modified.
285
+ if (!isOverlayCreated(entityId) && !modifiedPsets.has(entityId))
221
286
  modifiedEntityCount++;
222
287
  const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
223
288
  const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
@@ -242,6 +307,12 @@ export class StepExporter {
242
307
  }
243
308
  }
244
309
  for (const [entityId] of modifiedAttributes) {
310
+ // An overlay-CREATED entity carrying attribute edits is emitted once,
311
+ // by the new-entities pass, and already counted in `newEntityCount`.
312
+ // Counting it here too made the header claim two affected entities for
313
+ // one created-then-renamed wall.
314
+ if (isOverlayCreated(entityId))
315
+ continue;
245
316
  if (!entityPropMutations.has(entityId) && !entityQuantMutations.has(entityId)) {
246
317
  modifiedEntityCount++;
247
318
  }
@@ -412,20 +483,65 @@ export class StepExporter {
412
483
  },
413
484
  };
414
485
  }
415
- // Complete view over byId + any deferred property atoms. Walking byId alone
416
- // drops deferred atoms while keeping the IfcPropertySet/IfcElementQuantity
417
- // references to them, producing dangling #-refs in the output.
418
- const completeIndex = getCompleteEntityIndex(this.dataStore);
419
- // Build visible-only closure if requested
486
+ // Build visible-only closure if requested. Classification, the closure walk
487
+ // and the style pass all run over the EFFECTIVE index: an overlay-created
488
+ // product becomes a root by the same type rules as a parsed one, the walk
489
+ // follows its authored references into the geometry it alone owns, and a
490
+ // tombstoned entity is simply not there. Run over the source buffer, a
491
+ // created wall could never be a root and nothing referenced it, so
492
+ // `visibleOnly` wrote a file without it and said nothing (#2012).
420
493
  let allowedEntityIds = null;
421
494
  if (options.visibleOnly && this.dataStore.source) {
422
- const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null);
423
- allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, completeIndex, hiddenProductIds);
495
+ const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
496
+ allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
424
497
  // Second pass: collect IFCSTYLEDITEM entities that reference included
425
498
  // geometry. Styled items reference geometry items but nothing references
426
499
  // them back, so the forward closure misses them.
427
- collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: completeIndex, byType: this.dataStore.entityIndex.byType });
500
+ collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
428
501
  }
502
+ /**
503
+ * Will this id have a defining STEP line in the output at all?
504
+ *
505
+ * The predicate is #2030's, and it is the right one: the pset, quantity and
506
+ * type-owned passes below are built from unfiltered mutation history, and
507
+ * what each of them needs to know before emitting an
508
+ * `IFCRELDEFINESBYPROPERTIES` is not "was this deleted" or "is this hidden"
509
+ * but the general question those are two answers to. A relation naming an
510
+ * expressId that never gets written is a dangling reference and an invalid
511
+ * file, whichever route dropped the line.
512
+ *
513
+ * #2030 had to reach for four things to answer it — a tombstone probe, a
514
+ * visibility set, a byte-range test on `completeIndex`, and a `getNewEntity`
515
+ * fallback whose stated purpose was that `deleteEntity` FORGOT an
516
+ * overlay-created entity instead of tombstoning it, so `isDeleted` could not
517
+ * answer for one. That fallback was documented on main as a workaround for
518
+ * exactly the model-level defect this branch fixes: `deleteEntity` now
519
+ * tombstones as well as forgets, so the effective index answers existence
520
+ * for source and overlay ids alike and the workaround collapses into it.
521
+ *
522
+ * The overlay branch does NOT disappear with it, and the distinction matters:
523
+ * `isOverlayCreated` is still load-bearing here, because a live
524
+ * overlay-created entity has no source bytes and would fail the byte-range
525
+ * test that a source record passes. What the tombstone fix removed is the
526
+ * need for that branch to double as a deletion detector.
527
+ *
528
+ * Deliberately unchanged from #2030 for source records under `deltaOnly` /
529
+ * `exportPropertiesOnly`: the source-iteration pass is skipped wholesale in
530
+ * those modes, yet a source entity still answers true here. A delta is a
531
+ * patch against a file that already has the line, not a standalone model.
532
+ */
533
+ const willBeEmitted = (entityId) => {
534
+ if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
535
+ return false;
536
+ // Undefined for a tombstoned id and for one neither the file nor the
537
+ // session ever had — a stale mutation must not conjure a relation either.
538
+ const ref = effective.get(entityId);
539
+ if (!ref)
540
+ return false;
541
+ // An overlay-created record carries the placeholder byte range and is
542
+ // written by the new-entities pass; a source record needs real bytes.
543
+ return effective.isOverlayCreated(entityId) || (ref.byteLength > 0 && ref.byteOffset >= 0);
544
+ };
429
545
  // A modified pset is replaced wholesale, which skips ALL of its member atoms.
430
546
  // But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
431
547
  // IsExternal IfcPropertySingleValue shared by dozens of psets), so skipping a
@@ -435,13 +551,10 @@ export class StepExporter {
435
551
  // Export original entities from source buffer, SKIPPING modified property sets
436
552
  if (!options.deltaOnly && this.dataStore.source) {
437
553
  const source = this.dataStore.source;
438
- // Extract existing entities from source
439
- const overlayActive = !!this.mutationView && (options.applyMutations !== false);
440
- for (const [expressId, entityRef] of completeIndex) {
441
- // Skip entities deleted via the overlay (only when mutations are applied)
442
- if (overlayActive && typeof this.mutationView.isDeleted === 'function' && this.mutationView.isDeleted(expressId)) {
443
- continue;
444
- }
554
+ // Extract existing entities from source. The effective index has already
555
+ // dropped everything the overlay tombstoned, so there is no separate
556
+ // deleted check to forget here.
557
+ for (const [expressId, entityRef] of effective) {
445
558
  // Skip overlay-only entities — emitted by the new-entities pass below
446
559
  if (entityRef.byteLength === 0 || entityRef.byteOffset < 0) {
447
560
  continue;
@@ -519,30 +632,43 @@ export class StepExporter {
519
632
  }
520
633
  }
521
634
  // Generate new property entities for mutations (these REPLACE the skipped ones)
635
+ const generatedTypeOwnedPsetIds = new Map();
522
636
  for (const { entityId, psets } of newPropertySets) {
523
- const newEntities = this.generatePropertySetEntities(entityId, psets, allowedEntityIds, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
637
+ // Nothing may be emitted FOR an entity that gets no defining line —
638
+ // see `willBeEmitted` (#1978, #2030, #2012).
639
+ if (!willBeEmitted(entityId))
640
+ continue;
641
+ const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
524
642
  entities.push(...newEntities.lines);
525
643
  newEntityCount += newEntities.count;
526
- const typeOwnedPsetNames = typeOwnedPsetNamesByEntity.get(entityId);
527
- if (typeOwnedPsetNames && typeOwnedPsetNames.size > 0) {
528
- const rewritten = this.rewriteTypeEntityHasPropertySets(entityId, typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, newEntities.generatedTypeOwnedPsetIds);
529
- if (rewritten) {
530
- rewrittenEntityLines.set(entityId, rewritten);
531
- }
532
- }
644
+ generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
533
645
  }
534
- // Handle type-owned pset deletions with no replacement pset content
646
+ // Point every affected type object's HasPropertySets at the psets this
647
+ // export generated. One loop, because a type whose affected psets produced
648
+ // no replacement content (a deletion) needs exactly the same resolution
649
+ // with an empty replacement map.
535
650
  for (const [entityId, typeOwnedPsetNames] of typeOwnedPsetNamesByEntity) {
536
- if (rewrittenEntityLines.has(entityId))
651
+ // `entityId` here is a TYPE object rather than an element; `willBeEmitted`
652
+ // resolves either the same way (#2030).
653
+ if (!willBeEmitted(entityId))
654
+ continue;
655
+ const resolved = resolveTypeOwnedPsetIds(typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, generatedTypeOwnedPsetIds.get(entityId) ?? new Map(), (psetId) => this.getPropertySetName(psetId));
656
+ if (effective.isOverlayCreated(entityId)) {
657
+ // No source line to rewrite: the new-entities pass writes this record
658
+ // from its authored payload, so the list rides in as a slot override.
659
+ overlayTypeOwnedPsets.set(entityId, resolved.length > 0 ? resolved.map((id) => `#${id}`) : null);
537
660
  continue;
538
- const rewritten = this.rewriteTypeEntityHasPropertySets(entityId, typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, new Map());
661
+ }
662
+ const rewritten = this.replaceEntityAttribute(entityId, HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken(resolved));
539
663
  if (rewritten) {
540
664
  rewrittenEntityLines.set(entityId, rewritten);
541
665
  }
542
666
  }
543
667
  // Generate new quantity entities for mutations
544
668
  for (const { entityId, qsets } of newQuantitySets) {
545
- const newEntities = this.generateQuantitySetEntities(entityId, qsets, allowedEntityIds, options.guidRandom);
669
+ if (!willBeEmitted(entityId))
670
+ continue;
671
+ const newEntities = this.generateQuantitySetEntities(entityId, qsets, willBeEmitted, options.guidRandom);
546
672
  entities.push(...newEntities.lines);
547
673
  newEntityCount += newEntities.count;
548
674
  }
@@ -595,6 +721,32 @@ export class StepExporter {
595
721
  else {
596
722
  argsText = serializeEntityArgs(entity.type, entity.attributes, sourceSchema);
597
723
  }
724
+ // Edits made AFTER the create live in the overlay, never in the
725
+ // authored payload (#2006). The source-iteration pass applies them to
726
+ // source records via applyAttributeMutations / applyPositionalMutations;
727
+ // an overlay-created entity has no source record, so without this it was
728
+ // written from its creation payload alone and every later
729
+ // `setAttribute` / `setPositionalAttribute` was silently dropped on
730
+ // save — data loss with no error and no warning.
731
+ //
732
+ // Order mirrors the source pass: retype (above) -> named attributes ->
733
+ // positional overrides, all resolved against the EFFECTIVE class.
734
+ const attributeOverrides = modifiedAttributes.get(entity.expressId) ?? null;
735
+ const queuedPositional = typeof this.mutationView.getPositionalMutationsForEntity === 'function'
736
+ ? this.mutationView.getPositionalMutationsForEntity(entity.expressId)
737
+ : null;
738
+ // A created TYPE object owns its psets through HasPropertySets, and the
739
+ // ids of the psets this export generated are only known now — so they
740
+ // arrive as one more slot override rather than through the overlay.
741
+ // `has`, not `??`, for the same reason `overlaySlotValue` gives: the
742
+ // stored value is deliberately null when the resolved list is empty.
743
+ const positionalOverrides = overlayTypeOwnedPsets.has(entity.expressId)
744
+ ? new Map(queuedPositional).set(HAS_PROPERTY_SETS_SLOT, overlayTypeOwnedPsets.get(entity.expressId) ?? null)
745
+ : queuedPositional;
746
+ if ((attributeOverrides && attributeOverrides.size > 0)
747
+ || (positionalOverrides && positionalOverrides.size > 0)) {
748
+ argsText = this.applyOverlayEntityOverrides(argsText, upperType, attributeOverrides, positionalOverrides, sourceSchema);
749
+ }
598
750
  const line = `#${entity.expressId}=${upperType}(${argsText});`;
599
751
  if (converting) {
600
752
  const converted = convertStepLine(line, sourceSchema, schema, options.guidRandom);
@@ -662,28 +814,56 @@ export class StepExporter {
662
814
  * MANDATORY in IFC2X3 (IfcRoot.OwnerHistory), so emitting `$` yields an invalid
663
815
  * IFC2X3 file that strict readers (e.g. BIM Vision) reject.
664
816
  *
665
- * Prefer the host element's OWN owner history: it is the semantically correct
666
- * owner and being reachable from an exported root — is guaranteed to survive a
667
- * `visibleOnly` closure. Fall back to any owner history still inside the export
668
- * (closure-aware) so we never reference one a `visibleOnly` / isolated export
669
- * dropped, then to `$` only when the file has none.
817
+ * Prefer the host element's OWN owner history, then any owner history that
818
+ * survives this export, then `$` only when none does.
819
+ *
820
+ * "Survives" is `willBeEmitted`, the same predicate that decides whether the
821
+ * host itself may have psets generated for it. A reference is a reference: it
822
+ * is no more acceptable to point an emitted `IfcPropertySet` at an owner
823
+ * history the session deleted than at a host it deleted. This used to consult
824
+ * only the `visibleOnly` closure, so an overlay-created OwnerHistory that was
825
+ * later deleted still got referenced — a dangling `#N`, reached through the
826
+ * one attribute the generators fill in for themselves.
670
827
  */
671
- resolveOwnerHistoryRef(hostEntityId, allowedEntityIds) {
828
+ resolveOwnerHistoryRef(hostEntityId, willBeEmitted) {
672
829
  const own = this.getOwnerHistoryRefOfEntity(hostEntityId);
673
830
  if (own !== null) {
674
831
  const ownId = parseInt(own.slice(1), 10);
675
- if (allowedEntityIds === null || allowedEntityIds.has(ownId))
832
+ if (willBeEmitted(ownId))
676
833
  return own;
677
834
  }
678
835
  if (this.ownerHistoryFallbackRef === undefined) {
836
+ // Source-only: the fallback is a best-effort "some owner history the file
837
+ // still has", and the host's OWN history above is the path that resolves
838
+ // an overlay-created one.
679
839
  const ids = this.dataStore.entityIndex.byType.get('IFCOWNERHISTORY') ?? [];
680
- const surviving = allowedEntityIds === null
681
- ? ids[0]
682
- : ids.find((id) => allowedEntityIds.has(id));
840
+ const surviving = ids.find((id) => willBeEmitted(id));
683
841
  this.ownerHistoryFallbackRef = surviving !== undefined ? `#${surviving}` : '$';
684
842
  }
685
843
  return this.ownerHistoryFallbackRef;
686
844
  }
845
+ /**
846
+ * The overlay's answer for one positional slot of an overlay-created entity,
847
+ * falling back to the creation payload only when the overlay has NOTHING to
848
+ * say about that slot.
849
+ *
850
+ * **Ask `Map.has`, never `??`.** `setPositionalAttribute(id, slot, null)` is
851
+ * an explicit "clear this slot", and its value is `null`, so `??` reads the
852
+ * overlay's answer as an absence and reinstates the authored one. That is the
853
+ * same overlay-versus-buffer confusion this whole change is about, one
854
+ * attribute wide: an explicit null IS the overlay's answer, and the overlay is
855
+ * the authority. Cleared OwnerHistory came back as the authored reference, and
856
+ * a cleared `HasPropertySets` resurrected the list the user had removed.
857
+ */
858
+ overlaySlotValue(entityId, slot, authored) {
859
+ const overrides = this.mutationView?.getPositionalMutationsForEntity(entityId);
860
+ if (!overrides?.has(slot))
861
+ return authored;
862
+ const value = overrides.get(slot);
863
+ // `Map.get` widens to `| undefined`, which `has` has already ruled out. A
864
+ // slot explicitly set to nothing serializes as `$`, i.e. null.
865
+ return value === undefined ? null : value;
866
+ }
687
867
  /**
688
868
  * Read an element's own OwnerHistory reference (`#id`), or null when the
689
869
  * element omits one (`$`) or cannot be parsed. OwnerHistory is the second
@@ -694,6 +874,17 @@ export class StepExporter {
694
874
  if (cached !== undefined)
695
875
  return cached;
696
876
  let result = null;
877
+ // An overlay-created host has no source line to read, but it does have an
878
+ // authored OwnerHistory in slot 1 — reading only the buffer sent every
879
+ // generated pset on a created entity to the file's first owner history
880
+ // instead of the one the caller named (#2012).
881
+ const overlay = this.mutationView?.getNewEntity(entityId);
882
+ if (overlay) {
883
+ const refs = authoredEntityRefs(this.overlaySlotValue(entityId, OWNER_HISTORY_SLOT, overlay.attributes[OWNER_HISTORY_SLOT]));
884
+ result = refs.length > 0 ? `#${refs[0]}` : null;
885
+ this.ownerHistoryByEntity.set(entityId, result);
886
+ return result;
887
+ }
697
888
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
698
889
  if (entityRef && this.dataStore.source && entityRef.byteLength > 0) {
699
890
  const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
@@ -709,7 +900,7 @@ export class StepExporter {
709
900
  /**
710
901
  * Generate STEP entities for property sets
711
902
  */
712
- generatePropertySetEntities(entityId, psets, allowedEntityIds, typeOwnedPsetNames, random) {
903
+ generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNames, random) {
713
904
  const lines = [];
714
905
  let count = 0;
715
906
  const generatedTypeOwnedPsetIds = new Map();
@@ -733,7 +924,7 @@ export class StepExporter {
733
924
  const propRefs = propertyIds.map(id => `#${id}`).join(',');
734
925
  const globalId = this.generateGlobalId(random);
735
926
  // #ID=IFCPROPERTYSET('GlobalId',#ownerHistory,'Name',$,(#props));
736
- const psetLine = `#${psetId}=IFCPROPERTYSET('${globalId}',${this.resolveOwnerHistoryRef(entityId, allowedEntityIds)},'${escapeStepString(pset.name)}',$,(${propRefs}));`;
927
+ const psetLine = `#${psetId}=IFCPROPERTYSET('${globalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},'${escapeStepString(pset.name)}',$,(${propRefs}));`;
737
928
  lines.push(psetLine);
738
929
  if (typeOwnedPsetNames?.has(pset.name)) {
739
930
  generatedTypeOwnedPsetIds.set(pset.name, psetId);
@@ -744,7 +935,7 @@ export class StepExporter {
744
935
  count++;
745
936
  const relGlobalId = this.generateGlobalId(random);
746
937
  // #ID=IFCRELDEFINESBYPROPERTIES('GlobalId',#ownerHistory,$,$,(#entity),#pset);
747
- const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, allowedEntityIds)},$,$,(#${entityId}),#${psetId});`;
938
+ const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},$,$,(#${entityId}),#${psetId});`;
748
939
  lines.push(relLine);
749
940
  }
750
941
  }
@@ -753,7 +944,7 @@ export class StepExporter {
753
944
  /**
754
945
  * Generate STEP entities for quantity sets (IfcElementQuantity)
755
946
  */
756
- generateQuantitySetEntities(entityId, qsets, allowedEntityIds, random) {
947
+ generateQuantitySetEntities(entityId, qsets, willBeEmitted, random) {
757
948
  const lines = [];
758
949
  let count = 0;
759
950
  for (const qset of qsets) {
@@ -774,13 +965,13 @@ export class StepExporter {
774
965
  const quantRefs = quantityIds.map(id => `#${id}`).join(',');
775
966
  const globalId = this.generateGlobalId(random);
776
967
  // #ID=IFCELEMENTQUANTITY('GlobalId',#ownerHistory,'Name',$,$,(#quants));
777
- const qsetLine = `#${qsetId}=IFCELEMENTQUANTITY('${globalId}',${this.resolveOwnerHistoryRef(entityId, allowedEntityIds)},'${escapeStepString(qset.name)}',$,$,(${quantRefs}));`;
968
+ const qsetLine = `#${qsetId}=IFCELEMENTQUANTITY('${globalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},'${escapeStepString(qset.name)}',$,$,(${quantRefs}));`;
778
969
  lines.push(qsetLine);
779
970
  // Create IfcRelDefinesByProperties to link qset to entity
780
971
  const relId = this.nextExpressId++;
781
972
  count++;
782
973
  const relGlobalId = this.generateGlobalId(random);
783
- const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, allowedEntityIds)},$,$,(#${entityId}),#${qsetId});`;
974
+ const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},$,$,(#${entityId}),#${qsetId});`;
784
975
  lines.push(relLine);
785
976
  }
786
977
  return { lines, count };
@@ -794,17 +985,28 @@ export class StepExporter {
794
985
  if (openParen < 0 || closeParen < openParen) {
795
986
  return entityText;
796
987
  }
797
- const attrNames = getAttributeNames(entityType);
988
+ // Cross-schema, not the IFC4 pin: an IFC4X3-only class (IfcCourse, IfcRoad,
989
+ // IfcBridge, …) resolves no slots under the pin, so every named edit on one
990
+ // was silently discarded here too. Identical for the 755 pinned classes
991
+ // that declare attributes — `attribute-slot-types.test.ts` measures that —
992
+ // so no IFC4 export changes; this only stops dropping edits it used to drop.
993
+ const attrNames = getAttributeNamesAcrossSchemas(entityType);
798
994
  if (attrNames.length === 0) {
799
995
  return entityText;
800
996
  }
801
997
  const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
998
+ // A source line NEVER pads (unlike the overlay-created path): a short
999
+ // argument list here means the file speaks a different schema, and growing
1000
+ // a record we did not author would corrupt it.
802
1001
  let changed = false;
803
1002
  for (const [attrName, value] of attributeMutations) {
804
1003
  const index = attrNames.indexOf(attrName);
805
1004
  if (index < 0 || index >= args.length)
806
1005
  continue;
807
- args[index] = serializeAttributeValue(value, args[index]);
1006
+ // The source path shares every `$`-slot hole with the overlay-created
1007
+ // path, because a source record has plenty of `$` slots of its own. Both
1008
+ // go through the one helper below.
1009
+ args[index] = this.serializeNamedAttribute(entityType, index, value, args[index]);
808
1010
  changed = true;
809
1011
  }
810
1012
  if (!changed) {
@@ -812,6 +1014,92 @@ export class StepExporter {
812
1014
  }
813
1015
  return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
814
1016
  }
1017
+ /**
1018
+ * Serialize one NAMED attribute override into its slot — the single point
1019
+ * both the source-buffer rewrite and the overlay-created rewrite go through.
1020
+ *
1021
+ * `serializeAttributeValue` decides the STEP form by reading the token being
1022
+ * replaced, which is sound only while that token carries type information. A
1023
+ * `$` slot carries none, and both paths have plenty: a source record's
1024
+ * optional attributes are `$`, and overlay-created records pad missing slots
1025
+ * with `$`. So the declared type decides first, and inference is the fallback
1026
+ * for slots the schema does not classify (references, SELECTs, numerics),
1027
+ * where reading the old token is exactly the right heuristic.
1028
+ */
1029
+ serializeNamedAttribute(entityType, index, value, currentToken) {
1030
+ if (getEnumTypedSlots(entityType).has(index))
1031
+ return serializeEnumToken(value);
1032
+ if (getStringTypedSlots(entityType).has(index))
1033
+ return serializeStringSlot(value);
1034
+ return serializeAttributeValue(value, currentToken);
1035
+ }
1036
+ /**
1037
+ * Apply overlay attribute + positional overrides to an OVERLAY-CREATED
1038
+ * entity's argument list (#2006).
1039
+ *
1040
+ * Distinct from {@link applyAttributeMutations} / {@link applyPositionalMutations},
1041
+ * which rewrite a line read out of the source buffer. Here the whole line is
1042
+ * ours: it was serialized moments ago from the creation payload, so the
1043
+ * argument list is the authoring payload's, not the file's. That difference
1044
+ * is why this PADS — `entity_create` takes whatever positional list the
1045
+ * caller passes, so a wall authored with three arguments still has a real
1046
+ * `Tag` slot at index 7, and dropping the edit because the payload was short
1047
+ * would be the very data loss this fixes. The source-buffer path must not
1048
+ * pad: there a short line means a different schema, and growing a record we
1049
+ * did not author would corrupt it.
1050
+ *
1051
+ * Named and positional overrides resolve to a slot index up front and share
1052
+ * ONE padding rule. Two padding rules on one record is how the next bug
1053
+ * starts, and the argument for padding — the class is fixed at creation time,
1054
+ * so a short payload is partial authoring — never depended on which of the
1055
+ * two APIs queued the edit.
1056
+ */
1057
+ applyOverlayEntityOverrides(argsText, entityType, attributeOverrides, positionalOverrides, schemaVersion) {
1058
+ const args = argsText.length > 0 ? splitTopLevelArgs(argsText) : [];
1059
+ const attrNames = getAttributeNamesAcrossSchemas(entityType);
1060
+ const named = [];
1061
+ for (const [attrName, value] of attributeOverrides ?? []) {
1062
+ const index = attrNames.indexOf(attrName);
1063
+ if (index >= 0)
1064
+ named.push([index, value]);
1065
+ }
1066
+ // Grow to the class's FULL declared arity as soon as any override names a
1067
+ // declared slot the creation payload never reached. Growing only as far as
1068
+ // the edited slot would emit eight arguments for an IfcWall that declares
1069
+ // nine: this parser tolerates the truncated record, a schema-validating
1070
+ // consumer rejects the file.
1071
+ //
1072
+ // An index PAST the declared layout is not a slot at all, so it cannot
1073
+ // justify growing the record and stays dropped — as does any override on a
1074
+ // class neither schema source knows, where there is no arity to grow to.
1075
+ let needsPad = named.some(([index]) => index >= args.length);
1076
+ if (!needsPad && positionalOverrides) {
1077
+ for (const [index] of positionalOverrides) {
1078
+ if (index >= args.length && index < attrNames.length) {
1079
+ needsPad = true;
1080
+ break;
1081
+ }
1082
+ }
1083
+ }
1084
+ if (needsPad) {
1085
+ while (args.length < attrNames.length)
1086
+ args.push('$');
1087
+ }
1088
+ // Every `named` index is < attrNames.length by construction, and padding
1089
+ // has taken args.length to at least that, so each one lands.
1090
+ for (const [index, value] of named) {
1091
+ args[index] = this.serializeNamedAttribute(entityType, index, value, args[index]);
1092
+ }
1093
+ if (positionalOverrides && positionalOverrides.size > 0) {
1094
+ const realSlots = getRealTypedSlots(entityType, schemaVersion);
1095
+ for (const [index, value] of positionalOverrides) {
1096
+ if (index < 0 || index >= args.length)
1097
+ continue;
1098
+ args[index] = this.serializePositionalOverride(entityType, index, value, args[index], realSlots, schemaVersion);
1099
+ }
1100
+ }
1101
+ return args.join(',');
1102
+ }
815
1103
  /**
816
1104
  * Apply positional STEP argument overrides to an entity line.
817
1105
  * Used for non-IfcRoot edits (e.g. profile dimensions) where attributes
@@ -1022,25 +1310,31 @@ export class StepExporter {
1022
1310
  * the source: for each related entity, list the rels and property/quantity
1023
1311
  * sets that reference it. Used by the export pre-pass so the per-entity
1024
1312
  * "find owning rels" step is O(K) rather than O(N) per modified entity.
1313
+ *
1314
+ * `relatedByRel` is the same walk read the other way round, so the deleted-host
1315
+ * sweep costs nothing extra.
1025
1316
  */
1026
1317
  buildRelDefinesByPropertiesIndex() {
1027
- const out = new Map();
1318
+ const byEntity = new Map();
1319
+ const relatedByRel = new Map();
1028
1320
  for (const [relId, relRef] of this.dataStore.entityIndex.byId) {
1029
1321
  if (relRef.type.toUpperCase() !== 'IFCRELDEFINESBYPROPERTIES')
1030
1322
  continue;
1031
1323
  const psetId = this.getRelatedPropertySet(relId);
1032
1324
  if (!psetId)
1033
1325
  continue;
1034
- for (const entityId of this.getRelatedEntities(relId)) {
1035
- let bucket = out.get(entityId);
1326
+ const related = this.getRelatedEntities(relId);
1327
+ relatedByRel.set(relId, related);
1328
+ for (const entityId of related) {
1329
+ let bucket = byEntity.get(entityId);
1036
1330
  if (!bucket) {
1037
1331
  bucket = [];
1038
- out.set(entityId, bucket);
1332
+ byEntity.set(entityId, bucket);
1039
1333
  }
1040
1334
  bucket.push({ relId, psetId });
1041
1335
  }
1042
1336
  }
1043
- return out;
1337
+ return { byEntity, relatedByRel };
1044
1338
  }
1045
1339
  /**
1046
1340
  * Get entity IDs related by IfcRelDefinesByProperties (the related objects)
@@ -1160,56 +1454,32 @@ export class StepExporter {
1160
1454
  return ids;
1161
1455
  }
1162
1456
  /**
1163
- * Check whether an entity is an IFC type object (e.g. IfcWallType).
1164
- */
1165
- isTypeEntity(entityId) {
1166
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1167
- return entityRef?.type.toUpperCase().endsWith('TYPE') ?? false;
1168
- }
1169
- /**
1170
- * Get the full HasPropertySets ID list from a type entity.
1171
- * This preserves both property and quantity definitions already assigned there.
1457
+ * The full HasPropertySets id list of a type object, from whichever authority
1458
+ * owns the record.
1459
+ *
1460
+ * Slot 5 is `HasPropertySets` on every `IfcTypeObject` subtype. For a source
1461
+ * record the list is parsed out of the file; for an overlay-created type it is
1462
+ * read off the authored payload, where a reference is the documented `'#42'`
1463
+ * string form. Reading only the source made every pset on a created
1464
+ * `IfcWallType` look unowned, which is how it ended up on an occurrence
1465
+ * relation instead (#2012).
1172
1466
  */
1173
- getTypeOwnedHasPropertySetIds(entityId) {
1467
+ getTypeOwnedHasPropertySetIds(entityId, effective) {
1468
+ if (effective.isOverlayCreated(entityId)) {
1469
+ const authored = this.mutationView?.getNewEntity(entityId)?.attributes?.[HAS_PROPERTY_SETS_SLOT];
1470
+ return authoredEntityRefs(this.overlaySlotValue(entityId, HAS_PROPERTY_SETS_SLOT, authored));
1471
+ }
1174
1472
  if (!this.entityExtractor)
1175
1473
  return [];
1176
1474
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1177
1475
  if (!entityRef)
1178
1476
  return [];
1179
1477
  const entity = this.entityExtractor.extractEntity(entityRef);
1180
- const hasPropertySets = entity?.attributes?.[5];
1478
+ const hasPropertySets = entity?.attributes?.[HAS_PROPERTY_SETS_SLOT];
1181
1479
  if (!Array.isArray(hasPropertySets))
1182
1480
  return [];
1183
1481
  return hasPropertySets.filter((value) => typeof value === 'number');
1184
1482
  }
1185
- /**
1186
- * Rewrite a type entity so its HasPropertySets attribute points to replacement psets.
1187
- */
1188
- rewriteTypeEntityHasPropertySets(entityId, originalPsetIds, affectedPsetNames, replacementPsetIds) {
1189
- const rewrittenIds = [];
1190
- const usedReplacementNames = new Set();
1191
- for (const psetId of originalPsetIds) {
1192
- const psetName = this.getPropertySetName(psetId);
1193
- if (psetName && affectedPsetNames.has(psetName)) {
1194
- const replacementId = replacementPsetIds.get(psetName);
1195
- if (replacementId !== undefined) {
1196
- rewrittenIds.push(replacementId);
1197
- usedReplacementNames.add(psetName);
1198
- }
1199
- continue;
1200
- }
1201
- rewrittenIds.push(psetId);
1202
- }
1203
- for (const [psetName, psetId] of replacementPsetIds) {
1204
- if (!usedReplacementNames.has(psetName)) {
1205
- rewrittenIds.push(psetId);
1206
- }
1207
- }
1208
- const attrValue = rewrittenIds.length > 0
1209
- ? `(${rewrittenIds.map(id => `#${id}`).join(',')})`
1210
- : '$';
1211
- return this.replaceEntityAttribute(entityId, 5, attrValue);
1212
- }
1213
1483
  /**
1214
1484
  * Replace a single top-level STEP attribute in an entity line.
1215
1485
  */