@ifc-lite/viewer 1.27.0 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.turbo/turbo-build.log +35 -42
  2. package/CHANGELOG.md +74 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.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-HbyMq59o.js} +1 -1
  7. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
  10. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  14. package/dist/assets/index-E9wB0zWt.css +1 -0
  15. package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
  24. package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
  26. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
  28. package/dist/assets/server-client-DVZ2huNS.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/App.tsx +1 -3
  35. package/src/components/mcp/playground-dispatcher.ts +3 -0
  36. package/src/components/mcp/playground-files.ts +33 -1
  37. package/src/components/viewer/BCFPanel.tsx +1 -16
  38. package/src/components/viewer/ChatPanel.tsx +11 -46
  39. package/src/components/viewer/CommandPalette.tsx +6 -1
  40. package/src/components/viewer/ComparePanel.tsx +420 -0
  41. package/src/components/viewer/HierarchyPanel.tsx +48 -183
  42. package/src/components/viewer/IDSPanel.tsx +1 -26
  43. package/src/components/viewer/MainToolbar.tsx +94 -187
  44. package/src/components/viewer/MobileToolbar.tsx +1 -9
  45. package/src/components/viewer/PropertiesPanel.tsx +98 -127
  46. package/src/components/viewer/ScriptPanel.tsx +8 -34
  47. package/src/components/viewer/Section2DPanel.tsx +32 -1
  48. package/src/components/viewer/ViewerLayout.tsx +5 -2
  49. package/src/components/viewer/Viewport.tsx +3 -0
  50. package/src/components/viewer/ViewportContainer.tsx +24 -42
  51. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  52. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  53. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  54. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  55. package/src/components/viewer/hierarchy/types.ts +1 -0
  56. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  57. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  58. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  59. package/src/hooks/federationLoadGate.test.ts +12 -2
  60. package/src/hooks/federationLoadGate.ts +9 -2
  61. package/src/hooks/ingest/federationAlign.ts +488 -0
  62. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  63. package/src/hooks/useCompare.ts +0 -0
  64. package/src/hooks/useCompareOverlay.ts +119 -0
  65. package/src/hooks/useDrawingGeneration.ts +234 -14
  66. package/src/hooks/useIfc.ts +1 -1
  67. package/src/hooks/useIfcCache.ts +100 -24
  68. package/src/hooks/useIfcFederation.ts +42 -811
  69. package/src/hooks/useIfcLoader.ts +349 -1517
  70. package/src/hooks/useIfcServer.ts +3 -0
  71. package/src/hooks/useLens.ts +5 -1
  72. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  73. package/src/lib/compare/buildFingerprints.ts +173 -0
  74. package/src/lib/compare/describeChange.ts +0 -0
  75. package/src/lib/compare/geometricData.test.ts +54 -0
  76. package/src/lib/compare/geometricData.ts +37 -0
  77. package/src/lib/compare/overlay.test.ts +99 -0
  78. package/src/lib/compare/overlay.ts +91 -0
  79. package/src/lib/geo/cesium-placement.ts +1 -1
  80. package/src/lib/geo/reproject.ts +4 -1
  81. package/src/lib/llm/script-edit-ops.ts +23 -0
  82. package/src/lib/llm/stream-client.ts +8 -1
  83. package/src/lib/search/result-export.ts +7 -1
  84. package/src/sdk/adapters/export-adapter.ts +6 -1
  85. package/src/services/cacheService.ts +9 -25
  86. package/src/services/desktop-export.ts +2 -59
  87. package/src/services/file-dialog.ts +8 -142
  88. package/src/store/constants.ts +23 -0
  89. package/src/store/globalId.ts +15 -13
  90. package/src/store/index.ts +19 -6
  91. package/src/store/slices/cesiumSlice.ts +8 -1
  92. package/src/store/slices/compareSlice.ts +96 -0
  93. package/src/store/slices/drawing2DSlice.ts +8 -0
  94. package/src/store/slices/lensSlice.ts +8 -0
  95. package/src/store/slices/visibilitySlice.ts +22 -1
  96. package/src/store/types.ts +1 -71
  97. package/src/utils/acquireFileBuffer.test.ts +12 -4
  98. package/src/utils/ifcConfig.ts +0 -12
  99. package/src/utils/loadingUtils.ts +32 -0
  100. package/src/utils/spatialHierarchy.test.ts +53 -1
  101. package/src/utils/spatialHierarchy.ts +42 -2
  102. package/src/vite-env.d.ts +2 -0
  103. package/vite.config.ts +6 -3
  104. package/DESKTOP_CONTRACT_VERSION +0 -1
  105. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  106. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  107. package/dist/assets/event-B0kAzHa-.js +0 -1
  108. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  109. package/dist/assets/index-ajK6D32J.css +0 -1
  110. package/dist/assets/lens-PYsLu_MA.js +0 -1
  111. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  112. package/dist/assets/raw-D9iw0tmc.js +0 -1
  113. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  114. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  115. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  116. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  117. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  118. package/src/components/viewer/SettingsPage.tsx +0 -581
  119. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  120. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  121. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  122. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
  123. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  124. package/src/lib/desktop-entitlement.ts +0 -43
  125. package/src/lib/desktop-product.ts +0 -130
  126. package/src/lib/platform.ts +0 -23
  127. package/src/services/desktop-cache.ts +0 -186
  128. package/src/services/desktop-harness.ts +0 -196
  129. package/src/services/desktop-logger.ts +0 -20
  130. package/src/services/desktop-native-metadata.ts +0 -230
  131. package/src/services/desktop-panel-actions.ts +0 -43
  132. package/src/services/desktop-preferences.ts +0 -44
  133. package/src/services/fs-cache.ts +0 -212
  134. package/src/services/tauri-core-stub.ts +0 -7
  135. package/src/services/tauri-dialog-stub.ts +0 -7
  136. package/src/services/tauri-fs-stub.ts +0 -7
  137. package/src/services/tauri-modules.d.ts +0 -50
  138. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  139. package/src/utils/desktopModelSnapshot.ts +0 -358
  140. package/src/utils/nativeSpatialDataStore.ts +0 -277
  141. package/src-tauri/Cargo.toml +0 -29
  142. package/src-tauri/build.rs +0 -7
  143. package/src-tauri/capabilities/default.json +0 -18
  144. package/src-tauri/icons/128x128.png +0 -0
  145. package/src-tauri/icons/128x128@2x.png +0 -0
  146. package/src-tauri/icons/32x32.png +0 -0
  147. package/src-tauri/icons/Square107x107Logo.png +0 -0
  148. package/src-tauri/icons/Square142x142Logo.png +0 -0
  149. package/src-tauri/icons/Square150x150Logo.png +0 -0
  150. package/src-tauri/icons/Square284x284Logo.png +0 -0
  151. package/src-tauri/icons/Square30x30Logo.png +0 -0
  152. package/src-tauri/icons/Square310x310Logo.png +0 -0
  153. package/src-tauri/icons/Square44x44Logo.png +0 -0
  154. package/src-tauri/icons/Square71x71Logo.png +0 -0
  155. package/src-tauri/icons/Square89x89Logo.png +0 -0
  156. package/src-tauri/icons/StoreLogo.png +0 -0
  157. package/src-tauri/icons/icon.icns +0 -0
  158. package/src-tauri/icons/icon.ico +0 -0
  159. package/src-tauri/icons/icon.png +0 -0
  160. package/src-tauri/src/lib.rs +0 -21
  161. package/src-tauri/src/main.rs +0 -10
  162. package/src-tauri/tauri.conf.json +0 -39
