@ifc-lite/viewer 1.19.0 → 1.21.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 (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -24,10 +24,12 @@ import {
24
24
  ArrowRight,
25
25
  Box,
26
26
  HelpCircle,
27
+ Sparkles,
27
28
  Loader2,
28
29
  Camera,
29
30
  Info,
30
31
  Layers,
32
+ Layers2,
31
33
  SquareX,
32
34
  Building2,
33
35
  Plus,
@@ -50,6 +52,7 @@ import {
50
52
  DropdownMenuContent,
51
53
  DropdownMenuItem,
52
54
  DropdownMenuCheckboxItem,
55
+ DropdownMenuLabel,
53
56
  DropdownMenuSeparator,
54
57
  DropdownMenuTrigger,
55
58
  DropdownMenuSub,
@@ -306,6 +309,12 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
306
309
  const toggleHoverTooltips = useViewerStore((state) => state.toggleHoverTooltips);
307
310
  const typeVisibility = useViewerStore((state) => state.typeVisibility);
308
311
  const toggleTypeVisibility = useViewerStore((state) => state.toggleTypeVisibility);
312
+ // Issue #540: load-time toggle that asks the WASM bridge to merge
313
+ // Revit-style multilayer walls. We surface this in the Class
314
+ // Visibility dropdown so users discover it next to the other
315
+ // "what shows in the scene" controls.
316
+ const mergeLayers = useViewerStore((state) => state.mergeLayers);
317
+ const setMergeLayers = useViewerStore((state) => state.setMergeLayers);
309
318
  const resetViewerState = useViewerStore((state) => state.resetViewerState);
310
319
  const bcfPanelVisible = useViewerStore((state) => state.bcfPanelVisible);
311
320
  const setBcfPanelVisible = useViewerStore((state) => state.setBcfPanelVisible);
@@ -425,7 +434,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
425
434
  // Filter to supported files (IFC, IFCX, GLB)
426
435
  const supportedFiles = Array.from(files).filter(
427
436
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
428
- || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
437
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz')
429
438
  );
430
439
 
431
440
  if (supportedFiles.length === 0) return;
@@ -466,7 +475,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
466
475
  // Filter to supported files (IFC, IFCX, GLB)
467
476
  const supportedFiles = Array.from(files).filter(
468
477
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
469
- || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
478
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz')
470
479
  );
471
480
 
472
481
  if (supportedFiles.length === 0) return;
@@ -781,7 +790,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
781
790
  id="file-input-open"
782
791
  ref={fileInputRef}
783
792
  type="file"
784
- accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
793
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57,.pts,.xyz"
785
794
  multiple
786
795
  onChange={handleFileSelect}
787
796
  className="hidden"
@@ -789,7 +798,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
789
798
  <input
790
799
  ref={addModelInputRef}
791
800
  type="file"
792
- accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
801
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57,.pts,.xyz"
793
802
  multiple
794
803
  onChange={handleAddModelSelect}
795
804
  className="hidden"
@@ -1156,14 +1165,35 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1156
1165
  <Tooltip>
1157
1166
  <TooltipTrigger asChild>
1158
1167
  <DropdownMenuTrigger asChild>
1159
- <Button variant="ghost" size="icon-sm" disabled={!geometryResult && models.size === 0}>
1168
+ <Button
1169
+ variant="ghost"
1170
+ size="icon-sm"
1171
+ // Stay enabled even with no model loaded — the dropdown
1172
+ // also exposes load-time settings (Merge Multilayer
1173
+ // Walls) that the user should be able to set BEFORE
1174
+ // opening a file. Runtime items inside self-gate via
1175
+ // typeGeometryExists.
1176
+ aria-label={mergeLayers ? 'Class Visibility (Merge Multilayer Walls is on)' : 'Class Visibility'}
1177
+ className="relative"
1178
+ >
1160
1179
  <Layers className="h-4 w-4" />
1180
+ {mergeLayers && (
1181
+ // Tiny accent dot announcing that a non-default load
1182
+ // setting is active. Decorative — semantics live on
1183
+ // the button's aria-label and the tooltip.
1184
+ <span
1185
+ aria-hidden="true"
1186
+ className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-primary ring-1 ring-background"
1187
+ />
1188
+ )}
1161
1189
  </Button>
1162
1190
  </DropdownMenuTrigger>
1163
1191
  </TooltipTrigger>
1164
- <TooltipContent>Class Visibility</TooltipContent>
1192
+ <TooltipContent>
1193
+ {mergeLayers ? 'Class Visibility · Merge Multilayer Walls is on' : 'Class Visibility'}
1194
+ </TooltipContent>
1165
1195
  </Tooltip>
1166
- <DropdownMenuContent>
1196
+ <DropdownMenuContent className="w-72">
1167
1197
  {typeGeometryExists.spaces && (
1168
1198
  <DropdownMenuCheckboxItem
1169
1199
  checked={typeVisibility.spaces}
@@ -1191,6 +1221,30 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1191
1221
  Show Site
1192
1222
  </DropdownMenuCheckboxItem>
1193
1223
  )}
1224
+
1225
+ {/* Load-time toggles live below the runtime visibility
1226
+ switches — they apply on next model open rather than
1227
+ affecting the current scene. The subheader makes that
1228
+ boundary visible at a glance. */}
1229
+ <DropdownMenuSeparator />
1230
+ <DropdownMenuLabel className="px-2 pt-1 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
1231
+ Load Settings
1232
+ </DropdownMenuLabel>
1233
+ <DropdownMenuCheckboxItem
1234
+ checked={mergeLayers}
1235
+ onCheckedChange={(next) => setMergeLayers(next === true)}
1236
+ // Use items-start so the checkmark and icon line up with
1237
+ // the primary label while the description wraps below.
1238
+ className="items-start gap-2 py-2"
1239
+ >
1240
+ <Layers2 className="h-4 w-4 mr-2 mt-0.5 shrink-0 text-primary" />
1241
+ <div className="flex flex-col gap-0.5 min-w-0">
1242
+ <span className="text-sm font-medium leading-tight">Merge Multilayer Walls</span>
1243
+ <span className="text-[11px] leading-tight text-muted-foreground">
1244
+ Render walls as 1 solid · Applies on reload
1245
+ </span>
1246
+ </div>
1247
+ </DropdownMenuCheckboxItem>
1194
1248
  </DropdownMenuContent>
1195
1249
  </DropdownMenu>
1196
1250
 
@@ -1333,6 +1387,24 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1333
1387
 
1334
1388
  {/* Right Side Actions */}
1335
1389
  <div className="flex items-center gap-2 ml-2 pl-2 border-l border-zinc-200 dark:border-zinc-700/60">
1390
+ {/* /mcp cross-link — lives in the meta cluster (Settings / Theme /
1391
+ Help) so it shares space with shell-level navigation rather
1392
+ than competing with the modeling tools to its left. */}
1393
+ <Tooltip>
1394
+ <TooltipTrigger asChild>
1395
+ <Button
1396
+ variant="ghost"
1397
+ size="icon"
1398
+ className="rounded-full"
1399
+ onClick={() => navigateToPath('/mcp')}
1400
+ aria-label="Open ifc-lite MCP"
1401
+ >
1402
+ <Sparkles className="!h-[20px] !w-[20px]" />
1403
+ </Button>
1404
+ </TooltipTrigger>
1405
+ <TooltipContent>Drive ifc-lite from any LLM (MCP)</TooltipContent>
1406
+ </Tooltip>
1407
+
1336
1408
  {desktopShell ? (
1337
1409
  <Tooltip>
1338
1410
  <TooltipTrigger asChild>
@@ -0,0 +1,108 @@
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
+ * Reload-to-apply banner for the "Merge Multilayer Walls" load-time
7
+ * toggle (issue #540). The user flips the toggle in the Class
8
+ * Visibility dropdown; when a model is already loaded, the UI sets
9
+ * `mergeLayersPendingReload` and we surface this non-modal banner
10
+ * above the canvas asking the user to reload.
11
+ *
12
+ * Design note: this codebase has no "reload current model" function
13
+ * — `useIfcLoader.loadFile` is one-shot and does not retain the
14
+ * source File / NativeFileHandle. The pragmatic approach here is to
15
+ * call `window.location.reload()` for the Reload button, which is
16
+ * exactly what the wording promises ("Reload model to apply") and
17
+ * works on both web and the Tauri shell (which keeps its window).
18
+ * If a true in-place reload lands later, swap the handler — the
19
+ * banner contract stays the same.
20
+ */
21
+ import { useCallback } from 'react';
22
+ import { Layers2, RefreshCw, X } from 'lucide-react';
23
+ import { useViewerStore } from '@/store';
24
+ import { Button } from '@/components/ui/button';
25
+ import { cn } from '@/lib/utils';
26
+
27
+ export interface MergeLayersBannerProps {
28
+ /**
29
+ * When set, this overrides the default `window.location.reload()`
30
+ * fallback. Once a true "reload current model in place" path lands,
31
+ * the caller can pass it in here without changing the banner's
32
+ * visual contract.
33
+ */
34
+ onReload?: () => void;
35
+ }
36
+
37
+ export function MergeLayersBanner({ onReload }: MergeLayersBannerProps) {
38
+ const pending = useViewerStore((s) => s.mergeLayersPendingReload);
39
+ const merging = useViewerStore((s) => s.mergeLayers);
40
+ const dismiss = useViewerStore((s) => s.clearMergeLayersPendingReload);
41
+
42
+ const handleReload = useCallback(() => {
43
+ if (onReload) {
44
+ onReload();
45
+ return;
46
+ }
47
+ // Full-page reload is the only path we can guarantee works: the
48
+ // viewer doesn't retain the source File/handle once loading
49
+ // completes, so we can't re-run loadFile with the original input.
50
+ // The toggle is already persisted in localStorage so it will pick
51
+ // up the new value on the next boot.
52
+ if (typeof window !== 'undefined') {
53
+ window.location.reload();
54
+ }
55
+ }, [onReload]);
56
+
57
+ if (!pending) return null;
58
+
59
+ return (
60
+ // Centred non-modal overlay anchored to the top of the canvas.
61
+ // pointer-events-none on the wrapper lets clicks pass through
62
+ // unless they land on the inner card, so the underlying 3D
63
+ // viewport stays interactive.
64
+ <div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-40 max-w-[min(640px,calc(100%-1.5rem))] w-fit">
65
+ <div
66
+ role="status"
67
+ aria-live="polite"
68
+ className={cn(
69
+ 'pointer-events-auto flex items-center gap-3 border border-primary/40 bg-background/95 backdrop-blur',
70
+ 'px-3 py-2 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)] rounded-md',
71
+ 'animate-in slide-in-from-top-2 fade-in-0 duration-200',
72
+ )}
73
+ >
74
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
75
+ <Layers2 className="h-4 w-4" />
76
+ </div>
77
+ <div className="flex flex-col leading-tight min-w-0">
78
+ <span className="text-xs font-semibold text-foreground">
79
+ Merge Multilayer Walls {merging ? 'enabled' : 'disabled'}
80
+ </span>
81
+ <span className="text-[11px] text-muted-foreground truncate">
82
+ Reload model to apply the new setting.
83
+ </span>
84
+ </div>
85
+ <div className="flex items-center gap-1.5 ml-2">
86
+ <Button
87
+ size="sm"
88
+ variant="default"
89
+ className="h-7 px-2.5 gap-1.5 text-[11px] font-semibold uppercase tracking-wider"
90
+ onClick={handleReload}
91
+ >
92
+ <RefreshCw className="h-3.5 w-3.5" />
93
+ Reload
94
+ </Button>
95
+ <Button
96
+ size="icon-sm"
97
+ variant="ghost"
98
+ className="h-7 w-7"
99
+ onClick={dismiss}
100
+ aria-label="Dismiss reload reminder"
101
+ >
102
+ <X className="h-3.5 w-3.5" />
103
+ </Button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,326 @@
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
+ * Mobile-optimized toolbar for the 3D viewport.
7
+ * Compact, touch-friendly layout with essential actions visible
8
+ * and secondary actions in an overflow menu.
9
+ */
10
+
11
+ import React, { useRef, useCallback, useMemo } from 'react';
12
+ import {
13
+ FolderOpen,
14
+ MousePointer2,
15
+ Ruler,
16
+ Scissors,
17
+ Eye,
18
+ EyeOff,
19
+ Home,
20
+ Maximize2,
21
+ Crosshair,
22
+ Loader2,
23
+ MoreHorizontal,
24
+ Plus,
25
+ Download,
26
+ Orbit,
27
+ Sun,
28
+ Moon,
29
+ PersonStanding,
30
+ } from 'lucide-react';
31
+ import { Button } from '@/components/ui/button';
32
+ import {
33
+ DropdownMenu,
34
+ DropdownMenuContent,
35
+ DropdownMenuItem,
36
+ DropdownMenuSeparator,
37
+ DropdownMenuTrigger,
38
+ DropdownMenuCheckboxItem,
39
+ } from '@/components/ui/dropdown-menu';
40
+ import { Progress } from '@/components/ui/progress';
41
+ import { useViewerStore } from '@/store';
42
+ import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
43
+ import { executeBasketIsolate } from '@/store/basket/basketCommands';
44
+ import { useIfc } from '@/hooks/useIfc';
45
+ import { cn } from '@/lib/utils';
46
+ import { GLTFExporter } from '@ifc-lite/export';
47
+ import { openIfcFileDialog } from '@/services/file-dialog';
48
+ import { logToDesktopTerminal } from '@/services/desktop-logger';
49
+ import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
50
+ import { toast } from '@/components/ui/toast';
51
+
52
+ type Tool = 'select' | 'walk' | 'measure' | 'section';
53
+
54
+ export function MobileToolbar() {
55
+ const fileInputRef = useRef<HTMLInputElement>(null);
56
+ const addModelInputRef = useRef<HTMLInputElement>(null);
57
+ const {
58
+ loadFile,
59
+ loading,
60
+ progress,
61
+ geometryProgress,
62
+ metadataProgress,
63
+ geometryResult,
64
+ models,
65
+ loadFilesSequentially,
66
+ addModel,
67
+ } = useIfc();
68
+
69
+ const hasModelsLoaded = models.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0);
70
+ const activeTool = useViewerStore((state) => state.activeTool);
71
+ const setActiveTool = useViewerStore((state) => state.setActiveTool);
72
+ const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
73
+ const hideEntities = useViewerStore((state) => state.hideEntities);
74
+ const error = useViewerStore((state) => state.error);
75
+ const cameraCallbacks = useViewerStore((state) => state.cameraCallbacks);
76
+ const resetViewerState = useViewerStore((state) => state.resetViewerState);
77
+ const clearAllModels = useViewerStore((state) => state.clearAllModels);
78
+ const projectionMode = useViewerStore((state) => state.projectionMode);
79
+ const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
80
+ const theme = useViewerStore((state) => state.theme);
81
+ const toggleTheme = useViewerStore((state) => state.toggleTheme);
82
+
83
+ const hasSelection = selectedEntityId !== null;
84
+
85
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
86
+ const files = e.target.files;
87
+ if (!files || files.length === 0) return;
88
+ const supportedFiles = Array.from(files).filter(
89
+ f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
90
+ );
91
+ if (supportedFiles.length === 0) return;
92
+ recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
93
+ void cacheFileBlobs(supportedFiles);
94
+ if (supportedFiles.length === 1) {
95
+ loadFile(supportedFiles[0]);
96
+ } else {
97
+ resetViewerState();
98
+ clearAllModels();
99
+ loadFilesSequentially(supportedFiles);
100
+ }
101
+ e.target.value = '';
102
+ }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels]);
103
+
104
+ const handleAddModelSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
105
+ const files = e.target.files;
106
+ if (!files || files.length === 0) return;
107
+ const supportedFiles = Array.from(files).filter(
108
+ f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
109
+ );
110
+ if (supportedFiles.length === 0) return;
111
+ recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
112
+ void cacheFileBlobs(supportedFiles);
113
+ loadFilesSequentially(supportedFiles);
114
+ e.target.value = '';
115
+ }, [loadFilesSequentially]);
116
+
117
+ const handleIsolate = useCallback(() => {
118
+ executeBasketIsolate();
119
+ }, []);
120
+
121
+ const handleShowAll = useCallback(() => {
122
+ resetVisibilityForHomeFromStore();
123
+ }, []);
124
+
125
+ const handleHide = useCallback(() => {
126
+ if (selectedEntityId !== null) {
127
+ hideEntities([selectedEntityId]);
128
+ }
129
+ }, [selectedEntityId, hideEntities]);
130
+
131
+ const handleHome = useCallback(() => {
132
+ goHomeFromStore();
133
+ }, []);
134
+
135
+ const handleExportGLB = useCallback(async () => {
136
+ if (!geometryResult) return;
137
+ try {
138
+ const exporter = new GLTFExporter(geometryResult);
139
+ const glb = exporter.exportGLB({ includeMetadata: true });
140
+ const blob = new Blob([new Uint8Array(glb)], { type: 'model/gltf-binary' });
141
+ const url = URL.createObjectURL(blob);
142
+ const a = document.createElement('a');
143
+ a.href = url;
144
+ a.download = 'model.glb';
145
+ a.click();
146
+ URL.revokeObjectURL(url);
147
+ toast.success(`Exported GLB (${(blob.size / 1024).toFixed(0)} KB)`);
148
+ } catch (err) {
149
+ toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
150
+ }
151
+ }, [geometryResult]);
152
+
153
+ const toolButtons: { tool: Tool; icon: React.ElementType; label: string }[] = [
154
+ { tool: 'select', icon: MousePointer2, label: 'Select' },
155
+ { tool: 'measure', icon: Ruler, label: 'Measure' },
156
+ { tool: 'section', icon: Scissors, label: 'Section' },
157
+ ];
158
+
159
+ return (
160
+ <div className="flex items-center gap-0.5 px-1.5 h-11 border-b bg-white dark:bg-black border-zinc-200 dark:border-zinc-800 relative z-50 overflow-x-auto">
161
+ {/* Hidden file inputs */}
162
+ <input
163
+ ref={fileInputRef}
164
+ type="file"
165
+ accept=".ifc,.ifcx,.glb"
166
+ multiple
167
+ onChange={handleFileSelect}
168
+ className="hidden"
169
+ />
170
+ <input
171
+ ref={addModelInputRef}
172
+ type="file"
173
+ accept=".ifc,.ifcx,.glb"
174
+ multiple
175
+ onChange={handleAddModelSelect}
176
+ className="hidden"
177
+ />
178
+
179
+ {/* Open File */}
180
+ <Button
181
+ variant="ghost"
182
+ size="icon-sm"
183
+ className="h-9 w-9 flex-shrink-0"
184
+ onClick={async () => {
185
+ const file = await openIfcFileDialog();
186
+ if (file) {
187
+ recordRecentFiles([{ name: file.name, size: file.size, path: file.path, modifiedMs: file.modifiedMs ?? null }]);
188
+ void loadFile(file);
189
+ return;
190
+ }
191
+ fileInputRef.current?.click();
192
+ }}
193
+ disabled={loading}
194
+ >
195
+ {loading ? (
196
+ <Loader2 className="h-4 w-4 animate-spin" />
197
+ ) : (
198
+ <FolderOpen className="h-4 w-4" />
199
+ )}
200
+ </Button>
201
+
202
+ {/* Add Model */}
203
+ {hasModelsLoaded && (
204
+ <Button
205
+ variant="ghost"
206
+ size="icon-sm"
207
+ className="h-9 w-9 flex-shrink-0 text-[#9ece6a]"
208
+ onClick={() => addModelInputRef.current?.click()}
209
+ disabled={loading}
210
+ >
211
+ <Plus className="h-4 w-4" />
212
+ </Button>
213
+ )}
214
+
215
+ {/* Divider */}
216
+ <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
217
+
218
+ {/* Tool buttons */}
219
+ {toolButtons.map(({ tool, icon: Icon, label }) => (
220
+ <Button
221
+ key={tool}
222
+ variant={activeTool === tool ? 'default' : 'ghost'}
223
+ size="icon-sm"
224
+ className={cn('h-9 w-9 flex-shrink-0', activeTool === tool && 'bg-primary text-primary-foreground')}
225
+ onClick={() => setActiveTool(tool)}
226
+ aria-label={label}
227
+ >
228
+ <Icon className="h-4 w-4" />
229
+ </Button>
230
+ ))}
231
+
232
+ {/* Divider */}
233
+ <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
234
+
235
+ {/* Quick actions: Home, Fit, Show All */}
236
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0" onClick={handleHome} aria-label="Home">
237
+ <Home className="h-4 w-4" />
238
+ </Button>
239
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0" onClick={() => cameraCallbacks.fitAll?.()} aria-label="Fit All">
240
+ <Maximize2 className="h-4 w-4" />
241
+ </Button>
242
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0" onClick={handleShowAll} aria-label="Show All">
243
+ <Eye className="h-4 w-4" />
244
+ </Button>
245
+
246
+ {/* Spacer */}
247
+ <div className="flex-1 min-w-2" />
248
+
249
+ {/* Loading progress (compact) */}
250
+ {loading && (geometryProgress || metadataProgress || progress) && (
251
+ <div className="flex items-center gap-1.5 mr-1 flex-shrink-0">
252
+ <Progress value={(geometryProgress ?? metadataProgress ?? progress)?.percent ?? 0} className="w-16 h-1.5" />
253
+ <span className="text-[10px] text-muted-foreground tabular-nums">
254
+ {Math.round((geometryProgress ?? metadataProgress ?? progress)?.percent ?? 0)}%
255
+ </span>
256
+ </div>
257
+ )}
258
+
259
+ {/* Error */}
260
+ {error && (
261
+ <span className="text-[10px] text-destructive mr-1 truncate max-w-24 flex-shrink-0">{error}</span>
262
+ )}
263
+
264
+ {/* Overflow menu */}
265
+ <DropdownMenu>
266
+ <DropdownMenuTrigger asChild>
267
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0">
268
+ <MoreHorizontal className="h-4 w-4" />
269
+ </Button>
270
+ </DropdownMenuTrigger>
271
+ <DropdownMenuContent align="end" className="w-52">
272
+ {/* Walk Mode */}
273
+ <DropdownMenuCheckboxItem
274
+ checked={activeTool === 'walk'}
275
+ onCheckedChange={() => setActiveTool(activeTool === 'walk' ? 'select' : 'walk')}
276
+ >
277
+ <PersonStanding className="h-4 w-4 mr-2" />
278
+ Walk Mode
279
+ </DropdownMenuCheckboxItem>
280
+
281
+ <DropdownMenuSeparator />
282
+
283
+ {/* Visibility */}
284
+ <DropdownMenuItem onClick={handleIsolate}>
285
+ <Eye className="h-4 w-4 mr-2" />
286
+ Isolate Selection
287
+ </DropdownMenuItem>
288
+ <DropdownMenuItem onClick={handleHide} disabled={!hasSelection}>
289
+ <EyeOff className="h-4 w-4 mr-2" />
290
+ Hide Selection
291
+ </DropdownMenuItem>
292
+ {hasSelection && (
293
+ <DropdownMenuItem onClick={() => cameraCallbacks.frameSelection?.()}>
294
+ <Crosshair className="h-4 w-4 mr-2" />
295
+ Frame Selection
296
+ </DropdownMenuItem>
297
+ )}
298
+
299
+ <DropdownMenuSeparator />
300
+
301
+ {/* Camera */}
302
+ <DropdownMenuItem onClick={() => toggleProjectionMode()}>
303
+ <Orbit className="h-4 w-4 mr-2" />
304
+ {projectionMode === 'orthographic' ? 'Perspective' : 'Orthographic'}
305
+ </DropdownMenuItem>
306
+
307
+ <DropdownMenuSeparator />
308
+
309
+ {/* Export */}
310
+ {geometryResult && (
311
+ <DropdownMenuItem onClick={() => void handleExportGLB()}>
312
+ <Download className="h-4 w-4 mr-2" />
313
+ Export GLB
314
+ </DropdownMenuItem>
315
+ )}
316
+
317
+ {/* Theme */}
318
+ <DropdownMenuItem onClick={() => toggleTheme()}>
319
+ {theme === 'dark' ? <Sun className="h-4 w-4 mr-2" /> : <Moon className="h-4 w-4 mr-2" />}
320
+ {theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
321
+ </DropdownMenuItem>
322
+ </DropdownMenuContent>
323
+ </DropdownMenu>
324
+ </div>
325
+ );
326
+ }