@ifc-lite/export 2.8.0 → 2.8.3

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 (41) hide show
  1. package/README.md +13 -0
  2. package/dist/demesh-writer.d.ts.map +1 -1
  3. package/dist/demesh-writer.js +8 -4
  4. package/dist/demesh-writer.js.map +1 -1
  5. package/dist/effective-index.d.ts +16 -1
  6. package/dist/effective-index.d.ts.map +1 -1
  7. package/dist/effective-index.js +68 -0
  8. package/dist/effective-index.js.map +1 -1
  9. package/dist/glb.d.ts.map +1 -1
  10. package/dist/glb.js +12 -0
  11. package/dist/glb.js.map +1 -1
  12. package/dist/ifc5-exporter.d.ts +29 -3
  13. package/dist/ifc5-exporter.d.ts.map +1 -1
  14. package/dist/ifc5-exporter.js +170 -55
  15. package/dist/ifc5-exporter.js.map +1 -1
  16. package/dist/lod0-generator.d.ts.map +1 -1
  17. package/dist/lod0-generator.js +10 -4
  18. package/dist/lod0-generator.js.map +1 -1
  19. package/dist/lod1-generator.d.ts.map +1 -1
  20. package/dist/lod1-generator.js +5 -0
  21. package/dist/lod1-generator.js.map +1 -1
  22. package/dist/merged-exporter.d.ts.map +1 -1
  23. package/dist/merged-exporter.js +14 -5
  24. package/dist/merged-exporter.js.map +1 -1
  25. package/dist/parquet-exporter.d.ts +28 -1
  26. package/dist/parquet-exporter.d.ts.map +1 -1
  27. package/dist/parquet-exporter.js +144 -13
  28. package/dist/parquet-exporter.js.map +1 -1
  29. package/dist/reference-collector.d.ts +4 -4
  30. package/dist/reference-collector.d.ts.map +1 -1
  31. package/dist/reference-collector.js +64 -18
  32. package/dist/reference-collector.js.map +1 -1
  33. package/dist/step-exporter.d.ts +38 -0
  34. package/dist/step-exporter.d.ts.map +1 -1
  35. package/dist/step-exporter.js +192 -55
  36. package/dist/step-exporter.js.map +1 -1
  37. package/dist/type-owned-psets.d.ts +4 -2
  38. package/dist/type-owned-psets.d.ts.map +1 -1
  39. package/dist/type-owned-psets.js +4 -2
  40. package/dist/type-owned-psets.js.map +1 -1
  41. package/package.json +7 -7
@@ -1,8 +1,7 @@
1
1
  /* This Source Code Form is subject to the terms of the Mozilla Public
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
- import { EntityExtractor, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
5
- import { safeUtf8Decode } from '@ifc-lite/data';
4
+ import { asSourceBytes, EntityExtractor, generateHeader, parseSourceHeader, getAttributeNamesAcrossSchemas, serializeValue, ref, } from '@ifc-lite/parser';
6
5
  import { generateIfcGuid } from '@ifc-lite/encoding';
7
6
  import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
8
7
  import { convertStepLine, needsConversion } from './schema-converter.js';
@@ -14,8 +13,32 @@ import { escapeStepString, toStepReal, quantityTypeToIfcType, serializePropertyV
14
13
  import { getRealTypedSlots, serializeEntityArgs, serializeAttributeSlot, isTypedMarker } from './attribute-real-slots.js';
15
14
  import { getEnumTypedSlots, getStringTypedSlots, serializeEnumToken, serializeStringSlot, } from './attribute-slot-types.js';
16
15
  import { serializeQualifiedSelectSlot } from './select-qualification.js';
16
+ /**
17
+ * UTF-8 decode of `[start, end)` of the source, accepting either the raw bytes
18
+ * or the {@link IfcSourceBytes} accessor (#2183). Replaces the direct
19
+ * `safeUtf8Decode(source, …)` calls this file used to make: `decodeUtf8` is
20
+ * SAB-safe in exactly the same way, and routing through the accessor is what
21
+ * lets `IfcDataStore.source` change shape without touching these eight reads.
22
+ */
23
+ function decodeRange(src, start, end) {
24
+ return asSourceBytes(src).decodeUtf8(start, end);
25
+ }
17
26
  /** `OwnerHistory` is slot 1 on every `IfcRoot` subtype, all schemas. */
