@ifc-lite/viewer 1.27.0 → 1.28.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.
Files changed (97) hide show
  1. package/.turbo/turbo-build.log +38 -38
  2. package/CHANGELOG.md +64 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-BNRDNuUJ.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  6. package/dist/assets/{deflate-B-d0SYQM.js → deflate-DNGgs8Ur.js} +1 -1
  7. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-B9v81gi9.js} +1249 -1140
  10. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-D-YCLS4g.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-CCpq-5d3.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-DbgS5EUA.wasm} +0 -0
  14. package/dist/assets/{index-COYokSKc.js → index-Bgb3_Pu_.js} +41073 -38715
  15. package/dist/assets/index-BtbXFKsX.css +1 -0
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-CWfqZyyr.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-DGOAeUqU.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-XPLU2Wkq.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-1PMSCHwX.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-C65U9lNM.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-XxXos6yI.js} +2 -2
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-BdMWXC3m.js} +1 -1
  24. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-CRwaZf3s.js} +10 -10
  26. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-0sDo3g3m.js} +2960 -2552
  28. package/dist/assets/server-client-cTCJ-853.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-BtakWX7W.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-B1YOg2QB.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-CmwsbxmM.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/components/mcp/playground-dispatcher.ts +3 -0
  35. package/src/components/mcp/playground-files.ts +33 -1
  36. package/src/components/viewer/CommandPalette.tsx +6 -1
  37. package/src/components/viewer/ComparePanel.tsx +420 -0
  38. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  39. package/src/components/viewer/MainToolbar.tsx +19 -2
  40. package/src/components/viewer/PropertiesPanel.tsx +71 -2
  41. package/src/components/viewer/ViewerLayout.tsx +5 -0
  42. package/src/components/viewer/Viewport.tsx +3 -0
  43. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  44. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  45. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  46. package/src/components/viewer/hierarchy/types.ts +1 -0
  47. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  48. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  49. package/src/hooks/federationLoadGate.test.ts +12 -2
  50. package/src/hooks/federationLoadGate.ts +9 -2
  51. package/src/hooks/ingest/federationAlign.ts +481 -0
  52. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  53. package/src/hooks/useCompare.ts +0 -0
  54. package/src/hooks/useCompareOverlay.ts +119 -0
  55. package/src/hooks/useDrawingGeneration.ts +23 -1
  56. package/src/hooks/useIfc.ts +1 -1
  57. package/src/hooks/useIfcCache.ts +32 -9
  58. package/src/hooks/useIfcFederation.ts +42 -810
  59. package/src/hooks/useIfcLoader.ts +361 -488
  60. package/src/hooks/useIfcServer.ts +3 -0
  61. package/src/hooks/useLens.ts +5 -1
  62. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  63. package/src/lib/compare/buildFingerprints.ts +173 -0
  64. package/src/lib/compare/describeChange.ts +0 -0
  65. package/src/lib/compare/geometricData.test.ts +54 -0
  66. package/src/lib/compare/geometricData.ts +37 -0
  67. package/src/lib/compare/overlay.test.ts +99 -0
  68. package/src/lib/compare/overlay.ts +91 -0
  69. package/src/lib/geo/cesium-placement.ts +1 -1
  70. package/src/lib/geo/reproject.ts +4 -1
  71. package/src/lib/llm/script-edit-ops.ts +23 -0
  72. package/src/lib/llm/stream-client.ts +8 -1
  73. package/src/lib/search/result-export.ts +7 -1
  74. package/src/sdk/adapters/export-adapter.ts +6 -1
  75. package/src/store/globalId.ts +15 -13
  76. package/src/store/index.ts +16 -1
  77. package/src/store/slices/cesiumSlice.ts +8 -1
  78. package/src/store/slices/compareSlice.ts +96 -0
  79. package/src/store/slices/lensSlice.ts +8 -0
  80. package/src/utils/acquireFileBuffer.test.ts +12 -4
  81. package/src/utils/desktopModelSnapshot.ts +2 -1
  82. package/src/utils/loadingUtils.ts +32 -0
  83. package/src/utils/spatialHierarchy.test.ts +53 -1
  84. package/src/utils/spatialHierarchy.ts +42 -2
  85. package/src/vite-env.d.ts +2 -0
  86. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  87. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  88. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  89. package/dist/assets/index-ajK6D32J.css +0 -1
  90. package/dist/assets/lens-PYsLu_MA.js +0 -1
  91. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  92. package/dist/assets/raw-D9iw0tmc.js +0 -1
  93. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  94. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  95. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  96. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  97. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -36,7 +36,7 @@ import { getNativeEntityDetails } from '@/services/desktop-native-metadata';
36
36
  import { configureMutationView } from '@/utils/configureMutationView';
37
37
  import { IfcQuery } from '@ifc-lite/query';
38
38
  import { MutablePropertyView } from '@ifc-lite/mutations';
