@ifc-lite/export 2.9.3 → 2.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/merged-exporter.d.ts +3 -2
- package/dist/merged-exporter.d.ts.map +1 -1
- package/dist/merged-exporter.js +9 -3
- package/dist/merged-exporter.js.map +1 -1
- package/dist/parquet-exporter.js +2 -0
- package/dist/parquet-exporter.js.map +1 -1
- package/dist/reference-collector.d.ts +40 -17
- package/dist/reference-collector.d.ts.map +1 -1
- package/dist/reference-collector.js +89 -19
- package/dist/reference-collector.js.map +1 -1
- package/dist/schema-converter.d.ts.map +1 -1
- package/dist/schema-converter.js +27 -1
- package/dist/schema-converter.js.map +1 -1
- package/dist/source-ref-bounds.d.ts +16 -1
- package/dist/source-ref-bounds.d.ts.map +1 -1
- package/dist/source-ref-bounds.js +85 -0
- package/dist/source-ref-bounds.js.map +1 -1
- package/dist/step-exporter.d.ts +166 -188
- package/dist/step-exporter.d.ts.map +1 -1
- package/dist/step-exporter.js +738 -1442
- package/dist/step-exporter.js.map +1 -1
- package/dist/step-georeferencing.d.ts +58 -0
- package/dist/step-georeferencing.d.ts.map +1 -0
- package/dist/step-georeferencing.js +339 -0
- package/dist/step-georeferencing.js.map +1 -0
- package/dist/step-property-sets.d.ts +196 -0
- package/dist/step-property-sets.d.ts.map +1 -0
- package/dist/step-property-sets.js +842 -0
- package/dist/step-property-sets.js.map +1 -0
- package/package.json +6 -6
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
import { extractQuantitiesOnDemand, serializeValue, ref } from '@ifc-lite/parser';
|
|
5
|
+
import { generateIfcGuid } from '@ifc-lite/encoding';
|
|
6
|
+
import { recordSourceLineDelivery } from './delta-modification-ledger.js';
|
|
7
|
+
import { nominateDeliveredInPlaceEdits } from './in-place-nomination.js';
|
|
8
|
+
import { decodeRange } from './source-ref-bounds.js';
|
|
9
|
+
import { findLengthUnitReference, normalizeMapUnitName } from './step-georeferencing.js';
|
|
10
|
+
import { authoredEntityRefs } from './effective-index.js';
|
|
11
|
+
import { HAS_PROPERTY_SETS_SLOT, isTypeClass, resolveTypeOwnedPsetIds, rewriteTypeOwnedPsetLine, typeOwnedPsetRewriteWarning, } from './type-owned-psets.js';
|
|
12
|
+
import { escapeStepString, toStepReal, quantityTypeToIfcType } from './step-serialization.js';
|
|
13
|
+
import { serializeNominalValue } from './declared-property-type.js';
|
|
14
|
+
/** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
|
|
15
|
+
const OWNER_HISTORY_SLOT = 1;
|
|
16
|
+
/**
|
|
17
|
+
* The store the extractor THIS class installed on a view currently reads
|
|
18
|
+
* (#2487). The extractor is installed once per view and closes over this box
|
|
19
|
+
* rather than over a store directly, so a later export of the same view against
|
|
20
|
+
* a different store re-points it instead of answering from the first file.
|
|
21
|
+
*
|
|
22
|
+
* A box, and not a `WeakSet` of views, because ownership has to reflect the
|
|
23
|
+
* CURRENT state and not the historical fact that an export once installed
|
|
24
|
+
* something. `setQuantityExtractor` is public: a caller may install its own
|
|
25
|
+
* afterwards, and a marker saying "the exporter owns this view" would then keep
|
|
26
|
+
* overwriting a caller-supplied base forever. With a box, the second export
|
|
27
|
+
* writes to a box nothing reads any more and never calls the setter again, so
|
|
28
|
+
* the caller's extractor stands. Weak, so it never keeps a session alive.
|
|
29
|
+
*/
|
|
30
|
+
const exporterQuantityBase = new WeakMap();
|
|
31
|
+
/**
|
|
32
|
+
* Build a one-shot reverse index of every IfcRelDefinesByProperties in
|
|
33
|
+
* the source: for each related entity, list the rels and property/quantity
|
|
34
|
+
* sets that reference it. Used by the export pre-pass so the per-entity
|
|
35
|
+
* "find owning rels" step is O(K) rather than O(N) per modified entity.
|
|
36
|
+
*
|
|
37
|
+
* `relatedByRel` is the same walk read the other way round, so the deleted-host
|
|
38
|
+
* sweep costs nothing extra.
|
|
39
|
+
*/
|
|
40
|
+
export function buildRelDefinesByPropertiesIndex(ctx) {
|
|
41
|
+
const byEntity = new Map();
|
|
42
|
+
const relatedByRel = new Map();
|
|
43
|
+
for (const [relId, relRef] of ctx.dataStore.entityIndex.byId) {
|
|
44
|
+
if (relRef.type.toUpperCase() !== 'IFCRELDEFINESBYPROPERTIES')
|
|
45
|
+
continue;
|
|
46
|
+
const psetId = getRelatedPropertySet(ctx, relId);
|
|
47
|
+
if (!psetId)
|
|
48
|
+
continue;
|
|
49
|
+
const related = getRelatedEntities(ctx, relId);
|
|
50
|
+
relatedByRel.set(relId, related);
|
|
51
|
+
for (const entityId of related) {
|
|
52
|
+
let bucket = byEntity.get(entityId);
|
|
53
|
+
if (!bucket) {
|
|
54
|
+
bucket = [];
|
|
55
|
+
byEntity.set(entityId, bucket);
|
|
56
|
+
}
|
|
57
|
+
bucket.push({ relId, psetId });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { byEntity, relatedByRel };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The source STEP text of an entity's line, or `null` when there are no bytes
|
|
64
|
+
* to read.
|
|
65
|
+
*
|
|
66
|
+
* The byte check is on the RANGE, not on `dataStore.source`. `source` is a
|
|
67
|
+
* MANDATORY accessor — `EMPTY_SOURCE_BYTES` is how "this model kept no bytes"
|
|
68
|
+
* is spelled (server-parsed, synthetic, GLB and point-cloud stores all have
|
|
69
|
+
* one) — so the `!ctx.dataStore.source` guard the five readers below used to
|
|
70
|
+
* carry never fired. It was also redundant: a zero-length range decodes to
|
|
71
|
+
* `''`, which fails every regex those readers run, so they already answered
|
|
72
|
+
* "nothing" for a sourceless store. Scoping the check to the range is what
|
|
73
|
+
* makes the guard live without changing a single answer, the same shape and
|
|
74
|
+
* for the same reason as `reference-collector.ts` (#2339).
|
|
75
|
+
*
|
|
76
|
+
* An OVERLAY-created entity never reaches here: every caller resolves its id
|
|
77
|
+
* through `dataStore.entityIndex.byId`, which holds source records only
|
|
78
|
+
* (`effective-index.ts` synthesises the overlay refs on its own side and
|
|
79
|
+
* writes nothing back), so an overlay id is already `undefined` at the
|
|
80
|
+
* lookup and is served by the callers' documented "not a source record"
|
|
81
|
+
* path. That is why an early return is safe HERE and is NOT safe at the
|
|
82
|
+
* visible-only closure in `export` — see the comment there.
|
|
83
|
+
*
|
|
84
|
+
* ## Why `isReadableSourceRef` and not `byteLength === 0`
|
|
85
|
+
*
|
|
86
|
+
* An out-of-range ref does NOT degrade to "no match" here. `decodeUtf8`
|
|
87
|
+
* clamps the range it cannot address, and the clamped window is still a
|
|
88
|
+
* window over real file bytes — so these readers answer from somebody
|
|
89
|
+
* ELSE's record. `source-ref-bounds.ts` (#2491) carries the measured
|
|
90
|
+
* account of both shapes and of why "a clamped, empty decode already yields
|
|
91
|
+
* no match" is false; it is not restated here, because an argument kept in
|
|
92
|
+
* two files is an argument that has to stay true in two files.
|
|
93
|
+
*
|
|
94
|
+
* The consequence specific to THIS site is that the wrong answer is acted
|
|
95
|
+
* on. `retainSharedAtoms` un-skips every id `getPropertyIdsInSet` returns,
|
|
96
|
+
* so a member list read out of the wrong record un-skips the wrong atoms;
|
|
97
|
+
* and the source-iteration pass already refuses to emit a record whose ref
|
|
98
|
+
* fails `isReadableSourceRef` (see the `continue` in `export`), so before
|
|
99
|
+
* this gate these readers were making decisions on behalf of a container
|
|
100
|
+
* that the same export had decided not to write. Gating them on the same
|
|
101
|
+
* predicate is what makes the two passes agree.
|
|
102
|
+
*
|
|
103
|
+
* The degradation is the one the exporter already handles: a record with no
|
|
104
|
+
* emittable bytes, generating nothing and named by nothing. It costs one
|
|
105
|
+
* answer that used to be right by luck — an overrunning ref on the file's
|
|
106
|
+
* LAST record clamps back to exactly that record's text — but that record
|
|
107
|
+
* is one the emission pass drops anyway, so keeping the answer only kept
|
|
108
|
+
* the disagreement.
|
|
109
|
+
*/
|
|
110
|
+
/*
|
|
111
|
+
* Exported for `entity-line-text-bounds.test.ts`, which is the only pin on
|
|
112
|
+
* where this range STARTS: every pattern the readers below run is unanchored
|
|
113
|
+
* or `$`-anchored, so no public export path can see the first byte move
|
|
114
|
+
* (#2497). That test used to reach the method by casting a `StepExporter` to
|
|
115
|
+
* an interface of its privates; an export is the same access, stated.
|
|
116
|
+
*/
|
|
117
|
+
export function entityLineText(ctx, entityId) {
|
|
118
|
+
const entityRef = ctx.dataStore.entityIndex.byId.get(entityId);
|
|
119
|
+
if (!entityRef || !ctx.isReadableSourceRef(entityRef))
|
|
120
|
+
return null;
|
|
121
|
+
return decodeRange(ctx.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get entity IDs related by IfcRelDefinesByProperties (the related objects)
|
|
125
|
+
*/
|
|
126
|
+
function getRelatedEntities(ctx, relId) {
|
|
127
|
+
const entityText = entityLineText(ctx, relId);
|
|
128
|
+
if (entityText === null)
|
|
129
|
+
return [];
|
|
130
|
+
// Parse IfcRelDefinesByProperties: #ID=IFCRELDEFINESBYPROPERTIES('guid',$,$,$,(#objects),#pset);
|
|
131
|
+
// The 5th argument (index 4) is the list of related objects
|
|
132
|
+
const match = entityText.match(/\(([^)]+)\)\s*,\s*#(\d+)\s*\)\s*;/);
|
|
133
|
+
if (!match)
|
|
134
|
+
return [];
|
|
135
|
+
const objectsList = match[1];
|
|
136
|
+
const refs = [];
|
|
137
|
+
const refMatches = objectsList.matchAll(/#(\d+)/g);
|
|
138
|
+
for (const m of refMatches) {
|
|
139
|
+
refs.push(parseInt(m[1], 10));
|
|
140
|
+
}
|
|
141
|
+
return refs;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get the property set ID from IfcRelDefinesByProperties
|
|
145
|
+
*/
|
|
146
|
+
function getRelatedPropertySet(ctx, relId) {
|
|
147
|
+
const entityText = entityLineText(ctx, relId);
|
|
148
|
+
if (entityText === null)
|
|
149
|
+
return null;
|
|
150
|
+
// Last #ID before the closing );
|
|
151
|
+
const match = entityText.match(/,\s*#(\d+)\s*\)\s*;$/);
|
|
152
|
+
if (!match)
|
|
153
|
+
return null;
|
|
154
|
+
return parseInt(match[1], 10);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get the name of a property set by parsing the entity
|
|
158
|
+
*/
|
|
159
|
+
export function getPropertySetName(ctx, psetId) {
|
|
160
|
+
const entityText = entityLineText(ctx, psetId);
|
|
161
|
+
if (entityText === null)
|
|
162
|
+
return null;
|
|
163
|
+
// Parse: IFCPROPERTYSET('guid',$,'Name',$,...) - Name is 3rd argument
|
|
164
|
+
const match = entityText.match(/IFCPROPERTYSET\s*\([^,]*,[^,]*,'([^']*)'/i);
|
|
165
|
+
if (!match)
|
|
166
|
+
return null;
|
|
167
|
+
return match[1];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Get the name of an element quantity set by parsing the entity
|
|
171
|
+
*/
|
|
172
|
+
function getElementQuantityName(ctx, entityId) {
|
|
173
|
+
const entityText = entityLineText(ctx, entityId);
|
|
174
|
+
if (entityText === null)
|
|
175
|
+
return null;
|
|
176
|
+
// Parse: IFCELEMENTQUANTITY('guid',$,'Name',...) - Name is 3rd argument
|
|
177
|
+
const match = entityText.match(/IFCELEMENTQUANTITY\s*\([^,]*,[^,]*,'([^']*)'/i);
|
|
178
|
+
if (!match)
|
|
179
|
+
return null;
|
|
180
|
+
return match[1];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Get IDs of properties in a property set
|
|
184
|
+
*/
|
|
185
|
+
export function getPropertyIdsInSet(ctx, psetId) {
|
|
186
|
+
const entityText = entityLineText(ctx, psetId);
|
|
187
|
+
if (entityText === null)
|
|
188
|
+
return [];
|
|
189
|
+
// Parse: IFCPROPERTYSET(...,(#prop1,#prop2,...)); - Last argument is properties list
|
|
190
|
+
const match = entityText.match(/\(\s*(#[^)]+)\s*\)\s*\)\s*;$/);
|
|
191
|
+
if (!match)
|
|
192
|
+
return [];
|
|
193
|
+
const propsList = match[1];
|
|
194
|
+
const ids = [];
|
|
195
|
+
const refMatches = propsList.matchAll(/#(\d+)/g);
|
|
196
|
+
for (const m of refMatches) {
|
|
197
|
+
ids.push(parseInt(m[1], 10));
|
|
198
|
+
}
|
|
199
|
+
return ids;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The full HasPropertySets id list of a type object, from whichever authority
|
|
203
|
+
* owns the record.
|
|
204
|
+
*
|
|
205
|
+
* Slot 5 is `HasPropertySets` on every `IfcTypeObject` subtype. For a source
|
|
206
|
+
* record the list is parsed out of the file; for an overlay-created type it is
|
|
207
|
+
* read off the authored payload, where a reference is the documented `'#42'`
|
|
208
|
+
* string form. Reading only the source made every pset on a created
|
|
209
|
+
* `IfcWallType` look unowned, which is how it ended up on an occurrence
|
|
210
|
+
* relation instead (#2012).
|
|
211
|
+
*/
|
|
212
|
+
function getTypeOwnedHasPropertySetIds(ctx, entityId, effective) {
|
|
213
|
+
if (effective.isOverlayCreated(entityId)) {
|
|
214
|
+
const authored = ctx.mutationView?.getNewEntity(entityId)?.attributes?.[HAS_PROPERTY_SETS_SLOT];
|
|
215
|
+
return authoredEntityRefs(overlaySlotValue(ctx, entityId, HAS_PROPERTY_SETS_SLOT, authored));
|
|
216
|
+
}
|
|
217
|
+
if (!ctx.entityExtractor)
|
|
218
|
+
return [];
|
|
219
|
+
const entityRef = ctx.dataStore.entityIndex.byId.get(entityId);
|
|
220
|
+
if (!entityRef)
|
|
221
|
+
return [];
|
|
222
|
+
const entity = ctx.entityExtractor.extractEntity(entityRef);
|
|
223
|
+
const hasPropertySets = entity?.attributes?.[HAS_PROPERTY_SETS_SLOT];
|
|
224
|
+
if (!Array.isArray(hasPropertySets))
|
|
225
|
+
return [];
|
|
226
|
+
return hasPropertySets.filter((value) => typeof value === 'number');
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The overlay's answer for one positional slot of an overlay-created entity,
|
|
230
|
+
* falling back to the creation payload only when the overlay has NOTHING to
|
|
231
|
+
* say about that slot.
|
|
232
|
+
*
|
|
233
|
+
* **Ask `Map.has`, never `??`.** `setPositionalAttribute(id, slot, null)` is
|
|
234
|
+
* an explicit "clear this slot", and its value is `null`, so `??` reads the
|
|
235
|
+
* overlay's answer as an absence and reinstates the authored one. That is the
|
|
236
|
+
* same overlay-versus-buffer confusion this whole change is about, one
|
|
237
|
+
* attribute wide: an explicit null IS the overlay's answer, and the overlay is
|
|
238
|
+
* the authority. Cleared OwnerHistory came back as the authored reference, and
|
|
239
|
+
* a cleared `HasPropertySets` resurrected the list the user had removed.
|
|
240
|
+
*/
|
|
241
|
+
function overlaySlotValue(ctx, entityId, slot, authored) {
|
|
242
|
+
const overrides = ctx.mutationView?.getPositionalMutationsForEntity(entityId);
|
|
243
|
+
if (!overrides?.has(slot))
|
|
244
|
+
return authored;
|
|
245
|
+
const value = overrides.get(slot);
|
|
246
|
+
// `Map.get` widens to `| undefined`, which `has` has already ruled out. A
|
|
247
|
+
// slot explicitly set to nothing serializes as `$`, i.e. null.
|
|
248
|
+
return value === undefined ? null : value;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Read an element's own OwnerHistory reference (`#id`), or null when the
|
|
252
|
+
* element omits one (`$`) or cannot be parsed. OwnerHistory is the second
|
|
253
|
+
* attribute of every IfcRoot subtype, immediately after the GlobalId string.
|
|
254
|
+
*/
|
|
255
|
+
function getOwnerHistoryRefOfEntity(ctx, entityId) {
|
|
256
|
+
const cached = ctx.ownerHistory.byEntity.get(entityId);
|
|
257
|
+
if (cached !== undefined)
|
|
258
|
+
return cached;
|
|
259
|
+
let result = null;
|
|
260
|
+
// An overlay-created host has no source line to read, but it does have an
|
|
261
|
+
// authored OwnerHistory in slot 1 — reading only the buffer sent every
|
|
262
|
+
// generated pset on a created entity to the file's first owner history
|
|
263
|
+
// instead of the one the caller named (#2012).
|
|
264
|
+
const overlay = ctx.mutationView?.getNewEntity(entityId);
|
|
265
|
+
if (overlay) {
|
|
266
|
+
const refs = authoredEntityRefs(overlaySlotValue(ctx, entityId, OWNER_HISTORY_SLOT, overlay.attributes[OWNER_HISTORY_SLOT]));
|
|
267
|
+
result = refs.length > 0 ? `#${refs[0]}` : null;
|
|
268
|
+
ctx.ownerHistory.byEntity.set(entityId, result);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
const entityRef = ctx.dataStore.entityIndex.byId.get(entityId);
|
|
272
|
+
// Readability rather than presence, as everywhere else (#2491). A clamped
|
|
273
|
+
// decode would match nothing here, so this is tidiness rather than a bug —
|
|
274
|
+
// but the gates in this file agree on one predicate now.
|
|
275
|
+
if (entityRef && ctx.isReadableSourceRef(entityRef)) {
|
|
276
|
+
const entityText = decodeRange(ctx.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
277
|
+
// #ID=IFCWALL('GlobalId',#owner,...): GlobalId is a quoted STEP string
|
|
278
|
+
// (doubled '' escapes); OwnerHistory is the ref/`$` right after it.
|
|
279
|
+
const match = entityText.match(/=\s*IFC\w+\s*\(\s*'(?:[^']|'')*'\s*,\s*#(\d+)/i);
|
|
280
|
+
if (match)
|
|
281
|
+
result = `#${match[1]}`;
|
|
282
|
+
}
|
|
283
|
+
ctx.ownerHistory.byEntity.set(entityId, result);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Resolve a STEP reference to an existing IfcOwnerHistory for the
|
|
288
|
+
* IfcPropertySet / IfcRelDefinesByProperties / IfcElementQuantity entities we
|
|
289
|
+
* generate for `hostEntityId`'s mutations. OwnerHistory is optional in IFC4 but
|
|
290
|
+
* MANDATORY in IFC2X3 (IfcRoot.OwnerHistory), so emitting `$` yields an invalid
|
|
291
|
+
* IFC2X3 file that strict readers (e.g. BIM Vision) reject.
|
|
292
|
+
*
|
|
293
|
+
* Prefer the host element's OWN owner history, then any owner history that
|
|
294
|
+
* survives this export, then `$` only when none does.
|
|
295
|
+
*
|
|
296
|
+
* "Survives" is `willBeEmitted`, the same predicate that decides whether the
|
|
297
|
+
* host itself may have psets generated for it. A reference is a reference: it
|
|
298
|
+
* is no more acceptable to point an emitted `IfcPropertySet` at an owner
|
|
299
|
+
* history the session deleted than at a host it deleted. This used to consult
|
|
300
|
+
* only the `visibleOnly` closure, so an overlay-created OwnerHistory that was
|
|
301
|
+
* later deleted still got referenced — a dangling `#N`, reached through the
|
|
302
|
+
* one attribute the generators fill in for themselves.
|
|
303
|
+
*/
|
|
304
|
+
function resolveOwnerHistoryRef(ctx, hostEntityId, willBeEmitted) {
|
|
305
|
+
const own = getOwnerHistoryRefOfEntity(ctx, hostEntityId);
|
|
306
|
+
if (own !== null) {
|
|
307
|
+
const ownId = parseInt(own.slice(1), 10);
|
|
308
|
+
if (willBeEmitted(ownId))
|
|
309
|
+
return own;
|
|
310
|
+
}
|
|
311
|
+
if (ctx.ownerHistory.fallbackRef === undefined) {
|
|
312
|
+
// Source-only: the fallback is a best-effort "some owner history the file
|
|
313
|
+
// still has", and the host's OWN history above is the path that resolves
|
|
314
|
+
// an overlay-created one.
|
|
315
|
+
const ids = ctx.dataStore.entityIndex.byType.get('IFCOWNERHISTORY') ?? [];
|
|
316
|
+
const surviving = ids.find((id) => willBeEmitted(id));
|
|
317
|
+
ctx.ownerHistory.fallbackRef = surviving !== undefined ? `#${surviving}` : '$';
|
|
318
|
+
}
|
|
319
|
+
return ctx.ownerHistory.fallbackRef;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Generate a new IFC GlobalId (22 character base64). `random` is the
|
|
323
|
+
* export's optional seeded source (`StepExportOptions.guidRandom`);
|
|
324
|
+
* undefined keeps the default random path.
|
|
325
|
+
*/
|
|
326
|
+
function generateGlobalId(random) {
|
|
327
|
+
return generateIfcGuid(random);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Find a unit entity ID by name (simplified - returns null for now)
|
|
331
|
+
*/
|
|
332
|
+
function findUnitId(ctx, unitName, effective) {
|
|
333
|
+
return findLengthUnitReference(normalizeMapUnitName(unitName), effective, { dataStore: ctx.dataStore, entityExtractor: ctx.entityExtractor });
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Generate STEP entities for property sets
|
|
337
|
+
*/
|
|
338
|
+
function generatePropertySetEntities(ctx, entityId, psets, willBeEmitted, effective, typeOwnedPsetNames, random) {
|
|
339
|
+
const lines = [];
|
|
340
|
+
let count = 0;
|
|
341
|
+
const generatedTypeOwnedPsetIds = new Map();
|
|
342
|
+
for (const pset of psets) {
|
|
343
|
+
const propertyIds = [];
|
|
344
|
+
// Create IfcPropertySingleValue for each property
|
|
345
|
+
for (const prop of pset.properties) {
|
|
346
|
+
const propId = ctx.allocateExpressId();
|
|
347
|
+
count++;
|
|
348
|
+
// `prop.dataType`, not `prop.type` alone: regenerating the set rewrites
|
|
349
|
+
// every property in it, and the shape-derived primitive would re-declare
|
|
350
|
+
// the ones nobody edited (`IFCTEXT` → `IFCLABEL`, `IFCLENGTHMEASURE` →
|
|
351
|
+
// `IFCREAL`). See `declared-property-type.ts` for when the source token
|
|
352
|
+
// is trusted (#2482).
|
|
353
|
+
const valueStr = serializeNominalValue(prop.value, prop.type, prop.dataType);
|
|
354
|
+
const unitId = prop.unit ? findUnitId(ctx, prop.unit, effective) : null;
|
|
355
|
+
const unitStr = unitId !== null ? ref(unitId) : null;
|
|
356
|
+
// #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
|
|
357
|
+
const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
|
|
358
|
+
lines.push(line);
|
|
359
|
+
propertyIds.push(propId);
|
|
360
|
+
}
|
|
361
|
+
// Create IfcPropertySet
|
|
362
|
+
const psetId = ctx.allocateExpressId();
|
|
363
|
+
count++;
|
|
364
|
+
const propRefs = propertyIds.map(id => `#${id}`).join(',');
|
|
365
|
+
const globalId = generateGlobalId(random);
|
|
366
|
+
// #ID=IFCPROPERTYSET('GlobalId',#ownerHistory,'Name',$,(#props));
|
|
367
|
+
const psetLine = `#${psetId}=IFCPROPERTYSET('${globalId}',${resolveOwnerHistoryRef(ctx, entityId, willBeEmitted)},'${escapeStepString(pset.name)}',$,(${propRefs}));`;
|
|
368
|
+
lines.push(psetLine);
|
|
369
|
+
if (typeOwnedPsetNames?.has(pset.name)) {
|
|
370
|
+
generatedTypeOwnedPsetIds.set(pset.name, psetId);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
// Create IfcRelDefinesByProperties to link pset to entity
|
|
374
|
+
const relId = ctx.allocateExpressId();
|
|
375
|
+
count++;
|
|
376
|
+
const relGlobalId = generateGlobalId(random);
|
|
377
|
+
// #ID=IFCRELDEFINESBYPROPERTIES('GlobalId',#ownerHistory,$,$,(#entity),#pset);
|
|
378
|
+
const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${resolveOwnerHistoryRef(ctx, entityId, willBeEmitted)},$,$,(#${entityId}),#${psetId});`;
|
|
379
|
+
lines.push(relLine);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { lines, count, generatedTypeOwnedPsetIds };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Generate STEP entities for quantity sets (IfcElementQuantity)
|
|
386
|
+
*/
|
|
387
|
+
function generateQuantitySetEntities(ctx, entityId, qsets, willBeEmitted, random) {
|
|
388
|
+
const lines = [];
|
|
389
|
+
let count = 0;
|
|
390
|
+
for (const qset of qsets) {
|
|
391
|
+
const quantityIds = [];
|
|
392
|
+
for (const q of qset.quantities) {
|
|
393
|
+
const qId = ctx.allocateExpressId();
|
|
394
|
+
count++;
|
|
395
|
+
const ifcType = quantityTypeToIfcType(q.type);
|
|
396
|
+
// #ID=IFCQUANTITYLENGTH('Name',$,$,Value,$);
|
|
397
|
+
const val = toStepReal(q.value);
|
|
398
|
+
const line = `#${qId}=${ifcType}('${escapeStepString(q.name)}',$,$,${val},$);`;
|
|
399
|
+
lines.push(line);
|
|
400
|
+
quantityIds.push(qId);
|
|
401
|
+
}
|
|
402
|
+
// Create IfcElementQuantity
|
|
403
|
+
const qsetId = ctx.allocateExpressId();
|
|
404
|
+
count++;
|
|
405
|
+
const quantRefs = quantityIds.map(id => `#${id}`).join(',');
|
|
406
|
+
const globalId = generateGlobalId(random);
|
|
407
|
+
// #ID=IFCELEMENTQUANTITY('GlobalId',#ownerHistory,'Name',$,$,(#quants));
|
|
408
|
+
const qsetLine = `#${qsetId}=IFCELEMENTQUANTITY('${globalId}',${resolveOwnerHistoryRef(ctx, entityId, willBeEmitted)},'${escapeStepString(qset.name)}',$,$,(${quantRefs}));`;
|
|
409
|
+
lines.push(qsetLine);
|
|
410
|
+
// Create IfcRelDefinesByProperties to link qset to entity
|
|
411
|
+
const relId = ctx.allocateExpressId();
|
|
412
|
+
count++;
|
|
413
|
+
const relGlobalId = generateGlobalId(random);
|
|
414
|
+
const relLine = `#${relId}=IFCRELDEFINESBYPROPERTIES('${relGlobalId}',${resolveOwnerHistoryRef(ctx, entityId, willBeEmitted)},$,$,(#${entityId}),#${qsetId});`;
|
|
415
|
+
lines.push(relLine);
|
|
416
|
+
}
|
|
417
|
+
return { lines, count };
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Collect what the overlay's property-set and quantity-set edits mean for this
|
|
421
|
+
* export: which sets to regenerate (`pass.newPropertySets` /
|
|
422
|
+
* `pass.newQuantitySets`), which source records to withhold
|
|
423
|
+
* (`pass.skipPropertySetIds` / `pass.skipRelationshipIds`), and which type
|
|
424
|
+
* objects need their `HasPropertySets` resolved later
|
|
425
|
+
* (`pass.typeOwnedPsetNamesByEntity`, `…IdsByEntity`, `pass.rewrittenEntityIds`).
|
|
426
|
+
*
|
|
427
|
+
* The caller owns the `mutationView && options.applyMutations !== false` gate;
|
|
428
|
+
* reaching here means overlay edits were both present and enabled.
|
|
429
|
+
*/
|
|
430
|
+
export function collectPropertyAndQuantitySetMutations(pass, options, groups, ctx) {
|
|
431
|
+
const { entityPropMutations, entityQuantMutations, relDefinesByEntity } = groups;
|
|
432
|
+
// `export()` narrowed this through the enclosing `if`, and the caller still
|
|
433
|
+
// owns that gate: reaching here means the view exists and mutations are
|
|
434
|
+
// enabled. Named once here rather than asserted at each of the six reads.
|
|
435
|
+
const mutationView = ctx.mutationView;
|
|
436
|
+
// Collect modified property sets and find original psets to skip
|
|
437
|
+
for (const [entityId, psetNames] of entityPropMutations) {
|
|
438
|
+
// A deleted entity must not cause the exporter to REMOVE anything.
|
|
439
|
+
//
|
|
440
|
+
// This is the other half of the dangling-reference class, and the half
|
|
441
|
+
// `willBeEmitted` cannot reach: that predicate guards what gets ADDED,
|
|
442
|
+
// and this loop's real work is deciding what gets SKIPPED. An edited
|
|
443
|
+
// pset is replaced wholesale, so its original id goes into
|
|
444
|
+
// `skipPropertySetIds` — but IFC exporters share one IfcPropertySet
|
|
445
|
+
// between entities, and once the host is deleted there is no
|
|
446
|
+
// replacement to take its place. The surviving entity's relation then
|
|
447
|
+
// points at a container nobody wrote. Verified against main at
|
|
448
|
+
// e6516991 (#2030's own merge): edit `Pset_WallCommon` on one of two
|
|
449
|
+
// walls sharing it, delete that wall, and the export drops #11 while
|
|
450
|
+
// #12 still names it. `retainSharedAtoms` rescues a shared ATOM one
|
|
451
|
+
// level down; nothing rescues the shared container.
|
|
452
|
+
//
|
|
453
|
+
// Leaving the pset alone makes it an orphan when nothing else
|
|
454
|
+
// references it, which is valid IFC. Its relation is dropped by the
|
|
455
|
+
// sweep above, which handles a plain delete too — no pset edit needed.
|
|
456
|
+
if (pass.effective.isDeleted(entityId))
|
|
457
|
+
continue;
|
|
458
|
+
pass.modifiedEntities.add(entityId);
|
|
459
|
+
// Same rule as the attribute loop below: an overlay-CREATED entity is
|
|
460
|
+
// emitted once, by the new-entities pass, and already counted in
|
|
461
|
+
// `newEntityCount` — as are the pset entities this loop goes on to
|
|
462
|
+
// generate. Only the COUNT is guarded; the entity still records its
|
|
463
|
+
// pset edits and still emits them.
|
|
464
|
+
//
|
|
465
|
+
// A NOMINATION, in both modes, never a count on its own: this site sees
|
|
466
|
+
// a pset NAME the session touched, not whether that name resolves to
|
|
467
|
+
// anything. `deletePropertySet(id, 'AName')` on a host that owns no such
|
|
468
|
+
// set reaches here and changes nothing at all, and used to put "1
|
|
469
|
+
// modification" in the header of a byte-identical file (#2474). What
|
|
470
|
+
// settles it is the generator's `recordEmitted` and the skip branches'
|
|
471
|
+
// `recordWithheld` below.
|
|
472
|
+
if (!pass.isOverlayCreated(entityId) && pass.hasEmittableHostBytes(entityId)) {
|
|
473
|
+
pass.modifications.nominate(entityId, 'property-set');
|
|
474
|
+
}
|
|
475
|
+
// Get the FULL mutated property sets for this entity (merged base + mutations)
|
|
476
|
+
const allPsets = mutationView.getForEntity(entityId);
|
|
477
|
+
const relevantPsets = allPsets.filter((pset) => psetNames.has(pset.name));
|
|
478
|
+
const relDefinedPsetNames = new Set();
|
|
479
|
+
if (relevantPsets.length > 0) {
|
|
480
|
+
pass.newPropertySets.push({ entityId, psets: relevantPsets });
|
|
481
|
+
}
|
|
482
|
+
// Find original property set IDs and relationship IDs to skip — look
|
|
483
|
+
// up only the IfcRelDefinesByProperties rels that reference this entity.
|
|
484
|
+
const rels = relDefinesByEntity.get(entityId);
|
|
485
|
+
if (rels) {
|
|
486
|
+
for (const { relId, psetId: relatedPsetId } of rels) {
|
|
487
|
+
// Check if this pset is one we're modifying
|
|
488
|
+
const psetName = getPropertySetName(ctx, relatedPsetId);
|
|
489
|
+
if (psetName) {
|
|
490
|
+
relDefinedPsetNames.add(psetName);
|
|
491
|
+
}
|
|
492
|
+
if (psetName && psetNames.has(psetName)) {
|
|
493
|
+
pass.skipRelationshipIds.add(relId);
|
|
494
|
+
pass.skipPropertySetIds.add(relatedPsetId);
|
|
495
|
+
// Also skip the individual properties in this pset
|
|
496
|
+
const propIds = getPropertyIdsInSet(ctx, relatedPsetId);
|
|
497
|
+
for (const propId of propIds) {
|
|
498
|
+
pass.skipPropertySetIds.add(propId);
|
|
499
|
+
}
|
|
500
|
+
// The other half of "did this edit change the file": a full export
|
|
501
|
+
// applies a set DELETION by leaving these lines out, and produces
|
|
502
|
+
// no replacement content to record an emission for. Without this
|
|
503
|
+
// the count would settle from the generator alone and a real
|
|
504
|
+
// deletion would stop counting along with the no-op one (#2474).
|
|
505
|
+
pass.modifications.recordWithheld(entityId, 'property-set');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (isTypeClass(pass.effective.typeOf(entityId))) {
|
|
510
|
+
const typeOwnedPsetIds = getTypeOwnedHasPropertySetIds(ctx, entityId, pass.effective);
|
|
511
|
+
const typeOwnedAffected = new Set();
|
|
512
|
+
for (const psetId of typeOwnedPsetIds) {
|
|
513
|
+
const psetName = getPropertySetName(ctx, psetId);
|
|
514
|
+
if (!psetName || !psetNames.has(psetName))
|
|
515
|
+
continue;
|
|
516
|
+
typeOwnedAffected.add(psetName);
|
|
517
|
+
pass.skipPropertySetIds.add(psetId);
|
|
518
|
+
const propIds = getPropertyIdsInSet(ctx, psetId);
|
|
519
|
+
for (const propId of propIds) {
|
|
520
|
+
pass.skipPropertySetIds.add(propId);
|
|
521
|
+
}
|
|
522
|
+
// No `recordWithheld` twin of the rel-defined branch above, and
|
|
523
|
+
// deliberately: a name that matches an OWNED pset is either dropped
|
|
524
|
+
// from the resolved list or swapped for the replacement this export
|
|
525
|
+
// generated, so slot 5 always comes back different and the repoint
|
|
526
|
+
// below records the emission for it. A second record here would be
|
|
527
|
+
// one no mutation can kill.
|
|
528
|
+
}
|
|
529
|
+
for (const psetName of psetNames) {
|
|
530
|
+
if (!relDefinedPsetNames.has(psetName)) {
|
|
531
|
+
typeOwnedAffected.add(psetName);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (typeOwnedAffected.size > 0) {
|
|
535
|
+
pass.typeOwnedPsetNamesByEntity.set(entityId, typeOwnedAffected);
|
|
536
|
+
pass.typeOwnedPsetIdsByEntity.set(entityId, typeOwnedPsetIds);
|
|
537
|
+
pass.rewrittenEntityIds.add(entityId);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Collect modified quantity sets (only if quantities are included)
|
|
542
|
+
if (options.includeQuantities === false)
|
|
543
|
+
entityQuantMutations.clear();
|
|
544
|
+
// A quantity overlay with nothing under it regenerates a source quantity
|
|
545
|
+
// set from the edited quantity ALONE, and the skip loop below then
|
|
546
|
+
// withholds the source lines that held its siblings (#2487). Unlike
|
|
547
|
+
// properties — whose base falls back to the `baseTable` the view was
|
|
548
|
+
// constructed with — quantities have only the opt-in
|
|
549
|
+
// `setQuantityExtractor`, so the default really is an empty base, and
|
|
550
|
+
// four in-tree callers plus every external embedder never set it.
|
|
551
|
+
//
|
|
552
|
+
// The exporter is the one place that always holds the missing half: it
|
|
553
|
+
// was handed the very store the view is an overlay ON. Supplying it here
|
|
554
|
+
// makes the loss impossible for every caller rather than for the callers
|
|
555
|
+
// we happened to find, and a view that resolves its own quantities (the
|
|
556
|
+
// viewer, MCP, the CLI headless backend) is never overwritten.
|
|
557
|
+
//
|
|
558
|
+
// The extractor closes over ONE store, and the view outlives this export.
|
|
559
|
+
// So it closes over a BOX this class owns instead: a second export of the
|
|
560
|
+
// same view against a DIFFERENT store re-points that box rather than
|
|
561
|
+
// reading the first store's quantities, which is the one way "install only
|
|
562
|
+
// when absent" could have answered from the wrong file. The setter is
|
|
563
|
+
// called at most once per view, so a caller that installs its own
|
|
564
|
+
// extractor at any point — before the first export or after it — keeps it.
|
|
565
|
+
//
|
|
566
|
+
// `hasQuantityBase` and `setQuantityExtractor` are probed, like every other
|
|
567
|
+
// optional view capability this class reaches for (`peekNextExpressId`,
|
|
568
|
+
// `getNewEntities`, `getEntityTypeMutation`): `MutablePropertyView` is
|
|
569
|
+
// published API arriving from a separately versioned package, and callers
|
|
570
|
+
// pass partial and duck-typed views. `hasQuantityBase` is newer than
|
|
571
|
+
// `setQuantityExtractor`, and without it there is no way to tell an empty
|
|
572
|
+
// base from a caller-supplied one — so an older view falls back to the
|
|
573
|
+
// pre-#2487 behaviour (no base supplied) rather than risk overwriting one.
|
|
574
|
+
const quantityView = mutationView;
|
|
575
|
+
if (entityQuantMutations.size > 0 &&
|
|
576
|
+
typeof quantityView.setQuantityExtractor === 'function' &&
|
|
577
|
+
typeof quantityView.hasQuantityBase === 'function') {
|
|
578
|
+
const installed = exporterQuantityBase.get(quantityView);
|
|
579
|
+
if (installed) {
|
|
580
|
+
// Ours, or a caller's that replaced ours: re-pointing the box is a
|
|
581
|
+
// no-op in the second case, and calling the setter again is what
|
|
582
|
+
// would not be.
|
|
583
|
+
installed.store = ctx.dataStore;
|
|
584
|
+
}
|
|
585
|
+
else if (!quantityView.hasQuantityBase()) {
|
|
586
|
+
const box = { store: ctx.dataStore };
|
|
587
|
+
exporterQuantityBase.set(quantityView, box);
|
|
588
|
+
quantityView.setQuantityExtractor((id) => extractQuantitiesOnDemand(box.store, id));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const [entityId, qsetNames] of entityQuantMutations) {
|
|
592
|
+
// Same rule as the property loop above: a deleted entity removes nothing.
|
|
593
|
+
if (pass.effective.isDeleted(entityId))
|
|
594
|
+
continue;
|
|
595
|
+
pass.modifiedEntities.add(entityId);
|
|
596
|
+
// See the property loop above — an overlay-created entity is counted as
|
|
597
|
+
// new, not modified. The pset loop's own nomination no longer has to be
|
|
598
|
+
// excluded to avoid a double count: the ledger settles per ENTITY, so a
|
|
599
|
+
// host with both a pset and a qset edit counts once whatever is
|
|
600
|
+
// nominated. Nominating both buys the opposite — an accurate warning
|
|
601
|
+
// when the qset half is the half a delta cannot carry.
|
|
602
|
+
//
|
|
603
|
+
// Settled from effect like its property-set twin (#2474). The reachable
|
|
604
|
+
// no-op here is an UNDONE quantity-set creation whose name matches NO
|
|
605
|
+
// source set: `getMutations()` is append-only, so the `CREATE_QUANTITY`
|
|
606
|
+
// record still names the qset after `removeQuantityMutation` has taken
|
|
607
|
+
// it out of the overlay, and the generator below then finds nothing to
|
|
608
|
+
// write. The same undo against a COLLIDING name is not a no-op — it
|
|
609
|
+
// withholds the source set's lines — which is what the skip loop's
|
|
610
|
+
// `recordWithheld` below settles.
|
|
611
|
+
if (!pass.isOverlayCreated(entityId) && pass.hasEmittableHostBytes(entityId)) {
|
|
612
|
+
pass.modifications.nominate(entityId, 'quantity-set');
|
|
613
|
+
}
|
|
614
|
+
const allQsets = mutationView.getQuantitiesForEntity(entityId);
|
|
615
|
+
const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
|
|
616
|
+
if (relevantQsets.length > 0) {
|
|
617
|
+
pass.newQuantitySets.push({ entityId, qsets: relevantQsets });
|
|
618
|
+
}
|
|
619
|
+
// The names this export is actually WRITING a replacement for. The
|
|
620
|
+
// affected-name set is not the same thing: it comes from the session's
|
|
621
|
+
// append-only mutation history, which keeps naming a quantity set after
|
|
622
|
+
// an undo has taken it back out of the overlay, so a Ctrl+Z used to
|
|
623
|
+
// withhold a source `IfcElementQuantity` that nothing regenerated.
|
|
624
|
+
//
|
|
625
|
+
// A quantity-set REMOVAL is the one case where withholding WITHOUT a
|
|
626
|
+
// replacement is the intent rather than the bug. It had no public
|
|
627
|
+
// populator when #2487 wrote that rule, so the rule read "always the
|
|
628
|
+
// bug"; `MutablePropertyView.deleteQuantitySet` (#2508) gives it one,
|
|
629
|
+
// and the deleted set is now asked for by name below. Without that, the
|
|
630
|
+
// panel hid a base quantity set the exported file still carried.
|
|
631
|
+
const regeneratedQsetNames = new Set(relevantQsets.map((qset) => qset.name));
|
|
632
|
+
// Skip original quantity set entities (IfcElementQuantity).
|
|
633
|
+
// Same per-entity index lookup as the property branch above.
|
|
634
|
+
const rels = relDefinesByEntity.get(entityId);
|
|
635
|
+
if (rels) {
|
|
636
|
+
for (const { relId, psetId: relatedPsetId } of rels) {
|
|
637
|
+
const qsetName = getElementQuantityName(ctx, relatedPsetId);
|
|
638
|
+
const deleted = qsetName !== null
|
|
639
|
+
&& mutationView.isQuantitySetDeleted?.(entityId, qsetName) === true;
|
|
640
|
+
if (qsetName && (regeneratedQsetNames.has(qsetName) || deleted)) {
|
|
641
|
+
pass.skipRelationshipIds.add(relId);
|
|
642
|
+
pass.skipPropertySetIds.add(relatedPsetId);
|
|
643
|
+
const quantIds = getPropertyIdsInSet(ctx, relatedPsetId);
|
|
644
|
+
for (const quantId of quantIds) {
|
|
645
|
+
pass.skipPropertySetIds.add(quantId);
|
|
646
|
+
}
|
|
647
|
+
// The withheld half, exactly as the rel-defined property branch
|
|
648
|
+
// above. This loop has just decided that #`relatedPsetId`, its
|
|
649
|
+
// quantity atoms and the relationship that attached them do NOT
|
|
650
|
+
// go into the file; whether anything is generated to take their
|
|
651
|
+
// place is decided elsewhere, and is not this branch's to assume.
|
|
652
|
+
//
|
|
653
|
+
// It IS assumable for the pset side and not here, and the
|
|
654
|
+
// difference is where the two read their base from.
|
|
655
|
+
// `getForEntity` merges the overlay over the base pset walk, so a
|
|
656
|
+
// name the session touched but did not change still resolves to
|
|
657
|
+
// source content and is regenerated.
|
|
658
|
+
// `getQuantitiesForEntity` merges the overlay over
|
|
659
|
+
// `quantityExtractor`, which is OPT-IN: it defaults to null, and
|
|
660
|
+
// several in-tree callers wire the property extractor beside it
|
|
661
|
+
// and not it (`cli/commands/mutate.ts`, `gym.ts`,
|
|
662
|
+
// `generate-spaces.ts`, `export/demesh-session.ts`), as does any
|
|
663
|
+
// external embedder of these two published packages. With no
|
|
664
|
+
// extractor the base is empty and the overlay is the only source,
|
|
665
|
+
// so a qset the overlay no longer holds resolves to nothing.
|
|
666
|
+
//
|
|
667
|
+
// Which makes this reachable through an UNDONE quantity-set
|
|
668
|
+
// creation whose name COLLIDES with a source set:
|
|
669
|
+
// `setQuantity(id, 'Qto_WallBaseQuantities', ...)` followed by the
|
|
670
|
+
// `removeQuantityMutation` that mutationSlice runs on Ctrl+Z. The
|
|
671
|
+
// append-only history still names the qset, so this branch
|
|
672
|
+
// withholds the source lines; the overlay is empty again, so
|
|
673
|
+
// nothing is regenerated. The export drops the source quantity set
|
|
674
|
+
// — a real change to the file, and a data-loss bug of its own
|
|
675
|
+
// (#2487) — and this call is what stops the count from calling it
|
|
676
|
+
// nothing.
|
|
677
|
+
pass.modifications.recordWithheld(entityId, 'quantity-set');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Write the generated property-set and quantity-set records into
|
|
685
|
+
* `pass.entities`, and point every affected type object's `HasPropertySets` at
|
|
686
|
+
* the property sets this export just generated.
|
|
687
|
+
*
|
|
688
|
+
* Three loops, in the order `export()` ran them, and the order is load-bearing:
|
|
689
|
+
* the rewrite reads `generatedTypeOwnedPsetIds` from the property-set loop, and
|
|
690
|
+
* the caller flushes `pass.rewrittenEntityLines` — this function's output —
|
|
691
|
+
* only after the quantity-set loop has run.
|
|
692
|
+
*/
|
|
693
|
+
export function generatePropertyAndQuantitySetEntities(pass, options, ctx) {
|
|
694
|
+
// Generate new property entities for mutations (these REPLACE the skipped ones)
|
|
695
|
+
const generatedTypeOwnedPsetIds = new Map();
|
|
696
|
+
for (const { entityId, psets } of pass.newPropertySets) {
|
|
697
|
+
// Nothing may be emitted FOR an entity that gets no defining line —
|
|
698
|
+
// see `willBeEmitted` (#1978, #2030, #2012).
|
|
699
|
+
if (!pass.willBeEmitted(entityId))
|
|
700
|
+
continue;
|
|
701
|
+
const newEntities = generatePropertySetEntities(ctx, entityId, psets, pass.willBeEmitted, pass.effective, pass.typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
|
|
702
|
+
pass.entities.push(...newEntities.lines);
|
|
703
|
+
pass.newEntityCount += newEntities.count;
|
|
704
|
+
// Replacement content for this host actually landed, so a delta really
|
|
705
|
+
// does carry its PROPERTY-SET modification — and only that one (#2462).
|
|
706
|
+
if (newEntities.lines.length > 0)
|
|
707
|
+
pass.modifications.recordEmitted(entityId, 'property-set');
|
|
708
|
+
generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
|
|
709
|
+
}
|
|
710
|
+
// Point every affected type object's HasPropertySets at the psets this
|
|
711
|
+
// export generated. One loop, because a type whose affected psets produced
|
|
712
|
+
// no replacement content (a deletion) needs exactly the same resolution
|
|
713
|
+
// with an empty replacement map.
|
|
714
|
+
for (const [entityId, typeOwnedPsetNames] of pass.typeOwnedPsetNamesByEntity) {
|
|
715
|
+
// `entityId` here is a TYPE object rather than an element; `willBeEmitted`
|
|
716
|
+
// resolves either the same way (#2030).
|
|
717
|
+
if (!pass.willBeEmitted(entityId))
|
|
718
|
+
continue;
|
|
719
|
+
const resolved = resolveTypeOwnedPsetIds(pass.typeOwnedPsetIdsByEntity.get(entityId) ?? [], typeOwnedPsetNames, generatedTypeOwnedPsetIds.get(entityId) ?? new Map(), (psetId) => getPropertySetName(ctx, psetId));
|
|
720
|
+
if (pass.effective.isOverlayCreated(entityId)) {
|
|
721
|
+
// No source line to rewrite: the new-entities pass writes this record
|
|
722
|
+
// from its authored payload, so the list rides in as a slot override.
|
|
723
|
+
pass.overlayTypeOwnedPsets.set(entityId, resolved.length > 0 ? resolved.map((id) => `#${id}`) : null);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
// This line REPLACES the one the source-iteration pass would have
|
|
727
|
+
// written — `rewrittenEntityIds` makes that pass skip the entity — so it
|
|
728
|
+
// has to carry the entity's other edits too, and it has to apply them
|
|
729
|
+
// the way that pass does. It used to replace slot 5 and nothing else,
|
|
730
|
+
// which dropped the rename in `setAttribute(id,'Name',…)` +
|
|
731
|
+
// `addPropertySet(id,…)`, and then, once renames were special-cased
|
|
732
|
+
// here, still dropped retypes and positional edits — same line, same
|
|
733
|
+
// silence. So run the ONE pipeline both passes share and replace
|
|
734
|
+
// `HasPropertySets` on its output. Order matters: see
|
|
735
|
+
// {@link applySourceLineMutations}.
|
|
736
|
+
const record = pass.effective.get(entityId);
|
|
737
|
+
let sourceLine = null;
|
|
738
|
+
let mutated = null;
|
|
739
|
+
// One narrowed block for both calls: `record` is in scope for the decode
|
|
740
|
+
// AND for the record type below, with no non-null assertion to keep true
|
|
741
|
+
// by hand. `byteOffset >= 0` is the same "are there real source bytes"
|
|
742
|
+
// test the source-iteration pass makes — an overlay-authored record
|
|
743
|
+
// carries `-1` there, and decoding from it would read another entity's
|
|
744
|
+
// bytes rather than fall through to the no-source-bytes branch.
|
|
745
|
+
// `isReadableSourceRef` folds in the `byteOffset >= 0 && byteLength > 0`
|
|
746
|
+
// test this used to make by hand, and adds the bound the invariant used
|
|
747
|
+
// to supply (#2491).
|
|
748
|
+
if (record && pass.isReadableSourceRef(record)) {
|
|
749
|
+
sourceLine = decodeRange(ctx.dataStore.source, record.byteOffset, record.byteOffset + record.byteLength);
|
|
750
|
+
// The RECORD's class is the from-type: the bytes are still the source
|
|
751
|
+
// class, whatever `typeOf` now says the entity effectively is.
|
|
752
|
+
mutated = ctx.applySourceLineMutations(entityId, sourceLine, record.type, pass.modifiedAttributes.get(entityId), pass.sourceSchema, pass.overlayActive, (attr, value) => pass.warnings.push(`entity #${entityId}: attribute ${attr} not written - ` +
|
|
753
|
+
`${JSON.stringify(value)} is not a number and the slot is REAL-typed`));
|
|
754
|
+
}
|
|
755
|
+
if (mutated === null) {
|
|
756
|
+
// `willBeEmitted` already required real source bytes for a non-overlay
|
|
757
|
+
// record, so this is only reachable with no source buffer at all —
|
|
758
|
+
// in which case the source-iteration pass never ran either and there is
|
|
759
|
+
// nothing to lose. Say it anyway; the pset edit is still going nowhere.
|
|
760
|
+
pass.warnings.push(typeOwnedPsetRewriteWarning(entityId, 'no-source-bytes'));
|
|
761
|
+
// The line above IS the report, so the ledger must not add a second,
|
|
762
|
+
// vaguer one blaming the delta format for a drop the format did not
|
|
763
|
+
// cause.
|
|
764
|
+
pass.modifications.acknowledgeUndelivered(entityId, 'property-set');
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const { line, repointed } = rewriteTypeOwnedPsetLine(mutated.text, resolved);
|
|
768
|
+
if (repointed) {
|
|
769
|
+
// A repoint that resolves to the list the line ALREADY names changes
|
|
770
|
+
// nothing, and it is reachable: deleting a pset name the type object
|
|
771
|
+
// does not own leaves every original id in place (it is "affected" but
|
|
772
|
+
// matches none of them) and generates no replacement, so slot 5 comes
|
|
773
|
+
// back byte-identical. Same rule as the fallback branch below — an
|
|
774
|
+
// unchanged line has no place in a delta, and claiming it delivered the
|
|
775
|
+
// edit would put a modification in the header over a line that carries
|
|
776
|
+
// none. A FULL export still emits it: `rewrittenEntityIds` made the
|
|
777
|
+
// source-iteration pass skip this entity, so withholding the line there
|
|
778
|
+
// would delete the record from the file (#2469).
|
|
779
|
+
const changed = line !== sourceLine;
|
|
780
|
+
if (options.deltaOnly !== true || changed) {
|
|
781
|
+
pass.rewrittenEntityLines.set(entityId, line);
|
|
782
|
+
}
|
|
783
|
+
// A rewritten source line IS in the delta — the one in-place change a
|
|
784
|
+
// delta does carry today (#2462). The repoint itself delivers the
|
|
785
|
+
// property-set edit that put this host in the loop; the rest of the
|
|
786
|
+
// line delivers whichever in-place edits the pipeline applied to it.
|
|
787
|
+
if (changed) {
|
|
788
|
+
pass.modifications.recordEmitted(entityId, 'property-set');
|
|
789
|
+
recordSourceLineDelivery(pass.modifications, entityId, mutated);
|
|
790
|
+
// `rewrittenEntityIds` made the source-iteration pass skip this
|
|
791
|
+
// host, so this line is the ONLY place a full export can see its
|
|
792
|
+
// named-attribute edits land — per site, not per feature (#2483).
|
|
793
|
+
nominateDeliveredInPlaceEdits(pass.modifications, entityId, mutated, pass.inPlaceNominees);
|
|
794
|
+
}
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
// A malformed source line — too few arguments to have a slot 5, or not
|
|
798
|
+
// parseable as a STEP record at all. The entity must still come out:
|
|
799
|
+
// `rewrittenEntityIds` made the source-iteration pass skip it, so
|
|
800
|
+
// dropping the line here deletes the whole record from the file (#2469).
|
|
801
|
+
pass.warnings.push(typeOwnedPsetRewriteWarning(entityId, 'unparseable-line'));
|
|
802
|
+
// Same as the `no-source-bytes` branch: the property-set edit is
|
|
803
|
+
// genuinely undelivered — the repoint is what would have delivered it and
|
|
804
|
+
// it did not happen — but this warning already says so, precisely, so the
|
|
805
|
+
// ledger stays quiet about that pair rather than duplicating it. (When
|
|
806
|
+
// the affected psets produced replacement content, the property-set pass
|
|
807
|
+
// above has already recorded the emission, and an emission outranks an
|
|
808
|
+
// acknowledgement.)
|
|
809
|
+
pass.modifications.acknowledgeUndelivered(entityId, 'property-set');
|
|
810
|
+
// `line` is byte-for-byte what the source-iteration pass would have
|
|
811
|
+
// written, so emit it wherever that pass would have run. Under
|
|
812
|
+
// `deltaOnly` it does not run, and a line the mutation pipeline left
|
|
813
|
+
// identical to its source is not a change — it has no place in a delta.
|
|
814
|
+
const changed = line !== sourceLine;
|
|
815
|
+
if (options.deltaOnly !== true || changed) {
|
|
816
|
+
pass.rewrittenEntityLines.set(entityId, line);
|
|
817
|
+
}
|
|
818
|
+
// The ledger stays honest about WHICH modification landed: the
|
|
819
|
+
// property-set edit that nominated this host is the thing that just
|
|
820
|
+
// failed, so only the entity's OTHER edits are in this line. Under the
|
|
821
|
+
// per-kind keying that comes out as `attribute/retype/positional:
|
|
822
|
+
// delivered, property-set: undelivered` — the host still counts once,
|
|
823
|
+
// because a real change of its did land.
|
|
824
|
+
if (changed) {
|
|
825
|
+
recordSourceLineDelivery(pass.modifications, entityId, mutated);
|
|
826
|
+
// Same site rule as the repoint branch above: the failed repoint is
|
|
827
|
+
// what did not land, and the line still carries the host's OTHER edits.
|
|
828
|
+
nominateDeliveredInPlaceEdits(pass.modifications, entityId, mutated, pass.inPlaceNominees);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// Generate new quantity entities for mutations
|
|
832
|
+
for (const { entityId, qsets } of pass.newQuantitySets) {
|
|
833
|
+
if (!pass.willBeEmitted(entityId))
|
|
834
|
+
continue;
|
|
835
|
+
const newEntities = generateQuantitySetEntities(ctx, entityId, qsets, pass.willBeEmitted, options.guidRandom);
|
|
836
|
+
pass.entities.push(...newEntities.lines);
|
|
837
|
+
pass.newEntityCount += newEntities.count;
|
|
838
|
+
if (newEntities.lines.length > 0)
|
|
839
|
+
pass.modifications.recordEmitted(entityId, 'quantity-set');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
//# sourceMappingURL=step-property-sets.js.map
|