18
27
  const OWNER_HISTORY_SLOT = 1;
28
+ /**
29
+ * Message for the one refusal `export()` can report, shared by the returned
30
+ * `stats.warnings` entry and the console line so the two cannot drift.
31
+ */
32
+ const MAP_CONVERSION_WITHOUT_CONTEXT_WARNING = 'Cannot create IfcMapConversion: no IfcGeometricRepresentationContext is available to reference as SourceCRS. The IfcProjectedCRS is unaffected.';
33
+ /**
34
+ * Message for the refusal `export()` reports when a map conversion is
35
+ * requested but there is no IfcProjectedCRS to attach it to — none was
36
+ * requested and none exists in the file — distinct from
37
+ * {@link MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}, which is worded for the
38
+ * case where an IfcProjectedCRS exists (or was written) but no context is
39
+ * available to reference.
40
+ */
41
+ const MAP_CONVERSION_WITHOUT_CRS_WARNING = 'Cannot create IfcMapConversion: no IfcProjectedCRS was requested and none exists in the file to reference as TargetCRS. Nothing was written.';
19
42
  /**
20
43
  * IFC STEP file exporter
21
44
  */
@@ -69,7 +92,9 @@ export class StepExporter {
69
92
  // `sourceHeader`; fall back to parsing the (always-present) source bytes so
70
93
  // cache-restored stores — which don't carry `sourceHeader` — still work.
71
94
  const sourceHeader = this.dataStore.sourceHeader
72
- ?? (this.dataStore.source ? parseSourceHeader(this.dataStore.source) : undefined);
95
+ ?? (this.dataStore.source.byteLength > 0
96
+ ? parseSourceHeader(this.dataStore.source)
97
+ : undefined);
73
98
  // Preserve the exact FILE_SCHEMA identifier (e.g. IFC4X3_ADD2) only when we
74
99
  // are NOT converting schemas; conversion must emit the coarse target token.
75
100
  const schemaToken = !converting && sourceHeader?.schemaIdentifiers?.[0]
@@ -115,6 +140,60 @@ export class StepExporter {
115
140
  // entity has no source bytes, so the source-iteration pass below never sees
116
141
  // it and the new-entities pass at the end owns its line entirely (#2006).
117
142
  const isOverlayCreated = (entityId) => effective.isOverlayCreated(entityId);
143
+ // Build visible-only closure if requested. Classification, the closure walk
144
+ // and the style pass all run over the EFFECTIVE index: an overlay-created
145
+ // product becomes a root by the same type rules as a parsed one, the walk
146
+ // follows its authored references into the geometry it alone owns, and a
147
+ // tombstoned entity is simply not there. Run over the source buffer, a
148
+ // created wall could never be a root and nothing referenced it, so
149
+ // `visibleOnly` wrote a file without it and said nothing (#2012).
150
+ //
151
+ // Computed here, ahead of the modification-count passes below, because
152
+ // `hasEmittableHostBytes` needs it: a source-backed host EXCLUDED by
153
+ // `visibleOnly` never gets its line written by the source-iteration pass
154
+ // either, so counting it as "modified" would make the header claim a
155
+ // change the DATA section does not contain (CodeRabbit finding on #2414).
156
+ let allowedEntityIds = null;
157
+ if (options.visibleOnly && this.dataStore.source) {
158
+ const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
159
+ allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
160
+ // Second pass: collect IFCSTYLEDITEM entities that reference included
161
+ // geometry. Styled items reference geometry items but nothing references
162
+ // them back, so the forward closure misses them.
163
+ collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
164
+ }
165
+ // Will THIS entity's own line ever land in the file? The same byte-range
166
+ // test `willBeEmitted` uses (defined further below) and the source-
167
+ // iteration pass's own skip at `entityRef.byteLength === 0` — a source
168
+ // entity with no bytes (a point-cloud / GLB "entity" from
169
+ // `createSyntheticDataStore`, not an overlay-created one) never gets a
170
+ // defining line written, source-iteration or otherwise, so a pset/attribute
171
+ // edit against it must not count as a modification either: the header
172
+ // would describe a change the file does not contain (out-of-scope finding
173
+ // in #2398). Also excludes a source-backed host the visible-only closure
174
+ // above drops — same reasoning, different reason the line never lands.
175
+ //
176
+ // And, like `willBeEmitted` below, excludes a geometry-classified SOURCE
177
+ // host under `includeGeometry: false`: the source-iteration pass's own
178
+ // `isGeometryEntity` skip (further below) drops that line too, so this
179
+ // predicate must agree or a geometry entity's attribute edit inflates the
180
+ // count over an omitted line (CodeRabbit finding on #2414). Guarded by
181
+ // `!deltaOnly` for the same reason `willBeEmitted` is: under `deltaOnly`
182
+ // the source-iteration pass — and its geometry skip — never runs at all,
183
+ // so a source entity's line is assumed to already exist in the file being
184
+ // patched, geometry or not.
185
+ const isGeometryExcluded = (entityId, recordType) => options.includeGeometry === false
186
+ && this.isGeometryEntity(effective.effectiveType(entityId, recordType));
187
+ const hasEmittableHostBytes = (entityId) => {
188
+ if (allowedEntityIds !== null && !allowedEntityIds.has(entityId))
189
+ return false;
190
+ const ref = effective.get(entityId);
191
+ if (!ref || ref.byteLength <= 0 || ref.byteOffset < 0)
192
+ return false;
193
+ if (options.deltaOnly !== true && isGeometryExcluded(entityId, ref.type))
194
+ return false;
195
+ return true;
196
+ };
118
197
  // Collect entities that need to be modified or created
119
198
  const modifiedEntities = new Set();
120
199
  const modifiedPsets = new Map(); // entityId -> psetNames being modified
@@ -216,7 +295,7 @@ export class StepExporter {
216
295
  // `newEntityCount` — as are the pset entities this loop goes on to
217
296
  // generate. Only the COUNT is guarded; the entity still records its
218
297
  // pset edits and still emits them.
219
- if (!isOverlayCreated(entityId))
298
+ if (!isOverlayCreated(entityId) && hasEmittableHostBytes(entityId))
220
299
  modifiedEntityCount++;
221
300
  // Get the FULL mutated property sets for this entity (merged base + mutations)
222
301
  const allPsets = this.mutationView.getForEntity(entityId);
@@ -282,7 +361,7 @@ export class StepExporter {
282
361
  modifiedEntities.add(entityId);
283
362
  // See the property loop above — an overlay-created entity is counted as
284
363
  // new, not modified.
285
- if (!isOverlayCreated(entityId) && !modifiedPsets.has(entityId))
364
+ if (!isOverlayCreated(entityId) && !modifiedPsets.has(entityId) && hasEmittableHostBytes(entityId))
286
365
  modifiedEntityCount++;
287
366
  const allQsets = this.mutationView.getQuantitiesForEntity(entityId);
288
367
  const relevantQsets = allQsets.filter((qset) => qsetNames.has(qset.name));
@@ -313,6 +392,11 @@ export class StepExporter {
313
392
  // one created-then-renamed wall.
314
393
  if (isOverlayCreated(entityId))
315
394
  continue;
395
+ // A source entity with no bytes never gets its line rewritten (the
396
+ // source-iteration pass skips it), so an attribute edit against it
397
+ // must not inflate the count either.
398
+ if (!hasEmittableHostBytes(entityId))
399
+ continue;
316
400
  if (!entityPropMutations.has(entityId) && !entityQuantMutations.has(entityId)) {
317
401
  modifiedEntityCount++;
318
402
  }
@@ -320,10 +404,19 @@ export class StepExporter {
320
404
  }
321
405
  // Process georeferencing mutations (only when applyMutations is enabled)
322
406
  const newGeorefLines = [];
407
+ const warnings = [];
323
408
  if (options.applyMutations !== false && options.georefMutations) {
324
409
  const gm = options.georefMutations;
325
- const existingCrsIds = this.dataStore.entityIndex.byType.get('IFCPROJECTEDCRS');
326
- const existingMcIds = this.dataStore.entityIndex.byType.get('IFCMAPCONVERSION');
410
+ // `effective.byType`, not the raw index: a source IfcProjectedCRS the
411
+ // session tombstoned is still in `dataStore.entityIndex`, so the modify
412
+ // branch below would queue attribute edits against an id the
413
+ // source-iteration pass then skips — the replacement georeferencing
414
+ // vanishes from the file with no error. `effective.byType` drops
415
+ // tombstones and adds overlay-created records, which the new-entities
416
+ // pass applies `modifiedAttributes` to, so both branches agree on which
417
+ // georeferencing entities exist (#2048).
418
+ const existingCrsIds = effective.byType.get('IFCPROJECTEDCRS');
419
+ const existingMcIds = effective.byType.get('IFCMAPCONVERSION');
327
420
  // Modify existing IfcProjectedCRS
328
421
  if (gm.projectedCRS && existingCrsIds?.length) {
329
422
  const entityId = existingCrsIds[0];
@@ -358,13 +451,14 @@ export class StepExporter {
358
451
  changed = true;
359
452
  }
360
453
  if (crs.mapUnit !== undefined) {
361
- const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines);
454
+ const mapUnitRef = this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective);
362
455
  attrMap.set('MapUnit', `#${mapUnitRef}`);
363
456
  changed = true;
364
457
  }
365
458
  if (changed && !modifiedEntities.has(entityId)) {
366
459
  modifiedEntities.add(entityId);
367
- modifiedEntityCount++;
460
+ if (hasEmittableHostBytes(entityId))
461
+ modifiedEntityCount++;
368
462
  }
369
463
  }
370
464
  // Modify existing IfcMapConversion
@@ -402,7 +496,8 @@ export class StepExporter {
402
496
  }
403
497
  if (changed && !modifiedEntities.has(entityId)) {
404
498
  modifiedEntities.add(entityId);
405
- modifiedEntityCount++;
499
+ if (hasEmittableHostBytes(entityId))
500
+ modifiedEntityCount++;
406
501
  }
407
502
  }
408
503
  // CREATE new georef entities when file has none
@@ -417,12 +512,12 @@ export class StepExporter {
417
512
  const proj = crs.mapProjection ? `'${escapeStepString(String(crs.mapProjection))}'` : '$';
418
513
  const zone = crs.mapZone ? `'${escapeStepString(String(crs.mapZone))}'` : '$';
419
514
  const mapUnitRef = crs.mapUnit
420
- ? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines)}`
515
+ ? `#${this.resolveMapUnitReference(String(crs.mapUnit), newGeorefLines, effective)}`
421
516
  : '$';
422
517
  newGeorefLines.push(`#${crsId}=IFCPROJECTEDCRS(${name},${desc},${datum},${vDatum},${proj},${zone},${mapUnitRef});`);
423
518
  newEntityCount++;
424
519
  // Find IfcGeometricRepresentationContext as SourceCRS for MapConversion
425
- const contextId = this.findPreferredGeometricRepresentationContextId();
520
+ const contextId = this.findPreferredGeometricRepresentationContextId(effective);
426
521
  if (contextId) {
427
522
  const mc = gm.mapConversion || {};
428
523
  const mcId = this.nextExpressId++;
@@ -437,12 +532,12 @@ export class StepExporter {
437
532
  newEntityCount++;
438
533
  }
439
534
  else {
440
- console.warn('[StepExporter] Cannot create IfcMapConversion: no IfcGeometricRepresentationContext found in source file');
535
+ this.reportMapConversionRefused(warnings);
441
536
  }
442
537
  }
443
538
  else if (gm.mapConversion && !existingMcIds?.length && existingCrsIds?.length) {
444
539
  // CRS exists but no MapConversion — create just the conversion
445
- const contextId = this.findPreferredGeometricRepresentationContextId();
540
+ const contextId = this.findPreferredGeometricRepresentationContextId(effective);
446
541
  if (contextId) {
447
542
  const mc = gm.mapConversion;
448
543
  const mcId = this.nextExpressId++;
@@ -456,9 +551,17 @@ export class StepExporter {
456
551
  newEntityCount++;
457
552
  }
458
553
  else {
459
- console.warn('[StepExporter] Cannot create IfcMapConversion: no IfcGeometricRepresentationContext found in source file');
554
+ this.reportMapConversionRefused(warnings);
460
555
  }
461
556
  }
557
+ else if (gm.mapConversion && !existingMcIds?.length && !existingCrsIds?.length) {
558
+ // A map conversion was requested, but there is no IfcProjectedCRS to
559
+ // reference as TargetCRS: none was requested (the first branch above
560
+ // didn't fire) and none exists in the file. Both CREATE branches are
561
+ // skipped, so nothing is attempted — report the refusal so the
562
+ // caller isn't left with an empty stats.warnings and no hint (#2105).
563
+ this.reportMapConversionRefusedNoCrs(warnings);
564
+ }
462
565
  }
463
566
  // If delta only, only export modified entities. Overlay-created entities
464
567
  // also count — without this, `createEntity()`-only edits would silently
@@ -480,25 +583,10 @@ export class StepExporter {
480
583
  newEntityCount: 0,
481
584
  modifiedEntityCount: 0,
482
585
  fileSize: emptyContent.byteLength,
586
+ warnings,
483
587
  },
484
588
  };
485
589
  }
