@ifc-lite/export 2.9.4 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) 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/declared-property-type.d.ts +19 -8
  6. package/dist/declared-property-type.d.ts.map +1 -1
  7. package/dist/declared-property-type.js +31 -9
  8. package/dist/declared-property-type.js.map +1 -1
  9. package/dist/demesh-session.d.ts.map +1 -1
  10. package/dist/demesh-session.js +2 -1
  11. package/dist/demesh-session.js.map +1 -1
  12. package/dist/effective-index.js +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +9 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/lod1-generator.d.ts +0 -2
  18. package/dist/lod1-generator.d.ts.map +1 -1
  19. package/dist/lod1-generator.js +1 -1
  20. package/dist/lod1-generator.js.map +1 -1
  21. package/dist/merged-exporter.d.ts +19 -0
  22. package/dist/merged-exporter.d.ts.map +1 -1
  23. package/dist/merged-exporter.js +23 -41
  24. package/dist/merged-exporter.js.map +1 -1
  25. package/dist/parquet-exporter.d.ts +1 -3
  26. package/dist/parquet-exporter.d.ts.map +1 -1
  27. package/dist/parquet-exporter.js +1 -0
  28. package/dist/parquet-exporter.js.map +1 -1
  29. package/dist/property-value-serialization.d.ts +54 -0
  30. package/dist/property-value-serialization.d.ts.map +1 -0
  31. package/dist/property-value-serialization.js +134 -0
  32. package/dist/property-value-serialization.js.map +1 -0
  33. package/dist/reference-collector.d.ts +20 -0
  34. package/dist/reference-collector.d.ts.map +1 -1
  35. package/dist/reference-collector.js +46 -113
  36. package/dist/reference-collector.js.map +1 -1
  37. package/dist/schema-converter.d.ts.map +1 -1
  38. package/dist/schema-converter.js +46 -96
  39. package/dist/schema-converter.js.map +1 -1
  40. package/dist/source-ref-bounds.d.ts +3 -3
  41. package/dist/source-ref-bounds.js +3 -3
  42. package/dist/step-attribute-mutations.d.ts +49 -0
  43. package/dist/step-attribute-mutations.d.ts.map +1 -0
  44. package/dist/step-attribute-mutations.js +172 -0
  45. package/dist/step-attribute-mutations.js.map +1 -0
  46. package/dist/step-attribute-serializers.d.ts +41 -0
  47. package/dist/step-attribute-serializers.d.ts.map +1 -0
  48. package/dist/step-attribute-serializers.js +74 -0
  49. package/dist/step-attribute-serializers.js.map +1 -0
  50. package/dist/step-collection.d.ts +55 -0
  51. package/dist/step-collection.d.ts.map +1 -0
  52. package/dist/step-collection.js +126 -0
  53. package/dist/step-collection.js.map +1 -0
  54. package/dist/step-export-contexts.d.ts +61 -0
  55. package/dist/step-export-contexts.d.ts.map +1 -0
  56. package/dist/step-export-contexts.js +50 -0
  57. package/dist/step-export-contexts.js.map +1 -0
  58. package/dist/step-export-types.d.ts +262 -0
  59. package/dist/step-export-types.d.ts.map +1 -0
  60. package/dist/step-export-types.js +22 -0
  61. package/dist/step-export-types.js.map +1 -0
  62. package/dist/step-exporter.d.ts +23 -354
  63. package/dist/step-exporter.d.ts.map +1 -1
  64. package/dist/step-exporter.js +76 -1260
  65. package/dist/step-exporter.js.map +1 -1
  66. package/dist/step-geometry-types.d.ts +3 -0
  67. package/dist/step-geometry-types.d.ts.map +1 -0
  68. package/dist/step-geometry-types.js +73 -0
  69. package/dist/step-geometry-types.js.map +1 -0
  70. package/dist/step-georeferencing.d.ts +0 -10
  71. package/dist/step-georeferencing.d.ts.map +1 -1
  72. package/dist/step-georeferencing.js +46 -94
  73. package/dist/step-georeferencing.js.map +1 -1
  74. package/dist/step-header.d.ts +53 -0
  75. package/dist/step-header.d.ts.map +1 -0
  76. package/dist/step-header.js +63 -0
  77. package/dist/step-header.js.map +1 -0
  78. package/dist/step-map-unit.d.ts +90 -0
  79. package/dist/step-map-unit.d.ts.map +1 -0
  80. package/dist/step-map-unit.js +277 -0
  81. package/dist/step-map-unit.js.map +1 -0
  82. package/dist/step-omission-predicates.d.ts +51 -0
  83. package/dist/step-omission-predicates.d.ts.map +1 -0
  84. package/dist/step-omission-predicates.js +268 -0
  85. package/dist/step-omission-predicates.js.map +1 -0
  86. package/dist/step-overlay-attribute-overrides.d.ts +32 -0
  87. package/dist/step-overlay-attribute-overrides.d.ts.map +1 -0
  88. package/dist/step-overlay-attribute-overrides.js +83 -0
  89. package/dist/step-overlay-attribute-overrides.js.map +1 -0
  90. package/dist/step-overlay-entities.d.ts +64 -0
  91. package/dist/step-overlay-entities.d.ts.map +1 -0
  92. package/dist/step-overlay-entities.js +119 -0
  93. package/dist/step-overlay-entities.js.map +1 -0
  94. package/dist/step-pass-builder.d.ts +45 -0
  95. package/dist/step-pass-builder.d.ts.map +1 -0
  96. package/dist/step-pass-builder.js +249 -0
  97. package/dist/step-pass-builder.js.map +1 -0
  98. package/dist/step-property-set-collection.d.ts +24 -0
  99. package/dist/step-property-set-collection.d.ts.map +1 -0
  100. package/dist/step-property-set-collection.js +286 -0
  101. package/dist/step-property-set-collection.js.map +1 -0
  102. package/dist/step-property-set-generators.d.ts +20 -0
  103. package/dist/step-property-set-generators.d.ts.map +1 -0
  104. package/dist/step-property-set-generators.js +98 -0
  105. package/dist/step-property-set-generators.js.map +1 -0
  106. package/dist/step-property-set-index.d.ts +25 -0
  107. package/dist/step-property-set-index.d.ts.map +1 -0
  108. package/dist/step-property-set-index.js +69 -0
  109. package/dist/step-property-set-index.js.map +1 -0
  110. package/dist/step-property-set-readers.d.ts +158 -0
  111. package/dist/step-property-set-readers.d.ts.map +1 -0
  112. package/dist/step-property-set-readers.js +251 -0
  113. package/dist/step-property-set-readers.js.map +1 -0
  114. package/dist/step-property-sets.d.ts +2 -184
  115. package/dist/step-property-sets.d.ts.map +1 -1
  116. package/dist/step-property-sets.js +22 -674
  117. package/dist/step-property-sets.js.map +1 -1
  118. package/dist/step-serialization.d.ts +33 -52
  119. package/dist/step-serialization.d.ts.map +1 -1
  120. package/dist/step-serialization.js +69 -131
  121. package/dist/step-serialization.js.map +1 -1
  122. package/dist/step-source-iteration.d.ts +97 -0
  123. package/dist/step-source-iteration.d.ts.map +1 -0
  124. package/dist/step-source-iteration.js +178 -0
  125. package/dist/step-source-iteration.js.map +1 -0
  126. package/package.json +7 -7
@@ -1,42 +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 { EntityExtractor, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, } from '@ifc-lite/parser';
5
- import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities, filterHiddenRefsFromRelationshipLine, } from './reference-collector.js';
6
- import { convertStepLine, needsConversion } from './schema-converter.js';
7
- import { retypeStepLine, retypeArgTokens } from './retype.js';
4
+ import { EntityExtractor, parseSourceHeader } from '@ifc-lite/parser';
5
+ import { needsConversion } from './schema-converter.js';
8
6
  import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
9
- import { createModificationLedger, } from './delta-modification-ledger.js';
10
- import { nominateDeliveredInPlaceEdits } from './in-place-nomination.js';
11
- import { createSourceRefReader, decodeRange } from './source-ref-bounds.js';
12
- import { buildRelDefinesByPropertiesIndex, collectPropertyAndQuantitySetMutations, generatePropertyAndQuantitySetEntities, getPropertyIdsInSet, } from './step-property-sets.js';
13
- import { applyGeoreferencingMutations } from './step-georeferencing.js';
14
- import { getEffectiveEntityIndex } from './effective-index.js';
15
- import { HAS_PROPERTY_SETS_SLOT } from './type-owned-psets.js';
16
- import { toStepReal, serializeAttributeValue, serializeStepValue, tokenIsRealLiteral, } from './step-serialization.js';
17
- import { splitTopLevelArgs } from './step-argument-parser.js';
18
- import { assembleStepBytes } from './step-file-assembly.js';
19
- import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
20
- import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
21
- import { serializeQualifiedSelectSlot } from './select-qualification.js';
22
- /**
23
- * Message for a relationship this export DROPPED rather than rewrote.
24
- *
25
- * `filterHiddenRefsFromRelationshipLine` removes an omitted `#N` from a
26
- * SET/LIST attribute, but a single-valued attribute has no STEP spelling for
27
- * "omitted" and an empty SET is not the same statement as the original — so in
28
- * both of those cases it withholds the whole line and the relationship simply
29
- * is not in the output. Withholding beats shipping a dangling `#N`, but it is
30
- * not free: every OTHER entity that relationship named loses the association.
31
- * A visible element can therefore come out of a plain full export with one
32
- * fewer pset than it went in with, and before this warning existed nothing in
33
- * the result said so (adversarial review of #2668).
34
- *
35
- * Deliberately reports the relationship rather than the omitted target: the
36
- * target's own omission is already the caller's own doing in every reason but
37
- * the unreadable-ref one, whereas the lost association is the surprise.
38
- */
39
- const relationshipWithheldWarning = (expressId, type) => `Relationship #${expressId} (${type}) was withheld from the export: it names at least one entity that has no line in this export, in a slot with no spelling for an omitted reference (a single-valued attribute, or a set whose every member is omitted). Anything else that relationship associated is no longer associated in the output.`;
7
+ import { createSourceRefReader } from './source-ref-bounds.js';
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';
40
18
  /**
41
19
  * IFC STEP file exporter
42
20
  */
