@ifc-lite/export 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,24 +125,43 @@ 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();
138
+ // Attribute values come from the *overlay*, never from the mutation
139
+ // history. The history is append-only and undo writes its reverse edit
140
+ // with `skipHistory: true`, so a superseded UPDATE_ATTRIBUTE record keeps
141
+ // its stale `newValue` forever — replaying it resurrects edits the user
142
+ // undid (#1957). The overlay is what the editor shows, and it is already
143
+ // the source for psets, quantities, positional attributes and retypes
144
+ // below, so attributes were the sole outlier.
145
+ for (const [entityId, attrs] of this.mutationView.getAttributeMutationsByEntity()) {
146
+ modifiedEntities.add(entityId);
147
+ let target = modifiedAttributes.get(entityId);
148
+ if (!target) {
149
+ target = new Map();
150
+ modifiedAttributes.set(entityId, target);
151
+ }
152
+ for (const [name, value] of attrs)
153
+ target.set(name, value);
154
+ }
114
155
  // Group mutations by entity, separating property vs quantity mutations
115
156
  const entityPropMutations = new Map();
116
157
  const entityQuantMutations = new Map();
117
158
  for (const mutation of mutations) {
118
- if (mutation.type === 'UPDATE_ATTRIBUTE' && mutation.attributeName) {
119
- modifiedEntities.add(mutation.entityId);
120
- if (!modifiedAttributes.has(mutation.entityId)) {
121
- modifiedAttributes.set(mutation.entityId, new Map());
122
- }
123
- modifiedAttributes.get(mutation.entityId).set(mutation.attributeName, mutation.newValue == null ? '' : String(mutation.newValue));
159
+ // Handled above, off the overlay. Skipped explicitly because an
160
+ // UPDATE_ATTRIBUTE record can also carry a `psetName` (georef fields
161
+ // encode their target entity there) and must not be mistaken for a
162
+ // property-set edit.
163
+ if (mutation.type === 'UPDATE_ATTRIBUTE')
124
164
  continue;
125
- }
126
165
  if (!mutation.psetName)
127
166
  continue;
128
167
  const isQuantity = mutation.type === 'CREATE_QUANTITY' || mutation.type === 'UPDATE_QUANTITY' || mutation.type === 'DELETE_QUANTITY';
@@ -137,12 +176,48 @@ export class StepExporter {
137
176
  // below previously walked every entity in `entityIndex.byId` per
138
177
  // modified entity (O(E·N)); the index keeps the per-entity step
139
178
  // O(K) where K is the number of rels referencing that entity.
140
- 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
+ }
141
190
  // Collect modified property sets and find original psets to skip
142
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;
143
212
  modifiedEntities.add(entityId);
144
213
  modifiedPsets.set(entityId, psetNames);
145
- 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++;
146
221
  // Get the FULL mutated property sets for this entity (merged base + mutations)
147
222
  const allPsets = this.mutationView.getForEntity(entityId);
148
223
  const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
@@ -171,8 +246,8 @@ export class StepExporter {
171
246
  }
172
247
  }
173
248
  }
174
- if (this.isTypeEntity(entityId)) {
175
- const typeOwnedPsetIds = this.getTypeOwnedHasPropertySetIds(entityId);
249
+ if (isTypeClass(effective.typeOf(entityId))) {
250
+ const typeOwnedPsetIds = this.getTypeOwnedHasPropertySetIds(entityId, effective);
176
251
  const typeOwnedAffected = new Set();
177
252
  for (const psetId of typeOwnedPsetIds) {
178
253
  const psetName = this.getPropertySetName(psetId);
@@ -201,8 +276,13 @@ export class StepExporter {
201
276
  if (options.includeQuantities === false)
202
277
  entityQuantMutations.clear();
203
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;
204
282
  modifiedEntities.add(entityId);
205
- 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))
206
286
  modifiedEntityCount++;
207
287
  const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
208
288
  const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
@@ -227,6 +307,12 @@ export class StepExporter {
227
307
  }
228
308
  }
229
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;
230
316
  if (!entityPropMutations.has(entityId) && !entityQuantMutations.has(entityId)) {
231
317
  modifiedEntityCount++;
232
318
  }
@@ -397,20 +483,65 @@ export class StepExporter {
397
483
  },
398
484
  };
399
485
  }
400
- // Complete view over byId + any deferred property atoms. Walking byId alone
401
- // drops deferred atoms while keeping the IfcPropertySet/IfcElementQuantity
402
- // references to them, producing dangling #-refs in the output.
403
- const completeIndex = getCompleteEntityIndex(this.dataStore);
404
- // 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).
405
493
  let allowedEntityIds = null;
