@ifc-lite/export 2.9.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/csv-cell.d.ts +101 -0
  2. package/dist/csv-cell.d.ts.map +1 -0
  3. package/dist/csv-cell.js +90 -0
  4. package/dist/csv-cell.js.map +1 -0
  5. package/dist/demesh-session.d.ts.map +1 -1
  6. package/dist/demesh-session.js +2 -1
  7. package/dist/demesh-session.js.map +1 -1
  8. package/dist/effective-index.js +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +9 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lod1-generator.d.ts +0 -2
  14. package/dist/lod1-generator.d.ts.map +1 -1
  15. package/dist/lod1-generator.js +1 -1
  16. package/dist/lod1-generator.js.map +1 -1
  17. package/dist/merged-exporter.d.ts +3 -2
  18. package/dist/merged-exporter.d.ts.map +1 -1
  19. package/dist/merged-exporter.js +32 -44
  20. package/dist/merged-exporter.js.map +1 -1
  21. package/dist/parquet-exporter.js +2 -0
  22. package/dist/parquet-exporter.js.map +1 -1
  23. package/dist/reference-collector.d.ts +40 -17
  24. package/dist/reference-collector.d.ts.map +1 -1
  25. package/dist/reference-collector.js +89 -19
  26. package/dist/reference-collector.js.map +1 -1
  27. package/dist/schema-converter.d.ts.map +1 -1
  28. package/dist/schema-converter.js +27 -1
  29. package/dist/schema-converter.js.map +1 -1
  30. package/dist/source-ref-bounds.d.ts +16 -1
  31. package/dist/source-ref-bounds.d.ts.map +1 -1
  32. package/dist/source-ref-bounds.js +85 -0
  33. package/dist/source-ref-bounds.js.map +1 -1
  34. package/dist/step-attribute-mutations.d.ts +91 -0
  35. package/dist/step-attribute-mutations.d.ts.map +1 -0
  36. package/dist/step-attribute-mutations.js +302 -0
  37. package/dist/step-attribute-mutations.js.map +1 -0
  38. package/dist/step-collection.d.ts +55 -0
  39. package/dist/step-collection.d.ts.map +1 -0
  40. package/dist/step-collection.js +125 -0
  41. package/dist/step-collection.js.map +1 -0
  42. package/dist/step-export-contexts.d.ts +63 -0
  43. package/dist/step-export-contexts.d.ts.map +1 -0
  44. package/dist/step-export-contexts.js +51 -0
  45. package/dist/step-export-contexts.js.map +1 -0
  46. package/dist/step-export-types.d.ts +262 -0
  47. package/dist/step-export-types.d.ts.map +1 -0
  48. package/dist/step-export-types.js +22 -0
  49. package/dist/step-export-types.js.map +1 -0
  50. package/dist/step-exporter.d.ts +45 -398
  51. package/dist/step-exporter.d.ts.map +1 -1
  52. package/dist/step-exporter.js +145 -2033
  53. package/dist/step-exporter.js.map +1 -1
  54. package/dist/step-geometry-types.d.ts +19 -0
  55. package/dist/step-geometry-types.d.ts.map +1 -0
  56. package/dist/step-geometry-types.js +57 -0
  57. package/dist/step-geometry-types.js.map +1 -0
  58. package/dist/step-georeferencing.d.ts +58 -0
  59. package/dist/step-georeferencing.d.ts.map +1 -0
  60. package/dist/step-georeferencing.js +339 -0
  61. package/dist/step-georeferencing.js.map +1 -0
  62. package/dist/step-header.d.ts +43 -0
  63. package/dist/step-header.d.ts.map +1 -0
  64. package/dist/step-header.js +70 -0
  65. package/dist/step-header.js.map +1 -0
  66. package/dist/step-omission-predicates.d.ts +51 -0
  67. package/dist/step-omission-predicates.d.ts.map +1 -0
  68. package/dist/step-omission-predicates.js +268 -0
  69. package/dist/step-omission-predicates.js.map +1 -0
  70. package/dist/step-overlay-entities.d.ts +64 -0
  71. package/dist/step-overlay-entities.d.ts.map +1 -0
  72. package/dist/step-overlay-entities.js +119 -0
  73. package/dist/step-overlay-entities.js.map +1 -0
  74. package/dist/step-pass-builder.d.ts +44 -0
  75. package/dist/step-pass-builder.d.ts.map +1 -0
  76. package/dist/step-pass-builder.js +249 -0
  77. package/dist/step-pass-builder.js.map +1 -0
  78. package/dist/step-property-sets.d.ts +198 -0
  79. package/dist/step-property-sets.d.ts.map +1 -0
  80. package/dist/step-property-sets.js +842 -0
  81. package/dist/step-property-sets.js.map +1 -0
  82. package/dist/step-serialization.d.ts +16 -1
  83. package/dist/step-serialization.d.ts.map +1 -1
  84. package/dist/step-serialization.js +50 -3
  85. package/dist/step-serialization.js.map +1 -1
  86. package/dist/step-source-iteration.d.ts +95 -0
  87. package/dist/step-source-iteration.d.ts.map +1 -0
  88. package/dist/step-source-iteration.js +178 -0
  89. package/dist/step-source-iteration.js.map +1 -0
  90. package/package.json +7 -7
@@ -1,65 +1,20 @@
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, extractQuantitiesOnDemand, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
5
- import { generateIfcGuid } from '@ifc-lite/encoding';
6
- import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities, filterHiddenRefsFromRelationshipLine, } from './reference-collector.js';
7
- import { convertStepLine, needsConversion } from './schema-converter.js';
8
- import { retypeStepLine, retypeArgTokens } from './retype.js';
4
+ import { EntityExtractor, parseSourceHeader } from '@ifc-lite/parser';
5
+ import { needsConversion } from './schema-converter.js';
9
6
  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
7
  import { createSourceRefReader } from './source-ref-bounds.js';
13
- import { authoredEntityRefs, getEffectiveEntityIndex } from './effective-index.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';
18
- import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
19
- import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
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 four reads.
28
- */
29
- function decodeRange(src, start, end) {
30
- return asSourceBytes(src).decodeUtf8(start, end);
31
- }
32
- /** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
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.';
8
+ import { writeSourceEntityLines } from './step-source-iteration.js';
9
+ import { writeOverlayCreatedEntities } from './step-overlay-entities.js';
10
+ import { generatePropertyAndQuantitySetEntities, } from './step-property-sets.js';
11
+ import { collectModifications } from './step-collection.js';
12
+ import { assembleExportResult } from './step-header.js';
13
+ import { applySourceLineMutations } from './step-attribute-mutations.js';
14
+ import { buildExportPass } from './step-pass-builder.js';
15
+ import { evaluateOmissionPredicates } from './step-omission-predicates.js';
16
+ import { isGeometryEntity } from './step-geometry-types.js';
17
+ import { buildSourceIterationContext, buildOverlayEntitiesContext, } from './step-export-contexts.js';
63
18
  /**
64
19
  * IFC STEP file exporter
65
20
  */