@@ -48,7 +26,7 @@ export class StepExporter {
48
26
  /**
49
27
  * The owner-history memos the property-set and quantity-set generators read.
50
28
  *
51
- * Owned here and handed to `step-property-sets.ts` BY REFERENCE rather than
29
+ * Owned here and handed to `step-property-set-readers.ts` BY REFERENCE rather than
52
30
  * stored on its context: the reset below is an `export()`-level statement,
53
31
  * and the comment there is where "per export, not per exporter" is argued.
54
32
  * Moving the storage into a per-export context would make that reset
@@ -124,738 +102,45 @@ export class StepExporter {
124
102
  ? sourceHeader.schemaIdentifiers[0]
125
103
  : schema;
126
104
  // The one construction site for the state this export shares across its
127
- // seven phases. See `ExportPass` above for what belongs here, what
128
- // deliberately does not, and why every predicate reads `pass` rather than
129
- // a value captured at construction time.
130
- const pass = {
131
- entities: [],
132
- newEntityCount: 0,
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.
108
+ //
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,
133
118
  schema,
134
119
  sourceSchema,
135
120
  converting,
121
+ applyMutations,
122
+ excludeGeometry,
136
123
  sourceHeader,
137
124
  schemaToken,
138
- overlayActive: !!this.mutationView && applyMutations,
139
- // Built once entity counts are known, so the provenance item can report the
140
- // actual modification count. See the two call sites (empty delta + final).
141
- buildHeader: (modifications) => {
142
- // FILE_DESCRIPTION items: an explicit option wins, else the source items
143
- // verbatim, else the generic default.
144
- const description = options.description !== undefined
145
- ? [options.description]
146
- : sourceHeader && sourceHeader.description.length > 0
147
- ? [...sourceHeader.description]
148
- : ['Exported from ifc-lite'];
149
- // Honest provenance: never claim untouched source output. Append (never
150
- // overwrite) one item when ifc-lite actually changed the file.
151
- if (modifications > 0) {
152
- description.push(`Re-exported by ifc-lite, ${modifications} modification${modifications === 1 ? '' : 's'}`);
153
- }
154
- return generateHeader({
155
- schema: schemaToken,
156
- description,
157
- implementationLevel: sourceHeader?.implementationLevel,
158
- author: options.author ?? sourceHeader?.author,
159
- organization: options.organization ?? sourceHeader?.organization,
160
- // preprocessor_version = the tool that WROTE this file (ifc-lite);
161
- // originating_system keeps the source authoring tool so it isn't erased.
162
- preprocessorVersion: options.application ?? 'ifc-lite',
163
- originatingSystem: sourceHeader?.originatingSystem,
164
- authorization: sourceHeader?.authorization,
165
- application: options.application ?? 'ifc-lite',
166
- filename: options.filename ?? 'export.ifc',
167
- timeStamp: options.timeStamp,
168
- });
169
- },
170
- // The one authority for exists / class / deleted, overlay first and source
171
- // buffer second. Every pass below asks this instead of `this.dataStore`,
172
- // which answers only for the file as parsed (#2012).
173
- effective: getEffectiveEntityIndex(this.dataStore, this.mutationView, applyMutations),
174
- // Does this id belong to an entity the OVERLAY created (`createEntity` /
175
- // `store.addEntity`) rather than to a record in the source buffer? Such an
176
- // entity has no source bytes, so the source-iteration pass below never sees
177
- // it and the new-entities pass at the end owns its line entirely (#2006).
178
- isOverlayCreated: (entityId) => pass.effective.isOverlayCreated(entityId),
179
- // Does this record describe a line this export can actually READ out of the
180
- // source? One predicate for every byte-range gate below, so they cannot
181
- // disagree — see `source-ref-bounds.ts` for the corrupt file the weaker
182
- // "is there a source / does the ref claim bytes" pair let through (#2491).
183
- isReadableSourceRef: createSourceRefReader(this.dataStore.source),
184
- // Build visible-only closure if requested. Classification, the closure walk
185
- // and the style pass all run over the EFFECTIVE index: an overlay-created
186
- // product becomes a root by the same type rules as a parsed one, the walk
187
- // follows its authored references into the geometry it alone owns, and a
188
- // tombstoned entity is simply not there. Run over the source buffer, a
189
- // created wall could never be a root and nothing referenced it, so
190
- // `visibleOnly` wrote a file without it and said nothing (#2012).
191
- //
192
- // Computed here, ahead of the modification-count passes below, because
193
- // `hasEmittableHostBytes` needs it: a source-backed host EXCLUDED by
194
- // `visibleOnly` never gets its line written by the source-iteration pass
195
- // either, so counting it as "modified" would make the header claim a
196
- // change the DATA section does not contain (CodeRabbit finding on #2414).
197
- allowedEntityIds: null,
198
- // Populated alongside `allowedEntityIds` below. `getVisibleEntityIds`
199
- // excludes a hidden PRODUCT's own line from the closure, but `IFCREL*` is
200
- // an unconditional root a few lines down and its bytes are copied verbatim
201
- // by the source-iteration pass — nothing there filters a `#N` the closure
202
- // just excluded out of the relationship's own attribute list. Kept because
203
- // the closure walk's `isRefExcludedDuringClosureWalk` needs a notion of
204
- // "hidden" that does not read `allowedEntityIds` — the set that walk is
205
- // producing (#2398). The two OUTPUT passes no longer read this directly:
206
- // they filter on `isOmittedFromOutput`, which subsumes it via
207
- // `allowedEntityIds`.
208
- hiddenProductIds: null,
209
- // A relationship can name an excluded entity two ways that have nothing
210
- // to do with each other: a `visibleOnly` hidden PRODUCT (`hiddenProductIds`,
211
- // below), and a TOMBSTONED one — `editor.removeEntity` on a related object
212
- // named by a relationship the deletion sweep below does not reach (that
213
- // sweep only withholds an `IfcRelDefinesByProperties` when EVERY related
214
- // object is gone, and only for that one relationship class). Left alone, a
215
- // relationship still naming a deleted entity ships the identical `#N` with
216
- // no `#N=` line, on a path with no `visibleOnly` involved at all (#2398).
217
- // `effective.isDeleted` answers for every id, not just a precomputed set,
218
- // so this predicate covers both sources without a second exclusion set.
219
- //
220
- // Declared here, ahead of the closure walk below, and passed into
221
- // `collectReferencedEntityIds` as its `isRefExcluded` — rather than the
222
- // walk inventing its own `!entityIndex.has` proxy for "deleted" that could
223
- // disagree on an id that never existed in the file at all
224
- // (maintainer-found regression on #2637: such an id blocked the bridge but
225
- // did not stop the relationship's own line from shipping, dropping a
226
- // VISIBLE sibling's pset while adding a fresh dangling ref). A closure over
227
- // `pass.hiddenProductIds`, not a value snapshot — correct because nothing
228
- // reads it before the closure walk assigns it just below.
229
- //
230
- // ## Why this is NOT the predicate the OUTPUT-line filter uses
231
- //
232
- // The name says walk, and only walk. The two passes that write a
233
- // relationship's line ask `isOmittedFromOutput` (further below, derived
234
- // from `pass.willBeEmitted`), which is strictly stronger — it also answers
235
- // for the closure, for an unreadable source ref and for a geometry
236
- // exclusion.
237
- //
238
- // This one CANNOT be `willBeEmitted`, and the difference is structural
239
- // rather than stylistic: `willBeEmitted`'s first act is to consult
240
- // `allowedEntityIds`, and `allowedEntityIds` is precisely what the call
241
- // below is computing. Wiring it in here is circular: it would answer "not
242
- // in the closure" as `false` while the closure is still being built and
243
- // `true` for the same id afterwards.
244
- //
245
- // That is a genuine departure from the contract #2637 was closed on —
246
- // `reference-collector.ts` still documents the bridge as taking the
247
- // caller's OWN output predicate, "not two expressions that happened to
248
- // agree". It has an OBSERVABLE consequence, not just a naming one: for an
249
- // unreadable source ref this admits, the walk bridges through a
250
- // relationship the output then withholds, leaving the relationship's other
251
- // target in the closure with nothing naming it — an orphan, pinned by
252
- // `unreadable-ref-dangling.test.ts` ("walk and output predicates diverge").
253
- // The reverse direction is closed: every id this excludes,
254
- // `isOmittedFromOutput` excludes too, so the #2548 leak cannot return.
255
- isRefExcludedDuringClosureWalk: (id) => (pass.hiddenProductIds !== null && pass.hiddenProductIds.has(id))
256
- || pass.effective.isDeleted(id),
257
- // Will THIS entity's own line ever land in the file? The same byte-range
258
- // test `willBeEmitted` uses (defined further below) and the source-
259
- // iteration pass's own skip at `entityRef.byteLength === 0` — a source
260
- // entity with no bytes (a point-cloud / GLB "entity" from
261
- // `createSyntheticDataStore`, not an overlay-created one) never gets a
262
- // defining line written, source-iteration or otherwise, so a pset/attribute
263
- // edit against it must not count as a modification either: the header
264
- // would describe a change the file does not contain (out-of-scope finding
265
- // in #2398). Also excludes a source-backed host the visible-only closure
266
- // above drops — same reasoning, different reason the line never lands.
267
- //
268
- // And, like `willBeEmitted` below, excludes a geometry-classified SOURCE
269
- // host under `includeGeometry: false`: the source-iteration pass's own
270
- // `isGeometryEntity` skip (further below) drops that line too, so this
271
- // predicate must agree or a geometry entity's attribute edit inflates the
272
- // count over an omitted line (CodeRabbit finding on #2414). Guarded by
273
- // `!deltaOnly` for the same reason `willBeEmitted` is: under `deltaOnly`
274
- // the source-iteration pass — and its geometry skip — never runs at all,
275
- // so a source entity's line is assumed to already exist in the file being
276
- // patched, geometry or not.
277
- isGeometryExcluded: (entityId, recordType) => excludeGeometry
278
- && this.isGeometryEntity(pass.effective.effectiveType(entityId, recordType)),
279
- hasEmittableHostBytes: (entityId) => {
280
- if (pass.allowedEntityIds !== null && !pass.allowedEntityIds.has(entityId))
281
- return false;
282
- const ref = pass.effective.get(entityId);
283
- // The ref must be READABLE, not merely non-empty: a range this source
284
- // cannot address decodes to the empty string, which used to be pushed
285
- // into the file as a blank line while everything generated FOR the host
286
- // still named it (#2491).
287
- if (!ref || !pass.isReadableSourceRef(ref))
288
- return false;
289
- if (options.deltaOnly !== true && pass.isGeometryExcluded(entityId, ref.type))
290
- return false;
291
- return true;
292
- },
293
- /**
294
- * Will this id have a defining STEP line in the output at all?
295
- *
296
- * The predicate is #2030's, and it is the right one: the pset, quantity and
297
- * type-owned passes below are built from unfiltered mutation history, and
298
- * what each of them needs to know before emitting an
299
- * `IFCRELDEFINESBYPROPERTIES` is not "was this deleted" or "is this hidden"
300
- * but the general question those are two answers to. A relation naming an
301
- * expressId that never gets written is a dangling reference and an invalid
302
- * file, whichever route dropped the line.
303
- *
304
- * #2030 had to reach for four things to answer it — a tombstone probe, a
305
- * visibility set, a byte-range test on `completeIndex`, and a `getNewEntity`
306
- * fallback whose stated purpose was that `deleteEntity` FORGOT an
307
- * overlay-created entity instead of tombstoning it, so `isDeleted` could not
308
- * answer for one. That fallback was documented on main as a workaround for
309
- * exactly the model-level defect this branch fixes: `deleteEntity` now
310
- * tombstones as well as forgets, so the effective index answers existence
311
- * for source and overlay ids alike and the workaround collapses into it.
312
- *
313
- * The overlay branch does NOT disappear with it, and the distinction matters:
314
- * `isOverlayCreated` is still load-bearing here, because a live
315
- * overlay-created entity has no source bytes and would fail the byte-range
316
- * test that a source record passes. What the tombstone fix removed is the
317
- * need for that branch to double as a deletion detector.
318
- *
319
- * Deliberately unchanged from #2030 for source records under `deltaOnly` /
320
- * `exportPropertiesOnly`: the source-iteration pass is skipped wholesale in
321
- * those modes, yet a source entity still answers true here. A delta is a
322
- * patch against a file that already has the line, not a standalone model.
323
- */
324
- willBeEmitted: (entityId) => {
325
- if (pass.allowedEntityIds !== null && !pass.allowedEntityIds.has(entityId))
326
- return false;
327
- // Undefined for a tombstoned id and for one neither the file nor the
328
- // session ever had — a stale mutation must not conjure a relation either.
329
- const ref = pass.effective.get(entityId);
330
- if (!ref)
331
- return false;
332
- // An overlay-created record carries the placeholder byte range and is
333
- // written by the new-entities pass; a source record needs real bytes.
334
- if (pass.effective.isOverlayCreated(entityId)) {
335
- // The overlay new-entities pass applies its OWN `isGeometryEntity`
336
- // filter unconditionally — deltaOnly or not (see the comment at that
337
- // loop, further below) — so this branch mirrors it without the
338
- // deltaOnly carve-out the source branch gets.
339
- return !pass.isGeometryExcluded(entityId, ref.type);
340
- }
341
- // Same readability test as `hasEmittableHostBytes`, and for the reason
342
- // that predicate names: a ref this source cannot address is not a line
343
- // this export can write, so nothing may be generated naming it (#2491).
344
- if (!pass.isReadableSourceRef(ref))
345
- return false;
346
- // Mirrors `hasEmittableHostBytes`: under `deltaOnly` the source-
347
- // iteration pass — and its geometry skip — never runs, so a source
348
- // entity's line is assumed to already exist in the file being patched.
349
- if (options.deltaOnly === true)
350
- return true;
351
- return !pass.isGeometryExcluded(entityId, ref.type);
352
- },
353
- // Under `deltaOnly` a nomination only becomes a count once some pass has
354
- // actually written content that delivers THAT KIND of edit for the host —
355
- // see `delta-modification-ledger.ts` for why the two are not the same event
356
- // in that mode, and why the pair is (entity, kind) rather than the entity
357
- // (#2462).
358
- modifications: createModificationLedger(options.deltaOnly === true),
359
- /**
360
- * Hosts whose in-place named-attribute edits a FULL export may count, per
361
- * kind. Filled by the collection passes below and read by the two passes
362
- * that write a rewritten source line — see `in-place-nomination.ts` for why
363
- * the nomination waits for the rewrite in this mode and not under
364
- * `deltaOnly` (#2483).
365
- */
366
- inPlaceNominees: {
367
- attribute: new Set(),
368
- georeferencing: new Set(),
369
- },
370
- // Collect entities that need to be modified or created
371
- modifiedEntities: new Set(),
372
- modifiedAttributes: new Map(),
373
- newPropertySets: [],
374
- newQuantitySets: [],
375
- typeOwnedPsetNamesByEntity: new Map(),
376
- typeOwnedPsetIdsByEntity: new Map(),
377
- rewrittenEntityIds: new Set(),
378
- rewrittenEntityLines: new Map(),
379
- /** HasPropertySets slot value for an OVERLAY-CREATED type object, applied
380
- * by the new-entities pass (there is no source line to rewrite). */
381
- overlayTypeOwnedPsets: new Map(),
382
- // Track property set IDs and relationship IDs to skip
383
- skipPropertySetIds: new Set(),
384
- skipRelationshipIds: new Set(),
385
- // Written by the georeferencing pass and read again by the final
386
- // assembly, which is why they are pass state and not phase locals.
387
- newGeorefLines: [],
388
- warnings: [],
389
- };
390
- if (options.visibleOnly && this.dataStore.source) {
391
- const visible = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, pass.effective);
392
- pass.hiddenProductIds = visible.hiddenProductIds;
393
- pass.allowedEntityIds = collectReferencedEntityIds(visible.roots, this.dataStore.source, pass.effective, visible.hiddenProductIds, pass.isRefExcludedDuringClosureWalk);
394
- // Second pass: collect IFCSTYLEDITEM entities that reference included
395
- // geometry. Styled items reference geometry items but nothing references
396
- // them back, so the forward closure misses them.
397
- collectStyleEntities(pass.allowedEntityIds, this.dataStore.source, { byId: pass.effective, byType: pass.effective.byType });
398
- }
399
- /**
400
- * "Does this model hold a record whose bytes this export cannot read?" —
401
- * the one disjunct of {@link mayNameOmittedRefs} that is not already a
402
- * value in hand, so it is a function and called last, behind `||`.
403
- *
404
- * Scans the EFFECTIVE index, and that is a requirement rather than an
405
- * implementation detail: it has to cover the id space
406
- * `isOmittedFromOutput` answers over, and an unreadable record can live in
407
- * `deferredEntityIndex` — the secondary index `getCompleteEntityIndex`
408
- * exists to merge — and nowhere in `entityIndex.byId`. Scanning `byId`,
409
- * the obvious cheaper source, was measured to leave the gate false and
410
- * ship the dangling ref; `relationship-filter-gate.test.ts` pins the
411
- * merged scan behaviourally so that shortcut cannot come back as an
412
- * optimisation.
413
- *
414
- * Reads the ref ITERATION yields — what the source-iteration pass's own
415
- * skip reads — rather than re-asking `effective.get(id)` per id as
416
- * `willBeEmitted` does, which on the largest files would cost a binary
417
- * search and an allocation per entity and defeat the point of the gate.
418
- * Every index here keeps the two in step by construction:
419
- * `CompactEntityIndex` serves `get`, `has` and iteration from one pair of
420
- * `Uint32Array`s, a `Map` trivially agrees, the merged deferred view is
421
- * `byId.get ?? deferred.get` over `yield* byId; yield* deferred`, and
422
- * `OverlayIndex` filters both by one tombstone set. An index whose `has`
423
- * accepted an id its iteration never yields would defeat this — and would
424
- * equally defeat the source-iteration pass's skip, so that file is broken
425
- * either way; nothing in the repo builds one.
426
- *
427
- * Not short-circuited on `overlayActive`: an overlay-created record carries
428
- * `(OVERLAY_BYTE_OFFSET, 0)` and so counts as unreadable here, which would
429
- * make this always answer true once an overlay exists. Harmless —
430
- * `overlayActive` is an earlier disjunct, so this never runs then — and
431
- * correct if it ever did.
432
- *
433
- * ## Why a standalone pass rather than a value off the index
434
- *
435
- * Measured: 12.0 ms of a 470 ms export at 714,485 entities (2.55%), one
436
- * call, whole index walked because a well-formed model gives it nothing to
437
- * short-circuit on. The cheaper shape was prototyped and is 13x faster —
438
- * `min(byteLength)` and `max(byteOffset + byteLength)` over
439
- * `CompactEntityIndex`'s own `Uint32Array`s answer "is every ref readable
440
- * within `extent`" exactly and allocation-free in 0.74 ms — and was not
441
- * taken, because 11 ms does not buy what it costs.
442
- *
443
- * It could only stand in FRONT of this loop, never replace it:
444
- * `EntityByIdIndex` is a structural type and plain `Map`s satisfy it
445
- * (`synthetic-data-store.ts` builds one), so the walk stays for those. That
446
- * makes it a second implementation of one predicate across a package
447
- * boundary — the defect class #2637, #2668 and this gate are all instances
448
- * of. And storing it at construction is the invariant
449
- * `source-ref-bounds.ts` exists to delete: `CompactEntityIndex` is built by
450
- * its builder, by `compactEntityIndexFromColumns` in the transport, and by
451
- * embedders, so a value one producer writes is a value the next can skip,
452
- * whereas testing the ref where it is READ cannot be bypassed. If 2.5% ever
453
- * has to go, the safe shape is a memoized derivation the index computes
454
- * from its own arrays on demand — not a field set at build time.
455
- */
456
- const hasAnyUnreadableSourceRef = () => {
457
- for (const [, ref] of pass.effective) {
458
- if (!pass.isReadableSourceRef(ref))
459
- return true;
460
- }
461
- return false;
462
- };
463
- // Process mutations if we have a mutation view
464
- if (this.mutationView && applyMutations) {
465
- const mutations = this.mutationView.getMutations();
466
- // Attribute values come from the *overlay*, never from the mutation
467
- // history. The history is append-only and undo writes its reverse edit
468
- // with `skipHistory: true`, so a superseded UPDATE_ATTRIBUTE record keeps
469
- // its stale `newValue` forever — replaying it resurrects edits the user
470
- // undid (#1957). The overlay is what the editor shows, and it is already
471
- // the source for psets, quantities, positional attributes and retypes
472
- // below, so attributes were the sole outlier.
473
- for (const [entityId, attrs] of this.mutationView.getAttributeMutationsByEntity()) {
474
- pass.modifiedEntities.add(entityId);
475
- let target = pass.modifiedAttributes.get(entityId);
476
- if (!target) {
477
- target = new Map();
478
- pass.modifiedAttributes.set(entityId, target);
479
- }
480
- for (const [name, value] of attrs)
481
- target.set(name, value);
482
- }
483
- // Group mutations by entity, separating property vs quantity mutations
484
- const entityPropMutations = new Map();
485
- const entityQuantMutations = new Map();
486
- for (const mutation of mutations) {
487
- // Handled above, off the overlay. Skipped explicitly because an
488
- // UPDATE_ATTRIBUTE record can also carry a `psetName` (georef fields
489
- // encode their target entity there) and must not be mistaken for a
490
- // property-set edit.
491
- if (mutation.type === 'UPDATE_ATTRIBUTE')
492
- continue;
493
- if (!mutation.psetName)
494
- continue;
495
- const isQuantity = mutation.type === 'CREATE_QUANTITY' || mutation.type === 'UPDATE_QUANTITY'
496
- || mutation.type === 'DELETE_QUANTITY' || mutation.type === 'DELETE_QUANTITY_SET';
497
- const targetMap = isQuantity ? entityQuantMutations : entityPropMutations;
498
- if (!targetMap.has(mutation.entityId)) {
499
- targetMap.set(mutation.entityId, new Set());
500
- }
501
- targetMap.get(mutation.entityId).add(mutation.psetName);
502
- }
503
- // Build a reverse index of IfcRelDefinesByProperties → (relId, psetId)
504
- // pairs keyed on each related entity. The two property/quantity loops
505
- // below previously walked every entity in `entityIndex.byId` per
506
- // modified entity (O(E·N)); the index keeps the per-entity step
507
- // O(K) where K is the number of rels referencing that entity.
508
- const { byEntity: relDefinesByEntity, relatedByRel } = buildRelDefinesByPropertiesIndex(this.propertySetContext());
509
- // A source IfcRelDefinesByProperties whose EVERY related object the
510
- // session deleted has nothing left to relate, and emitting it leaves a
511
- // `#id` pointing at a record the export skipped. Dropped only when all of
512
- // them are gone: a rel that still names a live entity is that entity's
513
- // only link to its psets, and nothing here rewrites a RelatedObjects list.
514
- for (const [relId, related] of relatedByRel) {
515
- if (related.length > 0 && related.every((id) => pass.effective.isDeleted(id))) {
516
- pass.skipRelationshipIds.add(relId);
517
- }
518
- }
519
- collectPropertyAndQuantitySetMutations(pass, options, { entityPropMutations, entityQuantMutations, relDefinesByEntity }, this.propertySetContext());
520
- for (const [entityId] of pass.modifiedAttributes) {
521
- // An overlay-CREATED entity carrying attribute edits is emitted once,
522
- // by the new-entities pass, and already counted in `newEntityCount`.
523
- // Counting it here too made the header claim two affected entities for
524
- // one created-then-renamed wall.
525
- if (pass.isOverlayCreated(entityId))
526
- continue;
527
- // A source entity with no bytes never gets its line rewritten (the
528
- // source-iteration pass skips it), so an attribute edit against it
529
- // must not inflate the count either.
530
- if (!pass.hasEmittableHostBytes(entityId))
531
- continue;
532
- // Under `deltaOnly` this only NOMINATES the host's ATTRIBUTE edits:
533
- // nothing writes an in-place attribute edit into a delta except the
534
- // type-object line rewrite, so the ledger drops it at settle time
535
- // unless that pass reports having carried it (#2462). That nomination
536
- // is deliberately made at INTENT: the per-kind warning exists to NAME
537
- // an edit the delta could not carry, and an undeliverable edit is
538
- // exactly the one that must still be named.
539
- //
540
- // A FULL export has no such warning, so an edit that resolved to
541
- // nothing has nothing to say and nothing to claim — it waits for the
542
- // rewrite instead. `setAttribute` to the value already in the slot, and
543
- // `setAttribute` naming a slot the class does not declare, both leave
544
- // the line byte-identical and used to count anyway (#2483).
545
- //
546
- // Recorded unconditionally. It used to be skipped for a host that also
547
- // had a pset or qset edit, because the count was per entity and the
548
- // other loop had already nominated it — which is exactly what let a
549
- // pset emission mark the rename delivered and suppress its warning. The
550
- // ledger de-duplicates the COUNT per entity now, so the two edits can
551
- // and must be nominated separately.
552
- pass.inPlaceNominees.attribute.add(entityId);
553
- if (options.deltaOnly === true)
554
- pass.modifications.nominate(entityId, 'attribute');
555
- }
556
- }
557
- // Process georeferencing mutations (only when applyMutations is enabled)
558
- if (applyMutations && options.georefMutations) {
559
- applyGeoreferencingMutations(pass, options.georefMutations, this.georefContext(options.deltaOnly === true));
560
- }
561
- // If delta only, only export modified entities. Overlay-created entities
562
- // also count — without this, `createEntity()`-only edits would silently
563
- // drop out of delta exports.
564
- const overlayNewEntityCount = (this.mutationView
565
- && applyMutations
566
- && typeof this.mutationView.getNewEntities === 'function') ? this.mutationView.getNewEntities().length : 0;
567
- // Georef-only deltas (newGeorefLines populated but no entity changes) must
568
- // still produce a non-empty DATA section.
569
- if (options.deltaOnly
570
- && pass.modifiedEntities.size === 0
571
- && overlayNewEntityCount === 0
572
- && pass.newGeorefLines.length === 0) {
573
- const emptyContent = new TextEncoder().encode(pass.buildHeader(0) + 'DATA;\nENDSEC;\nEND-ISO-10303-21;\n');
574
- return {
575
- content: emptyContent,
576
- stats: {
577
- entityCount: 0,
578
- newEntityCount: 0,
579
- modifiedEntityCount: 0,
580
- fileSize: emptyContent.byteLength,
581
- warnings: pass.warnings,
582
- },
583
- };
584
- }
585
- /**
586
- * "May a line this export writes name `#id`?" — the single predicate both
587
- * relationship-line filter sites consume, derived from `willBeEmitted`
588
- * rather than from a second list kept in step with it by hand.
589
- *
590
- * DERIVED, not identical, and the gaps are named below rather than glossed:
591
- * a scope qualifier for ids the file never had, and `deltaOnly`, where
592
- * `willBeEmitted` answers `true` for a source record whose line this export
593
- * does not write at all (the source-iteration pass is skipped wholesale in
594
- * that mode). Nor does this make the CLOSURE WALK agree with either: the
595
- * walk keeps `isRefExcludedDuringClosureWalk` and diverges from this
596
- * predicate for an unreadable source ref — see the note on that predicate,
597
- * and the "walk and output predicates diverge" test.
598
- *
599
- * The hand-kept second list is the bug this replaces. `willBeEmitted` recognises
600
- * seven reasons a line never lands — outside the closure, hidden product,
601
- * tombstoned, never existed, unreadable source ref (#2491), geometry
602
- * excluded by options, and the `deltaOnly` carve-out — while the filter
603
- * used to consume `(hiddenProductIds !== null && hiddenProductIds.has(id))
604
- * || effective.isDeleted(id)`, which answered for two: hidden product, and
605
- * tombstoned. Notably NOT "never existed" — that one is deliberately out of
606
- * scope for the filter even now, for the reason under the qualifier heading
607
- * below. The gap was live: on a PLAIN full export, with no `visibleOnly`,
608
- * no deletions and no overlay, an unreadable ref made the source-iteration
609
- * pass skip an entity's line while an `IFCREL*` naming it shipped verbatim,
610
- * dangling.
611
- *
612
- * Deriving the filter from `willBeEmitted` is also what fixed the
613
- * `mayNameExcludedRefs` gate that stands in front of both call sites. That
614
- * gate used to be a SECOND, shorter enumeration of the same reasons
615
- * (hidden products exist, or an overlay is active) and answered `false` for
616
- * exactly the unreadable-ref export above, so the filter never ran at all.
617
- * It is now {@link mayNameOmittedRefs} — see there for why a gate is kept
618
- * at all (running the filter on every `IFCREL*` line costs +13% of a
619
- * 714k-entity export) and for the enumeration it has to cover.
620
- *
621
- * ## The one qualifier on top of `willBeEmitted`
622
- *
623
- * `willBeEmitted` answers NO for an id neither the file nor the session
624
- * ever had, which is right for its own job — nothing GENERATED may name an
625
- * id that does not exist. It is the wrong answer for rewriting a SOURCE
626
- * line, and the difference is whose bug it is. A `#999` already sitting in
627
- * a relationship's `OwnerHistory` slot in the input file is a dangling ref
628
- * this export did not create and cannot repair; `filterHiddenRefsFromRelationshipLine`
629
- * withholds a whole relationship when an excluded id is in a bare scalar,
630
- * so treating it as an exclusion would DELETE a visible element's pset over
631
- * somebody else's corrupt file. That is the harm #2637 was about, and
632
- * `step-exporter.test.ts` states the position out loud: a pre-existing
633
- * dangling ref is out of scope and ships as it arrived.
634
- *
635
- * So the filter asks the narrower question: is `#id` an entity this model
636
- * HAS, that this export is nonetheless not writing? `effective.has` is
637
- * false for a tombstone, hence the explicit `isDeleted` arm — deleting an
638
- * entity IS this session's doing and must be filtered.
639
- *
640
- * This is a scope qualifier, not a second enumeration of omission reasons:
641
- * an eighth reason added to `willBeEmitted` still reaches the filter with
642
- * no edit here.
643
- *
644
- * ## What the filter can and cannot reach
645
- *
646
- * Only `IFCREL*` lines. A `#N` named from a product's `Representation` or
647
- * `ObjectPlacement` slot is not touched, so `includeGeometry:false` — a
648
- * reason `willBeEmitted` does answer for — produces the same dangling refs
649
- * with this predicate as without it. Measured on `tests/models/AB22.ifc`:
650
- * 80 dangling refs before and after, output byte-identical but for the
651
- * header timestamp.
652
- *
653
- * ## Withholding is not free
654
- *
655
- * When the omitted id sits in a single-valued slot, or is a set's only
656
- * member, `filterHiddenRefsFromRelationshipLine` withholds the WHOLE
657
- * relationship — so an entity that relationship also named loses the
658
- * association, on a plain full export with no options set. That is why the
659
- * call sites push {@link relationshipWithheldWarning}.
660
- *
661
- * See `unreadable-ref-dangling.test.ts` for the reproduction. #2637 is the
662
- * prior instance of this class, which took seven rounds because the same
663
- * decision was recomputed per call site.
664
- */
665
- const isOmittedFromOutput = (id) => (pass.effective.has(id) || pass.effective.isDeleted(id)) && !pass.willBeEmitted(id);
666
- /**
667
- * "Can ANY id be omitted from this export at all?" — the precondition both
668
- * `IFCREL*` filter sites are gated on, so the common export pays nothing.
669
- *
670
- * ## Why a gate exists
671
- *
672
- * Running `filterHiddenRefsFromRelationshipLine` on every `IFCREL*` line
673
- * costs a re-parse of that line's attribute list, and a large model is
674
- * mostly relationships. Measured on `tests/models/ara3d/schependomlaan.ifc`
675
- * (714,485 entities, 21 interleaved reps in randomised order): 463 ms
676
- * median with this gate false versus 523 ms filtering unconditionally,
677
- * **+13%**. That is a real price paid on every export to protect a state
678
- * most exports are not in. With the gate, the same export is 475 ms, +2.7%,
679
- * all of it the fourth disjunct's one pass.
680
- *
681
- * ## Why THIS gate, and not the one that shipped before
682
- *
683
- * The gate this replaces was a second, hand-kept enumeration of "reasons an
684
- * entity might be excluded", and it went stale exactly as such lists do: it
685
- * named hidden products and the overlay and knew nothing about an unreadable
686
- * source ref, so the bug this branch fixes reached the output with the
687
- * filter switched off. A cheap gate is safe only as an OVER-APPROXIMATION of
688
- * `isOmittedFromOutput` that can be checked against `willBeEmitted` branch
689
- * by branch — so every branch is listed, with the disjunct that covers it:
690
- *
691
- * | `willBeEmitted` answers NO at | covered by |
692
- * |----------------------------------------------|-------------------------------|
693
- * | `allowedEntityIds !== null && !has(id)` | `allowedEntityIds !== null` |
694
- * | `!ref`, because the overlay tombstoned `id` | `overlayActive` |
695
- * | overlay-created, geometry excluded | `overlayActive` |
696
- * | `!isReadableSourceRef(ref)` | `hasAnyUnreadableSourceRef()` |
697
- * | source-backed, geometry excluded | `excludeGeometry` |
698
- * | `!ref`, because `id` never existed | out of scope (below) |
699
- * | `!ref` while `has(id)` is TRUE | nothing (below) |
700
- *
701
- * "Never existed" needs no disjunct: `isOmittedFromOutput`'s own
702
- * `(has || isDeleted)` qualifier already drops it, deliberately — a
703
- * pre-existing dangling ref in somebody else's file is not this export's to
704
- * repair (see that predicate's note).
705
- *
706
- * The last row is a real hole and is stated rather than hidden: an index
707
- * that answers `has(id)` for an id its iteration never yields makes
708
- * `isOmittedFromOutput` true with no disjunct true. It needs an index whose
709
- * `has`, `get` and iteration disagree, which nothing in the repo builds and
710
- * which would already break the source-iteration pass's own skip — see
711
- * {@link hasAnyUnreadableSourceRef}, which rests on the same agreement.
712
- *
713
- * Three of the four disjuncts are reads of values this export already
714
- * computed. Only the fourth costs anything, and it short-circuits: `||`
715
- * evaluates it solely when the other three are false, i.e. only for an
716
- * export that has nothing else to filter for.
717
- *
718
- * ## The two spellings that are deliberately NOT the obvious ones
719
- *
720
- * `allowedEntityIds !== null`, not `options.visibleOnly === true`. Not the
721
- * same test: the closure is built under `if (options.visibleOnly &&
722
- * this.dataStore.source)`, which is TRUTHY rather than `=== true`, and which
723
- * is a SECOND read of the caller's object. A plain-JS caller of this
724
- * published package passing `visibleOnly: 1` — or a `get visibleOnly()` that
725
- * answers `true` once — built the closure while the gate read false and
726
- * shipped a relationship naming an entity outside it. Executed, not
727
- * reasoned: 192 of an 800-case sweep over `visibleOnly`/`hidden`/`isolated`
728
- * combinations shipped a dangling ref against the `=== true` spelling, 0
729
- * against this one. Reading the state the walk PRODUCED cannot disagree with
730
- * the walk, whatever `options` says afterwards.
731
- *
732
- * It is also wider than the `hiddenProductIds.size > 0` the old gate used: a
733
- * closure exists whenever `visibleOnly` was requested, even with nothing
734
- * hidden, and can exclude an entity the roots simply never reach. No fixture
735
- * has produced that case, so the widening is defensive — but a gate that is
736
- * true too often costs speed on a rare path, while one that is false too
737
- * rarely ships a corrupt file, and this one costs nothing.
738
- *
739
- * `overlayActive` and `excludeGeometry` are the SAME consts the effective
740
- * index and `isGeometryExcluded` are built from — one read of `options` per
741
- * question, shared — so those two cannot disagree with the predicate either.
742
- */
743
- const mayNameOmittedRefs = pass.allowedEntityIds !== null
744
- || pass.overlayActive
745
- || excludeGeometry
746
- || hasAnyUnreadableSourceRef();
747
- // A modified pset is replaced wholesale, which skips ALL of its member atoms.
748
- // But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
749
- // IsExternal IfcPropertySingleValue shared by dozens of psets), so skipping a
750
- // shared atom would orphan every OTHER pset that still references it, leaving
751
- // dangling refs and an invalid file. Keep any atom a surviving container needs.
752
- this.retainSharedAtoms(pass.skipPropertySetIds, pass.allowedEntityIds);
753
- // Export original entities from source buffer, SKIPPING modified property sets
754
- if (!options.deltaOnly && this.dataStore.source) {
755
- const source = this.dataStore.source;
756
- // Extract existing entities from source. The effective index has already
757
- // dropped everything the overlay tombstoned, so there is no separate
758
- // deleted check to forget here.
759
- for (const [expressId, entityRef] of pass.effective) {
760
- // Skip overlay-only entities — emitted by the new-entities pass below.
761
- // A ref this source cannot address is skipped by the same test rather
762
- // than decoded: `decodeUtf8` clamps such a range and the empty string
763
- // it returns used to be pushed into the file as a blank line, leaving
764
- // every generated record that names the host dangling (#2491).
765
- if (!pass.isReadableSourceRef(entityRef)) {
766
- continue;
767
- }
768
- // Skip entities outside the visible closure
769
- if (pass.allowedEntityIds !== null && !pass.allowedEntityIds.has(expressId)) {
770
- continue;
771
- }
772
- // Skip property sets/relationships that are being replaced
773
- if (pass.skipPropertySetIds.has(expressId) || pass.skipRelationshipIds.has(expressId)) {
774
- continue;
775
- }
776
- // Skip type entities whose HasPropertySets attribute will be rewritten
777
- if (pass.rewrittenEntityIds.has(expressId)) {
778
- continue;
779
- }
780
- // Skip geometry if not included. Classified via `isGeometryExcluded`
781
- // (which reads the EFFECTIVE type, `effective.effectiveType`) rather
782
- // than `entityRef.type` directly: a retype can move a record across
783
- // the geometry boundary in either direction, and this check has to
784
- // agree with `hasEmittableHostBytes`/`willBeEmitted`'s use of the
785
- // same predicate — otherwise a wall retyped to `IfcCartesianPoint`
786
- // still ships its (rewritten) geometry line under
787
- // `includeGeometry: false`, the exact "predicate must agree" failure
788
- // this file already guards for the non-retyped case (#2414).
789
- if (pass.isGeometryExcluded(expressId, entityRef.type)) {
790
- continue;
791
- }
792
- // Get original entity text — decodeRange handles SAB-backed
793
- // sources (Firefox/Chrome reject `TextDecoder.decode()` on a
794
- // SharedArrayBuffer-backed view; the parser deliberately keeps
795
- // `source` zero-copy SAB-backed for worker sharing).
796
- const entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
797
- // Retype, named attribute edits and positional edits, in that order.
798
- // Shared verbatim with the type-object `HasPropertySets` rewrite below,
799
- // which writes the line this pass would otherwise have written.
800
- const mutated = this.applySourceLineMutations(expressId, entityText, entityRef.type, pass.modifiedAttributes.get(expressId), pass.sourceSchema, pass.overlayActive, (attr, value) => pass.warnings.push(`entity #${expressId}: attribute ${attr} not written - ` +
801
- `${JSON.stringify(value)} is not a number and the slot is REAL-typed`));
802
- let nextEntityText = mutated.text;
803
- // A hidden PRODUCT's own line is already out of the export via
804
- // `allowedEntityIds`, and a TOMBSTONED entity's via `effective` — this
805
- // is the relationship that NAMED either one. `IFCREL*` is an
806
- // unconditional root (see `getVisibleEntityIds`), so its bytes reach
807
- // here unfiltered even when one of the ids they name was just
808
- // excluded; left alone that ships a `#N` with no `#N=` line, whether
809
- // the exclusion came from `visibleOnly` or from a plain deletion
810
- // (#2398). Checked before the nomination below: a relationship this
811
- // withholds must not also be counted as a delivered modification.
812
- //
813
- // Classified by the EFFECTIVE type (`effective.effectiveType`), not
814
- // the source's authored type: a retype can move a record across
815
- // the `IFCREL*` boundary in either direction (`applySourceLineMutations`
816
- // already rewrote `nextEntityText` to the new class), and this check
817
- // has to agree with what actually got written, the same way
818
- // `getVisibleEntityIds` already does for the visibility walk itself.
819
- const effectiveRelType = pass.effective.effectiveType(expressId, entityRef.type).toUpperCase();
820
- if (mayNameOmittedRefs && effectiveRelType.startsWith('IFCREL')) {
821
- const filtered = filterHiddenRefsFromRelationshipLine(nextEntityText, isOmittedFromOutput);
822
- if (filtered === null) {
823
- pass.warnings.push(relationshipWithheldWarning(expressId, effectiveRelType));
824
- continue;
825
- }
826
- nextEntityText = filtered;
827
- }
828
- // A retype or a positional edit that CHANGED the line is what makes
829
- // this entity count; a named attribute edit was already nominated by
830
- // the collection pass. Both flags report effect, so retyping an entity
831
- // to the class it already is — or writing a slot the token it already
832
- // holds — no longer claims a modification over a line the export left
833
- // byte-identical. This pass is full-export-only (`deltaOnly` skips it
834
- // wholesale), so nomination IS emission here and the kinds only have to
835
- // be right for the entity count — which is per entity, hence unchanged.
836
- if (mutated.retyped || mutated.positional)
837
- pass.modifiedEntities.add(expressId);
838
- if (mutated.retyped)
839
- pass.modifications.nominate(expressId, 'retype');
840
- if (mutated.positional)
841
- pass.modifications.nominate(expressId, 'positional');
842
- // The named-attribute kinds join them here rather than at their
843
- // collection sites, for the same reason and on the same signal (#2483).
844
- // This pass is full-export-only, so there is nothing to gate.
845
- nominateDeliveredInPlaceEdits(pass.modifications, expressId, mutated, pass.inPlaceNominees);
846
- // Apply schema conversion if exporting to a different schema version
847
- if (pass.converting) {
848
- const converted = convertStepLine(nextEntityText, pass.sourceSchema, pass.schema, options.guidRandom);
849
- if (converted !== null) {
850
- pass.entities.push(converted);
851
- }
852
- // null means entity should be skipped (no valid representation in target schema)
853
- }
854
- else {
855
- pass.entities.push(nextEntityText);
856
- }
857
- }
858
- }
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()));
859
144
  // Generated property/quantity sets and the type-object `HasPropertySets`
