@ifc-lite/export 2.8.3 → 2.8.5

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 (51) hide show
  1. package/dist/declared-property-type.d.ts +107 -0
  2. package/dist/declared-property-type.d.ts.map +1 -0
  3. package/dist/declared-property-type.js +236 -0
  4. package/dist/declared-property-type.js.map +1 -0
  5. package/dist/delta-modification-ledger.d.ts +148 -0
  6. package/dist/delta-modification-ledger.d.ts.map +1 -0
  7. package/dist/delta-modification-ledger.js +194 -0
  8. package/dist/delta-modification-ledger.js.map +1 -0
  9. package/dist/demesh-session.d.ts.map +1 -1
  10. package/dist/demesh-session.js +7 -0
  11. package/dist/demesh-session.js.map +1 -1
  12. package/dist/in-place-nomination.d.ts +65 -0
  13. package/dist/in-place-nomination.d.ts.map +1 -0
  14. package/dist/in-place-nomination.js +27 -0
  15. package/dist/in-place-nomination.js.map +1 -0
  16. package/dist/merged-exporter.js +1 -1
  17. package/dist/retype.d.ts.map +1 -1
  18. package/dist/retype.js +2 -1
  19. package/dist/retype.js.map +1 -1
  20. package/dist/select-qualification.d.ts +12 -0
  21. package/dist/select-qualification.d.ts.map +1 -1
  22. package/dist/select-qualification.js +12 -1
  23. package/dist/select-qualification.js.map +1 -1
  24. package/dist/source-ref-bounds.d.ts +53 -0
  25. package/dist/source-ref-bounds.d.ts.map +1 -0
  26. package/dist/source-ref-bounds.js +25 -0
  27. package/dist/source-ref-bounds.js.map +1 -0
  28. package/dist/step-argument-parser.d.ts +90 -0
  29. package/dist/step-argument-parser.d.ts.map +1 -0
  30. package/dist/step-argument-parser.js +207 -0
  31. package/dist/step-argument-parser.js.map +1 -0
  32. package/dist/step-exporter.d.ts +45 -4
  33. package/dist/step-exporter.d.ts.map +1 -1
  34. package/dist/step-exporter.js +465 -84
  35. package/dist/step-exporter.js.map +1 -1
  36. package/dist/step-file-assembly.d.ts +35 -0
  37. package/dist/step-file-assembly.d.ts.map +1 -0
  38. package/dist/step-file-assembly.js +102 -0
  39. package/dist/step-file-assembly.js.map +1 -0
  40. package/dist/step-serialization.d.ts +48 -48
  41. package/dist/step-serialization.d.ts.map +1 -1
  42. package/dist/step-serialization.js +90 -182
  43. package/dist/step-serialization.js.map +1 -1
  44. package/dist/type-owned-psets.d.ts +36 -0
  45. package/dist/type-owned-psets.d.ts.map +1 -1
  46. package/dist/type-owned-psets.js +41 -0
  47. package/dist/type-owned-psets.js.map +1 -1
  48. package/dist/unit-normalize.d.ts.map +1 -1
  49. package/dist/unit-normalize.js +13 -1
  50. package/dist/unit-normalize.js.map +1 -1
  51. package/package.json +7 -6
