@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,1136 @@
|
|
|
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
|
+
* 3D viewport component
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { Renderer, MathUtils } from '@ifc-lite/renderer';
|
|
11
|
+
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
12
|
+
import { useViewerStore, type MeasurePoint } from '@/store';
|
|
13
|
+
|
|
14
|
+
interface ViewportProps {
|
|
15
|
+
geometry: MeshData[] | null;
|
|
16
|
+
coordinateInfo?: CoordinateInfo;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
20
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
21
|
+
const rendererRef = useRef<Renderer | null>(null);
|
|
22
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
23
|
+
const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
|
|
24
|
+
const setSelectedEntityId = useViewerStore((state) => state.setSelectedEntityId);
|
|
25
|
+
const hiddenEntities = useViewerStore((state) => state.hiddenEntities);
|
|
26
|
+
const isolatedEntities = useViewerStore((state) => state.isolatedEntities);
|
|
27
|
+
const activeTool = useViewerStore((state) => state.activeTool);
|
|
28
|
+
const updateCameraRotationRealtime = useViewerStore((state) => state.updateCameraRotationRealtime);
|
|
29
|
+
const updateScaleRealtime = useViewerStore((state) => state.updateScaleRealtime);
|
|
30
|
+
const setCameraCallbacks = useViewerStore((state) => state.setCameraCallbacks);
|
|
31
|
+
const theme = useViewerStore((state) => state.theme);
|
|
32
|
+
|
|
33
|
+
// New store subscriptions for enhanced features
|
|
34
|
+
const setHoverState = useViewerStore((state) => state.setHoverState);
|
|
35
|
+
const clearHover = useViewerStore((state) => state.clearHover);
|
|
36
|
+
const hoverTooltipsEnabled = useViewerStore((state) => state.hoverTooltipsEnabled);
|
|
37
|
+
const openContextMenu = useViewerStore((state) => state.openContextMenu);
|
|
38
|
+
const startBoxSelect = useViewerStore((state) => state.startBoxSelect);
|
|
39
|
+
const updateBoxSelect = useViewerStore((state) => state.updateBoxSelect);
|
|
40
|
+
const endBoxSelect = useViewerStore((state) => state.endBoxSelect);
|
|
41
|
+
const boxSelect = useViewerStore((state) => state.boxSelect);
|
|
42
|
+
const setSelectedEntityIds = useViewerStore((state) => state.setSelectedEntityIds);
|
|
43
|
+
const toggleSelection = useViewerStore((state) => state.toggleSelection);
|
|
44
|
+
const pendingMeasurePoint = useViewerStore((state) => state.pendingMeasurePoint);
|
|
45
|
+
const addMeasurePoint = useViewerStore((state) => state.addMeasurePoint);
|
|
46
|
+
const completeMeasurement = useViewerStore((state) => state.completeMeasurement);
|
|
47
|
+
const sectionPlane = useViewerStore((state) => state.sectionPlane);
|
|
48
|
+
|
|
49
|
+
// Theme-aware clear color ref (updated when theme changes)
|
|
50
|
+
const clearColorRef = useRef<[number, number, number, number]>([0.1, 0.1, 0.1, 1]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
// Update clear color when theme changes
|
|
54
|
+
if (theme === 'light') {
|
|
55
|
+
clearColorRef.current = [0.95, 0.95, 0.95, 1]; // Light gray/white for light mode
|
|
56
|
+
} else {
|
|
57
|
+
clearColorRef.current = [0.1, 0.1, 0.1, 1]; // Dark gray for dark mode
|
|
58
|
+
}
|
|
59
|
+
// Re-render with new clear color
|
|
60
|
+
const renderer = rendererRef.current;
|
|
61
|
+
if (renderer && isInitialized) {
|
|
62
|
+
renderer.render({
|
|
63
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
64
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
65
|
+
selectedId: selectedEntityIdRef.current,
|
|
66
|
+
clearColor: clearColorRef.current,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}, [theme, isInitialized]);
|
|
70
|
+
|
|
71
|
+
// Animation frame ref
|
|
72
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
73
|
+
const lastFrameTimeRef = useRef<number>(0);
|
|
74
|
+
|
|
75
|
+
// Mouse state
|
|
76
|
+
const mouseStateRef = useRef({
|
|
77
|
+
isDragging: false,
|
|
78
|
+
isPanning: false,
|
|
79
|
+
lastX: 0,
|
|
80
|
+
lastY: 0,
|
|
81
|
+
button: 0,
|
|
82
|
+
startX: 0, // Track start position for drag detection
|
|
83
|
+
startY: 0,
|
|
84
|
+
didDrag: false, // True if mouse moved significantly during drag
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Touch state
|
|
88
|
+
const touchStateRef = useRef({
|
|
89
|
+
touches: [] as Touch[],
|
|
90
|
+
lastDistance: 0,
|
|
91
|
+
lastCenter: { x: 0, y: 0 },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Double-click detection
|
|
95
|
+
const lastClickTimeRef = useRef<number>(0);
|
|
96
|
+
const lastClickPosRef = useRef<{ x: number; y: number } | null>(null);
|
|
97
|
+
|
|
98
|
+
// Keyboard handlers refs
|
|
99
|
+
const keyboardHandlersRef = useRef<{
|
|
100
|
+
handleKeyDown: ((e: KeyboardEvent) => void) | null;
|
|
101
|
+
handleKeyUp: ((e: KeyboardEvent) => void) | null;
|
|
102
|
+
}>({ handleKeyDown: null, handleKeyUp: null });
|
|
103
|
+
|
|
104
|
+
// First-person mode state
|
|
105
|
+
const firstPersonModeRef = useRef<boolean>(false);
|
|
106
|
+
|
|
107
|
+
// Geometry bounds for camera controls
|
|
108
|
+
const geometryBoundsRef = useRef<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>({
|
|
109
|
+
min: { x: -100, y: -100, z: -100 },
|
|
110
|
+
max: { x: 100, y: 100, z: 100 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Visibility state refs for animation loop
|
|
114
|
+
const hiddenEntitiesRef = useRef<Set<number>>(hiddenEntities);
|
|
115
|
+
const isolatedEntitiesRef = useRef<Set<number> | null>(isolatedEntities);
|
|
116
|
+
const selectedEntityIdRef = useRef<number | null>(selectedEntityId);
|
|
117
|
+
const activeToolRef = useRef<string>(activeTool);
|
|
118
|
+
const pendingMeasurePointRef = useRef<MeasurePoint | null>(pendingMeasurePoint);
|
|
119
|
+
const sectionPlaneRef = useRef(sectionPlane);
|
|
120
|
+
const boxSelectRef = useRef(boxSelect);
|
|
121
|
+
const geometryRef = useRef<MeshData[] | null>(geometry);
|
|
122
|
+
|
|
123
|
+
// Hover throttling
|
|
124
|
+
const lastHoverCheckRef = useRef<number>(0);
|
|
125
|
+
const hoverThrottleMs = 50; // Check hover every 50ms
|
|
126
|
+
const hoverTooltipsEnabledRef = useRef(hoverTooltipsEnabled);
|
|
127
|
+
|
|
128
|
+
// Keep refs in sync
|
|
129
|
+
useEffect(() => { hiddenEntitiesRef.current = hiddenEntities; }, [hiddenEntities]);
|
|
130
|
+
useEffect(() => { isolatedEntitiesRef.current = isolatedEntities; }, [isolatedEntities]);
|
|
131
|
+
useEffect(() => { selectedEntityIdRef.current = selectedEntityId; }, [selectedEntityId]);
|
|
132
|
+
useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]);
|
|
133
|
+
useEffect(() => { pendingMeasurePointRef.current = pendingMeasurePoint; }, [pendingMeasurePoint]);
|
|
134
|
+
useEffect(() => { sectionPlaneRef.current = sectionPlane; }, [sectionPlane]);
|
|
135
|
+
useEffect(() => { boxSelectRef.current = boxSelect; }, [boxSelect]);
|
|
136
|
+
useEffect(() => { geometryRef.current = geometry; }, [geometry]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
hoverTooltipsEnabledRef.current = hoverTooltipsEnabled;
|
|
139
|
+
if (!hoverTooltipsEnabled) {
|
|
140
|
+
// Clear hover state when disabled
|
|
141
|
+
clearHover();
|
|
142
|
+
}
|
|
143
|
+
}, [hoverTooltipsEnabled, clearHover]);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const canvas = canvasRef.current;
|
|
147
|
+
if (!canvas) return;
|
|
148
|
+
|
|
149
|
+
setIsInitialized(false);
|
|
150
|
+
|
|
151
|
+
let aborted = false;
|
|
152
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
153
|
+
|
|
154
|
+
const rect = canvas.getBoundingClientRect();
|
|
155
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
156
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
157
|
+
canvas.width = width;
|
|
158
|
+
canvas.height = height;
|
|
159
|
+
|
|
160
|
+
const renderer = new Renderer(canvas);
|
|
161
|
+
rendererRef.current = renderer;
|
|
162
|
+
|
|
163
|
+
renderer.init().then(() => {
|
|
164
|
+
if (aborted) return;
|
|
165
|
+
|
|
166
|
+
setIsInitialized(true);
|
|
167
|
+
|
|
168
|
+
const camera = renderer.getCamera();
|
|
169
|
+
const mouseState = mouseStateRef.current;
|
|
170
|
+
const touchState = touchStateRef.current;
|
|
171
|
+
|
|
172
|
+
// Helper function to get entity bounds (min/max) - defined early for callbacks
|
|
173
|
+
function getEntityBounds(
|
|
174
|
+
geom: MeshData[] | null,
|
|
175
|
+
entityId: number
|
|
176
|
+
): { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null {
|
|
177
|
+
if (!geom) {
|
|
178
|
+
console.warn('[Viewport] getEntityBounds: geometry is null');
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const mesh = geom.find(m => m.expressId === entityId);
|
|
182
|
+
if (!mesh) {
|
|
183
|
+
console.warn(`[Viewport] getEntityBounds: mesh not found for entityId ${entityId}`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (mesh.positions.length < 3) {
|
|
187
|
+
console.warn(`[Viewport] getEntityBounds: mesh has insufficient positions for entityId ${entityId}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
192
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < mesh.positions.length; i += 3) {
|
|
195
|
+
const x = mesh.positions[i];
|
|
196
|
+
const y = mesh.positions[i + 1];
|
|
197
|
+
const z = mesh.positions[i + 2];
|
|
198
|
+
minX = Math.min(minX, x);
|
|
199
|
+
minY = Math.min(minY, y);
|
|
200
|
+
minZ = Math.min(minZ, z);
|
|
201
|
+
maxX = Math.max(maxX, x);
|
|
202
|
+
maxY = Math.max(maxY, y);
|
|
203
|
+
maxZ = Math.max(maxZ, z);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
min: { x: minX, y: minY, z: minZ },
|
|
208
|
+
max: { x: maxX, y: maxY, z: maxZ },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Helper function to get entity center from geometry (uses bounding box center)
|
|
213
|
+
function getEntityCenter(
|
|
214
|
+
geom: MeshData[] | null,
|
|
215
|
+
entityId: number
|
|
216
|
+
): { x: number; y: number; z: number } | null {
|
|
217
|
+
const bounds = getEntityBounds(geom, entityId);
|
|
218
|
+
if (bounds) {
|
|
219
|
+
return {
|
|
220
|
+
x: (bounds.min.x + bounds.max.x) / 2,
|
|
221
|
+
y: (bounds.min.y + bounds.max.y) / 2,
|
|
222
|
+
z: (bounds.min.z + bounds.max.z) / 2,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Register camera callbacks for ViewCube and other controls
|
|
229
|
+
setCameraCallbacks({
|
|
230
|
+
setPresetView: (view) => {
|
|
231
|
+
// Pass actual geometry bounds to avoid distance drift
|
|
232
|
+
camera.setPresetView(view, geometryBoundsRef.current);
|
|
233
|
+
// Initial render - animation loop will continue rendering during animation
|
|
234
|
+
renderer.render({
|
|
235
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
236
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
237
|
+
selectedId: selectedEntityIdRef.current,
|
|
238
|
+
clearColor: clearColorRef.current,
|
|
239
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
240
|
+
});
|
|
241
|
+
calculateScale();
|
|
242
|
+
},
|
|
243
|
+
fitAll: () => {
|
|
244
|
+
// Zoom to fit without changing view direction
|
|
245
|
+
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
246
|
+
calculateScale();
|
|
247
|
+
},
|
|
248
|
+
home: () => {
|
|
249
|
+
// Reset to isometric view
|
|
250
|
+
camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
|
|
251
|
+
calculateScale();
|
|
252
|
+
},
|
|
253
|
+
zoomIn: () => {
|
|
254
|
+
camera.zoom(-50, false);
|
|
255
|
+
renderer.render({
|
|
256
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
257
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
258
|
+
selectedId: selectedEntityIdRef.current,
|
|
259
|
+
clearColor: clearColorRef.current,
|
|
260
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
261
|
+
});
|
|
262
|
+
calculateScale();
|
|
263
|
+
},
|
|
264
|
+
zoomOut: () => {
|
|
265
|
+
camera.zoom(50, false);
|
|
266
|
+
renderer.render({
|
|
267
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
268
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
269
|
+
selectedId: selectedEntityIdRef.current,
|
|
270
|
+
clearColor: clearColorRef.current,
|
|
271
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
272
|
+
});
|
|
273
|
+
calculateScale();
|
|
274
|
+
},
|
|
275
|
+
frameSelection: () => {
|
|
276
|
+
// Frame selection - zoom to fit selected element
|
|
277
|
+
const selectedId = selectedEntityIdRef.current;
|
|
278
|
+
const geom = geometryRef.current;
|
|
279
|
+
if (selectedId !== null && geom) {
|
|
280
|
+
const bounds = getEntityBounds(geom, selectedId);
|
|
281
|
+
if (bounds) {
|
|
282
|
+
camera.frameBounds(bounds.min, bounds.max, 300);
|
|
283
|
+
calculateScale();
|
|
284
|
+
} else {
|
|
285
|
+
console.warn('[Viewport] frameSelection: Could not get bounds for selected element');
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
console.warn('[Viewport] frameSelection: No selection or geometry');
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
orbit: (deltaX: number, deltaY: number) => {
|
|
292
|
+
// Orbit camera from ViewCube drag
|
|
293
|
+
camera.orbit(deltaX, deltaY, false);
|
|
294
|
+
renderer.render({
|
|
295
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
296
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
297
|
+
selectedId: selectedEntityIdRef.current,
|
|
298
|
+
clearColor: clearColorRef.current,
|
|
299
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
300
|
+
});
|
|
301
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
302
|
+
calculateScale();
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Calculate scale bar value (world-space size for 96px scale bar)
|
|
307
|
+
const calculateScale = () => {
|
|
308
|
+
const canvas = canvasRef.current;
|
|
309
|
+
if (!canvas) return;
|
|
310
|
+
|
|
311
|
+
const viewportHeight = canvas.height;
|
|
312
|
+
const distance = camera.getDistance();
|
|
313
|
+
const fov = camera.getFOV();
|
|
314
|
+
const scaleBarPixels = 96; // w-24 = 6rem = 96px
|
|
315
|
+
|
|
316
|
+
// Calculate world-space size: (screen pixels / viewport height) * (distance * tan(FOV/2) * 2)
|
|
317
|
+
const worldSize = (scaleBarPixels / viewportHeight) * (distance * Math.tan(fov / 2) * 2);
|
|
318
|
+
updateScaleRealtime(worldSize);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Animation loop - update ViewCube in real-time
|
|
322
|
+
let lastRotationUpdate = 0;
|
|
323
|
+
let lastScaleUpdate = 0;
|
|
324
|
+
const animate = (currentTime: number) => {
|
|
325
|
+
if (aborted) return;
|
|
326
|
+
|
|
327
|
+
const deltaTime = currentTime - lastFrameTimeRef.current;
|
|
328
|
+
lastFrameTimeRef.current = currentTime;
|
|
329
|
+
|
|
330
|
+
const isAnimating = camera.update(deltaTime);
|
|
331
|
+
if (isAnimating) {
|
|
332
|
+
renderer.render({
|
|
333
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
334
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
335
|
+
selectedId: selectedEntityIdRef.current,
|
|
336
|
+
clearColor: clearColorRef.current,
|
|
337
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
338
|
+
});
|
|
339
|
+
// Update ViewCube during camera animation (e.g., preset view transitions)
|
|
340
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
341
|
+
calculateScale();
|
|
342
|
+
} else if (!mouseState.isDragging && currentTime - lastRotationUpdate > 100) {
|
|
343
|
+
// Update camera rotation for ViewCube when not dragging (throttled)
|
|
344
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
345
|
+
lastRotationUpdate = currentTime;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Update scale bar (throttled to every 100ms)
|
|
349
|
+
if (currentTime - lastScaleUpdate > 100) {
|
|
350
|
+
calculateScale();
|
|
351
|
+
lastScaleUpdate = currentTime;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
355
|
+
};
|
|
356
|
+
lastFrameTimeRef.current = performance.now();
|
|
357
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
358
|
+
|
|
359
|
+
// Mouse controls - respect active tool
|
|
360
|
+
canvas.addEventListener('mousedown', async (e) => {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
mouseState.isDragging = true;
|
|
363
|
+
mouseState.button = e.button;
|
|
364
|
+
mouseState.lastX = e.clientX;
|
|
365
|
+
mouseState.lastY = e.clientY;
|
|
366
|
+
mouseState.startX = e.clientX;
|
|
367
|
+
mouseState.startY = e.clientY;
|
|
368
|
+
mouseState.didDrag = false;
|
|
369
|
+
|
|
370
|
+
// Determine action based on active tool and mouse button
|
|
371
|
+
const tool = activeToolRef.current;
|
|
372
|
+
|
|
373
|
+
// Box selection tool
|
|
374
|
+
if (tool === 'boxselect' && e.button === 0) {
|
|
375
|
+
startBoxSelect(e.clientX, e.clientY);
|
|
376
|
+
canvas.style.cursor = 'crosshair';
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const willOrbit = !(tool === 'pan' || e.button === 1 || e.button === 2 ||
|
|
381
|
+
(tool === 'select' && e.shiftKey) ||
|
|
382
|
+
(tool !== 'orbit' && tool !== 'select' && e.shiftKey));
|
|
383
|
+
|
|
384
|
+
// Set orbit pivot to what user clicks on (standard CAD/BIM behavior)
|
|
385
|
+
// Simple and predictable: orbit around clicked geometry, or model center if empty space
|
|
386
|
+
if (willOrbit && tool !== 'measure' && tool !== 'walk') {
|
|
387
|
+
const rect = canvas.getBoundingClientRect();
|
|
388
|
+
const x = e.clientX - rect.left;
|
|
389
|
+
const y = e.clientY - rect.top;
|
|
390
|
+
|
|
391
|
+
// Pick at cursor position - orbit around what user is clicking on
|
|
392
|
+
const pickedId = await renderer.pick(x, y);
|
|
393
|
+
if (pickedId !== null) {
|
|
394
|
+
const center = getEntityCenter(geometryRef.current, pickedId);
|
|
395
|
+
if (center) {
|
|
396
|
+
camera.setOrbitPivot(center);
|
|
397
|
+
} else {
|
|
398
|
+
camera.setOrbitPivot(null);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
// No geometry under cursor - orbit around current target (model center)
|
|
402
|
+
camera.setOrbitPivot(null);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (tool === 'pan' || e.button === 1 || e.button === 2) {
|
|
407
|
+
mouseState.isPanning = true;
|
|
408
|
+
canvas.style.cursor = 'move';
|
|
409
|
+
} else if (tool === 'orbit') {
|
|
410
|
+
mouseState.isPanning = false;
|
|
411
|
+
canvas.style.cursor = 'grabbing';
|
|
412
|
+
} else if (tool === 'select') {
|
|
413
|
+
// Select tool: shift+drag = pan, normal drag = orbit
|
|
414
|
+
mouseState.isPanning = e.shiftKey;
|
|
415
|
+
canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
|
|
416
|
+
} else if (tool === 'measure') {
|
|
417
|
+
// Measure tool - cursor indicates measurement mode
|
|
418
|
+
canvas.style.cursor = 'crosshair';
|
|
419
|
+
} else {
|
|
420
|
+
// Default behavior
|
|
421
|
+
mouseState.isPanning = e.shiftKey;
|
|
422
|
+
canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
canvas.addEventListener('mousemove', async (e) => {
|
|
427
|
+
const rect = canvas.getBoundingClientRect();
|
|
428
|
+
const x = e.clientX - rect.left;
|
|
429
|
+
const y = e.clientY - rect.top;
|
|
430
|
+
|
|
431
|
+
if (mouseState.isDragging) {
|
|
432
|
+
const dx = e.clientX - mouseState.lastX;
|
|
433
|
+
const dy = e.clientY - mouseState.lastY;
|
|
434
|
+
const tool = activeToolRef.current;
|
|
435
|
+
|
|
436
|
+
// Check if this counts as a drag (moved more than 5px from start)
|
|
437
|
+
const totalDx = e.clientX - mouseState.startX;
|
|
438
|
+
const totalDy = e.clientY - mouseState.startY;
|
|
439
|
+
if (Math.abs(totalDx) > 5 || Math.abs(totalDy) > 5) {
|
|
440
|
+
mouseState.didDrag = true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Handle box selection
|
|
444
|
+
if (tool === 'boxselect' && mouseState.button === 0) {
|
|
445
|
+
updateBoxSelect(e.clientX, e.clientY);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (mouseState.isPanning || tool === 'pan') {
|
|
450
|
+
camera.pan(dx, dy, false);
|
|
451
|
+
} else if (tool === 'walk') {
|
|
452
|
+
// Walk mode: left/right rotates, up/down moves forward/backward
|
|
453
|
+
camera.orbit(dx * 0.5, 0, false); // Only horizontal rotation
|
|
454
|
+
if (Math.abs(dy) > 2) {
|
|
455
|
+
camera.zoom(dy * 2, false); // Forward/backward movement
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
camera.orbit(dx, dy, false);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
mouseState.lastX = e.clientX;
|
|
462
|
+
mouseState.lastY = e.clientY;
|
|
463
|
+
renderer.render({
|
|
464
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
465
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
466
|
+
selectedId: selectedEntityIdRef.current,
|
|
467
|
+
clearColor: clearColorRef.current,
|
|
468
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
469
|
+
});
|
|
470
|
+
// Update ViewCube rotation in real-time during drag
|
|
471
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
472
|
+
calculateScale();
|
|
473
|
+
// Clear hover while dragging
|
|
474
|
+
clearHover();
|
|
475
|
+
} else if (hoverTooltipsEnabledRef.current) {
|
|
476
|
+
// Hover detection (throttled) - only if tooltips are enabled
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
if (now - lastHoverCheckRef.current > hoverThrottleMs) {
|
|
479
|
+
lastHoverCheckRef.current = now;
|
|
480
|
+
const pickedId = await renderer.pick(x, y);
|
|
481
|
+
if (pickedId) {
|
|
482
|
+
setHoverState({ entityId: pickedId, screenX: e.clientX, screenY: e.clientY });
|
|
483
|
+
} else {
|
|
484
|
+
clearHover();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
canvas.addEventListener('mouseup', () => {
|
|
491
|
+
mouseState.isDragging = false;
|
|
492
|
+
mouseState.isPanning = false;
|
|
493
|
+
const tool = activeToolRef.current;
|
|
494
|
+
canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : 'default');
|
|
495
|
+
// Clear orbit pivot after each orbit operation
|
|
496
|
+
camera.setOrbitPivot(null);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
canvas.addEventListener('mouseleave', () => {
|
|
500
|
+
mouseState.isDragging = false;
|
|
501
|
+
mouseState.isPanning = false;
|
|
502
|
+
camera.stopInertia();
|
|
503
|
+
camera.setOrbitPivot(null);
|
|
504
|
+
canvas.style.cursor = 'default';
|
|
505
|
+
clearHover();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
canvas.addEventListener('contextmenu', async (e) => {
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
const rect = canvas.getBoundingClientRect();
|
|
511
|
+
const x = e.clientX - rect.left;
|
|
512
|
+
const y = e.clientY - rect.top;
|
|
513
|
+
const pickedId = await renderer.pick(x, y);
|
|
514
|
+
openContextMenu(pickedId, e.clientX, e.clientY);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
canvas.addEventListener('wheel', (e) => {
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
const rect = canvas.getBoundingClientRect();
|
|
520
|
+
const mouseX = e.clientX - rect.left;
|
|
521
|
+
const mouseY = e.clientY - rect.top;
|
|
522
|
+
camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
|
|
523
|
+
renderer.render({
|
|
524
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
525
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
526
|
+
selectedId: selectedEntityIdRef.current,
|
|
527
|
+
clearColor: clearColorRef.current,
|
|
528
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Click handling
|
|
533
|
+
canvas.addEventListener('click', async (e) => {
|
|
534
|
+
const rect = canvas.getBoundingClientRect();
|
|
535
|
+
const x = e.clientX - rect.left;
|
|
536
|
+
const y = e.clientY - rect.top;
|
|
537
|
+
const tool = activeToolRef.current;
|
|
538
|
+
|
|
539
|
+
// Skip selection if user was dragging (orbiting/panning)
|
|
540
|
+
if (mouseState.didDrag) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Skip selection for orbit/pan tools - they don't select
|
|
545
|
+
if (tool === 'orbit' || tool === 'pan' || tool === 'walk') {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Handle measure tool clicks
|
|
550
|
+
if (tool === 'measure') {
|
|
551
|
+
const pickedId = await renderer.pick(x, y);
|
|
552
|
+
if (pickedId) {
|
|
553
|
+
// Get 3D position from mesh vertices (simplified - uses center of clicked entity)
|
|
554
|
+
// In a full implementation, you'd use ray-triangle intersection
|
|
555
|
+
const worldPos = getApproximateWorldPosition(geometryRef.current, pickedId, x, y, canvas.width, canvas.height);
|
|
556
|
+
const measurePoint: MeasurePoint = {
|
|
557
|
+
x: worldPos.x,
|
|
558
|
+
y: worldPos.y,
|
|
559
|
+
z: worldPos.z,
|
|
560
|
+
screenX: e.clientX,
|
|
561
|
+
screenY: e.clientY,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
if (pendingMeasurePointRef.current) {
|
|
565
|
+
// Complete the measurement
|
|
566
|
+
completeMeasurement(measurePoint);
|
|
567
|
+
} else {
|
|
568
|
+
// Start a new measurement
|
|
569
|
+
addMeasurePoint(measurePoint);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Handle box selection completion
|
|
576
|
+
if (tool === 'boxselect') {
|
|
577
|
+
// Get box selection coordinates (in screen space relative to viewport)
|
|
578
|
+
const bs = boxSelectRef.current;
|
|
579
|
+
const geom = geometryRef.current;
|
|
580
|
+
if (bs.isSelecting && geom) {
|
|
581
|
+
const selectionRect = {
|
|
582
|
+
left: Math.min(bs.startX, bs.currentX),
|
|
583
|
+
right: Math.max(bs.startX, bs.currentX),
|
|
584
|
+
top: Math.min(bs.startY, bs.currentY),
|
|
585
|
+
bottom: Math.max(bs.startY, bs.currentY),
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// Check if selection is large enough
|
|
589
|
+
const selectionWidth = selectionRect.right - selectionRect.left;
|
|
590
|
+
const selectionHeight = selectionRect.bottom - selectionRect.top;
|
|
591
|
+
|
|
592
|
+
if (selectionWidth > 5 && selectionHeight > 5) {
|
|
593
|
+
// Convert selection rect from viewport to canvas coordinates
|
|
594
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
595
|
+
const canvasLeft = selectionRect.left - canvasRect.left;
|
|
596
|
+
const canvasRight = selectionRect.right - canvasRect.left;
|
|
597
|
+
const canvasTop = selectionRect.top - canvasRect.top;
|
|
598
|
+
const canvasBottom = selectionRect.bottom - canvasRect.top;
|
|
599
|
+
|
|
600
|
+
// Find all entities whose center projects into the selection box
|
|
601
|
+
const selectedIds: number[] = [];
|
|
602
|
+
|
|
603
|
+
for (const mesh of geom) {
|
|
604
|
+
// Calculate mesh bounding box center
|
|
605
|
+
if (mesh.positions.length >= 3) {
|
|
606
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
607
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
608
|
+
|
|
609
|
+
for (let i = 0; i < mesh.positions.length; i += 3) {
|
|
610
|
+
const x = mesh.positions[i];
|
|
611
|
+
const y = mesh.positions[i + 1];
|
|
612
|
+
const z = mesh.positions[i + 2];
|
|
613
|
+
minX = Math.min(minX, x);
|
|
614
|
+
minY = Math.min(minY, y);
|
|
615
|
+
minZ = Math.min(minZ, z);
|
|
616
|
+
maxX = Math.max(maxX, x);
|
|
617
|
+
maxY = Math.max(maxY, y);
|
|
618
|
+
maxZ = Math.max(maxZ, z);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const center = {
|
|
622
|
+
x: (minX + maxX) / 2,
|
|
623
|
+
y: (minY + maxY) / 2,
|
|
624
|
+
z: (minZ + maxZ) / 2,
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// Project center to screen space
|
|
628
|
+
const screenPos = camera.projectToScreen(center, canvas.width, canvas.height);
|
|
629
|
+
|
|
630
|
+
if (screenPos) {
|
|
631
|
+
// Check if screen position is within selection box
|
|
632
|
+
if (screenPos.x >= canvasLeft && screenPos.x <= canvasRight &&
|
|
633
|
+
screenPos.y >= canvasTop && screenPos.y <= canvasBottom) {
|
|
634
|
+
selectedIds.push(mesh.expressId);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Select all found entities
|
|
641
|
+
if (selectedIds.length > 0) {
|
|
642
|
+
setSelectedEntityIds(selectedIds);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
endBoxSelect();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const now = Date.now();
|
|
651
|
+
const timeSinceLastClick = now - lastClickTimeRef.current;
|
|
652
|
+
const clickPos = { x, y };
|
|
653
|
+
|
|
654
|
+
if (lastClickPosRef.current &&
|
|
655
|
+
timeSinceLastClick < 300 &&
|
|
656
|
+
Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
|
|
657
|
+
Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
|
|
658
|
+
// Double-click - isolate element
|
|
659
|
+
const pickedId = await renderer.pick(x, y);
|
|
660
|
+
if (pickedId) {
|
|
661
|
+
setSelectedEntityId(pickedId);
|
|
662
|
+
}
|
|
663
|
+
lastClickTimeRef.current = 0;
|
|
664
|
+
lastClickPosRef.current = null;
|
|
665
|
+
} else {
|
|
666
|
+
// Single click
|
|
667
|
+
const pickedId = await renderer.pick(x, y);
|
|
668
|
+
|
|
669
|
+
// Multi-selection with Ctrl/Cmd
|
|
670
|
+
if (e.ctrlKey || e.metaKey) {
|
|
671
|
+
if (pickedId) {
|
|
672
|
+
toggleSelection(pickedId);
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
setSelectedEntityId(pickedId);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
lastClickTimeRef.current = now;
|
|
679
|
+
lastClickPosRef.current = clickPos;
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
// Helper function to get approximate world position (for measurement tool)
|
|
685
|
+
function getApproximateWorldPosition(
|
|
686
|
+
geom: MeshData[] | null,
|
|
687
|
+
entityId: number,
|
|
688
|
+
_screenX: number,
|
|
689
|
+
_screenY: number,
|
|
690
|
+
_canvasWidth: number,
|
|
691
|
+
_canvasHeight: number
|
|
692
|
+
): { x: number; y: number; z: number } {
|
|
693
|
+
return getEntityCenter(geom, entityId) || { x: 0, y: 0, z: 0 };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Touch controls
|
|
697
|
+
canvas.addEventListener('touchstart', async (e) => {
|
|
698
|
+
e.preventDefault();
|
|
699
|
+
touchState.touches = Array.from(e.touches);
|
|
700
|
+
|
|
701
|
+
if (touchState.touches.length === 1) {
|
|
702
|
+
touchState.lastCenter = {
|
|
703
|
+
x: touchState.touches[0].clientX,
|
|
704
|
+
y: touchState.touches[0].clientY,
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// Set orbit pivot to what user touches (same as mouse click behavior)
|
|
708
|
+
const rect = canvas.getBoundingClientRect();
|
|
709
|
+
const x = touchState.touches[0].clientX - rect.left;
|
|
710
|
+
const y = touchState.touches[0].clientY - rect.top;
|
|
711
|
+
|
|
712
|
+
const pickedId = await renderer.pick(x, y);
|
|
713
|
+
if (pickedId !== null) {
|
|
714
|
+
const center = getEntityCenter(geometryRef.current, pickedId);
|
|
715
|
+
if (center) {
|
|
716
|
+
camera.setOrbitPivot(center);
|
|
717
|
+
} else {
|
|
718
|
+
camera.setOrbitPivot(null);
|
|
719
|
+
}
|
|
720
|
+
} else {
|
|
721
|
+
camera.setOrbitPivot(null);
|
|
722
|
+
}
|
|
723
|
+
} else if (touchState.touches.length === 2) {
|
|
724
|
+
const dx = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
725
|
+
const dy = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
726
|
+
touchState.lastDistance = Math.sqrt(dx * dx + dy * dy);
|
|
727
|
+
touchState.lastCenter = {
|
|
728
|
+
x: (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2,
|
|
729
|
+
y: (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
canvas.addEventListener('touchmove', (e) => {
|
|
735
|
+
e.preventDefault();
|
|
736
|
+
touchState.touches = Array.from(e.touches);
|
|
737
|
+
|
|
738
|
+
if (touchState.touches.length === 1) {
|
|
739
|
+
const dx = touchState.touches[0].clientX - touchState.lastCenter.x;
|
|
740
|
+
const dy = touchState.touches[0].clientY - touchState.lastCenter.y;
|
|
741
|
+
camera.orbit(dx, dy, false);
|
|
742
|
+
touchState.lastCenter = {
|
|
743
|
+
x: touchState.touches[0].clientX,
|
|
744
|
+
y: touchState.touches[0].clientY,
|
|
745
|
+
};
|
|
746
|
+
renderer.render({
|
|
747
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
748
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
749
|
+
selectedId: selectedEntityIdRef.current,
|
|
750
|
+
clearColor: clearColorRef.current,
|
|
751
|
+
});
|
|
752
|
+
} else if (touchState.touches.length === 2) {
|
|
753
|
+
const dx1 = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
754
|
+
const dy1 = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
755
|
+
const distance = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
|
756
|
+
|
|
757
|
+
const centerX = (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2;
|
|
758
|
+
const centerY = (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2;
|
|
759
|
+
const panDx = centerX - touchState.lastCenter.x;
|
|
760
|
+
const panDy = centerY - touchState.lastCenter.y;
|
|
761
|
+
camera.pan(panDx, panDy, false);
|
|
762
|
+
|
|
763
|
+
const zoomDelta = distance - touchState.lastDistance;
|
|
764
|
+
const rect = canvas.getBoundingClientRect();
|
|
765
|
+
camera.zoom(zoomDelta * 10, false, centerX - rect.left, centerY - rect.top, canvas.width, canvas.height);
|
|
766
|
+
|
|
767
|
+
touchState.lastDistance = distance;
|
|
768
|
+
touchState.lastCenter = { x: centerX, y: centerY };
|
|
769
|
+
renderer.render({
|
|
770
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
771
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
772
|
+
selectedId: selectedEntityIdRef.current,
|
|
773
|
+
clearColor: clearColorRef.current,
|
|
774
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
canvas.addEventListener('touchend', (e) => {
|
|
780
|
+
e.preventDefault();
|
|
781
|
+
touchState.touches = Array.from(e.touches);
|
|
782
|
+
if (touchState.touches.length === 0) {
|
|
783
|
+
camera.stopInertia();
|
|
784
|
+
camera.setOrbitPivot(null);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// Keyboard controls
|
|
789
|
+
const keyState: { [key: string]: boolean } = {};
|
|
790
|
+
|
|
791
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
792
|
+
if (document.activeElement?.tagName === 'INPUT' ||
|
|
793
|
+
document.activeElement?.tagName === 'TEXTAREA') {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
keyState[e.key.toLowerCase()] = true;
|
|
798
|
+
|
|
799
|
+
// Preset views - set view and re-render
|
|
800
|
+
const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => {
|
|
801
|
+
camera.setPresetView(view, geometryBoundsRef.current);
|
|
802
|
+
renderer.render({
|
|
803
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
804
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
805
|
+
selectedId: selectedEntityIdRef.current,
|
|
806
|
+
clearColor: clearColorRef.current,
|
|
807
|
+
});
|
|
808
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
809
|
+
calculateScale();
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
if (e.key === '1') setViewAndRender('top');
|
|
813
|
+
if (e.key === '2') setViewAndRender('bottom');
|
|
814
|
+
if (e.key === '3') setViewAndRender('front');
|
|
815
|
+
if (e.key === '4') setViewAndRender('back');
|
|
816
|
+
if (e.key === '5') setViewAndRender('left');
|
|
817
|
+
if (e.key === '6') setViewAndRender('right');
|
|
818
|
+
|
|
819
|
+
// Frame selection (F) - zoom to fit selection, or fit all if nothing selected
|
|
820
|
+
if (e.key === 'f' || e.key === 'F') {
|
|
821
|
+
const selectedId = selectedEntityIdRef.current;
|
|
822
|
+
if (selectedId !== null) {
|
|
823
|
+
// Frame selection - zoom to fit selected element
|
|
824
|
+
const bounds = getEntityBounds(geometryRef.current, selectedId);
|
|
825
|
+
if (bounds) {
|
|
826
|
+
camera.frameBounds(bounds.min, bounds.max, 300);
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
// No selection - fit all
|
|
830
|
+
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
831
|
+
}
|
|
832
|
+
calculateScale();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Home view (H) - reset to isometric
|
|
836
|
+
if (e.key === 'h' || e.key === 'H') {
|
|
837
|
+
camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
|
|
838
|
+
calculateScale();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Fit all / Zoom extents (Z)
|
|
842
|
+
if (e.key === 'z' || e.key === 'Z') {
|
|
843
|
+
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
844
|
+
calculateScale();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Toggle first-person mode
|
|
848
|
+
if (e.key === 'c' || e.key === 'C') {
|
|
849
|
+
firstPersonModeRef.current = !firstPersonModeRef.current;
|
|
850
|
+
camera.enableFirstPersonMode(firstPersonModeRef.current);
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
855
|
+
keyState[e.key.toLowerCase()] = false;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
keyboardHandlersRef.current.handleKeyDown = handleKeyDown;
|
|
859
|
+
keyboardHandlersRef.current.handleKeyUp = handleKeyUp;
|
|
860
|
+
|
|
861
|
+
const keyboardMove = () => {
|
|
862
|
+
if (aborted) return;
|
|
863
|
+
|
|
864
|
+
let moved = false;
|
|
865
|
+
const panSpeed = 5;
|
|
866
|
+
const zoomSpeed = 0.1;
|
|
867
|
+
|
|
868
|
+
if (firstPersonModeRef.current) {
|
|
869
|
+
if (keyState['w'] || keyState['arrowup']) { camera.moveFirstPerson(1, 0, 0); moved = true; }
|
|
870
|
+
if (keyState['s'] || keyState['arrowdown']) { camera.moveFirstPerson(-1, 0, 0); moved = true; }
|
|
871
|
+
if (keyState['a'] || keyState['arrowleft']) { camera.moveFirstPerson(0, -1, 0); moved = true; }
|
|
872
|
+
if (keyState['d'] || keyState['arrowright']) { camera.moveFirstPerson(0, 1, 0); moved = true; }
|
|
873
|
+
if (keyState['q']) { camera.moveFirstPerson(0, 0, -1); moved = true; }
|
|
874
|
+
if (keyState['e']) { camera.moveFirstPerson(0, 0, 1); moved = true; }
|
|
875
|
+
} else {
|
|
876
|
+
if (keyState['w'] || keyState['arrowup']) { camera.pan(0, panSpeed, false); moved = true; }
|
|
877
|
+
if (keyState['s'] || keyState['arrowdown']) { camera.pan(0, -panSpeed, false); moved = true; }
|
|
878
|
+
if (keyState['a'] || keyState['arrowleft']) { camera.pan(-panSpeed, 0, false); moved = true; }
|
|
879
|
+
if (keyState['d'] || keyState['arrowright']) { camera.pan(panSpeed, 0, false); moved = true; }
|
|
880
|
+
if (keyState['q']) { camera.zoom(-zoomSpeed * 100, false); moved = true; }
|
|
881
|
+
if (keyState['e']) { camera.zoom(zoomSpeed * 100, false); moved = true; }
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (moved) {
|
|
885
|
+
renderer.render({
|
|
886
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
887
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
888
|
+
selectedId: selectedEntityIdRef.current,
|
|
889
|
+
clearColor: clearColorRef.current,
|
|
890
|
+
sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
requestAnimationFrame(keyboardMove);
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
897
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
898
|
+
keyboardMove();
|
|
899
|
+
|
|
900
|
+
resizeObserver = new ResizeObserver(() => {
|
|
901
|
+
if (aborted) return;
|
|
902
|
+
const rect = canvas.getBoundingClientRect();
|
|
903
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
904
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
905
|
+
renderer.resize(width, height);
|
|
906
|
+
renderer.render({
|
|
907
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
908
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
909
|
+
selectedId: selectedEntityIdRef.current,
|
|
910
|
+
clearColor: clearColorRef.current,
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
resizeObserver.observe(canvas);
|
|
914
|
+
|
|
915
|
+
renderer.render({
|
|
916
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
917
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
918
|
+
selectedId: selectedEntityIdRef.current,
|
|
919
|
+
clearColor: clearColorRef.current,
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
return () => {
|
|
924
|
+
aborted = true;
|
|
925
|
+
if (animationFrameRef.current !== null) {
|
|
926
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
927
|
+
}
|
|
928
|
+
if (resizeObserver) {
|
|
929
|
+
resizeObserver.disconnect();
|
|
930
|
+
}
|
|
931
|
+
if (keyboardHandlersRef.current.handleKeyDown) {
|
|
932
|
+
window.removeEventListener('keydown', keyboardHandlersRef.current.handleKeyDown);
|
|
933
|
+
}
|
|
934
|
+
if (keyboardHandlersRef.current.handleKeyUp) {
|
|
935
|
+
window.removeEventListener('keyup', keyboardHandlersRef.current.handleKeyUp);
|
|
936
|
+
}
|
|
937
|
+
setIsInitialized(false);
|
|
938
|
+
rendererRef.current = null;
|
|
939
|
+
};
|
|
940
|
+
// Note: selectedEntityId is intentionally NOT in dependencies
|
|
941
|
+
// The click handler captures setSelectedEntityId via closure
|
|
942
|
+
// Adding selectedEntityId would destroy/recreate the renderer on every selection change
|
|
943
|
+
}, [setSelectedEntityId]);
|
|
944
|
+
|
|
945
|
+
// Track processed meshes for incremental updates
|
|
946
|
+
const processedMeshIdsRef = useRef<Set<number>>(new Set());
|
|
947
|
+
const lastGeometryLengthRef = useRef<number>(0);
|
|
948
|
+
const lastGeometryRef = useRef<MeshData[] | null>(null);
|
|
949
|
+
const cameraFittedRef = useRef<boolean>(false);
|
|
950
|
+
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
const renderer = rendererRef.current;
|
|
953
|
+
|
|
954
|
+
if (!renderer || !geometry || !isInitialized) return;
|
|
955
|
+
|
|
956
|
+
const device = renderer.getGPUDevice();
|
|
957
|
+
if (!device) return;
|
|
958
|
+
|
|
959
|
+
const scene = renderer.getScene();
|
|
960
|
+
const currentLength = geometry.length;
|
|
961
|
+
const geometryChanged = lastGeometryRef.current !== geometry;
|
|
962
|
+
|
|
963
|
+
if (geometryChanged && lastGeometryRef.current !== null) {
|
|
964
|
+
// New file loaded - reset camera and bounds
|
|
965
|
+
scene.clear();
|
|
966
|
+
processedMeshIdsRef.current.clear();
|
|
967
|
+
cameraFittedRef.current = false;
|
|
968
|
+
lastGeometryLengthRef.current = 0;
|
|
969
|
+
lastGeometryRef.current = geometry;
|
|
970
|
+
// Reset camera state (clear orbit pivot, stop inertia, cancel animations)
|
|
971
|
+
renderer.getCamera().reset();
|
|
972
|
+
// Reset geometry bounds to default
|
|
973
|
+
geometryBoundsRef.current = {
|
|
974
|
+
min: { x: -100, y: -100, z: -100 },
|
|
975
|
+
max: { x: 100, y: 100, z: 100 },
|
|
976
|
+
};
|
|
977
|
+
} else if (currentLength > lastGeometryLengthRef.current) {
|
|
978
|
+
lastGeometryRef.current = geometry;
|
|
979
|
+
} else if (currentLength === 0) {
|
|
980
|
+
// Geometry cleared - reset camera and bounds
|
|
981
|
+
scene.clear();
|
|
982
|
+
processedMeshIdsRef.current.clear();
|
|
983
|
+
cameraFittedRef.current = false;
|
|
984
|
+
lastGeometryLengthRef.current = 0;
|
|
985
|
+
lastGeometryRef.current = null;
|
|
986
|
+
// Reset camera state
|
|
987
|
+
renderer.getCamera().reset();
|
|
988
|
+
// Reset geometry bounds to default
|
|
989
|
+
geometryBoundsRef.current = {
|
|
990
|
+
min: { x: -100, y: -100, z: -100 },
|
|
991
|
+
max: { x: 100, y: 100, z: 100 },
|
|
992
|
+
};
|
|
993
|
+
return;
|
|
994
|
+
} else if (currentLength === lastGeometryLengthRef.current && !geometryChanged) {
|
|
995
|
+
return;
|
|
996
|
+
} else {
|
|
997
|
+
// Length changed or other scenario - reset camera and bounds
|
|
998
|
+
scene.clear();
|
|
999
|
+
processedMeshIdsRef.current.clear();
|
|
1000
|
+
cameraFittedRef.current = false;
|
|
1001
|
+
lastGeometryLengthRef.current = 0;
|
|
1002
|
+
lastGeometryRef.current = geometry;
|
|
1003
|
+
// Reset camera state
|
|
1004
|
+
renderer.getCamera().reset();
|
|
1005
|
+
// Reset geometry bounds to default
|
|
1006
|
+
geometryBoundsRef.current = {
|
|
1007
|
+
min: { x: -100, y: -100, z: -100 },
|
|
1008
|
+
max: { x: 100, y: 100, z: 100 },
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (lastGeometryRef.current === null) {
|
|
1013
|
+
lastGeometryRef.current = geometry;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const startIndex = lastGeometryLengthRef.current;
|
|
1017
|
+
const meshesToAdd = geometry.slice(startIndex);
|
|
1018
|
+
|
|
1019
|
+
for (const meshData of meshesToAdd) {
|
|
1020
|
+
if (processedMeshIdsRef.current.has(meshData.expressId)) continue;
|
|
1021
|
+
|
|
1022
|
+
const vertexCount = meshData.positions.length / 3;
|
|
1023
|
+
const interleaved = new Float32Array(vertexCount * 6);
|
|
1024
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
1025
|
+
const base = i * 6;
|
|
1026
|
+
const posBase = i * 3;
|
|
1027
|
+
interleaved[base] = meshData.positions[posBase];
|
|
1028
|
+
interleaved[base + 1] = meshData.positions[posBase + 1];
|
|
1029
|
+
interleaved[base + 2] = meshData.positions[posBase + 2];
|
|
1030
|
+
interleaved[base + 3] = meshData.normals[posBase];
|
|
1031
|
+
interleaved[base + 4] = meshData.normals[posBase + 1];
|
|
1032
|
+
interleaved[base + 5] = meshData.normals[posBase + 2];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const vertexBuffer = device.createBuffer({
|
|
1036
|
+
size: interleaved.byteLength,
|
|
1037
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1038
|
+
});
|
|
1039
|
+
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
1040
|
+
|
|
1041
|
+
const indexBuffer = device.createBuffer({
|
|
1042
|
+
size: meshData.indices.byteLength,
|
|
1043
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
1044
|
+
});
|
|
1045
|
+
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
1046
|
+
|
|
1047
|
+
scene.addMesh({
|
|
1048
|
+
expressId: meshData.expressId,
|
|
1049
|
+
vertexBuffer,
|
|
1050
|
+
indexBuffer,
|
|
1051
|
+
indexCount: meshData.indices.length,
|
|
1052
|
+
transform: MathUtils.identity(),
|
|
1053
|
+
color: meshData.color,
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
processedMeshIdsRef.current.add(meshData.expressId);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
lastGeometryLengthRef.current = currentLength;
|
|
1060
|
+
|
|
1061
|
+
// Fit camera and store bounds
|
|
1062
|
+
if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
|
|
1063
|
+
const shiftedBounds = coordinateInfo.shiftedBounds;
|
|
1064
|
+
const maxSize = Math.max(
|
|
1065
|
+
shiftedBounds.max.x - shiftedBounds.min.x,
|
|
1066
|
+
shiftedBounds.max.y - shiftedBounds.min.y,
|
|
1067
|
+
shiftedBounds.max.z - shiftedBounds.min.z
|
|
1068
|
+
);
|
|
1069
|
+
if (maxSize > 0 && Number.isFinite(maxSize)) {
|
|
1070
|
+
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
1071
|
+
geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
|
|
1072
|
+
cameraFittedRef.current = true;
|
|
1073
|
+
}
|
|
1074
|
+
} else if (!cameraFittedRef.current && geometry.length > 0) {
|
|
1075
|
+
const fallbackBounds = {
|
|
1076
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
1077
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
for (const meshData of geometry) {
|
|
1081
|
+
for (let i = 0; i < meshData.positions.length; i += 3) {
|
|
1082
|
+
const x = meshData.positions[i];
|
|
1083
|
+
const y = meshData.positions[i + 1];
|
|
1084
|
+
const z = meshData.positions[i + 2];
|
|
1085
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
|
|
1086
|
+
fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
|
|
1087
|
+
fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
|
|
1088
|
+
fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
|
|
1089
|
+
fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
|
|
1090
|
+
fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
|
|
1091
|
+
fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (fallbackBounds.min.x !== Infinity) {
|
|
1097
|
+
renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
|
|
1098
|
+
geometryBoundsRef.current = fallbackBounds;
|
|
1099
|
+
cameraFittedRef.current = true;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
renderer.render({
|
|
1104
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
1105
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
1106
|
+
selectedId: selectedEntityIdRef.current,
|
|
1107
|
+
clearColor: clearColorRef.current,
|
|
1108
|
+
});
|
|
1109
|
+
// Note: visibility states are NOT in dependencies - they use refs and trigger re-render via separate effect
|
|
1110
|
+
}, [geometry, isInitialized, coordinateInfo]);
|
|
1111
|
+
|
|
1112
|
+
// Get selectedEntityIds from store for multi-selection
|
|
1113
|
+
const selectedEntityIds = useViewerStore((state) => state.selectedEntityIds);
|
|
1114
|
+
|
|
1115
|
+
// Re-render when visibility, selection, or section plane changes
|
|
1116
|
+
useEffect(() => {
|
|
1117
|
+
const renderer = rendererRef.current;
|
|
1118
|
+
if (!renderer || !isInitialized) return;
|
|
1119
|
+
|
|
1120
|
+
renderer.render({
|
|
1121
|
+
hiddenIds: hiddenEntities,
|
|
1122
|
+
isolatedIds: isolatedEntities,
|
|
1123
|
+
selectedId: selectedEntityId,
|
|
1124
|
+
selectedIds: selectedEntityIds,
|
|
1125
|
+
clearColor: clearColorRef.current,
|
|
1126
|
+
sectionPlane: sectionPlane.enabled ? sectionPlane : undefined,
|
|
1127
|
+
});
|
|
1128
|
+
}, [hiddenEntities, isolatedEntities, selectedEntityId, selectedEntityIds, isInitialized, sectionPlane]);
|
|
1129
|
+
|
|
1130
|
+
return (
|
|
1131
|
+
<canvas
|
|
1132
|
+
ref={canvasRef}
|
|
1133
|
+
className="w-full h-full block"
|
|
1134
|
+
/>
|
|
1135
|
+
);
|
|
1136
|
+
}
|