@@ -19,10 +19,7 @@ import { toGlobalIdFromModels } from '@/store/globalId';
19
19
  import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet';
20
20
  import { useIfc } from '@/hooks/useIfc';
21
21
  import { useWebGPU } from '@/hooks/useWebGPU';
22
- import { openIfcFileDialog } from '@/services/file-dialog';
23
- import { logToDesktopTerminal } from '@/services/desktop-logger';
24
22
  import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
25
- import { isTauri } from '@/lib/platform';
26
23
  import { toast } from '@/components/ui/toast';
27
24
  import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
28
25
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight, PackagePlus } from 'lucide-react';
@@ -69,6 +66,7 @@ export function ViewportContainer() {
69
66
  const releaseGeometryMemory = useViewerStore((s) => s.releaseGeometryMemory);
70
67
  const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
71
68
  const typeVisibility = useViewerStore((s) => s.typeVisibility);
69
+ const typeViewMode = useViewerStore((s) => s.typeViewMode);
72
70
  const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
73
71
  const classFilter = useViewerStore((s) => s.classFilter);
74
72
  const resetViewerState = useViewerStore((s) => s.resetViewerState);
@@ -392,20 +390,6 @@ export function ViewportContainer() {
392
390
  setCesiumSourceModelId(georef?.sourceModelId ?? null);
393
391
  }, [georef?.sourceModelId, setCesiumSourceModelId]);
394
392
 