@@ -1,18 +1,24 @@
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 { asSourceBytes, EntityExtractor, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
4
+ import { asSourceBytes, EntityExtractor, extractQuantitiesOnDemand, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
5
5
  import { generateIfcGuid } from '@ifc-lite/encoding';
6
6
  import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
7
7
  import { convertStepLine, needsConversion } from './schema-converter.js';
8
8
  import { retypeStepLine, retypeArgTokens } from './retype.js';
9
9
  import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
10
+ import { createModificationLedger, recordSourceLineDelivery, } from './delta-modification-ledger.js';
11
+ import { nominateDeliveredInPlaceEdits } from './in-place-nomination.js';
12
+ import { createSourceRefReader } from './source-ref-bounds.js';
10
13
  import { authoredEntityRefs, getEffectiveEntityIndex } from './effective-index.js';
11
- import { HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken, isTypeClass, resolveTypeOwnedPsetIds, } from './type-owned-psets.js';
12
- import { escapeStepString, toStepReal, quantityTypeToIfcType, serializePropertyValue, serializeAttributeValue, serializeStepValue, tokenIsRealLiteral, splitTopLevelArgs, splitTopLevelStepArguments, assembleStepBytes, } from './step-serialization.js';
14
+ import { HAS_PROPERTY_SETS_SLOT, isTypeClass, resolveTypeOwnedPsetIds, rewriteTypeOwnedPsetLine, typeOwnedPsetRewriteWarning, } from './type-owned-psets.js';
15
+ import { escapeStepString, toStepReal, quantityTypeToIfcType, serializeAttributeValue, serializeStepValue, tokenIsRealLiteral, } from './step-serialization.js';
16
+ import { splitTopLevelArgs } from './step-argument-parser.js';
17
+ import { assembleStepBytes } from './step-file-assembly.js';
13
18
  import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
14
19
  import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
15
20
  import { serializeQualifiedSelectSlot } from './select-qualification.js';
21
+ import { serializeNominalValue } from './declared-property-type.js';
16
22
  /**
17
23
  * UTF-8 decode of `[start, end)` of the source, accepting either the raw bytes
18
24
  * or the {@link IfcSourceBytes} accessor (#2183). Replaces the direct
@@ -25,6 +31,21 @@ function decodeRange(src, start, end) {
25
31
  }
26
32
  /** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
27
33
  const OWNER_HISTORY_SLOT = 1;
34
+ /**
35
+ * The store the extractor THIS class installed on a view currently reads
36
+ * (#2487). The extractor is installed once per view and closes over this box
37
+ * rather than over a store directly, so a later export of the same view against
38
+ * a different store re-points it instead of answering from the first file.
39
+ *
40
+ * A box, and not a `WeakSet` of views, because ownership has to reflect the
41
+ * CURRENT state and not the historical fact that an export once installed
42
+ * something. `setQuantityExtractor` is public: a caller may install its own
43
+ * afterwards, and a marker saying "the exporter owns this view" would then keep
44
+ * overwriting a caller-supplied base forever. With a box, the second export
45
+ * writes to a box nothing reads any more and never calls the setter again, so
46
+ * the caller's extractor stands. Weak, so it never keeps a session alive.
47
+ */
48
+ const exporterQuantityBase = new WeakMap();
28
49
  /**
29
50
  * Message for the one refusal `export()` can report, shared by the returned
30
51
  * `stats.warnings` entry and the console line so the two cannot drift.
@@ -68,7 +89,6 @@ export class StepExporter {
68
89
  export(options) {
69
90
  const entities = [];
70
91
  let newEntityCount = 0;
71
- let modifiedEntityCount = 0;
72
92
  // Both owner-history caches are per-EXPORT, not per-exporter: they now
73
93
  // depend on `willBeEmitted`, which depends on this call's options. Reusing
74
94
  // one exporter for a `visibleOnly` export and then a full one would
@@ -140,6 +160,11 @@ export class StepExporter {
140
160
  // entity has no source bytes, so the source-iteration pass below never sees
141
161
  // it and the new-entities pass at the end owns its line entirely (#2006).
142
162
  const isOverlayCreated = (entityId) => effective.isOverlayCreated(entityId);
163
+ // Does this record describe a line this export can actually READ out of the
164
+ // source? One predicate for every byte-range gate below, so they cannot
165
+ // disagree — see `source-ref-bounds.ts` for the corrupt file the weaker
166
+ // "is there a source / does the ref claim bytes" pair let through (#2491).
167
+ const isReadableSourceRef = createSourceRefReader(this.dataStore.source);
143
168
  // Build visible-only closure if requested. Classification, the closure walk
144
169
  // and the style pass all run over the EFFECTIVE index: an overlay-created
145
170
  // product becomes a root by the same type rules as a parsed one, the walk
@@ -188,15 +213,35 @@ export class StepExporter {
188
213
  if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
189
214
  return false;
190
215
  const ref = effective.get(entityId);
191
- if (!ref || ref.byteLength <= 0 || ref.byteOffset < 0)
216
+ // The ref must be READABLE, not merely non-empty: a range this source
217
+ // cannot address decodes to the empty string, which used to be pushed
218
+ // into the file as a blank line while everything generated FOR the host
219
+ // still named it (#2491).
220
+ if (!ref || !isReadableSourceRef(ref))
192
221
  return false;
193
222
  if (options.deltaOnly !== true && isGeometryExcluded(entityId, ref.type))
194
223
  return false;
195
224
  return true;
196
225
  };
226
+ // Under `deltaOnly` a nomination only becomes a count once some pass has
227
+ // actually written content that delivers THAT KIND of edit for the host —
228
+ // see `delta-modification-ledger.ts` for why the two are not the same event
229
+ // in that mode, and why the pair is (entity, kind) rather than the entity
230
+ // (#2462).
231
+ const modifications = createModificationLedger(options.deltaOnly === true);
232
+ /**
233
+ * Hosts whose in-place named-attribute edits a FULL export may count, per
234
+ * kind. Filled by the collection passes below and read by the two passes
235
+ * that write a rewritten source line — see `in-place-nomination.ts` for why
236
+ * the nomination waits for the rewrite in this mode and not under
237
+ * `deltaOnly` (#2483).
238
+ */
239
+ const inPlaceNominees = {
240
+ attribute: new Set(),
241
+ georeferencing: new Set(),
242
+ };
197
243
  // Collect entities that need to be modified or created
198
244
  const modifiedEntities = new Set();
199
- const modifiedPsets = new Map(); // entityId -> psetNames being modified
200
245
  const modifiedAttributes = new Map();
201
246
  const newPropertySets = [];
202
247
  const newQuantitySets = [];
@@ -289,14 +334,22 @@ export class StepExporter {
289
334
  if (effective.isDeleted(entityId))
290
335
  continue;
291
336
  modifiedEntities.add(entityId);
292
- modifiedPsets.set(entityId, psetNames);
293
337
  // Same rule as the attribute loop below: an overlay-CREATED entity is
294
338
  // emitted once, by the new-entities pass, and already counted in
295
339
  // `newEntityCount` — as are the pset entities this loop goes on to
296
340
  // generate. Only the COUNT is guarded; the entity still records its
297
341
  // pset edits and still emits them.
298
- if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId))
299
- modifiedEntityCount++;
342
+ //
343
+ // A NOMINATION, in both modes, never a count on its own: this site sees
344
+ // a pset NAME the session touched, not whether that name resolves to
345
+ // anything. `deletePropertySet(id, 'AName')` on a host that owns no such
346
+ // set reaches here and changes nothing at all, and used to put "1
347
+ // modification" in the header of a byte-identical file (#2474). What
348
+ // settles it is the generator's `recordEmitted` and the skip branches'
349
+ // `recordWithheld` below.
350
+ if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId)) {
351
+ modifications.nominate(entityId, 'property-set');
352
+ }
300
353
  // Get the FULL mutated property sets for this entity (merged base + mutations)
301
354
  const allPsets = this.mutationView.getForEntity(entityId);
302
355
  const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
@@ -322,6 +375,12 @@ export class StepExporter {
322
375
  for (const propId of propIds) {
323
376
  skipPropertySetIds.add(propId);
324
377
  }
378
+ // The other half of "did this edit change the file": a full export
379
+ // applies a set DELETION by leaving these lines out, and produces
380
+ // no replacement content to record an emission for. Without this
381
+ // the count would settle from the generator alone and a real
382
+ // deletion would stop counting along with the no-op one (#2474).
383
+ modifications.recordWithheld(entityId, 'property-set');
325
384
  }
