@ifc-lite/export 3.0.0 → 3.0.1

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.
Files changed (97) hide show
  1. package/dist/declared-property-type.d.ts +19 -8
  2. package/dist/declared-property-type.d.ts.map +1 -1
  3. package/dist/declared-property-type.js +31 -9
  4. package/dist/declared-property-type.js.map +1 -1
  5. package/dist/merged-exporter.d.ts +19 -0
  6. package/dist/merged-exporter.d.ts.map +1 -1
  7. package/dist/merged-exporter.js +1 -1
  8. package/dist/merged-exporter.js.map +1 -1
  9. package/dist/parquet-exporter.d.ts +1 -3
  10. package/dist/parquet-exporter.d.ts.map +1 -1
  11. package/dist/parquet-exporter.js +1 -0
  12. package/dist/parquet-exporter.js.map +1 -1
  13. package/dist/property-value-serialization.d.ts +54 -0
  14. package/dist/property-value-serialization.d.ts.map +1 -0
  15. package/dist/property-value-serialization.js +134 -0
  16. package/dist/property-value-serialization.js.map +1 -0
  17. package/dist/reference-collector.d.ts +20 -0
  18. package/dist/reference-collector.d.ts.map +1 -1
  19. package/dist/reference-collector.js +45 -112
  20. package/dist/reference-collector.js.map +1 -1
  21. package/dist/schema-converter.d.ts.map +1 -1
  22. package/dist/schema-converter.js +46 -96
  23. package/dist/schema-converter.js.map +1 -1
  24. package/dist/source-ref-bounds.d.ts +3 -3
  25. package/dist/source-ref-bounds.js +3 -3
  26. package/dist/step-attribute-mutations.d.ts +0 -42
  27. package/dist/step-attribute-mutations.d.ts.map +1 -1
  28. package/dist/step-attribute-mutations.js +17 -147
  29. package/dist/step-attribute-mutations.js.map +1 -1
  30. package/dist/step-attribute-serializers.d.ts +41 -0
  31. package/dist/step-attribute-serializers.d.ts.map +1 -0
  32. package/dist/step-attribute-serializers.js +74 -0
  33. package/dist/step-attribute-serializers.js.map +1 -0
  34. package/dist/step-collection.d.ts +2 -2
  35. package/dist/step-collection.d.ts.map +1 -1
  36. package/dist/step-collection.js +2 -1
  37. package/dist/step-collection.js.map +1 -1
  38. package/dist/step-export-contexts.d.ts +5 -7
  39. package/dist/step-export-contexts.d.ts.map +1 -1
  40. package/dist/step-export-contexts.js +6 -7
  41. package/dist/step-export-contexts.js.map +1 -1
  42. package/dist/step-exporter.d.ts +3 -3
  43. package/dist/step-exporter.js +4 -4
  44. package/dist/step-exporter.js.map +1 -1
  45. package/dist/step-geometry-types.d.ts +1 -17
  46. package/dist/step-geometry-types.d.ts.map +1 -1
  47. package/dist/step-geometry-types.js +51 -35
  48. package/dist/step-geometry-types.js.map +1 -1
  49. package/dist/step-georeferencing.d.ts +0 -10
  50. package/dist/step-georeferencing.d.ts.map +1 -1
  51. package/dist/step-georeferencing.js +46 -94
  52. package/dist/step-georeferencing.js.map +1 -1
  53. package/dist/step-header.d.ts +11 -1
  54. package/dist/step-header.d.ts.map +1 -1
  55. package/dist/step-header.js +0 -7
  56. package/dist/step-header.js.map +1 -1
  57. package/dist/step-map-unit.d.ts +90 -0
  58. package/dist/step-map-unit.d.ts.map +1 -0
  59. package/dist/step-map-unit.js +277 -0
  60. package/dist/step-map-unit.js.map +1 -0
  61. package/dist/step-overlay-attribute-overrides.d.ts +32 -0
  62. package/dist/step-overlay-attribute-overrides.d.ts.map +1 -0
  63. package/dist/step-overlay-attribute-overrides.js +83 -0
  64. package/dist/step-overlay-attribute-overrides.js.map +1 -0
  65. package/dist/step-overlay-entities.d.ts +3 -3
  66. package/dist/step-pass-builder.d.ts +4 -3
  67. package/dist/step-pass-builder.d.ts.map +1 -1
  68. package/dist/step-pass-builder.js.map +1 -1
  69. package/dist/step-property-set-collection.d.ts +24 -0
  70. package/dist/step-property-set-collection.d.ts.map +1 -0
  71. package/dist/step-property-set-collection.js +286 -0
  72. package/dist/step-property-set-collection.js.map +1 -0
  73. package/dist/step-property-set-generators.d.ts +20 -0
  74. package/dist/step-property-set-generators.d.ts.map +1 -0
  75. package/dist/step-property-set-generators.js +98 -0
  76. package/dist/step-property-set-generators.js.map +1 -0
  77. package/dist/step-property-set-index.d.ts +25 -0
  78. package/dist/step-property-set-index.d.ts.map +1 -0
  79. package/dist/step-property-set-index.js +69 -0
  80. package/dist/step-property-set-index.js.map +1 -0
  81. package/dist/step-property-set-readers.d.ts +158 -0
  82. package/dist/step-property-set-readers.d.ts.map +1 -0
  83. package/dist/step-property-set-readers.js +251 -0
  84. package/dist/step-property-set-readers.js.map +1 -0
  85. package/dist/step-property-sets.d.ts +2 -186
  86. package/dist/step-property-sets.d.ts.map +1 -1
  87. package/dist/step-property-sets.js +22 -674
  88. package/dist/step-property-sets.js.map +1 -1
  89. package/dist/step-serialization.d.ts +17 -51
  90. package/dist/step-serialization.d.ts.map +1 -1
  91. package/dist/step-serialization.js +19 -128
  92. package/dist/step-serialization.js.map +1 -1
  93. package/dist/step-source-iteration.d.ts +7 -5
  94. package/dist/step-source-iteration.d.ts.map +1 -1
  95. package/dist/step-source-iteration.js +3 -3
  96. package/dist/step-source-iteration.js.map +1 -1
  97. package/package.json +6 -6