395
- useEffect(() => {
396
- // Recent files are a desktop-only feature — the web viewer should not
397
- // show previously opened files in the landing page empty state.
398
- if (!isTauri()) return;
399
-
400
- const refreshRecentFiles = () => {
401
- setRecentFiles(getRecentFiles().slice(0, 3));
402
- };
403
-
404
- refreshRecentFiles();
405
- window.addEventListener('focus', refreshRecentFiles);
406
- return () => window.removeEventListener('focus', refreshRecentFiles);
407
- }, []);
408
-
409
393
  const handleDragOver = useCallback((e: React.DragEvent) => {
410
394
  e.preventDefault();
411
395
  e.stopPropagation();
@@ -504,7 +488,6 @@ export function ViewportContainer() {
504
488
 
505
489
  const handleStartBlank = useCallback(async () => {
506
490
  if (!webgpu.supported) return;
507
- void logToDesktopTerminal('info', '[ViewportContainer] Start blank IFC clicked');
508
491
  const file = createBlankIfcFile();
509
492
  // Must await: loadFile() calls resetViewerState() internally which
510
493
  // resets activeTool back to 'select'. Setting addElement before that
@@ -526,6 +509,7 @@ export function ViewportContainer() {
526
509
  const filteredSourceLenRef = useRef(0);
527
510
  const filteredSourceRef = useRef<MeshData[] | null>(null);
528
511
  const filteredTypeVisRef = useRef(typeVisibility);
512
+ const filteredTypeModeRef = useRef(typeViewMode);
529
513
  const filteredVersionRef = useRef(0);
530
514
 
531
515
  const filteredGeometry = useMemo(() => {
@@ -540,18 +524,21 @@ export function ViewportContainer() {
540
524
  const allMeshes = mergedGeometryResult.meshes;
541
525
  const cache = filteredCacheRef.current;
542
526
 
543
- // Full rebuild if: type visibility changed, source shrunk (new file), or empty cache
527
+ // Full rebuild if: type visibility changed, view mode changed, source shrunk
528
+ // (new file), or empty cache
544
529
  const prevVis = filteredTypeVisRef.current;
545
530
  const typeVisChanged =
546
531
  prevVis.spaces !== typeVisibility.spaces ||
547
532
  prevVis.openings !== typeVisibility.openings ||
548
- prevVis.site !== typeVisibility.site;
533
+ prevVis.site !== typeVisibility.site ||
534
+ filteredTypeModeRef.current !== typeViewMode;
549
535
  const sourceChanged = filteredSourceRef.current !== allMeshes;
550
536
  if (typeVisChanged || sourceChanged || allMeshes.length < filteredSourceLenRef.current) {
551
537
  cache.length = 0;
552
538
  filteredSourceLenRef.current = 0;
553
539
  filteredSourceRef.current = allMeshes;
554
540
  filteredTypeVisRef.current = typeVisibility;
541
+ filteredTypeModeRef.current = typeViewMode;
555
542
  }
556
543
 
557
544
  const needsFilter = !typeVisibility.spaces || !typeVisibility.openings || !typeVisibility.site;
@@ -562,6 +549,18 @@ export function ViewportContainer() {
562
549
  const mesh = allMeshes[i];
563
550
  const ifcType = mesh.ifcType;
564
551
 
552
+ // Model/Types view switch (#957 follow-up). geometryClass: 0 = occurrence,
553
+ // 1 = orphan type (no occurrence — shown in BOTH modes since it's the only
554
+ // geometry), 2 = instanced type-library shape. In 'model' mode hide class 2
555
+ // (else the AC20 duplicate boxes at the MappingOrigin reappear); in 'types'
556
+ // mode hide occurrences (class 0) so only the type library shows.
557
+ const geometryClass = mesh.geometryClass ?? 0;
558
+ if (typeViewMode === 'types') {
559
+ if (geometryClass === 0) continue;
560
+ } else if (geometryClass === 2) {
561
+ continue;
562
+ }
563
+
565
564
  if (needsFilter) {
566
565
  if (ifcType === 'IfcSpace' && !typeVisibility.spaces) continue;
567
566
  if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) continue;
@@ -588,7 +587,7 @@ export function ViewportContainer() {
588
587
  // Return the same array reference — downstream change detection uses
589
588
  // geometryVersion (which increments each batch) instead of array identity.
590
589
  return cache;
591
- }, [mergedGeometryResult, typeVisibility]);
590
+ }, [mergedGeometryResult, typeVisibility, typeViewMode]);
592
591
 
593
592
  // Version counter that changes every batch — triggers useGeometryStreaming
594
593
  // without requiring a new geometry array reference.
@@ -882,27 +881,10 @@ export function ViewportContainer() {
882
881
  */}
883
882
  {/* Track 1 — open / drag */}
884
883
  <button
885
- onClick={async () => {
884
+ onClick={() => {
886
885
  if (!webgpu.supported) {
887
886
  return;
888
887
  }
889
-
890
- void logToDesktopTerminal('info', '[ViewportContainer] Empty-state open button clicked');
891
- const file = await openIfcFileDialog();
892
- if (file) {
893
- void logToDesktopTerminal('info', `[ViewportContainer] Native dialog selected ${file.path}`);
894
- recordRecentFiles([{
895
- name: file.name,
896
- size: file.size,
897
- path: file.path,
898
- modifiedMs: file.modifiedMs ?? null,
899
- }]);
900
- setRecentFiles(getRecentFiles().slice(0, 3));
901
- loadFile(file);
902
- return;
903
- }
904
-
905
- void logToDesktopTerminal('info', '[ViewportContainer] Falling back to browser file input');
906
888
  fileInputRef.current?.click();
907
889
  }}
908
890
  disabled={!webgpu.supported || webgpu.checking}
@@ -1064,7 +1046,7 @@ export function ViewportContainer() {
1064
1046
  )}
1065
1047
 
1066
1048
  {/* Cesium 3D world context overlay — rendered behind the WebGPU canvas (web only) */}
1067
- {cesiumEnabled && georef && !isTauri() && (
1049
+ {cesiumEnabled && georef && (
1068
1050
  <CesiumOverlay
1069
1051
  mapConversion={georef.mapConversion}
1070
1052
  cameraMapConversion={georef.baseMapConversion}
@@ -1075,7 +1057,7 @@ export function ViewportContainer() {
1075
1057
  storeyElevations={georef.storeyElevations}
1076
1058
  />
1077
1059
  )}
1078
- {cesiumEnabled && georef?.mapConversion && !isTauri() && georef.baseMapConversion && (
1060
+ {cesiumEnabled && georef?.mapConversion && georef.baseMapConversion && (
1079
1061
  <CesiumPlacementEditor
1080
1062
  modelId={georef.sourceModelId}
1081
1063
  mapConversion={georef.mapConversion}
@@ -1094,7 +1076,7 @@ export function ViewportContainer() {
1094
1076
  coordinateInfo={mergedGeometryResult?.coordinateInfo}
1095
1077
  computedIsolatedIds={computedIsolatedIds}
1096
1078
  modelIdToIndex={modelIdToIndex}
1097
- cesiumActive={cesiumEnabled && georef !== null && !isTauri()}
1079
+ cesiumActive={cesiumEnabled && georef !== null}
1098
1080
  releaseGeometryAfterStream={false}
1099
1081
  onGeometryReleased={releaseGeometryMemory}
1100
1082
  />
@@ -15,15 +15,12 @@ import { useViewerStore } from '@/store';
15
15
  import { goHomeFromStore } from '@/store/homeView';
16
16
  import { useIfc } from '@/hooks/useIfc';
17
17
  import { cn } from '@/lib/utils';
18
- import { isTauri } from '@/lib/platform';
19
18
  import { ViewCube, type ViewCubeRef } from './ViewCube';
20
19
  import { AxisHelper, type AxisHelperRef } from './AxisHelper';
21
20
  import { BasepointOverlay } from './BasepointOverlay';
22
21
  import { PointCloudPanel } from './PointCloudPanel';
23
22
  import { Crosshair } from 'lucide-react';
24
23
 
25
- const isDesktop = isTauri();
26
-
27
24
  export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: boolean } = {}) {
28
25
  const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
29
26
  const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
@@ -144,7 +141,7 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
144
141
  <>
145
142
  <PointCloudPanelMount />
146
143
  {/* Bottom-right: Navigation controls (hidden when Cesium active — Cesium is web-only) */}
147
- {!(cesiumEnabled && !isDesktop) && (
144
+ {!cesiumEnabled && (
148
145
  <div
149
146
  className={cn(
150
147
  'absolute flex flex-col gap-1 bg-background/90 backdrop-blur-sm border p-1',
@@ -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
+ }
@@ -21,7 +21,6 @@
21
21
  import { useEffect, useRef, type MutableRefObject } from 'react';
22
22
  import type { Renderer } from '@ifc-lite/renderer';
23
23
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
24
- import { logToDesktopTerminal } from '@/services/desktop-logger';
25
24
  import { toast } from '../ui/toast.js';
26
25
 
27
26
  // Session-scoped flag so the linear-infrastructure hint fires at most once
@@ -87,7 +86,6 @@ const MAX_VALID_COORD = 10000;
87
86
 
88
87
  function traceGeometrySync(message: string): void {
89
88
  console.log(`[GeomSync] ${message}`);
90
- void logToDesktopTerminal('info', `[GeomSync] ${message}`);
91
89
  }
92
90
 
93
91
  export function useGeometryStreaming(params: UseGeometryStreamingParams): void {