@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
package/src/index.css ADDED
@@ -0,0 +1,177 @@
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 "tailwindcss";
6
+
7
+ /* Custom theme configuration */
8
+ @theme {
9
+ --color-background: hsl(0 0% 100%);
10
+ --color-foreground: hsl(240 10% 3.9%);
11
+ --color-card: hsl(0 0% 100%);
12
+ --color-card-foreground: hsl(240 10% 3.9%);
13
+ --color-popover: hsl(0 0% 100%);
14
+ --color-popover-foreground: hsl(240 10% 3.9%);
15
+ --color-primary: hsl(217 91% 60%);
16
+ --color-primary-foreground: hsl(0 0% 100%);
17
+ --color-secondary: hsl(240 4.8% 95.9%);
18
+ --color-secondary-foreground: hsl(240 5.9% 10%);
19
+ --color-muted: hsl(240 4.8% 95.9%);
20
+ --color-muted-foreground: hsl(240 3.8% 46.1%);
21
+ --color-accent: hsl(240 4.8% 95.9%);
22
+ --color-accent-foreground: hsl(240 5.9% 10%);
23
+ --color-destructive: hsl(0 84.2% 60.2%);
24
+ --color-destructive-foreground: hsl(0 0% 98%);
25
+ --color-border: hsl(240 5.9% 90%);
26
+ --color-input: hsl(240 5.9% 90%);
27
+ --color-ring: hsl(217 91% 60%);
28
+
29
+ --radius-sm: 0.25rem;
30
+ --radius-md: 0.375rem;
31
+ --radius-lg: 0.5rem;
32
+ --radius-xl: 0.75rem;
33
+ }
34
+
35
+ /* Dark mode colors */
36
+ .dark {
37
+ --color-background: hsl(240 10% 3.9%);
38
+ --color-foreground: hsl(0 0% 98%);
39
+ --color-card: hsl(240 10% 3.9%);
40
+ --color-card-foreground: hsl(0 0% 98%);
41
+ --color-popover: hsl(240 10% 3.9%);
42
+ --color-popover-foreground: hsl(0 0% 98%);
43
+ --color-primary: hsl(217 91% 60%);
44
+ --color-primary-foreground: hsl(0 0% 100%);
45
+ --color-secondary: hsl(240 3.7% 15.9%);
46
+ --color-secondary-foreground: hsl(0 0% 98%);
47
+ --color-muted: hsl(240 3.7% 15.9%);
48
+ --color-muted-foreground: hsl(240 5% 64.9%);
49
+ --color-accent: hsl(240 3.7% 15.9%);
50
+ --color-accent-foreground: hsl(0 0% 98%);
51
+ --color-destructive: hsl(0 62.8% 30.6%);
52
+ --color-destructive-foreground: hsl(0 0% 98%);
53
+ --color-border: hsl(240 3.7% 15.9%);
54
+ --color-input: hsl(240 3.7% 15.9%);
55
+ --color-ring: hsl(217 91% 60%);
56
+ }
57
+
58
+ /* Base styles */
59
+ * {
60
+ border-color: var(--color-border);
61
+ }
62
+
63
+ body {
64
+ background-color: var(--color-background);
65
+ color: var(--color-foreground);
66
+ font-feature-settings: "rlig" 1, "calt" 1;
67
+ }
68
+
69
+ /* Custom scrollbar */
70
+ .scrollbar-thin {
71
+ scrollbar-width: thin;
72
+ scrollbar-color: color-mix(in srgb, var(--color-muted-foreground) 30%, transparent) transparent;
73
+ }
74
+
75
+ .scrollbar-thin::-webkit-scrollbar {
76
+ width: 6px;
77
+ height: 6px;
78
+ }
79
+
80
+ .scrollbar-thin::-webkit-scrollbar-track {
81
+ background: transparent;
82
+ }
83
+
84
+ .scrollbar-thin::-webkit-scrollbar-thumb {
85
+ background-color: color-mix(in srgb, var(--color-muted-foreground) 30%, transparent);
86
+ border-radius: 3px;
87
+ }
88
+
89
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
90
+ background-color: color-mix(in srgb, var(--color-muted-foreground) 50%, transparent);
91
+ }
92
+
93
+ /* Panel resize handle */
94
+ .resize-handle {
95
+ background: transparent;
96
+ transition: background-color 0.15s;
97
+ }
98
+
99
+ .resize-handle:hover {
100
+ background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
101
+ }
102
+
103
+ .resize-handle[data-resize-handle-active] {
104
+ background-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
105
+ }
106
+
107
+ /* Tree node styles */
108
+ .tree-node {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 0.25rem;
112
+ padding: 0.5rem 0.25rem;
113
+ cursor: pointer;
114
+ border-radius: 0.125rem;
115
+ border-left: 2px solid transparent;
116
+ transition: all 0.1s;
117
+ }
118
+
119
+ .tree-node:hover {
120
+ background-color: color-mix(in srgb, var(--color-muted) 50%, transparent);
121
+ }
122
+
123
+ .tree-node.selected {
124
+ background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
125
+ border-left-color: var(--color-primary);
126
+ }
127
+
128
+ .tree-node.filtered {
129
+ opacity: 0.5;
130
+ }
131
+
132
+ .tree-node.hidden {
133
+ text-decoration: line-through;
134
+ opacity: 0.3;
135
+ }
136
+
137
+ /* Toolbar button styles */
138
+ .toolbar-button {
139
+ padding: 0.5rem;
140
+ border-radius: 0.375rem;
141
+ transition: all 0.15s;
142
+ }
143
+
144
+ .toolbar-button:hover {
145
+ background-color: var(--color-muted);
146
+ }
147
+
148
+ .toolbar-button:focus {
149
+ outline: none;
150
+ box-shadow: 0 0 0 2px var(--color-ring);
151
+ }
152
+
153
+ .toolbar-button.active {
154
+ background-color: var(--color-primary);
155
+ color: var(--color-primary-foreground);
156
+ }
157
+
158
+ .toolbar-button:disabled {
159
+ opacity: 0.5;
160
+ cursor: not-allowed;
161
+ }
162
+
163
+ /* Loading skeleton animation */
164
+ @keyframes skeleton-pulse {
165
+ 0%, 100% {
166
+ opacity: 1;
167
+ }
168
+ 50% {
169
+ opacity: 0.5;
170
+ }
171
+ }
172
+
173
+ .skeleton {
174
+ background-color: var(--color-muted);
175
+ border-radius: 0.25rem;
176
+ animation: skeleton-pulse 2s ease-in-out infinite;
177
+ }
@@ -0,0 +1,45 @@
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 { type ClassValue, clsx } from 'clsx';
6
+ import { twMerge } from 'tailwind-merge';
7
+
8
+ export function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs));
10
+ }
11
+
12
+ export function formatNumber(num: number): string {
13
+ if (num >= 1_000_000) {
14
+ return (num / 1_000_000).toFixed(1) + 'M';
15
+ }
16
+ if (num >= 1_000) {
17
+ return (num / 1_000).toFixed(1) + 'K';
18
+ }
19
+ return num.toLocaleString();
20
+ }
21
+
22
+ export function formatBytes(bytes: number): string {
23
+ if (bytes >= 1024 * 1024 * 1024) {
24
+ return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
25
+ }
26
+ if (bytes >= 1024 * 1024) {
27
+ return (bytes / (1024 * 1024)).toFixed(0) + ' MB';
28
+ }
29
+ if (bytes >= 1024) {
30
+ return (bytes / 1024).toFixed(0) + ' KB';
31
+ }
32
+ return bytes + ' B';
33
+ }
34
+
35
+ export function formatDuration(ms: number): string {
36
+ if (ms < 1000) {
37
+ return ms + 'ms';
38
+ }
39
+ if (ms < 60000) {
40
+ return (ms / 1000).toFixed(1) + 's';
41
+ }
42
+ const minutes = Math.floor(ms / 60000);
43
+ const seconds = Math.floor((ms % 60000) / 1000);
44
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
45
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,18 @@
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
+ * Application entry point
7
+ */
8
+
9
+ import React from 'react';
10
+ import ReactDOM from 'react-dom/client';
11
+ import { App } from './App';
12
+ import './index.css';
13
+
14
+ ReactDOM.createRoot(document.getElementById('root')!).render(
15
+ <React.StrictMode>
16
+ <App />
17
+ </React.StrictMode>
18
+ );
package/src/store.ts ADDED
@@ -0,0 +1,471 @@
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
+ * Zustand store for viewer state
7
+ */
8
+
9
+ import { create } from 'zustand';
10
+ import type { IfcDataStore } from '@ifc-lite/parser';
11
+ import type { GeometryResult, CoordinateInfo } from '@ifc-lite/geometry';
12
+
13
+ // Measurement types
14
+ export interface MeasurePoint {
15
+ x: number;
16
+ y: number;
17
+ z: number;
18
+ screenX: number;
19
+ screenY: number;
20
+ }
21
+
22
+ export interface Measurement {
23
+ id: string;
24
+ start: MeasurePoint;
25
+ end: MeasurePoint;
26
+ distance: number;
27
+ }
28
+
29
+ // Section plane types
30
+ export interface SectionPlane {
31
+ axis: 'x' | 'y' | 'z';
32
+ position: number; // 0-100 percentage of model bounds
33
+ enabled: boolean;
34
+ }
35
+
36
+ // Hover state
37
+ export interface HoverState {
38
+ entityId: number | null;
39
+ screenX: number;
40
+ screenY: number;
41
+ }
42
+
43
+ // Context menu state
44
+ export interface ContextMenuState {
45
+ isOpen: boolean;
46
+ entityId: number | null;
47
+ screenX: number;
48
+ screenY: number;
49
+ }
50
+
51
+ // Box selection state
52
+ export interface BoxSelectState {
53
+ isSelecting: boolean;
54
+ startX: number;
55
+ startY: number;
56
+ currentX: number;
57
+ currentY: number;
58
+ }
59
+
60
+ interface ViewerState {
61
+ // Loading state
62
+ loading: boolean;
63
+ progress: { phase: string; percent: number } | null;
64
+ error: string | null;
65
+
66
+ // Data
67
+ ifcDataStore: IfcDataStore | null;
68
+ geometryResult: GeometryResult | null;
69
+
70
+ // Selection
71
+ selectedEntityId: number | null;
72
+ selectedEntityIds: Set<number>; // Multi-selection support
73
+ selectedStorey: number | null;
74
+
75
+ // Visibility
76
+ hiddenEntities: Set<number>;
77
+ isolatedEntities: Set<number> | null; // null = show all, Set = only show these
78
+
79
+ // UI State
80
+ leftPanelCollapsed: boolean;
81
+ rightPanelCollapsed: boolean;
82
+ activeTool: string;
83
+ theme: 'light' | 'dark';
84
+ isMobile: boolean;
85
+ hoverTooltipsEnabled: boolean;
86
+
87
+ // Hover state
88
+ hoverState: HoverState;
89
+
90
+ // Context menu state
91
+ contextMenu: ContextMenuState;
92
+
93
+ // Box selection state
94
+ boxSelect: BoxSelectState;
95
+
96
+ // Measurement state
97
+ measurements: Measurement[];
98
+ pendingMeasurePoint: MeasurePoint | null;
99
+
100
+ // Section plane state
101
+ sectionPlane: SectionPlane;
102
+
103
+ // Camera state (for ViewCube sync)
104
+ cameraRotation: { azimuth: number; elevation: number };
105
+ cameraCallbacks: {
106
+ setPresetView?: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => void;
107
+ fitAll?: () => void;
108
+ home?: () => void; // Reset to isometric view
109
+ zoomIn?: () => void;
110
+ zoomOut?: () => void;
111
+ frameSelection?: () => void; // Center view on selected element (F key)
112
+ orbit?: (deltaX: number, deltaY: number) => void; // Orbit camera by delta
113
+ };
114
+ // Direct callback for real-time ViewCube updates (bypasses React state)
115
+ onCameraRotationChange: ((rotation: { azimuth: number; elevation: number }) => void) | null;
116
+ // Direct callback for real-time scale bar updates (bypasses React state)
117
+ onScaleChange: ((scale: number) => void) | null;
118
+
119
+ // Actions
120
+ setLoading: (loading: boolean) => void;
121
+ setProgress: (progress: { phase: string; percent: number } | null) => void;
122
+ setError: (error: string | null) => void;
123
+ setIfcDataStore: (result: IfcDataStore | null) => void;
124
+ setGeometryResult: (result: GeometryResult | null) => void;
125
+ appendGeometryBatch: (meshes: GeometryResult['meshes'], coordinateInfo?: CoordinateInfo) => void;
126
+ updateCoordinateInfo: (coordinateInfo: CoordinateInfo) => void;
127
+ setSelectedEntityId: (id: number | null) => void;
128
+ setSelectedStorey: (id: number | null) => void;
129
+ setLeftPanelCollapsed: (collapsed: boolean) => void;
130
+ setRightPanelCollapsed: (collapsed: boolean) => void;
131
+ setActiveTool: (tool: string) => void;
132
+ setTheme: (theme: 'light' | 'dark') => void;
133
+ toggleTheme: () => void;
134
+ setIsMobile: (isMobile: boolean) => void;
135
+ toggleHoverTooltips: () => void;
136
+
137
+ // Camera actions
138
+ setCameraRotation: (rotation: { azimuth: number; elevation: number }) => void;
139
+ setCameraCallbacks: (callbacks: ViewerState['cameraCallbacks']) => void;
140
+ setOnCameraRotationChange: (callback: ((rotation: { azimuth: number; elevation: number }) => void) | null) => void;
141
+ // Call this for real-time updates (uses callback if available, skips state)
142
+ updateCameraRotationRealtime: (rotation: { azimuth: number; elevation: number }) => void;
143
+ setOnScaleChange: (callback: ((scale: number) => void) | null) => void;
144
+ updateScaleRealtime: (scale: number) => void;
145
+
146
+ // Visibility actions
147
+ hideEntity: (id: number) => void;
148
+ hideEntities: (ids: number[]) => void;
149
+ showEntity: (id: number) => void;
150
+ showEntities: (ids: number[]) => void;
151
+ toggleEntityVisibility: (id: number) => void;
152
+ isolateEntity: (id: number) => void;
153
+ isolateEntities: (ids: number[]) => void;
154
+ clearIsolation: () => void;
155
+ showAll: () => void;
156
+ isEntityVisible: (id: number) => boolean;
157
+
158
+ // Multi-selection actions
159
+ addToSelection: (id: number) => void;
160
+ removeFromSelection: (id: number) => void;
161
+ toggleSelection: (id: number) => void;
162
+ setSelectedEntityIds: (ids: number[]) => void;
163
+ clearSelection: () => void;
164
+
165
+ // Hover actions
166
+ setHoverState: (state: HoverState) => void;
167
+ clearHover: () => void;
168
+
169
+ // Context menu actions
170
+ openContextMenu: (entityId: number | null, screenX: number, screenY: number) => void;
171
+ closeContextMenu: () => void;
172
+
173
+ // Box selection actions
174
+ startBoxSelect: (startX: number, startY: number) => void;
175
+ updateBoxSelect: (currentX: number, currentY: number) => void;
176
+ endBoxSelect: () => void;
177
+ cancelBoxSelect: () => void;
178
+
179
+ // Measurement actions
180
+ addMeasurePoint: (point: MeasurePoint) => void;
181
+ completeMeasurement: (endPoint: MeasurePoint) => void;
182
+ deleteMeasurement: (id: string) => void;
183
+ clearMeasurements: () => void;
184
+
185
+ // Section plane actions
186
+ setSectionPlaneAxis: (axis: 'x' | 'y' | 'z') => void;
187
+ setSectionPlanePosition: (position: number) => void;
188
+ toggleSectionPlane: () => void;
189
+ flipSectionPlane: () => void;
190
+ resetSectionPlane: () => void;
191
+
192
+ // Reset all viewer state (called when loading new file)
193
+ resetViewerState: () => void;
194
+ }
195
+
196
+ export const useViewerStore = create<ViewerState>((set, get) => ({
197
+ loading: false,
198
+ progress: null,
199
+ error: null,
200
+ ifcDataStore: null,
201
+ geometryResult: null,
202
+ selectedEntityId: null,
203
+ selectedEntityIds: new Set(),
204
+ selectedStorey: null,
205
+ hiddenEntities: new Set(),
206
+ isolatedEntities: null,
207
+ leftPanelCollapsed: false,
208
+ rightPanelCollapsed: false,
209
+ activeTool: 'select',
210
+ theme: 'dark',
211
+ isMobile: false,
212
+ hoverTooltipsEnabled: false,
213
+ hoverState: { entityId: null, screenX: 0, screenY: 0 },
214
+ contextMenu: { isOpen: false, entityId: null, screenX: 0, screenY: 0 },
215
+ boxSelect: { isSelecting: false, startX: 0, startY: 0, currentX: 0, currentY: 0 },
216
+ measurements: [],
217
+ pendingMeasurePoint: null,
218
+ sectionPlane: { axis: 'y', position: 50, enabled: false },
219
+ cameraRotation: { azimuth: 45, elevation: 25 },
220
+ cameraCallbacks: {},
221
+ onCameraRotationChange: null,
222
+ onScaleChange: null,
223
+
224
+ setLoading: (loading) => set({ loading }),
225
+ setProgress: (progress) => set({ progress }),
226
+ setError: (error) => set({ error }),
227
+ setIfcDataStore: (ifcDataStore) => set({ ifcDataStore }),
228
+ setGeometryResult: (geometryResult) => set({ geometryResult }),
229
+ appendGeometryBatch: (meshes, coordinateInfo) => set((state) => {
230
+ if (!state.geometryResult) {
231
+ const totalTriangles = meshes.reduce((sum, m) => sum + (m.indices.length / 3), 0);
232
+ const totalVertices = meshes.reduce((sum, m) => sum + (m.positions.length / 3), 0);
233
+ return {
234
+ geometryResult: {
235
+ meshes,
236
+ totalTriangles,
237
+ totalVertices,
238
+ coordinateInfo: coordinateInfo || {
239
+ originShift: { x: 0, y: 0, z: 0 },
240
+ originalBounds: { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } },
241
+ shiftedBounds: { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } },
242
+ isGeoReferenced: false,
243
+ },
244
+ },
245
+ };
246
+ }
247
+ const allMeshes = [...state.geometryResult.meshes, ...meshes];
248
+ const totalTriangles = allMeshes.reduce((sum, m) => sum + (m.indices.length / 3), 0);
249
+ const totalVertices = allMeshes.reduce((sum, m) => sum + (m.positions.length / 3), 0);
250
+ return {
251
+ geometryResult: {
252
+ ...state.geometryResult,
253
+ meshes: allMeshes,
254
+ totalTriangles,
255
+ totalVertices,
256
+ coordinateInfo: coordinateInfo || state.geometryResult.coordinateInfo,
257
+ },
258
+ };
259
+ }),
260
+ updateCoordinateInfo: (coordinateInfo) => set((state) => {
261
+ if (!state.geometryResult) return {};
262
+ return {
263
+ geometryResult: {
264
+ ...state.geometryResult,
265
+ coordinateInfo,
266
+ },
267
+ };
268
+ }),
269
+ setSelectedEntityId: (selectedEntityId) => set({ selectedEntityId }),
270
+ setSelectedStorey: (selectedStorey) => set({ selectedStorey }),
271
+ setLeftPanelCollapsed: (leftPanelCollapsed) => set({ leftPanelCollapsed }),
272
+ setRightPanelCollapsed: (rightPanelCollapsed) => set({ rightPanelCollapsed }),
273
+ setActiveTool: (activeTool) => set({ activeTool }),
274
+ setTheme: (theme) => {
275
+ document.documentElement.classList.toggle('dark', theme === 'dark');
276
+ set({ theme });
277
+ },
278
+ toggleTheme: () => {
279
+ const newTheme = get().theme === 'dark' ? 'light' : 'dark';
280
+ document.documentElement.classList.toggle('dark', newTheme === 'dark');
281
+ set({ theme: newTheme });
282
+ },
283
+ setIsMobile: (isMobile) => set({ isMobile }),
284
+ toggleHoverTooltips: () => set((state) => ({ hoverTooltipsEnabled: !state.hoverTooltipsEnabled })),
285
+
286
+ // Camera actions
287
+ setCameraRotation: (cameraRotation) => set({ cameraRotation }),
288
+ setCameraCallbacks: (cameraCallbacks) => set({ cameraCallbacks }),
289
+ setOnCameraRotationChange: (onCameraRotationChange) => set({ onCameraRotationChange }),
290
+ updateCameraRotationRealtime: (rotation) => {
291
+ const callback = get().onCameraRotationChange;
292
+ if (callback) {
293
+ // Use direct callback - no React state update, no re-renders
294
+ callback(rotation);
295
+ }
296
+ // Don't update store state during real-time updates
297
+ },
298
+ setOnScaleChange: (onScaleChange) => set({ onScaleChange }),
299
+ updateScaleRealtime: (scale) => {
300
+ const callback = get().onScaleChange;
301
+ if (callback) {
302
+ // Use direct callback - no React state update, no re-renders
303
+ callback(scale);
304
+ }
305
+ // Don't update store state during real-time updates
306
+ },
307
+
308
+ // Visibility actions
309
+ hideEntity: (id) => set((state) => {
310
+ const newHidden = new Set(state.hiddenEntities);
311
+ newHidden.add(id);
312
+ return { hiddenEntities: newHidden };
313
+ }),
314
+ hideEntities: (ids) => set((state) => {
315
+ const newHidden = new Set(state.hiddenEntities);
316
+ ids.forEach(id => newHidden.add(id));
317
+ return { hiddenEntities: newHidden };
318
+ }),
319
+ showEntity: (id) => set((state) => {
320
+ const newHidden = new Set(state.hiddenEntities);
321
+ newHidden.delete(id);
322
+ return { hiddenEntities: newHidden };
323
+ }),
324
+ showEntities: (ids) => set((state) => {
325
+ const newHidden = new Set(state.hiddenEntities);
326
+ ids.forEach(id => newHidden.delete(id));
327
+ return { hiddenEntities: newHidden };
328
+ }),
329
+ toggleEntityVisibility: (id) => set((state) => {
330
+ const newHidden = new Set(state.hiddenEntities);
331
+ if (newHidden.has(id)) {
332
+ newHidden.delete(id);
333
+ } else {
334
+ newHidden.add(id);
335
+ }
336
+ return { hiddenEntities: newHidden };
337
+ }),
338
+ isolateEntity: (id) => set({ isolatedEntities: new Set([id]) }),
339
+ isolateEntities: (ids) => set({ isolatedEntities: new Set(ids) }),
340
+ clearIsolation: () => set({ isolatedEntities: null }),
341
+ showAll: () => set({ hiddenEntities: new Set(), isolatedEntities: null }),
342
+ isEntityVisible: (id) => {
343
+ const state = get();
344
+ if (state.hiddenEntities.has(id)) return false;
345
+ if (state.isolatedEntities !== null && !state.isolatedEntities.has(id)) return false;
346
+ return true;
347
+ },
348
+
349
+ // Multi-selection actions
350
+ addToSelection: (id) => set((state) => {
351
+ const newSelection = new Set(state.selectedEntityIds);
352
+ newSelection.add(id);
353
+ return { selectedEntityIds: newSelection, selectedEntityId: id };
354
+ }),
355
+ removeFromSelection: (id) => set((state) => {
356
+ const newSelection = new Set(state.selectedEntityIds);
357
+ newSelection.delete(id);
358
+ const remaining = Array.from(newSelection);
359
+ return {
360
+ selectedEntityIds: newSelection,
361
+ selectedEntityId: remaining.length > 0 ? remaining[remaining.length - 1] : null,
362
+ };
363
+ }),
364
+ toggleSelection: (id) => set((state) => {
365
+ const newSelection = new Set(state.selectedEntityIds);
366
+ if (newSelection.has(id)) {
367
+ newSelection.delete(id);
368
+ } else {
369
+ newSelection.add(id);
370
+ }
371
+ const remaining = Array.from(newSelection);
372
+ return {
373
+ selectedEntityIds: newSelection,
374
+ selectedEntityId: remaining.length > 0 ? remaining[remaining.length - 1] : null,
375
+ };
376
+ }),
377
+ setSelectedEntityIds: (ids) => set({
378
+ selectedEntityIds: new Set(ids),
379
+ selectedEntityId: ids.length > 0 ? ids[ids.length - 1] : null,
380
+ }),
381
+ clearSelection: () => set({
382
+ selectedEntityIds: new Set(),
383
+ selectedEntityId: null,
384
+ }),
385
+
386
+ // Hover actions
387
+ setHoverState: (hoverState) => set({ hoverState }),
388
+ clearHover: () => set({ hoverState: { entityId: null, screenX: 0, screenY: 0 } }),
389
+
390
+ // Context menu actions
391
+ openContextMenu: (entityId, screenX, screenY) => set({
392
+ contextMenu: { isOpen: true, entityId, screenX, screenY },
393
+ }),
394
+ closeContextMenu: () => set({
395
+ contextMenu: { isOpen: false, entityId: null, screenX: 0, screenY: 0 },
396
+ }),
397
+
398
+ // Box selection actions
399
+ startBoxSelect: (startX, startY) => set({
400
+ boxSelect: { isSelecting: true, startX, startY, currentX: startX, currentY: startY },
401
+ }),
402
+ updateBoxSelect: (currentX, currentY) => set((state) => ({
403
+ boxSelect: { ...state.boxSelect, currentX, currentY },
404
+ })),
405
+ endBoxSelect: () => set({
406
+ boxSelect: { isSelecting: false, startX: 0, startY: 0, currentX: 0, currentY: 0 },
407
+ }),
408
+ cancelBoxSelect: () => set({
409
+ boxSelect: { isSelecting: false, startX: 0, startY: 0, currentX: 0, currentY: 0 },
410
+ }),
411
+
412
+ // Measurement actions
413
+ addMeasurePoint: (point) => set({ pendingMeasurePoint: point }),
414
+ completeMeasurement: (endPoint) => set((state) => {
415
+ if (!state.pendingMeasurePoint) return {};
416
+ const start = state.pendingMeasurePoint;
417
+ const distance = Math.sqrt(
418
+ Math.pow(endPoint.x - start.x, 2) +
419
+ Math.pow(endPoint.y - start.y, 2) +
420
+ Math.pow(endPoint.z - start.z, 2)
421
+ );
422
+ const measurement: Measurement = {
423
+ id: `m-${Date.now()}`,
424
+ start,
425
+ end: endPoint,
426
+ distance,
427
+ };
428
+ return {
429
+ measurements: [...state.measurements, measurement],
430
+ pendingMeasurePoint: null,
431
+ };
432
+ }),
433
+ deleteMeasurement: (id) => set((state) => ({
434
+ measurements: state.measurements.filter((m) => m.id !== id),
435
+ })),
436
+ clearMeasurements: () => set({ measurements: [], pendingMeasurePoint: null }),
437
+
438
+ // Section plane actions
439
+ setSectionPlaneAxis: (axis) => set((state) => ({
440
+ sectionPlane: { ...state.sectionPlane, axis },
441
+ })),
442
+ setSectionPlanePosition: (position) => set((state) => ({
443
+ sectionPlane: { ...state.sectionPlane, position },
444
+ })),
445
+ toggleSectionPlane: () => set((state) => ({
446
+ sectionPlane: { ...state.sectionPlane, enabled: !state.sectionPlane.enabled },
447
+ })),
448
+ flipSectionPlane: () => set((state) => ({
449
+ sectionPlane: { ...state.sectionPlane, position: 100 - state.sectionPlane.position },
450
+ })),
451
+ resetSectionPlane: () => set({
452
+ sectionPlane: { axis: 'y', position: 50, enabled: false },
453
+ }),
454
+
455
+ // Reset all viewer state when loading new file
456
+ resetViewerState: () => set({
457
+ selectedEntityId: null,
458
+ selectedEntityIds: new Set(),
459
+ selectedStorey: null,
460
+ hiddenEntities: new Set(),
461
+ isolatedEntities: null,
462
+ hoverState: { entityId: null, screenX: 0, screenY: 0 },
463
+ contextMenu: { isOpen: false, entityId: null, screenX: 0, screenY: 0 },
464
+ boxSelect: { isSelecting: false, startX: 0, startY: 0, currentX: 0, currentY: 0 },
465
+ measurements: [],
466
+ pendingMeasurePoint: null,
467
+ sectionPlane: { axis: 'y', position: 50, enabled: false },
468
+ cameraRotation: { azimuth: 45, elevation: 25 },
469
+ activeTool: 'select',
470
+ }),
471
+ }));