406
494
  if (options.visibleOnly && this.dataStore.source) {
407
- const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null);
408
- 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);
409
497
  // Second pass: collect IFCSTYLEDITEM entities that reference included
410
498
  // geometry. Styled items reference geometry items but nothing references
411
499
  // them back, so the forward closure misses them.
412
- collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: completeIndex, byType: this.dataStore.entityIndex.byType });
500
+ collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
413
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
+ };
414
545
  // A modified pset is replaced wholesale, which skips ALL of its member atoms.
415
546
  // But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
416
547
  // IsExternal IfcPropertySingleValue shared by dozens of psets), so skipping a
@@ -420,13 +551,10 @@ export class StepExporter {
420
551
  // Export original entities from source buffer, SKIPPING modified property sets
421
552
  if (!options.deltaOnly && this.dataStore.source) {
422
553
  const source = this.dataStore.source;
423
- // Extract existing entities from source
424
- const overlayActive = !!this.mutationView && (options.applyMutations !== false);
425
- for (const [expressId, entityRef] of completeIndex) {
426
- // Skip entities deleted via the overlay (only when mutations are applied)
427
- if (overlayActive && typeof this.mutationView.isDeleted === 'function' && this.mutationView.isDeleted(expressId)) {
428
- continue;
429
- }
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) {
430
558
  // Skip overlay-only entities — emitted by the new-entities pass below
431
559
  if (entityRef.byteLength === 0 || entityRef.byteOffset < 0) {
432
560
  continue;
@@ -504,30 +632,43 @@ export class StepExporter {
504
632
  }
505
633
  }
506
634
  // Generate new property entities for mutations (these REPLACE the skipped ones)
635
+ const generatedTypeOwnedPsetIds = new Map();
507
636
  for (const { entityId, psets } of newPropertySets) {
508
- 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);
509
642
  entities.push(...newEntities.lines);
510
643
  newEntityCount += newEntities.count;
511
- const typeOwnedPsetNames = typeOwnedPsetNamesByEntity.get(entityId);
512
- if (typeOwnedPsetNames && typeOwnedPsetNames.size > 0) {
513
- const rewritten = this.rewriteTypeEntityHasPropertySets(entityId, typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, newEntities.generatedTypeOwnedPsetIds);
514
- if (rewritten) {
515
- rewrittenEntityLines.set(entityId, rewritten);
516
- }
517
- }
644
+ generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
518
645
  }
519
- // 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.
520
650
  for (const [entityId, typeOwnedPsetNames] of typeOwnedPsetNamesByEntity) {
521
- 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))
522
654
  continue;
523
- const rewritten = this.rewriteTypeEntityHasPropertySets(entityId, typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, new Map());
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);
660
+ continue;
661
+ }
662
+ const rewritten = this.replaceEntityAttribute(entityId, HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken(resolved));
524
663
  if (rewritten) {
525
664
  rewrittenEntityLines.set(entityId, rewritten);
526
665
  }
527
666
  }
528
667
  // Generate new quantity entities for mutations
529
668
  for (const { entityId, qsets } of newQuantitySets) {
530
- 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);
531
672
  entities.push(...newEntities.lines);
532
673
  newEntityCount += newEntities.count;
533
674
  }
