@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
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
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
}));
|