@ifc-lite/export 2.8.1 → 2.8.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/README.md +13 -0
- package/dist/declared-property-type.d.ts +107 -0
- package/dist/declared-property-type.d.ts.map +1 -0
- package/dist/declared-property-type.js +236 -0
- package/dist/declared-property-type.js.map +1 -0
- package/dist/delta-modification-ledger.d.ts +148 -0
- package/dist/delta-modification-ledger.d.ts.map +1 -0
- package/dist/delta-modification-ledger.js +194 -0
- package/dist/delta-modification-ledger.js.map +1 -0
- package/dist/demesh-session.d.ts.map +1 -1
- package/dist/demesh-session.js +7 -0
- package/dist/demesh-session.js.map +1 -1
- package/dist/effective-index.d.ts +16 -1
- package/dist/effective-index.d.ts.map +1 -1
- package/dist/effective-index.js +68 -0
- package/dist/effective-index.js.map +1 -1
- package/dist/glb.d.ts.map +1 -1
- package/dist/glb.js +12 -0
- package/dist/glb.js.map +1 -1
- package/dist/ifc5-exporter.d.ts +29 -3
- package/dist/ifc5-exporter.d.ts.map +1 -1
- package/dist/ifc5-exporter.js +170 -55
- package/dist/ifc5-exporter.js.map +1 -1
- package/dist/in-place-nomination.d.ts +65 -0
- package/dist/in-place-nomination.d.ts.map +1 -0
- package/dist/in-place-nomination.js +27 -0
- package/dist/in-place-nomination.js.map +1 -0
- package/dist/lod1-generator.d.ts.map +1 -1
- package/dist/lod1-generator.js +5 -0
- package/dist/lod1-generator.js.map +1 -1
- package/dist/merged-exporter.d.ts.map +1 -1
- package/dist/merged-exporter.js +15 -6
- package/dist/merged-exporter.js.map +1 -1
- package/dist/parquet-exporter.d.ts +28 -1
- package/dist/parquet-exporter.d.ts.map +1 -1
- package/dist/parquet-exporter.js +144 -13
- package/dist/parquet-exporter.js.map +1 -1
- package/dist/reference-collector.d.ts +4 -4
- package/dist/reference-collector.d.ts.map +1 -1
- package/dist/reference-collector.js +64 -18
- package/dist/reference-collector.js.map +1 -1
- package/dist/retype.d.ts.map +1 -1
- package/dist/retype.js +2 -1
- package/dist/retype.js.map +1 -1
- package/dist/select-qualification.d.ts +12 -0
- package/dist/select-qualification.d.ts.map +1 -1
- package/dist/select-qualification.js +12 -1
- package/dist/select-qualification.js.map +1 -1
- package/dist/source-ref-bounds.d.ts +53 -0
- package/dist/source-ref-bounds.d.ts.map +1 -0
- package/dist/source-ref-bounds.js +25 -0
- package/dist/source-ref-bounds.js.map +1 -0
- package/dist/step-argument-parser.d.ts +90 -0
- package/dist/step-argument-parser.d.ts.map +1 -0
- package/dist/step-argument-parser.js +207 -0
- package/dist/step-argument-parser.js.map +1 -0
- package/dist/step-exporter.d.ts +83 -4
- package/dist/step-exporter.d.ts.map +1 -1
- package/dist/step-exporter.js +647 -129
- package/dist/step-exporter.js.map +1 -1
- package/dist/step-file-assembly.d.ts +35 -0
- package/dist/step-file-assembly.d.ts.map +1 -0
- package/dist/step-file-assembly.js +102 -0
- package/dist/step-file-assembly.js.map +1 -0
- package/dist/step-serialization.d.ts +48 -48
- package/dist/step-serialization.d.ts.map +1 -1
- package/dist/step-serialization.js +90 -182
- package/dist/step-serialization.js.map +1 -1
- package/dist/type-owned-psets.d.ts +40 -2
- package/dist/type-owned-psets.d.ts.map +1 -1
- package/dist/type-owned-psets.js +45 -2
- package/dist/type-owned-psets.js.map +1 -1
- package/dist/unit-normalize.d.ts.map +1 -1
- package/dist/unit-normalize.js +13 -1
- package/dist/unit-normalize.js.map +1 -1
- package/package.json +8 -7
package/dist/step-exporter.js
CHANGED
|
@@ -1,21 +1,65 @@
|
|
|
1
1
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
-
import { EntityExtractor, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
|
|
5
|
-
import { safeUtf8Decode } from '@ifc-lite/data';
|
|
4
|
+
import { asSourceBytes, EntityExtractor, extractQuantitiesOnDemand, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
|
|
6
5
|
import { generateIfcGuid } from '@ifc-lite/encoding';
|
|
7
6
|
import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
|
|
8
7
|
import { convertStepLine, needsConversion } from './schema-converter.js';
|
|
9
8
|
import { retypeStepLine, retypeArgTokens } from './retype.js';
|
|
10
9
|
import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
|
|
10
|
+
import { createModificationLedger, recordSourceLineDelivery, } from './delta-modification-ledger.js';
|
|
11
|
+
import { nominateDeliveredInPlaceEdits } from './in-place-nomination.js';
|
|
12
|
+
import { createSourceRefReader } from './source-ref-bounds.js';
|
|
11
13
|
import { authoredEntityRefs, getEffectiveEntityIndex } from './effective-index.js';
|
|
12
|
-
import { HAS_PROPERTY_SETS_SLOT,
|
|
13
|
-
import { escapeStepString, toStepReal, quantityTypeToIfcType,
|
|
14
|
+
import { HAS_PROPERTY_SETS_SLOT, isTypeClass, resolveTypeOwnedPsetIds, rewriteTypeOwnedPsetLine, typeOwnedPsetRewriteWarning, } from './type-owned-psets.js';
|
|
15
|
+
import { escapeStepString, toStepReal, quantityTypeToIfcType, serializeAttributeValue, serializeStepValue, tokenIsRealLiteral, } from './step-serialization.js';
|
|
16
|
+
import { splitTopLevelArgs } from './step-argument-parser.js';
|
|
17
|
+
import { assembleStepBytes } from './step-file-assembly.js';
|
|
14
18
|
import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
|
|
15
19
|
import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
|
|
16
20
|
import { serializeQualifiedSelectSlot } from './select-qualification.js';
|
|
21
|
+
import { serializeNominalValue } from './declared-property-type.js';
|
|
22
|
+
/**
|
|
23
|
+
* UTF-8 decode of `[start, end)` of the source, accepting either the raw bytes
|
|
24
|
+
* or the {@link IfcSourceBytes} accessor (#2183). Replaces the direct
|
|
25
|
+
* `safeUtf8Decode(source, …)` calls this file used to make: `decodeUtf8` is
|
|
26
|
+
* SAB-safe in exactly the same way, and routing through the accessor is what
|
|
27
|
+
* lets `IfcDataStore.source` change shape without touching these eight reads.
|
|
28
|
+
*/
|
|
29
|
+
function decodeRange(src, start, end) {
|
|
30
|
+
return asSourceBytes(src).decodeUtf8(start, end);
|
|
31
|
+
}
|
|
17
32
|
/** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
|
|
18
33
|
const OWNER_HISTORY_SLOT = 1;
|
|
34
|
+
/**
|
|
35
|
+
* The store the extractor THIS class installed on a view currently reads
|
|
36
|
+
* (#2487). The extractor is installed once per view and closes over this box
|
|
37
|
+
* rather than over a store directly, so a later export of the same view against
|
|
38
|
+
* a different store re-points it instead of answering from the first file.
|
|
39
|
+
*
|
|
40
|
+
* A box, and not a `WeakSet` of views, because ownership has to reflect the
|
|
41
|
+
* CURRENT state and not the historical fact that an export once installed
|
|
42
|
+
* something. `setQuantityExtractor` is public: a caller may install its own
|
|
43
|
+
* afterwards, and a marker saying "the exporter owns this view" would then keep
|
|
44
|
+
* overwriting a caller-supplied base forever. With a box, the second export
|
|
45
|
+
* writes to a box nothing reads any more and never calls the setter again, so
|
|
46
|
+
* the caller's extractor stands. Weak, so it never keeps a session alive.
|
|
47
|
+
*/
|
|
48
|
+
const exporterQuantityBase = new WeakMap();
|
|
49
|
+
/**
|
|
50
|
+
* Message for the one refusal `export()` can report, shared by the returned
|
|
51
|
+
* `stats.warnings` entry and the console line so the two cannot drift.
|
|
52
|
+
*/
|
|
53
|
+
const MAP_CONVERSION_WITHOUT_CONTEXT_WARNING = 'Cannot create IfcMapConversion: no IfcGeometricRepresentationContext is available to reference as SourceCRS. The IfcProjectedCRS is unaffected.';
|
|
54
|
+
/**
|
|
55
|
+
* Message for the refusal `export()` reports when a map conversion is
|
|
56
|
+
* requested but there is no IfcProjectedCRS to attach it to — none was
|
|
57
|
+
* requested and none exists in the file — distinct from
|
|
58
|
+
* {@link MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}, which is worded for the
|
|
59
|
+
* case where an IfcProjectedCRS exists (or was written) but no context is
|
|
60
|
+
* available to reference.
|
|
61
|
+
*/
|
|
62
|
+
const MAP_CONVERSION_WITHOUT_CRS_WARNING = 'Cannot create IfcMapConversion: no IfcProjectedCRS was requested and none exists in the file to reference as TargetCRS. Nothing was written.';
|
|
19
63
|
/**
|
|
20
64
|
* IFC STEP file exporter
|
|
21
65
|
*/
|
|
@@ -45,7 +89,6 @@ export class StepExporter {
|
|
|
45
89
|
export(options) {
|
|
46
90
|
const entities = [];
|
|
47
91
|
let newEntityCount = 0;
|
|
48
|
-
let modifiedEntityCount = 0;
|
|
49
92
|
// Both owner-history caches are per-EXPORT, not per-exporter: they now
|
|
50
93
|
// depend on `willBeEmitted`, which depends on this call's options. Reusing
|
|
51
94
|
// one exporter for a `visibleOnly` export and then a full one would
|
|
@@ -69,7 +112,9 @@ export class StepExporter {
|
|
|
69
112
|
// `sourceHeader`; fall back to parsing the (always-present) source bytes so
|
|
70
113
|
// cache-restored stores — which don't carry `sourceHeader` — still work.
|
|
71
114
|
const sourceHeader = this.dataStore.sourceHeader
|
|
72
|
-
?? (this.dataStore.source
|
|
115
|
+
?? (this.dataStore.source.byteLength > 0
|
|
116
|
+
? parseSourceHeader(this.dataStore.source)
|
|
117
|
+
: undefined);
|
|
73
118
|
// Preserve the exact FILE_SCHEMA identifier (e.g. IFC4X3_ADD2) only when we
|
|
74
119
|
// are NOT converting schemas; conversion must emit the coarse target token.
|
|
75
120
|
const schemaToken = !converting && sourceHeader?.schemaIdentifiers?.[0]
|
|
@@ -115,9 +160,88 @@ export class StepExporter {
|
|
|
115
160
|
// entity has no source bytes, so the source-iteration pass below never sees
|
|
116
161
|
// it and the new-entities pass at the end owns its line entirely (#2006).
|
|
117
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
|
+
if (options.visibleOnly && this.dataStore.source) {
|
|
183
|
+
const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
|
|
184
|
+
allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
|
|
185
|
+
// Second pass: collect IFCSTYLEDITEM entities that reference included
|
|
186
|
+
// geometry. Styled items reference geometry items but nothing references
|
|
187
|
+
// them back, so the forward closure misses them.
|
|
188
|
+
collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
|
|
189
|
+
}
|
|
190
|
+
// Will THIS entity's own line ever land in the file? The same byte-range
|
|
191
|
+
// test `willBeEmitted` uses (defined further below) and the source-
|
|
192
|
+
// iteration pass's own skip at `entityRef.byteLength === 0` — a source
|
|
193
|
+
// entity with no bytes (a point-cloud / GLB "entity" from
|
|
194
|
+
// `createSyntheticDataStore`, not an overlay-created one) never gets a
|
|
195
|
+
// defining line written, source-iteration or otherwise, so a pset/attribute
|
|
196
|
+
// edit against it must not count as a modification either: the header
|
|
197
|
+
// would describe a change the file does not contain (out-of-scope finding
|
|
198
|
+
// in #2398). Also excludes a source-backed host the visible-only closure
|
|
199
|
+
// above drops — same reasoning, different reason the line never lands.
|
|
200
|
+
//
|
|
201
|
+
// And, like `willBeEmitted` below, excludes a geometry-classified SOURCE
|
|
202
|
+
// host under `includeGeometry: false`: the source-iteration pass's own
|
|
203
|
+
// `isGeometryEntity` skip (further below) drops that line too, so this
|
|
204
|
+
// predicate must agree or a geometry entity's attribute edit inflates the
|
|
205
|
+
// count over an omitted line (CodeRabbit finding on #2414). Guarded by
|
|
206
|
+
// `!deltaOnly` for the same reason `willBeEmitted` is: under `deltaOnly`
|
|
207
|
+
// the source-iteration pass — and its geometry skip — never runs at all,
|
|
208
|
+
// so a source entity's line is assumed to already exist in the file being
|
|
209
|
+
// patched, geometry or not.
|
|
210
|
+
const isGeometryExcluded = (entityId, recordType) => options.includeGeometry === false
|
|
211
|
+
&& this.isGeometryEntity(effective.effectiveType(entityId, recordType));
|
|
212
|
+
const hasEmittableHostBytes = (entityId) => {
|
|
213
|
+
if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
|
|
214
|
+
return false;
|
|
215
|
+
const ref = effective.get(entityId);
|
|
216
|
+
// The ref must be READABLE, not merely non-empty: a range this source
|
|
217
|
+
// cannot address decodes to the empty string, which used to be pushed
|
|
218
|
+
// into the file as a blank line while everything generated FOR the host
|
|
219
|
+
// still named it (#2491).
|
|
220
|
+
if (!ref || !isReadableSourceRef(ref))
|
|
221
|
+
return false;
|
|
222
|
+
if (options.deltaOnly !== true && isGeometryExcluded(entityId, ref.type))
|
|
223
|
+
return false;
|
|
224
|
+
return true;
|
|
225
|
+
};
|
|
226
|
+
// Under `deltaOnly` a nomination only becomes a count once some pass has
|
|
227
|
+
// actually written content that delivers THAT KIND of edit for the host —
|
|
228
|
+
// see `delta-modification-ledger.ts` for why the two are not the same event
|
|
229
|
+
// in that mode, and why the pair is (entity, kind) rather than the entity
|
|
230
|
+
// (#2462).
|
|
231
|
+
const modifications = createModificationLedger(options.deltaOnly === true);
|
|
232
|
+
/**
|
|
233
|
+
* Hosts whose in-place named-attribute edits a FULL export may count, per
|
|
234
|
+
* kind. Filled by the collection passes below and read by the two passes
|
|
235
|
+
* that write a rewritten source line — see `in-place-nomination.ts` for why
|
|
236
|
+
* the nomination waits for the rewrite in this mode and not under
|
|
237
|
+
* `deltaOnly` (#2483).
|
|
238
|
+
*/
|
|
239
|
+
const inPlaceNominees = {
|
|
240
|
+
attribute: new Set(),
|
|
241
|
+
georeferencing: new Set(),
|
|
242
|
+
};
|
|
118
243
|
// Collect entities that need to be modified or created
|
|
119
244
|
const modifiedEntities = new Set();
|
|
120
|
-
const modifiedPsets = new Map(); // entityId -> psetNames being modified
|
|
121
245
|
const modifiedAttributes = new Map();
|
|
122
246
|
const newPropertySets = [];
|
|
123
247
|
const newQuantitySets = [];
|
|
@@ -210,14 +334,22 @@ export class StepExporter {
|
|
|
210
334
|
if (effective.isDeleted(entityId))
|
|
211
335
|
continue;
|
|
212
336
|
modifiedEntities.add(entityId);
|
|
213
|
-
modifiedPsets.set(entityId, psetNames);
|
|
214
337
|
// Same rule as the attribute loop below: an overlay-CREATED entity is
|
|
215
338
|
// emitted once, by the new-entities pass, and already counted in
|
|
216
339
|
// `newEntityCount` — as are the pset entities this loop goes on to
|
|
217
340
|
// generate. Only the COUNT is guarded; the entity still records its
|
|
218
341
|
// pset edits and still emits them.
|
|
219
|
-
|
|
220
|
-
|
|
342
|
+
//
|
|
343
|
+
// A NOMINATION, in both modes, never a count on its own: this site sees
|
|
344
|
+
// a pset NAME the session touched, not whether that name resolves to
|
|
345
|
+
// anything. `deletePropertySet(id, 'AName')` on a host that owns no such
|
|
346
|
+
// set reaches here and changes nothing at all, and used to put "1
|
|
347
|
+
// modification" in the header of a byte-identical file (#2474). What
|
|
348
|
+
// settles it is the generator's `recordEmitted` and the skip branches'
|
|
349
|
+
// `recordWithheld` below.
|
|
350
|
+
if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId)) {
|
|
351
|
+
modifications.nominate(entityId, 'property-set');
|
|
352
|
+
}
|
|
221
353
|
// Get the FULL mutated property sets for this entity (merged base + mutations)
|
|
222
354
|
const allPsets = this.mutationView.getForEntity(entityId);
|
|
223
355
|
const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
|
|
@@ -243,6 +375,12 @@ export class StepExporter {
|
|
|
243
375
|
for (const propId of propIds) {
|
|
244
376
|
skipPropertySetIds.add(propId);
|
|
245
377
|
}
|
|
378
|
+
// The other half of "did this edit change the file": a full export
|
|
379
|
+
// applies a set DELETION by leaving these lines out, and produces
|
|
380
|
+
// no replacement content to record an emission for. Without this
|
|
381
|
+
// the count would settle from the generator alone and a real
|
|
382
|
+
// deletion would stop counting along with the no-op one (#2474).
|
|
383
|
+
modifications.recordWithheld(entityId, 'property-set');
|
|
246
384
|
}
|
|
247
385
|
}
|
|
248
386
|
}
|
|
@@ -259,6 +397,12 @@ export class StepExporter {
|
|
|
259
397
|
for (const propId of propIds) {
|
|
260
398
|
skipPropertySetIds.add(propId);
|
|
261
399
|
}
|
|
400
|
+
// No `recordWithheld` twin of the rel-defined branch above, and
|
|
401
|
+
// deliberately: a name that matches an OWNED pset is either dropped
|
|
402
|
+
// from the resolved list or swapped for the replacement this export
|
|
403
|
+
// generated, so slot 5 always comes back different and the repoint
|
|
404
|
+
// below records the emission for it. A second record here would be
|
|
405
|
+
// one no mutation can kill.
|
|
262
406
|
}
|
|
263
407
|
for (const psetName of psetNames) {
|
|
264
408
|
if (!relDefinedPsetNames.has(psetName)) {
|
|
@@ -275,33 +419,134 @@ export class StepExporter {
|
|
|
275
419
|
// Collect modified quantity sets (only if quantities are included)
|
|
276
420
|
if (options.includeQuantities === false)
|
|
277
421
|
entityQuantMutations.clear();
|
|
422
|
+
// A quantity overlay with nothing under it regenerates a source quantity
|
|
423
|
+
// set from the edited quantity ALONE, and the skip loop below then
|
|
424
|
+
// withholds the source lines that held its siblings (#2487). Unlike
|
|
425
|
+
// properties — whose base falls back to the `baseTable` the view was
|
|
426
|
+
// constructed with — quantities have only the opt-in
|
|
427
|
+
// `setQuantityExtractor`, so the default really is an empty base, and
|
|
428
|
+
// four in-tree callers plus every external embedder never set it.
|
|
429
|
+
//
|
|
430
|
+
// The exporter is the one place that always holds the missing half: it
|
|
431
|
+
// was handed the very store the view is an overlay ON. Supplying it here
|
|
432
|
+
// makes the loss impossible for every caller rather than for the callers
|
|
433
|
+
// we happened to find, and a view that resolves its own quantities (the
|
|
434
|
+
// viewer, MCP, the CLI headless backend) is never overwritten.
|
|
435
|
+
//
|
|
436
|
+
// The extractor closes over ONE store, and the view outlives this export.
|
|
437
|
+
// So it closes over a BOX this class owns instead: a second export of the
|
|
438
|
+
// same view against a DIFFERENT store re-points that box rather than
|
|
439
|
+
// reading the first store's quantities, which is the one way "install only
|
|
440
|
+
// when absent" could have answered from the wrong file. The setter is
|
|
441
|
+
// called at most once per view, so a caller that installs its own
|
|
442
|
+
// extractor at any point — before the first export or after it — keeps it.
|
|
443
|
+
//
|
|
444
|
+
// `hasQuantityBase` and `setQuantityExtractor` are probed, like every other
|
|
445
|
+
// optional view capability this class reaches for (`peekNextExpressId`,
|
|
446
|
+
// `getNewEntities`, `getEntityTypeMutation`): `MutablePropertyView` is
|
|
447
|
+
// published API arriving from a separately versioned package, and callers
|
|
448
|
+
// pass partial and duck-typed views. `hasQuantityBase` is newer than
|
|
449
|
+
// `setQuantityExtractor`, and without it there is no way to tell an empty
|
|
450
|
+
// base from a caller-supplied one — so an older view falls back to the
|
|
451
|
+
// pre-#2487 behaviour (no base supplied) rather than risk overwriting one.
|
|
452
|
+
const quantityView = this.mutationView;
|
|
453
|
+
if (entityQuantMutations.size > 0 &&
|
|
454
|
+
typeof quantityView.setQuantityExtractor === 'function' &&
|
|
455
|
+
typeof quantityView.hasQuantityBase === 'function') {
|
|
456
|
+
const installed = exporterQuantityBase.get(quantityView);
|
|
457
|
+
if (installed) {
|
|
458
|
+
// Ours, or a caller's that replaced ours: re-pointing the box is a
|
|
459
|
+
// no-op in the second case, and calling the setter again is what
|
|
460
|
+
// would not be.
|
|
461
|
+
installed.store = this.dataStore;
|
|
462
|
+
}
|
|
463
|
+
else if (!quantityView.hasQuantityBase()) {
|
|
464
|
+
const box = { store: this.dataStore };
|
|
465
|
+
exporterQuantityBase.set(quantityView, box);
|
|
466
|
+
quantityView.setQuantityExtractor((id) => extractQuantitiesOnDemand(box.store, id));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
278
469
|
for (const [entityId, qsetNames] of entityQuantMutations) {
|
|
279
470
|
// Same rule as the property loop above: a deleted entity removes nothing.
|
|
280
471
|
if (effective.isDeleted(entityId))
|
|
281
472
|
continue;
|
|
282
473
|
modifiedEntities.add(entityId);
|
|
283
474
|
// See the property loop above — an overlay-created entity is counted as
|
|
284
|
-
// new, not modified.
|
|
285
|
-
|
|
286
|
-
|
|
475
|
+
// new, not modified. The pset loop's own nomination no longer has to be
|
|
476
|
+
// excluded to avoid a double count: the ledger settles per ENTITY, so a
|
|
477
|
+
// host with both a pset and a qset edit counts once whatever is
|
|
478
|
+
// nominated. Nominating both buys the opposite — an accurate warning
|
|
479
|
+
// when the qset half is the half a delta cannot carry.
|
|
480
|
+
//
|
|
481
|
+
// Settled from effect like its property-set twin (#2474). The reachable
|
|
482
|
+
// no-op here is an UNDONE quantity-set creation whose name matches NO
|
|
483
|
+
// source set: `getMutations()` is append-only, so the `CREATE_QUANTITY`
|
|
484
|
+
// record still names the qset after `removeQuantityMutation` has taken
|
|
485
|
+
// it out of the overlay, and the generator below then finds nothing to
|
|
486
|
+
// write. The same undo against a COLLIDING name is not a no-op — it
|
|
487
|
+
// withholds the source set's lines — which is what the skip loop's
|
|
488
|
+
// `recordWithheld` below settles.
|
|
489
|
+
if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId)) {
|
|
490
|
+
modifications.nominate(entityId, 'quantity-set');
|
|
491
|
+
}
|
|
287
492
|
const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
|
|
288
493
|
const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
|
|
289
494
|
if (relevantQsets.length > 0) {
|
|
290
495
|
newQuantitySets.push({ entityId, qsets: relevantQsets });
|
|
291
496
|
}
|
|
497
|
+
// The names this export is actually WRITING a replacement for. The
|
|
498
|
+
// affected-name set is not the same thing: it comes from the session's
|
|
499
|
+
// append-only mutation history, which keeps naming a quantity set after
|
|
500
|
+
// an undo has taken it back out of the overlay, so a Ctrl+Z used to
|
|
501
|
+
// withhold a source `IfcElementQuantity` that nothing regenerated. There
|
|
502
|
+
// is no quantity-set REMOVAL to preserve here — `deletedQsets` has no
|
|
503
|
+
// public populator, so withholding without a replacement is always the
|
|
504
|
+
// bug and never the intent (#2487).
|
|
505
|
+
const regeneratedQsetNames = new Set(relevantQsets.map((qset) => qset.name));
|
|
292
506
|
// Skip original quantity set entities (IfcElementQuantity).
|
|
293
507
|
// Same per-entity index lookup as the property branch above.
|
|
294
508
|
const rels = relDefinesByEntity.get(entityId);
|
|
295
509
|
if (rels) {
|
|
296
510
|
for (const { relId, psetId: relatedPsetId } of rels) {
|
|
297
511
|
const qsetName = this.getElementQuantityName(relatedPsetId);
|
|
298
|
-
if (qsetName &&
|
|
512
|
+
if (qsetName && regeneratedQsetNames.has(qsetName)) {
|
|
299
513
|
skipRelationshipIds.add(relId);
|
|
300
514
|
skipPropertySetIds.add(relatedPsetId);
|
|
301
515
|
const quantIds = this.getPropertyIdsInSet(relatedPsetId);
|
|
302
516
|
for (const quantId of quantIds) {
|
|
303
517
|
skipPropertySetIds.add(quantId);
|
|
304
518
|
}
|
|
519
|
+
// The withheld half, exactly as the rel-defined property branch
|
|
520
|
+
// above. This loop has just decided that #`relatedPsetId`, its
|
|
521
|
+
// quantity atoms and the relationship that attached them do NOT
|
|
522
|
+
// go into the file; whether anything is generated to take their
|
|
523
|
+
// place is decided elsewhere, and is not this branch's to assume.
|
|
524
|
+
//
|
|
525
|
+
// It IS assumable for the pset side and not here, and the
|
|
526
|
+
// difference is where the two read their base from.
|
|
527
|
+
// `getForEntity` merges the overlay over the base pset walk, so a
|
|
528
|
+
// name the session touched but did not change still resolves to
|
|
529
|
+
// source content and is regenerated.
|
|
530
|
+
// `getQuantitiesForEntity` merges the overlay over
|
|
531
|
+
// `quantityExtractor`, which is OPT-IN: it defaults to null, and
|
|
532
|
+
// several in-tree callers wire the property extractor beside it
|
|
533
|
+
// and not it (`cli/commands/mutate.ts`, `gym.ts`,
|
|
534
|
+
// `generate-spaces.ts`, `export/demesh-session.ts`), as does any
|
|
535
|
+
// external embedder of these two published packages. With no
|
|
536
|
+
// extractor the base is empty and the overlay is the only source,
|
|
537
|
+
// so a qset the overlay no longer holds resolves to nothing.
|
|
538
|
+
//
|
|
539
|
+
// Which makes this reachable through an UNDONE quantity-set
|
|
540
|
+
// creation whose name COLLIDES with a source set:
|
|
541
|
+
// `setQuantity(id, 'Qto_WallBaseQuantities', ...)` followed by the
|
|
542
|
+
// `removeQuantityMutation` that mutationSlice runs on Ctrl+Z. The
|
|
543
|
+
// append-only history still names the qset, so this branch
|
|
544
|
+
// withholds the source lines; the overlay is empty again, so
|
|
545
|
+
// nothing is regenerated. The export drops the source quantity set
|
|
546
|
+
// — a real change to the file, and a data-loss bug of its own
|
|
547
|
+
// (#2487) — and this call is what stops the count from calling it
|
|
548
|
+
// nothing.
|
|
549
|
+
modifications.recordWithheld(entityId, 'quantity-set');
|
|
305
550
|
}
|
|
306
551
|
}
|
|
307
552
|
}
|
|
@@ -313,17 +558,51 @@ export class StepExporter {
|
|
|
313
558
|
// one created-then-renamed wall.
|
|
314
559
|
if (isOverlayCreated(entityId))
|
|
315
560
|
continue;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
561
|
+
// A source entity with no bytes never gets its line rewritten (the
|
|
562
|
+
// source-iteration pass skips it), so an attribute edit against it
|
|
563
|
+
// must not inflate the count either.
|
|
564
|
+
if (!hasEmittableHostBytes(entityId))
|
|
565
|
+
continue;
|
|
566
|
+
// Under `deltaOnly` this only NOMINATES the host's ATTRIBUTE edits:
|
|
567
|
+
// nothing writes an in-place attribute edit into a delta except the
|
|
568
|
+
// type-object line rewrite, so the ledger drops it at settle time
|
|
569
|
+
// unless that pass reports having carried it (#2462). That nomination
|
|
570
|
+
// is deliberately made at INTENT: the per-kind warning exists to NAME
|
|
571
|
+
// an edit the delta could not carry, and an undeliverable edit is
|
|
572
|
+
// exactly the one that must still be named.
|
|
573
|
+
//
|
|
574
|
+
// A FULL export has no such warning, so an edit that resolved to
|
|
575
|
+
// nothing has nothing to say and nothing to claim — it waits for the
|
|
576
|
+
// rewrite instead. `setAttribute` to the value already in the slot, and
|
|
577
|
+
// `setAttribute` naming a slot the class does not declare, both leave
|
|
578
|
+
// the line byte-identical and used to count anyway (#2483).
|
|
579
|
+
//
|
|
580
|
+
// Recorded unconditionally. It used to be skipped for a host that also
|
|
581
|
+
// had a pset or qset edit, because the count was per entity and the
|
|
582
|
+
// other loop had already nominated it — which is exactly what let a
|
|
583
|
+
// pset emission mark the rename delivered and suppress its warning. The
|
|
584
|
+
// ledger de-duplicates the COUNT per entity now, so the two edits can
|
|
585
|
+
// and must be nominated separately.
|
|
586
|
+
inPlaceNominees.attribute.add(entityId);
|
|
587
|
+
if (options.deltaOnly === true)
|
|
588
|
+
modifications.nominate(entityId, 'attribute');
|
|
319
589
|
}
|
|
320
590
|
}
|
|
321
591
|
// Process georeferencing mutations (only when applyMutations is enabled)
|
|
322
592
|
const newGeorefLines = [];
|
|
593
|
+
const warnings = [];
|
|
323
594
|
if (options.applyMutations !== false && options.georefMutations) {
|
|
324
595
|
const gm = options.georefMutations;
|
|
325
|
-
|
|
326
|
-
|
|
596
|
+
// `effective.byType`, not the raw index: a source IfcProjectedCRS the
|
|
597
|
+
// session tombstoned is still in `dataStore.entityIndex`, so the modify
|
|
598
|
+
// branch below would queue attribute edits against an id the
|
|
599
|
+
// source-iteration pass then skips — the replacement georeferencing
|
|
600
|
+
// vanishes from the file with no error. `effective.byType` drops
|
|
601
|
+
// tombstones and adds overlay-created records, which the new-entities
|
|
602
|
+
// pass applies `modifiedAttributes` to, so both branches agree on which
|
|
603
|
+
// georeferencing entities exist (#2048).
|
|
604
|
+
const existingCrsIds = effective.byType.get('IFCPROJECTEDCRS');
|
|
605
|
+
const existingMcIds = effective.byType.get('IFCMAPCONVERSION');
|
|
327
606
|
// Modify existing IfcProjectedCRS
|
|
328
607
|
if (gm.projectedCRS && existingCrsIds?.length) {
|
|
329
608
|
const entityId = existingCrsIds[0];
|
|
@@ -358,13 +637,29 @@ export class StepExporter {
|
|
|
358
637
|
changed = true;
|
|
359
638
|
}
|
|
360
639
|
if (crs.mapUnit !== undefined) {
|
|
361
|
-
const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines);
|
|
640
|
+
const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective);
|
|
362
641
|
attrMap.set('MapUnit', `#${mapUnitRef}`);
|
|
363
642
|
changed = true;
|
|
364
643
|
}
|
|
365
|
-
if (changed
|
|
644
|
+
if (changed) {
|
|
366
645
|
modifiedEntities.add(entityId);
|
|
367
|
-
|
|
646
|
+
// Queued as attribute edits, which only the source-iteration pass
|
|
647
|
+
// writes — so under `deltaOnly` this nominates and settle decides.
|
|
648
|
+
// Recorded even when the host is already in `modifiedEntities`: that
|
|
649
|
+
// guard existed to stop a second COUNT, which the ledger now handles
|
|
650
|
+
// per entity, and suppressing the nomination would hide a dropped
|
|
651
|
+
// georeferencing edit behind an unrelated edit to the same record.
|
|
652
|
+
//
|
|
653
|
+
// `changed` above is INTENT — a field was supplied, not a field that
|
|
654
|
+
// differs from the one in the file. Writing `name: 'EPSG:2056'` over
|
|
655
|
+
// an IfcProjectedCRS already named `EPSG:2056` leaves the line
|
|
656
|
+
// byte-identical, so a full export waits for the rewrite exactly as
|
|
657
|
+
// the plain attribute site does (#2483).
|
|
658
|
+
if (hasEmittableHostBytes(entityId)) {
|
|
659
|
+
inPlaceNominees.georeferencing.add(entityId);
|
|
660
|
+
if (options.deltaOnly === true)
|
|
661
|
+
modifications.nominate(entityId, 'georeferencing');
|
|
662
|
+
}
|
|
368
663
|
}
|
|
369
664
|
}
|
|
370
665
|
// Modify existing IfcMapConversion
|
|
@@ -400,9 +695,14 @@ export class StepExporter {
|
|
|
400
695
|
attrMap.set('Scale', String(mc.scale));
|
|
401
696
|
changed = true;
|
|
402
697
|
}
|
|
403
|
-
if (changed
|
|
698
|
+
if (changed) {
|
|
404
699
|
modifiedEntities.add(entityId);
|
|
405
|
-
|
|
700
|
+
// Same as the IfcProjectedCRS branch above, effect gate included.
|
|
701
|
+
if (hasEmittableHostBytes(entityId)) {
|
|
702
|
+
inPlaceNominees.georeferencing.add(entityId);
|
|
703
|
+
if (options.deltaOnly === true)
|
|
704
|
+
modifications.nominate(entityId, 'georeferencing');
|
|
705
|
+
}
|
|
406
706
|
}
|
|
407
707
|
}
|
|
408
708
|
// CREATE new georef entities when file has none
|
|
@@ -417,12 +717,12 @@ export class StepExporter {
|
|
|
417
717
|
const proj = crs.mapProjection ? `'${escapeStepString(String(crs.mapProjection))}'` : '$';
|
|
418
718
|
const zone = crs.mapZone ? `'${escapeStepString(String(crs.mapZone))}'` : '$';
|
|
419
719
|
const mapUnitRef = crs.mapUnit
|
|
420
|
-
? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines)}`
|
|
720
|
+
? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective)}`
|
|
421
721
|
: '$';
|
|
422
722
|
newGeorefLines.push(`#${crsId}=IFCPROJECTEDCRS(${name},${desc},${datum},${vDatum},${proj},${zone},${mapUnitRef});`);
|
|
423
723
|
newEntityCount++;
|
|
424
724
|
// Find IfcGeometricRepresentationContext as SourceCRS for MapConversion
|
|
425
|
-
const contextId = this.findPreferredGeometricRepresentationContextId();
|
|
725
|
+
const contextId = this.findPreferredGeometricRepresentationContextId(effective);
|
|
426
726
|
if (contextId) {
|
|
427
727
|
const mc = gm.mapConversion || {};
|
|
428
728
|
const mcId = this.nextExpressId++;
|
|
@@ -437,12 +737,12 @@ export class StepExporter {
|
|
|
437
737
|
newEntityCount++;
|
|
438
738
|
}
|
|
439
739
|
else {
|
|
440
|
-
|
|
740
|
+
this.reportMapConversionRefused(warnings);
|
|
441
741
|
}
|
|
442
742
|
}
|
|
443
743
|
else if (gm.mapConversion && !existingMcIds?.length && existingCrsIds?.length) {
|
|
444
744
|
// CRS exists but no MapConversion — create just the conversion
|
|
445
|
-
const contextId = this.findPreferredGeometricRepresentationContextId();
|
|
745
|
+
const contextId = this.findPreferredGeometricRepresentationContextId(effective);
|
|
446
746
|
if (contextId) {
|
|
447
747
|
const mc = gm.mapConversion;
|
|
448
748
|
const mcId = this.nextExpressId++;
|
|
@@ -456,9 +756,17 @@ export class StepExporter {
|
|
|
456
756
|
newEntityCount++;
|
|
457
757
|
}
|
|
458
758
|
else {
|
|
459
|
-
|
|
759
|
+
this.reportMapConversionRefused(warnings);
|
|
460
760
|
}
|
|
461
761
|
}
|
|
762
|
+
else if (gm.mapConversion && !existingMcIds?.length && !existingCrsIds?.length) {
|
|
763
|
+
// A map conversion was requested, but there is no IfcProjectedCRS to
|
|
764
|
+
// reference as TargetCRS: none was requested (the first branch above
|
|
765
|
+
// didn't fire) and none exists in the file. Both CREATE branches are
|
|
766
|
+
// skipped, so nothing is attempted — report the refusal so the
|
|
767
|
+
// caller isn't left with an empty stats.warnings and no hint (#2105).
|
|
768
|
+
this.reportMapConversionRefusedNoCrs(warnings);
|
|
769
|
+
}
|
|
462
770
|
}
|
|
463
771
|
// If delta only, only export modified entities. Overlay-created entities
|
|
464
772
|
// also count — without this, `createEntity()`-only edits would silently
|
|
@@ -480,25 +788,10 @@ export class StepExporter {
|
|
|
480
788
|
newEntityCount: 0,
|
|
481
789
|
modifiedEntityCount: 0,
|
|
482
790
|
fileSize: emptyContent.byteLength,
|
|
791
|
+
warnings,
|
|
483
792
|
},
|
|
484
793
|
};
|
|
485
794
|
}
|
|
486
|
-
// Build visible-only closure if requested. Classification, the closure walk
|
|
487
|
-
// and the style pass all run over the EFFECTIVE index: an overlay-created
|
|
488
|
-
// product becomes a root by the same type rules as a parsed one, the walk
|
|
489
|
-
// follows its authored references into the geometry it alone owns, and a
|
|
490
|
-
// tombstoned entity is simply not there. Run over the source buffer, a
|
|
491
|
-
// created wall could never be a root and nothing referenced it, so
|
|
492
|
-
// `visibleOnly` wrote a file without it and said nothing (#2012).
|
|
493
|
-
let allowedEntityIds = null;
|
|
494
|
-
if (options.visibleOnly && this.dataStore.source) {
|
|
495
|
-
const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
|
|
496
|
-
allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
|
|
497
|
-
// Second pass: collect IFCSTYLEDITEM entities that reference included
|
|
498
|
-
// geometry. Styled items reference geometry items but nothing references
|
|
499
|
-
// them back, so the forward closure misses them.
|
|
500
|
-
collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
|
|
501
|
-
}
|
|
502
795
|
/**
|
|
503
796
|
* Will this id have a defining STEP line in the output at all?
|
|
504
797
|
*
|
|
@@ -540,7 +833,24 @@ export class StepExporter {
|
|
|
540
833
|
return false;
|
|
541
834
|
// An overlay-created record carries the placeholder byte range and is
|
|
542
835
|
// written by the new-entities pass; a source record needs real bytes.
|
|
543
|
-
|
|
836
|
+
if (effective.isOverlayCreated(entityId)) {
|
|
837
|
+
// The overlay new-entities pass applies its OWN `isGeometryEntity`
|
|
838
|
+
// filter unconditionally — deltaOnly or not (see the comment at that
|
|
839
|
+
// loop, further below) — so this branch mirrors it without the
|
|
840
|
+
// deltaOnly carve-out the source branch gets.
|
|
841
|
+
return !isGeometryExcluded(entityId, ref.type);
|
|
842
|
+
}
|
|
843
|
+
// Same readability test as `hasEmittableHostBytes`, and for the reason
|
|
844
|
+
// that predicate names: a ref this source cannot address is not a line
|
|
845
|
+
// this export can write, so nothing may be generated naming it (#2491).
|
|
846
|
+
if (!isReadableSourceRef(ref))
|
|
847
|
+
return false;
|
|
848
|
+
// Mirrors `hasEmittableHostBytes`: under `deltaOnly` the source-
|
|
849
|
+
// iteration pass — and its geometry skip — never runs, so a source
|
|
850
|
+
// entity's line is assumed to already exist in the file being patched.
|
|
851
|
+
if (options.deltaOnly === true)
|
|
852
|
+
return true;
|
|
853
|
+
return !isGeometryExcluded(entityId, ref.type);
|
|
544
854
|
};
|
|
545
855
|
// A modified pset is replaced wholesale, which skips ALL of its member atoms.
|
|
546
856
|
// But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
|
|
@@ -555,8 +865,12 @@ export class StepExporter {
|
|
|
555
865
|
// dropped everything the overlay tombstoned, so there is no separate
|
|
556
866
|
// deleted check to forget here.
|
|
557
867
|
for (const [expressId, entityRef] of effective) {
|
|
558
|
-
// Skip overlay-only entities — emitted by the new-entities pass below
|
|
559
|
-
|
|
868
|
+
// Skip overlay-only entities — emitted by the new-entities pass below.
|
|
869
|
+
// A ref this source cannot address is skipped by the same test rather
|
|
870
|
+
// than decoded: `decodeUtf8` clamps such a range and the empty string
|
|
871
|
+
// it returns used to be pushed into the file as a blank line, leaving
|
|
872
|
+
// every generated record that names the host dangling (#2491).
|
|
873
|
+
if (!isReadableSourceRef(entityRef)) {
|
|
560
874
|
continue;
|
|
561
875
|
}
|
|
562
876
|
// Skip entities outside the visible closure
|
|
@@ -577,47 +891,34 @@ export class StepExporter {
|
|
|
577
891
|
if (options.includeGeometry === false && this.isGeometryEntity(entityType)) {
|
|
578
892
|
continue;
|
|
579
893
|
}
|
|
580
|
-
// Get original entity text —
|
|
894
|
+
// Get original entity text — decodeRange handles SAB-backed
|
|
581
895
|
// sources (Firefox/Chrome reject `TextDecoder.decode()` on a
|
|
582
896
|
// SharedArrayBuffer-backed view; the parser deliberately keeps
|
|
583
897
|
// `source` zero-copy SAB-backed for worker sharing).
|
|
584
|
-
const entityText =
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (modifiedAttributes.has(expressId)) {
|
|
609
|
-
nextEntityText = this.applyAttributeMutations(nextEntityText, workingType, modifiedAttributes.get(expressId));
|
|
610
|
-
}
|
|
611
|
-
const positional = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
|
|
612
|
-
? this.mutationView.getPositionalMutationsForEntity(expressId)
|
|
613
|
-
: null;
|
|
614
|
-
if (positional && positional.size > 0) {
|
|
615
|
-
nextEntityText = this.applyPositionalMutations(nextEntityText, positional, workingType, sourceSchema);
|
|
616
|
-
if (!modifiedEntities.has(expressId)) {
|
|
617
|
-
modifiedEntities.add(expressId);
|
|
618
|
-
modifiedEntityCount++;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
898
|
+
const entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
899
|
+
// Retype, named attribute edits and positional edits, in that order.
|
|
900
|
+
// Shared verbatim with the type-object `HasPropertySets` rewrite below,
|
|
901
|
+
// which writes the line this pass would otherwise have written.
|
|
902
|
+
const mutated = this.applySourceLineMutations(expressId, entityText, entityRef.type, modifiedAttributes.get(expressId), sourceSchema, overlayActive);
|
|
903
|
+
const nextEntityText = mutated.text;
|
|
904
|
+
// A retype or a positional edit that CHANGED the line is what makes
|
|
905
|
+
// this entity count; a named attribute edit was already nominated by
|
|
906
|
+
// the collection pass. Both flags report effect, so retyping an entity
|
|
907
|
+
// to the class it already is — or writing a slot the token it already
|
|
908
|
+
// holds — no longer claims a modification over a line the export left
|
|
909
|
+
// byte-identical. This pass is full-export-only (`deltaOnly` skips it
|
|
910
|
+
// wholesale), so nomination IS emission here and the kinds only have to
|
|
911
|
+
// be right for the entity count — which is per entity, hence unchanged.
|
|
912
|
+
if (mutated.retyped || mutated.positional)
|
|
913
|
+
modifiedEntities.add(expressId);
|
|
914
|
+
if (mutated.retyped)
|
|
915
|
+
modifications.nominate(expressId, 'retype');
|
|
916
|
+
if (mutated.positional)
|
|
917
|
+
modifications.nominate(expressId, 'positional');
|
|
918
|
+
// The named-attribute kinds join them here rather than at their
|
|
919
|
+
// collection sites, for the same reason and on the same signal (#2483).
|
|
920
|
+
// This pass is full-export-only, so there is nothing to gate.
|
|
921
|
+
nominateDeliveredInPlaceEdits(modifications, expressId, mutated, inPlaceNominees);
|
|
621
922
|
// Apply schema conversion if exporting to a different schema version
|
|
622
923
|
if (converting) {
|
|
623
924
|
const converted = convertStepLine(nextEntityText, sourceSchema, schema, options.guidRandom);
|
|
@@ -638,9 +939,13 @@ export class StepExporter {
|
|
|
638
939
|
// see `willBeEmitted` (#1978, #2030, #2012).
|
|
639
940
|
if (!willBeEmitted(entityId))
|
|
640
941
|
continue;
|
|
641
|
-
const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
|
|
942
|
+
const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
|
|
642
943
|
entities.push(...newEntities.lines);
|
|
643
944
|
newEntityCount += newEntities.count;
|
|
945
|
+
// Replacement content for this host actually landed, so a delta really
|
|
946
|
+
// does carry its PROPERTY-SET modification — and only that one (#2462).
|
|
947
|
+
if (newEntities.lines.length > 0)
|
|
948
|
+
modifications.recordEmitted(entityId, 'property-set');
|
|
644
949
|
generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
|
|
645
950
|
}
|
|
646
951
|
// Point every affected type object's HasPropertySets at the psets this
|
|
@@ -659,9 +964,108 @@ export class StepExporter {
|
|
|
659
964
|
overlayTypeOwnedPsets.set(entityId, resolved.length > 0 ? resolved.map((id) => `#${id}`) : null);
|
|
660
965
|
continue;
|
|
661
966
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
967
|
+
// This line REPLACES the one the source-iteration pass would have
|
|
968
|
+
// written — `rewrittenEntityIds` makes that pass skip the entity — so it
|
|
969
|
+
// has to carry the entity's other edits too, and it has to apply them
|
|
970
|
+
// the way that pass does. It used to replace slot 5 and nothing else,
|
|
971
|
+
// which dropped the rename in `setAttribute(id,'Name',…)` +
|
|
972
|
+
// `addPropertySet(id,…)`, and then, once renames were special-cased
|
|
973
|
+
// here, still dropped retypes and positional edits — same line, same
|
|
974
|
+
// silence. So run the ONE pipeline both passes share and replace
|
|
975
|
+
// `HasPropertySets` on its output. Order matters: see
|
|
976
|
+
// {@link applySourceLineMutations}.
|
|
977
|
+
const record = effective.get(entityId);
|
|
978
|
+
let sourceLine = null;
|
|
979
|
+
let mutated = null;
|
|
980
|
+
// One narrowed block for both calls: `record` is in scope for the decode
|
|
981
|
+
// AND for the record type below, with no non-null assertion to keep true
|
|
982
|
+
// by hand. `byteOffset >= 0` is the same "are there real source bytes"
|
|
983
|
+
// test the source-iteration pass makes — an overlay-authored record
|
|
984
|
+
// carries `-1` there, and decoding from it would read another entity's
|
|
985
|
+
// bytes rather than fall through to the no-source-bytes branch.
|
|
986
|
+
// `isReadableSourceRef` folds in the `byteOffset >= 0 && byteLength > 0`
|
|
987
|
+
// test this used to make by hand, and adds the bound the invariant used
|
|
988
|
+
// to supply (#2491).
|
|
989
|
+
if (record && isReadableSourceRef(record)) {
|
|
990
|
+
sourceLine = decodeRange(this.dataStore.source, record.byteOffset, record.byteOffset + record.byteLength);
|
|
991
|
+
// The RECORD's class is the from-type: the bytes are still the source
|
|
992
|
+
// class, whatever `typeOf` now says the entity effectively is.
|
|
993
|
+
mutated = this.applySourceLineMutations(entityId, sourceLine, record.type, modifiedAttributes.get(entityId), sourceSchema, overlayActive);
|
|
994
|
+
}
|
|
995
|
+
if (mutated === null) {
|
|
996
|
+
// `willBeEmitted` already required real source bytes for a non-overlay
|
|
997
|
+
// record, so this is only reachable with no source buffer at all —
|
|
998
|
+
// in which case the source-iteration pass never ran either and there is
|
|
999
|
+
// nothing to lose. Say it anyway; the pset edit is still going nowhere.
|
|
1000
|
+
warnings.push(typeOwnedPsetRewriteWarning(entityId, 'no-source-bytes'));
|
|
1001
|
+
// The line above IS the report, so the ledger must not add a second,
|
|
1002
|
+
// vaguer one blaming the delta format for a drop the format did not
|
|
1003
|
+
// cause.
|
|
1004
|
+
modifications.acknowledgeUndelivered(entityId, 'property-set');
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
const { line, repointed } = rewriteTypeOwnedPsetLine(mutated.text, resolved);
|
|
1008
|
+
if (repointed) {
|
|
1009
|
+
// A repoint that resolves to the list the line ALREADY names changes
|
|
1010
|
+
// nothing, and it is reachable: deleting a pset name the type object
|
|
1011
|
+
// does not own leaves every original id in place (it is "affected" but
|
|
1012
|
+
// matches none of them) and generates no replacement, so slot 5 comes
|
|
1013
|
+
// back byte-identical. Same rule as the fallback branch below — an
|
|
1014
|
+
// unchanged line has no place in a delta, and claiming it delivered the
|
|
1015
|
+
// edit would put a modification in the header over a line that carries
|
|
1016
|
+
// none. A FULL export still emits it: `rewrittenEntityIds` made the
|
|
1017
|
+
// source-iteration pass skip this entity, so withholding the line there
|
|
1018
|
+
// would delete the record from the file (#2469).
|
|
1019
|
+
const changed = line !== sourceLine;
|
|
1020
|
+
if (options.deltaOnly !== true || changed) {
|
|
1021
|
+
rewrittenEntityLines.set(entityId, line);
|
|
1022
|
+
}
|
|
1023
|
+
// A rewritten source line IS in the delta — the one in-place change a
|
|
1024
|
+
// delta does carry today (#2462). The repoint itself delivers the
|
|
1025
|
+
// property-set edit that put this host in the loop; the rest of the
|
|
1026
|
+
// line delivers whichever in-place edits the pipeline applied to it.
|
|
1027
|
+
if (changed) {
|
|
1028
|
+
modifications.recordEmitted(entityId, 'property-set');
|
|
1029
|
+
recordSourceLineDelivery(modifications, entityId, mutated);
|
|
1030
|
+
// `rewrittenEntityIds` made the source-iteration pass skip this
|
|
1031
|
+
// host, so this line is the ONLY place a full export can see its
|
|
1032
|
+
// named-attribute edits land — per site, not per feature (#2483).
|
|
1033
|
+
nominateDeliveredInPlaceEdits(modifications, entityId, mutated, inPlaceNominees);
|
|
1034
|
+
}
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
// A malformed source line — too few arguments to have a slot 5, or not
|
|
1038
|
+
// parseable as a STEP record at all. The entity must still come out:
|
|
1039
|
+
// `rewrittenEntityIds` made the source-iteration pass skip it, so
|
|
1040
|
+
// dropping the line here deletes the whole record from the file (#2469).
|
|
1041
|
+
warnings.push(typeOwnedPsetRewriteWarning(entityId, 'unparseable-line'));
|
|
1042
|
+
// Same as the `no-source-bytes` branch: the property-set edit is
|
|
1043
|
+
// genuinely undelivered — the repoint is what would have delivered it and
|
|
1044
|
+
// it did not happen — but this warning already says so, precisely, so the
|
|
1045
|
+
// ledger stays quiet about that pair rather than duplicating it. (When
|
|
1046
|
+
// the affected psets produced replacement content, the property-set pass
|
|
1047
|
+
// above has already recorded the emission, and an emission outranks an
|
|
1048
|
+
// acknowledgement.)
|
|
1049
|
+
modifications.acknowledgeUndelivered(entityId, 'property-set');
|
|
1050
|
+
// `line` is byte-for-byte what the source-iteration pass would have
|
|
1051
|
+
// written, so emit it wherever that pass would have run. Under
|
|
1052
|
+
// `deltaOnly` it does not run, and a line the mutation pipeline left
|
|
1053
|
+
// identical to its source is not a change — it has no place in a delta.
|
|
1054
|
+
const changed = line !== sourceLine;
|
|
1055
|
+
if (options.deltaOnly !== true || changed) {
|
|
1056
|
+
rewrittenEntityLines.set(entityId, line);
|
|
1057
|
+
}
|
|
1058
|
+
// The ledger stays honest about WHICH modification landed: the
|
|
1059
|
+
// property-set edit that nominated this host is the thing that just
|
|
1060
|
+
// failed, so only the entity's OTHER edits are in this line. Under the
|
|
1061
|
+
// per-kind keying that comes out as `attribute/retype/positional:
|
|
1062
|
+
// delivered, property-set: undelivered` — the host still counts once,
|
|
1063
|
+
// because a real change of its did land.
|
|
1064
|
+
if (changed) {
|
|
1065
|
+
recordSourceLineDelivery(modifications, entityId, mutated);
|
|
1066
|
+
// Same site rule as the repoint branch above: the failed repoint is
|
|
1067
|
+
// what did not land, and the line still carries the host's OTHER edits.
|
|
1068
|
+
nominateDeliveredInPlaceEdits(modifications, entityId, mutated, inPlaceNominees);
|
|
665
1069
|
}
|
|
666
1070
|
}
|
|
667
1071
|
// Generate new quantity entities for mutations
|
|
@@ -671,6 +1075,8 @@ export class StepExporter {
|
|
|
671
1075
|
const newEntities = this.generateQuantitySetEntities(entityId, qsets, willBeEmitted, options.guidRandom);
|
|
672
1076
|
entities.push(...newEntities.lines);
|
|
673
1077
|
newEntityCount += newEntities.count;
|
|
1078
|
+
if (newEntities.lines.length > 0)
|
|
1079
|
+
modifications.recordEmitted(entityId, 'quantity-set');
|
|
674
1080
|
}
|
|
675
1081
|
for (const rewrittenLine of rewrittenEntityLines.values()) {
|
|
676
1082
|
entities.push(rewrittenLine);
|
|
@@ -761,6 +1167,13 @@ export class StepExporter {
|
|
|
761
1167
|
}
|
|
762
1168
|
}
|
|
763
1169
|
}
|
|
1170
|
+
// Settle the count against what the passes above actually wrote, and say
|
|
1171
|
+
// out loud every KIND of edit a delta could not carry, per host. Silence
|
|
1172
|
+
// was the other half of #2462: `deltaOnly` skips the source-iteration pass,
|
|
1173
|
+
// so an in-place edit to a source entity is not in the file and never was —
|
|
1174
|
+
// the header merely used to claim otherwise.
|
|
1175
|
+
const { modifiedEntityCount, warnings: deltaWarnings } = modifications.settle();
|
|
1176
|
+
warnings.push(...deltaWarnings);
|
|
764
1177
|
// Assemble final file as Uint8Array chunks to avoid V8 string length limit.
|
|
765
1178
|
// The header is built last so its provenance item reflects the real count.
|
|
766
1179
|
const header = buildHeader(newEntityCount + modifiedEntityCount);
|
|
@@ -772,6 +1185,7 @@ export class StepExporter {
|
|
|
772
1185
|
newEntityCount,
|
|
773
1186
|
modifiedEntityCount,
|
|
774
1187
|
fileSize: content.byteLength,
|
|
1188
|
+
warnings,
|
|
775
1189
|
},
|
|
776
1190
|
};
|
|
777
1191
|
}
|
|
@@ -886,8 +1300,11 @@ export class StepExporter {
|
|
|
886
1300
|
return result;
|
|
887
1301
|
}
|
|
888
1302
|
const entityRef = this.dataStore.entityIndex.byId.get(entityId);
|
|
889
|
-
|
|
890
|
-
|
|
1303
|
+
// Readability rather than presence, as everywhere else (#2491). A clamped
|
|
1304
|
+
// decode would match nothing here, so this is tidiness rather than a bug —
|
|
1305
|
+
// but the gates in this file agree on one predicate now.
|
|
1306
|
+
if (entityRef && createSourceRefReader(this.dataStore.source)(entityRef)) {
|
|
1307
|
+
const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
891
1308
|
// #ID=IFCWALL('GlobalId',#owner,...): GlobalId is a quoted STEP string
|
|
892
1309
|
// (doubled '' escapes); OwnerHistory is the ref/`$` right after it.
|
|
893
1310
|
const match = entityText.match(/=\s*IFC\w+\s*\(\s*'(?:[^']|'')*'\s*,\s*#(\d+)/i);
|
|
@@ -900,7 +1317,7 @@ export class StepExporter {
|
|
|
900
1317
|
/**
|
|
901
1318
|
* Generate STEP entities for property sets
|
|
902
1319
|
*/
|
|
903
|
-
generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNames, random) {
|
|
1320
|
+
generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNames, random) {
|
|
904
1321
|
const lines = [];
|
|
905
1322
|
let count = 0;
|
|
906
1323
|
const generatedTypeOwnedPsetIds = new Map();
|
|
@@ -910,8 +1327,13 @@ export class StepExporter {
|
|
|
910
1327
|
for (const prop of pset.properties) {
|
|
911
1328
|
const propId = this.nextExpressId++;
|
|
912
1329
|
count++;
|
|
913
|
-
|
|
914
|
-
|
|
1330
|
+
// `prop.dataType`, not `prop.type` alone: regenerating the set rewrites
|
|
1331
|
+
// every property in it, and the shape-derived primitive would re-declare
|
|
1332
|
+
// the ones nobody edited (`IFCTEXT` → `IFCLABEL`, `IFCLENGTHMEASURE` →
|
|
1333
|
+
// `IFCREAL`). See `declared-property-type.ts` for when the source token
|
|
1334
|
+
// is trusted (#2482).
|
|
1335
|
+
const valueStr = serializeNominalValue(prop.value, prop.type, prop.dataType);
|
|
1336
|
+
const unitId = prop.unit ? this.findUnitId(prop.unit, effective) : null;
|
|
915
1337
|
const unitStr = unitId !== null ? ref(unitId) : null;
|
|
916
1338
|
// #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
|
|
917
1339
|
const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
|
|
@@ -976,6 +1398,86 @@ export class StepExporter {
|
|
|
976
1398
|
}
|
|
977
1399
|
return { lines, count };
|
|
978
1400
|
}
|
|
1401
|
+
/**
|
|
1402
|
+
* THE mutation pipeline for a line read out of the source buffer: retype,
|
|
1403
|
+
* then named attribute edits, then positional edits.
|
|
1404
|
+
*
|
|
1405
|
+
* **One implementation, two call sites**, and that is the whole point. Two
|
|
1406
|
+
* passes can write the defining line of a source entity — the
|
|
1407
|
+
* source-iteration pass, and the type-object `HasPropertySets` rewrite that
|
|
1408
|
+
* REPLACES it (`rewrittenEntityIds` makes the source pass skip those ids).
|
|
1409
|
+
* The rewrite used to do its own thing (replace slot 5, nothing else), so
|
|
1410
|
+
* every other edit to a type object with a type-owned pset edit was dropped
|
|
1411
|
+
* in silence: first the renames (#2462 follow-up), and after those were
|
|
1412
|
+
* special-cased here, still the retypes and the positional edits. Whatever
|
|
1413
|
+
* the source pass applies, the rewrite has to apply too, or the next edit
|
|
1414
|
+
* kind added to one site goes missing at the other.
|
|
1415
|
+
*
|
|
1416
|
+
* The order is load-bearing:
|
|
1417
|
+
*
|
|
1418
|
+
* - the retype runs FIRST so named attribute edits resolve against the
|
|
1419
|
+
* TARGET class's attribute names, and so positional slots are indexed
|
|
1420
|
+
* into the retyped argument list;
|
|
1421
|
+
* - the `HasPropertySets` replacement (rewrite path only) runs LAST, on
|
|
1422
|
+
* the text this returns. Run it first and a positional edit to slot 5 —
|
|
1423
|
+
* or a retype's argument-list rebuild — overwrites the resolved pset
|
|
1424
|
+
* list with the stale one, which is the same silent drop one slot over.
|
|
1425
|
+
*
|
|
1426
|
+
* The expressId is unchanged by all of this, so geometry / placement /
|
|
1427
|
+
* representation and every IfcRel* reference (keyed by #id) carry over.
|
|
1428
|
+
*
|
|
1429
|
+
* All three flags report EFFECT, not intent — each is the answer to "did this
|
|
1430
|
+
* operation change the line", measured across that operation alone. The count
|
|
1431
|
+
* and the ledger are claims about the FILE, so an edit that resolves to the
|
|
1432
|
+
* text already there has delivered nothing and must not be reported: retyping
|
|
1433
|
+
* an entity to the class it already is, or writing a positional slot the token
|
|
1434
|
+
* it already holds, used to count as a modification and reach the ledger as a
|
|
1435
|
+
* landed edit, over a byte-identical line. Discarded edits read the same way:
|
|
1436
|
+
* `applyAttributeMutations` drops a name its class has no slot for and
|
|
1437
|
+
* `retypeStepLine` returns an unparseable line untouched, and neither is a
|
|
1438
|
+
* modification of anything.
|
|
1439
|
+
*
|
|
1440
|
+
* `retyped` / `positional` matter most in a FULL export, which is where the
|
|
1441
|
+
* two are nominated (their edits have no earlier nomination site); named
|
|
1442
|
+
* attribute edits are nominated by the collection pass and `attributed` only
|
|
1443
|
+
* settles their delivery.
|
|
1444
|
+
*/
|
|
1445
|
+
applySourceLineMutations(expressId, entityText, recordType, attributeMutations, sourceSchema, overlayActive) {
|
|
1446
|
+
let text = entityText;
|
|
1447
|
+
let workingType = recordType.toUpperCase();
|
|
1448
|
+
const typeMutation = overlayActive && typeof this.mutationView.getEntityTypeMutation === 'function'
|
|
1449
|
+
? this.mutationView.getEntityTypeMutation(expressId)
|
|
1450
|
+
: null;
|
|
1451
|
+
let retyped = false;
|
|
1452
|
+
if (typeMutation) {
|
|
1453
|
+
const beforeRetype = text;
|
|
1454
|
+
text = retypeStepLine(text, recordType, typeMutation.newType, typeMutation.predefinedType ?? null, sourceSchema);
|
|
1455
|
+
retyped = text !== beforeRetype;
|
|
1456
|
+
// Set even for a no-op retype: the entity IS the target class from here
|
|
1457
|
+
// on, so the named and positional edits below must resolve against it.
|
|
1458
|
+
workingType = typeMutation.newType.toUpperCase();
|
|
1459
|
+
}
|
|
1460
|
+
// `applyAttributeMutations` returns its input UNCHANGED when it wrote
|
|
1461
|
+
// nothing — no slot resolved for any of the names, or the line does not
|
|
1462
|
+
// parse — so comparing is what tells the ledger whether a named attribute
|
|
1463
|
+
// edit was really carried, rather than merely attempted.
|
|
1464
|
+
let attributed = false;
|
|
1465
|
+
if (attributeMutations && attributeMutations.size > 0) {
|
|
1466
|
+
const beforeAttributes = text;
|
|
1467
|
+
text = this.applyAttributeMutations(text, workingType, attributeMutations);
|
|
1468
|
+
attributed = text !== beforeAttributes;
|
|
1469
|
+
}
|
|
1470
|
+
const positionals = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
|
|
1471
|
+
? this.mutationView.getPositionalMutationsForEntity(expressId)
|
|
1472
|
+
: null;
|
|
1473
|
+
let positional = false;
|
|
1474
|
+
if (positionals && positionals.size > 0) {
|
|
1475
|
+
const beforePositionals = text;
|
|
1476
|
+
text = this.applyPositionalMutations(text, positionals, workingType, sourceSchema);
|
|
1477
|
+
positional = text !== beforePositionals;
|
|
1478
|
+
}
|
|
1479
|
+
return { text, attributed, retyped, positional };
|
|
1480
|
+
}
|
|
979
1481
|
/**
|
|
980
1482
|
* Rewrite root IFC attributes directly on the original STEP entity line.
|
|
981
1483
|
*/
|
|
@@ -1141,9 +1643,9 @@ export class StepExporter {
|
|
|
1141
1643
|
const forceReal = realSlots.has(index) || tokenIsRealLiteral(currentToken);
|
|
1142
1644
|
return serializeStepValue(value, forceReal);
|
|
1143
1645
|
}
|
|
1144
|
-
resolveMapUnitReference(unitName, newGeorefLines) {
|
|
1646
|
+
resolveMapUnitReference(unitName, newGeorefLines, effective) {
|
|
1145
1647
|
const normalized = this.normalizeMapUnitName(unitName);
|
|
1146
|
-
const existing = this.findLengthUnitReference(normalized);
|
|
1648
|
+
const existing = this.findLengthUnitReference(normalized, effective);
|
|
1147
1649
|
if (existing !== null) {
|
|
1148
1650
|
return existing;
|
|
1149
1651
|
}
|
|
@@ -1179,14 +1681,22 @@ export class StepExporter {
|
|
|
1179
1681
|
return 'FOOT';
|
|
1180
1682
|
return normalized;
|
|
1181
1683
|
}
|
|
1182
|
-
|
|
1684
|
+
/**
|
|
1685
|
+
* `effective` filters the candidates the same way the georef reads above do:
|
|
1686
|
+
* returning a tombstoned unit id hands the caller a `#id` for a line the
|
|
1687
|
+
* export never writes. Returning null instead makes `resolveMapUnitReference`
|
|
1688
|
+
* synthesise a fresh unit, which is the outcome a deleted unit deserves.
|
|
1689
|
+
*/
|
|
1690
|
+
findLengthUnitReference(preferredUnitName, effective) {
|
|
1183
1691
|
if (!this.entityExtractor)
|
|
1184
1692
|
return null;
|
|
1185
|
-
|
|
1186
|
-
|
|
1693
|
+
// Only source records carry the bytes `extractEntity` reads, so an
|
|
1694
|
+
// overlay-created project is skipped rather than shadowing the file's own.
|
|
1695
|
+
const projectId = (effective.byType.get('IFCPROJECT') ?? []).find((id) => this.dataStore.entityIndex.byId.has(id));
|
|
1696
|
+
const projectRef = projectId !== undefined ? this.dataStore.entityIndex.byId.get(projectId) : undefined;
|
|
1187
1697
|
const project = projectRef ? this.entityExtractor.extractEntity(projectRef) : null;
|
|
1188
1698
|
const unitAssignmentId = project?.attributes?.[8];
|
|
1189
|
-
if (typeof unitAssignmentId !== 'number')
|
|
1699
|
+
if (typeof unitAssignmentId !== 'number' || effective.isDeleted(unitAssignmentId))
|
|
1190
1700
|
return null;
|
|
1191
1701
|
const unitAssignmentRef = this.dataStore.entityIndex.byId.get(unitAssignmentId);
|
|
1192
1702
|
const unitAssignment = unitAssignmentRef ? this.entityExtractor.extractEntity(unitAssignmentRef) : null;
|
|
@@ -1194,7 +1704,7 @@ export class StepExporter {
|
|
|
1194
1704
|
if (!Array.isArray(units))
|
|
1195
1705
|
return null;
|
|
1196
1706
|
for (const unitId of units) {
|
|
1197
|
-
if (typeof unitId !== 'number')
|
|
1707
|
+
if (typeof unitId !== 'number' || effective.isDeleted(unitId))
|
|
1198
1708
|
continue;
|
|
1199
1709
|
const unitRef = this.dataStore.entityIndex.byId.get(unitId);
|
|
1200
1710
|
const unit = unitRef ? this.entityExtractor.extractEntity(unitRef) : null;
|
|
@@ -1222,10 +1732,36 @@ export class StepExporter {
|
|
|
1222
1732
|
}
|
|
1223
1733
|
return null;
|
|
1224
1734
|
}
|
|
1225
|
-
|
|
1735
|
+
/**
|
|
1736
|
+
* Record that a requested IfcMapConversion could not be written. Emitting it
|
|
1737
|
+
* anyway would leave `SourceCRS` pointing at nothing, so the refusal is the
|
|
1738
|
+
* correct output — but the file alone cannot express it, which is why it goes
|
|
1739
|
+
* back to the caller in `stats.warnings` as well as to the console (#2067).
|
|
1740
|
+
*/
|
|
1741
|
+
reportMapConversionRefused(warnings) {
|
|
1742
|
+
warnings.push(MAP_CONVERSION_WITHOUT_CONTEXT_WARNING);
|
|
1743
|
+
console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}`);
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Record that a requested IfcMapConversion could not be written because
|
|
1747
|
+
* there is no IfcProjectedCRS to attach it to — a different refusal from
|
|
1748
|
+
* {@link reportMapConversionRefused}: "no CRS to attach it to" rather than
|
|
1749
|
+
* "no context to reference" (#2105).
|
|
1750
|
+
*/
|
|
1751
|
+
reportMapConversionRefusedNoCrs(warnings) {
|
|
1752
|
+
warnings.push(MAP_CONVERSION_WITHOUT_CRS_WARNING);
|
|
1753
|
+
console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CRS_WARNING}`);
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* `effective` again: the id returned here becomes the new IfcMapConversion's
|
|
1757
|
+
* SourceCRS, so a tombstoned context would leave the created line pointing at
|
|
1758
|
+
* a record the export skips — a dangling reference and an invalid file.
|
|
1759
|
+
*/
|
|
1760
|
+
findPreferredGeometricRepresentationContextId(effective) {
|
|
1226
1761
|
if (!this.entityExtractor)
|
|
1227
1762
|
return null;
|
|
1228
|
-
const contextIds =
|
|
1763
|
+
const contextIds = (effective.byType.get('IFCGEOMETRICREPRESENTATIONCONTEXT') ?? [])
|
|
1764
|
+
.filter((id) => this.dataStore.entityIndex.byId.has(id));
|
|
1229
1765
|
let first3dContext = null;
|
|
1230
1766
|
for (const contextId of contextIds) {
|
|
1231
1767
|
const contextRef = this.dataStore.entityIndex.byId.get(contextId);
|
|
@@ -1263,8 +1799,8 @@ export class StepExporter {
|
|
|
1263
1799
|
/**
|
|
1264
1800
|
* Find a unit entity ID by name (simplified - returns null for now)
|
|
1265
1801
|
*/
|
|
1266
|
-
findUnitId(unitName) {
|
|
1267
|
-
return this.findLengthUnitReference(this.normalizeMapUnitName(unitName));
|
|
1802
|
+
findUnitId(unitName, effective) {
|
|
1803
|
+
return this.findLengthUnitReference(this.normalizeMapUnitName(unitName), effective);
|
|
1268
1804
|
}
|
|
1269
1805
|
/**
|
|
1270
1806
|
* Check if an entity type is a geometry-related type
|
|
@@ -1343,7 +1879,7 @@ export class StepExporter {
|
|
|
1343
1879
|
const entityRef = this.dataStore.entityIndex.byId.get(relId);
|
|
1344
1880
|
if (!entityRef || !this.dataStore.source)
|
|
1345
1881
|
return [];
|
|
1346
|
-
const entityText =
|
|
1882
|
+
const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
1347
1883
|
// Parse IfcRelDefinesByProperties: #ID=IFCRELDEFINESBYPROPERTIES('guid',$,$,$,(#objects),#pset);
|
|
1348
1884
|
// The 5th argument (index 4) is the list of related objects
|
|
1349
1885
|
const match = entityText.match(/\(([^)]+)\)\s*,\s*#(\d+)\s*\)\s*;/);
|
|
@@ -1364,7 +1900,7 @@ export class StepExporter {
|
|
|
1364
1900
|
const entityRef = this.dataStore.entityIndex.byId.get(relId);
|
|
1365
1901
|
if (!entityRef || !this.dataStore.source)
|
|
1366
1902
|
return null;
|
|
1367
|
-
const entityText =
|
|
1903
|
+
const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
1368
1904
|
// Last #ID before the closing );
|
|
1369
1905
|
const match = entityText.match(/,\s*#(\d+)\s*\)\s*;$/);
|
|
1370
1906
|
if (!match)
|
|
@@ -1378,7 +1914,7 @@ export class StepExporter {
|
|
|
1378
1914
|
const entityRef = this.dataStore.entityIndex.byId.get(psetId);
|
|
1379
1915
|
if (!entityRef || !this.dataStore.source)
|
|
1380
1916
|
return null;
|
|
1381
|
-
const entityText =
|
|
1917
|
+
const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
1382
1918
|
// Parse: IFCPROPERTYSET('guid',$,'Name',$,...) - Name is 3rd argument
|
|
1383
1919
|
const match = entityText.match(/IFCPROPERTYSET\s*\([^,]*,[^,]*,'([^']*)'/i);
|
|
1384
1920
|
if (!match)
|
|
@@ -1392,7 +1928,7 @@ export class StepExporter {
|
|
|
1392
1928
|
const entityRef = this.dataStore.entityIndex.byId.get(entityId);
|
|
1393
1929
|
if (!entityRef || !this.dataStore.source)
|
|
1394
1930
|
return null;
|
|
1395
|
-
const entityText =
|
|
1931
|
+
const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
1396
1932
|
// Parse: IFCELEMENTQUANTITY('guid',$,'Name',...) - Name is 3rd argument
|
|
1397
1933
|
const match = entityText.match(/IFCELEMENTQUANTITY\s*\([^,]*,[^,]*,'([^']*)'/i);
|
|
1398
1934
|
if (!match)
|
|
@@ -1440,7 +1976,7 @@ export class StepExporter {
|
|
|
1440
1976
|
const entityRef = this.dataStore.entityIndex.byId.get(psetId);
|
|
1441
1977
|
if (!entityRef || !this.dataStore.source)
|
|
1442
1978
|
return [];
|
|
1443
|
-
const entityText =
|
|
1979
|
+
const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
1444
1980
|
// Parse: IFCPROPERTYSET(...,(#prop1,#prop2,...)); - Last argument is properties list
|
|
1445
1981
|
const match = entityText.match(/\(\s*(#[^)]+)\s*\)\s*\)\s*;$/);
|
|
1446
1982
|
if (!match)
|
|
@@ -1480,24 +2016,6 @@ export class StepExporter {
|
|
|
1480
2016
|
return [];
|
|
1481
2017
|
return hasPropertySets.filter((value) => typeof value === 'number');
|
|
1482
2018
|
}
|
|
1483
|
-
/**
|
|
1484
|
-
* Replace a single top-level STEP attribute in an entity line.
|
|
1485
|
-
*/
|
|
1486
|
-
replaceEntityAttribute(entityId, attrIndex, replacement) {
|
|
1487
|
-
const entityRef = this.dataStore.entityIndex.byId.get(entityId);
|
|
1488
|
-
if (!entityRef || !this.dataStore.source)
|
|
1489
|
-
return null;
|
|
1490
|
-
const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
1491
|
-
const match = entityText.match(/^(#\d+\s*=\s*\w+\()([\s\S]*)(\)\s*;)\s*$/);
|
|
1492
|
-
if (!match)
|
|
1493
|
-
return null;
|
|
1494
|
-
const [, prefix, attrsText, suffix] = match;
|
|
1495
|
-
const attrs = splitTopLevelStepArguments(attrsText);
|
|
1496
|
-
if (attrIndex >= attrs.length)
|
|
1497
|
-
return null;
|
|
1498
|
-
attrs[attrIndex] = replacement;
|
|
1499
|
-
return `${prefix}${attrs.join(',')}${suffix}`;
|
|
1500
|
-
}
|
|
1501
2019
|
}
|
|
1502
2020
|
/**
|
|
1503
2021
|
* Quick export function for simple use cases.
|