326
385
  }
327
386
  }
@@ -338,6 +397,12 @@ export class StepExporter {
338
397
  for (const propId of propIds) {
339
398
  skipPropertySetIds.add(propId);
340
399
  }
400
+ // No `recordWithheld` twin of the rel-defined branch above, and
401
+ // deliberately: a name that matches an OWNED pset is either dropped
402
+ // from the resolved list or swapped for the replacement this export
403
+ // generated, so slot 5 always comes back different and the repoint
404
+ // below records the emission for it. A second record here would be
405
+ // one no mutation can kill.
341
406
  }
342
407
  for (const psetName of psetNames) {
343
408
  if (!relDefinedPsetNames.has(psetName)) {
@@ -354,33 +419,134 @@ export class StepExporter {
354
419
  // Collect modified quantity sets (only if quantities are included)
355
420
  if (options.includeQuantities === false)
356
421
  entityQuantMutations.clear();
422
+ // A quantity overlay with nothing under it regenerates a source quantity
423
+ // set from the edited quantity ALONE, and the skip loop below then
424
+ // withholds the source lines that held its siblings (#2487). Unlike
425
+ // properties — whose base falls back to the `baseTable` the view was
426
+ // constructed with — quantities have only the opt-in
427
+ // `setQuantityExtractor`, so the default really is an empty base, and
428
+ // four in-tree callers plus every external embedder never set it.
429
+ //
430
+ // The exporter is the one place that always holds the missing half: it
431
+ // was handed the very store the view is an overlay ON. Supplying it here
432
+ // makes the loss impossible for every caller rather than for the callers
433
+ // we happened to find, and a view that resolves its own quantities (the
434
+ // viewer, MCP, the CLI headless backend) is never overwritten.
435
+ //
436
+ // The extractor closes over ONE store, and the view outlives this export.
437
+ // So it closes over a BOX this class owns instead: a second export of the
438
+ // same view against a DIFFERENT store re-points that box rather than
439
+ // reading the first store's quantities, which is the one way "install only
440
+ // when absent" could have answered from the wrong file. The setter is
441
+ // called at most once per view, so a caller that installs its own
442
+ // extractor at any point — before the first export or after it — keeps it.
443
+ //
444
+ // `hasQuantityBase` and `setQuantityExtractor` are probed, like every other
445
+ // optional view capability this class reaches for (`peekNextExpressId`,
446
+ // `getNewEntities`, `getEntityTypeMutation`): `MutablePropertyView` is
447
+ // published API arriving from a separately versioned package, and callers
448
+ // pass partial and duck-typed views. `hasQuantityBase` is newer than
449
+ // `setQuantityExtractor`, and without it there is no way to tell an empty
450
+ // base from a caller-supplied one — so an older view falls back to the
451
+ // pre-#2487 behaviour (no base supplied) rather than risk overwriting one.
452
+ const quantityView = this.mutationView;
453
+ if (entityQuantMutations.size > 0 &&
454
+ typeof quantityView.setQuantityExtractor === 'function' &&
455
+ typeof quantityView.hasQuantityBase === 'function') {
456
+ const installed = exporterQuantityBase.get(quantityView);
457
+ if (installed) {
458
+ // Ours, or a caller's that replaced ours: re-pointing the box is a
459
+ // no-op in the second case, and calling the setter again is what
460
+ // would not be.
461
+ installed.store = this.dataStore;
462
+ }
463
+ else if (!quantityView.hasQuantityBase()) {
464
+ const box = { store: this.dataStore };
465
+ exporterQuantityBase.set(quantityView, box);
466
+ quantityView.setQuantityExtractor((id) => extractQuantitiesOnDemand(box.store, id));
467
+ }
468
+ }
357
469
  for (const [entityId, qsetNames] of entityQuantMutations) {
358
470
  // Same rule as the property loop above: a deleted entity removes nothing.
359
471
  if (effective.isDeleted(entityId))
360
472
  continue;
361
473
  modifiedEntities.add(entityId);
362
474
  // See the property loop above — an overlay-created entity is counted as
363
- // new, not modified.
364
- if (!isOverlayCreated(entityId) && !modifiedPsets.has(entityId) && hasEmittableHostBytes(entityId))
365
- modifiedEntityCount++;
475
+ // new, not modified. The pset loop's own nomination no longer has to be
476
+ // excluded to avoid a double count: the ledger settles per ENTITY, so a
477
+ // host with both a pset and a qset edit counts once whatever is
478
+ // nominated. Nominating both buys the opposite — an accurate warning
479
+ // when the qset half is the half a delta cannot carry.
480
+ //
481
+ // Settled from effect like its property-set twin (#2474). The reachable
482
+ // no-op here is an UNDONE quantity-set creation whose name matches NO
483
+ // source set: `getMutations()` is append-only, so the `CREATE_QUANTITY`
484
+ // record still names the qset after `removeQuantityMutation` has taken
485
+ // it out of the overlay, and the generator below then finds nothing to
486
+ // write. The same undo against a COLLIDING name is not a no-op — it
487
+ // withholds the source set's lines — which is what the skip loop's
488
+ // `recordWithheld` below settles.
489
+ if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId)) {
490
+ modifications.nominate(entityId, 'quantity-set');
491
+ }
366
492
  const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
367
493
  const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
368
494
  if (relevantQsets.length > 0) {
369
495
  newQuantitySets.push({ entityId, qsets: relevantQsets });
370
496
  }
497
+ // The names this export is actually WRITING a replacement for. The
498
+ // affected-name set is not the same thing: it comes from the session's
499
+ // append-only mutation history, which keeps naming a quantity set after
500
+ // an undo has taken it back out of the overlay, so a Ctrl+Z used to
501
+ // withhold a source `IfcElementQuantity` that nothing regenerated. There
502
+ // is no quantity-set REMOVAL to preserve here — `deletedQsets` has no
503
+ // public populator, so withholding without a replacement is always the
504
+ // bug and never the intent (#2487).
505
+ const regeneratedQsetNames = new Set(relevantQsets.map((qset) => qset.name));
371
506
  // Skip original quantity set entities (IfcElementQuantity).
372
507
  // Same per-entity index lookup as the property branch above.
373
508
  const rels = relDefinesByEntity.get(entityId);
374
509
  if (rels) {
375
510
  for (const { relId, psetId: relatedPsetId } of rels) {
376
511
  const qsetName = this.getElementQuantityName(relatedPsetId);
377
- if (qsetName && qsetNames.has(qsetName)) {
512
+ if (qsetName && regeneratedQsetNames.has(qsetName)) {
378
513
  skipRelationshipIds.add(relId);
379
514
  skipPropertySetIds.add(relatedPsetId);
380
515
  const quantIds = this.getPropertyIdsInSet(relatedPsetId);
381
516
  for (const quantId of quantIds) {
382
517
  skipPropertySetIds.add(quantId);
383
518
  }
519
+ // The withheld half, exactly as the rel-defined property branch
520
+ // above. This loop has just decided that #`relatedPsetId`, its
521
+ // quantity atoms and the relationship that attached them do NOT
522
+ // go into the file; whether anything is generated to take their
523
+ // place is decided elsewhere, and is not this branch's to assume.
524
+ //
525
+ // It IS assumable for the pset side and not here, and the
526
+ // difference is where the two read their base from.
527
+ // `getForEntity` merges the overlay over the base pset walk, so a
528
+ // name the session touched but did not change still resolves to
529
+ // source content and is regenerated.
530
+ // `getQuantitiesForEntity` merges the overlay over
531
+ // `quantityExtractor`, which is OPT-IN: it defaults to null, and
532
+ // several in-tree callers wire the property extractor beside it
533
+ // and not it (`cli/commands/mutate.ts`, `gym.ts`,
534
+ // `generate-spaces.ts`, `export/demesh-session.ts`), as does any
535
+ // external embedder of these two published packages. With no
536
+ // extractor the base is empty and the overlay is the only source,
537
+ // so a qset the overlay no longer holds resolves to nothing.
538
+ //
539
+ // Which makes this reachable through an UNDONE quantity-set
540
+ // creation whose name COLLIDES with a source set:
541
+ // `setQuantity(id, 'Qto_WallBaseQuantities', ...)` followed by the
542
+ // `removeQuantityMutation` that mutationSlice runs on Ctrl+Z. The
543
+ // append-only history still names the qset, so this branch
544
+ // withholds the source lines; the overlay is empty again, so
545
+ // nothing is regenerated. The export drops the source quantity set
546
+ // — a real change to the file, and a data-loss bug of its own
547
+ // (#2487) — and this call is what stops the count from calling it
548
+ // nothing.
549
+ modifications.recordWithheld(entityId, 'quantity-set');
384
550
  }
385
551
  }
386
552
  }
@@ -397,9 +563,29 @@ export class StepExporter {
397
563
  // must not inflate the count either.
398
564
  if (!hasEmittableHostBytes(entityId))
399
565
  continue;
400
- if (!entityPropMutations.has(entityId) && !entityQuantMutations.has(entityId)) {
401
- modifiedEntityCount++;
402
- }
566
+ // Under `deltaOnly` this only NOMINATES the host's ATTRIBUTE edits:
567
+ // nothing writes an in-place attribute edit into a delta except the
568
+ // type-object line rewrite, so the ledger drops it at settle time
569
+ // unless that pass reports having carried it (#2462). That nomination
570
+ // is deliberately made at INTENT: the per-kind warning exists to NAME
571
+ // an edit the delta could not carry, and an undeliverable edit is
572
+ // exactly the one that must still be named.
573
+ //
574
+ // A FULL export has no such warning, so an edit that resolved to
575
+ // nothing has nothing to say and nothing to claim — it waits for the
576
+ // rewrite instead. `setAttribute` to the value already in the slot, and
577
+ // `setAttribute` naming a slot the class does not declare, both leave
578
+ // the line byte-identical and used to count anyway (#2483).
579
+ //
580
+ // Recorded unconditionally. It used to be skipped for a host that also
581
+ // had a pset or qset edit, because the count was per entity and the
582
+ // other loop had already nominated it — which is exactly what let a
583
+ // pset emission mark the rename delivered and suppress its warning. The
584
+ // ledger de-duplicates the COUNT per entity now, so the two edits can
585
+ // and must be nominated separately.
586
+ inPlaceNominees.attribute.add(entityId);
587
+ if (options.deltaOnly === true)
588
+ modifications.nominate(entityId, 'attribute');
403
589
  }
404
590
  }
405
591
  // Process georeferencing mutations (only when applyMutations is enabled)
@@ -455,10 +641,25 @@ export class StepExporter {
455
641
  attrMap.set('MapUnit', `#${mapUnitRef}`);
456
642
  changed = true;
457
643
  }
458
- if (changed && !modifiedEntities.has(entityId)) {
644
+ if (changed) {
459
645
  modifiedEntities.add(entityId);
460
- if (hasEmittableHostBytes(entityId))
461
- modifiedEntityCount++;
646
+ // Queued as attribute edits, which only the source-iteration pass
647
+ // writes — so under `deltaOnly` this nominates and settle decides.
648
+ // Recorded even when the host is already in `modifiedEntities`: that
649
+ // guard existed to stop a second COUNT, which the ledger now handles
650
+ // per entity, and suppressing the nomination would hide a dropped
651
+ // georeferencing edit behind an unrelated edit to the same record.
652
+ //
653
+ // `changed` above is INTENT — a field was supplied, not a field that
654
+ // differs from the one in the file. Writing `name: 'EPSG:2056'` over
655
+ // an IfcProjectedCRS already named `EPSG:2056` leaves the line
656
+ // byte-identical, so a full export waits for the rewrite exactly as
657
+ // the plain attribute site does (#2483).
658
+ if (hasEmittableHostBytes(entityId)) {
659
+ inPlaceNominees.georeferencing.add(entityId);
660
+ if (options.deltaOnly === true)
661
+ modifications.nominate(entityId, 'georeferencing');
662
+ }
462
663
  }
463
664
  }
464
665
  // Modify existing IfcMapConversion
@@ -494,10 +695,14 @@ export class StepExporter {
494
695
  attrMap.set('Scale', String(mc.scale));
495
696
  changed = true;
496
697
  }
497
- if (changed && !modifiedEntities.has(entityId)) {
698
+ if (changed) {
498
699
  modifiedEntities.add(entityId);
499
- if (hasEmittableHostBytes(entityId))
500
- modifiedEntityCount++;
700
+ // Same as the IfcProjectedCRS branch above, effect gate included.
701
+ if (hasEmittableHostBytes(entityId)) {
702
+ inPlaceNominees.georeferencing.add(entityId);
703
+ if (options.deltaOnly === true)
704
+ modifications.nominate(entityId, 'georeferencing');
705
+ }
501
706
  }
502
707
  }
503
708
  // CREATE new georef entities when file has none
@@ -635,7 +840,10 @@ export class StepExporter {
635
840
  // deltaOnly carve-out the source branch gets.
636
841
  return !isGeometryExcluded(entityId, ref.type);
637
842
  }
638
- if (!(ref.byteLength > 0 && ref.byteOffset >= 0))
843
+ // Same readability test as `hasEmittableHostBytes`, and for the reason
844
+ // that predicate names: a ref this source cannot address is not a line
845
+ // this export can write, so nothing may be generated naming it (#2491).
846
+ if (!isReadableSourceRef(ref))
639
847
  return false;
640
848
  // Mirrors `hasEmittableHostBytes`: under `deltaOnly` the source-
641
849
  // iteration pass — and its geometry skip — never runs, so a source
@@ -657,8 +865,12 @@ export class StepExporter {
657
865
  // dropped everything the overlay tombstoned, so there is no separate
658
866
  // deleted check to forget here.
659
867
  for (const [expressId, entityRef] of effective) {
660
- // Skip overlay-only entities — emitted by the new-entities pass below
661
- if (entityRef.byteLength === 0 || entityRef.byteOffset < 0) {
868
+ // Skip overlay-only entities — emitted by the new-entities pass below.
869
+ // A ref this source cannot address is skipped by the same test rather
870
+ // than decoded: `decodeUtf8` clamps such a range and the empty string
871
+ // it returns used to be pushed into the file as a blank line, leaving
872
+ // every generated record that names the host dangling (#2491).
873
+ if (!isReadableSourceRef(entityRef)) {
662
874
  continue;
663
875
  }
664
876
  // Skip entities outside the visible closure
@@ -684,42 +896,29 @@ export class StepExporter {
684
896
  // SharedArrayBuffer-backed view; the parser deliberately keeps
685
897
  // `source` zero-copy SAB-backed for worker sharing).
686
898
  const entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
687
- let nextEntityText = entityText;
688
- // Entity retype (reassign class) runs FIRST so attribute mutations
689
- // below resolve against the TARGET class's attribute names. The
690
- // expressId is unchanged, so geometry / placement / representation and
691
- // every IfcRel* reference (keyed by #id) carry over untouched.
692
- //
693
- // This materializes inside the source-iteration loop, which `deltaOnly`
694
- // skips so, like in-place attribute/positional edits to existing
695
- // entities, an existing-entity retype is only emitted by a full export
696
- // (the common `applyMutations` path). Retyped OVERLAY-created entities
697
- // are emitted under `deltaOnly` via the new-entities pass below.
698
- const typeMutation = overlayActive && typeof this.mutationView.getEntityTypeMutation === 'function'
699
- ? this.mutationView.getEntityTypeMutation(expressId)
700
- : null;
701
- let workingType = entityType;
702
- if (typeMutation) {
703
- nextEntityText = retypeStepLine(nextEntityText, entityRef.type, typeMutation.newType, typeMutation.predefinedType ?? null, sourceSchema);
704
- workingType = typeMutation.newType.toUpperCase();
705
- if (!modifiedEntities.has(expressId)) {
706
- modifiedEntities.add(expressId);
707
- modifiedEntityCount++;
708
- }
709
- }
710
- if (modifiedAttributes.has(expressId)) {
711
- nextEntityText = this.applyAttributeMutations(nextEntityText, workingType, modifiedAttributes.get(expressId));
712
- }
713
- const positional = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
714
- ? this.mutationView.getPositionalMutationsForEntity(expressId)
715
- : null;
716
- if (positional && positional.size > 0) {
717
- nextEntityText = this.applyPositionalMutations(nextEntityText, positional, workingType, sourceSchema);
718
- if (!modifiedEntities.has(expressId)) {
719
- modifiedEntities.add(expressId);
720
- modifiedEntityCount++;
721
- }
722
- }
899
+ // Retype, named attribute edits and positional edits, in that order.
900
+ // Shared verbatim with the type-object `HasPropertySets` rewrite below,
901
+ // which writes the line this pass would otherwise have written.
902
+ const mutated = this.applySourceLineMutations(expressId, entityText, entityRef.type, modifiedAttributes.get(expressId), sourceSchema, overlayActive);
903
+ const nextEntityText = mutated.text;
904
+ // A retype or a positional edit that CHANGED the line is what makes
905
+ // this entity count; a named attribute edit was already nominated by
906
+ // the collection pass. Both flags report effect, so retyping an entity
907
+ // to the class it already is or writing a slot the token it already
908
+ // holds no longer claims a modification over a line the export left
909
+ // byte-identical. This pass is full-export-only (`deltaOnly` skips it
910
+ // wholesale), so nomination IS emission here and the kinds only have to
911
+ // be right for the entity count — which is per entity, hence unchanged.
912
+ if (mutated.retyped || mutated.positional)
913
+ modifiedEntities.add(expressId);
914
+ if (mutated.retyped)
915
+ modifications.nominate(expressId, 'retype');
916
+ if (mutated.positional)
917
+ modifications.nominate(expressId, 'positional');
918
+ // The named-attribute kinds join them here rather than at their
919
+ // collection sites, for the same reason and on the same signal (#2483).
920
+ // This pass is full-export-only, so there is nothing to gate.
921
+ nominateDeliveredInPlaceEdits(modifications, expressId, mutated, inPlaceNominees);
723
922
  // Apply schema conversion if exporting to a different schema version
724
923
  if (converting) {
725
924
  const converted = convertStepLine(nextEntityText, sourceSchema, schema, options.guidRandom);
@@ -743,6 +942,10 @@ export class StepExporter {
743
942
  const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
744
943
  entities.push(...newEntities.lines);
745
944
  newEntityCount += newEntities.count;
945
+ // Replacement content for this host actually landed, so a delta really
946
+ // does carry its PROPERTY-SET modification — and only that one (#2462).
947
+ if (newEntities.lines.length > 0)
948
+ modifications.recordEmitted(entityId, 'property-set');
746
949
  generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
747
950
  }
748
951
  // Point every affected type object's HasPropertySets at the psets this
@@ -761,9 +964,108 @@ export class StepExporter {
761
964
  overlayTypeOwnedPsets.set(entityId, resolved.length > 0 ? resolved.map((id) => `#${id}`) : null);
762
965
  continue;
763
966
  }
764
- const rewritten = this.replaceEntityAttribute(entityId, HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken(resolved));
765
- if (rewritten) {
766
- rewrittenEntityLines.set(entityId, rewritten);
967
+ // This line REPLACES the one the source-iteration pass would have
968
+ // written — `rewrittenEntityIds` makes that pass skip the entity — so it
969
+ // has to carry the entity's other edits too, and it has to apply them
970
+ // the way that pass does. It used to replace slot 5 and nothing else,
971
+ // which dropped the rename in `setAttribute(id,'Name',…)` +
972
+ // `addPropertySet(id,…)`, and then, once renames were special-cased
973
+ // here, still dropped retypes and positional edits — same line, same
974
+ // silence. So run the ONE pipeline both passes share and replace
975
+ // `HasPropertySets` on its output. Order matters: see
976
+ // {@link applySourceLineMutations}.
977
+ const record = effective.get(entityId);
978
+ let sourceLine = null;
979
+ let mutated = null;
980
+ // One narrowed block for both calls: `record` is in scope for the decode
981
+ // AND for the record type below, with no non-null assertion to keep true
982
+ // by hand. `byteOffset >= 0` is the same "are there real source bytes"
983
+ // test the source-iteration pass makes — an overlay-authored record
984
+ // carries `-1` there, and decoding from it would read another entity's
985
+ // bytes rather than fall through to the no-source-bytes branch.
986
+ // `isReadableSourceRef` folds in the `byteOffset >= 0 && byteLength > 0`
987
+ // test this used to make by hand, and adds the bound the invariant used
988
+ // to supply (#2491).
989
+ if (record && isReadableSourceRef(record)) {
990
+ sourceLine = decodeRange(this.dataStore.source, record.byteOffset, record.byteOffset + record.byteLength);
991
+ // The RECORD's class is the from-type: the bytes are still the source
992
+ // class, whatever `typeOf` now says the entity effectively is.
993
+ mutated = this.applySourceLineMutations(entityId, sourceLine, record.type, modifiedAttributes.get(entityId), sourceSchema, overlayActive);
994
+ }
995
+ if (mutated === null) {
996
+ // `willBeEmitted` already required real source bytes for a non-overlay
997
+ // record, so this is only reachable with no source buffer at all —
998
+ // in which case the source-iteration pass never ran either and there is
999
+ // nothing to lose. Say it anyway; the pset edit is still going nowhere.
1000
+ warnings.push(typeOwnedPsetRewriteWarning(entityId, 'no-source-bytes'));
1001
+ // The line above IS the report, so the ledger must not add a second,
1002
+ // vaguer one blaming the delta format for a drop the format did not
1003
+ // cause.
1004
+ modifications.acknowledgeUndelivered(entityId, 'property-set');
1005
+ continue;
1006
+ }
1007
+ const { line, repointed } = rewriteTypeOwnedPsetLine(mutated.text, resolved);
1008
+ if (repointed) {
1009
+ // A repoint that resolves to the list the line ALREADY names changes
1010
+ // nothing, and it is reachable: deleting a pset name the type object
1011
+ // does not own leaves every original id in place (it is "affected" but
1012
+ // matches none of them) and generates no replacement, so slot 5 comes
1013
+ // back byte-identical. Same rule as the fallback branch below — an
1014
+ // unchanged line has no place in a delta, and claiming it delivered the
1015
+ // edit would put a modification in the header over a line that carries
1016
+ // none. A FULL export still emits it: `rewrittenEntityIds` made the
1017
+ // source-iteration pass skip this entity, so withholding the line there
1018
+ // would delete the record from the file (#2469).
1019
+ const changed = line !== sourceLine;
1020
+ if (options.deltaOnly !== true || changed) {
1021
+ rewrittenEntityLines.set(entityId, line);
1022
+ }
1023
+ // A rewritten source line IS in the delta — the one in-place change a
1024
+ // delta does carry today (#2462). The repoint itself delivers the
1025
+ // property-set edit that put this host in the loop; the rest of the
1026
+ // line delivers whichever in-place edits the pipeline applied to it.
1027
+ if (changed) {
1028
+ modifications.recordEmitted(entityId, 'property-set');
1029
+ recordSourceLineDelivery(modifications, entityId, mutated);
1030
+ // `rewrittenEntityIds` made the source-iteration pass skip this
1031
+ // host, so this line is the ONLY place a full export can see its
1032
+ // named-attribute edits land — per site, not per feature (#2483).
1033
+ nominateDeliveredInPlaceEdits(modifications, entityId, mutated, inPlaceNominees);
1034
+ }
1035
+ continue;
1036
+ }
1037
+ // A malformed source line — too few arguments to have a slot 5, or not
1038
+ // parseable as a STEP record at all. The entity must still come out:
1039
+ // `rewrittenEntityIds` made the source-iteration pass skip it, so
1040
+ // dropping the line here deletes the whole record from the file (#2469).
1041
+ warnings.push(typeOwnedPsetRewriteWarning(entityId, 'unparseable-line'));
1042
+ // Same as the `no-source-bytes` branch: the property-set edit is
1043
+ // genuinely undelivered — the repoint is what would have delivered it and
1044
+ // it did not happen — but this warning already says so, precisely, so the
1045
+ // ledger stays quiet about that pair rather than duplicating it. (When
1046
+ // the affected psets produced replacement content, the property-set pass
1047
+ // above has already recorded the emission, and an emission outranks an
1048
+ // acknowledgement.)
1049
+ modifications.acknowledgeUndelivered(entityId, 'property-set');
1050
+ // `line` is byte-for-byte what the source-iteration pass would have
1051
+ // written, so emit it wherever that pass would have run. Under
1052
+ // `deltaOnly` it does not run, and a line the mutation pipeline left
1053
+ // identical to its source is not a change — it has no place in a delta.
1054
+ const changed = line !== sourceLine;
1055
+ if (options.deltaOnly !== true || changed) {
1056
+ rewrittenEntityLines.set(entityId, line);
1057
+ }
1058
+ // The ledger stays honest about WHICH modification landed: the
1059
+ // property-set edit that nominated this host is the thing that just
1060
+ // failed, so only the entity's OTHER edits are in this line. Under the
1061
+ // per-kind keying that comes out as `attribute/retype/positional:
1062
+ // delivered, property-set: undelivered` — the host still counts once,
1063
+ // because a real change of its did land.
1064
+ if (changed) {
1065
+ recordSourceLineDelivery(modifications, entityId, mutated);
1066
+ // Same site rule as the repoint branch above: the failed repoint is
1067
+ // what did not land, and the line still carries the host's OTHER edits.
1068
+ nominateDeliveredInPlaceEdits(modifications, entityId, mutated, inPlaceNominees);
767
1069
  }
768
1070
  }
769
1071
  // Generate new quantity entities for mutations
@@ -773,6 +1075,8 @@ export class StepExporter {
773
1075
  const newEntities = this.generateQuantitySetEntities(entityId, qsets, willBeEmitted, options.guidRandom);
774
1076
  entities.push(...newEntities.lines);
775
1077
  newEntityCount += newEntities.count;
1078
+ if (newEntities.lines.length > 0)
1079
+ modifications.recordEmitted(entityId, 'quantity-set');
776
1080
  }
777
1081
  for (const rewrittenLine of rewrittenEntityLines.values()) {
778
1082
  entities.push(rewrittenLine);
@@ -863,6 +1167,13 @@ export class StepExporter {
863
1167
  }
864
1168
  }
865
1169
  }
1170
+ // Settle the count against what the passes above actually wrote, and say
1171
+ // out loud every KIND of edit a delta could not carry, per host. Silence
1172
+ // was the other half of #2462: `deltaOnly` skips the source-iteration pass,
1173
+ // so an in-place edit to a source entity is not in the file and never was —
1174
+ // the header merely used to claim otherwise.
1175
+ const { modifiedEntityCount, warnings: deltaWarnings } = modifications.settle();
1176
+ warnings.push(...deltaWarnings);
866
1177
  // Assemble final file as Uint8Array chunks to avoid V8 string length limit.
867
1178
  // The header is built last so its provenance item reflects the real count.
868
1179
  const header = buildHeader(newEntityCount + modifiedEntityCount);
@@ -989,7 +1300,10 @@ export class StepExporter {
989
1300
  return result;
990
1301
  }
991
1302
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
992
- if (entityRef && this.dataStore.source && entityRef.byteLength > 0) {
1303
+ // Readability rather than presence, as everywhere else (#2491). A clamped
1304
+ // decode would match nothing here, so this is tidiness rather than a bug —
1305
+ // but the gates in this file agree on one predicate now.
1306
+ if (entityRef && createSourceRefReader(this.dataStore.source)(entityRef)) {
993
1307
  const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
994
1308
  // #ID=IFCWALL('GlobalId',#owner,...): GlobalId is a quoted STEP string
995
1309
  // (doubled '' escapes); OwnerHistory is the ref/`$` right after it.
@@ -1013,7 +1327,12 @@ export class StepExporter {
1013
1327
  for (const prop of pset.properties) {
1014
1328
  const propId = this.nextExpressId++;
1015
1329
  count++;
1016
- const valueStr = serializePropertyValue(prop.value, prop.type);
1330
+ // `prop.dataType`, not `prop.type` alone: regenerating the set rewrites
1331
+ // every property in it, and the shape-derived primitive would re-declare
1332
+ // the ones nobody edited (`IFCTEXT` → `IFCLABEL`, `IFCLENGTHMEASURE` →
1333
+ // `IFCREAL`). See `declared-property-type.ts` for when the source token
1334
+ // is trusted (#2482).
1335
+ const valueStr = serializeNominalValue(prop.value, prop.type, prop.dataType);
1017
1336
  const unitId = prop.unit ? this.findUnitId(prop.unit, effective) : null;
1018
1337
  const unitStr = unitId !== null ? ref(unitId) : null;
1019
1338
  // #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
@@ -1079,6 +1398,86 @@ export class StepExporter {
1079
1398
  }
1080
1399
  return { lines, count };
1081
1400
  }
1401
+ /**
1402
+ * THE mutation pipeline for a line read out of the source buffer: retype,
1403
+ * then named attribute edits, then positional edits.
1404
+ *
1405
+ * **One implementation, two call sites**, and that is the whole point. Two
1406
+ * passes can write the defining line of a source entity — the
1407
+ * source-iteration pass, and the type-object `HasPropertySets` rewrite that
1408
+ * REPLACES it (`rewrittenEntityIds` makes the source pass skip those ids).
1409
+ * The rewrite used to do its own thing (replace slot 5, nothing else), so
1410
+ * every other edit to a type object with a type-owned pset edit was dropped
1411
+ * in silence: first the renames (#2462 follow-up), and after those were
1412
+ * special-cased here, still the retypes and the positional edits. Whatever
1413
+ * the source pass applies, the rewrite has to apply too, or the next edit
1414
+ * kind added to one site goes missing at the other.
1415
+ *
1416
+ * The order is load-bearing:
1417
+ *
1418
+ * - the retype runs FIRST so named attribute edits resolve against the
1419
+ * TARGET class's attribute names, and so positional slots are indexed
1420
+ * into the retyped argument list;
1421
+ * - the `HasPropertySets` replacement (rewrite path only) runs LAST, on
1422
+ * the text this returns. Run it first and a positional edit to slot 5 —
1423
+ * or a retype's argument-list rebuild — overwrites the resolved pset
1424
+ * list with the stale one, which is the same silent drop one slot over.
1425
+ *
1426
+ * The expressId is unchanged by all of this, so geometry / placement /
1427
+ * representation and every IfcRel* reference (keyed by #id) carry over.
1428
+ *
1429
+ * All three flags report EFFECT, not intent — each is the answer to "did this
1430
+ * operation change the line", measured across that operation alone. The count
1431
+ * and the ledger are claims about the FILE, so an edit that resolves to the
1432
+ * text already there has delivered nothing and must not be reported: retyping
1433
+ * an entity to the class it already is, or writing a positional slot the token
1434
+ * it already holds, used to count as a modification and reach the ledger as a
1435
+ * landed edit, over a byte-identical line. Discarded edits read the same way:
1436
+ * `applyAttributeMutations` drops a name its class has no slot for and
1437
+ * `retypeStepLine` returns an unparseable line untouched, and neither is a
1438
+ * modification of anything.
1439
+ *
1440
+ * `retyped` / `positional` matter most in a FULL export, which is where the
1441
+ * two are nominated (their edits have no earlier nomination site); named
1442
+ * attribute edits are nominated by the collection pass and `attributed` only
1443
+ * settles their delivery.
1444
+ */
1445
+ applySourceLineMutations(expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive) {
1446
+ let text = entityText;
1447
+ let workingType = recordType.toUpperCase();
1448
+ const typeMutation = overlayActive && typeof this.mutationView.getEntityTypeMutation === 'function'
1449
+ ? this.mutationView.getEntityTypeMutation(expressId)
1450
+ : null;
1451
+ let retyped = false;
1452
+ if (typeMutation) {
1453
+ const beforeRetype = text;
1454
+ text = retypeStepLine(text, recordType, typeMutation.newType, typeMutation.predefinedType ?? null, sourceSchema);
1455
+ retyped = text !== beforeRetype;
1456
+ // Set even for a no-op retype: the entity IS the target class from here
1457
+ // on, so the named and positional edits below must resolve against it.
1458
+ workingType = typeMutation.newType.toUpperCase();
1459
+ }
1460
+ // `applyAttributeMutations` returns its input UNCHANGED when it wrote
1461
+ // nothing — no slot resolved for any of the names, or the line does not
1462
+ // parse — so comparing is what tells the ledger whether a named attribute
1463
+ // edit was really carried, rather than merely attempted.
1464
+ let attributed = false;
1465
+ if (attributeMutations && attributeMutations.size > 0) {
1466
+ const beforeAttributes = text;
1467
+ text = this.applyAttributeMutations(text, workingType, attributeMutations);
1468
+ attributed = text !== beforeAttributes;
1469
+ }
1470
+ const positionals = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
1471
+ ? this.mutationView.getPositionalMutationsForEntity(expressId)
1472
+ : null;
1473
+ let positional = false;
1474
+ if (positionals && positionals.size > 0) {
1475
+ const beforePositionals = text;
1476
+ text = this.applyPositionalMutations(text, positionals, workingType, sourceSchema);
1477
+ positional = text !== beforePositionals;
1478
+ }
1479
+ return { text, attributed, retyped, positional };
1480
+ }
1082
1481
  /**
1083
1482
  * Rewrite root IFC attributes directly on the original STEP entity line.
1084
1483
  */
@@ -1617,24 +2016,6 @@ export class StepExporter {
1617
2016
  return [];
1618
2017
  return hasPropertySets.filter((value) => typeof value === 'number');
1619
2018
  }
1620
- /**
1621
- * Replace a single top-level STEP attribute in an entity line.
1622
- */
1623
- replaceEntityAttribute(entityId, attrIndex, replacement) {
1624
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1625
- if (!entityRef || !this.dataStore.source)
1626
- return null;
1627
- const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1628
- const match = entityText.match(/^(#\d+\s*=\s*\w+\()([\s\S]*)(\)\s*;)\s*$/);
1629
- if (!match)
1630
- return null;
1631
- const [, prefix, attrsText, suffix] = match;
1632
- const attrs = splitTopLevelStepArguments(attrsText);
1633
- if (attrIndex >= attrs.length)
1634
- return null;
1635
- attrs[attrIndex] = replacement;
1636
- return `${prefix}${attrs.join(',')}${suffix}`;
1637
- }
1638
2019
  }
1639
2020
  /**
1640
2021
  * Quick export function for simple use cases.