@ifc-lite/export 2.8.1 → 2.8.4

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 (76) hide show
  1. package/README.md +13 -0
  2. package/dist/declared-property-type.d.ts +107 -0
  3. package/dist/declared-property-type.d.ts.map +1 -0
  4. package/dist/declared-property-type.js +236 -0
  5. package/dist/declared-property-type.js.map +1 -0
  6. package/dist/delta-modification-ledger.d.ts +148 -0
  7. package/dist/delta-modification-ledger.d.ts.map +1 -0
  8. package/dist/delta-modification-ledger.js +194 -0
  9. package/dist/delta-modification-ledger.js.map +1 -0
  10. package/dist/demesh-session.d.ts.map +1 -1
  11. package/dist/demesh-session.js +7 -0
  12. package/dist/demesh-session.js.map +1 -1
  13. package/dist/effective-index.d.ts +16 -1
  14. package/dist/effective-index.d.ts.map +1 -1
  15. package/dist/effective-index.js +68 -0
  16. package/dist/effective-index.js.map +1 -1
  17. package/dist/glb.d.ts.map +1 -1
  18. package/dist/glb.js +12 -0
  19. package/dist/glb.js.map +1 -1
  20. package/dist/ifc5-exporter.d.ts +29 -3
  21. package/dist/ifc5-exporter.d.ts.map +1 -1
  22. package/dist/ifc5-exporter.js +170 -55
  23. package/dist/ifc5-exporter.js.map +1 -1
  24. package/dist/in-place-nomination.d.ts +65 -0
  25. package/dist/in-place-nomination.d.ts.map +1 -0
  26. package/dist/in-place-nomination.js +27 -0
  27. package/dist/in-place-nomination.js.map +1 -0
  28. package/dist/lod1-generator.d.ts.map +1 -1
  29. package/dist/lod1-generator.js +5 -0
  30. package/dist/lod1-generator.js.map +1 -1
  31. package/dist/merged-exporter.d.ts.map +1 -1
  32. package/dist/merged-exporter.js +15 -6
  33. package/dist/merged-exporter.js.map +1 -1
  34. package/dist/parquet-exporter.d.ts +28 -1
  35. package/dist/parquet-exporter.d.ts.map +1 -1
  36. package/dist/parquet-exporter.js +144 -13
  37. package/dist/parquet-exporter.js.map +1 -1
  38. package/dist/reference-collector.d.ts +4 -4
  39. package/dist/reference-collector.d.ts.map +1 -1
  40. package/dist/reference-collector.js +64 -18
  41. package/dist/reference-collector.js.map +1 -1
  42. package/dist/retype.d.ts.map +1 -1
  43. package/dist/retype.js +2 -1
  44. package/dist/retype.js.map +1 -1
  45. package/dist/select-qualification.d.ts +12 -0
  46. package/dist/select-qualification.d.ts.map +1 -1
  47. package/dist/select-qualification.js +12 -1
  48. package/dist/select-qualification.js.map +1 -1
  49. package/dist/source-ref-bounds.d.ts +53 -0
  50. package/dist/source-ref-bounds.d.ts.map +1 -0
  51. package/dist/source-ref-bounds.js +25 -0
  52. package/dist/source-ref-bounds.js.map +1 -0
  53. package/dist/step-argument-parser.d.ts +90 -0
  54. package/dist/step-argument-parser.d.ts.map +1 -0
  55. package/dist/step-argument-parser.js +207 -0
  56. package/dist/step-argument-parser.js.map +1 -0
  57. package/dist/step-exporter.d.ts +83 -4
  58. package/dist/step-exporter.d.ts.map +1 -1
  59. package/dist/step-exporter.js +647 -129
  60. package/dist/step-exporter.js.map +1 -1
  61. package/dist/step-file-assembly.d.ts +35 -0
  62. package/dist/step-file-assembly.d.ts.map +1 -0
  63. package/dist/step-file-assembly.js +102 -0
  64. package/dist/step-file-assembly.js.map +1 -0
  65. package/dist/step-serialization.d.ts +48 -48
  66. package/dist/step-serialization.d.ts.map +1 -1
  67. package/dist/step-serialization.js +90 -182
  68. package/dist/step-serialization.js.map +1 -1
  69. package/dist/type-owned-psets.d.ts +40 -2
  70. package/dist/type-owned-psets.d.ts.map +1 -1
  71. package/dist/type-owned-psets.js +45 -2
  72. package/dist/type-owned-psets.js.map +1 -1
  73. package/dist/unit-normalize.d.ts.map +1 -1
  74. package/dist/unit-normalize.js +13 -1
  75. package/dist/unit-normalize.js.map +1 -1
  76. package/package.json +8 -7
