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