39
- import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGeoreferencingOnDemand, extractLengthUnitScale, getAttributeNames, type IfcDataStore } from '@ifc-lite/parser';
39
+ import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractMaterialPropertiesOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGeoreferencingOnDemand, extractLengthUnitScale, getAttributeNames, type IfcDataStore, type MaterialPsetGroup } from '@ifc-lite/parser';
40
40
  import type { NewEntity } from '@ifc-lite/mutations';
41
41
  import { EntityFlags, RelationshipType, isSpatialStructureTypeName, isStoreyLikeSpatialTypeName } from '@ifc-lite/data';
42
42
  import type { EntityRef, FederatedModel } from '@/store/types';
@@ -47,6 +47,7 @@ import { QuantitySetCard } from './properties/QuantitySetCard';
47
47
  import { ModelMetadataPanel } from './properties/ModelMetadataPanel';
48
48
  import { ClassificationCard } from './properties/ClassificationCard';
49
49
  import { MaterialCard } from './properties/MaterialCard';
50
+ import { MaterialTotalsPanel } from './properties/MaterialTotalsPanel';
50
51
  import { ScheduleCard } from './properties/ScheduleCard';
51
52
  import { TaskEditCard } from './properties/TaskEditCard';
52
53
  import { DocumentCard } from './properties/DocumentCard';
@@ -56,6 +57,17 @@ import { BsddCard } from './properties/BsddCard';
56
57
  import { GeoreferencingPanel } from './properties/GeoreferencingPanel';
57
58
  import { RawStepCard } from './properties/RawStepCard';
58
59
 
60
+ /** IFC material *definition* classes selectable from the Materials tab. */
61
+ const MATERIAL_DEF_TYPES = new Set([
62
+ 'IFCMATERIAL',
63
+ 'IFCMATERIALLAYERSET',
64
+ 'IFCMATERIALLAYERSETUSAGE',
65
+ 'IFCMATERIALPROFILESET',
66
+ 'IFCMATERIALPROFILESETUSAGE',
67
+ 'IFCMATERIALCONSTITUENTSET',
68
+ 'IFCMATERIALLIST',
69
+ ]);
70
+
59
71
  type DisplayProperty = { name: string; value: unknown; isMutated: boolean };
60
72
  type DisplayPropertySet = {
61
73
  name: string;
@@ -450,6 +462,16 @@ export function PropertiesPanel() {
450
462
  return typeName.endsWith('Type');
451
463
  }, [selectedEntity, model, ifcDataStore]);
452
464
 
465
+ // Detect a material definition selected from the "Materials" hierarchy tab.
466
+ // Materials aren't products, so the EntityTable's getTypeName doesn't cover
467
+ // them — read the raw class from the entity index instead.
468
+ const selectedMaterialId = useMemo(() => {
469
+ if (!selectedEntity) return null;
470
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
471
+ const rawType = (dataStore as IfcDataStore | null)?.entityIndex?.byId?.get(selectedEntity.expressId)?.type;
472
+ return rawType && MATERIAL_DEF_TYPES.has(rawType.toUpperCase()) ? selectedEntity.expressId : null;
473
+ }, [selectedEntity, model, ifcDataStore]);
474
+
453
475
  // Unified property/quantity access - EntityNode handles on-demand extraction automatically
454
476
  // These hooks must be called before any early return to maintain hook order
455
477
  // Use MutablePropertyView as primary source when available (it handles base + mutations)
@@ -627,6 +649,17 @@ export function PropertiesPanel() {
627
649
  return extractMaterialsOnDemand(dataStore as IfcDataStore, lookupExpressId);
628
650
  }, [selectedEntity, lookupExpressId, model, ifcDataStore]);
629
651
 
652
+ // Property sets attached to the selected entity's material(s) via
653
+ // IfcMaterialProperties (e.g. Pset_MaterialConcrete). These live on the
654
+ // IfcMaterial — not on the object — so they never surface through the
655
+ // occurrence/type pset paths; resolve them through the material association.
656
+ const materialProperties: MaterialPsetGroup[] = useMemo(() => {
657
+ if (!selectedEntity || lookupExpressId === null) return [];
658
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
659
+ if (!dataStore) return [];
660
+ return extractMaterialPropertiesOnDemand(dataStore as IfcDataStore, lookupExpressId);
661
+ }, [selectedEntity, lookupExpressId, model, ifcDataStore]);
662
+
630
663
  // Extract documents for the selected entity from the IFC data store