@@ -580,6 +721,32 @@ export class StepExporter {
580
721
  else {
581
722
  argsText = serializeEntityArgs(entity.type, entity.attributes, sourceSchema);
582
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
+ }
583
750
  const line = `#${entity.expressId}=${upperType}(${argsText});`;
584
751
  if (converting) {
585
752
  const converted = convertStepLine(line, sourceSchema, schema, options.guidRandom);
@@ -647,28 +814,56 @@ export class StepExporter {
647
814
  * MANDATORY in IFC2X3 (IfcRoot.OwnerHistory), so emitting `$` yields an invalid
648
815
  * IFC2X3 file that strict readers (e.g. BIM Vision) reject.
649
816
  *
650
- * Prefer the host element's OWN owner history: it is the semantically correct
651
- * owner and being reachable from an exported root — is guaranteed to survive a
652
- * `visibleOnly` closure. Fall back to any owner history still inside the export
653
- * (closure-aware) so we never reference one a `visibleOnly` / isolated export
654
- * 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.
655
827
  */
656
- resolveOwnerHistoryRef(hostEntityId, allowedEntityIds) {
828
+ resolveOwnerHistoryRef(hostEntityId, willBeEmitted) {
657
829
  const own = this.getOwnerHistoryRefOfEntity(hostEntityId);
658
830
  if (own !== null) {
659
831
  const ownId = parseInt(own.slice(1), 10);
660
- if (allowedEntityIds === null || allowedEntityIds.has(ownId))
832
+ if (willBeEmitted(ownId))
661
833
  return own;
662
834
  }
663
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.
664
839
  const ids = this.dataStore.entityIndex.byType.get('IFCOWNERHISTORY') ?? [];
665
- const surviving = allowedEntityIds === null
666
- ? ids[0]
667
- : ids.find((id) => allowedEntityIds.has(id));
840
+ const surviving = ids.find((id) => willBeEmitted(id));
668
841
  this.ownerHistoryFallbackRef = surviving !== undefined ? `#${surviving}` : '$';
669
842
  }
670
843
  return this.ownerHistoryFallbackRef;
671
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
+ }
672
867
  /**
673
868
  * Read an element's own OwnerHistory reference (`#id`), or null when the
674
869
  * element omits one (`$`) or cannot be parsed. OwnerHistory is the second
@@ -679,6 +874,17 @@ export class StepExporter {
679
874
  if (cached !== undefined)
680
875
  return cached;
681
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
+ }
682
888
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
683
889
  if (entityRef && this.dataStore.source && entityRef.byteLength > 0) {
684
890
  const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
@@ -694,7 +900,7 @@ export class StepExporter {
694
900
  /**
695
901
  * Generate STEP entities for property sets
696
902
  */
697
- generatePropertySetEntities(entityId, psets, allowedEntityIds, typeOwnedPsetNames, random) {
903
+ generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNames, random) {
698
904
  const lines = [];
699
905
  let count = 0;
700
906
  const generatedTypeOwnedPsetIds = new Map();
@@ -718,7 +924,7 @@ export class StepExporter {
718
924
  const propRefs = propertyIds.map(id => `#${id}`).join(',');
719
925
  const globalId = this.generateGlobalId(random);
720
926
  // #ID=IFCPROPERTYSET('GlobalId',#ownerHistory,'Name',$,(#props));
721
- 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}));`;
722
928
  lines.push(psetLine);
723
929
  if (typeOwnedPsetNames?.has(pset.name)) {
724
930
  generatedTypeOwnedPsetIds.set(pset.name, psetId);
@@ -729,7 +935,7 @@ export class StepExporter {
729
935
  count++;
730
936
  const relGlobalId = this.generateGlobalId(random);
731
937
  // #ID=IFCRELDEFINESBYPROPERTIES('GlobalId',#ownerHistory,$,$,(#entity),#pset);
732
- const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, allowedEntityIds)},$,$,(#${entityId}),#${psetId});`;
938
+ const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},$,$,(#${entityId}),#${psetId});`;
733
939
  lines.push(relLine);
734
940
  }
735
941
  }
@@ -738,7 +944,7 @@ export class StepExporter {
738
944
  /**
739
945
  * Generate STEP entities for quantity sets (IfcElementQuantity)
740
946
  */
741
- generateQuantitySetEntities(entityId, qsets, allowedEntityIds, random) {
947
+ generateQuantitySetEntities(entityId, qsets, willBeEmitted, random) {
742
948
  const lines = [];
743
949
  let count = 0;
744
950
  for (const qset of qsets) {
@@ -759,13 +965,13 @@ export class StepExporter {
759
965
  const quantRefs = quantityIds.map(id => `#${id}`).join(',');
760
966
  const globalId = this.generateGlobalId(random);
761
967
  // #ID=IFCELEMENTQUANTITY('GlobalId',#ownerHistory,'Name',$,$,(#quants));
762
- 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}));`;
763
969
  lines.push(qsetLine);
764
970
  // Create IfcRelDefinesByProperties to link qset to entity
765
971
  const relId = this.nextExpressId++;
766
972
  count++;
767
973
  const relGlobalId = this.generateGlobalId(random);
768
- const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, allowedEntityIds)},$,$,(#${entityId}),#${qsetId});`;
974
+ const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},$,$,(#${entityId}),#${qsetId});`;
769
975
  lines.push(relLine);
770
976
  }
771
977
  return { lines, count };
@@ -779,17 +985,28 @@ export class StepExporter {
779
985
  if (openParen < 0 || closeParen < openParen) {
780
986
  return entityText;
781
987
  }
782
- 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);
783
994
  if (attrNames.length === 0) {
784
995
  return entityText;
785
996
  }
786
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.
787
1001
  let changed = false;
788
1002
  for (const [attrName, value] of attributeMutations) {
789
1003
  const index = attrNames.indexOf(attrName);
790
1004
  if (index < 0 || index >= args.length)
791
1005
  continue;
792
- 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]);
793
1010
  changed = true;