860
145
  // rewrite that resolves against them, in that one order (#2475 steps 2b
861
146
  // and 2c). `pass.rewrittenEntityLines`, this call's output, is flushed
@@ -868,132 +153,12 @@ export class StepExporter {
868
153
  for (const line of pass.newGeorefLines) {
869
154
  pass.entities.push(line);
870
155
  }
871
- // Add overlay-created entities (store.addEntity / mutationView.createEntity).
872
- // Apply the same filters as the source-iteration pass so newly-created
873
- // beams/slabs don't smuggle their geometry helpers (IfcCartesianPoint,
874
- // IfcExtrudedAreaSolid, etc.) past `includeGeometry:false` /
875
- // `exportPropertiesOnly()` modes.
876
- if (this.mutationView
877
- && applyMutations
878
- && typeof this.mutationView.getNewEntities === 'function') {
879
- const getTypeMut = typeof this.mutationView.getEntityTypeMutation === 'function'
880
- ? this.mutationView.getEntityTypeMutation.bind(this.mutationView)
881
- : null;
882
- for (const entity of this.mutationView.getNewEntities()) {
883
- // A retyped overlay entity keeps its AUTHORED type on `entity.type`
884
- // (the overlay typeMutation is the source of truth for the effective
885
- // class). Resolve the effective class, then re-lay-out the authored
886
- // attributes from the authored layout up to it.
887
- const typeMut = getTypeMut ? getTypeMut(entity.expressId) : null;
888
- const effectiveType = typeMut?.newType ?? entity.type;
889
- // STEP requires UPPERCASE entity type tokens; the upper-case happens
890
- // here at the file-format boundary.
891
- const upperType = effectiveType.toUpperCase();
892
- if (excludeGeometry && this.isGeometryEntity(upperType)) {
893
- continue;
894
- }
895
- if (pass.allowedEntityIds !== null && !pass.allowedEntityIds.has(entity.expressId)) {
896
- continue;
897
- }
898
- // Re-lay-out by name against the effective class (identity for
899
- // compatible layouts). Runs whenever a retype intent exists — even a
900
- // same-class retype, which carries a PredefinedType override
901
- // (e.g. setEntityType(id, 'IfcColumn', 'PILASTER')).
902
- let argsText;
903
- if (typeMut) {
904
- // Serialize against the AUTHORED layout (`entity.type`); retypeArgTokens
905
- // then re-lays the tokens out by name up to the effective class.
906
- const srcTokens = entity.attributes.map((value, i) => serializeAttributeSlot(entity.type, i, value, pass.sourceSchema));
907
- const { tokens } = retypeArgTokens(srcTokens, entity.type, effectiveType, typeMut.predefinedType ?? null, pass.sourceSchema);
908
- argsText = tokens.join(',');
909
- }
910
- else {
911
- argsText = serializeEntityArgs(entity.type, entity.attributes, pass.sourceSchema);
912
- }
913
- // Edits made AFTER the create live in the overlay, never in the
914
- // authored payload (#2006). The source-iteration pass applies them to
915
- // source records via applyAttributeMutations / applyPositionalMutations;
916
- // an overlay-created entity has no source record, so without this it was
917
- // written from its creation payload alone and every later
918
- // `setAttribute` / `setPositionalAttribute` was silently dropped on
919
- // save — data loss with no error and no warning.
920
- //
921
- // Order mirrors the source pass: retype (above) -> named attributes ->
922
- // positional overrides, all resolved against the EFFECTIVE class.
923
- const attributeOverrides = pass.modifiedAttributes.get(entity.expressId) ?? null;
924
- const queuedPositional = typeof this.mutationView.getPositionalMutationsForEntity === 'function'
925
- ? this.mutationView.getPositionalMutationsForEntity(entity.expressId)
926
- : null;
927
- // A created TYPE object owns its psets through HasPropertySets, and the
928
- // ids of the psets this export generated are only known now — so they
929
- // arrive as one more slot override rather than through the overlay.
930
- // `has`, not `??`, for the same reason `overlaySlotValue` gives: the
931
- // stored value is deliberately null when the resolved list is empty.
932
- const positionalOverrides = pass.overlayTypeOwnedPsets.has(entity.expressId)
933
- ? new Map(queuedPositional).set(HAS_PROPERTY_SETS_SLOT, pass.overlayTypeOwnedPsets.get(entity.expressId) ?? null)
934
- : queuedPositional;
935
- if ((attributeOverrides && attributeOverrides.size > 0)
936
- || (positionalOverrides && positionalOverrides.size > 0)) {
937
- argsText = this.applyOverlayEntityOverrides(argsText, upperType, attributeOverrides, positionalOverrides, pass.sourceSchema,
938
- // Overlay-created entities report a rejected REAL edit exactly as
939
- // source-backed ones do. Without this the slot was kept and NOTHING
940
- // was said - the silent discard this whole change exists to
941
- // prevent, surviving in the one path that had no test.
942
- (attr, value) => pass.warnings.push(`entity #${entity.expressId}: attribute ${attr} not written - ` +
943
- `${JSON.stringify(value)} is not a number and the slot is REAL-typed`));
944
- }
945
- let line = `#${entity.expressId}=${upperType}(${argsText});`;
946
- // Same gap as the source-iteration pass, for an overlay-authored
947
- // relationship instead of a parsed one (#2398).
948
- //
949
- // `mayNameOmittedRefs` is provably TRUE wherever this line executes:
950
- // the block enclosing this pass requires `this.mutationView` and
951
- // `applyMutations`, which is `pass.overlayActive`, which is one of the
952
- // gate's own disjuncts. Spelled out anyway so both filter sites read the
953
- // same — the previous gate's failure was one site's condition drifting
954
- // from what the filter needed, and a pass reachable without an overlay
955
- // would otherwise silently need the gate re-derived here.
956
- if (mayNameOmittedRefs && upperType.startsWith('IFCREL')) {
957
- line = filterHiddenRefsFromRelationshipLine(line, isOmittedFromOutput);
958
- if (line === null) {
959
- pass.warnings.push(relationshipWithheldWarning(entity.expressId, upperType));
960
- continue;
961
- }
962
- }
963
- if (pass.converting) {
964
- const converted = convertStepLine(line, pass.sourceSchema, pass.schema, options.guidRandom);
965
- if (converted !== null) {
966
- pass.entities.push(converted);
967
- pass.newEntityCount++;
968
- }
969
- }
970
- else {
971
- pass.entities.push(line);
972
- pass.newEntityCount++;
973
- }
974
- }
975
- }
976
- // Settle the count against what the passes above actually wrote, and say
977
- // out loud every KIND of edit a delta could not carry, per host. Silence
978
- // was the other half of #2462: `deltaOnly` skips the source-iteration pass,
979
- // so an in-place edit to a source entity is not in the file and never was —
980
- // the header merely used to claim otherwise.
981
- const { modifiedEntityCount, warnings: deltaWarnings } = pass.modifications.settle();
982
- pass.warnings.push(...deltaWarnings);
983
- // Assemble final file as Uint8Array chunks to avoid V8 string length limit.
984
- // The header is built last so its provenance item reflects the real count.
985
- const header = pass.buildHeader(pass.newEntityCount + modifiedEntityCount);
986
- const content = assembleStepBytes(header, pass.entities);
987
- return {
988
- content,
989
- stats: {
990
- entityCount: pass.entities.length,
991
- newEntityCount: pass.newEntityCount,
992
- modifiedEntityCount,
993
- fileSize: content.byteLength,
994
- warnings: pass.warnings,
995
- },
996
- };
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);
997
162
  }