631
664
  const documents = useMemo(() => {
632
665
  if (!selectedEntity || lookupExpressId === null) return [];
@@ -1002,6 +1035,7 @@ export function PropertiesPanel() {
1002
1035
  const renderedAttributes = isNativeLazySelection ? [] : attributes;
1003
1036
  const renderedClassifications = isNativeLazySelection ? [] : classifications;
1004
1037
  const renderedMaterialInfo = isNativeLazySelection ? null : materialInfo;
1038
+ const renderedMaterialProperties = isNativeLazySelection ? [] : materialProperties;
1005
1039
  const renderedDocuments = isNativeLazySelection ? [] : documents;
1006
1040
  const renderedEntityRelationships = isNativeLazySelection ? null : entityRelationships;
1007
1041
  const renderedGeoref = isNativeLazySelection ? null : georef;
@@ -1053,6 +1087,12 @@ export function PropertiesPanel() {
1053
1087
  }
1054
1088
  }
1055
1089
 
1090
+ // Material selected from the "Materials" hierarchy tab — show the material's
1091
+ // own property sets plus quantities aggregated across all using elements.
1092
+ if (selectedMaterialId !== null && selectedEntity) {
1093
+ return <MaterialTotalsPanel materialId={selectedMaterialId} modelId={selectedEntity.modelId} />;
1094
+ }
1095
+
1056
1096
  // Multi-entity selection (unified storeys) - render combined view
1057
1097
  if (selectedEntities.length > 1) {
1058
1098
  return (
@@ -1481,6 +1521,7 @@ export function PropertiesPanel() {
1481
1521
  {renderedMergedProperties.length === 0
1482
1522
  && renderedClassifications.length === 0
1483
1523
  && !renderedMaterialInfo
1524
+ && renderedMaterialProperties.length === 0
1484
1525
  && renderedDocuments.length === 0
1485
1526
  && !renderedEntityRelationships
1486
1527
  && !hasScheduleForSelection ? (
@@ -1560,10 +1601,38 @@ export function PropertiesPanel() {
1560
1601
  </>
1561
1602
  )}
1562
1603
 
1604
+ {/* Material Property Sets (Pset_Material* attached to the
1605
+ IfcMaterial via IfcMaterialProperties). Grouped per material,
1606
+ mirroring the Type Properties block. */}
1607
+ {renderedMaterialProperties.length > 0 && (
1608
+ <>
1609
+ {(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo) && (
1610
+ <div className="border-t border-amber-200 dark:border-amber-800/50 pt-2 mt-2" />
1611
+ )}
1612
+ {renderedMaterialProperties.map((group) => (
1613
+ <div key={`matpset-${group.materialId}`} className="space-y-3">
1614
+ <div className="flex items-center gap-2 px-1 pb-0.5 text-[11px] text-amber-600/70 dark:text-amber-400/60 uppercase tracking-wider font-semibold">
1615
+ <Layers className="h-3 w-3 shrink-0" />
1616
+ <span className="truncate">Material Properties ({group.materialName})</span>
1617
+ </div>
1618
+ {group.psets.map((pset) => (
1619
+ <PropertySetCard
1620
+ key={`matpset-${group.materialId}-${pset.name}`}
1621
+ pset={{
1622
+ name: pset.name,
1623
+ properties: pset.properties.map((p) => ({ name: p.name, value: p.value, isMutated: false })),
1624
+ }}
1625
+ />
1626
+ ))}
1627
+ </div>
1628
+ ))}
1629
+ </>
1630
+ )}
1631
+
1563
1632
  {/* Documents */}
1564
1633
  {renderedDocuments.length > 0 && (
1565
1634
  <>
1566
- {(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo) && (
1635
+ {(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo || renderedMaterialProperties.length > 0) && (
1567
1636
  <div className="border-t border-zinc-200 dark:border-zinc-800 pt-2 mt-2" />
1568
1637
  )}
1569
1638
  {renderedDocuments.map((doc, i) => (
@@ -29,6 +29,7 @@ import { BCFPanel } from './BCFPanel';
29
29
  import { IDSPanel } from './IDSPanel';
30
30
  import { LensPanel } from './LensPanel';
31
31
  import { ClashPanel } from './ClashPanel';
32
+ import { ComparePanel } from './ComparePanel';
32
33
  import { ListPanel } from './lists/ListPanel';
33
34
  import { ScriptPanel } from './ScriptPanel';
34
35
  import { GanttPanel } from './schedule/GanttPanel';
@@ -135,6 +136,8 @@ export function ViewerLayout() {
135
136
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
136
137
  const clashPanelVisible = useViewerStore((s) => s.clashPanelVisible);
137
138
  const setClashPanelVisible = useViewerStore((s) => s.setClashPanelVisible);
139
+ const comparePanelVisible = useViewerStore((s) => s.comparePanelVisible);
140
+ const setComparePanelVisible = useViewerStore((s) => s.setComparePanelVisible);
138
141
  const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
139
142
  const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
140
143
  const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
@@ -347,6 +350,8 @@ export function ViewerLayout() {
347
350
  <LensPanel onClose={() => setLensPanelVisible(false)} />
348
351
  ) : clashPanelVisible ? (
349
352
  <ClashPanel onClose={() => setClashPanelVisible(false)} />
353
+ ) : comparePanelVisible ? (
354
+ <ComparePanel onClose={() => setComparePanelVisible(false)} />
350
355
  ) : idsPanelVisible ? (
351
356
  <IDSPanel onClose={() => setIdsPanelVisible(false)} />
352
357
  ) : bcfPanelVisible ? (
@@ -808,6 +808,9 @@ export function Viewport({
808
808
  }
809
809
  setIsInitialized(false);
810
810
  rendererRef.current = null;
811
+ // Free all WebGPU resources held by this renderer instance.
812
+ // destroy() is idempotent, so this is safe even if init() rejected.
813
+ renderer.destroy();
811
814
  // Clear BCF global refs to prevent memory leaks
812
815
  clearGlobalRefs();
813
816
  };
@@ -275,7 +275,7 @@ export function HierarchyNode({
275
275
  {/* Name */}
276
276
  <span className={cn(
277
277
  'flex-1 text-sm truncate ml-1.5',
278
- isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' || node.type === 'type-group'
278
+ isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' || node.type === 'type-group' || node.type === 'material-group'
279
279
  ? 'font-medium text-zinc-900 dark:text-zinc-100'
280
280
  : 'text-zinc-700 dark:text-zinc-300',
281
281
  nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
@@ -306,11 +306,11 @@ export function HierarchyNode({
306
306
  <Tooltip>
307
307
  <TooltipTrigger asChild>
308
308
  <span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-950 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-none">
309
- {node.elementCount}
309
+ {node.elementCount.toLocaleString()}
310
310
  </span>
311
311
  </TooltipTrigger>
312
312
  <TooltipContent>
313
- <p className="text-xs">{node.elementCount} {node.elementCount === 1 ? 'element' : 'elements'}</p>
313
+ <p className="text-xs">{node.elementCount.toLocaleString()} {node.elementCount === 1 ? 'element' : 'elements'}</p>
314
314
  </TooltipContent>
315
315
  </Tooltip>
316
316
  )}
@@ -140,6 +140,15 @@ export const IFC_ICON_CODEPOINTS: Record<string, string> = {
140
140
  IfcTrackElement: '\ue260', // "linear_scale"
141
141
  IfcVehicle: '\ue531', // "directions_car"
142
142
 
143
+ // Materials (Materials hierarchy tab)
144
+ IfcMaterial: '\ue4f4', // "texture"
145
+ IfcMaterialLayerSet: '\ue8fe', // "layers"
146
+ IfcMaterialLayerSetUsage: '\ue8fe',
147
+ IfcMaterialProfileSet: '\ue8fe',
148
+ IfcMaterialProfileSetUsage: '\ue8fe',
149
+ IfcMaterialConstituentSet: '\ue4f4',
150
+ IfcMaterialList: '\ue4f4',
151
+
143
152
  // Proxy / generic fallback
144
153
  IfcProduct: '\ue047',
145
154
  IfcBuildingElementProxy: '\ue047',
@@ -12,6 +12,7 @@ import {
12
12
  type SpatialNode,
13
13
  } from '@ifc-lite/data';
14
14
  import type { IfcDataStore } from '@ifc-lite/parser';
15
+ import { buildMaterialUsageIndex } from '@ifc-lite/parser';
15
16
  import { useViewerStore, type FederatedModel } from '@/store';
16
17
  import { toGlobalIdFromModels } from '@/store/globalId';
17
18
  import type { TreeNode, NodeType, StoreyData, UnifiedStorey } from './types';
@@ -712,6 +713,92 @@ export function buildIfcTypeTree(
712
713
  return nodes;
713
714
  }
714
715
 
716
+ /**
717
+ * Build a flat "By Material" tree: one row per base material (IfcMaterial),
718
+ * grouped by name so the same-named material across federated models merges.
719
+ * Each row carries the using elements' global ids for click-to-isolate and the
720
+ * representative material express id for the properties panel. Mirrors
721
+ * {@link buildIfcTypeTree} but keyed on the parser's material usage index.
722
+ */
723
+ export function buildMaterialTree(
724
+ models: Map<string, FederatedModel>,
725
+ ifcDataStore: IfcDataStore | null | undefined,
726
+ _expandedNodes: Set<string>,
727
+ _isMultiModel: boolean,
728
+ geometricIds?: Set<number>,
729
+ ): TreeNode[] {
730
+ interface MatEntry {
731
+ name: string;
732
+ ifcClass: string;
733
+ materialId: number; // representative material express id
734
+ modelIds: Set<string>; // contributing models (insertion order)
735
+ elements: Map<number, number>; // globalId -> expressId (deduped)
736
+ }
737
+
738
+ const byName = new Map<string, MatEntry>();
739
+ const applyGeomFilter = !!geometricIds && geometricIds.size > 0;
740
+
741
+ const processDataStore = (dataStore: IfcDataStore, modelId: string) => {
742
+ const usage = buildMaterialUsageIndex(dataStore);
743
+ for (const u of usage.values()) {
744
+ let entry = byName.get(u.name);
745
+ if (!entry) {
746
+ // Invariant: the representative `materialId` and the first entry in
747
+ // `modelIds` come from the SAME (first-contributing) model, so the click
748
+ // handler's `node.modelIds[0]` + `node.entityExpressId` always resolve a
749
+ // valid (model, material) pair. Sets preserve insertion order.
750
+ entry = {
751
+ name: u.name,
752
+ ifcClass: u.ifcClass,
753
+ materialId: u.id,
754
+ modelIds: new Set([modelId]),
755
+ elements: new Map(),
756
+ };
757
+ byName.set(u.name, entry);
758
+ } else {
759
+ entry.modelIds.add(modelId);
760
+ }
761
+ for (const { entityId } of u.entries) {
762
+ const globalId = resolveTreeGlobalId(modelId, entityId, models);
763
+ if (applyGeomFilter && !geometricIds!.has(globalId)) continue;
764
+ entry.elements.set(globalId, entityId);
765
+ }
766
+ }
767
+ };
768
+
769
+ if (models.size > 0) {
770
+ for (const [modelId, model] of models) {
771
+ if (model.ifcDataStore) processDataStore(model.ifcDataStore, modelId);
772
+ }
773
+ } else if (ifcDataStore) {
774
+ processDataStore(ifcDataStore, 'legacy');
775
+ }
776
+
777
+ const nodes: TreeNode[] = [];
778
+ const names = Array.from(byName.keys()).sort((a, b) => a.localeCompare(b));
779
+ for (const name of names) {
780
+ const entry = byName.get(name)!;
781
+ if (entry.elements.size === 0) continue; // skip materials with no visible elements (dead clicks)
782
+ nodes.push({
783
+ id: `material-${name}`,
784
+ expressIds: Array.from(entry.elements.values()),
785
+ globalIds: Array.from(entry.elements.keys()),
786
+ entityExpressId: entry.materialId,
787
+ modelIds: Array.from(entry.modelIds),
788
+ name,
789
+ type: 'material-group',
790
+ ifcType: entry.ifcClass,
791
+ depth: 0,
792
+ hasChildren: false,
793
+ isExpanded: false,
794
+ isVisible: true,
795
+ elementCount: entry.elements.size,
796
+ });
797
+ }
798
+
799
+ return nodes;
800
+ }
801
+
715
802
  /** Filter nodes based on search query */
716
803
  export function filterNodes(nodes: TreeNode[], searchQuery: string): TreeNode[] {
717
804
  if (!searchQuery.trim()) return nodes;
@@ -22,6 +22,7 @@ export type NodeType =
22
22
  | 'IfcSpace' // Space node
23
23
  | 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
24
24
  | 'ifc-type' // IFC type entity node (e.g., "IfcWallType/W01")
25
+ | 'material-group' // Material grouping (e.g., "Concrete (47)") from the Materials tab
25
26
  | 'element'; // Individual element
26
27
 
27
28
  export interface TreeNode {
@@ -13,11 +13,12 @@ import {
13
13
  buildTreeData,
14
14
  buildTypeTree,
15
15
  buildIfcTypeTree,
16
+ buildMaterialTree,
16
17
  filterNodes,
17
18
  splitNodes,
18
19
  } from './treeDataBuilder';
19
20
 
20
- export type GroupingMode = 'spatial' | 'type' | 'ifc-type';
21
+ export type GroupingMode = 'spatial' | 'type' | 'ifc-type' | 'material';
21
22
 
22
23
  interface UseHierarchyTreeParams {
23
24
  models: Map<string, FederatedModel>;
@@ -192,6 +193,9 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
192
193
  if (groupingMode === 'ifc-type') {
193
194
  return buildIfcTypeTree(models, ifcDataStore, expandedNodes, isMultiModel, geometricIds);
194
195
  }
196
+ if (groupingMode === 'material') {
197
+ return buildMaterialTree(models, ifcDataStore, expandedNodes, isMultiModel, geometricIds);
198
+ }
195
199
  return buildTreeData(models, ifcDataStore, expandedNodes, isMultiModel, unifiedStoreys);
196
200
  },
197
201
  [models, ifcDataStore, expandedNodes, isMultiModel, unifiedStoreys, groupingMode, geometricIds]
@@ -223,7 +227,7 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
223
227
 
224
228
  // Get all elements for a node (handles type groups, ifc-type, unified storeys, single storeys, model contributions, and elements)
225
229
  const getNodeElements = useCallback((node: TreeNode): number[] => {
226
- if (node.type === 'type-group' || node.type === 'ifc-type') {
230
+ if (node.type === 'type-group' || node.type === 'ifc-type' || node.type === 'material-group') {
227
231
  // GlobalIds are pre-stored on the node during tree construction — O(1)
228
232
  return node.globalIds;
229
233
  }
@@ -0,0 +1,283 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Material totals panel — shown when a material is selected from the
7
+ * "Materials" hierarchy tab. Surfaces the material's own property sets
8
+ * (IfcMaterialProperties) plus quantities aggregated across every element that
9
+ * uses the material. Volumes/areas are apportioned by each element's material
10
+ * share (layer thickness / constituent fraction), so a layered wall's volume is
11
+ * split between its concrete and insulation rather than double-counted.
12
+ */
13
+
14
+ import { useMemo } from 'react';
15
+ import { Layers, Calculator, Boxes, Info } from 'lucide-react';
16
+ import { ScrollArea } from '@/components/ui/scroll-area';
17
+ import { useIfc } from '@/hooks/useIfc';
18
+ import {
19
+ buildMaterialUsageIndex,
20
+ getMaterialDisplay,
21
+ extractMaterialPropertiesForMaterialId,
22
+ extractQuantitiesOnDemand,
23
+ type IfcDataStore,
24
+ } from '@ifc-lite/parser';
25
+ import { QuantityType } from '@ifc-lite/data';
26
+ import { PropertySetCard } from './PropertySetCard';
27
+ import type { PropertySet } from './encodingUtils';
28
+
29
+ interface MaterialTotals {
30
+ /** Number of elements using this material (across all loaded models). */
31
+ elementCount: number;
32
+ /** Elements that contributed at least one volume quantity. */
33
+ elementsWithVolume: number;
34
+ volume: number;
35
+ hasVolume: boolean;
36
+ area: number;
37
+ hasArea: boolean;
38
+ weight: number;
39
+ hasWeight: boolean;
40
+ /** Element count per IFC class, sorted desc. */
41
+ byClass: Array<{ ifcClass: string; count: number }>;
42
+ }
43
+
44
+ /** Pick a quantity value by candidate names (case-insensitive), else by type. */
45
+ function pickQuantity(
46
+ byName: Map<string, number>,
47
+ candidates: string[],
48
+ ): number | undefined {
49
+ for (const c of candidates) {
50
+ const v = byName.get(c);
51
+ if (v !== undefined) return v;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** Format an aggregated quantity with magnitude-appropriate precision. */
57
+ function formatNumber(value: number): string {
58
+ if (value === 0) return '0';
59
+ if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
60
+ if (Math.abs(value) >= 1) return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
61
+ return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
62
+ }
63
+
64
+ export function MaterialTotalsPanel({ materialId, modelId }: { materialId: number; modelId: string }) {
65
+ const { ifcDataStore, models } = useIfc();
66
+
67
+ // The store the selected material lives in, plus every loaded store (so the
68
+ // totals merge same-named materials across a federation).
69
+ const { selectedStore, allStores } = useMemo(() => {
70
+ const stores: IfcDataStore[] = [];
71
+ if (models.size > 0) {
72
+ for (const [, m] of models) {
73
+ if (m.ifcDataStore) stores.push(m.ifcDataStore as IfcDataStore);
74
+ }
75
+ } else if (ifcDataStore) {
76
+ stores.push(ifcDataStore as IfcDataStore);
77
+ }
78
+ const sel = modelId !== 'legacy'
79
+ ? (models.get(modelId)?.ifcDataStore as IfcDataStore | undefined) ?? (ifcDataStore as IfcDataStore | null) ?? undefined
80
+ : (ifcDataStore as IfcDataStore | null) ?? undefined;
81
+ return { selectedStore: sel, allStores: stores.length > 0 ? stores : (sel ? [sel] : []) };
82
+ }, [models, ifcDataStore, modelId]);
83
+
84
+ const display = useMemo(() => {
85
+ if (!selectedStore) return { name: `Material #${materialId}`, type: 'IfcMaterial' };
86
+ return getMaterialDisplay(selectedStore, materialId);
87
+ }, [selectedStore, materialId]);
88
+
89
+ // The material's own property sets (Pset_Material*).
90
+ const psetGroups = useMemo(() => {
91
+ if (!selectedStore) return [];
92
+ return extractMaterialPropertiesForMaterialId(selectedStore, materialId);
93
+ }, [selectedStore, materialId]);
94
+
95
+ // Aggregate quantities across all elements using a material of this name.
96
+ const totals = useMemo<MaterialTotals>(() => {
97
+ const result: MaterialTotals = {
98
+ elementCount: 0,
99
+ elementsWithVolume: 0,
100
+ volume: 0,
101
+ hasVolume: false,
102
+ area: 0,
103
+ hasArea: false,
104
+ weight: 0,
105
+ hasWeight: false,
106
+ byClass: [],
107
+ };
108
+ const classCounts = new Map<string, number>();
109
+ const targetName = display.name;
110
+
111
+ for (const store of allStores) {
112
+ const usageIndex = buildMaterialUsageIndex(store);
113
+ // Forward map of entity -> quantity-set ids (when on-demand parsing is
114
+ // active). Used to skip the per-element extractor allocation for elements
115
+ // that carry no quantities — the common case in large models, so a
116
+ // material used by thousands of elements only pays the parse cost for the
117
+ // subset that actually has Qto data.
118
+ const qMap = store.onDemandQuantityMap;
119
+ for (const usage of usageIndex.values()) {
120
+ if (usage.name !== targetName) continue;
121
+ for (const { entityId, weight } of usage.entries) {
122
+ result.elementCount += 1;
123
+
124
+ const ifcClass = store.entityIndex.byId.get(entityId)?.type || usage.ifcClass;
125
+ classCounts.set(ifcClass, (classCounts.get(ifcClass) ?? 0) + 1);
126
+
127
+ if (qMap && !qMap.get(entityId)?.length) continue; // no quantities — skip extraction
128
+ const qsets = extractQuantitiesOnDemand(store, entityId);
129
+ if (qsets.length === 0) continue;
130
+ const volByName = new Map<string, number>();
131
+ const areaByName = new Map<string, number>();
132
+ const weightByName = new Map<string, number>();
133
+ for (const qset of qsets) {
134
+ for (const q of qset.quantities) {
135
+ const key = q.name.toLowerCase();
136
+ if (q.type === QuantityType.Volume) volByName.set(key, q.value);
137
+ else if (q.type === QuantityType.Area) areaByName.set(key, q.value);
138
+ else if (q.type === QuantityType.Weight) weightByName.set(key, q.value);
139
+ }
140
+ }
141
+
142
+ const vol = pickQuantity(volByName, ['netvolume', 'grossvolume', 'volume'])
143
+ ?? (volByName.size > 0 ? [...volByName.values()][0] : undefined);
144
+ if (vol !== undefined) {
145
+ result.volume += vol * weight;
146
+ result.hasVolume = true;
147
+ result.elementsWithVolume += 1;
148
+ }
149
+
150
+ const area = pickQuantity(areaByName, ['netarea', 'grossarea', 'netsidearea', 'grosssidearea', 'netfloorarea', 'grossfloorarea', 'area']);
151
+ if (area !== undefined) {
152
+ result.area += area * weight;
153
+ result.hasArea = true;
154
+ }
155
+
156
+ const wt = pickQuantity(weightByName, ['netweight', 'grossweight', 'weight']);
157
+ if (wt !== undefined) {
158
+ result.weight += wt * weight;
159
+ result.hasWeight = true;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ result.byClass = [...classCounts.entries()]
166
+ .map(([ifcClass, count]) => ({ ifcClass, count }))
167
+ .sort((a, b) => b.count - a.count);
168
+ return result;
169
+ }, [allStores, display.name]);
170
+
171
+ const psetCount = psetGroups.reduce((sum, g) => sum + g.psets.length, 0);
172
+
173
+ return (
174
+ <div className="h-full flex flex-col border-l-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
175
+ {/* Header */}
176
+ <div className="p-4 border-b-2 border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20 space-y-2">
177
+ <div className="flex items-start gap-3">
178
+ <div className="p-2 border-2 border-amber-200 dark:border-amber-800 bg-white dark:bg-zinc-950 shrink-0">
179
+ <Layers className="h-5 w-5 text-amber-600 dark:text-amber-400" />
180
+ </div>
181
+ <div className="flex-1 min-w-0 pt-0.5">
182
+ <h3 className="font-bold text-sm truncate uppercase tracking-tight text-zinc-900 dark:text-zinc-100 min-w-0">
183
+ {display.name}
184
+ </h3>
185
+ <p className="text-xs font-mono text-amber-600/80 dark:text-amber-400/80">{display.type}</p>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <ScrollArea className="flex-1 bg-white dark:bg-black">
191
+ <div className="p-3 space-y-3 w-full overflow-hidden">
192
+ {/* Totals */}
193
+ <div className="border-2 border-amber-200 dark:border-amber-800 bg-amber-50/20 dark:bg-amber-950/20">
194
+ <div className="flex items-center gap-2 px-2.5 py-2 border-b-2 border-amber-200 dark:border-amber-800">
195
+ <Calculator className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
196
+ <span className="font-bold text-xs text-amber-700 dark:text-amber-400 uppercase tracking-wide">Totals</span>
197
+ </div>
198
+ <div className="divide-y divide-amber-100 dark:divide-amber-900/30">
199
+ <TotalRow label="Elements" value={totals.elementCount.toLocaleString()} />
200
+ {totals.hasVolume && (
201
+ <TotalRow label="Volume" value={`${formatNumber(totals.volume)} m³`} />
202
+ )}
203
+ {totals.hasArea && (
204
+ <TotalRow label="Area" value={`${formatNumber(totals.area)} m²`} />
205
+ )}
206
+ {totals.hasWeight && (
207
+ <TotalRow label="Weight" value={`${formatNumber(totals.weight)} kg`} />
208
+ )}
209
+ </div>
210
+ {totals.elementCount > 0 && !totals.hasVolume && (
211
+ <div className="flex items-start gap-1.5 px-2.5 py-2 text-[10px] text-zinc-500 dark:text-zinc-400 border-t border-amber-100 dark:border-amber-900/30">
212
+ <Info className="h-3 w-3 shrink-0 mt-px" />
213
+ <span>No volume quantities (Qto_*) found on these elements.</span>
214
+ </div>
215
+ )}
216
+ {totals.hasVolume && totals.elementsWithVolume < totals.elementCount && (
217
+ <div className="flex items-start gap-1.5 px-2.5 py-2 text-[10px] text-zinc-500 dark:text-zinc-400 border-t border-amber-100 dark:border-amber-900/30">
218
+ <Info className="h-3 w-3 shrink-0 mt-px" />
219
+ <span>
220
+ Volume from {totals.elementsWithVolume.toLocaleString()} of {totals.elementCount.toLocaleString()} elements with reported quantities;
221
+ multi-material elements are split by layer thickness / constituent fraction.
222
+ </span>
223
+ </div>
224
+ )}
225
+ </div>
226
+
227
+ {/* Breakdown by class */}
228
+ {totals.byClass.length > 0 && (
229
+ <div className="border border-zinc-200 dark:border-zinc-800">
230
+ <div className="flex items-center gap-2 px-2.5 py-2 border-b border-zinc-200 dark:border-zinc-800">
231
+ <Boxes className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
232
+ <span className="font-bold text-xs text-zinc-600 dark:text-zinc-300 uppercase tracking-wide">By Class</span>
233
+ </div>
234
+ <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
235
+ {totals.byClass.map((c) => (
236
+ <div key={c.ifcClass} className="flex items-center justify-between px-2.5 py-1.5 text-xs">
237
+ <span className="font-mono text-zinc-600 dark:text-zinc-400 truncate">{c.ifcClass}</span>
238
+ <span className="font-mono text-zinc-900 dark:text-zinc-100">{c.count.toLocaleString()}</span>
239
+ </div>
240
+ ))}
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ {/* Material property sets */}
246
+ {psetCount > 0 && (
247
+ <div className="space-y-3">
248
+ <div className="flex items-center gap-2 px-1 pt-1 pb-0.5 text-[11px] text-amber-600/70 dark:text-amber-400/60 uppercase tracking-wider font-semibold">
249
+ <Layers className="h-3 w-3 shrink-0" />
250
+ <span className="truncate">Material Properties</span>
251
+ </div>
252
+ {psetGroups.map((group) =>
253
+ group.psets.map((pset) => {
254
+ const psetView: PropertySet = {
255
+ name: pset.name,
256
+ properties: pset.properties.map((p) => ({ name: p.name, value: p.value, isMutated: false })),
257
+ };
258
+ return <PropertySetCard key={`${group.materialId}-${pset.name}`} pset={psetView} />;
259
+ }),
260
+ )}
261
+ </div>
262
+ )}
263
+
264
+ {psetCount === 0 && totals.elementCount === 0 && (
265
+ <p className="text-sm text-zinc-500 dark:text-zinc-500 text-center py-8 font-mono">
266
+ No data for this material
267
+ </p>
268
+ )}
269
+ </div>
270
+ </ScrollArea>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ /** A single label/value row in the material totals card. */
276
+ function TotalRow({ label, value }: { label: string; value: string }) {
277
+ return (
278
+ <div className="flex items-center justify-between px-2.5 py-2 text-xs">
279
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">{label}</span>
280
+ <span className="font-mono font-semibold text-amber-700 dark:text-amber-300 tabular-nums">{value}</span>
281
+ </div>
282
+ );
283
+ }
@@ -59,11 +59,21 @@ describe('federationLoadGate', () => {
59
59
  const bPromise = acquireFederationLoadSlot(50).then((id) => { order.push('b'); return id; });
60
60
 
61
61
  await new Promise((r) => setTimeout(r, 10));
62
+ // Releasing the blocker frees the budget for the head of the FIFO queue.
63
+ // A 2048 MB load costs more than the whole budget, so the first-queued load
64
+ // is admitted alone (single-file exception) and the 50 MB load stays queued
65
+ // until it releases. Awaiting them together would deadlock — and asserting
66
+ // that B does NOT wake alongside A is exactly what proves the gate respects
67
+ // the budget during the drain (the regression this guards against).
62
68
  releaseFederationLoadSlot(blocker);
63
69
 
64
- const [a, b] = await Promise.all([aPromise, bPromise]);
65
- assert.strictEqual(order[0], 'a');
70
+ const a = await aPromise;
71
+ assert.deepStrictEqual(order, ['a']);
72
+ assert.strictEqual(__getFederationLoadGateStats().queuedCount, 1);
73
+
66
74
  releaseFederationLoadSlot(a);
75
+ const b = await bPromise;
76
+ assert.deepStrictEqual(order, ['a', 'b']);
67
77
  releaseFederationLoadSlot(b);
68
78
  });
69
79