794
1011
  }
795
1012
  if (!changed) {
@@ -797,6 +1014,92 @@ export class StepExporter {
797
1014
  }
798
1015
  return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
799
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
+ }
800
1103
  /**
801
1104
  * Apply positional STEP argument overrides to an entity line.
802
1105
  * Used for non-IfcRoot edits (e.g. profile dimensions) where attributes
@@ -1007,25 +1310,31 @@ export class StepExporter {
1007
1310
  * the source: for each related entity, list the rels and property/quantity
1008
1311
  * sets that reference it. Used by the export pre-pass so the per-entity
1009
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.
1010
1316
  */
1011
1317
  buildRelDefinesByPropertiesIndex() {
1012
- const out = new Map();
1318
+ const byEntity = new Map();
1319
+ const relatedByRel = new Map();
1013
1320
  for (const [relId, relRef] of this.dataStore.entityIndex.byId) {
1014
1321
  if (relRef.type.toUpperCase() !== 'IFCRELDEFINESBYPROPERTIES')
1015
1322
  continue;
1016
1323
  const psetId = this.getRelatedPropertySet(relId);
1017
1324
  if (!psetId)
1018
1325
  continue;
1019
- for (const entityId of this.getRelatedEntities(relId)) {
1020
- 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);
1021
1330
  if (!bucket) {
1022
1331
  bucket = [];
1023
- out.set(entityId, bucket);
1332
+ byEntity.set(entityId, bucket);
1024
1333
  }
1025
1334
  bucket.push({ relId, psetId });
1026
1335
  }
1027
1336
  }
1028
- return out;
1337
+ return { byEntity, relatedByRel };
1029
1338
  }
1030
1339
  /**
1031
1340
  * Get entity IDs related by IfcRelDefinesByProperties (the related objects)
@@ -1145,56 +1454,32 @@ export class StepExporter {
1145
1454
  return ids;
1146
1455
  }
1147
1456
  /**
1148
- * Check whether an entity is an IFC type object (e.g. IfcWallType).
1149
- */
1150
- isTypeEntity(entityId) {
1151
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1152
- return entityRef?.type.toUpperCase().endsWith('TYPE') ?? false;
1153
- }
1154
- /**
1155
- * Get the full HasPropertySets ID list from a type entity.
1156
- * 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).
1157
1466
  */
1158
- 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
+ }
1159
1472
  if (!this.entityExtractor)
1160
1473
  return [];
1161
1474
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1162
1475
  if (!entityRef)
1163
1476
  return [];
1164
1477
  const entity = this.entityExtractor.extractEntity(entityRef);
1165
- const hasPropertySets = entity?.attributes?.[5];
1478
+ const hasPropertySets = entity?.attributes?.[HAS_PROPERTY_SETS_SLOT];
1166
1479
  if (!Array.isArray(hasPropertySets))
1167
1480
  return [];
1168
1481
  return hasPropertySets.filter((value) => typeof value === 'number');
1169
1482
  }
1170
- /**
1171
- * Rewrite a type entity so its HasPropertySets attribute points to replacement psets.
1172
- */
1173
- rewriteTypeEntityHasPropertySets(entityId, originalPsetIds, affectedPsetNames, replacementPsetIds) {
1174
- const rewrittenIds = [];
1175
- const usedReplacementNames = new Set();
1176
- for (const psetId of originalPsetIds) {
1177
- const psetName = this.getPropertySetName(psetId);
1178
- if (psetName && affectedPsetNames.has(psetName)) {
1179
- const replacementId = replacementPsetIds.get(psetName);
1180
- if (replacementId !== undefined) {
1181
- rewrittenIds.push(replacementId);
1182
- usedReplacementNames.add(psetName);
1183
- }
1184
- continue;
1185
- }
1186
- rewrittenIds.push(psetId);
1187
- }
1188
- for (const [psetName, psetId] of replacementPsetIds) {
1189
- if (!usedReplacementNames.has(psetName)) {
1190
- rewrittenIds.push(psetId);
1191
- }
1192
- }
1193
- const attrValue = rewrittenIds.length > 0
1194
- ? `(${rewrittenIds.map(id => `#${id}`).join(',')})`
1195
- : '$';
1196
- return this.replaceEntityAttribute(entityId, 5, attrValue);
1197
- }
1198
1483
  /**
1199
1484
  * Replace a single top-level STEP attribute in an entity line.
1200
1485
  */