@@ -1,21 +1,65 @@
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, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
5
- import { safeUtf8Decode } from '@ifc-lite/data';
4
+ import { asSourceBytes, EntityExtractor, extractQuantitiesOnDemand, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
6
5
  import { generateIfcGuid } from '@ifc-lite/encoding';
7
6
  import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
8
7
  import { convertStepLine, needsConversion } from './schema-converter.js';
9
8
  import { retypeStepLine, retypeArgTokens } from './retype.js';
10
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';
11
13
  import { authoredEntityRefs, getEffectiveEntityIndex } from './effective-index.js';
12
- import { HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken, isTypeClass, resolveTypeOwnedPsetIds, } from './type-owned-psets.js';
13
- 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';
14
18
  import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
15
19
  import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
16
20
  import { serializeQualifiedSelectSlot } from './select-qualification.js';
21
+ import { serializeNominalValue } from './declared-property-type.js';
22
+ /**
23
+ * UTF-8 decode of `[start, end)` of the source, accepting either the raw bytes
24
+ * or the {@link IfcSourceBytes} accessor (#2183). Replaces the direct
25
+ * `safeUtf8Decode(source, …)` calls this file used to make: `decodeUtf8` is
26
+ * SAB-safe in exactly the same way, and routing through the accessor is what
27
+ * lets `IfcDataStore.source` change shape without touching these eight reads.
28
+ */
29
+ function decodeRange(src, start, end) {
30
+ return asSourceBytes(src).decodeUtf8(start, end);
31
+ }
17
32
  /** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
18
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();
49
+ /**
50
+ * Message for the one refusal `export()` can report, shared by the returned
51
+ * `stats.warnings` entry and the console line so the two cannot drift.
52
+ */
53
+ const MAP_CONVERSION_WITHOUT_CONTEXT_WARNING = 'Cannot create IfcMapConversion: no IfcGeometricRepresentationContext is available to reference as SourceCRS. The IfcProjectedCRS is unaffected.';
54
+ /**
55
+ * Message for the refusal `export()` reports when a map conversion is
56
+ * requested but there is no IfcProjectedCRS to attach it to — none was
57
+ * requested and none exists in the file — distinct from
58
+ * {@link MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}, which is worded for the
59
+ * case where an IfcProjectedCRS exists (or was written) but no context is
60
+ * available to reference.
61
+ */
62
+ const MAP_CONVERSION_WITHOUT_CRS_WARNING = 'Cannot create IfcMapConversion: no IfcProjectedCRS was requested and none exists in the file to reference as TargetCRS. Nothing was written.';
19
63
  /**
20
64
  * IFC STEP file exporter
21
65
  */
@@ -45,7 +89,6 @@ export class StepExporter {
45
89
  export(options) {
46
90
  const entities = [];
47
91
  let newEntityCount = 0;
48
- let modifiedEntityCount = 0;
49
92
  // Both owner-history caches are per-EXPORT, not per-exporter: they now
50
93
  // depend on `willBeEmitted`, which depends on this call's options. Reusing
51
94
  // one exporter for a `visibleOnly` export and then a full one would
@@ -69,7 +112,9 @@ export class StepExporter {
69
112
  // `sourceHeader`; fall back to parsing the (always-present) source bytes so
70
113
  // cache-restored stores — which don't carry `sourceHeader` — still work.
71
114
  const sourceHeader = this.dataStore.sourceHeader
72
- ?? (this.dataStore.source ? parseSourceHeader(this.dataStore.source) : undefined);
115
+ ?? (this.dataStore.source.byteLength > 0
116
+ ? parseSourceHeader(this.dataStore.source)
117
+ : undefined);
73
118
  // Preserve the exact FILE_SCHEMA identifier (e.g. IFC4X3_ADD2) only when we
74
119
  // are NOT converting schemas; conversion must emit the coarse target token.
75
120
  const schemaToken = !converting && sourceHeader?.schemaIdentifiers?.[0]
@@ -115,9 +160,88 @@ export class StepExporter {
115
160
  // entity has no source bytes, so the source-iteration pass below never sees
116
161
  // it and the new-entities pass at the end owns its line entirely (#2006).
117
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);
168
+ // Build visible-only closure if requested. Classification, the closure walk
169
+ // and the style pass all run over the EFFECTIVE index: an overlay-created
170
+ // product becomes a root by the same type rules as a parsed one, the walk
171
+ // follows its authored references into the geometry it alone owns, and a
172
+ // tombstoned entity is simply not there. Run over the source buffer, a
173
+ // created wall could never be a root and nothing referenced it, so
174
+ // `visibleOnly` wrote a file without it and said nothing (#2012).
175
+ //
176
+ // Computed here, ahead of the modification-count passes below, because
177
+ // `hasEmittableHostBytes` needs it: a source-backed host EXCLUDED by
178
+ // `visibleOnly` never gets its line written by the source-iteration pass
179
+ // either, so counting it as "modified" would make the header claim a
180
+ // change the DATA section does not contain (CodeRabbit finding on #2414).
181
+ let allowedEntityIds = null;
182
+ if (options.visibleOnly && this.dataStore.source) {
183
+ const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
184
+ allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
185
+ // Second pass: collect IFCSTYLEDITEM entities that reference included
186
+ // geometry. Styled items reference geometry items but nothing references
187
+ // them back, so the forward closure misses them.
188
+ collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
189
+ }
190
+ // Will THIS entity's own line ever land in the file? The same byte-range
191
+ // test `willBeEmitted` uses (defined further below) and the source-
192
+ // iteration pass's own skip at `entityRef.byteLength === 0` — a source
193
+ // entity with no bytes (a point-cloud / GLB "entity" from
194
+ // `createSyntheticDataStore`, not an overlay-created one) never gets a
195
+ // defining line written, source-iteration or otherwise, so a pset/attribute
196
+ // edit against it must not count as a modification either: the header
197
+ // would describe a change the file does not contain (out-of-scope finding
198
+ // in #2398). Also excludes a source-backed host the visible-only closure
199
+ // above drops — same reasoning, different reason the line never lands.
200
+ //
201
+ // And, like `willBeEmitted` below, excludes a geometry-classified SOURCE
202
+ // host under `includeGeometry: false`: the source-iteration pass's own
203
+ // `isGeometryEntity` skip (further below) drops that line too, so this
204
+ // predicate must agree or a geometry entity's attribute edit inflates the
205
+ // count over an omitted line (CodeRabbit finding on #2414). Guarded by
206
+ // `!deltaOnly` for the same reason `willBeEmitted` is: under `deltaOnly`
207
+ // the source-iteration pass — and its geometry skip — never runs at all,
208
+ // so a source entity's line is assumed to already exist in the file being
209
+ // patched, geometry or not.
210
+ const isGeometryExcluded = (entityId, recordType) => options.includeGeometry === false
211
+ && this.isGeometryEntity(effective.effectiveType(entityId, recordType));
212
+ const hasEmittableHostBytes = (entityId) => {
213
+ if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
214
+ return false;
215
+ const ref = effective.get(entityId);
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))
221
+ return false;
222
+ if (options.deltaOnly !== true && isGeometryExcluded(entityId, ref.type))
223
+ return false;
224
+ return true;
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
+ };
118
243
  // Collect entities that need to be modified or created
119
244
  const modifiedEntities = new Set();
120
- const modifiedPsets = new Map(); // entityId -> psetNames being modified
121
245
  const modifiedAttributes = new Map();
122
246
  const newPropertySets = [];
123
247
  const newQuantitySets = [];
@@ -210,14 +334,22 @@ export class StepExporter {
210
334
  if (effective.isDeleted(entityId))
211
335
  continue;
212
336
  modifiedEntities.add(entityId);
213
- modifiedPsets.set(entityId, psetNames);
214
337
  // Same rule as the attribute loop below: an overlay-CREATED entity is
215
338
  // emitted once, by the new-entities pass, and already counted in
216
339
  // `newEntityCount` — as are the pset entities this loop goes on to
217
340
  // generate. Only the COUNT is guarded; the entity still records its
218
341
  // pset edits and still emits them.
219
- if (!isOverlayCreated(entityId))
220
- 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
+ }
221
353
  // Get the FULL mutated property sets for this entity (merged base + mutations)
222
354
  const allPsets = this.mutationView.getForEntity(entityId);
223
355
  const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
@@ -243,6 +375,12 @@ export class StepExporter {
243
375
  for (const propId of propIds) {
244
376
  skipPropertySetIds.add(propId);
245
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');
246
384
  }
247
385
  }
248
386
  }
@@ -259,6 +397,12 @@ export class StepExporter {
259
397
  for (const propId of propIds) {
260
398
  skipPropertySetIds.add(propId);
261
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.
262
406
  }
263
407
  for (const psetName of psetNames) {
264
408
  if (!relDefinedPsetNames.has(psetName)) {
@@ -275,33 +419,134 @@ export class StepExporter {
275
419
  // Collect modified quantity sets (only if quantities are included)
276
420
  if (options.includeQuantities === false)
277
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
+ }
278
469
  for (const [entityId, qsetNames] of entityQuantMutations) {
279
470
  // Same rule as the property loop above: a deleted entity removes nothing.
280
471
  if (effective.isDeleted(entityId))
281
472
  continue;
282
473
  modifiedEntities.add(entityId);
283
474
  // See the property loop above — an overlay-created entity is counted as
284
- // new, not modified.
285
- if (!isOverlayCreated(entityId) && !modifiedPsets.has(entityId))
286
- 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
+ }
287
492
  const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
288
493
  const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
289
494
  if (relevantQsets.length > 0) {
290
495
  newQuantitySets.push({ entityId, qsets: relevantQsets });
291
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));
292
506
  // Skip original quantity set entities (IfcElementQuantity).
293
507
  // Same per-entity index lookup as the property branch above.
294
508
  const rels = relDefinesByEntity.get(entityId);
295
509
  if (rels) {
296
510
  for (const { relId, psetId: relatedPsetId } of rels) {
297
511
  const qsetName = this.getElementQuantityName(relatedPsetId);
298
- if (qsetName && qsetNames.has(qsetName)) {
512
+ if (qsetName && regeneratedQsetNames.has(qsetName)) {
299
513
  skipRelationshipIds.add(relId);
300
514
  skipPropertySetIds.add(relatedPsetId);
301
515
  const quantIds = this.getPropertyIdsInSet(relatedPsetId);
302
516
  for (const quantId of quantIds) {
303
517
  skipPropertySetIds.add(quantId);
304
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');
305
550
  }
306
551
  }
307
552
  }
@@ -313,17 +558,51 @@ export class StepExporter {
313
558
  // one created-then-renamed wall.
314
559
  if (isOverlayCreated(entityId))
315
560
  continue;
316
- if (!entityPropMutations.has(entityId) && !entityQuantMutations.has(entityId)) {
317
- modifiedEntityCount++;
318
- }
561
+ // A source entity with no bytes never gets its line rewritten (the
562
+ // source-iteration pass skips it), so an attribute edit against it
563
+ // must not inflate the count either.
564
+ if (!hasEmittableHostBytes(entityId))
565
+ continue;
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');
319
589
  }
320
590
  }
321
591
  // Process georeferencing mutations (only when applyMutations is enabled)
322
592
  const newGeorefLines = [];
593
+ const warnings = [];
323
594
  if (options.applyMutations !== false && options.georefMutations) {
324
595
  const gm = options.georefMutations;
325
- const existingCrsIds = this.dataStore.entityIndex.byType.get('IFCPROJECTEDCRS');
326
- const existingMcIds = this.dataStore.entityIndex.byType.get('IFCMAPCONVERSION');
596
+ // `effective.byType`, not the raw index: a source IfcProjectedCRS the
597
+ // session tombstoned is still in `dataStore.entityIndex`, so the modify
598
+ // branch below would queue attribute edits against an id the
599
+ // source-iteration pass then skips — the replacement georeferencing
600
+ // vanishes from the file with no error. `effective.byType` drops
601
+ // tombstones and adds overlay-created records, which the new-entities
602
+ // pass applies `modifiedAttributes` to, so both branches agree on which
603
+ // georeferencing entities exist (#2048).
604
+ const existingCrsIds = effective.byType.get('IFCPROJECTEDCRS');
605
+ const existingMcIds = effective.byType.get('IFCMAPCONVERSION');
327
606
  // Modify existing IfcProjectedCRS
328
607
  if (gm.projectedCRS && existingCrsIds?.length) {
329
608
  const entityId = existingCrsIds[0];
@@ -358,13 +637,29 @@ export class StepExporter {
358
637
  changed = true;
359
638
  }
360
639
  if (crs.mapUnit !== undefined) {
361
- const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines);
640
+ const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective);
362
641
  attrMap.set('MapUnit', `#${mapUnitRef}`);
363
642
  changed = true;
364
643
  }
365
- if (changed && !modifiedEntities.has(entityId)) {
644
+ if (changed) {
366
645
  modifiedEntities.add(entityId);
367
- 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
+ }
368
663
  }
369
664
  }
370
665
  // Modify existing IfcMapConversion
@@ -400,9 +695,14 @@ export class StepExporter {
400
695
  attrMap.set('Scale', String(mc.scale));
401
696
  changed = true;
402
697
  }
403
- if (changed && !modifiedEntities.has(entityId)) {
698
+ if (changed) {
404
699
  modifiedEntities.add(entityId);
405
- 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
+ }
406
706
  }
407
707
  }
408
708
  // CREATE new georef entities when file has none
@@ -417,12 +717,12 @@ export class StepExporter {
417
717
  const proj = crs.mapProjection ? `'${escapeStepString(String(crs.mapProjection))}'` : '$';
418
718
  const zone = crs.mapZone ? `'${escapeStepString(String(crs.mapZone))}'` : '$';
419
719
  const mapUnitRef = crs.mapUnit
420
- ? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines)}`
720
+ ? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective)}`
421
721
  : '$';
422
722
  newGeorefLines.push(`#${crsId}=IFCPROJECTEDCRS(${name},${desc},${datum},${vDatum},${proj},${zone},${mapUnitRef});`);
423
723
  newEntityCount++;
424
724
  // Find IfcGeometricRepresentationContext as SourceCRS for MapConversion
425
- const contextId = this.findPreferredGeometricRepresentationContextId();
725
+ const contextId = this.findPreferredGeometricRepresentationContextId(effective);
426
726
  if (contextId) {
427
727
  const mc = gm.mapConversion || {};
428
728
  const mcId = this.nextExpressId++;
@@ -437,12 +737,12 @@ export class StepExporter {
437
737
  newEntityCount++;
438
738
  }
439
739
  else {
440
- console.warn('[StepExporter] Cannot create IfcMapConversion: no IfcGeometricRepresentationContext found in source file');
740
+ this.reportMapConversionRefused(warnings);
441
741
  }
442
742
  }
443
743
  else if (gm.mapConversion && !existingMcIds?.length && existingCrsIds?.length) {
444
744
  // CRS exists but no MapConversion — create just the conversion
445
- const contextId = this.findPreferredGeometricRepresentationContextId();
745
+ const contextId = this.findPreferredGeometricRepresentationContextId(effective);
446
746
  if (contextId) {
447
747
  const mc = gm.mapConversion;
448
748
  const mcId = this.nextExpressId++;
@@ -456,9 +756,17 @@ export class StepExporter {
456
756
  newEntityCount++;
457
757
  }
458
758
  else {
459
- console.warn('[StepExporter] Cannot create IfcMapConversion: no IfcGeometricRepresentationContext found in source file');
759
+ this.reportMapConversionRefused(warnings);
460
760
  }
461
761
  }
762
+ else if (gm.mapConversion && !existingMcIds?.length && !existingCrsIds?.length) {
763
+ // A map conversion was requested, but there is no IfcProjectedCRS to
764
+ // reference as TargetCRS: none was requested (the first branch above
765
+ // didn't fire) and none exists in the file. Both CREATE branches are
766
+ // skipped, so nothing is attempted — report the refusal so the
767
+ // caller isn't left with an empty stats.warnings and no hint (#2105).
768
+ this.reportMapConversionRefusedNoCrs(warnings);
769
+ }
462
770
  }
463
771
  // If delta only, only export modified entities. Overlay-created entities
464
772
  // also count — without this, `createEntity()`-only edits would silently
@@ -480,25 +788,10 @@ export class StepExporter {
480
788
  newEntityCount: 0,
481
789
  modifiedEntityCount: 0,
482
790
  fileSize: emptyContent.byteLength,
791
+ warnings,
483
792
  },
484
793
  };