486
- // Build visible-only closure if requested. Classification, the closure walk
487
- // and the style pass all run over the EFFECTIVE index: an overlay-created
488
- // product becomes a root by the same type rules as a parsed one, the walk
489
- // follows its authored references into the geometry it alone owns, and a
490
- // tombstoned entity is simply not there. Run over the source buffer, a
491
- // created wall could never be a root and nothing referenced it, so
492
- // `visibleOnly` wrote a file without it and said nothing (#2012).
493
- let allowedEntityIds = null;
494
- if (options.visibleOnly && this.dataStore.source) {
495
- const { roots, hiddenProductIds } = getVisibleEntityIds(this.dataStore, options.hiddenEntityIds ?? new Set(), options.isolatedEntityIds ?? null, effective);
496
- allowedEntityIds = collectReferencedEntityIds(roots, this.dataStore.source, effective, hiddenProductIds);
497
- // Second pass: collect IFCSTYLEDITEM entities that reference included
498
- // geometry. Styled items reference geometry items but nothing references
499
- // them back, so the forward closure misses them.
500
- collectStyleEntities(allowedEntityIds, this.dataStore.source, { byId: effective, byType: effective.byType });
501
- }
502
590
  /**
503
591
  * Will this id have a defining STEP line in the output at all?
504
592
  *
@@ -540,7 +628,21 @@ export class StepExporter {
540
628
  return false;
541
629
  // An overlay-created record carries the placeholder byte range and is
542
630
  // written by the new-entities pass; a source record needs real bytes.
543
- return effective.isOverlayCreated(entityId) || (ref.byteLength > 0 && ref.byteOffset >= 0);
631
+ if (effective.isOverlayCreated(entityId)) {
632
+ // The overlay new-entities pass applies its OWN `isGeometryEntity`
633
+ // filter unconditionally — deltaOnly or not (see the comment at that
634
+ // loop, further below) — so this branch mirrors it without the
635
+ // deltaOnly carve-out the source branch gets.
636
+ return !isGeometryExcluded(entityId, ref.type);
637
+ }
638
+ if (!(ref.byteLength > 0 && ref.byteOffset >= 0))
639
+ return false;
640
+ // Mirrors `hasEmittableHostBytes`: under `deltaOnly` the source-
641
+ // iteration pass — and its geometry skip — never runs, so a source
642
+ // entity's line is assumed to already exist in the file being patched.
643
+ if (options.deltaOnly === true)
644
+ return true;
645
+ return !isGeometryExcluded(entityId, ref.type);
544
646
  };
545
647
  // A modified pset is replaced wholesale, which skips ALL of its member atoms.
546
648
  // But IFC exporters deduplicate identical Pset_*Common atoms (e.g. one
@@ -577,11 +679,11 @@ export class StepExporter {
577
679
  if (options.includeGeometry === false && this.isGeometryEntity(entityType)) {
578
680
  continue;
579
681
  }
580
- // Get original entity text — safeUtf8Decode handles SAB-backed
682
+ // Get original entity text — decodeRange handles SAB-backed
581
683
  // sources (Firefox/Chrome reject `TextDecoder.decode()` on a
582
684
  // SharedArrayBuffer-backed view; the parser deliberately keeps
583
685
  // `source` zero-copy SAB-backed for worker sharing).
584
- const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
686
+ const entityText = decodeRange(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
585
687
  let nextEntityText = entityText;
586
688
  // Entity retype (reassign class) runs FIRST so attribute mutations
587
689
  // below resolve against the TARGET class's attribute names. The
@@ -638,7 +740,7 @@ export class StepExporter {
638
740
  // see `willBeEmitted` (#1978, #2030, #2012).
639
741
  if (!willBeEmitted(entityId))
640
742
  continue;
641
- const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
743
+ const newEntities = this.generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNamesByEntity.get(entityId), options.guidRandom);
642
744
  entities.push(...newEntities.lines);
643
745
  newEntityCount += newEntities.count;
644
746
  generatedTypeOwnedPsetIds.set(entityId, newEntities.generatedTypeOwnedPsetIds);
@@ -772,6 +874,7 @@ export class StepExporter {
772
874
  newEntityCount,
773
875
  modifiedEntityCount,
774
876
  fileSize: content.byteLength,
877
+ warnings,
775
878
  },
776
879
  };
777
880
  }
@@ -887,7 +990,7 @@ export class StepExporter {
887
990
  }
888
991
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
889
992
  if (entityRef && this.dataStore.source && entityRef.byteLength > 0) {
890
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
993
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
891
994
  // #ID=IFCWALL('GlobalId',#owner,...): GlobalId is a quoted STEP string
892
995
  // (doubled '' escapes); OwnerHistory is the ref/`$` right after it.
893
996
  const match = entityText.match(/=\s*IFC\w+\s*\(\s*'(?:[^']|'')*'\s*,\s*#(\d+)/i);
@@ -900,7 +1003,7 @@ export class StepExporter {
900
1003
  /**
901
1004
  * Generate STEP entities for property sets
902
1005
  */
