@ifc-lite/viewer 1.0.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/LICENSE +373 -0
- package/components.json +22 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/geometry.worker-DpnHtNr3.ts +157 -0
- package/dist/assets/ifc_lite_wasm_bg-Cd3m3f2h.wasm +0 -0
- package/dist/assets/index-DKe9Oy-s.css +1 -0
- package/dist/assets/index-Dzz3WVwq.js +637 -0
- package/dist/ifc_lite_wasm_bg.wasm +0 -0
- package/dist/index.html +13 -0
- package/dist/web-ifc.wasm +0 -0
- package/index.html +12 -0
- package/package.json +52 -0
- package/postcss.config.js +6 -0
- package/public/ifc_lite_wasm_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/App.tsx +13 -0
- package/src/components/Viewport.tsx +723 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +174 -0
- package/src/components/ui/dropdown-menu.tsx +175 -0
- package/src/components/ui/input.tsx +49 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +47 -0
- package/src/components/ui/separator.tsx +27 -0
- package/src/components/ui/tabs.tsx +56 -0
- package/src/components/ui/tooltip.tsx +31 -0
- package/src/components/viewer/AxisHelper.tsx +125 -0
- package/src/components/viewer/BoxSelectionOverlay.tsx +53 -0
- package/src/components/viewer/EntityContextMenu.tsx +220 -0
- package/src/components/viewer/HierarchyPanel.tsx +363 -0
- package/src/components/viewer/HoverTooltip.tsx +82 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +104 -0
- package/src/components/viewer/MainToolbar.tsx +441 -0
- package/src/components/viewer/PropertiesPanel.tsx +288 -0
- package/src/components/viewer/StatusBar.tsx +141 -0
- package/src/components/viewer/ToolOverlays.tsx +311 -0
- package/src/components/viewer/ViewCube.tsx +195 -0
- package/src/components/viewer/ViewerLayout.tsx +190 -0
- package/src/components/viewer/Viewport.tsx +1136 -0
- package/src/components/viewer/ViewportContainer.tsx +49 -0
- package/src/components/viewer/ViewportOverlays.tsx +185 -0
- package/src/hooks/useIfc.ts +168 -0
- package/src/hooks/useKeyboardShortcuts.ts +142 -0
- package/src/index.css +177 -0
- package/src/lib/utils.ts +45 -0
- package/src/main.tsx +18 -0
- package/src/store.ts +471 -0
- package/src/webgpu-types.d.ts +20 -0
- package/tailwind.config.js +72 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +45 -0
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
import { useMemo } from 'react';
|
|
6
|
+
import { Viewport } from './Viewport';
|
|
7
|
+
import { ViewportOverlays } from './ViewportOverlays';
|
|
8
|
+
import { ToolOverlays } from './ToolOverlays';
|
|
9
|
+
import { useViewerStore } from '@/store';
|
|
10
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
11
|
+
|
|
12
|
+
export function ViewportContainer() {
|
|
13
|
+
const { geometryResult, ifcDataStore } = useIfc();
|
|
14
|
+
const selectedStorey = useViewerStore((s) => s.selectedStorey);
|
|
15
|
+
|
|
16
|
+
// Filter geometry based on selected storey
|
|
17
|
+
const filteredGeometry = useMemo(() => {
|
|
18
|
+
if (!geometryResult?.meshes || !ifcDataStore?.spatialHierarchy) {
|
|
19
|
+
return geometryResult?.meshes || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (selectedStorey === null) {
|
|
23
|
+
return geometryResult.meshes;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hierarchy = ifcDataStore.spatialHierarchy;
|
|
27
|
+
const storeyElementIds = hierarchy.byStorey.get(selectedStorey);
|
|
28
|
+
|
|
29
|
+
if (!storeyElementIds || storeyElementIds.length === 0) {
|
|
30
|
+
return geometryResult.meshes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const storeyElementIdSet = new Set(storeyElementIds);
|
|
34
|
+
return geometryResult.meshes.filter(mesh =>
|
|
35
|
+
storeyElementIdSet.has(mesh.expressId)
|
|
36
|
+
);
|
|
37
|
+
}, [geometryResult, ifcDataStore, selectedStorey]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="relative h-full w-full bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-900 dark:to-slate-800">
|
|
41
|
+
<Viewport
|
|
42
|
+
geometry={filteredGeometry}
|
|
43
|
+
coordinateInfo={geometryResult?.coordinateInfo}
|
|
44
|
+
/>
|
|
45
|
+
<ViewportOverlays />
|
|
46
|
+
<ToolOverlays />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
Home,
|
|
8
|
+
ZoomIn,
|
|
9
|
+
ZoomOut,
|
|
10
|
+
Layers,
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
14
|
+
import { useViewerStore } from '@/store';
|
|
15
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
16
|
+
import { ViewCube, type ViewCubeRef } from './ViewCube';
|
|
17
|
+
import { AxisHelper } from './AxisHelper';
|
|
18
|
+
|
|
19
|
+
export function ViewportOverlays() {
|
|
20
|
+
const selectedStorey = useViewerStore((s) => s.selectedStorey);
|
|
21
|
+
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
22
|
+
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
23
|
+
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
|
|
24
|
+
const setOnCameraRotationChange = useViewerStore((s) => s.setOnCameraRotationChange);
|
|
25
|
+
const setOnScaleChange = useViewerStore((s) => s.setOnScaleChange);
|
|
26
|
+
const { ifcDataStore, geometryResult } = useIfc();
|
|
27
|
+
|
|
28
|
+
// Use refs for rotation to avoid re-renders - ViewCube updates itself directly
|
|
29
|
+
const cameraRotationRef = useRef({ azimuth: 45, elevation: 25 });
|
|
30
|
+
const viewCubeRef = useRef<ViewCubeRef | null>(null);
|
|
31
|
+
|
|
32
|
+
// Local state for scale - updated via callback, no global re-renders
|
|
33
|
+
const [scale, setScale] = useState(10);
|
|
34
|
+
|
|
35
|
+
// Register callback for real-time rotation updates - updates ViewCube directly
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleRotationChange = (rotation: { azimuth: number; elevation: number }) => {
|
|
38
|
+
cameraRotationRef.current = rotation;
|
|
39
|
+
// Update ViewCube directly via ref (no React re-render)
|
|
40
|
+
if (viewCubeRef.current) {
|
|
41
|
+
const viewCubeRotationX = -rotation.elevation;
|
|
42
|
+
const viewCubeRotationY = -rotation.azimuth;
|
|
43
|
+
viewCubeRef.current.updateRotation(viewCubeRotationX, viewCubeRotationY);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
setOnCameraRotationChange(handleRotationChange);
|
|
47
|
+
return () => setOnCameraRotationChange(null);
|
|
48
|
+
}, [setOnCameraRotationChange]);
|
|
49
|
+
|
|
50
|
+
// Register callback for real-time scale updates
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setOnScaleChange(setScale);
|
|
53
|
+
return () => setOnScaleChange(null);
|
|
54
|
+
}, [setOnScaleChange]);
|
|
55
|
+
|
|
56
|
+
const storeyName = selectedStorey && ifcDataStore
|
|
57
|
+
? ifcDataStore.entities.getName(selectedStorey) || `Storey #${selectedStorey}`
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
// Calculate visible count considering visibility filters
|
|
61
|
+
const totalCount = geometryResult?.meshes?.length ?? 0;
|
|
62
|
+
let visibleCount = totalCount;
|
|
63
|
+
if (isolatedEntities !== null) {
|
|
64
|
+
visibleCount = isolatedEntities.size;
|
|
65
|
+
} else if (hiddenEntities.size > 0) {
|
|
66
|
+
visibleCount = totalCount - hiddenEntities.size;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Initial rotation values (ViewCube will update itself via ref)
|
|
70
|
+
const initialRotationX = -cameraRotationRef.current.elevation;
|
|
71
|
+
const initialRotationY = -cameraRotationRef.current.azimuth;
|
|
72
|
+
|
|
73
|
+
const handleViewChange = useCallback((view: string) => {
|
|
74
|
+
const viewMap: Record<string, 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right'> = {
|
|
75
|
+
top: 'top',
|
|
76
|
+
bottom: 'bottom',
|
|
77
|
+
front: 'front',
|
|
78
|
+
back: 'back',
|
|
79
|
+
left: 'left',
|
|
80
|
+
right: 'right',
|
|
81
|
+
};
|
|
82
|
+
const mappedView = viewMap[view];
|
|
83
|
+
if (mappedView && cameraCallbacks.setPresetView) {
|
|
84
|
+
cameraCallbacks.setPresetView(mappedView);
|
|
85
|
+
}
|
|
86
|
+
}, [cameraCallbacks]);
|
|
87
|
+
|
|
88
|
+
const handleHome = useCallback(() => {
|
|
89
|
+
cameraCallbacks.home?.();
|
|
90
|
+
}, [cameraCallbacks]);
|
|
91
|
+
|
|
92
|
+
const handleFitAll = useCallback(() => {
|
|
93
|
+
cameraCallbacks.fitAll?.();
|
|
94
|
+
}, [cameraCallbacks]);
|
|
95
|
+
|
|
96
|
+
const handleZoomIn = useCallback(() => {
|
|
97
|
+
cameraCallbacks.zoomIn?.();
|
|
98
|
+
}, [cameraCallbacks]);
|
|
99
|
+
|
|
100
|
+
const handleZoomOut = useCallback(() => {
|
|
101
|
+
cameraCallbacks.zoomOut?.();
|
|
102
|
+
}, [cameraCallbacks]);
|
|
103
|
+
|
|
104
|
+
// Format scale value for display
|
|
105
|
+
const formatScale = (worldSize: number): string => {
|
|
106
|
+
if (worldSize >= 1000) {
|
|
107
|
+
return `${(worldSize / 1000).toFixed(1)}km`;
|
|
108
|
+
} else if (worldSize >= 1) {
|
|
109
|
+
return `${worldSize.toFixed(1)}m`;
|
|
110
|
+
} else if (worldSize >= 0.1) {
|
|
111
|
+
return `${(worldSize * 100).toFixed(0)}cm`;
|
|
112
|
+
} else {
|
|
113
|
+
return `${(worldSize * 1000).toFixed(0)}mm`;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
{/* Navigation Controls (bottom-right) */}
|
|
120
|
+
<div className="absolute bottom-4 right-4 flex flex-col gap-1 bg-background/80 backdrop-blur-sm rounded-lg border shadow-sm p-1">
|
|
121
|
+
<Tooltip>
|
|
122
|
+
<TooltipTrigger asChild>
|
|
123
|
+
<Button variant="ghost" size="icon-sm" onClick={handleHome}>
|
|
124
|
+
<Home className="h-4 w-4" />
|
|
125
|
+
</Button>
|
|
126
|
+
</TooltipTrigger>
|
|
127
|
+
<TooltipContent side="left">Home (H)</TooltipContent>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
|
|
130
|
+
<Tooltip>
|
|
131
|
+
<TooltipTrigger asChild>
|
|
132
|
+
<Button variant="ghost" size="icon-sm" onClick={handleZoomIn}>
|
|
133
|
+
<ZoomIn className="h-4 w-4" />
|
|
134
|
+
</Button>
|
|
135
|
+
</TooltipTrigger>
|
|
136
|
+
<TooltipContent side="left">Zoom In (+)</TooltipContent>
|
|
137
|
+
</Tooltip>
|
|
138
|
+
|
|
139
|
+
<Tooltip>
|
|
140
|
+
<TooltipTrigger asChild>
|
|
141
|
+
<Button variant="ghost" size="icon-sm" onClick={handleZoomOut}>
|
|
142
|
+
<ZoomOut className="h-4 w-4" />
|
|
143
|
+
</Button>
|
|
144
|
+
</TooltipTrigger>
|
|
145
|
+
<TooltipContent side="left">Zoom Out (-)</TooltipContent>
|
|
146
|
+
</Tooltip>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Context Info (bottom-center) - Storey name only */}
|
|
150
|
+
{storeyName && (
|
|
151
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-background/80 backdrop-blur-sm rounded-full border shadow-sm">
|
|
152
|
+
<div className="flex items-center gap-2 text-sm">
|
|
153
|
+
<Layers className="h-4 w-4 text-primary" />
|
|
154
|
+
<span className="font-medium">{storeyName}</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* ViewCube (top-right) */}
|
|
160
|
+
<div className="absolute top-6 right-6">
|
|
161
|
+
<ViewCube
|
|
162
|
+
ref={viewCubeRef}
|
|
163
|
+
onViewChange={handleViewChange}
|
|
164
|
+
onDrag={(deltaX, deltaY) => cameraCallbacks.orbit?.(deltaX, deltaY)}
|
|
165
|
+
rotationX={initialRotationX}
|
|
166
|
+
rotationY={initialRotationY}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Axis Helper (bottom-left, above scale bar) - IFC Z-up convention */}
|
|
171
|
+
<div className="absolute bottom-16 left-4">
|
|
172
|
+
<AxisHelper
|
|
173
|
+
rotationX={initialRotationX}
|
|
174
|
+
rotationY={initialRotationY}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Scale Bar (bottom-left) */}
|
|
179
|
+
<div className="absolute bottom-4 left-4 flex flex-col items-start gap-1">
|
|
180
|
+
<div className="h-1 w-24 bg-foreground/80 rounded-full" />
|
|
181
|
+
<span className="text-xs text-foreground/80">{formatScale(scale)}</span>
|
|
182
|
+
</div>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
* Hook for loading and processing IFC files
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo, useCallback, useRef } from 'react';
|
|
10
|
+
import { useViewerStore } from '../store.js';
|
|
11
|
+
import { IfcParser } from '@ifc-lite/parser';
|
|
12
|
+
import { GeometryProcessor, GeometryQuality, type MeshData } from '@ifc-lite/geometry';
|
|
13
|
+
import { IfcQuery } from '@ifc-lite/query';
|
|
14
|
+
import { BufferBuilder } from '@ifc-lite/geometry';
|
|
15
|
+
import { buildSpatialIndex } from '@ifc-lite/spatial';
|
|
16
|
+
|
|
17
|
+
export function useIfc() {
|
|
18
|
+
const {
|
|
19
|
+
loading,
|
|
20
|
+
progress,
|
|
21
|
+
error,
|
|
22
|
+
ifcDataStore,
|
|
23
|
+
geometryResult,
|
|
24
|
+
setLoading,
|
|
25
|
+
setProgress,
|
|
26
|
+
setError,
|
|
27
|
+
setIfcDataStore,
|
|
28
|
+
setGeometryResult,
|
|
29
|
+
appendGeometryBatch,
|
|
30
|
+
updateCoordinateInfo,
|
|
31
|
+
} = useViewerStore();
|
|
32
|
+
|
|
33
|
+
// Track if we've already logged for this ifcDataStore
|
|
34
|
+
const lastLoggedDataStoreRef = useRef<typeof ifcDataStore>(null);
|
|
35
|
+
|
|
36
|
+
const loadFile = useCallback(async (file: File) => {
|
|
37
|
+
const { resetViewerState } = useViewerStore.getState();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Reset all viewer state before loading new file
|
|
41
|
+
resetViewerState();
|
|
42
|
+
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
setProgress({ phase: 'Loading file', percent: 0 });
|
|
46
|
+
|
|
47
|
+
// Read file
|
|
48
|
+
const buffer = await file.arrayBuffer();
|
|
49
|
+
setProgress({ phase: 'Parsing IFC', percent: 10 });
|
|
50
|
+
|
|
51
|
+
// Parse IFC using columnar parser
|
|
52
|
+
const parser = new IfcParser();
|
|
53
|
+
const dataStore = await parser.parseColumnar(buffer, {
|
|
54
|
+
onProgress: (prog) => {
|
|
55
|
+
setProgress({
|
|
56
|
+
phase: `Parsing: ${prog.phase}`,
|
|
57
|
+
percent: 10 + (prog.percent * 0.4),
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
setIfcDataStore(dataStore);
|
|
63
|
+
setProgress({ phase: 'Triangulating geometry', percent: 50 });
|
|
64
|
+
|
|
65
|
+
// Process geometry with streaming for progressive rendering
|
|
66
|
+
// Quality: Fast for speed, Balanced for quality, High for best quality
|
|
67
|
+
const geometryProcessor = new GeometryProcessor({
|
|
68
|
+
useWorkers: false,
|
|
69
|
+
quality: GeometryQuality.Balanced // Can be GeometryQuality.Fast, Balanced, or High
|
|
70
|
+
});
|
|
71
|
+
await geometryProcessor.init();
|
|
72
|
+
|
|
73
|
+
// Pass entity index for priority-based loading
|
|
74
|
+
const entityIndexMap = new Map<number, any>();
|
|
75
|
+
if (dataStore.entityIndex?.byId) {
|
|
76
|
+
for (const [id, ref] of dataStore.entityIndex.byId) {
|
|
77
|
+
entityIndexMap.set(id, { type: ref.type });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Use streaming processing for progressive rendering
|
|
82
|
+
const bufferBuilder = new BufferBuilder();
|
|
83
|
+
let estimatedTotal = 0;
|
|
84
|
+
let totalMeshes = 0;
|
|
85
|
+
const allMeshes: MeshData[] = []; // Collect all meshes for BVH building
|
|
86
|
+
|
|
87
|
+
// Clear existing geometry result
|
|
88
|
+
setGeometryResult(null);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for await (const event of geometryProcessor.processStreaming(new Uint8Array(buffer), entityIndexMap, 100)) {
|
|
92
|
+
switch (event.type) {
|
|
93
|
+
case 'start':
|
|
94
|
+
estimatedTotal = event.totalEstimate;
|
|
95
|
+
break;
|
|
96
|
+
case 'model-open':
|
|
97
|
+
setProgress({ phase: 'Processing geometry', percent: 50 });
|
|
98
|
+
break;
|
|
99
|
+
case 'batch':
|
|
100
|
+
// Collect meshes for BVH building
|
|
101
|
+
allMeshes.push(...event.meshes);
|
|
102
|
+
|
|
103
|
+
// Convert MeshData[] to GPU-ready format and append
|
|
104
|
+
const gpuMeshes = bufferBuilder.processMeshes(event.meshes).meshes;
|
|
105
|
+
appendGeometryBatch(gpuMeshes, event.coordinateInfo);
|
|
106
|
+
totalMeshes = event.totalSoFar;
|
|
107
|
+
|
|
108
|
+
// Update progress (50-95% for geometry processing)
|
|
109
|
+
const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal, totalMeshes)) * 45);
|
|
110
|
+
setProgress({
|
|
111
|
+
phase: `Rendering geometry (${totalMeshes} meshes)`,
|
|
112
|
+
percent: progressPercent
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
case 'complete':
|
|
116
|
+
// Update geometry result with final coordinate info
|
|
117
|
+
updateCoordinateInfo(event.coordinateInfo);
|
|
118
|
+
|
|
119
|
+
// Build spatial index from all collected meshes
|
|
120
|
+
if (allMeshes.length > 0) {
|
|
121
|
+
setProgress({ phase: 'Building spatial index', percent: 95 });
|
|
122
|
+
try {
|
|
123
|
+
const spatialIndex = buildSpatialIndex(allMeshes);
|
|
124
|
+
// Attach spatial index to dataStore
|
|
125
|
+
(dataStore as any).spatialIndex = spatialIndex;
|
|
126
|
+
setIfcDataStore(dataStore); // Update store with spatial index
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.warn('[useIfc] Failed to build spatial index:', err);
|
|
129
|
+
// Continue without spatial index - it's optional
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setProgress({ phase: 'Complete', percent: 100 });
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error('[useIfc] Error in streaming processing:', err);
|
|
139
|
+
setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setLoading(false);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
145
|
+
setLoading(false);
|
|
146
|
+
}
|
|
147
|
+
}, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, appendGeometryBatch, updateCoordinateInfo]);
|
|
148
|
+
|
|
149
|
+
// Memoize query to prevent recreation on every render
|
|
150
|
+
const query = useMemo(() => {
|
|
151
|
+
if (!ifcDataStore) return null;
|
|
152
|
+
|
|
153
|
+
// Only log once per ifcDataStore
|
|
154
|
+
lastLoggedDataStoreRef.current = ifcDataStore;
|
|
155
|
+
|
|
156
|
+
return new IfcQuery(ifcDataStore);
|
|
157
|
+
}, [ifcDataStore]);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
loading,
|
|
161
|
+
progress,
|
|
162
|
+
error,
|
|
163
|
+
ifcDataStore,
|
|
164
|
+
geometryResult,
|
|
165
|
+
query,
|
|
166
|
+
loadFile,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
* Global keyboard shortcuts for the viewer
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useCallback } from 'react';
|
|
10
|
+
import { useViewerStore } from '@/store';
|
|
11
|
+
|
|
12
|
+
interface KeyboardShortcutsOptions {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
17
|
+
const { enabled = true } = options;
|
|
18
|
+
|
|
19
|
+
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
20
|
+
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
21
|
+
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
22
|
+
const isolateEntity = useViewerStore((s) => s.isolateEntity);
|
|
23
|
+
const hideEntity = useViewerStore((s) => s.hideEntity);
|
|
24
|
+
const showAll = useViewerStore((s) => s.showAll);
|
|
25
|
+
const toggleTheme = useViewerStore((s) => s.toggleTheme);
|
|
26
|
+
|
|
27
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
28
|
+
// Ignore if typing in an input or textarea
|
|
29
|
+
const target = e.target as HTMLElement;
|
|
30
|
+
if (
|
|
31
|
+
target.tagName === 'INPUT' ||
|
|
32
|
+
target.tagName === 'TEXTAREA' ||
|
|
33
|
+
target.isContentEditable
|
|
34
|
+
) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Get modifier keys
|
|
39
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
40
|
+
const shift = e.shiftKey;
|
|
41
|
+
const key = e.key.toLowerCase();
|
|
42
|
+
|
|
43
|
+
// Navigation tools
|
|
44
|
+
if (key === 'v' && !ctrl && !shift) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
setActiveTool('select');
|
|
47
|
+
}
|
|
48
|
+
if (key === 'p' && !ctrl && !shift) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
setActiveTool('pan');
|
|
51
|
+
}
|
|
52
|
+
if (key === 'o' && !ctrl && !shift) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
setActiveTool('orbit');
|
|
55
|
+
}
|
|
56
|
+
if (key === 'c' && !ctrl && !shift) {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
setActiveTool('walk');
|
|
59
|
+
}
|
|
60
|
+
if (key === 'm' && !ctrl && !shift) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setActiveTool('measure');
|
|
63
|
+
}
|
|
64
|
+
if (key === 'x' && !ctrl && !shift) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
setActiveTool('section');
|
|
67
|
+
}
|
|
68
|
+
if (key === 'b' && !ctrl && !shift) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setActiveTool('boxselect');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Visibility controls
|
|
74
|
+
if (key === 'i' && !ctrl && !shift && selectedEntityId) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
isolateEntity(selectedEntityId);
|
|
77
|
+
}
|
|
78
|
+
if ((key === 'delete' || key === 'backspace') && !ctrl && !shift && selectedEntityId) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
hideEntity(selectedEntityId);
|
|
81
|
+
}
|
|
82
|
+
if (key === 'a' && !ctrl && !shift) {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
showAll();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Selection - Escape clears selection and switches to select tool
|
|
88
|
+
if (key === 'escape') {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
setSelectedEntityId(null);
|
|
91
|
+
showAll();
|
|
92
|
+
setActiveTool('select');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Theme toggle
|
|
96
|
+
if (key === 't' && !ctrl && !shift) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
toggleTheme();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Help - handled by KeyboardShortcutsDialog hook
|
|
102
|
+
// The dialog hook listens for '?' key globally
|
|
103
|
+
}, [
|
|
104
|
+
selectedEntityId,
|
|
105
|
+
setSelectedEntityId,
|
|
106
|
+
setActiveTool,
|
|
107
|
+
isolateEntity,
|
|
108
|
+
hideEntity,
|
|
109
|
+
showAll,
|
|
110
|
+
toggleTheme,
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!enabled) return;
|
|
115
|
+
|
|
116
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
117
|
+
return () => {
|
|
118
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
119
|
+
};
|
|
120
|
+
}, [enabled, handleKeyDown]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Export shortcut definitions for UI display
|
|
124
|
+
export const KEYBOARD_SHORTCUTS = [
|
|
125
|
+
{ key: 'V', description: 'Select tool', category: 'Tools' },
|
|
126
|
+
{ key: 'B', description: 'Box select tool', category: 'Tools' },
|
|
127
|
+
{ key: 'P', description: 'Pan tool', category: 'Tools' },
|
|
128
|
+
{ key: 'O', description: 'Orbit tool', category: 'Tools' },
|
|
129
|
+
{ key: 'C', description: 'Walk mode', category: 'Tools' },
|
|
130
|
+
{ key: 'M', description: 'Measure tool', category: 'Tools' },
|
|
131
|
+
{ key: 'X', description: 'Section tool', category: 'Tools' },
|
|
132
|
+
{ key: 'I', description: 'Isolate selection', category: 'Visibility' },
|
|
133
|
+
{ key: 'Del', description: 'Hide selection', category: 'Visibility' },
|
|
134
|
+
{ key: 'A', description: 'Show all', category: 'Visibility' },
|
|
135
|
+
{ key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
|
|
136
|
+
{ key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
|
|
137
|
+
{ key: 'F', description: 'Frame selection', category: 'Camera' },
|
|
138
|
+
{ key: '0-6', description: 'Preset views', category: 'Camera' },
|
|
139
|
+
{ key: 'T', description: 'Toggle theme', category: 'UI' },
|
|
140
|
+
{ key: 'Esc', description: 'Clear selection & switch to Select tool', category: 'Selection' },
|
|
141
|
+
{ key: '?', description: 'Show keyboard shortcuts', category: 'Help' },
|
|
142
|
+
] as const;
|