@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.
Files changed (52) hide show
  1. package/LICENSE +373 -0
  2. package/components.json +22 -0
  3. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  4. package/dist/assets/geometry.worker-DpnHtNr3.ts +157 -0
  5. package/dist/assets/ifc_lite_wasm_bg-Cd3m3f2h.wasm +0 -0
  6. package/dist/assets/index-DKe9Oy-s.css +1 -0
  7. package/dist/assets/index-Dzz3WVwq.js +637 -0
  8. package/dist/ifc_lite_wasm_bg.wasm +0 -0
  9. package/dist/index.html +13 -0
  10. package/dist/web-ifc.wasm +0 -0
  11. package/index.html +12 -0
  12. package/package.json +52 -0
  13. package/postcss.config.js +6 -0
  14. package/public/ifc_lite_wasm_bg.wasm +0 -0
  15. package/public/web-ifc.wasm +0 -0
  16. package/src/App.tsx +13 -0
  17. package/src/components/Viewport.tsx +723 -0
  18. package/src/components/ui/button.tsx +58 -0
  19. package/src/components/ui/collapsible.tsx +11 -0
  20. package/src/components/ui/context-menu.tsx +174 -0
  21. package/src/components/ui/dropdown-menu.tsx +175 -0
  22. package/src/components/ui/input.tsx +49 -0
  23. package/src/components/ui/progress.tsx +26 -0
  24. package/src/components/ui/scroll-area.tsx +47 -0
  25. package/src/components/ui/separator.tsx +27 -0
  26. package/src/components/ui/tabs.tsx +56 -0
  27. package/src/components/ui/tooltip.tsx +31 -0
  28. package/src/components/viewer/AxisHelper.tsx +125 -0
  29. package/src/components/viewer/BoxSelectionOverlay.tsx +53 -0
  30. package/src/components/viewer/EntityContextMenu.tsx +220 -0
  31. package/src/components/viewer/HierarchyPanel.tsx +363 -0
  32. package/src/components/viewer/HoverTooltip.tsx +82 -0
  33. package/src/components/viewer/KeyboardShortcutsDialog.tsx +104 -0
  34. package/src/components/viewer/MainToolbar.tsx +441 -0
  35. package/src/components/viewer/PropertiesPanel.tsx +288 -0
  36. package/src/components/viewer/StatusBar.tsx +141 -0
  37. package/src/components/viewer/ToolOverlays.tsx +311 -0
  38. package/src/components/viewer/ViewCube.tsx +195 -0
  39. package/src/components/viewer/ViewerLayout.tsx +190 -0
  40. package/src/components/viewer/Viewport.tsx +1136 -0
  41. package/src/components/viewer/ViewportContainer.tsx +49 -0
  42. package/src/components/viewer/ViewportOverlays.tsx +185 -0
  43. package/src/hooks/useIfc.ts +168 -0
  44. package/src/hooks/useKeyboardShortcuts.ts +142 -0
  45. package/src/index.css +177 -0
  46. package/src/lib/utils.ts +45 -0
  47. package/src/main.tsx +18 -0
  48. package/src/store.ts +471 -0
  49. package/src/webgpu-types.d.ts +20 -0
  50. package/tailwind.config.js +72 -0
  51. package/tsconfig.json +16 -0
  52. 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;