903
- generatePropertySetEntities(entityId, psets, willBeEmitted, typeOwnedPsetNames, random) {
1006
+ generatePropertySetEntities(entityId, psets, willBeEmitted, effective, typeOwnedPsetNames, random) {
904
1007
  const lines = [];
905
1008
  let count = 0;
906
1009
  const generatedTypeOwnedPsetIds = new Map();
@@ -911,7 +1014,7 @@ export class StepExporter {
911
1014
  const propId = this.nextExpressId++;
912
1015
  count++;
913
1016
  const valueStr = serializePropertyValue(prop.value, prop.type);
914
- const unitId = prop.unit ? this.findUnitId(prop.unit) : null;
1017
+ const unitId = prop.unit ? this.findUnitId(prop.unit, effective) : null;
915
1018
  const unitStr = unitId !== null ? ref(unitId) : null;
916
1019
  // #ID=IFCPROPERTYSINGLEVALUE('Name',$,Value,Unit);
917
1020
  const line = `#${propId}=IFCPROPERTYSINGLEVALUE('${escapeStepString(prop.name)}',$,${valueStr},${unitStr ? serializeValue(unitStr) : '$'});`;
@@ -1141,9 +1244,9 @@ export class StepExporter {
1141
1244
  const forceReal = realSlots.has(index) || tokenIsRealLiteral(currentToken);
1142
1245
  return serializeStepValue(value, forceReal);
1143
1246
  }
1144
- resolveMapUnitReference(unitName, newGeorefLines) {
1247
+ resolveMapUnitReference(unitName, newGeorefLines, effective) {
1145
1248
  const normalized = this.normalizeMapUnitName(unitName);
1146
- const existing = this.findLengthUnitReference(normalized);
1249
+ const existing = this.findLengthUnitReference(normalized, effective);
1147
1250
  if (existing !== null) {
1148
1251
  return existing;
1149
1252
  }
@@ -1179,14 +1282,22 @@ export class StepExporter {
1179
1282
  return 'FOOT';
1180
1283
  return normalized;
1181
1284
  }
1182
- findLengthUnitReference(preferredUnitName) {
1285
+ /**
1286
+ * `effective` filters the candidates the same way the georef reads above do:
1287
+ * returning a tombstoned unit id hands the caller a `#id` for a line the
1288
+ * export never writes. Returning null instead makes `resolveMapUnitReference`
1289
+ * synthesise a fresh unit, which is the outcome a deleted unit deserves.
1290
+ */
1291
+ findLengthUnitReference(preferredUnitName, effective) {
1183
1292
  if (!this.entityExtractor)
1184
1293
  return null;
1185
- const projectIds = this.dataStore.entityIndex.byType.get('IFCPROJECT') ?? [];
1186
- const projectRef = projectIds[0] ? this.dataStore.entityIndex.byId.get(projectIds[0]) : undefined;
1294
+ // Only source records carry the bytes `extractEntity` reads, so an
1295
+ // overlay-created project is skipped rather than shadowing the file's own.
1296
+ const projectId = (effective.byType.get('IFCPROJECT') ?? []).find((id) => this.dataStore.entityIndex.byId.has(id));
1297
+ const projectRef = projectId !== undefined ? this.dataStore.entityIndex.byId.get(projectId) : undefined;
1187
1298
  const project = projectRef ? this.entityExtractor.extractEntity(projectRef) : null;
1188
1299
  const unitAssignmentId = project?.attributes?.[8];
1189
- if (typeof unitAssignmentId !== 'number')
1300
+ if (typeof unitAssignmentId !== 'number' || effective.isDeleted(unitAssignmentId))
1190
1301
  return null;
1191
1302
  const unitAssignmentRef = this.dataStore.entityIndex.byId.get(unitAssignmentId);
1192
1303
  const unitAssignment = unitAssignmentRef ? this.entityExtractor.extractEntity(unitAssignmentRef) : null;
@@ -1194,7 +1305,7 @@ export class StepExporter {
1194
1305
  if (!Array.isArray(units))
1195
1306
  return null;
1196
1307
  for (const unitId of units) {
1197
- if (typeof unitId !== 'number')
1308
+ if (typeof unitId !== 'number' || effective.isDeleted(unitId))
1198
1309
  continue;
1199
1310
  const unitRef = this.dataStore.entityIndex.byId.get(unitId);
1200
1311
  const unit = unitRef ? this.entityExtractor.extractEntity(unitRef) : null;
@@ -1222,10 +1333,36 @@ export class StepExporter {
1222
1333
  }
1223
1334
  return null;
1224
1335
  }
1225
- findPreferredGeometricRepresentationContextId() {
1336
+ /**
1337
+ * Record that a requested IfcMapConversion could not be written. Emitting it
1338
+ * anyway would leave `SourceCRS` pointing at nothing, so the refusal is the
1339
+ * correct output — but the file alone cannot express it, which is why it goes
1340
+ * back to the caller in `stats.warnings` as well as to the console (#2067).
1341
+ */
1342
+ reportMapConversionRefused(warnings) {
1343
+ warnings.push(MAP_CONVERSION_WITHOUT_CONTEXT_WARNING);
1344
+ console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CONTEXT_WARNING}`);
1345
+ }
1346
+ /**
1347
+ * Record that a requested IfcMapConversion could not be written because
1348
+ * there is no IfcProjectedCRS to attach it to — a different refusal from
1349
+ * {@link reportMapConversionRefused}: "no CRS to attach it to" rather than
1350
+ * "no context to reference" (#2105).
1351
+ */
1352
+ reportMapConversionRefusedNoCrs(warnings) {
1353
+ warnings.push(MAP_CONVERSION_WITHOUT_CRS_WARNING);
1354
+ console.warn(`[StepExporter] ${MAP_CONVERSION_WITHOUT_CRS_WARNING}`);
1355
+ }
1356
+ /**
1357
+ * `effective` again: the id returned here becomes the new IfcMapConversion's
1358
+ * SourceCRS, so a tombstoned context would leave the created line pointing at
1359
+ * a record the export skips — a dangling reference and an invalid file.
1360
+ */
1361
+ findPreferredGeometricRepresentationContextId(effective) {
1226
1362
  if (!this.entityExtractor)
1227
1363
  return null;
1228
- const contextIds = this.dataStore.entityIndex.byType.get('IFCGEOMETRICREPRESENTATIONCONTEXT') ?? [];
1364
+ const contextIds = (effective.byType.get('IFCGEOMETRICREPRESENTATIONCONTEXT') ?? [])
1365
+ .filter((id) => this.dataStore.entityIndex.byId.has(id));
1229
1366
  let first3dContext = null;
1230
1367
  for (const contextId of contextIds) {
1231
1368
  const contextRef = this.dataStore.entityIndex.byId.get(contextId);
@@ -1263,8 +1400,8 @@ export class StepExporter {
1263
1400
  /**
1264
1401
  * Find a unit entity ID by name (simplified - returns null for now)
1265
1402
  */
1266
- findUnitId(unitName) {
1267
- return this.findLengthUnitReference(this.normalizeMapUnitName(unitName));
1403
+ findUnitId(unitName, effective) {
1404
+ return this.findLengthUnitReference(this.normalizeMapUnitName(unitName), effective);
1268
1405
  }
1269
1406
  /**
1270
1407
  * Check if an entity type is a geometry-related type
@@ -1343,7 +1480,7 @@ export class StepExporter {
1343
1480
  const entityRef = this.dataStore.entityIndex.byId.get(relId);
1344
1481
  if (!entityRef || !this.dataStore.source)
1345
1482
  return [];
1346
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1483
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1347
1484
  // Parse IfcRelDefinesByProperties: #ID=IFCRELDEFINESBYPROPERTIES('guid',$,$,$,(#objects),#pset);
1348
1485
  // The 5th argument (index 4) is the list of related objects
1349
1486
  const match = entityText.match(/\(([^)]+)\)\s*,\s*#(\d+)\s*\)\s*;/);
@@ -1364,7 +1501,7 @@ export class StepExporter {
1364
1501
  const entityRef = this.dataStore.entityIndex.byId.get(relId);
1365
1502
  if (!entityRef || !this.dataStore.source)
1366
1503
  return null;
1367
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1504
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1368
1505
  // Last #ID before the closing );
1369
1506
  const match = entityText.match(/,\s*#(\d+)\s*\)\s*;$/);
1370
1507
  if (!match)
@@ -1378,7 +1515,7 @@ export class StepExporter {
1378
1515
  const entityRef = this.dataStore.entityIndex.byId.get(psetId);
1379
1516
  if (!entityRef || !this.dataStore.source)
1380
1517
  return null;
1381
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1518
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1382
1519
  // Parse: IFCPROPERTYSET('guid',$,'Name',$,...) - Name is 3rd argument
1383
1520
  const match = entityText.match(/IFCPROPERTYSET\s*\([^,]*,[^,]*,'([^']*)'/i);
1384
1521
  if (!match)
@@ -1392,7 +1529,7 @@ export class StepExporter {
1392
1529
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1393
1530
  if (!entityRef || !this.dataStore.source)
1394
1531
  return null;
1395
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1532
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1396
1533
  // Parse: IFCELEMENTQUANTITY('guid',$,'Name',...) - Name is 3rd argument
1397
1534
  const match = entityText.match(/IFCELEMENTQUANTITY\s*\([^,]*,[^,]*,'([^']*)'/i);
1398
1535
  if (!match)
@@ -1440,7 +1577,7 @@ export class StepExporter {
1440
1577
  const entityRef = this.dataStore.entityIndex.byId.get(psetId);
1441
1578
  if (!entityRef || !this.dataStore.source)
1442
1579
  return [];
1443
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1580
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1444
1581
  // Parse: IFCPROPERTYSET(...,(#prop1,#prop2,...)); - Last argument is properties list
1445
1582
  const match = entityText.match(/\(\s*(#[^)]+)\s*\)\s*\)\s*;$/);
1446
1583
  if (!match)
@@ -1487,7 +1624,7 @@ export class StepExporter {
1487
1624
  const entityRef = this.dataStore.entityIndex.byId.get(entityId);
1488
1625
  if (!entityRef || !this.dataStore.source)
1489
1626
  return null;
1490
- const entityText = safeUtf8Decode(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1627
+ const entityText = decodeRange(this.dataStore.source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
1491
1628
  const match = entityText.match(/^(#\d+\s*=\s*\w+\()([\s\S]*)(\)\s*;)\s*$/);
1492
1629
  if (!match)
1493
1630
  return null;