@ifc-lite/export 2.9.2 → 2.9.4

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