@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.
- package/.turbo/turbo-build.log +59 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +79 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +60 -15
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/generated/mcp-catalog.json +82 -0
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- 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
|
|
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>
|
|
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
|
+
}
|