@ifc-lite/parser 1.6.1 → 1.8.0

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.
@@ -4,6 +4,7 @@
4
4
  import { SpatialHierarchyBuilder } from './spatial-hierarchy-builder.js';
5
5
  import { EntityExtractor } from './entity-extractor.js';
6
6
  import { extractLengthUnitScale } from './unit-extractor.js';
7
+ import { getAttributeNames } from './ifc-schema.js';
7
8
  import { StringTable, EntityTableBuilder, PropertyTableBuilder, QuantityTableBuilder, RelationshipGraphBuilder, RelationshipType, QuantityType, } from '@ifc-lite/data';
8
9
  // Pre-computed type sets for O(1) lookups
9
10
  const GEOMETRY_TYPES = new Set([
@@ -21,6 +22,7 @@ const RELATIONSHIP_TYPES = new Set([
21
22
  'IFCRELCONTAINEDINSPATIALSTRUCTURE', 'IFCRELAGGREGATES',
22
23
  'IFCRELDEFINESBYPROPERTIES', 'IFCRELDEFINESBYTYPE',
23
24
  'IFCRELASSOCIATESMATERIAL', 'IFCRELASSOCIATESCLASSIFICATION',
25
+ 'IFCRELASSOCIATESDOCUMENT',
24
26
  'IFCRELVOIDSELEMENT', 'IFCRELFILLSELEMENT',
25
27
  'IFCRELCONNECTSPATHELEMENTS', 'IFCRELCONNECTSELEMENTS',
26
28
  'IFCRELSPACEBOUNDARY',
@@ -36,6 +38,7 @@ const REL_TYPE_MAP = {
36
38
  'IFCRELDEFINESBYTYPE': RelationshipType.DefinesByType,
37
39
  'IFCRELASSOCIATESMATERIAL': RelationshipType.AssociatesMaterial,
38
40
  'IFCRELASSOCIATESCLASSIFICATION': RelationshipType.AssociatesClassification,
41
+ 'IFCRELASSOCIATESDOCUMENT': RelationshipType.AssociatesDocument,
39
42
  'IFCRELVOIDSELEMENT': RelationshipType.VoidsElement,
40
43
  'IFCRELFILLSELEMENT': RelationshipType.FillsElement,
41
44
  'IFCRELCONNECTSPATHELEMENTS': RelationshipType.ConnectsPathElements,
@@ -60,11 +63,19 @@ const SPATIAL_TYPES = new Set([
60
63
  // Relationship types needed for hierarchy
61
64
  const HIERARCHY_REL_TYPES = new Set([
62
65
  'IFCRELAGGREGATES', 'IFCRELCONTAINEDINSPATIALSTRUCTURE',
66
+ 'IFCRELDEFINESBYTYPE',
63
67
  ]);
64
68
  // Relationship types for on-demand property loading
65
69
  const PROPERTY_REL_TYPES = new Set([
66
70
  'IFCRELDEFINESBYPROPERTIES',
67
71
  ]);
72
+ // Relationship types for on-demand classification/material loading
73
+ const ASSOCIATION_REL_TYPES = new Set([
74
+ 'IFCRELASSOCIATESCLASSIFICATION', 'IFCRELASSOCIATESMATERIAL',
75
+ 'IFCRELASSOCIATESDOCUMENT',
76
+ ]);
77
+ // Attributes to skip in extractAllEntityAttributes (shown elsewhere or non-displayable)
78
+ const SKIP_DISPLAY_ATTRS = new Set(['GlobalId', 'OwnerHistory', 'ObjectPlacement', 'Representation']);
68
79
  // Property-related entity types for on-demand extraction
69
80
  const PROPERTY_ENTITY_TYPES = new Set([
70
81
  'IFCPROPERTYSET', 'IFCELEMENTQUANTITY',
@@ -74,6 +85,21 @@ const PROPERTY_ENTITY_TYPES = new Set([
74
85
  'IFCQUANTITYLENGTH', 'IFCQUANTITYAREA', 'IFCQUANTITYVOLUME',
75
86
  'IFCQUANTITYCOUNT', 'IFCQUANTITYWEIGHT', 'IFCQUANTITYTIME',
76
87
  ]);
88
+ /**
89
+ * Detect the IFC schema version from the STEP FILE_SCHEMA header.
90
+ * Scans the first 2000 bytes for FILE_SCHEMA(('IFC2X3')), FILE_SCHEMA(('IFC4')), etc.
91
+ */
92
+ function detectSchemaVersion(buffer) {
93
+ const headerEnd = Math.min(buffer.length, 2000);
94
+ const headerText = new TextDecoder().decode(buffer.subarray(0, headerEnd)).toUpperCase();
95
+ if (headerText.includes('IFC4X3'))
96
+ return 'IFC4X3';
97
+ if (headerText.includes('IFC4'))
98
+ return 'IFC4';
99
+ if (headerText.includes('IFC2X3'))
100
+ return 'IFC2X3';
101
+ return 'IFC4'; // Default fallback
102
+ }
77
103
  export class ColumnarParser {
78
104
  /**
79
105
  * Parse IFC file into columnar data store
@@ -87,6 +113,8 @@ export class ColumnarParser {
87
113
  const uint8Buffer = new Uint8Array(buffer);
88
114
  const totalEntities = entityRefs.length;
89
115
  options.onProgress?.({ phase: 'building', percent: 0 });
116
+ // Detect schema version from FILE_SCHEMA header
117
+ const schemaVersion = detectSchemaVersion(uint8Buffer);
90
118
  // Initialize builders
91
119
  const strings = new StringTable();
92
120
  const entityTableBuilder = new EntityTableBuilder(totalEntities, strings);
@@ -104,6 +132,7 @@ export class ColumnarParser {
104
132
  const relationshipRefs = [];
105
133
  const propertyRelRefs = [];
106
134
  const propertyEntityRefs = [];
135
+ const associationRelRefs = [];
107
136
  for (const ref of entityRefs) {
108
137
  // Build entity index
109
138
  entityIndex.byId.set(ref.expressId, ref);
@@ -130,6 +159,9 @@ export class ColumnarParser {
130
159
  else if (PROPERTY_ENTITY_TYPES.has(typeUpper)) {
131
160
  propertyEntityRefs.push(ref);
132
161
  }
162
+ else if (ASSOCIATION_REL_TYPES.has(typeUpper)) {
163
+ associationRelRefs.push(ref);
164
+ }
133
165
  }
134
166
  // === TARGETED PARSING: Parse spatial and geometry entities for GlobalIds ===
135
167
  options.onProgress?.({ phase: 'parsing spatial', percent: 10 });
@@ -220,6 +252,58 @@ export class ColumnarParser {
220
252
  }
221
253
  }
222
254
  }
255
+ // === PARSE ASSOCIATION RELATIONSHIPS for on-demand classification/material/document loading ===
256
+ const onDemandClassificationMap = new Map();
257
+ const onDemandMaterialMap = new Map();
258
+ const onDemandDocumentMap = new Map();
259
+ for (const ref of associationRelRefs) {
260
+ const entity = extractor.extractEntity(ref);
261
+ if (entity) {
262
+ const attrs = entity.attributes || [];
263
+ // IfcRelAssociates subtypes:
264
+ // [0] GlobalId, [1] OwnerHistory, [2] Name, [3] Description
265
+ // [4] RelatedObjects (list of element IDs)
266
+ // [5] RelatingClassification / RelatingMaterial / RelatingDocument
267
+ const relatedObjects = attrs[4];
268
+ const relatingRef = attrs[5];
269
+ if (typeof relatingRef === 'number' && Array.isArray(relatedObjects)) {
270
+ const typeUpper = ref.type.toUpperCase();
271
+ if (typeUpper === 'IFCRELASSOCIATESCLASSIFICATION') {
272
+ for (const objId of relatedObjects) {
273
+ if (typeof objId === 'number') {
274
+ let list = onDemandClassificationMap.get(objId);
275
+ if (!list) {
276
+ list = [];
277
+ onDemandClassificationMap.set(objId, list);
278
+ }
279
+ list.push(relatingRef);
280
+ }
281
+ }
282
+ }
283
+ else if (typeUpper === 'IFCRELASSOCIATESMATERIAL') {
284
+ // IFC allows multiple IfcRelAssociatesMaterial per element but typically
285
+ // only one is valid. Last-write-wins: later relationships override earlier ones.
286
+ for (const objId of relatedObjects) {
287
+ if (typeof objId === 'number') {
288
+ onDemandMaterialMap.set(objId, relatingRef);
289
+ }
290
+ }
291
+ }
292
+ else if (typeUpper === 'IFCRELASSOCIATESDOCUMENT') {
293
+ for (const objId of relatedObjects) {
294
+ if (typeof objId === 'number') {
295
+ let list = onDemandDocumentMap.get(objId);
296
+ if (!list) {
297
+ list = [];
298
+ onDemandDocumentMap.set(objId, list);
299
+ }
300
+ list.push(relatingRef);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
223
307
  // === BUILD ENTITY TABLE with spatial data included ===
224
308
  options.onProgress?.({ phase: 'building entities', percent: 30 });
225
309
  // OPTIMIZATION: Only add entities that are useful for the viewer UI
@@ -288,7 +372,7 @@ export class ColumnarParser {
288
372
  options.onProgress?.({ phase: 'complete', percent: 100 });
289
373
  return {
290
374
  fileSize: buffer.byteLength,
291
- schemaVersion: 'IFC4',
375
+ schemaVersion,
292
376
  entityCount: totalEntities,
293
377
  parseTime,
294
378
  source: uint8Buffer,
@@ -301,6 +385,9 @@ export class ColumnarParser {
301
385
  spatialHierarchy,
302
386
  onDemandPropertyMap, // For instant property access
303
387
  onDemandQuantityMap, // For instant quantity access
388
+ onDemandClassificationMap, // For instant classification access
389
+ onDemandMaterialMap, // For instant material access
390
+ onDemandDocumentMap, // For instant document access
304
391
  };
305
392
  }
306
393
  /**
@@ -312,7 +399,7 @@ export class ColumnarParser {
312
399
  return null;
313
400
  let relatingObject;
314
401
  let relatedObjects;
315
- if (typeUpper === 'IFCRELDEFINESBYPROPERTIES' || typeUpper === 'IFCRELCONTAINEDINSPATIALSTRUCTURE') {
402
+ if (typeUpper === 'IFCRELDEFINESBYPROPERTIES' || typeUpper === 'IFCRELDEFINESBYTYPE' || typeUpper === 'IFCRELCONTAINEDINSPATIALSTRUCTURE') {
316
403
  relatedObjects = attrs[4];
317
404
  relatingObject = attrs[5];
318
405
  }
@@ -371,20 +458,8 @@ export class ColumnarParser {
371
458
  const propName = typeof propAttrs[0] === 'string' ? propAttrs[0] : '';
372
459
  if (!propName)
373
460
  continue;
374
- // IfcPropertySingleValue: [name, description, nominalValue, unit]
375
- const nominalValue = propAttrs[2];
376
- let type = 0; // String
377
- let value = nominalValue;
378
- if (typeof nominalValue === 'number') {
379
- type = 1; // Real
380
- }
381
- else if (typeof nominalValue === 'boolean') {
382
- type = 2; // Boolean
383
- }
384
- else if (nominalValue !== null && nominalValue !== undefined) {
385
- value = String(nominalValue);
386
- }
387
- properties.push({ name: propName, type, value });
461
+ const parsed = parsePropertyValue(propEntity);
462
+ properties.push({ name: propName, type: parsed.type, value: parsed.value });
388
463
  }
389
464
  }
390
465
  if (properties.length > 0 || psetName) {
@@ -467,26 +542,813 @@ export function extractQuantitiesOnDemand(store, entityId) {
467
542
  }
468
543
  /**
469
544
  * Extract entity attributes on-demand from source buffer
470
- * Returns globalId, name, description, objectType for any IfcRoot-derived entity.
545
+ * Returns globalId, name, description, objectType, tag for any IfcRoot-derived entity.
471
546
  * This is used for entities that weren't fully parsed during initial load.
472
547
  */
473
548
  export function extractEntityAttributesOnDemand(store, entityId) {
474
549
  const ref = store.entityIndex.byId.get(entityId);
475
550
  if (!ref) {
476
- return { globalId: '', name: '', description: '', objectType: '' };
551
+ return { globalId: '', name: '', description: '', objectType: '', tag: '' };
477
552
  }
478
553
  const extractor = new EntityExtractor(store.source);
479
554
  const entity = extractor.extractEntity(ref);
480
555
  if (!entity) {
481
- return { globalId: '', name: '', description: '', objectType: '' };
556
+ return { globalId: '', name: '', description: '', objectType: '', tag: '' };
482
557
  }
483
558
  const attrs = entity.attributes || [];
484
559
  // IfcRoot attributes: [GlobalId, OwnerHistory, Name, Description]
485
560
  // IfcObject adds: [ObjectType] at index 4
561
+ // IfcProduct adds: [ObjectPlacement, Representation] at indices 5-6
562
+ // IfcElement adds: [Tag] at index 7
486
563
  const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
487
564
  const name = typeof attrs[2] === 'string' ? attrs[2] : '';
488
565
  const description = typeof attrs[3] === 'string' ? attrs[3] : '';
489
566
  const objectType = typeof attrs[4] === 'string' ? attrs[4] : '';
490
- return { globalId, name, description, objectType };
567
+ const tag = typeof attrs[7] === 'string' ? attrs[7] : '';
568
+ return { globalId, name, description, objectType, tag };
569
+ }
570
+ /**
571
+ * Extract ALL named entity attributes on-demand from source buffer.
572
+ * Uses the IFC schema to map attribute indices to names.
573
+ * Returns only string/enum attributes, skipping references and structural attributes.
574
+ */
575
+ export function extractAllEntityAttributes(store, entityId) {
576
+ const ref = store.entityIndex.byId.get(entityId);
577
+ if (!ref)
578
+ return [];
579
+ const extractor = new EntityExtractor(store.source);
580
+ const entity = extractor.extractEntity(ref);
581
+ if (!entity)
582
+ return [];
583
+ const attrs = entity.attributes || [];
584
+ // Use properly-cased type name from entity table (IfcTypeEnumToString)
585
+ // instead of ref.type which is UPPERCASE from STEP (e.g., IFCWALLSTANDARDCASE)
586
+ // and breaks multi-word type normalization in getAttributeNames
587
+ const typeName = store.entities.getTypeName(entityId);
588
+ const attrNames = getAttributeNames(typeName || ref.type);
589
+ const result = [];
590
+ const len = Math.min(attrs.length, attrNames.length);
591
+ for (let i = 0; i < len; i++) {
592
+ const attrName = attrNames[i];
593
+ if (SKIP_DISPLAY_ATTRS.has(attrName))
594
+ continue;
595
+ const raw = attrs[i];
596
+ if (typeof raw === 'string' && raw) {
597
+ // Clean enum values: .NOTDEFINED. -> NOTDEFINED
598
+ const display = raw.startsWith('.') && raw.endsWith('.')
599
+ ? raw.slice(1, -1)
600
+ : raw;
601
+ result.push({ name: attrName, value: display });
602
+ }
603
+ }
604
+ return result;
605
+ }
606
+ /**
607
+ * Extract classifications for a single entity ON-DEMAND.
608
+ * Uses the onDemandClassificationMap built during parsing.
609
+ * Falls back to relationship graph when on-demand map is not available (e.g., server-loaded models).
610
+ * Also checks type-level associations via IfcRelDefinesByType.
611
+ * Returns an array of classification references with system info.
612
+ */
613
+ export function extractClassificationsOnDemand(store, entityId) {
614
+ let classRefIds;
615
+ if (store.onDemandClassificationMap) {
616
+ classRefIds = store.onDemandClassificationMap.get(entityId);
617
+ }
618
+ else if (store.relationships) {
619
+ // Fallback: use relationship graph (server-loaded models)
620
+ const related = store.relationships.getRelated(entityId, RelationshipType.AssociatesClassification, 'inverse');
621
+ if (related.length > 0)
622
+ classRefIds = related;
623
+ }
624
+ // Also check type-level classifications via IfcRelDefinesByType
625
+ if (store.relationships) {
626
+ const typeIds = store.relationships.getRelated(entityId, RelationshipType.DefinesByType, 'inverse');
627
+ for (const typeId of typeIds) {
628
+ let typeClassRefs;
629
+ if (store.onDemandClassificationMap) {
630
+ typeClassRefs = store.onDemandClassificationMap.get(typeId);
631
+ }
632
+ else {
633
+ const related = store.relationships.getRelated(typeId, RelationshipType.AssociatesClassification, 'inverse');
634
+ if (related.length > 0)
635
+ typeClassRefs = related;
636
+ }
637
+ if (typeClassRefs && typeClassRefs.length > 0) {
638
+ classRefIds = classRefIds ? [...classRefIds, ...typeClassRefs] : [...typeClassRefs];
639
+ }
640
+ }
641
+ }
642
+ if (!classRefIds || classRefIds.length === 0)
643
+ return [];
644
+ if (!store.source?.length)
645
+ return [];
646
+ const extractor = new EntityExtractor(store.source);
647
+ const results = [];
648
+ for (const classRefId of classRefIds) {
649
+ const ref = store.entityIndex.byId.get(classRefId);
650
+ if (!ref)
651
+ continue;
652
+ const entity = extractor.extractEntity(ref);
653
+ if (!entity)
654
+ continue;
655
+ const typeUpper = entity.type.toUpperCase();
656
+ const attrs = entity.attributes || [];
657
+ if (typeUpper === 'IFCCLASSIFICATIONREFERENCE') {
658
+ // IfcClassificationReference: [Location, Identification, Name, ReferencedSource, Description, Sort]
659
+ const info = {
660
+ location: typeof attrs[0] === 'string' ? attrs[0] : undefined,
661
+ identification: typeof attrs[1] === 'string' ? attrs[1] : undefined,
662
+ name: typeof attrs[2] === 'string' ? attrs[2] : undefined,
663
+ description: typeof attrs[4] === 'string' ? attrs[4] : undefined,
664
+ };
665
+ // Walk up to find the classification system name
666
+ const referencedSourceId = typeof attrs[3] === 'number' ? attrs[3] : undefined;
667
+ if (referencedSourceId) {
668
+ const path = walkClassificationChain(store, extractor, referencedSourceId);
669
+ info.system = path.systemName;
670
+ info.path = path.codes;
671
+ }
672
+ results.push(info);
673
+ }
674
+ else if (typeUpper === 'IFCCLASSIFICATION') {
675
+ // IfcClassification: [Source, Edition, EditionDate, Name, Description, Location, ReferenceTokens]
676
+ results.push({
677
+ system: typeof attrs[3] === 'string' ? attrs[3] : undefined,
678
+ name: typeof attrs[3] === 'string' ? attrs[3] : undefined,
679
+ description: typeof attrs[4] === 'string' ? attrs[4] : undefined,
680
+ location: typeof attrs[5] === 'string' ? attrs[5] : undefined,
681
+ });
682
+ }
683
+ }
684
+ return results;
685
+ }
686
+ /**
687
+ * Walk up the IfcClassificationReference chain to find the root IfcClassification system.
688
+ */
689
+ function walkClassificationChain(store, extractor, startId) {
690
+ const codes = [];
691
+ let currentId = startId;
692
+ const visited = new Set();
693
+ while (currentId !== undefined && !visited.has(currentId)) {
694
+ visited.add(currentId);
695
+ const ref = store.entityIndex.byId.get(currentId);
696
+ if (!ref)
697
+ break;
698
+ const entity = extractor.extractEntity(ref);
699
+ if (!entity)
700
+ break;
701
+ const typeUpper = entity.type.toUpperCase();
702
+ const attrs = entity.attributes || [];
703
+ if (typeUpper === 'IFCCLASSIFICATION') {
704
+ // Root: IfcClassification [Source, Edition, EditionDate, Name, ...]
705
+ const systemName = typeof attrs[3] === 'string' ? attrs[3] : undefined;
706
+ return { systemName, codes };
707
+ }
708
+ if (typeUpper === 'IFCCLASSIFICATIONREFERENCE') {
709
+ // IfcClassificationReference [Location, Identification, Name, ReferencedSource, ...]
710
+ const code = typeof attrs[1] === 'string' ? attrs[1] :
711
+ typeof attrs[2] === 'string' ? attrs[2] : undefined;
712
+ if (code)
713
+ codes.unshift(code);
714
+ currentId = typeof attrs[3] === 'number' ? attrs[3] : undefined;
715
+ }
716
+ else {
717
+ break;
718
+ }
719
+ }
720
+ return { codes };
721
+ }
722
+ /**
723
+ * Extract materials for a single entity ON-DEMAND.
724
+ * Uses the onDemandMaterialMap built during parsing.
725
+ * Falls back to relationship graph when on-demand map is not available (e.g., server-loaded models).
726
+ * Also checks type-level material assignments via IfcRelDefinesByType.
727
+ * Resolves the full material structure (layers, profiles, constituents, lists).
728
+ */
729
+ export function extractMaterialsOnDemand(store, entityId) {
730
+ let materialId;
731
+ if (store.onDemandMaterialMap) {
732
+ materialId = store.onDemandMaterialMap.get(entityId);
733
+ }
734
+ else if (store.relationships) {
735
+ // Fallback: use relationship graph (server-loaded models)
736
+ const related = store.relationships.getRelated(entityId, RelationshipType.AssociatesMaterial, 'inverse');
737
+ if (related.length > 0)
738
+ materialId = related[0];
739
+ }
740
+ // Check type-level material if occurrence has none
741
+ if (materialId === undefined && store.relationships) {
742
+ const typeIds = store.relationships.getRelated(entityId, RelationshipType.DefinesByType, 'inverse');
743
+ for (const typeId of typeIds) {
744
+ if (store.onDemandMaterialMap) {
745
+ materialId = store.onDemandMaterialMap.get(typeId);
746
+ }
747
+ else {
748
+ const related = store.relationships.getRelated(typeId, RelationshipType.AssociatesMaterial, 'inverse');
749
+ if (related.length > 0)
750
+ materialId = related[0];
751
+ }
752
+ if (materialId !== undefined)
753
+ break;
754
+ }
755
+ }
756
+ if (materialId === undefined)
757
+ return null;
758
+ if (!store.source?.length)
759
+ return null;
760
+ const extractor = new EntityExtractor(store.source);
761
+ return resolveMaterial(store, extractor, materialId, new Set());
762
+ }
763
+ /**
764
+ * Resolve a material entity by ID, handling all IFC material types.
765
+ * Uses visited set to prevent infinite recursion on cyclic *Usage references.
766
+ */
767
+ function resolveMaterial(store, extractor, materialId, visited = new Set()) {
768
+ if (visited.has(materialId))
769
+ return null;
770
+ visited.add(materialId);
771
+ const ref = store.entityIndex.byId.get(materialId);
772
+ if (!ref)
773
+ return null;
774
+ const entity = extractor.extractEntity(ref);
775
+ if (!entity)
776
+ return null;
777
+ const typeUpper = entity.type.toUpperCase();
778
+ const attrs = entity.attributes || [];
779
+ switch (typeUpper) {
780
+ case 'IFCMATERIAL': {
781
+ // IfcMaterial: [Name, Description, Category]
782
+ return {
783
+ type: 'Material',
784
+ name: typeof attrs[0] === 'string' ? attrs[0] : undefined,
785
+ description: typeof attrs[1] === 'string' ? attrs[1] : undefined,
786
+ };
787
+ }
788
+ case 'IFCMATERIALLAYERSET': {
789
+ // IfcMaterialLayerSet: [MaterialLayers, LayerSetName, Description]
790
+ const layerIds = Array.isArray(attrs[0]) ? attrs[0].filter((id) => typeof id === 'number') : [];
791
+ const layers = [];
792
+ for (const layerId of layerIds) {
793
+ const layerRef = store.entityIndex.byId.get(layerId);
794
+ if (!layerRef)
795
+ continue;
796
+ const layerEntity = extractor.extractEntity(layerRef);
797
+ if (!layerEntity)
798
+ continue;
799
+ const la = layerEntity.attributes || [];
800
+ // IfcMaterialLayer: [Material, LayerThickness, IsVentilated, Name, Description, Category, Priority]
801
+ const matId = typeof la[0] === 'number' ? la[0] : undefined;
802
+ let materialName;
803
+ if (matId) {
804
+ const matRef = store.entityIndex.byId.get(matId);
805
+ if (matRef) {
806
+ const matEntity = extractor.extractEntity(matRef);
807
+ if (matEntity) {
808
+ materialName = typeof matEntity.attributes?.[0] === 'string' ? matEntity.attributes[0] : undefined;
809
+ }
810
+ }
811
+ }
812
+ layers.push({
813
+ materialName,
814
+ thickness: typeof la[1] === 'number' ? la[1] : undefined,
815
+ isVentilated: la[2] === true || la[2] === '.T.',
816
+ name: typeof la[3] === 'string' ? la[3] : undefined,
817
+ category: typeof la[5] === 'string' ? la[5] : undefined,
818
+ });
819
+ }
820
+ return {
821
+ type: 'MaterialLayerSet',
822
+ name: typeof attrs[1] === 'string' ? attrs[1] : undefined,
823
+ description: typeof attrs[2] === 'string' ? attrs[2] : undefined,
824
+ layers,
825
+ };
826
+ }
827
+ case 'IFCMATERIALPROFILESET': {
828
+ // IfcMaterialProfileSet: [Name, Description, MaterialProfiles, CompositeProfile]
829
+ const profileIds = Array.isArray(attrs[2]) ? attrs[2].filter((id) => typeof id === 'number') : [];
830
+ const profiles = [];
831
+ for (const profId of profileIds) {
832
+ const profRef = store.entityIndex.byId.get(profId);
833
+ if (!profRef)
834
+ continue;
835
+ const profEntity = extractor.extractEntity(profRef);
836
+ if (!profEntity)
837
+ continue;
838
+ const pa = profEntity.attributes || [];
839
+ // IfcMaterialProfile: [Name, Description, Material, Profile, Priority, Category]
840
+ const matId = typeof pa[2] === 'number' ? pa[2] : undefined;
841
+ let materialName;
842
+ if (matId) {
843
+ const matRef = store.entityIndex.byId.get(matId);
844
+ if (matRef) {
845
+ const matEntity = extractor.extractEntity(matRef);
846
+ if (matEntity) {
847
+ materialName = typeof matEntity.attributes?.[0] === 'string' ? matEntity.attributes[0] : undefined;
848
+ }
849
+ }
850
+ }
851
+ profiles.push({
852
+ materialName,
853
+ name: typeof pa[0] === 'string' ? pa[0] : undefined,
854
+ category: typeof pa[5] === 'string' ? pa[5] : undefined,
855
+ });
856
+ }
857
+ return {
858
+ type: 'MaterialProfileSet',
859
+ name: typeof attrs[0] === 'string' ? attrs[0] : undefined,
860
+ description: typeof attrs[1] === 'string' ? attrs[1] : undefined,
861
+ profiles,
862
+ };
863
+ }
864
+ case 'IFCMATERIALCONSTITUENTSET': {
865
+ // IfcMaterialConstituentSet: [Name, Description, MaterialConstituents]
866
+ const constituentIds = Array.isArray(attrs[2]) ? attrs[2].filter((id) => typeof id === 'number') : [];
867
+ const constituents = [];
868
+ for (const constId of constituentIds) {
869
+ const constRef = store.entityIndex.byId.get(constId);
870
+ if (!constRef)
871
+ continue;
872
+ const constEntity = extractor.extractEntity(constRef);
873
+ if (!constEntity)
874
+ continue;
875
+ const ca = constEntity.attributes || [];
876
+ // IfcMaterialConstituent: [Name, Description, Material, Fraction, Category]
877
+ const matId = typeof ca[2] === 'number' ? ca[2] : undefined;
878
+ let materialName;
879
+ if (matId) {
880
+ const matRef = store.entityIndex.byId.get(matId);
881
+ if (matRef) {
882
+ const matEntity = extractor.extractEntity(matRef);
883
+ if (matEntity) {
884
+ materialName = typeof matEntity.attributes?.[0] === 'string' ? matEntity.attributes[0] : undefined;
885
+ }
886
+ }
887
+ }
888
+ constituents.push({
889
+ materialName,
890
+ name: typeof ca[0] === 'string' ? ca[0] : undefined,
891
+ fraction: typeof ca[3] === 'number' ? ca[3] : undefined,
892
+ category: typeof ca[4] === 'string' ? ca[4] : undefined,
893
+ });
894
+ }
895
+ return {
896
+ type: 'MaterialConstituentSet',
897
+ name: typeof attrs[0] === 'string' ? attrs[0] : undefined,
898
+ description: typeof attrs[1] === 'string' ? attrs[1] : undefined,
899
+ constituents,
900
+ };
901
+ }
902
+ case 'IFCMATERIALLIST': {
903
+ // IfcMaterialList: [Materials]
904
+ const matIds = Array.isArray(attrs[0]) ? attrs[0].filter((id) => typeof id === 'number') : [];
905
+ const materials = [];
906
+ for (const matId of matIds) {
907
+ const matRef = store.entityIndex.byId.get(matId);
908
+ if (!matRef)
909
+ continue;
910
+ const matEntity = extractor.extractEntity(matRef);
911
+ if (matEntity) {
912
+ const name = typeof matEntity.attributes?.[0] === 'string' ? matEntity.attributes[0] : `Material #${matId}`;
913
+ materials.push(name);
914
+ }
915
+ }
916
+ return {
917
+ type: 'MaterialList',
918
+ materials,
919
+ };
920
+ }
921
+ case 'IFCMATERIALLAYERSETUSAGE': {
922
+ // IfcMaterialLayerSetUsage: [ForLayerSet, LayerSetDirection, DirectionSense, OffsetFromReferenceLine, ...]
923
+ const layerSetId = typeof attrs[0] === 'number' ? attrs[0] : undefined;
924
+ if (layerSetId) {
925
+ return resolveMaterial(store, extractor, layerSetId, visited);
926
+ }
927
+ return null;
928
+ }
929
+ case 'IFCMATERIALPROFILESETUSAGE': {
930
+ // IfcMaterialProfileSetUsage: [ForProfileSet, ...]
931
+ const profileSetId = typeof attrs[0] === 'number' ? attrs[0] : undefined;
932
+ if (profileSetId) {
933
+ return resolveMaterial(store, extractor, profileSetId, visited);
934
+ }
935
+ return null;
936
+ }
937
+ default:
938
+ return null;
939
+ }
940
+ }
941
+ /**
942
+ * Parse a property entity's value based on its IFC type.
943
+ * Handles all 6 IfcProperty subtypes:
944
+ * - IfcPropertySingleValue: direct value
945
+ * - IfcPropertyEnumeratedValue: list of enum values → joined string
946
+ * - IfcPropertyBoundedValue: upper/lower bounds → "value [min – max]"
947
+ * - IfcPropertyListValue: list of values → joined string
948
+ * - IfcPropertyTableValue: defining/defined value pairs → "Table(N rows)"
949
+ * - IfcPropertyReferenceValue: entity reference → "Reference #ID"
950
+ */
951
+ function parsePropertyValue(propEntity) {
952
+ const attrs = propEntity.attributes || [];
953
+ const typeUpper = propEntity.type.toUpperCase();
954
+ switch (typeUpper) {
955
+ case 'IFCPROPERTYENUMERATEDVALUE': {
956
+ // [Name, Description, EnumerationValues (list), EnumerationReference]
957
+ const enumValues = attrs[2];
958
+ if (Array.isArray(enumValues)) {
959
+ const values = enumValues.map(v => {
960
+ if (Array.isArray(v) && v.length === 2)
961
+ return String(v[1]); // Typed value
962
+ return String(v);
963
+ }).filter(v => v !== 'null' && v !== 'undefined');
964
+ return { type: 0, value: values.join(', ') };
965
+ }
966
+ return { type: 0, value: null };
967
+ }
968
+ case 'IFCPROPERTYBOUNDEDVALUE': {
969
+ // [Name, Description, UpperBoundValue, LowerBoundValue, Unit, SetPointValue]
970
+ const upper = extractNumericValue(attrs[2]);
971
+ const lower = extractNumericValue(attrs[3]);
972
+ const setPoint = extractNumericValue(attrs[5]);
973
+ const displayValue = setPoint ?? upper ?? lower;
974
+ let display = displayValue != null ? String(displayValue) : '';
975
+ if (lower != null && upper != null) {
976
+ display += ` [${lower} – ${upper}]`;
977
+ }
978
+ return { type: displayValue != null ? 1 : 0, value: display || null };
979
+ }
980
+ case 'IFCPROPERTYLISTVALUE': {
981
+ // [Name, Description, ListValues (list), Unit]
982
+ const listValues = attrs[2];
983
+ if (Array.isArray(listValues)) {
984
+ const values = listValues.map(v => {
985
+ if (Array.isArray(v) && v.length === 2)
986
+ return String(v[1]);
987
+ return String(v);
988
+ }).filter(v => v !== 'null' && v !== 'undefined');
989
+ return { type: 0, value: values.join(', ') };
990
+ }
991
+ return { type: 0, value: null };
992
+ }
993
+ case 'IFCPROPERTYTABLEVALUE': {
994
+ // [Name, Description, DefiningValues, DefinedValues, ...]
995
+ const definingValues = attrs[2];
996
+ const definedValues = attrs[3];
997
+ const rowCount = Array.isArray(definingValues) ? definingValues.length : 0;
998
+ if (rowCount > 0 && Array.isArray(definedValues)) {
999
+ return { type: 0, value: `Table (${rowCount} rows)` };
1000
+ }
1001
+ return { type: 0, value: null };
1002
+ }
1003
+ case 'IFCPROPERTYREFERENCEVALUE': {
1004
+ // [Name, Description, PropertyReference]
1005
+ const refValue = attrs[2];
1006
+ if (typeof refValue === 'number') {
1007
+ return { type: 0, value: `#${refValue}` };
1008
+ }
1009
+ return { type: 0, value: null };
1010
+ }
1011
+ default: {
1012
+ // IfcPropertySingleValue and fallback: [Name, Description, NominalValue, Unit]
1013
+ const nominalValue = attrs[2];
1014
+ let type = 0;
1015
+ let value = nominalValue;
1016
+ // Handle typed values like IFCBOOLEAN(.T.), IFCREAL(1.5)
1017
+ if (Array.isArray(nominalValue) && nominalValue.length === 2) {
1018
+ const innerValue = nominalValue[1];
1019
+ const typeName = String(nominalValue[0]).toUpperCase();
1020
+ if (typeName.includes('BOOLEAN') || typeName.includes('LOGICAL')) {
1021
+ type = 2;
1022
+ value = innerValue === '.T.' || innerValue === true;
1023
+ }
1024
+ else if (typeof innerValue === 'number') {
1025
+ type = 1;
1026
+ value = innerValue;
1027
+ }
1028
+ else {
1029
+ type = 0;
1030
+ value = String(innerValue);
1031
+ }
1032
+ }
1033
+ else if (typeof nominalValue === 'number') {
1034
+ type = 1;
1035
+ }
1036
+ else if (typeof nominalValue === 'boolean') {
1037
+ type = 2;
1038
+ }
1039
+ else if (nominalValue !== null && nominalValue !== undefined) {
1040
+ value = String(nominalValue);
1041
+ }
1042
+ return { type, value };
1043
+ }
1044
+ }
1045
+ }
1046
+ /** Extract a numeric value from a possibly typed STEP value. */
1047
+ function extractNumericValue(attr) {
1048
+ if (typeof attr === 'number')
1049
+ return attr;
1050
+ if (Array.isArray(attr) && attr.length === 2 && typeof attr[1] === 'number')
1051
+ return attr[1];
1052
+ return null;
1053
+ }
1054
+ /**
1055
+ * Extract property sets from a list of pset IDs using the entity index.
1056
+ * Shared logic between instance-level and type-level property extraction.
1057
+ */
1058
+ function extractPsetsFromIds(store, extractor, psetIds) {
1059
+ const result = [];
1060
+ for (const psetId of psetIds) {
1061
+ const psetRef = store.entityIndex.byId.get(psetId);
1062
+ if (!psetRef)
1063
+ continue;
1064
+ // Only extract IFCPROPERTYSET entities (skip quantity sets etc.)
1065
+ if (psetRef.type.toUpperCase() !== 'IFCPROPERTYSET')
1066
+ continue;
1067
+ const psetEntity = extractor.extractEntity(psetRef);
1068
+ if (!psetEntity)
1069
+ continue;
1070
+ const psetAttrs = psetEntity.attributes || [];
1071
+ const psetGlobalId = typeof psetAttrs[0] === 'string' ? psetAttrs[0] : undefined;
1072
+ const psetName = typeof psetAttrs[2] === 'string' ? psetAttrs[2] : `PropertySet #${psetId}`;
1073
+ const hasProperties = psetAttrs[4];
1074
+ const properties = [];
1075
+ if (Array.isArray(hasProperties)) {
1076
+ for (const propRef of hasProperties) {
1077
+ if (typeof propRef !== 'number')
1078
+ continue;
1079
+ const propEntityRef = store.entityIndex.byId.get(propRef);
1080
+ if (!propEntityRef)
1081
+ continue;
1082
+ const propEntity = extractor.extractEntity(propEntityRef);
1083
+ if (!propEntity)
1084
+ continue;
1085
+ const propAttrs = propEntity.attributes || [];
1086
+ const propName = typeof propAttrs[0] === 'string' ? propAttrs[0] : '';
1087
+ if (!propName)
1088
+ continue;
1089
+ const parsed = parsePropertyValue(propEntity);
1090
+ properties.push({ name: propName, type: parsed.type, value: parsed.value });
1091
+ }
1092
+ }
1093
+ if (properties.length > 0 || psetName) {
1094
+ result.push({ name: psetName, globalId: psetGlobalId, properties });
1095
+ }
1096
+ }
1097
+ return result;
1098
+ }
1099
+ /**
1100
+ * Extract type-level properties for a single entity ON-DEMAND.
1101
+ * Finds the element's type via IfcRelDefinesByType, then extracts property sets from:
1102
+ * 1. The type entity's HasPropertySets attribute (IFC2X3/IFC4: index 5 on IfcTypeObject)
1103
+ * 2. The onDemandPropertyMap for the type entity (IFC4 IFCRELDEFINESBYPROPERTIES → type)
1104
+ * Returns null if no type relationship exists.
1105
+ */
1106
+ export function extractTypePropertiesOnDemand(store, entityId) {
1107
+ if (!store.relationships)
1108
+ return null;
1109
+ // Find type entity via DefinesByType relationship (inverse: element → type)
1110
+ const typeIds = store.relationships.getRelated(entityId, RelationshipType.DefinesByType, 'inverse');
1111
+ if (typeIds.length === 0)
1112
+ return null;
1113
+ const typeId = typeIds[0]; // An element typically has one type
1114
+ const typeRef = store.entityIndex.byId.get(typeId);
1115
+ if (!typeRef)
1116
+ return null;
1117
+ if (!store.source?.length)
1118
+ return null;
1119
+ const extractor = new EntityExtractor(store.source);
1120
+ // Get type name from entity
1121
+ const typeEntity = extractor.extractEntity(typeRef);
1122
+ const typeName = typeEntity && typeof typeEntity.attributes?.[2] === 'string'
1123
+ ? typeEntity.attributes[2]
1124
+ : typeRef.type;
1125
+ const allPsets = [];
1126
+ const seenPsetNames = new Set();
1127
+ // Source 1: HasPropertySets attribute on the type entity (index 5 for IfcTypeObject subtypes)
1128
+ // Works for both IFC2X3 and IFC4
1129
+ if (typeEntity) {
1130
+ const hasPropertySets = typeEntity.attributes?.[5];
1131
+ if (Array.isArray(hasPropertySets)) {
1132
+ const psetIds = hasPropertySets.filter((id) => typeof id === 'number');
1133
+ const psets = extractPsetsFromIds(store, extractor, psetIds);
1134
+ for (const pset of psets) {
1135
+ seenPsetNames.add(pset.name);
1136
+ allPsets.push(pset);
1137
+ }
1138
+ }
1139
+ }
1140
+ // Source 2: onDemandPropertyMap for the type entity (IFC4: via IFCRELDEFINESBYPROPERTIES)
1141
+ if (store.onDemandPropertyMap) {
1142
+ const typePsetIds = store.onDemandPropertyMap.get(typeId);
1143
+ if (typePsetIds && typePsetIds.length > 0) {
1144
+ const psets = extractPsetsFromIds(store, extractor, typePsetIds);
1145
+ for (const pset of psets) {
1146
+ if (!seenPsetNames.has(pset.name)) {
1147
+ allPsets.push(pset);
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ if (allPsets.length === 0)
1153
+ return null;
1154
+ return {
1155
+ typeName,
1156
+ typeId,
1157
+ properties: allPsets,
1158
+ };
1159
+ }
1160
+ /**
1161
+ * Extract documents for a single entity ON-DEMAND.
1162
+ * Uses the onDemandDocumentMap built during parsing.
1163
+ * Falls back to relationship graph when on-demand map is not available.
1164
+ * Also checks type-level documents via IfcRelDefinesByType.
1165
+ * Returns an array of document info objects.
1166
+ */
1167
+ export function extractDocumentsOnDemand(store, entityId) {
1168
+ let docRefIds;
1169
+ if (store.onDemandDocumentMap) {
1170
+ docRefIds = store.onDemandDocumentMap.get(entityId);
1171
+ }
1172
+ else if (store.relationships) {
1173
+ const related = store.relationships.getRelated(entityId, RelationshipType.AssociatesDocument, 'inverse');
1174
+ if (related.length > 0)
1175
+ docRefIds = related;
1176
+ }
1177
+ // Also check type-level documents via IfcRelDefinesByType
1178
+ if (store.relationships) {
1179
+ const typeIds = store.relationships.getRelated(entityId, RelationshipType.DefinesByType, 'inverse');
1180
+ for (const typeId of typeIds) {
1181
+ let typeDocRefs;
1182
+ if (store.onDemandDocumentMap) {
1183
+ typeDocRefs = store.onDemandDocumentMap.get(typeId);
1184
+ }
1185
+ else {
1186
+ const related = store.relationships.getRelated(typeId, RelationshipType.AssociatesDocument, 'inverse');
1187
+ if (related.length > 0)
1188
+ typeDocRefs = related;
1189
+ }
1190
+ if (typeDocRefs && typeDocRefs.length > 0) {
1191
+ docRefIds = docRefIds ? [...docRefIds, ...typeDocRefs] : [...typeDocRefs];
1192
+ }
1193
+ }
1194
+ }
1195
+ if (!docRefIds || docRefIds.length === 0)
1196
+ return [];
1197
+ if (!store.source?.length)
1198
+ return [];
1199
+ const extractor = new EntityExtractor(store.source);
1200
+ const results = [];
1201
+ for (const docId of docRefIds) {
1202
+ const docRef = store.entityIndex.byId.get(docId);
1203
+ if (!docRef)
1204
+ continue;
1205
+ const docEntity = extractor.extractEntity(docRef);
1206
+ if (!docEntity)
1207
+ continue;
1208
+ const typeUpper = docEntity.type.toUpperCase();
1209
+ const attrs = docEntity.attributes || [];
1210
+ if (typeUpper === 'IFCDOCUMENTREFERENCE') {
1211
+ // IFC4: [Location, Identification, Name, Description, ReferencedDocument]
1212
+ // IFC2X3: [Location, ItemReference, Name]
1213
+ const info = {
1214
+ location: typeof attrs[0] === 'string' ? attrs[0] : undefined,
1215
+ identification: typeof attrs[1] === 'string' ? attrs[1] : undefined,
1216
+ name: typeof attrs[2] === 'string' ? attrs[2] : undefined,
1217
+ description: typeof attrs[3] === 'string' ? attrs[3] : undefined,
1218
+ };
1219
+ // Walk to IfcDocumentInformation if ReferencedDocument is set (IFC4 attr[4])
1220
+ if (typeof attrs[4] === 'number') {
1221
+ const docInfoRef = store.entityIndex.byId.get(attrs[4]);
1222
+ if (docInfoRef) {
1223
+ const docInfoEntity = extractor.extractEntity(docInfoRef);
1224
+ if (docInfoEntity && docInfoEntity.type.toUpperCase() === 'IFCDOCUMENTINFORMATION') {
1225
+ const ia = docInfoEntity.attributes || [];
1226
+ // IfcDocumentInformation: [Identification, Name, Description, Location, Purpose, IntendedUse, Scope, Revision, ...]
1227
+ if (!info.identification && typeof ia[0] === 'string')
1228
+ info.identification = ia[0];
1229
+ if (!info.name && typeof ia[1] === 'string')
1230
+ info.name = ia[1];
1231
+ if (!info.description && typeof ia[2] === 'string')
1232
+ info.description = ia[2];
1233
+ if (!info.location && typeof ia[3] === 'string')
1234
+ info.location = ia[3];
1235
+ if (typeof ia[4] === 'string')
1236
+ info.purpose = ia[4];
1237
+ if (typeof ia[5] === 'string')
1238
+ info.intendedUse = ia[5];
1239
+ if (typeof ia[7] === 'string')
1240
+ info.revision = ia[7];
1241
+ }
1242
+ }
1243
+ }
1244
+ if (info.name || info.location || info.identification) {
1245
+ results.push(info);
1246
+ }
1247
+ }
1248
+ else if (typeUpper === 'IFCDOCUMENTINFORMATION') {
1249
+ // Direct IfcDocumentInformation (less common)
1250
+ const info = {
1251
+ identification: typeof attrs[0] === 'string' ? attrs[0] : undefined,
1252
+ name: typeof attrs[1] === 'string' ? attrs[1] : undefined,
1253
+ description: typeof attrs[2] === 'string' ? attrs[2] : undefined,
1254
+ location: typeof attrs[3] === 'string' ? attrs[3] : undefined,
1255
+ purpose: typeof attrs[4] === 'string' ? attrs[4] : undefined,
1256
+ intendedUse: typeof attrs[5] === 'string' ? attrs[5] : undefined,
1257
+ revision: typeof attrs[7] === 'string' ? attrs[7] : undefined,
1258
+ };
1259
+ if (info.name || info.location || info.identification) {
1260
+ results.push(info);
1261
+ }
1262
+ }
1263
+ }
1264
+ return results;
1265
+ }
1266
+ /**
1267
+ * Extract structural relationships for a single entity ON-DEMAND.
1268
+ * Finds openings (VoidsElement), fills (FillsElement), groups (AssignsToGroup),
1269
+ * and path connections (ConnectsPathElements).
1270
+ */
1271
+ export function extractRelationshipsOnDemand(store, entityId) {
1272
+ const result = {
1273
+ voids: [],
1274
+ fills: [],
1275
+ groups: [],
1276
+ connections: [],
1277
+ };
1278
+ if (!store.relationships)
1279
+ return result;
1280
+ const getEntityInfo = (id) => {
1281
+ const ref = store.entityIndex.byId.get(id);
1282
+ if (!ref)
1283
+ return { type: 'Unknown' };
1284
+ const name = store.entities?.getName(id);
1285
+ return { name: name || undefined, type: ref.type };
1286
+ };
1287
+ // VoidsElement: openings that void this element
1288
+ const voidsIds = store.relationships.getRelated(entityId, RelationshipType.VoidsElement, 'forward');
1289
+ for (const id of voidsIds) {
1290
+ const info = getEntityInfo(id);
1291
+ result.voids.push({ id, ...info });
1292
+ }
1293
+ // FillsElement: this element fills an opening
1294
+ const fillsIds = store.relationships.getRelated(entityId, RelationshipType.FillsElement, 'inverse');
1295
+ for (const id of fillsIds) {
1296
+ const info = getEntityInfo(id);
1297
+ result.fills.push({ id, ...info });
1298
+ }
1299
+ // AssignsToGroup: groups this element belongs to
1300
+ const groupIds = store.relationships.getRelated(entityId, RelationshipType.AssignsToGroup, 'inverse');
1301
+ for (const id of groupIds) {
1302
+ const name = store.entities?.getName(id);
1303
+ result.groups.push({ id, name: name || undefined });
1304
+ }
1305
+ // ConnectsPathElements: connected walls
1306
+ const connectedIds = store.relationships.getRelated(entityId, RelationshipType.ConnectsPathElements, 'forward');
1307
+ const connectedInverseIds = store.relationships.getRelated(entityId, RelationshipType.ConnectsPathElements, 'inverse');
1308
+ const allConnected = new Set([...connectedIds, ...connectedInverseIds]);
1309
+ allConnected.delete(entityId);
1310
+ for (const id of allConnected) {
1311
+ const info = getEntityInfo(id);
1312
+ result.connections.push({ id, ...info });
1313
+ }
1314
+ return result;
1315
+ }
1316
+ // ============================================================================
1317
+ // On-Demand Georeferencing Extraction
1318
+ // ============================================================================
1319
+ import { extractGeoreferencing as extractGeorefFromEntities } from './georef-extractor.js';
1320
+ /**
1321
+ * Extract georeferencing info from on-demand store (source buffer + entityIndex).
1322
+ * Bridges to the entity-based georef extractor by resolving entities lazily.
1323
+ */
1324
+ export function extractGeoreferencingOnDemand(store) {
1325
+ if (!store.source?.length || !store.entityIndex)
1326
+ return null;
1327
+ const extractor = new EntityExtractor(store.source);
1328
+ const { byId, byType } = store.entityIndex;
1329
+ // Build a lightweight entity map for just the georef-related types
1330
+ const entityMap = new Map();
1331
+ const typeMap = new Map();
1332
+ for (const typeName of ['IFCMAPCONVERSION', 'IFCPROJECTEDCRS']) {
1333
+ const ids = byType.get(typeName);
1334
+ if (!ids?.length)
1335
+ continue;
1336
+ // Use mixed-case for the georef extractor's type lookup
1337
+ const displayName = typeName === 'IFCMAPCONVERSION' ? 'IfcMapConversion' : 'IfcProjectedCRS';
1338
+ typeMap.set(displayName, ids);
1339
+ for (const id of ids) {
1340
+ const ref = byId.get(id);
1341
+ if (!ref)
1342
+ continue;
1343
+ const entity = extractor.extractEntity(ref);
1344
+ if (entity) {
1345
+ entityMap.set(id, entity);
1346
+ }
1347
+ }
1348
+ }
1349
+ if (entityMap.size === 0)
1350
+ return null;
1351
+ // Cast to IfcEntity (they share the same shape)
1352
+ return extractGeorefFromEntities(entityMap, typeMap);
491
1353
  }
492
1354
  //# sourceMappingURL=columnar-parser.js.map