@@ -1,685 +1,33 @@
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 { 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
4
  /**
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.
5
+ * The write phase of the property-set and quantity-set generation
6
+ * (#2475 steps 2b/2c): writes the records the generation phase
7
+ * (`step-property-set-generators.ts`) produces into `pass.entities`, then
8
+ * points every affected type object's `HasPropertySets` at them.
102
9
  *
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.
10
+ * `step-property-sets.ts` was split into this file plus
11
+ * `step-property-set-readers.ts` (the shared {@link PropertySetContext} and
12
+ * per-entity STEP-text readers), `step-property-set-index.ts` (the
13
+ * IfcRelDefinesByProperties reverse index), `step-property-set-generators.ts`
14
+ * (the two record generators) and `step-property-set-collection.ts` (the
15
+ * overlay-edit collection phase) — #3184. This file kept the original name
16
+ * because it is the phase `export()` calls last and the one every doc
17
+ * comment elsewhere in the package already cites as `step-property-sets.ts`.
204
18
  *
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.
19
+ * ## The state this function cannot read off the pass
232
20
  *
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.
21
+ * `allocateExpressId` is the exporter's own `nextExpressId++`, shared with
22
+ * the georeferencing phase, so it is injected as a callback on
23
+ * {@link PropertySetContext} rather than hoisted onto the pass.
240
24
  */
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
- }
25
+ import { decodeRange } from './source-ref-bounds.js';
26
+ import { resolveTypeOwnedPsetIds, rewriteTypeOwnedPsetLine, typeOwnedPsetRewriteWarning, } from './type-owned-psets.js';
27
+ import { recordSourceLineDelivery } from './delta-modification-ledger.js';
28
+ import { nominateDeliveredInPlaceEdits } from './in-place-nomination.js';
29
+ import { getPropertySetName } from './step-property-set-readers.js';
30
+ import { generatePropertySetEntities, generateQuantitySetEntities } from './step-property-set-generators.js';
683
31
  /**
684
32
  * Write the generated property-set and quantity-set records into
685
33
  * `pass.entities`, and point every affected type object's `HasPropertySets` at