@@ -68,11 +23,16 @@ export class StepExporter {
68
23
  mutationView;
69
24
  nextExpressId;
70
25
  entityExtractor;
71
- /** Lazily-resolved fallback `#id` of an IfcOwnerHistory that survives the
72
- * current export closure (or `$` when the file has none). */
73
- ownerHistoryFallbackRef;
74
- /** Per-host cache of an element's own OwnerHistory ref (`#id` or null). */
75
- ownerHistoryByEntity = new Map();
26
+ /**
27
+ * The owner-history memos the property-set and quantity-set generators read.
28
+ *
29
+ * Owned here and handed to `step-property-sets.ts` BY REFERENCE rather than
30
+ * stored on its context: the reset below is an `export()`-level statement,
31
+ * and the comment there is where "per export, not per exporter" is argued.
32
+ * Moving the storage into a per-export context would make that reset
33
+ * implicit — the same invariant, in a place nothing says it (#2475 step 2b).
34
+ */
35
+ ownerHistory = { fallbackRef: undefined, byEntity: new Map() };
76
36
  /**
77
37
  * "Can this record's line actually be read out of this store's source?"
78
38
  * (`source-ref-bounds.ts`, #2491). Built once — `dataStore` is assigned in
@@ -95,20 +55,33 @@ export class StepExporter {
95
55
  * Export to STEP format
96
56
  */
97
57
  export(options) {
98
- const entities = [];
99
- let newEntityCount = 0;
100
58
  // Both owner-history caches are per-EXPORT, not per-exporter: they now
101
59
  // depend on `willBeEmitted`, which depends on this call's options. Reusing
102
60
  // one exporter for a `visibleOnly` export and then a full one would
103
61
  // otherwise answer the second from the first one's closure.
104
- this.ownerHistoryFallbackRef = undefined;
105
- this.ownerHistoryByEntity.clear();
62
+ this.ownerHistory.fallbackRef = undefined;
63
+ this.ownerHistory.byEntity.clear();
106
64
  // Determine target schema from options, source schema from data store
107
65
  const schema = options.schema || this.dataStore.schemaVersion || 'IFC4';
108
66
  const sourceSchema = this.dataStore.schemaVersion || 'IFC4';
109
67
  const converting = needsConversion(sourceSchema, schema);
68
+ // Read ONCE, here, and consumed everywhere below instead of re-spelling
69
+ // `options.applyMutations !== false` per site. `options` is the caller's
70
+ // object and this export re-enters it dozens of times; an accessor that
71
+ // answered differently on the second read would have let the effective
72
+ // index be built WITH the overlay while a later guard — including the
73
+ // relationship-filter precondition — decided there was none. Reading each
74
+ // option that feeds that precondition exactly once makes the two agree by
75
+ // construction rather than by every site happening to spell it the same
76
+ // way (adversarial review of #2668's replacement gate).
77
+ const applyMutations = options.applyMutations !== false;
78
+ // Same, for the other option the precondition reads. `isGeometryExcluded`
79
+ // below and both output passes' own geometry skips consume this one const,
80
+ // so "the gate thinks geometry is included while the predicate thinks it is
81
+ // excluded" is not a state this export can reach.
82
+ const excludeGeometry = options.includeGeometry === false;
110
83
  if (schema === 'IFC2X3' &&
111
- options.applyMutations !== false &&
84
+ applyMutations &&
112
85
  options.georefMutations &&
113
86
  (Object.keys(options.georefMutations.projectedCRS ?? {}).length > 0 ||
114
87
  Object.keys(options.georefMutations.mapConversion ?? {}).length > 0)) {
@@ -128,1151 +101,64 @@ export class StepExporter {
128
101
  const schemaToken = !converting && sourceHeader?.schemaIdentifiers?.[0]
129
102
  ? sourceHeader.schemaIdentifiers[0]
130
103
  : schema;
131
- // Built once entity counts are known, so the provenance item can report the
132
- // actual modification count. See the two call sites (empty delta + final).
133
- const buildHeader = (modifications) => {
134
- // FILE_DESCRIPTION items: an explicit option wins, else the source items
135
- // verbatim, else the generic default.
136
- const description = options.description !== undefined
137
- ? [options.description]
138
- : sourceHeader && sourceHeader.description.length > 0
139
- ? [...sourceHeader.description]
140
- : ['Exported from ifc-lite'];
141
- // Honest provenance: never claim untouched source output. Append (never
142
- // overwrite) one item when ifc-lite actually changed the file.
143
- if (modifications > 0) {
144
- description.push(`Re-exported by ifc-lite, ${modifications} modification${modifications === 1 ? '' : 's'}`);
145
- }
146
- return generateHeader({
147
- schema: schemaToken,
148
- description,
149
- implementationLevel: sourceHeader?.implementationLevel,
150
- author: options.author ?? sourceHeader?.author,
151
- organization: options.organization ?? sourceHeader?.organization,
152
- // preprocessor_version = the tool that WROTE this file (ifc-lite);
153
- // originating_system keeps the source authoring tool so it isn't erased.
154
- preprocessorVersion: options.application ?? 'ifc-lite',
155
- originatingSystem: sourceHeader?.originatingSystem,
156
- authorization: sourceHeader?.authorization,
157
- application: options.application ?? 'ifc-lite',
158
- filename: options.filename ?? 'export.ifc',
159
- timeStamp: options.timeStamp,
160
- });
161
- };
162
- // The one authority for exists / class / deleted, overlay first and source
163
- // buffer second. Every pass below asks this instead of `this.dataStore`,
164
- // which answers only for the file as parsed (#2012).
165
- const effective = getEffectiveEntityIndex(this.dataStore, this.mutationView, options.applyMutations !== false);
166
- // Does this id belong to an entity the OVERLAY created (`createEntity` /
167
- // `store.addEntity`) rather than to a record in the source buffer? Such an
168
- // entity has no source bytes, so the source-iteration pass below never sees
169
- // it and the new-entities pass at the end owns its line entirely (#2006).
170
- const isOverlayCreated = (entityId) => effective.isOverlayCreated(entityId);
171
- // Does this record describe a line this export can actually READ out of the
172
- // source? One predicate for every byte-range gate below, so they cannot
173
- // disagree — see `source-ref-bounds.ts` for the corrupt file the weaker
174
- // "is there a source / does the ref claim bytes" pair let through (#2491).
175
- const isReadableSourceRef = createSourceRefReader(this.dataStore.source);
176
- // Build visible-only closure if requested. Classification, the closure walk
177
- // and the style pass all run over the EFFECTIVE index: an overlay-created
178
- // product becomes a root by the same type rules as a parsed one, the walk
179
- // follows its authored references into the geometry it alone owns, and a
180
- // tombstoned entity is simply not there. Run over the source buffer, a
181
- // created wall could never be a root and nothing referenced it, so
182
- // `visibleOnly` wrote a file without it and said nothing (#2012).
183
- //
184
- // Computed here, ahead of the modification-count passes below, because
185
- // `hasEmittableHostBytes` needs it: a source-backed host EXCLUDED by
186
- // `visibleOnly` never gets its line written by the source-iteration pass
187
- // either, so counting it as "modified" would make the header claim a
188
- // change the DATA section does not contain (CodeRabbit finding on #2414).
189
- let allowedEntityIds = null;
190
- // Populated alongside `allowedEntityIds` below. `getVisibleEntityIds`
191
- // excludes a hidden PRODUCT's own line from the closure, but `IFCREL*` is
192
- // an unconditional root a few lines down and its bytes are copied verbatim
193
- // by the source-iteration pass — nothing there filters a `#N` the closure
194
- // just excluded out of the relationship's own attribute list. Kept so the
195
- // source-iteration and overlay new-entity passes can run
196
- // `filterHiddenRefsFromRelationshipLine` against the SAME exclusion set
197
- // `collectReferencedEntityIds` used, rather than a second, possibly
198
- // divergent notion of "hidden" (#2398).
199
- let hiddenProductIds = null;
200
- // A relationship can name an excluded entity two ways that have nothing
201
- // to do with each other: a `visibleOnly` hidden PRODUCT (`hiddenProductIds`,
202
- // below), and a TOMBSTONED one — `editor.removeEntity` on a related object
203
- // named by a relationship the deletion sweep below does not reach (that
204
- // sweep only withholds an `IfcRelDefinesByProperties` when EVERY related
205
- // object is gone, and only for that one relationship class). Left alone, a
206
- // relationship still naming a deleted entity ships the identical `#N` with
207
- // no `#N=` line, on a path with no `visibleOnly` involved at all (#2398).
208
- // `effective.isDeleted` answers for every id, not just a precomputed set,
209
- // so this predicate covers both sources without a second exclusion set.
210
- //
211
- // Declared here, ahead of the closure walk below, and passed into
212
- // `collectReferencedEntityIds` as its `isRefExcluded` — the walk's bridge
213
- // decision (whether an `IFCREL*` root may reach what it names) and the
214
- // OUTPUT-line filtering further down now read the SAME predicate, rather
215
- // than the walk inventing its own `!entityIndex.has` proxy for "deleted"
216
- // that could disagree with this one on an id that never existed in the
217
- // file at all (maintainer-found regression on #2637: such an id blocked
218
- // the bridge but did not stop the relationship's own line from shipping,
219
- // dropping a VISIBLE sibling's pset while adding a fresh dangling ref).
220
- // A closure over the `let hiddenProductIds` above, not a value snapshot —
221
- // correct because nothing reads it before `hiddenProductIds` is assigned
222
- // just below.
223
- const isExcludedFromRelationshipRefs = (id) => (hiddenProductIds !== null && hiddenProductIds.has(id)) || effective.isDeleted(id);
224
- if (options.visibleOnly && this.dataStore.source) {
225
- const visible = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
226
- hiddenProductIds = visible.hiddenProductIds;
227
- allowedEntityIds = collectReferencedEntityIds(visible.roots, this.dataStore.source, effective, visible.hiddenProductIds, isExcludedFromRelationshipRefs);
228
- // Second pass: collect IFCSTYLEDITEM entities that reference included
229
- // geometry. Styled items reference geometry items but nothing references
230
- // them back, so the forward closure misses them.
231
- collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
232
- }
233
- // `overlayActive` proper (used everywhere else) is declared further below,
234
- // ahead of the mutation-processing block it gates; duplicated here as the
235
- // same expression rather than reordering that declaration.
236
- const mayNameExcludedRefs = (hiddenProductIds !== null && hiddenProductIds.size > 0)
237
- || (!!this.mutationView && options.applyMutations !== false);
238
- // Will THIS entity's own line ever land in the file? The same byte-range
239
- // test `willBeEmitted` uses (defined further below) and the source-
240
- // iteration pass's own skip at `entityRef.byteLength === 0` — a source
241
- // entity with no bytes (a point-cloud / GLB "entity" from
242
- // `createSyntheticDataStore`, not an overlay-created one) never gets a
243
- // defining line written, source-iteration or otherwise, so a pset/attribute
244
- // edit against it must not count as a modification either: the header
245
- // would describe a change the file does not contain (out-of-scope finding
246
- // in #2398). Also excludes a source-backed host the visible-only closure
247
- // above drops — same reasoning, different reason the line never lands.
104
+ // The one construction site for the state this export shares across its
105
+ // seven phases, built in `step-pass-builder.ts` (#2475). `ExportPass` in
106
+ // `step-export-types.ts` says what belongs on it and what deliberately
107
+ // does not.
248
108
  //
249
- // And, like `willBeEmitted` below, excludes a geometry-classified SOURCE
250
- // host under `includeGeometry: false`: the source-iteration pass's own
251
- // `isGeometryEntity` skip (further below) drops that line too, so this
252
- // predicate must agree or a geometry entity's attribute edit inflates the
253
- // count over an omitted line (CodeRabbit finding on #2414). Guarded by
254
- // `!deltaOnly` for the same reason `willBeEmitted` is: under `deltaOnly`
255
- // the source-iteration pass — and its geometry skip — never runs at all,
256
- // so a source entity's line is assumed to already exist in the file being
257
- // patched, geometry or not.
258
- const isGeometryExcluded = (entityId, recordType) => options.includeGeometry === false
259
- && this.isGeometryEntity(effective.effectiveType(entityId, recordType));
260
- const hasEmittableHostBytes = (entityId) => {
261
- if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
262
- return false;
263
- const ref = effective.get(entityId);
264
- // The ref must be READABLE, not merely non-empty: a range this source
265
- // cannot address decodes to the empty string, which used to be pushed
266
- // into the file as a blank line while everything generated FOR the host
267
- // still named it (#2491).
268
- if (!ref || !isReadableSourceRef(ref))
269
- return false;
270
- if (options.deltaOnly !== true && isGeometryExcluded(entityId, ref.type))
271
- return false;
272
- return true;
273
- };
274
- // Under `deltaOnly` a nomination only becomes a count once some pass has
275
- // actually written content that delivers THAT KIND of edit for the host —
276
- // see `delta-modification-ledger.ts` for why the two are not the same event
277
- // in that mode, and why the pair is (entity, kind) rather than the entity
278
- // (#2462).
279
- const modifications = createModificationLedger(options.deltaOnly === true);
280
- /**
281
- * Hosts whose in-place named-attribute edits a FULL export may count, per
282
- * kind. Filled by the collection passes below and read by the two passes
283
- * that write a rewritten source line see `in-place-nomination.ts` for why
284
- * the nomination waits for the rewrite in this mode and not under
285
- * `deltaOnly` (#2483).
286
- */
287
- const inPlaceNominees = {
288
- attribute: new Set(),
289
- georeferencing: new Set(),
290
- };
291
- // Collect entities that need to be modified or created
292
- const modifiedEntities = new Set();
293
- const modifiedAttributes = new Map();
294
- const newPropertySets = [];
295
- const newQuantitySets = [];
296
- const typeOwnedPsetNamesByEntity = new Map();
297
- const typeOwnedPsetIdsByEntity = new Map();
298
- const rewrittenEntityIds = new Set();
299
- const rewrittenEntityLines = new Map();
300
- /** HasPropertySets slot value for an OVERLAY-CREATED type object, applied
301
- * by the new-entities pass (there is no source line to rewrite). */
302
- const overlayTypeOwnedPsets = new Map();
303
- // Track property set IDs and relationship IDs to skip
304
- const skipPropertySetIds = new Set();
305
- const skipRelationshipIds = new Set();
306
- const overlayActive = !!this.mutationView && (options.applyMutations !== false);
307
- // Process mutations if we have a mutation view
308
- if (this.mutationView && (options.applyMutations !== false)) {
309
- const mutations = this.mutationView.getMutations();
310
- // Attribute values come from the *overlay*, never from the mutation
311
- // history. The history is append-only and undo writes its reverse edit
312
- // with `skipHistory: true`, so a superseded UPDATE_ATTRIBUTE record keeps
313
- // its stale `newValue` forever — replaying it resurrects edits the user
314
- // undid (#1957). The overlay is what the editor shows, and it is already
315
- // the source for psets, quantities, positional attributes and retypes
316
- // below, so attributes were the sole outlier.
317
- for (const [entityId, attrs] of this.mutationView.getAttributeMutationsByEntity()) {
318
- modifiedEntities.add(entityId);
319
- let target = modifiedAttributes.get(entityId);
320
- if (!target) {
321
- target = new Map();
322
- modifiedAttributes.set(entityId, target);
323
- }
324
- for (const [name, value] of attrs)
325
- target.set(name, value);
326
- }
327
- // Group mutations by entity, separating property vs quantity mutations
328
- const entityPropMutations = new Map();
329
- const entityQuantMutations = new Map();
330
- for (const mutation of mutations) {
331
- // Handled above, off the overlay. Skipped explicitly because an
332
- // UPDATE_ATTRIBUTE record can also carry a `psetName` (georef fields
333
- // encode their target entity there) and must not be mistaken for a
334
- // property-set edit.
335
- if (mutation.type === 'UPDATE_ATTRIBUTE')
336
- continue;
337
- if (!mutation.psetName)
338
- continue;
339
- const isQuantity = mutation.type === 'CREATE_QUANTITY' || mutation.type === 'UPDATE_QUANTITY'
340
- || mutation.type === 'DELETE_QUANTITY' || mutation.type === 'DELETE_QUANTITY_SET';
341
- const targetMap = isQuantity ? entityQuantMutations : entityPropMutations;
342
- if (!targetMap.has(mutation.entityId)) {
343
- targetMap.set(mutation.entityId, new Set());
344
- }
345
- targetMap.get(mutation.entityId).add(mutation.psetName);
346
- }
347
- // Build a reverse index of IfcRelDefinesByProperties → (relId, psetId)
348
- // pairs keyed on each related entity. The two property/quantity loops
349
- // below previously walked every entity in `entityIndex.byId` per
350
- // modified entity (O(E·N)); the index keeps the per-entity step
351
- // O(K) where K is the number of rels referencing that entity.
352
- const { byEntity: relDefinesByEntity, relatedByRel } = this.buildRelDefinesByPropertiesIndex();
353
- // A source IfcRelDefinesByProperties whose EVERY related object the
354
- // session deleted has nothing left to relate, and emitting it leaves a
355
- // `#id` pointing at a record the export skipped. Dropped only when all of
356
- // them are gone: a rel that still names a live entity is that entity's
357
- // only link to its psets, and nothing here rewrites a RelatedObjects list.
358
- for (const [relId, related] of relatedByRel) {
359
- if (related.length > 0 && related.every((id) => effective.isDeleted(id))) {
360
- skipRelationshipIds.add(relId);
361
- }
362
- }
363
- // Collect modified property sets and find original psets to skip
364
- for (const [entityId, psetNames] of entityPropMutations) {
365
- // A deleted entity must not cause the exporter to REMOVE anything.
366
- //
367
- // This is the other half of the dangling-reference class, and the half
368
- // `willBeEmitted` cannot reach: that predicate guards what gets ADDED,
369
- // and this loop's real work is deciding what gets SKIPPED. An edited
370
- // pset is replaced wholesale, so its original id goes into
371
- // `skipPropertySetIds` — but IFC exporters share one IfcPropertySet
372
- // between entities, and once the host is deleted there is no
373
- // replacement to take its place. The surviving entity's relation then
374
- // points at a container nobody wrote. Verified against main at
375
- // e6516991 (#2030's own merge): edit `Pset_WallCommon` on one of two
376
- // walls sharing it, delete that wall, and the export drops #11 while
377
- // #12 still names it. `retainSharedAtoms` rescues a shared ATOM one
378
- // level down; nothing rescues the shared container.
379
- //
380
- // Leaving the pset alone makes it an orphan when nothing else
381
- // references it, which is valid IFC. Its relation is dropped by the
382
- // sweep above, which handles a plain delete too — no pset edit needed.
383
- if (effective.isDeleted(entityId))
384
- continue;
385
- modifiedEntities.add(entityId);
386
- // Same rule as the attribute loop below: an overlay-CREATED entity is
387
- // emitted once, by the new-entities pass, and already counted in
388
- // `newEntityCount` — as are the pset entities this loop goes on to
389
- // generate. Only the COUNT is guarded; the entity still records its
390
- // pset edits and still emits them.
391
- //
392
- // A NOMINATION, in both modes, never a count on its own: this site sees
393
- // a pset NAME the session touched, not whether that name resolves to
394
- // anything. `deletePropertySet(id, 'AName')` on a host that owns no such
395
- // set reaches here and changes nothing at all, and used to put "1
396
- // modification" in the header of a byte-identical file (#2474). What
397
- // settles it is the generator's `recordEmitted` and the skip branches'
398
- // `recordWithheld` below.
399
- if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId)) {
400
- modifications.nominate(entityId, 'property-set');
401
- }
402
- // Get the FULL mutated property sets for this entity (merged base + mutations)
403
- const allPsets = this.mutationView.getForEntity(entityId);
404
- const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
405
- const relDefinedPsetNames = new Set();
406
- if (relevantPsets.length > 0) {
407
- newPropertySets.push({ entityId, psets: relevantPsets });
408
- }
409
- // Find original property set IDs and relationship IDs to skip — look
410
- // up only the IfcRelDefinesByProperties rels that reference this entity.
411
- const rels = relDefinesByEntity.get(entityId);
412
- if (rels) {
413
- for (const { relId, psetId: relatedPsetId } of rels) {
414
- // Check if this pset is one we're modifying
415
- const psetName = this.getPropertySetName(relatedPsetId);
416
- if (psetName) {
417
- relDefinedPsetNames.add(psetName);
418
- }
419
- if (psetName && psetNames.has(psetName)) {
420
- skipRelationshipIds.add(relId);
421
- skipPropertySetIds.add(relatedPsetId);
422
- // Also skip the individual properties in this pset
423
- const propIds = this.getPropertyIdsInSet(relatedPsetId);
424
- for (const propId of propIds) {
425
- skipPropertySetIds.add(propId);
426
- }
427
- // The other half of "did this edit change the file": a full export
428
- // applies a set DELETION by leaving these lines out, and produces
429
- // no replacement content to record an emission for. Without this
430
- // the count would settle from the generator alone and a real
431
- // deletion would stop counting along with the no-op one (#2474).
432
- modifications.recordWithheld(entityId, 'property-set');
433
- }
434
- }
435
- }
436
- if (isTypeClass(effective.typeOf(entityId))) {
437
- const typeOwnedPsetIds = this.getTypeOwnedHasPropertySetIds(entityId, effective);
438
- const typeOwnedAffected = new Set();
439
- for (const psetId of typeOwnedPsetIds) {
440
- const psetName = this.getPropertySetName(psetId);
441
- if (!psetName || !psetNames.has(psetName))
442
- continue;
443
- typeOwnedAffected.add(psetName);
444
- skipPropertySetIds.add(psetId);
445
- const propIds = this.getPropertyIdsInSet(psetId);
446
- for (const propId of propIds) {
447
- skipPropertySetIds.add(propId);
448
- }
449
- // No `recordWithheld` twin of the rel-defined branch above, and
450
- // deliberately: a name that matches an OWNED pset is either dropped
451
- // from the resolved list or swapped for the replacement this export
452
- // generated, so slot 5 always comes back different and the repoint
453
- // below records the emission for it. A second record here would be
454
- // one no mutation can kill.
455
- }
456
- for (const psetName of psetNames) {
457
- if (!relDefinedPsetNames.has(psetName)) {
458
- typeOwnedAffected.add(psetName);
459
- }
460
- }
461
- if (typeOwnedAffected.size > 0) {
462
- typeOwnedPsetNamesByEntity.set(entityId, typeOwnedAffected);
463
- typeOwnedPsetIdsByEntity.set(entityId, typeOwnedPsetIds);
464
- rewrittenEntityIds.add(entityId);
465
- }
466
- }
467
- }
468
- // Collect modified quantity sets (only if quantities are included)
469
- if (options.includeQuantities === false)
470
- entityQuantMutations.clear();
471
- // A quantity overlay with nothing under it regenerates a source quantity
472
- // set from the edited quantity ALONE, and the skip loop below then
473
- // withholds the source lines that held its siblings (#2487). Unlike
474
- // properties — whose base falls back to the `baseTable` the view was
475
- // constructed with — quantities have only the opt-in
476
- // `setQuantityExtractor`, so the default really is an empty base, and
477
- // four in-tree callers plus every external embedder never set it.
478
- //
479
- // The exporter is the one place that always holds the missing half: it
480
- // was handed the very store the view is an overlay ON. Supplying it here
481
- // makes the loss impossible for every caller rather than for the callers
482
- // we happened to find, and a view that resolves its own quantities (the
483
- // viewer, MCP, the CLI headless backend) is never overwritten.
484
- //
485
- // The extractor closes over ONE store, and the view outlives this export.
486
- // So it closes over a BOX this class owns instead: a second export of the
487
- // same view against a DIFFERENT store re-points that box rather than
488
- // reading the first store's quantities, which is the one way "install only
489
- // when absent" could have answered from the wrong file. The setter is
490
- // called at most once per view, so a caller that installs its own
491
- // extractor at any point — before the first export or after it — keeps it.
492
- //
493
- // `hasQuantityBase` and `setQuantityExtractor` are probed, like every other
494
- // optional view capability this class reaches for (`peekNextExpressId`,
495
- // `getNewEntities`, `getEntityTypeMutation`): `MutablePropertyView` is
496
- // published API arriving from a separately versioned package, and callers
497
- // pass partial and duck-typed views. `hasQuantityBase` is newer than
498
- // `setQuantityExtractor`, and without it there is no way to tell an empty
499
- // base from a caller-supplied one — so an older view falls back to the
500
- // pre-#2487 behaviour (no base supplied) rather than risk overwriting one.
501
- const quantityView = this.mutationView;
502
- if (entityQuantMutations.size > 0 &&
503
- typeof quantityView.setQuantityExtractor === 'function' &&
504
- typeof quantityView.hasQuantityBase === 'function') {
505
- const installed = exporterQuantityBase.get(quantityView);
506
- if (installed) {
507
- // Ours, or a caller's that replaced ours: re-pointing the box is a
508
- // no-op in the second case, and calling the setter again is what
509
- // would not be.
510
- installed.store = this.dataStore;
511
- }
512
- else if (!quantityView.hasQuantityBase()) {
513
- const box = { store: this.dataStore };
514
- exporterQuantityBase.set(quantityView, box);
515
- quantityView.setQuantityExtractor((id) => extractQuantitiesOnDemand(box.store, id));
516
- }
517
- }
518
- for (const [entityId, qsetNames] of entityQuantMutations) {
519
- // Same rule as the property loop above: a deleted entity removes nothing.
520
- if (effective.isDeleted(entityId))
521
- continue;
522
- modifiedEntities.add(entityId);
523
- // See the property loop above — an overlay-created entity is counted as
524
- // new, not modified. The pset loop's own nomination no longer has to be
525
- // excluded to avoid a double count: the ledger settles per ENTITY, so a
526
- // host with both a pset and a qset edit counts once whatever is
527
- // nominated. Nominating both buys the opposite — an accurate warning
528
- // when the qset half is the half a delta cannot carry.
529
- //
530
- // Settled from effect like its property-set twin (#2474). The reachable
531
- // no-op here is an UNDONE quantity-set creation whose name matches NO
532
- // source set: `getMutations()` is append-only, so the `CREATE_QUANTITY`
533
- // record still names the qset after `removeQuantityMutation` has taken
534
- // it out of the overlay, and the generator below then finds nothing to
535
- // write. The same undo against a COLLIDING name is not a no-op — it
536
- // withholds the source set's lines — which is what the skip loop's
537
- // `recordWithheld` below settles.
538
- if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId)) {
539
- modifications.nominate(entityId, 'quantity-set');
540
- }
541
- const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
542
- const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
543
- if (relevantQsets.length > 0) {
544
- newQuantitySets.push({ entityId, qsets: relevantQsets });
545
- }
546
- // The names this export is actually WRITING a replacement for. The
547
- // affected-name set is not the same thing: it comes from the session's
548
- // append-only mutation history, which keeps naming a quantity set after
549
- // an undo has taken it back out of the overlay, so a Ctrl+Z used to
550
- // withhold a source `IfcElementQuantity` that nothing regenerated.
551
- //
552
- // A quantity-set REMOVAL is the one case where withholding WITHOUT a
553
- // replacement is the intent rather than the bug. It had no public
554
- // populator when #2487 wrote that rule, so the rule read "always the
555
- // bug"; `MutablePropertyView.deleteQuantitySet` (#2508) gives it one,
556
- // and the deleted set is now asked for by name below. Without that, the
557
- // panel hid a base quantity set the exported file still carried.
558
- const regeneratedQsetNames = new Set(relevantQsets.map((qset) => qset.name));
559
- // Skip original quantity set entities (IfcElementQuantity).
560
- // Same per-entity index lookup as the property branch above.
561
- const rels = relDefinesByEntity.get(entityId);
562
- if (rels) {
563
- for (const { relId, psetId: relatedPsetId } of rels) {
564
- const qsetName = this.getElementQuantityName(relatedPsetId);
565
- const deleted = qsetName !== null
566
- && this.mutationView.isQuantitySetDeleted?.(entityId, qsetName) === true;
567
- if (qsetName && (regeneratedQsetNames.has(qsetName) || deleted)) {
568
- skipRelationshipIds.add(relId);
569
- skipPropertySetIds.add(relatedPsetId);
570
- const quantIds = this.getPropertyIdsInSet(relatedPsetId);
571
- for (const quantId of quantIds) {
572
- skipPropertySetIds.add(quantId);
573
- }
574
- // The withheld half, exactly as the rel-defined property branch
575
- // above. This loop has just decided that #`relatedPsetId`, its
576
- // quantity atoms and the relationship that attached them do NOT
577
- // go into the file; whether anything is generated to take their
578
- // place is decided elsewhere, and is not this branch's to assume.
579
- //
580
- // It IS assumable for the pset side and not here, and the
581
- // difference is where the two read their base from.
582
- // `getForEntity` merges the overlay over the base pset walk, so a
583
- // name the session touched but did not change still resolves to
584
- // source content and is regenerated.
585
- // `getQuantitiesForEntity` merges the overlay over
586
- // `quantityExtractor`, which is OPT-IN: it defaults to null, and
587
- // several in-tree callers wire the property extractor beside it
588
- // and not it (`cli/commands/mutate.ts`, `gym.ts`,
589
- // `generate-spaces.ts`, `export/demesh-session.ts`), as does any
590
- // external embedder of these two published packages. With no
591
- // extractor the base is empty and the overlay is the only source,
592
- // so a qset the overlay no longer holds resolves to nothing.
593
- //
594
- // Which makes this reachable through an UNDONE quantity-set
595
- // creation whose name COLLIDES with a source set:
596
- // `setQuantity(id, 'Qto_WallBaseQuantities', ...)` followed by the
597
- // `removeQuantityMutation` that mutationSlice runs on Ctrl+Z. The
598
- // append-only history still names the qset, so this branch
599
- // withholds the source lines; the overlay is empty again, so
600
- // nothing is regenerated. The export drops the source quantity set
601
- // — a real change to the file, and a data-loss bug of its own
602
- // (#2487) — and this call is what stops the count from calling it
603
- // nothing.
604
- modifications.recordWithheld(entityId, 'quantity-set');
605
- }
606
- }
607
- }
608
- }
609
- for (const [entityId] of modifiedAttributes) {
610
- // An overlay-CREATED entity carrying attribute edits is emitted once,
611
- // by the new-entities pass, and already counted in `newEntityCount`.
612
- // Counting it here too made the header claim two affected entities for
613
- // one created-then-renamed wall.
614
- if (isOverlayCreated(entityId))
615
- continue;
616
- // A source entity with no bytes never gets its line rewritten (the
617
- // source-iteration pass skips it), so an attribute edit against it
618
- // must not inflate the count either.
619
- if (!hasEmittableHostBytes(entityId))
620
- continue;
621
- // Under `deltaOnly` this only NOMINATES the host's ATTRIBUTE edits:
622
- // nothing writes an in-place attribute edit into a delta except the
623
- // type-object line rewrite, so the ledger drops it at settle time
624
- // unless that pass reports having carried it (#2462). That nomination
625
- // is deliberately made at INTENT: the per-kind warning exists to NAME
626
- // an edit the delta could not carry, and an undeliverable edit is
627
- // exactly the one that must still be named.
628
- //
629
- // A FULL export has no such warning, so an edit that resolved to
630
- // nothing has nothing to say and nothing to claim — it waits for the
631
- // rewrite instead. `setAttribute` to the value already in the slot, and
632
- // `setAttribute` naming a slot the class does not declare, both leave
633
- // the line byte-identical and used to count anyway (#2483).
634
- //
635
- // Recorded unconditionally. It used to be skipped for a host that also
636
- // had a pset or qset edit, because the count was per entity and the
637
- // other loop had already nominated it — which is exactly what let a
638
- // pset emission mark the rename delivered and suppress its warning. The
639
- // ledger de-duplicates the COUNT per entity now, so the two edits can
640
- // and must be nominated separately.
641
- inPlaceNominees.attribute.add(entityId);
642
- if (options.deltaOnly === true)
643
- modifications.nominate(entityId, 'attribute');
644
- }
645
- }
646
- // Process georeferencing mutations (only when applyMutations is enabled)
647
- const newGeorefLines = [];
648
- const warnings = [];
649
- if (options.applyMutations !== false && options.georefMutations) {
650
- const gm = options.georefMutations;
651
- // `effective.byType`, not the raw index: a source IfcProjectedCRS the
652
- // session tombstoned is still in `dataStore.entityIndex`, so the modify
653
- // branch below would queue attribute edits against an id the
654
- // source-iteration pass then skips — the replacement georeferencing
655
- // vanishes from the file with no error. `effective.byType` drops
656
- // tombstones and adds overlay-created records, which the new-entities
657
- // pass applies `modifiedAttributes` to, so both branches agree on which
658
- // georeferencing entities exist (#2048).
659
- const existingCrsIds = effective.byType.get('IFCPROJECTEDCRS');
660
- const existingMcIds = effective.byType.get('IFCMAPCONVERSION');
661
- // Modify existing IfcProjectedCRS
662
- if (gm.projectedCRS && existingCrsIds?.length) {
663
- const entityId = existingCrsIds[0];
664
- if (!modifiedAttributes.has(entityId)) {
665
- modifiedAttributes.set(entityId, new Map());
666
- }
667
- const attrMap = modifiedAttributes.get(entityId);
668
- const crs = gm.projectedCRS;
669
- let changed = false;
670
- if (crs.name !== undefined) {
671
- attrMap.set('Name', String(crs.name));
672
- changed = true;
673
- }
674
- if (crs.description !== undefined) {
675
- attrMap.set('Description', String(crs.description));
676
- changed = true;
677
- }
678
- if (crs.geodeticDatum !== undefined) {
679
- attrMap.set('GeodeticDatum', String(crs.geodeticDatum));
680
- changed = true;
681
- }
682
- if (crs.verticalDatum !== undefined) {
683
- attrMap.set('VerticalDatum', String(crs.verticalDatum));
684
- changed = true;
685
- }
686
- if (crs.mapProjection !== undefined) {
687
- attrMap.set('MapProjection', String(crs.mapProjection));
688
- changed = true;
689
- }
690
- if (crs.mapZone !== undefined) {
691
- attrMap.set('MapZone', String(crs.mapZone));
692
- changed = true;
693
- }
694
- if (crs.mapUnit !== undefined) {
695
- const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective);
696
- attrMap.set('MapUnit', `#${mapUnitRef}`);
697
- changed = true;
698
- }
699
- if (changed) {
700
- modifiedEntities.add(entityId);
701
- // Queued as attribute edits, which only the source-iteration pass
702
- // writes — so under `deltaOnly` this nominates and settle decides.
703
- // Recorded even when the host is already in `modifiedEntities`: that
704
- // guard existed to stop a second COUNT, which the ledger now handles
705
- // per entity, and suppressing the nomination would hide a dropped
706
- // georeferencing edit behind an unrelated edit to the same record.
707
- //
708
- // `changed` above is INTENT — a field was supplied, not a field that
709
- // differs from the one in the file. Writing `name: 'EPSG:2056'` over
710
- // an IfcProjectedCRS already named `EPSG:2056` leaves the line
711
- // byte-identical, so a full export waits for the rewrite exactly as
712
- // the plain attribute site does (#2483).
713
- if (hasEmittableHostBytes(entityId)) {
714
- inPlaceNominees.georeferencing.add(entityId);
715
- if (options.deltaOnly === true)
716
- modifications.nominate(entityId, 'georeferencing');
717
- }
718
- }
719
- }
720
- // Modify existing IfcMapConversion
721
- if (gm.mapConversion && existingMcIds?.length) {
722
- const entityId = existingMcIds[0];
723
- if (!modifiedAttributes.has(entityId)) {
724
- modifiedAttributes.set(entityId, new Map());
725
- }
726
- const attrMap = modifiedAttributes.get(entityId);
727
- const mc = gm.mapConversion;
728
- let changed = false;
729
- if (mc.eastings !== undefined) {
730
- attrMap.set('Eastings', String(mc.eastings));
731
- changed = true;
732
- }
733
- if (mc.northings !== undefined) {
734
- attrMap.set('Northings', String(mc.northings));
735
- changed = true;
736
- }
737
- if (mc.orthogonalHeight !== undefined) {
738
- attrMap.set('OrthogonalHeight', String(mc.orthogonalHeight));
739
- changed = true;
740
- }
741
- if (mc.xAxisAbscissa !== undefined) {
742
- attrMap.set('XAxisAbscissa', String(mc.xAxisAbscissa));
743
- changed = true;
744
- }
745
- if (mc.xAxisOrdinate !== undefined) {
746
- attrMap.set('XAxisOrdinate', String(mc.xAxisOrdinate));
747
- changed = true;
748
- }
749
- if (mc.scale !== undefined) {
750
- attrMap.set('Scale', String(mc.scale));
751
- changed = true;
752
- }
753
- if (changed) {
754
- modifiedEntities.add(entityId);
755
- // Same as the IfcProjectedCRS branch above, effect gate included.
756
- if (hasEmittableHostBytes(entityId)) {
757
- inPlaceNominees.georeferencing.add(entityId);
758
- if (options.deltaOnly === true)
759
- modifications.nominate(entityId, 'georeferencing');
760
- }
761
- }
762
- }
763
- // CREATE new georef entities when file has none
764
- if (gm.projectedCRS && !existingCrsIds?.length) {
765
- const crs = gm.projectedCRS;
766
- const crsId = this.nextExpressId++;
767
- // IfcProjectedCRS(Name, Description, GeodeticDatum, VerticalDatum, MapProjection, MapZone, MapUnit)
768
- const name = crs.name ? `'${escapeStepString(String(crs.name))}'` : '$';
769
- const desc = crs.description ? `'${escapeStepString(String(crs.description))}'` : '$';
770
- const datum = crs.geodeticDatum ? `'${escapeStepString(String(crs.geodeticDatum))}'` : '$';
771
- const vDatum = crs.verticalDatum ? `'${escapeStepString(String(crs.verticalDatum))}'` : '$';
772
- const proj = crs.mapProjection ? `'${escapeStepString(String(crs.mapProjection))}'` : '$';
773
- const zone = crs.mapZone ? `'${escapeStepString(String(crs.mapZone))}'` : '$';
774
- const mapUnitRef = crs.mapUnit
775
- ? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective)}`
776
- : '$';
777
- newGeorefLines.push(`#${crsId}=IFCPROJECTEDCRS(${name},${desc},${datum},${vDatum},${proj},${zone},${mapUnitRef});`);
778
- newEntityCount++;
779
- // Find IfcGeometricRepresentationContext as SourceCRS for MapConversion
780
- const contextId = this.findPreferredGeometricRepresentationContextId(effective);
781
- if (contextId) {
782
- const mc = gm.mapConversion || {};
783
- const mcId = this.nextExpressId++;
784
- const eastings = toStepReal(Number(mc.eastings) || 0);
785
- const northings = toStepReal(Number(mc.northings) || 0);
786
- const height = toStepReal(Number(mc.orthogonalHeight) || 0);
787
- const abscissa = mc.xAxisAbscissa !== undefined ? toStepReal(Number(mc.xAxisAbscissa)) : '$';
788
- const ordinate = mc.xAxisOrdinate !== undefined ? toStepReal(Number(mc.xAxisOrdinate)) : '$';
789
- const scale = mc.scale !== undefined ? toStepReal(Number(mc.scale)) : '$';
790
- // IfcMapConversion(SourceCRS, TargetCRS, Eastings, Northings, OrthogonalHeight, XAxisAbscissa, XAxisOrdinate, Scale)
791
- newGeorefLines.push(`#${mcId}=IFCMAPCONVERSION(#${contextId},#${crsId},${eastings},${northings},${height},${abscissa},${ordinate},${scale});`);
792
- newEntityCount++;
793
- }
794
- else {
795
- this.reportMapConversionRefused(warnings);
796
- }
797
- }
798
- else if (gm.mapConversion && !existingMcIds?.length && existingCrsIds?.length) {
799
- // CRS exists but no MapConversion — create just the conversion
800
- const contextId = this.findPreferredGeometricRepresentationContextId(effective);
801
- if (contextId) {
802
- const mc = gm.mapConversion;
803
- const mcId = this.nextExpressId++;
804
- const eastings = toStepReal(Number(mc.eastings) || 0);
805
- const northings = toStepReal(Number(mc.northings) || 0);
806
- const height = toStepReal(Number(mc.orthogonalHeight) || 0);
807
- const abscissa = mc.xAxisAbscissa !== undefined ? toStepReal(Number(mc.xAxisAbscissa)) : '$';
808
- const ordinate = mc.xAxisOrdinate !== undefined ? toStepReal(Number(mc.xAxisOrdinate)) : '$';
809
- const scale = mc.scale !== undefined ? toStepReal(Number(mc.scale)) : '$';
810
- newGeorefLines.push(`#${mcId}=IFCMAPCONVERSION(#${contextId},#${existingCrsIds[0]},${eastings},${northings},${height},${abscissa},${ordinate},${scale});`);
811
- newEntityCount++;
812
- }
813
- else {
814
- this.reportMapConversionRefused(warnings);
815
- }
816
- }
817
- else if (gm.mapConversion && !existingMcIds?.length && !existingCrsIds?.length) {
818
- // A map conversion was requested, but there is no IfcProjectedCRS to
819
- // reference as TargetCRS: none was requested (the first branch above
820
- // didn't fire) and none exists in the file. Both CREATE branches are
821
- // skipped, so nothing is attempted — report the refusal so the
822
- // caller isn't left with an empty stats.warnings and no hint (#2105).
823
- this.reportMapConversionRefusedNoCrs(warnings);
824
- }
825
- }
826
- // If delta only, only export modified entities. Overlay-created entities
827
- // also count — without this, `createEntity()`-only edits would silently
828
- // drop out of delta exports.
829
- const overlayNewEntityCount = (this.mutationView
830
- && options.applyMutations !== false
831
- && typeof this.mutationView.getNewEntities === 'function') ? this.mutationView.getNewEntities().length : 0;
832
- // Georef-only deltas (newGeorefLines populated but no entity changes) must
833
- // still produce a non-empty DATA section.
834
- if (options.deltaOnly
835
- && modifiedEntities.size === 0
836
- && overlayNewEntityCount === 0
837
- && newGeorefLines.length === 0) {
838
- const emptyContent = new TextEncoder().encode(buildHeader(0) + 'DATA;\nENDSEC;\nEND-ISO-10303-21;\n');
839
- return {
840
- content: emptyContent,
841
- stats: {
842
- entityCount: 0,
843
- newEntityCount: 0,
844
- modifiedEntityCount: 0,
845
- fileSize: emptyContent.byteLength,
846
- warnings,
847
- },
848
- };
849
- }
850
- /**
851
- * Will this id have a defining STEP line in the output at all?
852
- *
853
- * The predicate is #2030's, and it is the right one: the pset, quantity and
854
- * type-owned passes below are built from unfiltered mutation history, and
855
- * what each of them needs to know before emitting an
856
- * `IFCRELDEFINESBYPROPERTIES` is not "was this deleted" or "is this hidden"
857
- * but the general question those are two answers to. A relation naming an
858
- * expressId that never gets written is a dangling reference and an invalid
859
- * file, whichever route dropped the line.
860
- *
861
- * #2030 had to reach for four things to answer it — a tombstone probe, a
862
- * visibility set, a byte-range test on `completeIndex`, and a `getNewEntity`
863
- * fallback whose stated purpose was that `deleteEntity` FORGOT an
864
- * overlay-created entity instead of tombstoning it, so `isDeleted` could not
865
- * answer for one. That fallback was documented on main as a workaround for
866
- * exactly the model-level defect this branch fixes: `deleteEntity` now
867
- * tombstones as well as forgets, so the effective index answers existence
868
- * for source and overlay ids alike and the workaround collapses into it.
869
- *
870
- * The overlay branch does NOT disappear with it, and the distinction matters:
871
- * `isOverlayCreated` is still load-bearing here, because a live
872
- * overlay-created entity has no source bytes and would fail the byte-range
873
- * test that a source record passes. What the tombstone fix removed is the
874
- * need for that branch to double as a deletion detector.
875
- *
876
- * Deliberately unchanged from #2030 for source records under `deltaOnly` /
877
- * `exportPropertiesOnly`: the source-iteration pass is skipped wholesale in
878
- * those modes, yet a source entity still answers true here. A delta is a
879
- * patch against a file that already has the line, not a standalone model.
880
- */
881
- const willBeEmitted = (entityId) => {
882
- if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
883
- return false;
884
- // Undefined for a tombstoned id and for one neither the file nor the
885
- // session ever had — a stale mutation must not conjure a relation either.
886
- const ref = effective.get(entityId);
887
- if (!ref)
888
- return false;
889
- // An overlay-created record carries the placeholder byte range and is
890
- // written by the new-entities pass; a source record needs real bytes.
891
- if (effective.isOverlayCreated(entityId)) {
892
- // The overlay new-entities pass applies its OWN `isGeometryEntity`
893
- // filter unconditionally — deltaOnly or not (see the comment at that
894
- // loop, further below) — so this branch mirrors it without the
895
- // deltaOnly carve-out the source branch gets.
896
- return !isGeometryExcluded(entityId, ref.type);
897
- }
898
- // Same readability test as `hasEmittableHostBytes`, and for the reason
899
- // that predicate names: a ref this source cannot address is not a line
900
- // this export can write, so nothing may be generated naming it (#2491).
901
- if (!isReadableSourceRef(ref))
902
- return false;
903
- // Mirrors `hasEmittableHostBytes`: under `deltaOnly` the source-
904
- // iteration pass — and its geometry skip — never runs, so a source
905
- // entity's line is assumed to already exist in the file being patched.
906
- if (options.deltaOnly === true)
907
- return true;
908
- return !isGeometryExcluded(entityId, ref.type);
909
- };
910
- // A modified pset is replaced wholesale, which skips ALL of its member atoms.
911
- // But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
912
- // IsExternal IfcPropertySingleValue shared by dozens of psets), so skipping a
913
- // shared atom would orphan every OTHER pset that still references it, leaving
914
- // dangling refs and an invalid file. Keep any atom a surviving container needs.
915
- this.retainSharedAtoms(skipPropertySetIds, allowedEntityIds);
916
- // Export original entities from source buffer, SKIPPING modified property sets
917
- if (!options.deltaOnly && this.dataStore.source) {
918
- const source = this.dataStore.source;
919
- // Extract existing entities from source. The effective index has already
920
- // dropped everything the overlay tombstoned, so there is no separate
921
- // deleted check to forget here.
922
- for (const [expressId, entityRef] of effective) {
923
- // Skip overlay-only entities — emitted by the new-entities pass below.
924
- // A ref this source cannot address is skipped by the same test rather
925
- // than decoded: `decodeUtf8` clamps such a range and the empty string
926
- // it returns used to be pushed into the file as a blank line, leaving
927
- // every generated record that names the host dangling (#2491).
928
- if (!isReadableSourceRef(entityRef)) {
929
- continue;
930
- }
931
- // Skip entities outside the visible closure
932
- if (allowedEntityIds !== null && !allowedEntityIds.has(expressId)) {
933
- continue;
934
- }
935
- // Skip property sets/relationships that are being replaced
936
- if (skipPropertySetIds.has(expressId) || skipRelationshipIds.has(expressId)) {
937
- continue;
938
- }
939
- // Skip type entities whose HasPropertySets attribute will be rewritten
940
- if (rewrittenEntityIds.has(expressId)) {
941
- continue;
942
- }
943
- // Skip if we're only doing geometry or specific types
944
- const entityType = entityRef.type.toUpperCase();
945
- // Skip geometry if not included
946
- if (options.includeGeometry === false && this.isGeometryEntity(entityType)) {
947
- continue;
948
- }
949
- // Get original entity text — decodeRange handles SAB-backed
950
- // sources (Firefox/Chrome reject `TextDecoder.decode()` on a
951
- // SharedArrayBuffer-backed view; the parser deliberately keeps
952
- // `source` zero-copy SAB-backed for worker sharing).
953
- const entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
954
- // Retype, named attribute edits and positional edits, in that order.
955
- // Shared verbatim with the type-object `HasPropertySets` rewrite below,
956
- // which writes the line this pass would otherwise have written.
957
- const mutated = this.applySourceLineMutations(expressId, entityText, entityRef.type, modifiedAttributes.get(expressId), sourceSchema, overlayActive);
958
- let nextEntityText = mutated.text;
959
- // A hidden PRODUCT's own line is already out of the export via
960
- // `allowedEntityIds`, and a TOMBSTONED entity's via `effective` — this
961
- // is the relationship that NAMED either one. `IFCREL*` is an
962
- // unconditional root (see `getVisibleEntityIds`), so its bytes reach
963
- // here unfiltered even when one of the ids they name was just
964
- // excluded; left alone that ships a `#N` with no `#N=` line, whether
965
- // the exclusion came from `visibleOnly` or from a plain deletion
966
- // (#2398). Checked before the nomination below: a relationship this
967
- // withholds must not also be counted as a delivered modification.
968
- //
969
- // Classified by the EFFECTIVE type (`effective.effectiveType`), not
970
- // the authored `entityType` above: a retype can move a record across
971
- // the `IFCREL*` boundary in either direction (`applySourceLineMutations`
972
- // already rewrote `nextEntityText` to the new class), and this check
973
- // has to agree with what actually got written, the same way
974
- // `getVisibleEntityIds` already does for the visibility walk itself.
975
- const effectiveRelType = effective.effectiveType(expressId, entityRef.type).toUpperCase();
976
- if (mayNameExcludedRefs && effectiveRelType.startsWith('IFCREL')) {
977
- const filtered = filterHiddenRefsFromRelationshipLine(nextEntityText, isExcludedFromRelationshipRefs);
978
- if (filtered === null)
979
- continue;
980
- nextEntityText = filtered;
981
- }
982
- // A retype or a positional edit that CHANGED the line is what makes
983
- // this entity count; a named attribute edit was already nominated by
984
- // the collection pass. Both flags report effect, so retyping an entity
985
- // to the class it already is — or writing a slot the token it already
986
- // holds — no longer claims a modification over a line the export left
987
- // byte-identical. This pass is full-export-only (`deltaOnly` skips it
988
- // wholesale), so nomination IS emission here and the kinds only have to
989
- // be right for the entity count — which is per entity, hence unchanged.
990
- if (mutated.retyped || mutated.positional)
991
- modifiedEntities.add(expressId);
992
- if (mutated.retyped)
993
- modifications.nominate(expressId, 'retype');
994
- if (mutated.positional)
995
- modifications.nominate(expressId, 'positional');
996
- // The named-attribute kinds join them here rather than at their
997
- // collection sites, for the same reason and on the same signal (#2483).
998
- // This pass is full-export-only, so there is nothing to gate.
999
- nominateDeliveredInPlaceEdits(modifications, expressId, mutated, inPlaceNominees);
1000
- // Apply schema conversion if exporting to a different schema version
1001
- if (converting) {
1002
- const converted = convertStepLine(nextEntityText, sourceSchema, schema, options.guidRandom);
1003
- if (converted !== null) {
1004
- entities.push(converted);
1005
- }
1006
- // null means entity should be skipped (no valid representation in target schema)
1007
- }
1008
- else {
1009
- entities.push(nextEntityText);
1010
- }
1011
- }
1012
- }
1013
- // Generate new property entities for mutations (these REPLACE the skipped ones)
1014
- const generatedTypeOwnedPsetIds = new Map();
1015
- for (const { entityId, psets } of newPropertySets) {
1016
- // Nothing may be emitted FOR an entity that gets no defining line —
1017
- // see `willBeEmitted` (#1978, #2030, #2012).
1018
- if (!willBeEmitted(entityId))
1019
- continue;
1020
- const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
1021
- entities.push(...newEntities.lines);
1022
- newEntityCount += newEntities.count;
1023
- // Replacement content for this host actually landed, so a delta really
1024
- // does carry its PROPERTY-SET modification — and only that one (#2462).
1025
- if (newEntities.lines.length > 0)
1026
- modifications.recordEmitted(entityId, 'property-set');
1027
- generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
1028
- }
1029
- // Point every affected type object's HasPropertySets at the psets this
1030
- // export generated. One loop, because a type whose affected psets produced
1031
- // no replacement content (a deletion) needs exactly the same resolution
1032
- // with an empty replacement map.
1033
- for (const [entityId, typeOwnedPsetNames] of typeOwnedPsetNamesByEntity) {
1034
- // `entityId` here is a TYPE object rather than an element; `willBeEmitted`
1035
- // resolves either the same way (#2030).
1036
- if (!willBeEmitted(entityId))
1037
- continue;
1038
- const resolved = resolveTypeOwnedPsetIds(typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, generatedTypeOwnedPsetIds.get(entityId) ?? new Map(), (psetId) => this.getPropertySetName(psetId));
1039
- if (effective.isOverlayCreated(entityId)) {
1040
- // No source line to rewrite: the new-entities pass writes this record
1041
- // from its authored payload, so the list rides in as a slot override.
1042
- overlayTypeOwnedPsets.set(entityId, resolved.length > 0 ? resolved.map((id) => `#${id}`) : null);
1043
- continue;
1044
- }
1045
- // This line REPLACES the one the source-iteration pass would have
1046
- // written — `rewrittenEntityIds` makes that pass skip the entity — so it
1047
- // has to carry the entity's other edits too, and it has to apply them
1048
- // the way that pass does. It used to replace slot 5 and nothing else,
1049
- // which dropped the rename in `setAttribute(id,'Name',…)` +
1050
- // `addPropertySet(id,…)`, and then, once renames were special-cased
1051
- // here, still dropped retypes and positional edits — same line, same
1052
- // silence. So run the ONE pipeline both passes share and replace
1053
- // `HasPropertySets` on its output. Order matters: see
1054
- // {@link applySourceLineMutations}.
1055
- const record = effective.get(entityId);
1056
- let sourceLine = null;
1057
- let mutated = null;
1058
- // One narrowed block for both calls: `record` is in scope for the decode
1059
- // AND for the record type below, with no non-null assertion to keep true
1060
- // by hand. `byteOffset >= 0` is the same "are there real source bytes"
1061
- // test the source-iteration pass makes — an overlay-authored record
1062
- // carries `-1` there, and decoding from it would read another entity's
1063
- // bytes rather than fall through to the no-source-bytes branch.
1064
- // `isReadableSourceRef` folds in the `byteOffset >= 0 && byteLength > 0`
1065
- // test this used to make by hand, and adds the bound the invariant used
1066
- // to supply (#2491).
1067
- if (record && isReadableSourceRef(record)) {
1068
- sourceLine = decodeRange(this.dataStore.source, record.byteOffset, record.byteOffset + record.byteLength);
1069
- // The RECORD's class is the from-type: the bytes are still the source
1070
- // class, whatever `typeOf` now says the entity effectively is.
1071
- mutated = this.applySourceLineMutations(entityId, sourceLine, record.type, modifiedAttributes.get(entityId), sourceSchema, overlayActive);
1072
- }
1073
- if (mutated === null) {
1074
- // `willBeEmitted` already required real source bytes for a non-overlay
1075
- // record, so this is only reachable with no source buffer at all —
1076
- // in which case the source-iteration pass never ran either and there is
1077
- // nothing to lose. Say it anyway; the pset edit is still going nowhere.
1078
- warnings.push(typeOwnedPsetRewriteWarning(entityId, 'no-source-bytes'));
1079
- // The line above IS the report, so the ledger must not add a second,
1080
- // vaguer one blaming the delta format for a drop the format did not
1081
- // cause.
1082
- modifications.acknowledgeUndelivered(entityId, 'property-set');
1083
- continue;
1084
- }
1085
- const { line, repointed } = rewriteTypeOwnedPsetLine(mutated.text, resolved);
1086
- if (repointed) {
1087
- // A repoint that resolves to the list the line ALREADY names changes
1088
- // nothing, and it is reachable: deleting a pset name the type object
1089
- // does not own leaves every original id in place (it is "affected" but
1090
- // matches none of them) and generates no replacement, so slot 5 comes
1091
- // back byte-identical. Same rule as the fallback branch below — an
1092
- // unchanged line has no place in a delta, and claiming it delivered the
1093
- // edit would put a modification in the header over a line that carries
1094
- // none. A FULL export still emits it: `rewrittenEntityIds` made the
1095
- // source-iteration pass skip this entity, so withholding the line there
1096
- // would delete the record from the file (#2469).
1097
- const changed = line !== sourceLine;
1098
- if (options.deltaOnly !== true || changed) {
1099
- rewrittenEntityLines.set(entityId, line);
1100
- }
1101
- // A rewritten source line IS in the delta — the one in-place change a
1102
- // delta does carry today (#2462). The repoint itself delivers the
1103
- // property-set edit that put this host in the loop; the rest of the
1104
- // line delivers whichever in-place edits the pipeline applied to it.
1105
- if (changed) {
1106
- modifications.recordEmitted(entityId, 'property-set');
1107
- recordSourceLineDelivery(modifications, entityId, mutated);
1108
- // `rewrittenEntityIds` made the source-iteration pass skip this
1109
- // host, so this line is the ONLY place a full export can see its
1110
- // named-attribute edits land — per site, not per feature (#2483).
1111
- nominateDeliveredInPlaceEdits(modifications, entityId, mutated, inPlaceNominees);
1112
- }
1113
- continue;
1114
- }
1115
- // A malformed source line — too few arguments to have a slot 5, or not
1116
- // parseable as a STEP record at all. The entity must still come out:
1117
- // `rewrittenEntityIds` made the source-iteration pass skip it, so
1118
- // dropping the line here deletes the whole record from the file (#2469).
1119
- warnings.push(typeOwnedPsetRewriteWarning(entityId, 'unparseable-line'));
1120
- // Same as the `no-source-bytes` branch: the property-set edit is
1121
- // genuinely undelivered — the repoint is what would have delivered it and
1122
- // it did not happen — but this warning already says so, precisely, so the
1123
- // ledger stays quiet about that pair rather than duplicating it. (When
1124
- // the affected psets produced replacement content, the property-set pass
1125
- // above has already recorded the emission, and an emission outranks an
1126
- // acknowledgement.)
1127
- modifications.acknowledgeUndelivered(entityId, 'property-set');
1128
- // `line` is byte-for-byte what the source-iteration pass would have
1129
- // written, so emit it wherever that pass would have run. Under
1130
- // `deltaOnly` it does not run, and a line the mutation pipeline left
1131
- // identical to its source is not a change — it has no place in a delta.
1132
- const changed = line !== sourceLine;
1133
- if (options.deltaOnly !== true || changed) {
1134
- rewrittenEntityLines.set(entityId, line);
1135
- }
1136
- // The ledger stays honest about WHICH modification landed: the
1137
- // property-set edit that nominated this host is the thing that just
1138
- // failed, so only the entity's OTHER edits are in this line. Under the
1139
- // per-kind keying that comes out as `attribute/retype/positional:
1140
- // delivered, property-set: undelivered` — the host still counts once,
1141
- // because a real change of its did land.
1142
- if (changed) {
1143
- recordSourceLineDelivery(modifications, entityId, mutated);
1144
- // Same site rule as the repoint branch above: the failed repoint is
1145
- // what did not land, and the line still carries the host's OTHER edits.
1146
- nominateDeliveredInPlaceEdits(modifications, entityId, mutated, inPlaceNominees);
1147
- }
1148
- }
1149
- // Generate new quantity entities for mutations
1150
- for (const { entityId, qsets } of newQuantitySets) {
1151
- if (!willBeEmitted(entityId))
1152
- continue;
1153
- const newEntities = this.generateQuantitySetEntities(entityId, qsets, willBeEmitted, options.guidRandom);
1154
- entities.push(...newEntities.lines);
1155
- newEntityCount += newEntities.count;
1156
- if (newEntities.lines.length > 0)
1157
- modifications.recordEmitted(entityId, 'quantity-set');
1158
- }
1159
- for (const rewrittenLine of rewrittenEntityLines.values()) {
1160
- entities.push(rewrittenLine);
109
+ // What comes back is the object the phases below MUTATE -- notably
110
+ // `collectModifications`, which fills in the `allowedEntityIds` /
111
+ // `hiddenProductIds` that the pass's own predicates close over. Do not
112
+ // copy it; `step-pass-builder.test.ts` is what stops that.
113
+ const pass = buildExportPass({
114
+ dataStore: this.dataStore,
115
+ mutationView: this.mutationView,
116
+ isGeometryEntity,
117
+ options,
118
+ schema,
119
+ sourceSchema,
120
+ converting,
121
+ applyMutations,
122
+ excludeGeometry,
123
+ sourceHeader,
124
+ schemaToken,
125
+ });
126
+ // Visible-only closure, overlay mutation grouping, and georeferencing
127
+ // edits everything `pass` needs before the omission predicates below,
128
+ // and before the output passes that consume them, can run (#2475, the
129
+ // collection block).
130
+ collectModifications(pass, options, applyMutations, this.collectionContext());
131
+ // Which ids this export may still NAME, now that the collection phase has
132
+ // decided which ids it is WRITING (#2475). Must stay here, between those
133
+ // two -- see `step-omission-predicates.ts` for why the position is load
134
+ // bearing rather than stylistic.
135
+ const omission = evaluateOmissionPredicates(pass, options, applyMutations, excludeGeometry, this.mutationView);
136
+ // A deltaOnly export with nothing to say is already finished.
137
+ if (omission.kind === 'short-circuit')
138
+ return omission.result;
139
+ const { isOmittedFromOutput, mayNameOmittedRefs } = omission;
140
+ // Write every source-backed record this export keeps (#2475 step 2d),
141
+ // preceded inside that call by the shared-atom retention that decides
142
+ // which member atoms the skip sets may still drop.
143
+ writeSourceEntityLines(pass, options, mayNameOmittedRefs, isOmittedFromOutput, buildSourceIterationContext(this.dataStore, this.mutationView, () => this.propertySetContext()));
144
+ // Generated property/quantity sets and the type-object `HasPropertySets`
145
+ // rewrite that resolves against them, in that one order (#2475 steps 2b
146
+ // and 2c). `pass.rewrittenEntityLines`, this call's output, is flushed
147
+ // just below — after the quantity-set loop inside it, as it always was.
148
+ generatePropertyAndQuantitySetEntities(pass, options, this.propertySetContext());
149
+ for (const rewrittenLine of pass.rewrittenEntityLines.values()) {
150
+ pass.entities.push(rewrittenLine);
1161
151
  }
1162
152
  // Add new georeferencing entities (IfcProjectedCRS, IfcMapConversion)
1163
- for (const line of newGeorefLines) {
1164
- entities.push(line);
1165
- }
1166
- // Add overlay-created entities (store.addEntity / mutationView.createEntity).
1167
- // Apply the same filters as the source-iteration pass so newly-created
1168
- // beams/slabs don't smuggle their geometry helpers (IfcCartesianPoint,
1169
- // IfcExtrudedAreaSolid, etc.) past `includeGeometry:false` /
1170
- // `exportPropertiesOnly()` modes.
1171
- if (this.mutationView
1172
- && (options.applyMutations !== false)
1173
- && typeof this.mutationView.getNewEntities === 'function') {
1174
- const getTypeMut = typeof this.mutationView.getEntityTypeMutation === 'function'
1175
- ? this.mutationView.getEntityTypeMutation.bind(this.mutationView)
1176
- : null;
1177
- for (const entity of this.mutationView.getNewEntities()) {
1178
- // A retyped overlay entity keeps its AUTHORED type on `entity.type`
1179
- // (the overlay typeMutation is the source of truth for the effective
1180
- // class). Resolve the effective class, then re-lay-out the authored
1181
- // attributes from the authored layout up to it.
1182
- const typeMut = getTypeMut ? getTypeMut(entity.expressId) : null;
1183
- const effectiveType = typeMut?.newType ?? entity.type;
1184
- // STEP requires UPPERCASE entity type tokens; the upper-case happens
1185
- // here at the file-format boundary.
1186
- const upperType = effectiveType.toUpperCase();
1187
- if (options.includeGeometry === false && this.isGeometryEntity(upperType)) {
1188
- continue;
1189
- }
1190
- if (allowedEntityIds !== null && !allowedEntityIds.has(entity.expressId)) {
1191
- continue;
1192
- }
1193
- // Re-lay-out by name against the effective class (identity for
1194
- // compatible layouts). Runs whenever a retype intent exists — even a
1195
- // same-class retype, which carries a PredefinedType override
1196
- // (e.g. setEntityType(id, 'IfcColumn', 'PILASTER')).
1197
- let argsText;
1198
- if (typeMut) {
1199
- // Serialize against the AUTHORED layout (`entity.type`); retypeArgTokens
1200
- // then re-lays the tokens out by name up to the effective class.
1201
- const srcTokens = entity.attributes.map((value, i) => serializeAttributeSlot(entity.type, i, value, sourceSchema));
1202
- const { tokens } = retypeArgTokens(srcTokens, entity.type, effectiveType, typeMut.predefinedType ?? null, sourceSchema);
1203
- argsText = tokens.join(',');
1204
- }
1205
- else {
1206
- argsText = serializeEntityArgs(entity.type, entity.attributes, sourceSchema);
1207
- }
1208
- // Edits made AFTER the create live in the overlay, never in the
1209
- // authored payload (#2006). The source-iteration pass applies them to
1210
- // source records via applyAttributeMutations / applyPositionalMutations;
1211
- // an overlay-created entity has no source record, so without this it was
1212
- // written from its creation payload alone and every later
1213
- // `setAttribute` / `setPositionalAttribute` was silently dropped on
1214
- // save — data loss with no error and no warning.
1215
- //
1216
- // Order mirrors the source pass: retype (above) -> named attributes ->
1217
- // positional overrides, all resolved against the EFFECTIVE class.
1218
- const attributeOverrides = modifiedAttributes.get(entity.expressId) ?? null;
1219
- const queuedPositional = typeof this.mutationView.getPositionalMutationsForEntity === 'function'
1220
- ? this.mutationView.getPositionalMutationsForEntity(entity.expressId)
1221
- : null;
1222
- // A created TYPE object owns its psets through HasPropertySets, and the
1223
- // ids of the psets this export generated are only known now — so they
1224
- // arrive as one more slot override rather than through the overlay.
1225
- // `has`, not `??`, for the same reason `overlaySlotValue` gives: the
1226
- // stored value is deliberately null when the resolved list is empty.
1227
- const positionalOverrides = overlayTypeOwnedPsets.has(entity.expressId)
1228
- ? new Map(queuedPositional).set(HAS_PROPERTY_SETS_SLOT, overlayTypeOwnedPsets.get(entity.expressId) ?? null)
1229
- : queuedPositional;
1230
- if ((attributeOverrides && attributeOverrides.size > 0)
1231
- || (positionalOverrides && positionalOverrides.size > 0)) {
1232
- argsText = this.applyOverlayEntityOverrides(argsText, upperType, attributeOverrides, positionalOverrides, sourceSchema);
1233
- }
1234
- let line = `#${entity.expressId}=${upperType}(${argsText});`;
1235
- // Same gap as the source-iteration pass, for an overlay-authored
1236
- // relationship instead of a parsed one (#2398).
1237
- if (mayNameExcludedRefs && upperType.startsWith('IFCREL')) {
1238
- line = filterHiddenRefsFromRelationshipLine(line, isExcludedFromRelationshipRefs);
1239
- if (line === null)
1240
- continue;
1241
- }
1242
- if (converting) {
1243
- const converted = convertStepLine(line, sourceSchema, schema, options.guidRandom);
1244
- if (converted !== null) {
1245
- entities.push(converted);
1246
- newEntityCount++;
1247
- }
1248
- }
1249
- else {
1250
- entities.push(line);
1251
- newEntityCount++;
1252
- }
1253
- }
153
+ for (const line of pass.newGeorefLines) {
154
+ pass.entities.push(line);
1254
155
  }
1255
- // Settle the count against what the passes above actually wrote, and say
1256
- // out loud every KIND of edit a delta could not carry, per host. Silence
1257
- // was the other half of #2462: `deltaOnly` skips the source-iteration pass,
1258
- // so an in-place edit to a source entity is not in the file and never was
1259
- // the header merely used to claim otherwise.
1260
- const { modifiedEntityCount, warnings: deltaWarnings } = modifications.settle();
1261
- warnings.push(...deltaWarnings);
1262
- // Assemble final file as Uint8Array chunks to avoid V8 string length limit.
1263
- // The header is built last so its provenance item reflects the real count.
1264
- const header = buildHeader(newEntityCount + modifiedEntityCount);
1265
- const content = assembleStepBytes(header, entities);
1266
- return {
1267
- content,
1268
- stats: {
1269
- entityCount: entities.length,
1270
- newEntityCount,
1271
- modifiedEntityCount,
1272
- fileSize: content.byteLength,
1273
- warnings,
1274
- },
1275
- };
156
+ // Add overlay-created entities (store.addEntity / mutationView.createEntity),
157
+ // applying the same filters as the source-iteration pass (#2475 step 2e).
158
+ writeOverlayCreatedEntities(pass, options, excludeGeometry, applyMutations, mayNameOmittedRefs, isOmittedFromOutput, buildOverlayEntitiesContext(this.mutationView));
159
+ // Settle the ledger, build the header, assemble the finished bytes
160
+ // `step-header.ts` (#2475 header/assembly tail).
161
+ return assembleExportResult(pass);
1276
162
  }
1277
163
  /**
1278
164
  * Async export that yields to the event loop periodically, keeping the
@@ -1306,573 +192,6 @@ export class StepExporter {
1306
192
  deltaOnly: true,
1307
193
  });
1308
194
  }
1309
- /**
1310
- * Resolve a STEP reference to an existing IfcOwnerHistory for the
1311
- * IfcPropertySet / IfcRelDefinesByProperties / IfcElementQuantity entities we
1312
- * generate for `hostEntityId`'s mutations. OwnerHistory is optional in IFC4 but
1313
- * MANDATORY in IFC2X3 (IfcRoot.OwnerHistory), so emitting `$` yields an invalid
1314
- * IFC2X3 file that strict readers (e.g. BIM Vision) reject.
1315
- *
1316
- * Prefer the host element's OWN owner history, then any owner history that
1317
- * survives this export, then `$` only when none does.
1318
- *
1319
- * "Survives" is `willBeEmitted`, the same predicate that decides whether the
1320
- * host itself may have psets generated for it. A reference is a reference: it
1321
- * is no more acceptable to point an emitted `IfcPropertySet` at an owner
1322
- * history the session deleted than at a host it deleted. This used to consult
1323
- * only the `visibleOnly` closure, so an overlay-created OwnerHistory that was
1324
- * later deleted still got referenced — a dangling `#N`, reached through the
1325
- * one attribute the generators fill in for themselves.
1326
- */
1327
- resolveOwnerHistoryRef(hostEntityId, willBeEmitted) {
1328
- const own = this.getOwnerHistoryRefOfEntity(hostEntityId);
1329
- if (own !== null) {
1330
- const ownId = parseInt(own.slice(1), 10);
1331
- if (willBeEmitted(ownId))
1332
- return own;
1333
- }
1334
- if (this.ownerHistoryFallbackRef === undefined) {
1335
- // Source-only: the fallback is a best-effort "some owner history the file
1336
- // still has", and the host's OWN history above is the path that resolves
1337
- // an overlay-created one.
1338
- const ids = this.dataStore.entityIndex.byType.get('IFCOWNERHISTORY') ?? [];
1339
- const surviving = ids.find((id) => willBeEmitted(id));
1340
- this.ownerHistoryFallbackRef = surviving !== undefined ? `#${surviving}` : '$';
1341
- }
1342
- return this.ownerHistoryFallbackRef;
1343
- }
1344
- /**
1345
- * The overlay's answer for one positional slot of an overlay-created entity,
1346
- * falling back to the creation payload only when the overlay has NOTHING to
1347
- * say about that slot.
1348
- *
1349
- * **Ask `Map.has`, never `??`.** `setPositionalAttribute(id, slot, null)` is
1350
- * an explicit "clear this slot", and its value is `null`, so `??` reads the
1351
- * overlay's answer as an absence and reinstates the authored one. That is the
1352
- * same overlay-versus-buffer confusion this whole change is about, one
1353
- * attribute wide: an explicit null IS the overlay's answer, and the overlay is
1354
- * the authority. Cleared OwnerHistory came back as the authored reference, and
1355
- * a cleared `HasPropertySets` resurrected the list the user had removed.
1356
- */
1357
- overlaySlotValue(entityId, slot, authored) {
1358
- const overrides = this.mutationView?.getPositionalMutationsForEntity(entityId);
1359
- if (!overrides?.has(slot))
1360
- return authored;
1361
- const value = overrides.get(slot);
1362
- // `Map.get` widens to `| undefined`, which `has` has already ruled out. A
1363
- // slot explicitly set to nothing serializes as `$`, i.e. null.
1364
- return value === undefined ? null : value;
1365
- }
1366
- /**
1367
- * Read an element's own OwnerHistory reference (`#id`), or null when the
1368
- * element omits one (`$`) or cannot be parsed. OwnerHistory is the second
1369
- * attribute of every IfcRoot subtype, immediately after the GlobalId string.
1370
- */
1371
- getOwnerHistoryRefOfEntity(entityId) {
1372
- const cached = this.ownerHistoryByEntity.get(entityId);
1373
- if (cached !== undefined)
1374
- return cached;
1375
- let result = null;
1376
- // An overlay-created host has no source line to read, but it does have an
1377
- // authored OwnerHistory in slot 1 — reading only the buffer sent every
1378
- // generated pset on a created entity to the file's first owner history
1379
- // instead of the one the caller named (#2012).
1380
- const overlay = this.mutationView?.getNewEntity(entityId);
1381
- if (overlay) {
1382
- const refs = authoredEntityRefs(this.overlaySlotValue(entityId, OWNER_HISTORY_SLOT, overlay.attributes[OWNER_HISTORY_SLOT]));
1383
- result = refs.length > 0 ? `#${refs[0]}` : null;
1384
- this.ownerHistoryByEntity.set(entityId, result);
1385
- return result;
1386
- }
1387
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1388
- // Readability rather than presence, as everywhere else (#2491). A clamped
1389
- // decode would match nothing here, so this is tidiness rather than a bug —
1390
- // but the gates in this file agree on one predicate now.
1391
- if (entityRef && this.isReadableSourceRef(entityRef)) {
1392
- const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1393
- // #ID=IFCWALL('GlobalId',#owner,...): GlobalId is a quoted STEP string
1394
- // (doubled '' escapes); OwnerHistory is the ref/`$` right after it.
1395
- const match = entityText.match(/=\s*IFC\w+\s*\(\s*'(?:[^']|'')*'\s*,\s*#(\d+)/i);
1396
- if (match)
1397
- result = `#${match[1]}`;
1398
- }
1399
- this.ownerHistoryByEntity.set(entityId, result);
1400
- return result;
1401
- }
1402
- /**
1403
- * Generate STEP entities for property sets
1404
- */
1405
- generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNames, random) {
1406
- const lines = [];
1407
- let count = 0;
1408
- const generatedTypeOwnedPsetIds = new Map();
1409
- for (const pset of psets) {
1410
- const propertyIds = [];
1411
- // Create IfcPropertySingleValue for each property
1412
- for (const prop of pset.properties) {
1413
- const propId = this.nextExpressId++;
1414
- count++;
1415
- // `prop.dataType`, not `prop.type` alone: regenerating the set rewrites
1416
- // every property in it, and the shape-derived primitive would re-declare
1417
- // the ones nobody edited (`IFCTEXT` → `IFCLABEL`, `IFCLENGTHMEASURE` →
1418
- // `IFCREAL`). See `declared-property-type.ts` for when the source token
1419
- // is trusted (#2482).
1420
- const valueStr = serializeNominalValue(prop.value, prop.type, prop.dataType);
1421
- const unitId = prop.unit ? this.findUnitId(prop.unit, effective) : null;
1422
- const unitStr = unitId !== null ? ref(unitId) : null;
1423
- // #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
1424
- const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
1425
- lines.push(line);
1426
- propertyIds.push(propId);
1427
- }
1428
- // Create IfcPropertySet
1429
- const psetId = this.nextExpressId++;
1430
- count++;
1431
- const propRefs = propertyIds.map(id => `#${id}`).join(',');
1432
- const globalId = this.generateGlobalId(random);
1433
- // #ID=IFCPROPERTYSET('GlobalId',#ownerHistory,'Name',$,(#props));
1434
- const psetLine = `#${psetId}=IFCPROPERTYSET('${globalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},'${escapeStepString(pset.name)}',$,(${propRefs}));`;
1435
- lines.push(psetLine);
1436
- if (typeOwnedPsetNames?.has(pset.name)) {
1437
- generatedTypeOwnedPsetIds.set(pset.name, psetId);
1438
- }
1439
- else {
1440
- // Create IfcRelDefinesByProperties to link pset to entity
1441
- const relId = this.nextExpressId++;
1442
- count++;
1443
- const relGlobalId = this.generateGlobalId(random);
1444
- // #ID=IFCRELDEFINESBYPROPERTIES('GlobalId',#ownerHistory,$,$,(#entity),#pset);
1445
- const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},$,$,(#${entityId}),#${psetId});`;
1446
- lines.push(relLine);
1447
- }
1448
- }
1449
- return { lines, count, generatedTypeOwnedPsetIds };
1450
- }
1451
- /**
1452
- * Generate STEP entities for quantity sets (IfcElementQuantity)
1453
- */
1454
- generateQuantitySetEntities(entityId, qsets, willBeEmitted, random) {
1455
- const lines = [];
1456
- let count = 0;
1457
- for (const qset of qsets) {
1458
- const quantityIds = [];
1459
- for (const q of qset.quantities) {
1460
- const qId = this.nextExpressId++;
1461
- count++;
1462
- const ifcType = quantityTypeToIfcType(q.type);
1463
- // #ID=IFCQUANTITYLENGTH('Name',$,$,Value,$);
1464
- const val = toStepReal(q.value);
1465
- const line = `#${qId}=${ifcType}('${escapeStepString(q.name)}',$,$,${val},$);`;
1466
- lines.push(line);
1467
- quantityIds.push(qId);
1468
- }
1469
- // Create IfcElementQuantity
1470
- const qsetId = this.nextExpressId++;
1471
- count++;
1472
- const quantRefs = quantityIds.map(id => `#${id}`).join(',');
1473
- const globalId = this.generateGlobalId(random);
1474
- // #ID=IFCELEMENTQUANTITY('GlobalId',#ownerHistory,'Name',$,$,(#quants));
1475
- const qsetLine = `#${qsetId}=IFCELEMENTQUANTITY('${globalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},'${escapeStepString(qset.name)}',$,$,(${quantRefs}));`;
1476
- lines.push(qsetLine);
1477
- // Create IfcRelDefinesByProperties to link qset to entity
1478
- const relId = this.nextExpressId++;
1479
- count++;
1480
- const relGlobalId = this.generateGlobalId(random);
1481
- const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${this.resolveOwnerHistoryRef(entityId, willBeEmitted)},$,$,(#${entityId}),#${qsetId});`;
1482
- lines.push(relLine);
1483
- }
1484
- return { lines, count };
1485
- }
1486
- /**
1487
- * THE mutation pipeline for a line read out of the source buffer: retype,
1488
- * then named attribute edits, then positional edits.
1489
- *
1490
- * **One implementation, two call sites**, and that is the whole point. Two
1491
- * passes can write the defining line of a source entity — the
1492
- * source-iteration pass, and the type-object `HasPropertySets` rewrite that
1493
- * REPLACES it (`rewrittenEntityIds` makes the source pass skip those ids).
1494
- * The rewrite used to do its own thing (replace slot 5, nothing else), so
1495
- * every other edit to a type object with a type-owned pset edit was dropped
1496
- * in silence: first the renames (#2462 follow-up), and after those were
1497
- * special-cased here, still the retypes and the positional edits. Whatever
1498
- * the source pass applies, the rewrite has to apply too, or the next edit
1499
- * kind added to one site goes missing at the other.
1500
- *
1501
- * The order is load-bearing:
1502
- *
1503
- * - the retype runs FIRST so named attribute edits resolve against the
1504
- * TARGET class's attribute names, and so positional slots are indexed
1505
- * into the retyped argument list;
1506
- * - the `HasPropertySets` replacement (rewrite path only) runs LAST, on
1507
- * the text this returns. Run it first and a positional edit to slot 5 —
1508
- * or a retype's argument-list rebuild — overwrites the resolved pset
1509
- * list with the stale one, which is the same silent drop one slot over.
1510
- *
1511
- * The expressId is unchanged by all of this, so geometry / placement /
1512
- * representation and every IfcRel* reference (keyed by #id) carry over.
1513
- *
1514
- * All three flags report EFFECT, not intent — each is the answer to "did this
1515
- * operation change the line", measured across that operation alone. The count
1516
- * and the ledger are claims about the FILE, so an edit that resolves to the
1517
- * text already there has delivered nothing and must not be reported: retyping
1518
- * an entity to the class it already is, or writing a positional slot the token
1519
- * it already holds, used to count as a modification and reach the ledger as a
1520
- * landed edit, over a byte-identical line. Discarded edits read the same way:
1521
- * `applyAttributeMutations` drops a name its class has no slot for and
1522
- * `retypeStepLine` returns an unparseable line untouched, and neither is a
1523
- * modification of anything.
1524
- *
1525
- * `retyped` / `positional` matter most in a FULL export, which is where the
1526
- * two are nominated (their edits have no earlier nomination site); named
1527
- * attribute edits are nominated by the collection pass and `attributed` only
1528
- * settles their delivery.
1529
- */
1530
- applySourceLineMutations(expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive) {
1531
- let text = entityText;
1532
- let workingType = recordType.toUpperCase();
1533
- const typeMutation = overlayActive && typeof this.mutationView.getEntityTypeMutation === 'function'
1534
- ? this.mutationView.getEntityTypeMutation(expressId)
1535
- : null;
1536
- let retyped = false;
1537
- if (typeMutation) {
1538
- const beforeRetype = text;
1539
- text = retypeStepLine(text, recordType, typeMutation.newType, typeMutation.predefinedType ?? null, sourceSchema);
1540
- retyped = text !== beforeRetype;
1541
- // Set even for a no-op retype: the entity IS the target class from here
1542
- // on, so the named and positional edits below must resolve against it.
1543
- workingType = typeMutation.newType.toUpperCase();
1544
- }
1545
- // `applyAttributeMutations` returns its input UNCHANGED when it wrote
1546
- // nothing — no slot resolved for any of the names, or the line does not
1547
- // parse — so comparing is what tells the ledger whether a named attribute
1548
- // edit was really carried, rather than merely attempted.
1549
- let attributed = false;
1550
- if (attributeMutations && attributeMutations.size > 0) {
1551
- const beforeAttributes = text;
1552
- text = this.applyAttributeMutations(text, workingType, attributeMutations);
1553
- attributed = text !== beforeAttributes;
1554
- }
1555
- const positionals = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
1556
- ? this.mutationView.getPositionalMutationsForEntity(expressId)
1557
- : null;
1558
- let positional = false;
1559
- if (positionals && positionals.size > 0) {
1560
- const beforePositionals = text;
1561
- text = this.applyPositionalMutations(text, positionals, workingType, sourceSchema);
1562
- positional = text !== beforePositionals;
1563
- }
1564
- return { text, attributed, retyped, positional };
1565
- }
1566
- /**
1567
- * Rewrite root IFC attributes directly on the original STEP entity line.
1568
- */
1569
- applyAttributeMutations(entityText, entityType, attributeMutations) {
1570
- const openParen = entityText.indexOf('(');
1571
- const closeParen = entityText.lastIndexOf(');');
1572
- if (openParen < 0 || closeParen < openParen) {
1573
- return entityText;
1574
- }
1575
- // Cross-schema, not the IFC4 pin: an IFC4X3-only class (IfcCourse, IfcRoad,
1576
- // IfcBridge, …) resolves no slots under the pin, so every named edit on one
1577
- // was silently discarded here too. Identical for the 755 pinned classes
1578
- // that declare attributes — `attribute-slot-types.test.ts` measures that —
1579
- // so no IFC4 export changes; this only stops dropping edits it used to drop.
1580
- const attrNames = getAttributeNamesAcrossSchemas(entityType);
1581
- if (attrNames.length === 0) {
1582
- return entityText;
1583
- }
1584
- const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
1585
- // A source line NEVER pads (unlike the overlay-created path): a short
1586
- // argument list here means the file speaks a different schema, and growing
1587
- // a record we did not author would corrupt it.
1588
- let changed = false;
1589
- for (const [attrName, value] of attributeMutations) {
1590
- const index = attrNames.indexOf(attrName);
1591
- if (index < 0 || index >= args.length)
1592
- continue;
1593
- // The source path shares every `$`-slot hole with the overlay-created
1594
- // path, because a source record has plenty of `$` slots of its own. Both
1595
- // go through the one helper below.
1596
- args[index] = this.serializeNamedAttribute(entityType, index, value, args[index]);
1597
- changed = true;
1598
- }
1599
- if (!changed) {
1600
- return entityText;
1601
- }
1602
- return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
1603
- }
1604
- /**
1605
- * Serialize one NAMED attribute override into its slot — the single point
1606
- * both the source-buffer rewrite and the overlay-created rewrite go through.
1607
- *
1608
- * `serializeAttributeValue` decides the STEP form by reading the token being
1609
- * replaced, which is sound only while that token carries type information. A
1610
- * `$` slot carries none, and both paths have plenty: a source record's
1611
- * optional attributes are `$`, and overlay-created records pad missing slots
1612
- * with `$`. So the declared type decides first, and inference is the fallback
1613
- * for slots the schema does not classify (references, SELECTs, numerics),
1614
- * where reading the old token is exactly the right heuristic.
1615
- */
1616
- serializeNamedAttribute(entityType, index, value, currentToken) {
1617
- if (getEnumTypedSlots(entityType).has(index))
1618
- return serializeEnumToken(value);
1619
- if (getStringTypedSlots(entityType).has(index))
1620
- return serializeStringSlot(value);
1621
- return serializeAttributeValue(value, currentToken);
1622
- }
1623
- /**
1624
- * Apply overlay attribute + positional overrides to an OVERLAY-CREATED
1625
- * entity's argument list (#2006).
1626
- *
1627
- * Distinct from {@link applyAttributeMutations} / {@link applyPositionalMutations},
1628
- * which rewrite a line read out of the source buffer. Here the whole line is
1629
- * ours: it was serialized moments ago from the creation payload, so the
1630
- * argument list is the authoring payload's, not the file's. That difference
1631
- * is why this PADS — `entity_create` takes whatever positional list the
1632
- * caller passes, so a wall authored with three arguments still has a real
1633
- * `Tag` slot at index 7, and dropping the edit because the payload was short
1634
- * would be the very data loss this fixes. The source-buffer path must not
1635
- * pad: there a short line means a different schema, and growing a record we
1636
- * did not author would corrupt it.
1637
- *
1638
- * Named and positional overrides resolve to a slot index up front and share
1639
- * ONE padding rule. Two padding rules on one record is how the next bug
1640
- * starts, and the argument for padding — the class is fixed at creation time,
1641
- * so a short payload is partial authoring — never depended on which of the
1642
- * two APIs queued the edit.
1643
- */
1644
- applyOverlayEntityOverrides(argsText, entityType, attributeOverrides, positionalOverrides, schemaVersion) {
1645
- const args = argsText.length > 0 ? splitTopLevelArgs(argsText) : [];
1646
- const attrNames = getAttributeNamesAcrossSchemas(entityType);
1647
- const named = [];
1648
- for (const [attrName, value] of attributeOverrides ?? []) {
1649
- const index = attrNames.indexOf(attrName);
1650
- if (index >= 0)
1651
- named.push([index, value]);
1652
- }
1653
- // Grow to the class's FULL declared arity as soon as any override names a
1654
- // declared slot the creation payload never reached. Growing only as far as
1655
- // the edited slot would emit eight arguments for an IfcWall that declares
1656
- // nine: this parser tolerates the truncated record, a schema-validating
1657
- // consumer rejects the file.
1658
- //
1659
- // An index PAST the declared layout is not a slot at all, so it cannot
1660
- // justify growing the record and stays dropped — as does any override on a
1661
- // class neither schema source knows, where there is no arity to grow to.
1662
- let needsPad = named.some(([index]) => index >= args.length);
1663
- if (!needsPad && positionalOverrides) {
1664
- for (const [index] of positionalOverrides) {
1665
- if (index >= args.length && index < attrNames.length) {
1666
- needsPad = true;
1667
- break;
1668
- }
1669
- }
1670
- }
1671
- if (needsPad) {
1672
- while (args.length < attrNames.length)
1673
- args.push('$');
1674
- }
1675
- // Every `named` index is < attrNames.length by construction, and padding
1676
- // has taken args.length to at least that, so each one lands.
1677
- for (const [index, value] of named) {
1678
- args[index] = this.serializeNamedAttribute(entityType, index, value, args[index]);
1679
- }
1680
- if (positionalOverrides && positionalOverrides.size > 0) {
1681
- const realSlots = getRealTypedSlots(entityType, schemaVersion);
1682
- for (const [index, value] of positionalOverrides) {
1683
- if (index < 0 || index >= args.length)
1684
- continue;
1685
- args[index] = this.serializePositionalOverride(entityType, index, value, args[index], realSlots, schemaVersion);
1686
- }
1687
- }
1688
- return args.join(',');
1689
- }
1690
- /**
1691
- * Apply positional STEP argument overrides to an entity line.
1692
- * Used for non-IfcRoot edits (e.g. profile dimensions) where attributes
1693
- * have no symbolic names. Indexes that fall outside the existing arg list
1694
- * are silently ignored.
1695
- */
1696
- applyPositionalMutations(entityText, positionals, entityType, schemaVersion) {
1697
- const openParen = entityText.indexOf('(');
1698
- const closeParen = entityText.lastIndexOf(');');
1699
- if (openParen < 0 || closeParen < openParen)
1700
- return entityText;
1701
- const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
1702
- const realSlots = getRealTypedSlots(entityType, schemaVersion);
1703
- let changed = false;
1704
- for (const [index, value] of positionals) {
1705
- if (index < 0 || index >= args.length)
1706
- continue;
1707
- args[index] = this.serializePositionalOverride(entityType, index, value, args[index], realSlots, schemaVersion);
1708
- changed = true;
1709
- }
1710
- if (!changed)
1711
- return entityText;
1712
- return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
1713
- }
1714
- /**
1715
- * Serialize one positional override, composing the schema-aware passes:
1716
- * explicit `{ real }`/`{ typed }` marker → SELECT auto-qualification
1717
- * (`IFCBOOLEAN(.T.)`) → REAL forcing. For REAL forcing the current source
1718
- * token is a secondary signal: replacing a value that was already a REAL
1719
- * (`0.4`, `1.5E-7`) keeps it REAL even for entities the XSD index doesn't
1720
- * cover, so a whole-number edit can't silently downgrade the slot.
1721
- */
1722
- serializePositionalOverride(entityType, index, value, currentToken, realSlots, schemaVersion) {
1723
- if (isTypedMarker(value))
1724
- return serializeStepValue(value);
1725
- const qualified = serializeQualifiedSelectSlot(entityType, index, value);
1726
- if (qualified !== null)
1727
- return qualified;
1728
- const forceReal = realSlots.has(index) || tokenIsRealLiteral(currentToken);
1729
- return serializeStepValue(value, forceReal);
1730
- }
1731
- resolveMapUnitReference(unitName, newGeorefLines, effective) {
1732
- const normalized = this.normalizeMapUnitName(unitName);
1733
- const existing = this.findLengthUnitReference(normalized, effective);
1734
- if (existing !== null) {
1735
- return existing;
1736
- }
1737
- if (normalized === 'METRE') {
1738
- const unitId = this.nextExpressId++;
1739
- newGeorefLines.push(`#${unitId}=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);`);
1740
- return unitId;
1741
- }
1742
- if (normalized === 'FOOT' || normalized === 'US SURVEY FOOT') {
1743
- const dimId = this.nextExpressId++;
1744
- const siUnitId = this.nextExpressId++;
1745
- const measureId = this.nextExpressId++;
1746
- const convUnitId = this.nextExpressId++;
1747
- const factor = normalized === 'US SURVEY FOOT' ? 1200 / 3937 : 0.3048;
1748
- const name = normalized === 'US SURVEY FOOT' ? 'US SURVEY FOOT' : 'FOOT';
1749
- newGeorefLines.push(`#${dimId}=IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0);`);
1750
- newGeorefLines.push(`#${siUnitId}=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);`);
1751
- newGeorefLines.push(`#${measureId}=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(${toStepReal(factor)}),#${siUnitId});`);
1752
- newGeorefLines.push(`#${convUnitId}=IFCCONVERSIONBASEDUNIT(#${dimId},.LENGTHUNIT.,'${name}',#${measureId});`);
1753
- return convUnitId;
1754
- }
1755
- const fallbackId = this.nextExpressId++;
1756
- newGeorefLines.push(`#${fallbackId}=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);`);
1757
- return fallbackId;
1758
- }
1759
- normalizeMapUnitName(unitName) {
1760
- const normalized = unitName.trim().toUpperCase().replace(/\s+/g, ' ');
1761
- if (normalized.includes('US SURVEY FOOT'))
1762
- return 'US SURVEY FOOT';
1763
- if (normalized.includes('METER') || normalized.includes('METRE'))
1764
- return 'METRE';
1765
- if (normalized.includes('FOOT') || normalized.includes('FEET'))
1766
- return 'FOOT';
1767
- return normalized;
1768
- }
1769
- /**
1770
- * `effective` filters the candidates the same way the georef reads above do:
1771
- * returning a tombstoned unit id hands the caller a `#id` for a line the
1772
- * export never writes. Returning null instead makes `resolveMapUnitReference`
1773
- * synthesise a fresh unit, which is the outcome a deleted unit deserves.
1774
- */
1775
- findLengthUnitReference(preferredUnitName, effective) {
1776
- if (!this.entityExtractor)
1777
- return null;
1778
- // Only source records carry the bytes `extractEntity` reads, so an
1779
- // overlay-created project is skipped rather than shadowing the file's own.
1780
- const projectId = (effective.byType.get('IFCPROJECT') ?? []).find((id) => this.dataStore.entityIndex.byId.has(id));
1781
- const projectRef = projectId !== undefined ? this.dataStore.entityIndex.byId.get(projectId) : undefined;
1782
- const project = projectRef ? this.entityExtractor.extractEntity(projectRef) : null;
1783
- const unitAssignmentId = project?.attributes?.[8];
1784
- if (typeof unitAssignmentId !== 'number' || effective.isDeleted(unitAssignmentId))
1785
- return null;
1786
- const unitAssignmentRef = this.dataStore.entityIndex.byId.get(unitAssignmentId);
1787
- const unitAssignment = unitAssignmentRef ? this.entityExtractor.extractEntity(unitAssignmentRef) : null;
1788
- const units = unitAssignment?.attributes?.[0];
1789
- if (!Array.isArray(units))
1790
- return null;
1791
- for (const unitId of units) {
1792
- if (typeof unitId !== 'number' || effective.isDeleted(unitId))
1793
- continue;
1794
- const unitRef = this.dataStore.entityIndex.byId.get(unitId);
1795
- const unit = unitRef ? this.entityExtractor.extractEntity(unitRef) : null;
1796
- if (!unit)
1797
- continue;
1798
- const typeName = unit.type.toUpperCase();
1799
- const attrs = unit.attributes ?? [];
1800
- const unitType = typeof attrs[1] === 'string' ? attrs[1].replace(/\./g, '').toUpperCase() : '';
1801
- if (unitType !== 'LENGTHUNIT')
1802
- continue;
1803
- if (typeName === 'IFCSIUNIT') {
1804
- const prefix = typeof attrs[2] === 'string' ? attrs[2].replace(/\./g, '').toUpperCase() : '';
1805
- const name = typeof attrs[3] === 'string' ? attrs[3].replace(/\./g, '').toUpperCase() : '';
1806
- const combined = prefix ? `${prefix}${name}` : name;
1807
- if (preferredUnitName === 'METRE' && (combined === 'METRE' || combined === 'METER')) {
1808
- return unitId;
1809
- }
1810
- }
1811
- if (typeName === 'IFCCONVERSIONBASEDUNIT') {
1812
- const name = typeof attrs[2] === 'string' ? this.normalizeMapUnitName(attrs[2]) : '';
1813
- if (name === preferredUnitName) {
1814
- return unitId;
1815
- }
1816
- }
1817
- }
1818
- return null;
1819
- }
1820
- /**
1821
- * Record that a requested IfcMapConversion could not be written. Emitting it
1822
- * anyway would leave `SourceCRS` pointing at nothing, so the refusal is the
1823
- * correct output — but the file alone cannot express it, which is why it goes
1824
- * back to the caller in `stats.warnings` as well as to the console (#2067).
1825
- */
1826
- reportMapConversionRefused(warnings) {
1827
- warnings.push(MAP_CONVERSION_WITHOUT_CONTEXT_WARNING);
1828
- console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}`);
1829
- }
1830
- /**
1831
- * Record that a requested IfcMapConversion could not be written because
1832
- * there is no IfcProjectedCRS to attach it to — a different refusal from
1833
- * {@link reportMapConversionRefused}: "no CRS to attach it to" rather than
1834
- * "no context to reference" (#2105).
1835
- */
1836
- reportMapConversionRefusedNoCrs(warnings) {
1837
- warnings.push(MAP_CONVERSION_WITHOUT_CRS_WARNING);
1838
- console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CRS_WARNING}`);
1839
- }
1840
- /**
1841
- * `effective` again: the id returned here becomes the new IfcMapConversion's
1842
- * SourceCRS, so a tombstoned context would leave the created line pointing at
1843
- * a record the export skips — a dangling reference and an invalid file.
1844
- */
1845
- findPreferredGeometricRepresentationContextId(effective) {
1846
- if (!this.entityExtractor)
1847
- return null;
1848
- const contextIds = (effective.byType.get('IFCGEOMETRICREPRESENTATIONCONTEXT') ?? [])
1849
- .filter((id) => this.dataStore.entityIndex.byId.has(id));
1850
- let first3dContext = null;
1851
- for (const contextId of contextIds) {
1852
- const contextRef = this.dataStore.entityIndex.byId.get(contextId);
1853
- const context = contextRef ? this.entityExtractor.extractEntity(contextRef) : null;
1854
- if (!context)
1855
- continue;
1856
- const attrs = context.attributes ?? [];
1857
- const contextType = typeof attrs[1] === 'string' ? attrs[1].trim().toUpperCase() : '';
1858
- const dimension = typeof attrs[2] === 'number' ? attrs[2] : null;
1859
- if (dimension === 3 && first3dContext === null) {
1860
- first3dContext = contextId;
1861
- }
1862
- if (contextType === 'MODEL' && dimension === 3) {
1863
- return contextId;
1864
- }
1865
- }
1866
- return first3dContext ?? contextIds[0] ?? null;
1867
- }
1868
- /**
1869
- * Generate a new IFC GlobalId (22 character base64). `random` is the
1870
- * export's optional seeded source (`StepExportOptions.guidRandom`);
1871
- * undefined keeps the default random path.
1872
- */
1873
- generateGlobalId(random) {
1874
- return generateIfcGuid(random);
1875
- }
1876
195
  /**
1877
196
  * Find the maximum EXPRESS ID in the data store
1878
197
  */