998
163
  /**
999
164
  * Async export that yields to the event loop periodically, keeping the
@@ -1027,297 +192,6 @@ export class StepExporter {
1027
192
  deltaOnly: true,
1028
193
  });
1029
194
  }
1030
- /**
1031
- * THE mutation pipeline for a line read out of the source buffer: retype,
1032
- * then named attribute edits, then positional edits.
1033
- *
1034
- * **One implementation, two call sites**, and that is the whole point. Two
1035
- * passes can write the defining line of a source entity — the
1036
- * source-iteration pass, and the type-object `HasPropertySets` rewrite that
1037
- * REPLACES it (`rewrittenEntityIds` makes the source pass skip those ids).
1038
- * The rewrite used to do its own thing (replace slot 5, nothing else), so
1039
- * every other edit to a type object with a type-owned pset edit was dropped
1040
- * in silence: first the renames (#2462 follow-up), and after those were
1041
- * special-cased here, still the retypes and the positional edits. Whatever
1042
- * the source pass applies, the rewrite has to apply too, or the next edit
1043
- * kind added to one site goes missing at the other.
1044
- *
1045
- * The order is load-bearing:
1046
- *
1047
- * - the retype runs FIRST so named attribute edits resolve against the
1048
- * TARGET class's attribute names, and so positional slots are indexed
1049
- * into the retyped argument list;
1050
- * - the `HasPropertySets` replacement (rewrite path only) runs LAST, on
1051
- * the text this returns. Run it first and a positional edit to slot 5 —
1052
- * or a retype's argument-list rebuild — overwrites the resolved pset
1053
- * list with the stale one, which is the same silent drop one slot over.
1054
- *
1055
- * The expressId is unchanged by all of this, so geometry / placement /
1056
- * representation and every IfcRel* reference (keyed by #id) carry over.
1057
- *
1058
- * All three flags report EFFECT, not intent — each is the answer to "did this
1059
- * operation change the line", measured across that operation alone. The count
1060
- * and the ledger are claims about the FILE, so an edit that resolves to the
1061
- * text already there has delivered nothing and must not be reported: retyping
1062
- * an entity to the class it already is, or writing a positional slot the token
1063
- * it already holds, used to count as a modification and reach the ledger as a
1064
- * landed edit, over a byte-identical line. Discarded edits read the same way:
1065
- * `applyAttributeMutations` drops a name its class has no slot for and
1066
- * `retypeStepLine` returns an unparseable line untouched, and neither is a
1067
- * modification of anything.
1068
- *
1069
- * `retyped` / `positional` matter most in a FULL export, which is where the
1070
- * two are nominated (their edits have no earlier nomination site); named
1071
- * attribute edits are nominated by the collection pass and `attributed` only
1072
- * settles their delivery.
1073
- */
1074
- applySourceLineMutations(expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected) {
1075
- let text = entityText;
1076
- let workingType = recordType.toUpperCase();
1077
- const typeMutation = overlayActive && typeof this.mutationView.getEntityTypeMutation === 'function'
1078
- ? this.mutationView.getEntityTypeMutation(expressId)
1079
- : null;
1080
- let retyped = false;
1081
- if (typeMutation) {
1082
- const beforeRetype = text;
1083
- text = retypeStepLine(text, recordType, typeMutation.newType, typeMutation.predefinedType ?? null, sourceSchema);
1084
- retyped = text !== beforeRetype;
1085
- // Set even for a no-op retype: the entity IS the target class from here
1086
- // on, so the named and positional edits below must resolve against it.
1087
- workingType = typeMutation.newType.toUpperCase();
1088
- }
1089
- // `applyAttributeMutations` returns its input UNCHANGED when it wrote
1090
- // nothing — no slot resolved for any of the names, or the line does not
1091
- // parse — so comparing is what tells the ledger whether a named attribute
1092
- // edit was really carried, rather than merely attempted.
1093
- let attributed = false;
1094
- if (attributeMutations && attributeMutations.size > 0) {
1095
- const beforeAttributes = text;
1096
- text = this.applyAttributeMutations(text, workingType, attributeMutations, sourceSchema, onRejected);
1097
- attributed = text !== beforeAttributes;
1098
- }
1099
- const positionals = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
1100
- ? this.mutationView.getPositionalMutationsForEntity(expressId)
1101
- : null;
1102
- let positional = false;
1103
- if (positionals && positionals.size > 0) {
1104
- const beforePositionals = text;
1105
- text = this.applyPositionalMutations(text, positionals, workingType, sourceSchema);
1106
- positional = text !== beforePositionals;
1107
- }
1108
- return { text, attributed, retyped, positional };
1109
- }
1110
- /**
1111
- * Rewrite root IFC attributes directly on the original STEP entity line.
1112
- */
1113
- applyAttributeMutations(entityText, entityType, attributeMutations, schemaVersion, onRejected) {
1114
- const openParen = entityText.indexOf('(');
1115
- const closeParen = entityText.lastIndexOf(');');
1116
- if (openParen < 0 || closeParen < openParen) {
1117
- return entityText;
1118
- }
1119
- // Cross-schema, not the IFC4 pin: an IFC4X3-only class (IfcCourse, IfcRoad,
1120
- // IfcBridge, …) resolves no slots under the pin, so every named edit on one
1121
- // was silently discarded here too. Identical for the 755 pinned classes
1122
- // that declare attributes — `attribute-slot-types.test.ts` measures that —
1123
- // so no IFC4 export changes; this only stops dropping edits it used to drop.
1124
- const attrNames = getAttributeNamesAcrossSchemas(entityType);
1125
- if (attrNames.length === 0) {
1126
- return entityText;
1127
- }
1128
- const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
1129
- // A source line NEVER pads (unlike the overlay-created path): a short
1130
- // argument list here means the file speaks a different schema, and growing
1131
- // a record we did not author would corrupt it.
1132
- let changed = false;
1133
- const realSlots = getRealTypedSlots(entityType, schemaVersion);
1134
- for (const [attrName, value] of attributeMutations) {
1135
- const index = attrNames.indexOf(attrName);
1136
- if (index < 0 || index >= args.length)
1137
- continue;
1138
- // The source path shares every `$`-slot hole with the overlay-created
1139
- // path, because a source record has plenty of `$` slots of its own. Both
1140
- // go through the one helper below.
1141
- const serialized = this.serializeNamedAttribute(entityType, index, value, args[index], realSlots);
1142
- if (serialized === null) {
1143
- // Slot untouched AND reported. Not counted as a change: claiming a
1144
- // modification we did not make is the failure this avoids.
1145
- onRejected?.(attrName, value);
1146
- continue;
1147
- }
1148
- args[index] = serialized;
1149
- changed = true;
1150
- }
1151
- if (!changed) {
1152
- return entityText;
1153
- }
1154
- return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
1155
- }
1156
- /**
1157
- * Serialize one NAMED attribute override into its slot — the single point
1158
- * both the source-buffer rewrite and the overlay-created rewrite go through.
1159
- *
1160
- * `serializeAttributeValue` decides the STEP form by reading the token being
1161
- * replaced, which is sound only while that token carries type information. A
1162
- * `$` slot carries none, and both paths have plenty: a source record's
1163
- * optional attributes are `$`, and overlay-created records pad missing slots
1164
- * with `$`. So the declared type decides first, and inference is the fallback
1165
- * for slots the schema does not classify (references, SELECTs, numerics),
1166
- * where reading the old token is exactly the right heuristic.
1167
- *
1168
- * Before this REAL check existed, "the declared type decides first" was true
1169
- * for enum/string slots only — a REAL-backed slot (`IfcMapConversion.
1170
- * OrthogonalHeight`, any other `IfcLengthMeasure`/`IfcReal`-typed attribute)
1171
- * fell straight to `serializeAttributeValue`'s token inference, which quotes
1172
- * anything it cannot recognize as numeric. A schema-legal `$` placeholder
1173
- * carries no digits to recognize, so setting such a field for the first time
1174
- * wrote `'12345'` in a slot ISO 10303-21 requires to be an unquoted REAL —
1175
- * silently invalid output (#2724, LTplus-AG/ifc-lite#2475).
1176
- */
1177
- serializeNamedAttribute(entityType, index, value, currentToken, realSlots) {
1178
- if (getEnumTypedSlots(entityType).has(index))
1179
- return serializeEnumToken(value);
1180
- if (getStringTypedSlots(entityType).has(index))
1181
- return serializeStringSlot(value);
1182
- if (realSlots.has(index)) {
1183
- const trimmed = value.trim();
1184
- if (trimmed === '')
1185
- return '$';
1186
- const numberValue = Number(trimmed);
1187
- if (Number.isFinite(numberValue))
1188
- return toStepReal(numberValue);
1189
- // A non-numeric value in a REAL slot used to fall through and be QUOTED,
1190
- // producing the same ISO 10303-21 violation #2725 exists to prevent
1191
- // (#2741). `StoreEditor.setAttribute` takes a string, so any UI text
1192
- // field bound to a georeferencing REAL can deliver one; it does not need
1193
- // a corrupt file.
1194
- //
1195
- // `null` means "leave the slot as the file had it". Simply returning
1196
- // `currentToken` here would stop the invalid output but SILENTLY DISCARD
1197
- // the edit - the exporter would then claim a modification it did not
1198
- // carry, which is the exact misreport #2723/#2724/#2726 were written to
1199
- // pin. The caller turns this into a warning, so a dropped edit is visible
1200
- // rather than inferred from absence.
1201
- return null;
1202
- }
1203
- return serializeAttributeValue(value, currentToken);
1204
- }
1205
- /**
1206
- * Apply overlay attribute + positional overrides to an OVERLAY-CREATED
1207
- * entity's argument list (#2006).
1208
- *
1209
- * Distinct from {@link applyAttributeMutations} / {@link applyPositionalMutations},
1210
- * which rewrite a line read out of the source buffer. Here the whole line is
1211
- * ours: it was serialized moments ago from the creation payload, so the
1212
- * argument list is the authoring payload's, not the file's. That difference
1213
- * is why this PADS — `entity_create` takes whatever positional list the
1214
- * caller passes, so a wall authored with three arguments still has a real
1215
- * `Tag` slot at index 7, and dropping the edit because the payload was short
1216
- * would be the very data loss this fixes. The source-buffer path must not
1217
- * pad: there a short line means a different schema, and growing a record we
1218
- * did not author would corrupt it.
1219
- *
1220
- * Named and positional overrides resolve to a slot index up front and share
1221
- * ONE padding rule. Two padding rules on one record is how the next bug
1222
- * starts, and the argument for padding — the class is fixed at creation time,
1223
- * so a short payload is partial authoring — never depended on which of the
1224
- * two APIs queued the edit.
1225
- */
1226
- applyOverlayEntityOverrides(argsText, entityType, attributeOverrides, positionalOverrides, schemaVersion, onRejected) {
1227
- const args = argsText.length > 0 ? splitTopLevelArgs(argsText) : [];
1228
- const attrNames = getAttributeNamesAcrossSchemas(entityType);
1229
- const named = [];
1230
- for (const [attrName, value] of attributeOverrides ?? []) {
1231
- const index = attrNames.indexOf(attrName);
1232
- if (index >= 0)
1233
- named.push([index, value]);
1234
- }
1235
- // Grow to the class's FULL declared arity as soon as any override names a
1236
- // declared slot the creation payload never reached. Growing only as far as
1237
- // the edited slot would emit eight arguments for an IfcWall that declares
1238
- // nine: this parser tolerates the truncated record, a schema-validating
1239
- // consumer rejects the file.
1240
- //
1241
- // An index PAST the declared layout is not a slot at all, so it cannot
1242
- // justify growing the record and stays dropped — as does any override on a
1243
- // class neither schema source knows, where there is no arity to grow to.
1244
- let needsPad = named.some(([index]) => index >= args.length);
1245
- if (!needsPad && positionalOverrides) {
1246
- for (const [index] of positionalOverrides) {
1247
- if (index >= args.length && index < attrNames.length) {
1248
- needsPad = true;
1249
- break;
1250
- }
1251
- }
1252
- }
1253
- if (needsPad) {
1254
- while (args.length < attrNames.length)
1255
- args.push('$');
1256
- }
1257
- // Every `named` index is < attrNames.length by construction, and padding
1258
- // has taken args.length to at least that, so each one lands.
1259
- const realSlots = getRealTypedSlots(entityType, schemaVersion);
1260
- for (const [index, value] of named) {
1261
- const serialized = this.serializeNamedAttribute(entityType, index, value, args[index], realSlots);
1262
- // Overlay-created entities take the same rejection: a non-numeric REAL is
1263
- // invalid STEP whoever authored the record. The slot keeps the `$` this
1264
- // path padded it with, rather than gaining a quoted string.
1265
- if (serialized === null) {
1266
- onRejected?.(attrNames[index] ?? `#${index}`, value);
1267
- continue;
1268
- }
1269
- args[index] = serialized;
1270
- }
1271
- if (positionalOverrides && positionalOverrides.size > 0) {
1272
- for (const [index, value] of positionalOverrides) {
1273
- if (index < 0 || index >= args.length)
1274
- continue;
1275
- args[index] = this.serializePositionalOverride(entityType, index, value, args[index], realSlots, schemaVersion);
1276
- }
1277
- }
1278
- return args.join(',');
1279
- }
1280
- /**
1281
- * Apply positional STEP argument overrides to an entity line.
1282
- * Used for non-IfcRoot edits (e.g. profile dimensions) where attributes
1283
- * have no symbolic names. Indexes that fall outside the existing arg list
1284
- * are silently ignored.
1285
- */
1286
- applyPositionalMutations(entityText, positionals, entityType, schemaVersion) {
1287
- const openParen = entityText.indexOf('(');
1288
- const closeParen = entityText.lastIndexOf(');');
1289
- if (openParen < 0 || closeParen < openParen)
1290
- return entityText;
1291
- const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
1292
- const realSlots = getRealTypedSlots(entityType, schemaVersion);
1293
- let changed = false;
1294
- for (const [index, value] of positionals) {
1295
- if (index < 0 || index >= args.length)
1296
- continue;
1297
- args[index] = this.serializePositionalOverride(entityType, index, value, args[index], realSlots, schemaVersion);
1298
- changed = true;
1299
- }
1300
- if (!changed)
1301
- return entityText;
1302
- return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
1303
- }
1304
- /**
1305
- * Serialize one positional override, composing the schema-aware passes:
1306
- * explicit `{ real }`/`{ typed }` marker → SELECT auto-qualification
1307
- * (`IFCBOOLEAN(.T.)`) → REAL forcing. For REAL forcing the current source
1308
- * token is a secondary signal: replacing a value that was already a REAL
1309
- * (`0.4`, `1.5E-7`) keeps it REAL even for entities the XSD index doesn't
1310
- * cover, so a whole-number edit can't silently downgrade the slot.
1311
- */
1312
- serializePositionalOverride(entityType, index, value, currentToken, realSlots, schemaVersion) {
1313
- if (isTypedMarker(value))
1314
- return serializeStepValue(value);
1315
- const qualified = serializeQualifiedSelectSlot(entityType, index, value);
1316
- if (qualified !== null)
1317
- return qualified;
1318
- const forceReal = realSlots.has(index) || tokenIsRealLiteral(currentToken);
1319
- return serializeStepValue(value, forceReal);
1320
- }
1321
195
  /**
1322
196
  * Find the maximum EXPRESS ID in the data store
1323
197
  */
