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