@@ -1882,273 +201,66 @@ export class StepExporter {
1882
201
  return getMaxExpressId(getCompleteEntityIndex(this.dataStore));
1883
202
  }
1884
203
  /**
1885
- * Find a unit entity ID by name (simplified - returns null for now)
1886
- */
1887
- findUnitId(unitName, effective) {
1888
- return this.findLengthUnitReference(this.normalizeMapUnitName(unitName), effective);
1889
- }
1890
- /**
1891
- * Check if an entity type is a geometry-related type
1892
- */
1893
- isGeometryEntity(type) {
1894
- const geometryTypes = new Set([
1895
- 'IFCCARTESIANPOINT',
1896
- 'IFCDIRECTION',
1897
- 'IFCAXIS2PLACEMENT2D',
1898
- 'IFCAXIS2PLACEMENT3D',
1899
- 'IFCLOCALPLACEMENT',
1900
- 'IFCSHAPEREPRESENTATION',
1901
- 'IFCPRODUCTDEFINITIONSHAPE',
1902
- 'IFCGEOMETRICREPRESENTATIONCONTEXT',
1903
- 'IFCGEOMETRICREPRESENTATIONSUBCONTEXT',
1904
- 'IFCEXTRUDEDAREASOLID',
1905
- 'IFCFACETEDBREP',
1906
- 'IFCPOLYLOOP',
1907
- 'IFCFACE',
1908
- 'IFCFACEOUTERBOUND',
1909
- 'IFCCLOSEDSHELL',
1910
- 'IFCRECTANGLEPROFILEDEF',
1911
- 'IFCCIRCLEPROFILEDEF',
1912
- 'IFCARBITRARYCLOSEDPROFILEDEF',
1913
- 'IFCPOLYLINE',
1914
- 'IFCTRIMMEDCURVE',
1915
- 'IFCBSPLINECURVE',
1916
- 'IFCBSPLINESURFACE',
1917
- 'IFCTRIANGULATEDFACESET',
1918
- 'IFCPOLYGONALFACE',
1919
- 'IFCINDEXEDPOLYGONALFACE',
1920
- 'IFCPOLYGONALFACESET',
1921
- 'IFCSTYLEDITEM',
1922
- 'IFCPRESENTATIONSTYLEASSIGNMENT',
1923
- 'IFCSURFACESTYLE',
1924
- 'IFCSURFACESTYLERENDERING',
1925
- 'IFCCOLOURRGB',
1926
- ]);
1927
- return geometryTypes.has(type);
1928
- }
1929
- /**
1930
- * Build a one-shot reverse index of every IfcRelDefinesByProperties in
1931
- * the source: for each related entity, list the rels and property/quantity
1932
- * sets that reference it. Used by the export pre-pass so the per-entity
1933
- * "find owning rels" step is O(K) rather than O(N) per modified entity.
204
+ * The exporter state `step-georeferencing.ts` cannot read off the pass.
1934
205
  *
1935
- * `relatedByRel` is the same walk read the other way round, so the deleted-host
1936
- * sweep costs nothing extra.
1937
- */
1938
- buildRelDefinesByPropertiesIndex() {
1939
- const byEntity = new Map();
1940
- const relatedByRel = new Map();
1941
- for (const [relId, relRef] of this.dataStore.entityIndex.byId) {
1942
- if (relRef.type.toUpperCase() !== 'IFCRELDEFINESBYPROPERTIES')
1943
- continue;
1944
- const psetId = this.getRelatedPropertySet(relId);
1945
- if (!psetId)
1946
- continue;
1947
- const related = this.getRelatedEntities(relId);
1948
- relatedByRel.set(relId, related);
1949
- for (const entityId of related) {
1950
- let bucket = byEntity.get(entityId);
1951
- if (!bucket) {
1952
- bucket = [];
1953
- byEntity.set(entityId, bucket);
1954
- }
1955
- bucket.push({ relId, psetId });
1956
- }
1957
- }
1958
- return { byEntity, relatedByRel };
206
+ * `allocateExpressId` hands out ids from THIS exporter's `nextExpressId`,
207
+ * which the property-set and quantity-set generators in
208
+ * `step-property-sets.ts` increment at six further sites through the same
209
+ * callback — hoisting the counter onto the pass would change what it
210
+ * computes, not merely where it is named, so both phases get a callback
211
+ * instead (#2475 step 2a).
212
+ */
213
+ georefContext(deltaOnly) {
214
+ return {
215
+ dataStore: this.dataStore,
216
+ entityExtractor: this.entityExtractor,
217
+ allocateExpressId: () => this.nextExpressId++,
218
+ deltaOnly,
219
+ };
1959
220
  }
1960
221
  /**
1961
- * The source STEP text of an entity's line, or `null` when there are no bytes
1962
- * to read.
1963
- *
1964
- * The byte check is on the RANGE, not on `dataStore.source`. `source` is a
1965
- * MANDATORY accessor — `EMPTY_SOURCE_BYTES` is how "this model kept no bytes"
1966
- * is spelled (server-parsed, synthetic, GLB and point-cloud stores all have
1967
- * one) — so the `!this.dataStore.source` guard the five readers below used to
1968
- * carry never fired. It was also redundant: a zero-length range decodes to
1969
- * `''`, which fails every regex those readers run, so they already answered
1970
- * "nothing" for a sourceless store. Scoping the check to the range is what
1971
- * makes the guard live without changing a single answer, the same shape and
1972
- * for the same reason as `reference-collector.ts` (#2339).
1973
- *
1974
- * An OVERLAY-created entity never reaches here: every caller resolves its id
1975
- * through `dataStore.entityIndex.byId`, which holds source records only
1976
- * (`effective-index.ts` synthesises the overlay refs on its own side and
1977
- * writes nothing back), so an overlay id is already `undefined` at the
1978
- * lookup and is served by the callers' documented "not a source record"
1979
- * path. That is why an early return is safe HERE and is NOT safe at the
1980
- * visible-only closure in `export` — see the comment there.
222
+ * The state `step-property-sets.ts` cannot read off the pass (#2475 2b/2c).
1981
223
  *
1982
- * ## Why `isReadableSourceRef` and not `byteLength === 0`
1983
- *
1984
- * An out-of-range ref does NOT degrade to "no match" here. `decodeUtf8`
1985
- * clamps the range it cannot address, and the clamped window is still a
1986
- * window over real file bytes so these readers answer from somebody
1987
- * ELSE's record. `source-ref-bounds.ts` (#2491) carries the measured
1988
- * account of both shapes and of why "a clamped, empty decode already yields
1989
- * no match" is false; it is not restated here, because an argument kept in
1990
- * two files is an argument that has to stay true in two files.
1991
- *
1992
- * The consequence specific to THIS site is that the wrong answer is acted
1993
- * on. `retainSharedAtoms` un-skips every id `getPropertyIdsInSet` returns,
1994
- * so a member list read out of the wrong record un-skips the wrong atoms;
1995
- * and the source-iteration pass already refuses to emit a record whose ref
1996
- * fails `isReadableSourceRef` (see the `continue` in `export`), so before
1997
- * this gate these readers were making decisions on behalf of a container
1998
- * that the same export had decided not to write. Gating them on the same
1999
- * predicate is what makes the two passes agree.
2000
- *
2001
- * The degradation is the one the exporter already handles: a record with no
2002
- * emittable bytes, generating nothing and named by nothing. It costs one
2003
- * answer that used to be right by luck — an overrunning ref on the file's
2004
- * LAST record clamps back to exactly that record's text — but that record
2005
- * is one the emission pass drops anyway, so keeping the answer only kept
2006
- * the disagreement.
2007
- */
2008
- entityLineText(entityId) {
2009
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
2010
- if (!entityRef || !this.isReadableSourceRef(entityRef))
2011
- return null;
2012
- return decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
2013
- }
2014
- /**
2015
- * Get entity IDs related by IfcRelDefinesByProperties (the related objects)
2016
- */
2017
- getRelatedEntities(relId) {
2018
- const entityText = this.entityLineText(relId);
2019
- if (entityText === null)
2020
- return [];
2021
- // Parse IfcRelDefinesByProperties: #ID=IFCRELDEFINESBYPROPERTIES('guid',$,$,$,(#objects),#pset);
2022
- // The 5th argument (index 4) is the list of related objects
2023
- const match = entityText.match(/\(([^)]+)\)\s*,\s*#(\d+)\s*\)\s*;/);
2024
- if (!match)
2025
- return [];
2026
- const objectsList = match[1];
2027
- const refs = [];
2028
- const refMatches = objectsList.matchAll(/#(\d+)/g);
2029
- for (const m of refMatches) {
2030
- refs.push(parseInt(m[1], 10));
2031
- }
2032
- return refs;
2033
- }
2034
- /**
2035
- * Get the property set ID from IfcRelDefinesByProperties
2036
- */
2037
- getRelatedPropertySet(relId) {
2038
- const entityText = this.entityLineText(relId);
2039
- if (entityText === null)
2040
- return null;
2041
- // Last #ID before the closing );
2042
- const match = entityText.match(/,\s*#(\d+)\s*\)\s*;$/);
2043
- if (!match)
2044
- return null;
2045
- return parseInt(match[1], 10);
2046
- }
2047
- /**
2048
- * Get the name of a property set by parsing the entity
2049
- */
2050
- getPropertySetName(psetId) {
2051
- const entityText = this.entityLineText(psetId);
2052
- if (entityText === null)
2053
- return null;
2054
- // Parse: IFCPROPERTYSET('guid',$,'Name',$,...) - Name is 3rd argument
2055
- const match = entityText.match(/IFCPROPERTYSET\s*\([^,]*,[^,]*,'([^']*)'/i);
2056
- if (!match)
2057
- return null;
2058
- return match[1];
2059
- }
2060
- /**
2061
- * Get the name of an element quantity set by parsing the entity
2062
- */
2063
- getElementQuantityName(entityId) {
2064
- const entityText = this.entityLineText(entityId);
2065
- if (entityText === null)
2066
- return null;
2067
- // Parse: IFCELEMENTQUANTITY('guid',$,'Name',...) - Name is 3rd argument
2068
- const match = entityText.match(/IFCELEMENTQUANTITY\s*\([^,]*,[^,]*,'([^']*)'/i);
2069
- if (!match)
2070
- return null;
2071
- return match[1];
2072
- }
2073
- /**
2074
- * Get IDs of properties in a property set
2075
- */
2076
- /**
2077
- * Un-skip property/quantity atoms that a surviving (non-skipped, and — under
2078
- * visible-only export — still-included) IfcPropertySet / IfcElementQuantity
2079
- * still references.
224
+ * `allocateExpressId` is the same callback `georefContext` hands out, over
225
+ * the same counter, so the ids the two phases allocate stay in one sequence.
226
+ * `ownerHistory` is passed by reference the object is this exporter's, and
227
+ * `export()` resets it. `isReadableSourceRef` is the instance predicate, not
228
+ * `pass.isReadableSourceRef`, because two consumers of that module
229
+ * (`buildRelDefinesByPropertiesIndex`, and `retainSharedAtoms` in
230
+ * `step-source-iteration.ts`) run with no pass in hand; both readers are
231
+ * built over the same source.
2080
232
  *
2081
- * When a property is edited, the modified pset is replaced and its member atoms
2082
- * are added to `skipIds` wholesale. Because exporters deduplicate shared
2083
- * Pset_*Common atoms (e.g. a single IsExternal / IsLoadBearing value referenced
2084
- * by many psets), that wholesale skip can drop an atom another pset still needs.
2085
- * This pass restores any such atom: the edited pset still emits its replacement
2086
- * with the new value, while the shared atom stays for the psets that keep their
2087
- * original value.
233
+ * Rebuilt per call, as `georefContext` is: every call site runs once per
234
+ * export bar `retainSharedAtoms`, which hoists it out of its loop — hence
235
+ * `buildSourceIterationContext` (`step-export-contexts.ts`) takes this as a
236
+ * thunk rather than a value.
2088
237
  */
2089
- retainSharedAtoms(skipIds, allowedEntityIds) {
2090
- if (skipIds.size === 0)
2091
- return;
2092
- const byType = this.dataStore.entityIndex.byType;
2093
- const containerIds = [
2094
- ...(byType.get('IFCPROPERTYSET') ?? []),
2095
- ...(byType.get('IFCELEMENTQUANTITY') ?? []),
2096
- ];
2097
- for (const containerId of containerIds) {
2098
- // Skipped containers are being dropped/replaced — their atoms may go.
2099
- if (skipIds.has(containerId))
2100
- continue;
2101
- // Under visible-only export a container outside the closure is not emitted,
2102
- // so it cannot keep an atom alive.
2103
- if (allowedEntityIds !== null && !allowedEntityIds.has(containerId))
2104
- continue;
2105
- for (const atomId of this.getPropertyIdsInSet(containerId)) {
2106
- skipIds.delete(atomId);
2107
- }
2108
- }
2109
- }
2110
- getPropertyIdsInSet(psetId) {
2111
- const entityText = this.entityLineText(psetId);
2112
- if (entityText === null)
2113
- return [];
2114
- // Parse: IFCPROPERTYSET(...,(#prop1,#prop2,...)); - Last argument is properties list
2115
- const match = entityText.match(/\(\s*(#[^)]+)\s*\)\s*\)\s*;$/);
2116
- if (!match)
2117
- return [];
2118
- const propsList = match[1];
2119
- const ids = [];
2120
- const refMatches = propsList.matchAll(/#(\d+)/g);
2121
- for (const m of refMatches) {
2122
- ids.push(parseInt(m[1], 10));
2123
- }
2124
- return ids;
238
+ propertySetContext() {
239
+ return {
240
+ dataStore: this.dataStore,
241
+ entityExtractor: this.entityExtractor,
242
+ mutationView: this.mutationView,
243
+ isReadableSourceRef: this.isReadableSourceRef,
244
+ allocateExpressId: () => this.nextExpressId++,
245
+ ownerHistory: this.ownerHistory,
246
+ applySourceLineMutations: (expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected) => applySourceLineMutations(this.mutationView, expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected),
247
+ };
2125
248
  }
2126
249
  /**
2127
- * The full HasPropertySets id list of a type object, from whichever authority
2128
- * owns the record.
2129
- *
2130
- * Slot 5 is `HasPropertySets` on every `IfcTypeObject` subtype. For a source
2131
- * record the list is parsed out of the file; for an overlay-created type it is
2132
- * read off the authored payload, where a reference is the documented `'#42'`
2133
- * string form. Reading only the source made every pset on a created
2134
- * `IfcWallType` look unowned, which is how it ended up on an occurrence
2135
- * relation instead (#2012).
250
+ * The state `step-collection.ts` cannot read off the pass (#2475, the
251
+ * collection block). `propertySetContext` and `georefContext` are handed
252
+ * over as the SAME thunks {@link propertySetContext} and
253
+ * {@link georefContext} already are this phase calls the first twice per
254
+ * export and the second once, and nothing here should change how often
255
+ * either is rebuilt.
2136
256
  */
2137
- getTypeOwnedHasPropertySetIds(entityId, effective) {
2138
- if (effective.isOverlayCreated(entityId)) {
2139
- const authored = this.mutationView?.getNewEntity(entityId)?.attributes?.[HAS_PROPERTY_SETS_SLOT];
2140
- return authoredEntityRefs(this.overlaySlotValue(entityId, HAS_PROPERTY_SETS_SLOT, authored));
2141
- }
2142
- if (!this.entityExtractor)
2143
- return [];
2144
- const entityRef = this.dataStore.entityIndex.byId.get(entityId);
2145
- if (!entityRef)
2146
- return [];
2147
- const entity = this.entityExtractor.extractEntity(entityRef);
2148
- const hasPropertySets = entity?.attributes?.[HAS_PROPERTY_SETS_SLOT];
2149
- if (!Array.isArray(hasPropertySets))
2150
- return [];
2151
- return hasPropertySets.filter((value) => typeof value === 'number');
257
+ collectionContext() {
258
+ return {
259
+ dataStore: this.dataStore,
260
+ mutationView: this.mutationView,
261
+ propertySetContext: () => this.propertySetContext(),
262
+ georefContext: (deltaOnly) => this.georefContext(deltaOnly),
263
+ };
2152
264
  }
2153
265
  }
2154
266
  /**