485
794
  }
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).
493
- let allowedEntityIds = null;
494
- if (options.visibleOnly && this.dataStore.source) {
495
- const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
496
- allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
497
- // Second pass: collect IFCSTYLEDITEM entities that reference included
498
- // geometry. Styled items reference geometry items but nothing references
499
- // them back, so the forward closure misses them.
500
- collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
501
- }
502
795
  /**
503
796
  * Will this id have a defining STEP line in the output at all?
504
797
  *
@@ -540,7 +833,24 @@ export class StepExporter {
540
833
  return false;
541
834
  // An overlay-created record carries the placeholder byte range and is
542
835
  // written by the new-entities pass; a source record needs real bytes.
543
- return effective.isOverlayCreated(entityId) || (ref.byteLength > 0 && ref.byteOffset >= 0);
836
+ if (effective.isOverlayCreated(entityId)) {
837
+ // The overlay new-entities pass applies its OWN `isGeometryEntity`
838
+ // filter unconditionally — deltaOnly or not (see the comment at that
839
+ // loop, further below) — so this branch mirrors it without the
840
+ // deltaOnly carve-out the source branch gets.
841
+ return !isGeometryExcluded(entityId, ref.type);
842
+ }
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))
847
+ return false;
848
+ // Mirrors `hasEmittableHostBytes`: under `deltaOnly` the source-
849
+ // iteration pass — and its geometry skip — never runs, so a source
850
+ // entity's line is assumed to already exist in the file being patched.
851
+ if (options.deltaOnly === true)
852
+ return true;
853
+ return !isGeometryExcluded(entityId, ref.type);
544
854
  };
545
855
  // A modified pset is replaced wholesale, which skips ALL of its member atoms.
546
856
  // But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
@@ -555,8 +865,12 @@ export class StepExporter {
555
865
  // dropped everything the overlay tombstoned, so there is no separate
556
866
  // deleted check to forget here.
557
867
  for (const [expressId, entityRef] of effective) {
558
- // Skip overlay-only entities — emitted by the new-entities pass below
559
- 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)) {
560
874
  continue;
561
875
  }
562
876
  // Skip entities outside the visible closure
@@ -577,47 +891,34 @@ export class StepExporter {
577
891
  if (options.includeGeometry === false && this.isGeometryEntity(entityType)) {
578
892
  continue;
579
893
  }
580
- // Get original entity text — safeUtf8Decode handles SAB-backed
894
+ // Get original entity text — decodeRange handles SAB-backed
581
895
  // sources (Firefox/Chrome reject `TextDecoder.decode()` on a
582
896
  // SharedArrayBuffer-backed view; the parser deliberately keeps
583
897
  // `source` zero-copy SAB-backed for worker sharing).
584
- const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
585
- let nextEntityText = entityText;
586
- // Entity retype (reassign class) runs FIRST so attribute mutations
587
- // below resolve against the TARGET class's attribute names. The
588
- // expressId is unchanged, so geometry / placement / representation and
589
- // every IfcRel* reference (keyed by #id) carry over untouched.
590
- //
591
- // This materializes inside the source-iteration loop, which `deltaOnly`
592
- // skips so, like in-place attribute/positional edits to existing
593
- // entities, an existing-entity retype is only emitted by a full export
594
- // (the common `applyMutations` path). Retyped OVERLAY-created entities
595
- // are emitted under `deltaOnly` via the new-entities pass below.
596
- const typeMutation = overlayActive && typeof this.mutationView.getEntityTypeMutation === 'function'
597
- ? this.mutationView.getEntityTypeMutation(expressId)
598
- : null;
599
- let workingType = entityType;
600
- if (typeMutation) {
601
- nextEntityText = retypeStepLine(nextEntityText, entityRef.type, typeMutation.newType, typeMutation.predefinedType ?? null, sourceSchema);
602
- workingType = typeMutation.newType.toUpperCase();
603
- if (!modifiedEntities.has(expressId)) {
604
- modifiedEntities.add(expressId);
605
- modifiedEntityCount++;
606
- }
607
- }
608
- if (modifiedAttributes.has(expressId)) {
609
- nextEntityText = this.applyAttributeMutations(nextEntityText, workingType, modifiedAttributes.get(expressId));
610
- }
611
- const positional = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
612
- ? this.mutationView.getPositionalMutationsForEntity(expressId)
613
- : null;
614
- if (positional && positional.size > 0) {
615
- nextEntityText = this.applyPositionalMutations(nextEntityText, positional, workingType, sourceSchema);
616
- if (!modifiedEntities.has(expressId)) {
617
- modifiedEntities.add(expressId);
618
- modifiedEntityCount++;
619
- }
620
- }
898
+ const entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
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);
621
922
  // Apply schema conversion if exporting to a different schema version
622
923
  if (converting) {
623
924
  const converted = convertStepLine(nextEntityText, sourceSchema, schema, options.guidRandom);
@@ -638,9 +939,13 @@ export class StepExporter {
638
939
  // see `willBeEmitted` (#1978, #2030, #2012).
639
940
  if (!willBeEmitted(entityId))
640
941
  continue;
641
- const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
942
+ const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
642
943
  entities.push(...newEntities.lines);
643
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');
644
949
  generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
645
950
  }
646
951
  // Point every affected type object's HasPropertySets at the psets this
@@ -659,9 +964,108 @@ export class StepExporter {
659
964
  overlayTypeOwnedPsets.set(entityId, resolved.length > 0 ? resolved.map((id) => `#${id}`) : null);
660
965
  continue;
661
966
  }
662
- const rewritten = this.replaceEntityAttribute(entityId, HAS_PROPERTY_SETS_SLOT, hasPropertySetsToken(resolved));
663
- if (rewritten) {
664
- 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);
665
1069
  }
666
1070
  }
667
1071
  // Generate new quantity entities for mutations
@@ -671,6 +1075,8 @@ export class StepExporter {
671
1075
  const newEntities = this.generateQuantitySetEntities(entityId, qsets, willBeEmitted, options.guidRandom);
672
1076
  entities.push(...newEntities.lines);
673
1077
  newEntityCount += newEntities.count;
1078
+ if (newEntities.lines.length > 0)
1079
+ modifications.recordEmitted(entityId, 'quantity-set');
674
1080
  }
675
1081
  for (const rewrittenLine of rewrittenEntityLines.values()) {
676
1082
  entities.push(rewrittenLine);
@@ -761,6 +1167,13 @@ export class StepExporter {
761
1167
  }
762
1168
  }
763
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);
764
1177
  // Assemble final file as Uint8Array chunks to avoid V8 string length limit.
765
1178
  // The header is built last so its provenance item reflects the real count.
766
1179
  const header = buildHeader(newEntityCount + modifiedEntityCount);
@@ -772,6 +1185,7 @@ export class StepExporter {
772
1185
  newEntityCount,
773
1186
  modifiedEntityCount,
774
1187
  fileSize: content.byteLength,
1188
+ warnings,
775
1189
  },
776
1190
  };
777
1191
  }
@@ -886,8 +1300,11 @@ export class StepExporter {
886
1300
  return result;
887
1301
  }
888
1302
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
889
- if (entityRef && this.dataStore.source && entityRef.byteLength > 0) {
890
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
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)) {
1307
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
891
1308
  // #ID=IFCWALL('GlobalId',#owner,...): GlobalId is a quoted STEP string
892
1309
  // (doubled '' escapes); OwnerHistory is the ref/`$` right after it.
893
1310
  const match = entityText.match(/=\s*IFC\w+\s*\(\s*'(?:[^']|'')*'\s*,\s*#(\d+)/i);
@@ -900,7 +1317,7 @@ export class StepExporter {
900
1317
  /**
901
1318
  * Generate STEP entities for property sets
902
1319
  */
