@ifc-lite/export 1.17.2 → 1.18.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.
@@ -2,10 +2,11 @@
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
4
  import { EntityExtractor, generateHeader, getAttributeNames, serializeValue, ref, } from '@ifc-lite/parser';
5
- import { PropertyValueType, QuantityType } from '@ifc-lite/data';
5
+ import { safeUtf8Decode } from '@ifc-lite/data';
6
6
  import { generateIfcGuid } from '@ifc-lite/encoding';
7
7
  import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
8
8
  import { convertStepLine, needsConversion } from './schema-converter.js';
9
+ import { escapeStepString, toStepReal, quantityTypeToIfcType, serializePropertyValue, serializeAttributeValue, serializeStepArgs, serializeStepValue, splitTopLevelArgs, splitTopLevelStepArguments, } from './step-serialization.js';
9
10
  /**
10
11
  * IFC STEP file exporter
11
12
  */
@@ -17,8 +18,11 @@ export class StepExporter {
17
18
  constructor(dataStore, mutationView) {
18
19
  this.dataStore = dataStore;
19
20
  this.mutationView = mutationView || null;
20
- // Start new IDs after the highest existing ID
21
- this.nextExpressId = this.findMaxExpressId() + 1;
21
+ const maxExisting = this.findMaxExpressId();
22
+ const overlayWatermark = typeof mutationView?.peekNextExpressId === 'function'
23
+ ? mutationView.peekNextExpressId() - 1
24
+ : 0;
25
+ this.nextExpressId = Math.max(maxExisting, overlayWatermark) + 1;
22
26
  this.entityExtractor = dataStore.source ? new EntityExtractor(dataStore.source) : null;
23
27
  }
24
28
  /**
@@ -85,6 +89,12 @@ export class StepExporter {
85
89
  }
86
90
  targetMap.get(mutation.entityId).add(mutation.psetName);
87
91
  }
92
+ // Build a reverse index of IfcRelDefinesByProperties → (relId, psetId)
93
+ // pairs keyed on each related entity. The two property/quantity loops
94
+ // below previously walked every entity in `entityIndex.byId` per
95
+ // modified entity (O(E·N)); the index keeps the per-entity step
96
+ // O(K) where K is the number of rels referencing that entity.
97
+ const relDefinesByEntity = this.buildRelDefinesByPropertiesIndex();
88
98
  // Collect modified property sets and find original psets to skip
89
99
  for (const [entityId, psetNames] of entityPropMutations) {
90
100
  modifiedEntities.add(entityId);
@@ -97,28 +107,23 @@ export class StepExporter {
97
107
  if (relevantPsets.length > 0) {
98
108
  newPropertySets.push({ entityId, psets: relevantPsets });
99
109
  }
100
- // Find original property set IDs and relationship IDs to skip
101
- // Look for IfcRelDefinesByProperties that reference this entity
102
- for (const [relId, relRef] of this.dataStore.entityIndex.byId) {
103
- const relType = relRef.type.toUpperCase();
104
- if (relType === 'IFCRELDEFINESBYPROPERTIES') {
105
- // Parse the relationship to check if it references our entity
106
- const relatedEntities = this.getRelatedEntities(relId);
107
- const relatedPsetId = this.getRelatedPropertySet(relId);
108
- if (relatedEntities.includes(entityId) && relatedPsetId) {
109
- // Check if this pset is one we're modifying
110
- const psetName = this.getPropertySetName(relatedPsetId);
111
- if (psetName) {
112
- relDefinedPsetNames.add(psetName);
113
- }
114
- if (psetName && psetNames.has(psetName)) {
115
- skipRelationshipIds.add(relId);
116
- skipPropertySetIds.add(relatedPsetId);
117
- // Also skip the individual properties in this pset
118
- const propIds = this.getPropertyIdsInSet(relatedPsetId);
119
- for (const propId of propIds) {
120
- skipPropertySetIds.add(propId);
121
- }
110
+ // Find original property set IDs and relationship IDs to skip — look
111
+ // up only the IfcRelDefinesByProperties rels that reference this entity.
112
+ const rels = relDefinesByEntity.get(entityId);
113
+ if (rels) {
114
+ for (const { relId, psetId: relatedPsetId } of rels) {
115
+ // Check if this pset is one we're modifying
116
+ const psetName = this.getPropertySetName(relatedPsetId);
117
+ if (psetName) {
118
+ relDefinedPsetNames.add(psetName);
119
+ }
120
+ if (psetName && psetNames.has(psetName)) {
121
+ skipRelationshipIds.add(relId);
122
+ skipPropertySetIds.add(relatedPsetId);
123
+ // Also skip the individual properties in this pset
124
+ const propIds = this.getPropertyIdsInSet(relatedPsetId);
125
+ for (const propId of propIds) {
126
+ skipPropertySetIds.add(propId);
122
127
  }
123
128
  }
124
129
  }
@@ -161,21 +166,18 @@ export class StepExporter {
161
166
  if (relevantQsets.length > 0) {
162
167
  newQuantitySets.push({ entityId, qsets: relevantQsets });
163
168
  }
164
- // Skip original quantity set entities (IfcElementQuantity)
165
- for (const [relId, relRef] of this.dataStore.entityIndex.byId) {
166
- const relType = relRef.type.toUpperCase();
167
- if (relType === 'IFCRELDEFINESBYPROPERTIES') {
168
- const relatedEntities = this.getRelatedEntities(relId);
169
- const relatedPsetId = this.getRelatedPropertySet(relId);
170
- if (relatedEntities.includes(entityId) && relatedPsetId) {
171
- const qsetName = this.getElementQuantityName(relatedPsetId);
172
- if (qsetName && qsetNames.has(qsetName)) {
173
- skipRelationshipIds.add(relId);
174
- skipPropertySetIds.add(relatedPsetId);
175
- const quantIds = this.getPropertyIdsInSet(relatedPsetId);
176
- for (const quantId of quantIds) {
177
- skipPropertySetIds.add(quantId);
178
- }
169
+ // Skip original quantity set entities (IfcElementQuantity).
170
+ // Same per-entity index lookup as the property branch above.
171
+ const rels = relDefinesByEntity.get(entityId);
172
+ if (rels) {
173
+ for (const { relId, psetId: relatedPsetId } of rels) {
174
+ const qsetName = this.getElementQuantityName(relatedPsetId);
175
+ if (qsetName && qsetNames.has(qsetName)) {
176
+ skipRelationshipIds.add(relId);
177
+ skipPropertySetIds.add(relatedPsetId);
178
+ const quantIds = this.getPropertyIdsInSet(relatedPsetId);
179
+ for (const quantId of quantIds) {
180
+ skipPropertySetIds.add(quantId);
179
181
  }
180
182
  }
181
183
  }
@@ -279,12 +281,12 @@ export class StepExporter {
279
281
  const crs = gm.projectedCRS;
280
282
  const crsId = this.nextExpressId++;
281
283
  // IfcProjectedCRS(Name, Description, GeodeticDatum, VerticalDatum, MapProjection, MapZone, MapUnit)
282
- const name = crs.name ? `'${this.escapeStepString(String(crs.name))}'` : '$';
283
- const desc = crs.description ? `'${this.escapeStepString(String(crs.description))}'` : '$';
284
- const datum = crs.geodeticDatum ? `'${this.escapeStepString(String(crs.geodeticDatum))}'` : '$';
285
- const vDatum = crs.verticalDatum ? `'${this.escapeStepString(String(crs.verticalDatum))}'` : '$';
286
- const proj = crs.mapProjection ? `'${this.escapeStepString(String(crs.mapProjection))}'` : '$';
287
- const zone = crs.mapZone ? `'${this.escapeStepString(String(crs.mapZone))}'` : '$';
284
+ const name = crs.name ? `'${escapeStepString(String(crs.name))}'` : '$';
285
+ const desc = crs.description ? `'${escapeStepString(String(crs.description))}'` : '$';
286
+ const datum = crs.geodeticDatum ? `'${escapeStepString(String(crs.geodeticDatum))}'` : '$';
287
+ const vDatum = crs.verticalDatum ? `'${escapeStepString(String(crs.verticalDatum))}'` : '$';
288
+ const proj = crs.mapProjection ? `'${escapeStepString(String(crs.mapProjection))}'` : '$';
289
+ const zone = crs.mapZone ? `'${escapeStepString(String(crs.mapZone))}'` : '$';
288
290
  const mapUnitRef = crs.mapUnit
289
291
  ? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines)}`
290
292
  : '$';
@@ -295,12 +297,12 @@ export class StepExporter {
295
297
  if (contextId) {
296
298
  const mc = gm.mapConversion || {};
297
299
  const mcId = this.nextExpressId++;
298
- const eastings = this.toStepReal(Number(mc.eastings) || 0);
299
- const northings = this.toStepReal(Number(mc.northings) || 0);
300
- const height = this.toStepReal(Number(mc.orthogonalHeight) || 0);
301
- const abscissa = mc.xAxisAbscissa !== undefined ? this.toStepReal(Number(mc.xAxisAbscissa)) : '$';
302
- const ordinate = mc.xAxisOrdinate !== undefined ? this.toStepReal(Number(mc.xAxisOrdinate)) : '$';
303
- const scale = mc.scale !== undefined ? this.toStepReal(Number(mc.scale)) : '$';
300
+ const eastings = toStepReal(Number(mc.eastings) || 0);
301
+ const northings = toStepReal(Number(mc.northings) || 0);
302
+ const height = toStepReal(Number(mc.orthogonalHeight) || 0);
303
+ const abscissa = mc.xAxisAbscissa !== undefined ? toStepReal(Number(mc.xAxisAbscissa)) : '$';
304
+ const ordinate = mc.xAxisOrdinate !== undefined ? toStepReal(Number(mc.xAxisOrdinate)) : '$';
305
+ const scale = mc.scale !== undefined ? toStepReal(Number(mc.scale)) : '$';
304
306
  // IfcMapConversion(SourceCRS, TargetCRS, Eastings, Northings, OrthogonalHeight, XAxisAbscissa, XAxisOrdinate, Scale)
305
307
  newGeorefLines.push(`#${mcId}=IFCMAPCONVERSION(#${contextId},#${crsId},${eastings},${northings},${height},${abscissa},${ordinate},${scale});`);
306
308
  newEntityCount++;
@@ -315,12 +317,12 @@ export class StepExporter {
315
317
  if (contextId) {
316
318
  const mc = gm.mapConversion;
317
319
  const mcId = this.nextExpressId++;
318
- const eastings = this.toStepReal(Number(mc.eastings) || 0);
319
- const northings = this.toStepReal(Number(mc.northings) || 0);
320
- const height = this.toStepReal(Number(mc.orthogonalHeight) || 0);
321
- const abscissa = mc.xAxisAbscissa !== undefined ? this.toStepReal(Number(mc.xAxisAbscissa)) : '$';
322
- const ordinate = mc.xAxisOrdinate !== undefined ? this.toStepReal(Number(mc.xAxisOrdinate)) : '$';
323
- const scale = mc.scale !== undefined ? this.toStepReal(Number(mc.scale)) : '$';
320
+ const eastings = toStepReal(Number(mc.eastings) || 0);
321
+ const northings = toStepReal(Number(mc.northings) || 0);
322
+ const height = toStepReal(Number(mc.orthogonalHeight) || 0);
323
+ const abscissa = mc.xAxisAbscissa !== undefined ? toStepReal(Number(mc.xAxisAbscissa)) : '$';
324
+ const ordinate = mc.xAxisOrdinate !== undefined ? toStepReal(Number(mc.xAxisOrdinate)) : '$';
325
+ const scale = mc.scale !== undefined ? toStepReal(Number(mc.scale)) : '$';
324
326
  newGeorefLines.push(`#${mcId}=IFCMAPCONVERSION(#${contextId},#${existingCrsIds[0]},${eastings},${northings},${height},${abscissa},${ordinate},${scale});`);
325
327
  newEntityCount++;
326
328
  }
@@ -329,8 +331,18 @@ export class StepExporter {
329
331
  }
330
332
  }
331
333
  }
332
- // If delta only, only export modified entities
333
- if (options.deltaOnly && modifiedEntities.size === 0) {
334
+ // If delta only, only export modified entities. Overlay-created entities
335
+ // also count without this, `createEntity()`-only edits would silently
336
+ // drop out of delta exports.
337
+ const overlayNewEntityCount = (this.mutationView
338
+ && options.applyMutations !== false
339
+ && typeof this.mutationView.getNewEntities === 'function') ? this.mutationView.getNewEntities().length : 0;
340
+ // Georef-only deltas (newGeorefLines populated but no entity changes) must
341
+ // still produce a non-empty DATA section.
342
+ if (options.deltaOnly
343
+ && modifiedEntities.size === 0
344
+ && overlayNewEntityCount === 0
345
+ && newGeorefLines.length === 0) {
334
346
  const emptyContent = new TextEncoder().encode(header + 'DATA;\nENDSEC;\nEND-ISO-10303-21;\n');
335
347
  return {
336
348
  content: emptyContent,
@@ -354,10 +366,18 @@ export class StepExporter {
354
366
  }
355
367
  // Export original entities from source buffer, SKIPPING modified property sets
356
368
  if (!options.deltaOnly && this.dataStore.source) {
357
- const decoder = new TextDecoder();
358
369
  const source = this.dataStore.source;
359
370
  // Extract existing entities from source
371
+ const overlayActive = !!this.mutationView && (options.applyMutations !== false);
360
372
  for (const [expressId, entityRef] of this.dataStore.entityIndex.byId) {
373
+ // Skip entities deleted via the overlay (only when mutations are applied)
374
+ if (overlayActive && typeof this.mutationView.isDeleted === 'function' && this.mutationView.isDeleted(expressId)) {
375
+ continue;
376
+ }
377
+ // Skip overlay-only entities — emitted by the new-entities pass below
378
+ if (entityRef.byteLength === 0 || entityRef.byteOffset < 0) {
379
+ continue;
380
+ }
361
381
  // Skip entities outside the visible closure
362
382
  if (allowedEntityIds !== null && !allowedEntityIds.has(expressId)) {
363
383
  continue;
@@ -376,11 +396,24 @@ export class StepExporter {
376
396
  if (options.includeGeometry === false && this.isGeometryEntity(entityType)) {
377
397
  continue;
378
398
  }
379
- // Get original entity text
380
- const entityText = decoder.decode(source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
381
- const nextEntityText = modifiedAttributes.has(expressId)
399
+ // Get original entity text — safeUtf8Decode handles SAB-backed
400
+ // sources (Firefox/Chrome reject `TextDecoder.decode()` on a
401
+ // SharedArrayBuffer-backed view; the parser deliberately keeps
402
+ // `source` zero-copy SAB-backed for worker sharing).
403
+ const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
404
+ let nextEntityText = modifiedAttributes.has(expressId)
382
405
  ? this.applyAttributeMutations(entityText, entityType, modifiedAttributes.get(expressId))
383
406
  : entityText;
407
+ const positional = overlayActive && typeof this.mutationView.getPositionalMutationsForEntity === 'function'
408
+ ? this.mutationView.getPositionalMutationsForEntity(expressId)
409
+ : null;
410
+ if (positional && positional.size > 0) {
411
+ nextEntityText = this.applyPositionalMutations(nextEntityText, positional);
412
+ if (!modifiedEntities.has(expressId)) {
413
+ modifiedEntities.add(expressId);
414
+ modifiedEntityCount++;
415
+ }
416
+ }
384
417
  // Apply schema conversion if exporting to a different schema version
385
418
  if (converting) {
386
419
  const converted = convertStepLine(nextEntityText, sourceSchema, schema);
@@ -429,6 +462,39 @@ export class StepExporter {
429
462
  for (const line of newGeorefLines) {
430
463
  entities.push(line);
431
464
  }
465
+ // Add overlay-created entities (store.addEntity / mutationView.createEntity).
466
+ // Apply the same filters as the source-iteration pass so newly-created
467
+ // beams/slabs don't smuggle their geometry helpers (IfcCartesianPoint,
468
+ // IfcExtrudedAreaSolid, etc.) past `includeGeometry:false` /
469
+ // `exportPropertiesOnly()` modes.
470
+ if (this.mutationView
471
+ && (options.applyMutations !== false)
472
+ && typeof this.mutationView.getNewEntities === 'function') {
473
+ for (const entity of this.mutationView.getNewEntities()) {
474
+ // STEP requires UPPERCASE entity type tokens. `NewEntity.type` is
475
+ // stored in canonical PascalCase per the public API contract; the
476
+ // upper-case happens here at the file-format boundary.
477
+ const upperType = entity.type.toUpperCase();
478
+ if (options.includeGeometry === false && this.isGeometryEntity(upperType)) {
479
+ continue;
480
+ }
481
+ if (allowedEntityIds !== null && !allowedEntityIds.has(entity.expressId)) {
482
+ continue;
483
+ }
484
+ const line = `#${entity.expressId}=${upperType}(${serializeStepArgs(entity.attributes)});`;
485
+ if (converting) {
486
+ const converted = convertStepLine(line, sourceSchema, schema);
487
+ if (converted !== null) {
488
+ entities.push(converted);
489
+ newEntityCount++;
490
+ }
491
+ }
492
+ else {
493
+ entities.push(line);
494
+ newEntityCount++;
495
+ }
496
+ }
497
+ }
432
498
  // Assemble final file as Uint8Array chunks to avoid V8 string length limit
433
499
  const content = assembleStepBytes(header, entities);
434
500
  return {
@@ -486,11 +552,11 @@ export class StepExporter {
486
552
  for (const prop of pset.properties) {
487
553
  const propId = this.nextExpressId++;
488
554
  count++;
489
- const valueStr = this.serializePropertyValue(prop.value, prop.type);
555
+ const valueStr = serializePropertyValue(prop.value, prop.type);
490
556
  const unitId = prop.unit ? this.findUnitId(prop.unit) : null;
491
557
  const unitStr = unitId !== null ? ref(unitId) : null;
492
558
  // #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
493
- const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${this.escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
559
+ const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
494
560
  lines.push(line);
495
561
  propertyIds.push(propId);
496
562
  }
@@ -500,7 +566,7 @@ export class StepExporter {
500
566
  const propRefs = propertyIds.map(id => `#${id}`).join(',');
501
567
  const globalId = this.generateGlobalId();
502
568
  // #ID=IFCPROPERTYSET('GlobalId',$,'Name',$,(#props));
503
- const psetLine = `#${psetId}=IFCPROPERTYSET('${globalId}',$,'${this.escapeStepString(pset.name)}',$,(${propRefs}));`;
569
+ const psetLine = `#${psetId}=IFCPROPERTYSET('${globalId}',$,'${escapeStepString(pset.name)}',$,(${propRefs}));`;
504
570
  lines.push(psetLine);
505
571
  if (typeOwnedPsetNames?.has(pset.name)) {
506
572
  generatedTypeOwnedPsetIds.set(pset.name, psetId);
@@ -528,10 +594,10 @@ export class StepExporter {
528
594
  for (const q of qset.quantities) {
529
595
  const qId = this.nextExpressId++;
530
596
  count++;
531
- const ifcType = this.quantityTypeToIfcType(q.type);
597
+ const ifcType = quantityTypeToIfcType(q.type);
532
598
  // #ID=IFCQUANTITYLENGTH('Name',$,$,Value,$);
533
- const val = this.toStepReal(q.value);
534
- const line = `#${qId}=${ifcType}('${this.escapeStepString(q.name)}',$,$,${val},$);`;
599
+ const val = toStepReal(q.value);
600
+ const line = `#${qId}=${ifcType}('${escapeStepString(q.name)}',$,$,${val},$);`;
535
601
  lines.push(line);
536
602
  quantityIds.push(qId);
537
603
  }
@@ -541,7 +607,7 @@ export class StepExporter {
541
607
  const quantRefs = quantityIds.map(id => `#${id}`).join(',');
542
608
  const globalId = this.generateGlobalId();
543
609
  // #ID=IFCELEMENTQUANTITY('GlobalId',$,'Name',$,$,(#quants));
544
- const qsetLine = `#${qsetId}=IFCELEMENTQUANTITY('${globalId}',$,'${this.escapeStepString(qset.name)}',$,$,(${quantRefs}));`;
610
+ const qsetLine = `#${qsetId}=IFCELEMENTQUANTITY('${globalId}',$,'${escapeStepString(qset.name)}',$,$,(${quantRefs}));`;
545
611
  lines.push(qsetLine);
546
612
  // Create IfcRelDefinesByProperties to link qset to entity
547
613
  const relId = this.nextExpressId++;
@@ -552,60 +618,6 @@ export class StepExporter {
552
618
  }
553
619
  return { lines, count };
554
620
  }
555
- /**
556
- * Map QuantityType to IFC STEP entity type
557
- */
558
- quantityTypeToIfcType(type) {
559
- switch (type) {
560
- case QuantityType.Length: return 'IFCQUANTITYLENGTH';
561
- case QuantityType.Area: return 'IFCQUANTITYAREA';
562
- case QuantityType.Volume: return 'IFCQUANTITYVOLUME';
563
- case QuantityType.Count: return 'IFCQUANTITYCOUNT';
564
- case QuantityType.Weight: return 'IFCQUANTITYWEIGHT';
565
- case QuantityType.Time: return 'IFCQUANTITYTIME';
566
- default: return 'IFCQUANTITYCOUNT';
567
- }
568
- }
569
- /**
570
- * Serialize a property value to STEP format
571
- */
572
- serializePropertyValue(value, type) {
573
- if (value === null || value === undefined) {
574
- return '$';
575
- }
576
- switch (type) {
577
- case PropertyValueType.String:
578
- case PropertyValueType.Label:
579
- case PropertyValueType.Text:
580
- return `IFCLABEL('${this.escapeStepString(String(value))}')`;
581
- case PropertyValueType.Identifier:
582
- return `IFCIDENTIFIER('${this.escapeStepString(String(value))}')`;
583
- case PropertyValueType.Real:
584
- const num = Number(value);
585
- if (!Number.isFinite(num))
586
- return '$';
587
- return `IFCREAL(${num.toString().includes('.') ? num : num + '.'})`;
588
- case PropertyValueType.Integer:
589
- return `IFCINTEGER(${Math.round(Number(value))})`;
590
- case PropertyValueType.Boolean:
591
- case PropertyValueType.Logical:
592
- if (value === true)
593
- return `IFCBOOLEAN(.T.)`;
594
- if (value === false)
595
- return `IFCBOOLEAN(.F.)`;
596
- return `IFCLOGICAL(.U.)`;
597
- case PropertyValueType.Enum:
598
- return `.${String(value).toUpperCase()}.`;
599
- case PropertyValueType.List:
600
- if (Array.isArray(value)) {
601
- const items = value.map(v => this.serializePropertyValue(v, PropertyValueType.String));
602
- return `(${items.join(',')})`;
603
- }
604
- return '$';
605
- default:
606
- return `IFCLABEL('${this.escapeStepString(String(value))}')`;
607
- }
608
- }
609
621
  /**
610
622
  * Rewrite root IFC attributes directly on the original STEP entity line.
611
623
  */
@@ -619,13 +631,13 @@ export class StepExporter {
619
631
  if (attrNames.length === 0) {
620
632
  return entityText;
621
633
  }
622
- const args = this.splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
634
+ const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
623
635
  let changed = false;
624
636
  for (const [attrName, value] of attributeMutations) {
625
637
  const index = attrNames.indexOf(attrName);
626
638
  if (index < 0 || index >= args.length)
627
639
  continue;
628
- args[index] = this.serializeAttributeValue(value, args[index]);
640
+ args[index] = serializeAttributeValue(value, args[index]);
629
641
  changed = true;
630
642
  }
631
643
  if (!changed) {
@@ -633,77 +645,28 @@ export class StepExporter {
633
645
  }
634
646
  return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
635
647
  }
636
- serializeAttributeValue(value, currentToken) {
637
- const trimmed = value.trim();
638
- const current = currentToken.trim();
639
- if (value === '')
640
- return '$';
641
- if (trimmed === '$' || trimmed === '*')
642
- return trimmed;
643
- if (/^#\d+$/.test(trimmed))
644
- return trimmed;
645
- if (/^\.[A-Z0-9_]+\.$/i.test(current) || /^\.[A-Z0-9_]+\.$/i.test(trimmed)) {
646
- return `.${trimmed.replace(/^\./, '').replace(/\.$/, '').toUpperCase()}.`;
647
- }
648
- if (/^(?:\.T\.|\.F\.|\.U\.)$/i.test(current)) {
649
- const normalized = trimmed.toLowerCase();
650
- if (normalized === 'true' || normalized === '.t.')
651
- return '.T.';
652
- if (normalized === 'false' || normalized === '.f.')
653
- return '.F.';
654
- return '.U.';
655
- }
656
- if (/^-?\d+(?:\.\d+)?(?:E[+-]?\d+)?$/i.test(trimmed) && /^-?\d/.test(current)) {
657
- const numberValue = Number(trimmed);
658
- if (!Number.isFinite(numberValue))
659
- return '$';
660
- return current.includes('.') || /E/i.test(current)
661
- ? this.toStepReal(numberValue)
662
- : String(numberValue);
663
- }
664
- return serializeValue(value);
665
- }
666
- splitTopLevelArgs(text) {
667
- const parts = [];
668
- let current = '';
669
- let depth = 0;
670
- let inString = false;
671
- for (let i = 0; i < text.length; i++) {
672
- const char = text[i];
673
- current += char;
674
- if (inString) {
675
- if (char === '\'') {
676
- if (text[i + 1] === '\'') {
677
- current += text[i + 1];
678
- i++;
679
- }
680
- else {
681
- inString = false;
682
- }
683
- }
684
- continue;
685
- }
686
- if (char === '\'') {
687
- inString = true;
688
- continue;
689
- }
690
- if (char === '(') {
691
- depth++;
692
- continue;
693
- }
694
- if (char === ')') {
695
- depth--;
648
+ /**
649
+ * Apply positional STEP argument overrides to an entity line.
650
+ * Used for non-IfcRoot edits (e.g. profile dimensions) where attributes
651
+ * have no symbolic names. Indexes that fall outside the existing arg list
652
+ * are silently ignored.
653
+ */
654
+ applyPositionalMutations(entityText, positionals) {
655
+ const openParen = entityText.indexOf('(');
656
+ const closeParen = entityText.lastIndexOf(');');
657
+ if (openParen < 0 || closeParen < openParen)
658
+ return entityText;
659
+ const args = splitTopLevelArgs(entityText.slice(openParen + 1, closeParen));
660
+ let changed = false;
661
+ for (const [index, value] of positionals) {
662
+ if (index < 0 || index >= args.length)
696
663
  continue;
697
- }
698
- if (char === ',' && depth === 0) {
699
- parts.push(current.slice(0, -1).trim());
700
- current = '';
701
- }
702
- }
703
- if (current.trim() || text.endsWith(',')) {
704
- parts.push(current.trim());
664
+ args[index] = serializeStepValue(value);
665
+ changed = true;
705
666
  }
706
- return parts;
667
+ if (!changed)
668
+ return entityText;
669
+ return `${entityText.slice(0, openParen + 1)}${args.join(',')}${entityText.slice(closeParen)}`;
707
670
  }
708
671
  resolveMapUnitReference(unitName, newGeorefLines) {
709
672
  const normalized = this.normalizeMapUnitName(unitName);
@@ -725,7 +688,7 @@ export class StepExporter {
725
688
  const name = normalized === 'US SURVEY FOOT' ? 'US SURVEY FOOT' : 'FOOT';
726
689
  newGeorefLines.push(`#${dimId}=IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0);`);
727
690
  newGeorefLines.push(`#${siUnitId}=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);`);
728
- newGeorefLines.push(`#${measureId}=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(${this.toStepReal(factor)}),#${siUnitId});`);
691
+ newGeorefLines.push(`#${measureId}=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(${toStepReal(factor)}),#${siUnitId});`);
729
692
  newGeorefLines.push(`#${convUnitId}=IFCCONVERSIONBASEDUNIT(#${dimId},.LENGTHUNIT.,'${name}',#${measureId});`);
730
693
  return convUnitId;
731
694
  }
@@ -808,24 +771,6 @@ export class StepExporter {
808
771
  }
809
772
  return first3dContext ?? contextIds[0] ?? null;
810
773
  }
811
- /**
812
- * Convert a number to a valid STEP REAL literal.
813
- * Handles NaN/Infinity (→ 0.) and ensures a decimal point is present.
814
- */
815
- toStepReal(v) {
816
- if (!Number.isFinite(v))
817
- return '0.';
818
- const s = v.toString();
819
- return s.includes('.') ? s : s + '.';
820
- }
821
- /**
822
- * Escape a string for STEP format
823
- */
824
- escapeStepString(str) {
825
- return str
826
- .replace(/\\/g, '\\\\')
827
- .replace(/'/g, "''");
828
- }
829
774
  /**
830
775
  * Generate a new IFC GlobalId (22 character base64)
831
776
  */
@@ -888,6 +833,31 @@ export class StepExporter {
888
833
  ]);
889
834
  return geometryTypes.has(type);
890
835
  }
836
+ /**
837
+ * Build a one-shot reverse index of every IfcRelDefinesByProperties in
838
+ * the source: for each related entity, list the rels and property/quantity
839
+ * sets that reference it. Used by the export pre-pass so the per-entity
840
+ * "find owning rels" step is O(K) rather than O(N) per modified entity.
841
+ */
842
+ buildRelDefinesByPropertiesIndex() {
843
+ const out = new Map();
844
+ for (const [relId, relRef] of this.dataStore.entityIndex.byId) {
845
+ if (relRef.type.toUpperCase() !== 'IFCRELDEFINESBYPROPERTIES')
846
+ continue;
847
+ const psetId = this.getRelatedPropertySet(relId);
848
+ if (!psetId)
849
+ continue;
850
+ for (const entityId of this.getRelatedEntities(relId)) {
851
+ let bucket = out.get(entityId);
852
+ if (!bucket) {
853
+ bucket = [];
854
+ out.set(entityId, bucket);
855
+ }
856
+ bucket.push({ relId, psetId });
857
+ }
858
+ }
859
+ return out;
860
+ }
891
861
  /**
892
862
  * Get entity IDs related by IfcRelDefinesByProperties (the related objects)
893
863
  */
@@ -895,8 +865,7 @@ export class StepExporter {
895
865
  const entityRef = this.dataStore.entityIndex.byId.get(relId);
896
866
  if (!entityRef || !this.dataStore.source)
897
867
  return [];
898
- const decoder = new TextDecoder();
899
- const entityText = decoder.decode(this.dataStore.source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
868
+ const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
900
869
  // Parse IfcRelDefinesByProperties: #ID=IFCRELDEFINESBYPROPERTIES('guid',$,$,$,(#objects),#pset);
901
870
  // The 5th argument (index 4) is the list of related objects
902
871
  const match = entityText.match(/\(([^)]+)\)\s*,\s*#(\d+)\s*\)\s*;/);
@@ -917,8 +886,7 @@ export class StepExporter {
917
886
  const entityRef = this.dataStore.entityIndex.byId.get(relId);
918
887
  if (!entityRef || !this.dataStore.source)
919
888
  return null;
920
- const decoder = new TextDecoder();
921
- const entityText = decoder.decode(this.dataStore.source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
889
+ const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
922
890
  // Last #ID before the closing );
923
891
  const match = entityText.match(/,\s*#(\d+)\s*\)\s*;$/);
924
892
  if (!match)
@@ -932,8 +900,7 @@ export class StepExporter {
932
900
  const entityRef = this.dataStore.entityIndex.byId.get(psetId);
933
901
  if (!entityRef || !this.dataStore.source)
934
902
  return null;
935
- const decoder = new TextDecoder();
936
- const entityText = decoder.decode(this.dataStore.source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
903
+ const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
937
904
  // Parse: IFCPROPERTYSET('guid',$,'Name',$,...) - Name is 3rd argument
938
905
  const match = entityText.match(/IFCPROPERTYSET\s*\([^,]*,[^,]*,'([^']*)'/i);
939
906
  if (!match)
@@ -947,8 +914,7 @@ export class StepExporter {
947
914
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
948
915
  if (!entityRef || !this.dataStore.source)
949
916
  return null;
950
- const decoder = new TextDecoder();
951
- const entityText = decoder.decode(this.dataStore.source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
917
+ const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
952
918
  // Parse: IFCELEMENTQUANTITY('guid',$,'Name',...) - Name is 3rd argument
953
919
  const match = entityText.match(/IFCELEMENTQUANTITY\s*\([^,]*,[^,]*,'([^']*)'/i);
954
920
  if (!match)
@@ -962,8 +928,7 @@ export class StepExporter {
962
928
  const entityRef = this.dataStore.entityIndex.byId.get(psetId);
963
929
  if (!entityRef || !this.dataStore.source)
964
930
  return [];
965
- const decoder = new TextDecoder();
966
- const entityText = decoder.decode(this.dataStore.source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
931
+ const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
967
932
  // Parse: IFCPROPERTYSET(...,(#prop1,#prop2,...)); - Last argument is properties list
968
933
  const match = entityText.match(/\(\s*(#[^)]+)\s*\)\s*\)\s*;$/);
969
934
  if (!match)
@@ -1034,54 +999,17 @@ export class StepExporter {
1034
999
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1035
1000
  if (!entityRef || !this.dataStore.source)
1036
1001
  return null;
1037
- const decoder = new TextDecoder();
1038
- const entityText = decoder.decode(this.dataStore.source.subarray(entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength));
1002
+ const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1039
1003
  const match = entityText.match(/^(#\d+\s*=\s*\w+\()([\s\S]*)(\)\s*;)\s*$/);
1040
1004
  if (!match)
1041
1005
  return null;
1042
1006
  const [, prefix, attrsText, suffix] = match;
1043
- const attrs = this.splitTopLevelStepArguments(attrsText);
1007
+ const attrs = splitTopLevelStepArguments(attrsText);
1044
1008
  if (attrIndex >= attrs.length)
1045
1009
  return null;
1046
1010
  attrs[attrIndex] = replacement;
1047
1011
  return `${prefix}${attrs.join(',')}${suffix}`;
1048
1012
  }
1049
- /**
1050
- * Split a STEP argument list on top-level commas while preserving nested syntax.
1051
- */
1052
- splitTopLevelStepArguments(input) {
1053
- const parts = [];
1054
- let current = '';
1055
- let depth = 0;
1056
- let inString = false;
1057
- for (let i = 0; i < input.length; i++) {
1058
- const char = input[i];
1059
- if (char === "'") {
1060
- current += char;
1061
- if (inString && i + 1 < input.length && input[i + 1] === "'") {
1062
- current += input[i + 1];
1063
- i++;
1064
- continue;
1065
- }
1066
- inString = !inString;
1067
- continue;
1068
- }
1069
- if (!inString) {
1070
- if (char === '(')
1071
- depth++;
1072
- else if (char === ')')
1073
- depth--;
1074
- else if (char === ',' && depth === 0) {
1075
- parts.push(current);
1076
- current = '';
1077
- continue;
1078
- }
1079
- }
1080
- current += char;
1081
- }
1082
- parts.push(current);
1083
- return parts;
1084
- }
1085
1013
  }
1086
1014
  /**
1087
1015
  * Quick export function for simple use cases.