@@ -1331,7 +205,7 @@ export class StepExporter {
1331
205
  *
1332
206
  * `allocateExpressId` hands out ids from THIS exporter's `nextExpressId`,
1333
207
  * which the property-set and quantity-set generators in
1334
- * `step-property-sets.ts` increment at six further sites through the same
208
+ * `step-property-set-generators.ts` increment at six further sites through the same
1335
209
  * callback — hoisting the counter onto the pass would change what it
1336
210
  * computes, not merely where it is named, so both phases get a callback
1337
211
  * instead (#2475 step 2a).
@@ -1345,18 +219,21 @@ export class StepExporter {
1345
219
  };
1346
220
  }
1347
221
  /**
1348
- * The state `step-property-sets.ts` cannot read off the pass (#2475 2b/2c).
222
+ * The state the property-set phase cannot read off the pass (#2475 2b/2c).
1349
223
  *
1350
224
  * `allocateExpressId` is the same callback `georefContext` hands out, over
1351
225
  * the same counter, so the ids the two phases allocate stay in one sequence.
1352
226
  * `ownerHistory` is passed by reference — the object is this exporter's, and
1353
227
  * `export()` resets it. `isReadableSourceRef` is the instance predicate, not
1354
228
  * `pass.isReadableSourceRef`, because two consumers of that module
1355
- * (`buildRelDefinesByPropertiesIndex`, `retainSharedAtoms`) run with no pass
1356
- * in hand; both readers are built over the same source.
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.
1357
232
  *
1358
233
  * Rebuilt per call, as `georefContext` is: every call site runs once per
1359
- * export bar `retainSharedAtoms`, which hoists it out of its loop.
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.
1360
237
  */
1361
238
  propertySetContext() {
1362
239
  return {
@@ -1366,85 +243,24 @@ export class StepExporter {
1366
243
  isReadableSourceRef: this.isReadableSourceRef,
1367
244
  allocateExpressId: () => this.nextExpressId++,
1368
245
  ownerHistory: this.ownerHistory,
1369
- applySourceLineMutations: (expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected) => this.applySourceLineMutations(expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected),
246
+ applySourceLineMutations: (expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected) => applySourceLineMutations(this.mutationView, expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive, onRejected),
1370
247
  };
1371
248
  }
1372
249
  /**
1373
- * Check if an entity type is a geometry-related type
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.
1374
256
  */
1375
- isGeometryEntity(type) {
1376
- const geometryTypes = new Set([
1377
- 'IFCCARTESIANPOINT',
1378
- 'IFCDIRECTION',
1379
- 'IFCAXIS2PLACEMENT2D',
1380
- 'IFCAXIS2PLACEMENT3D',
1381
- 'IFCLOCALPLACEMENT',
1382
- 'IFCSHAPEREPRESENTATION',
1383
- 'IFCPRODUCTDEFINITIONSHAPE',
1384
- 'IFCGEOMETRICREPRESENTATIONCONTEXT',
1385
- 'IFCGEOMETRICREPRESENTATIONSUBCONTEXT',
1386
- 'IFCEXTRUDEDAREASOLID',
1387
- 'IFCFACETEDBREP',
1388
- 'IFCPOLYLOOP',
1389
- 'IFCFACE',
1390
- 'IFCFACEOUTERBOUND',
1391
- 'IFCCLOSEDSHELL',
1392
- 'IFCRECTANGLEPROFILEDEF',
1393
- 'IFCCIRCLEPROFILEDEF',
1394
- 'IFCARBITRARYCLOSEDPROFILEDEF',
1395
- 'IFCPOLYLINE',
1396
- 'IFCTRIMMEDCURVE',
1397
- 'IFCBSPLINECURVE',
1398
- 'IFCBSPLINESURFACE',
1399
- 'IFCTRIANGULATEDFACESET',
1400
- 'IFCPOLYGONALFACE',
1401
- 'IFCINDEXEDPOLYGONALFACE',
1402
- 'IFCPOLYGONALFACESET',
1403
- 'IFCSTYLEDITEM',
1404
- 'IFCPRESENTATIONSTYLEASSIGNMENT',
1405
- 'IFCSURFACESTYLE',
1406
- 'IFCSURFACESTYLERENDERING',
1407
- 'IFCCOLOURRGB',
1408
- ]);
1409
- return geometryTypes.has(type);
1410
- }
1411
- /**
1412
- * Un-skip property/quantity atoms that a surviving (non-skipped, and — under
1413
- * visible-only export — still-included) IfcPropertySet / IfcElementQuantity
1414
- * still references.
1415
- *
1416
- * When a property is edited, the modified pset is replaced and its member atoms
1417
- * are added to `skipIds` wholesale. Because exporters deduplicate shared
1418
- * Pset_*Common atoms (e.g. a single IsExternal / IsLoadBearing value referenced
1419
- * by many psets), that wholesale skip can drop an atom another pset still needs.
1420
- * This pass restores any such atom: the edited pset still emits its replacement
1421
- * with the new value, while the shared atom stays for the psets that keep their
1422
- * original value.
1423
- */
1424
- retainSharedAtoms(skipIds, allowedEntityIds) {
1425
- if (skipIds.size === 0)
1426
- return;
1427
- // Built once for the whole sweep rather than per container: the readers in
1428
- // `step-property-sets.ts` take the context, and this loop calls one of them
1429
- // once per IfcPropertySet / IfcElementQuantity in the file.
1430
- const ctx = this.propertySetContext();
1431
- const byType = this.dataStore.entityIndex.byType;
1432
- const containerIds = [
1433
- ...(byType.get('IFCPROPERTYSET') ?? []),
1434
- ...(byType.get('IFCELEMENTQUANTITY') ?? []),
1435
- ];
1436
- for (const containerId of containerIds) {
1437
- // Skipped containers are being dropped/replaced — their atoms may go.
1438
- if (skipIds.has(containerId))
1439
- continue;
1440
- // Under visible-only export a container outside the closure is not emitted,
1441
- // so it cannot keep an atom alive.
1442
- if (allowedEntityIds !== null && !allowedEntityIds.has(containerId))
1443
- continue;
1444
- for (const atomId of getPropertyIdsInSet(ctx, containerId)) {
1445
- skipIds.delete(atomId);
1446
- }
1447
- }
257
+ collectionContext() {
258
+ return {
259
+ dataStore: this.dataStore,
260
+ mutationView: this.mutationView,
261
+ propertySetContext: () => this.propertySetContext(),
262
+ georefContext: (deltaOnly) => this.georefContext(deltaOnly),
263
+ };
1448
264
  }
1449
265
  }
1450
266
  /**