903
- generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNames, random) {
1320
+ generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNames, random) {
904
1321
  const lines = [];
905
1322
  let count = 0;
906
1323
  const generatedTypeOwnedPsetIds = new Map();
@@ -910,8 +1327,13 @@ export class StepExporter {
910
1327
  for (const prop of pset.properties) {
911
1328
  const propId = this.nextExpressId++;
912
1329
  count++;
913
- const valueStr = serializePropertyValue(prop.value, prop.type);
914
- const unitId = prop.unit ? this.findUnitId(prop.unit) : null;
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);
1336
+ const unitId = prop.unit ? this.findUnitId(prop.unit, effective) : null;
915
1337
  const unitStr = unitId !== null ? ref(unitId) : null;
916
1338
  // #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
917
1339
  const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
@@ -976,6 +1398,86 @@ export class StepExporter {
976
1398
  }
977
1399
  return { lines, count };
978
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
+ }
979
1481
  /**
980
1482
  * Rewrite root IFC attributes directly on the original STEP entity line.
981
1483
  */
@@ -1141,9 +1643,9 @@ export class StepExporter {
1141
1643
  const forceReal = realSlots.has(index) || tokenIsRealLiteral(currentToken);
1142
1644
  return serializeStepValue(value, forceReal);
1143
1645
  }
1144
- resolveMapUnitReference(unitName, newGeorefLines) {
1646
+ resolveMapUnitReference(unitName, newGeorefLines, effective) {
1145
1647
  const normalized = this.normalizeMapUnitName(unitName);
1146
- const existing = this.findLengthUnitReference(normalized);
1648
+ const existing = this.findLengthUnitReference(normalized, effective);
1147
1649
  if (existing !== null) {
1148
1650
  return existing;
1149
1651
  }
@@ -1179,14 +1681,22 @@ export class StepExporter {
1179
1681
  return 'FOOT';
1180
1682
  return normalized;
1181
1683
  }
1182
- findLengthUnitReference(preferredUnitName) {
1684
+ /**
1685
+ * `effective` filters the candidates the same way the georef reads above do:
1686
+ * returning a tombstoned unit id hands the caller a `#id` for a line the
1687
+ * export never writes. Returning null instead makes `resolveMapUnitReference`
1688
+ * synthesise a fresh unit, which is the outcome a deleted unit deserves.
1689
+ */
1690
+ findLengthUnitReference(preferredUnitName, effective) {
1183
1691
  if (!this.entityExtractor)
1184
1692
  return null;
1185
- const projectIds = this.dataStore.entityIndex.byType.get('IFCPROJECT') ?? [];
1186
- const projectRef = projectIds[0] ? this.dataStore.entityIndex.byId.get(projectIds[0]) : undefined;
1693
+ // Only source records carry the bytes `extractEntity` reads, so an
1694
+ // overlay-created project is skipped rather than shadowing the file's own.
1695
+ const projectId = (effective.byType.get('IFCPROJECT') ?? []).find((id) => this.dataStore.entityIndex.byId.has(id));
1696
+ const projectRef = projectId !== undefined ? this.dataStore.entityIndex.byId.get(projectId) : undefined;
1187
1697
  const project = projectRef ? this.entityExtractor.extractEntity(projectRef) : null;
1188
1698
  const unitAssignmentId = project?.attributes?.[8];
1189
- if (typeof unitAssignmentId !== 'number')
1699
+ if (typeof unitAssignmentId !== 'number' || effective.isDeleted(unitAssignmentId))
1190
1700
  return null;
1191
1701
  const unitAssignmentRef = this.dataStore.entityIndex.byId.get(unitAssignmentId);
1192
1702
  const unitAssignment = unitAssignmentRef ? this.entityExtractor.extractEntity(unitAssignmentRef) : null;
@@ -1194,7 +1704,7 @@ export class StepExporter {
1194
1704
  if (!Array.isArray(units))
1195
1705
  return null;
1196
1706
  for (const unitId of units) {
1197
- if (typeof unitId !== 'number')
1707
+ if (typeof unitId !== 'number' || effective.isDeleted(unitId))
1198
1708
  continue;
1199
1709
  const unitRef = this.dataStore.entityIndex.byId.get(unitId);
1200
1710
  const unit = unitRef ? this.entityExtractor.extractEntity(unitRef) : null;
@@ -1222,10 +1732,36 @@ export class StepExporter {
1222
1732
  }
1223
1733
  return null;
1224
1734
  }
1225
- findPreferredGeometricRepresentationContextId() {
1735
+ /**
1736
+ * Record that a requested IfcMapConversion could not be written. Emitting it
1737
+ * anyway would leave `SourceCRS` pointing at nothing, so the refusal is the
1738
+ * correct output — but the file alone cannot express it, which is why it goes
1739
+ * back to the caller in `stats.warnings` as well as to the console (#2067).
1740
+ */
1741
+ reportMapConversionRefused(warnings) {
1742
+ warnings.push(MAP_CONVERSION_WITHOUT_CONTEXT_WARNING);
1743
+ console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}`);
1744
+ }
1745
+ /**
1746
+ * Record that a requested IfcMapConversion could not be written because
1747
+ * there is no IfcProjectedCRS to attach it to — a different refusal from
1748
+ * {@link reportMapConversionRefused}: "no CRS to attach it to" rather than
1749
+ * "no context to reference" (#2105).
1750
+ */
1751
+ reportMapConversionRefusedNoCrs(warnings) {
1752
+ warnings.push(MAP_CONVERSION_WITHOUT_CRS_WARNING);
1753
+ console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CRS_WARNING}`);
1754
+ }
1755
+ /**
1756
+ * `effective` again: the id returned here becomes the new IfcMapConversion's
1757
+ * SourceCRS, so a tombstoned context would leave the created line pointing at
1758
+ * a record the export skips — a dangling reference and an invalid file.
1759
+ */
1760
+ findPreferredGeometricRepresentationContextId(effective) {
1226
1761
  if (!this.entityExtractor)
1227
1762
  return null;
1228
- const contextIds = this.dataStore.entityIndex.byType.get('IFCGEOMETRICREPRESENTATIONCONTEXT') ?? [];
1763
+ const contextIds = (effective.byType.get('IFCGEOMETRICREPRESENTATIONCONTEXT') ?? [])
1764
+ .filter((id) => this.dataStore.entityIndex.byId.has(id));
1229
1765
  let first3dContext = null;
1230
1766
  for (const contextId of contextIds) {
1231
1767
  const contextRef = this.dataStore.entityIndex.byId.get(contextId);
@@ -1263,8 +1799,8 @@ export class StepExporter {
1263
1799
  /**
1264
1800
  * Find a unit entity ID by name (simplified - returns null for now)
1265
1801
  */
1266
- findUnitId(unitName) {
1267
- return this.findLengthUnitReference(this.normalizeMapUnitName(unitName));
1802
+ findUnitId(unitName, effective) {
1803
+ return this.findLengthUnitReference(this.normalizeMapUnitName(unitName), effective);
1268
1804
  }
1269
1805
  /**
1270
1806
  * Check if an entity type is a geometry-related type
@@ -1343,7 +1879,7 @@ export class StepExporter {
1343
1879
  const entityRef = this.dataStore.entityIndex.byId.get(relId);
1344
1880
  if (!entityRef || !this.dataStore.source)
1345
1881
  return [];
1346
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1882
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1347
1883
  // Parse IfcRelDefinesByProperties: #ID=IFCRELDEFINESBYPROPERTIES('guid',$,$,$,(#objects),#pset);
1348
1884
  // The 5th argument (index 4) is the list of related objects
1349
1885
  const match = entityText.match(/\(([^)]+)\)\s*,\s*#(\d+)\s*\)\s*;/);
@@ -1364,7 +1900,7 @@ export class StepExporter {
1364
1900
  const entityRef = this.dataStore.entityIndex.byId.get(relId);
1365
1901
  if (!entityRef || !this.dataStore.source)
1366
1902
  return null;
1367
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1903
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1368
1904
  // Last #ID before the closing );
1369
1905
  const match = entityText.match(/,\s*#(\d+)\s*\)\s*;$/);
1370
1906
  if (!match)
@@ -1378,7 +1914,7 @@ export class StepExporter {
1378
1914
  const entityRef = this.dataStore.entityIndex.byId.get(psetId);
1379
1915
  if (!entityRef || !this.dataStore.source)
1380
1916
  return null;
1381
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1917
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1382
1918
  // Parse: IFCPROPERTYSET('guid',$,'Name',$,...) - Name is 3rd argument
1383
1919
  const match = entityText.match(/IFCPROPERTYSET\s*\([^,]*,[^,]*,'([^']*)'/i);
1384
1920
  if (!match)
@@ -1392,7 +1928,7 @@ export class StepExporter {
1392
1928
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1393
1929
  if (!entityRef || !this.dataStore.source)
1394
1930
  return null;
1395
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1931
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1396
1932
  // Parse: IFCELEMENTQUANTITY('guid',$,'Name',...) - Name is 3rd argument
1397
1933
  const match = entityText.match(/IFCELEMENTQUANTITY\s*\([^,]*,[^,]*,'([^']*)'/i);
1398
1934
  if (!match)
@@ -1440,7 +1976,7 @@ export class StepExporter {
1440
1976
  const entityRef = this.dataStore.entityIndex.byId.get(psetId);
1441
1977
  if (!entityRef || !this.dataStore.source)
1442
1978
  return [];
1443
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1979
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1444
1980
  // Parse: IFCPROPERTYSET(...,(#prop1,#prop2,...)); - Last argument is properties list
1445
1981
  const match = entityText.match(/\(\s*(#[^)]+)\s*\)\s*\)\s*;$/);
1446
1982
  if (!match)
@@ -1480,24 +2016,6 @@ export class StepExporter {
1480
2016
  return [];
1481
2017
  return hasPropertySets.filter((value) => typeof value === 'number');
1482
2018
  }
1483
- /**
1484
- * Replace a single top-level STEP attribute in an entity line.
1485
- */
1486
- replaceEntityAttribute(entityId, attrIndex, replacement) {
1487
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1488
- if (!entityRef || !this.dataStore.source)
1489
- return null;
1490
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1491
- const match = entityText.match(/^(#\d+\s*=\s*\w+\()([\s\S]*)(\)\s*;)\s*$/);
1492
- if (!match)
1493
- return null;
1494
- const [, prefix, attrsText, suffix] = match;
1495
- const attrs = splitTopLevelStepArguments(attrsText);
1496
- if (attrIndex >= attrs.length)
1497
- return null;
1498
- attrs[attrIndex] = replacement;
1499
- return `${prefix}${attrs.join(',')}${suffix}`;
1500
- }
1501
2019
  }
1502
2020
  /**
1503
2021
  * Quick export function for simple use cases.