@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,723 @@
|
|
|
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 } from '../store.js';
|
|
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
|
+
|
|
26
|
+
// Animation frame ref
|
|
27
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
28
|
+
const lastFrameTimeRef = useRef<number>(0);
|
|
29
|
+
|
|
30
|
+
// Mouse state
|
|
31
|
+
const mouseStateRef = useRef({
|
|
32
|
+
isDragging: false,
|
|
33
|
+
isPanning: false,
|
|
34
|
+
lastX: 0,
|
|
35
|
+
lastY: 0,
|
|
36
|
+
button: 0, // 0 = left, 1 = middle, 2 = right
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Touch state
|
|
40
|
+
const touchStateRef = useRef({
|
|
41
|
+
touches: [] as Touch[],
|
|
42
|
+
lastDistance: 0,
|
|
43
|
+
lastCenter: { x: 0, y: 0 },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Double-click detection
|
|
47
|
+
const lastClickTimeRef = useRef<number>(0);
|
|
48
|
+
const lastClickPosRef = useRef<{ x: number; y: number } | null>(null);
|
|
49
|
+
|
|
50
|
+
// Keyboard handlers refs
|
|
51
|
+
const keyboardHandlersRef = useRef<{
|
|
52
|
+
handleKeyDown: ((e: KeyboardEvent) => void) | null;
|
|
53
|
+
handleKeyUp: ((e: KeyboardEvent) => void) | null;
|
|
54
|
+
}>({ handleKeyDown: null, handleKeyUp: null });
|
|
55
|
+
|
|
56
|
+
// First-person mode state
|
|
57
|
+
const firstPersonModeRef = useRef<boolean>(false);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const canvas = canvasRef.current;
|
|
61
|
+
if (!canvas) return;
|
|
62
|
+
|
|
63
|
+
// Reset initialized state at start of effect (important for HMR)
|
|
64
|
+
setIsInitialized(false);
|
|
65
|
+
|
|
66
|
+
// Abort flag to prevent stale async operations from completing
|
|
67
|
+
let aborted = false;
|
|
68
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
69
|
+
|
|
70
|
+
// Set canvas pixel dimensions from CSS dimensions before init
|
|
71
|
+
const rect = canvas.getBoundingClientRect();
|
|
72
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
73
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
74
|
+
canvas.width = width;
|
|
75
|
+
canvas.height = height;
|
|
76
|
+
|
|
77
|
+
const renderer = new Renderer(canvas);
|
|
78
|
+
rendererRef.current = renderer;
|
|
79
|
+
|
|
80
|
+
renderer.init().then(() => {
|
|
81
|
+
// Skip if component was unmounted during async init
|
|
82
|
+
if (aborted) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log('[Viewport] Renderer initialized');
|
|
86
|
+
setIsInitialized(true);
|
|
87
|
+
|
|
88
|
+
const camera = renderer.getCamera();
|
|
89
|
+
const mouseState = mouseStateRef.current;
|
|
90
|
+
const touchState = touchStateRef.current;
|
|
91
|
+
|
|
92
|
+
// Animation loop for camera inertia
|
|
93
|
+
const animate = (currentTime: number) => {
|
|
94
|
+
if (aborted) return;
|
|
95
|
+
|
|
96
|
+
const deltaTime = currentTime - lastFrameTimeRef.current;
|
|
97
|
+
lastFrameTimeRef.current = currentTime;
|
|
98
|
+
|
|
99
|
+
const isAnimating = camera.update(deltaTime);
|
|
100
|
+
if (isAnimating) {
|
|
101
|
+
renderer.render();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
105
|
+
};
|
|
106
|
+
lastFrameTimeRef.current = performance.now();
|
|
107
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
108
|
+
|
|
109
|
+
// Mouse controls
|
|
110
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
mouseState.isDragging = true;
|
|
113
|
+
mouseState.isPanning = e.button === 1 || e.button === 2 || e.shiftKey;
|
|
114
|
+
mouseState.button = e.button;
|
|
115
|
+
mouseState.lastX = e.clientX;
|
|
116
|
+
mouseState.lastY = e.clientY;
|
|
117
|
+
canvas.style.cursor = mouseState.isPanning ? 'move' : 'grab';
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
121
|
+
if (mouseState.isDragging) {
|
|
122
|
+
const dx = e.clientX - mouseState.lastX;
|
|
123
|
+
const dy = e.clientY - mouseState.lastY;
|
|
124
|
+
|
|
125
|
+
if (mouseState.isPanning) {
|
|
126
|
+
camera.pan(dx, dy, false);
|
|
127
|
+
} else {
|
|
128
|
+
camera.orbit(dx, dy, false);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
mouseState.lastX = e.clientX;
|
|
132
|
+
mouseState.lastY = e.clientY;
|
|
133
|
+
renderer.render();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
canvas.addEventListener('mouseup', () => {
|
|
138
|
+
mouseState.isDragging = false;
|
|
139
|
+
mouseState.isPanning = false;
|
|
140
|
+
canvas.style.cursor = 'default';
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
canvas.addEventListener('mouseleave', () => {
|
|
144
|
+
mouseState.isDragging = false;
|
|
145
|
+
mouseState.isPanning = false;
|
|
146
|
+
camera.stopInertia();
|
|
147
|
+
canvas.style.cursor = 'default';
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Prevent context menu on right-click
|
|
151
|
+
canvas.addEventListener('contextmenu', (e) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Wheel zoom - zoom towards mouse position
|
|
156
|
+
canvas.addEventListener('wheel', (e) => {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
const rect = canvas.getBoundingClientRect();
|
|
159
|
+
const mouseX = e.clientX - rect.left;
|
|
160
|
+
const mouseY = e.clientY - rect.top;
|
|
161
|
+
camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
|
|
162
|
+
renderer.render();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Click and double-click
|
|
166
|
+
canvas.addEventListener('click', async (e) => {
|
|
167
|
+
const rect = canvas.getBoundingClientRect();
|
|
168
|
+
const x = e.clientX - rect.left;
|
|
169
|
+
const y = e.clientY - rect.top;
|
|
170
|
+
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const timeSinceLastClick = now - lastClickTimeRef.current;
|
|
173
|
+
const clickPos = { x, y };
|
|
174
|
+
|
|
175
|
+
// Check for double-click
|
|
176
|
+
if (lastClickPosRef.current &&
|
|
177
|
+
timeSinceLastClick < 300 &&
|
|
178
|
+
Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
|
|
179
|
+
Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
|
|
180
|
+
// Double-click: zoom to fit selected element
|
|
181
|
+
const pickedId = await renderer.pick(x, y);
|
|
182
|
+
if (pickedId) {
|
|
183
|
+
setSelectedEntityId(pickedId);
|
|
184
|
+
// Find bounds of selected element (simplified - would need scene bounds)
|
|
185
|
+
const meshes = renderer.getScene().getMeshes();
|
|
186
|
+
const selectedMesh = meshes.find(m => m.expressId === pickedId);
|
|
187
|
+
if (selectedMesh) {
|
|
188
|
+
// For now, just zoom to current bounds
|
|
189
|
+
// In production, would calculate element bounds
|
|
190
|
+
const bounds = {
|
|
191
|
+
min: { x: -10, y: -10, z: -10 },
|
|
192
|
+
max: { x: 10, y: 10, z: 10 },
|
|
193
|
+
};
|
|
194
|
+
camera.zoomToFit(bounds.min, bounds.max, 500);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
lastClickTimeRef.current = 0;
|
|
198
|
+
lastClickPosRef.current = null;
|
|
199
|
+
} else {
|
|
200
|
+
// Single click: pick element
|
|
201
|
+
console.log('[Viewport] Click at:', { x, y });
|
|
202
|
+
const pickedId = await renderer.pick(x, y);
|
|
203
|
+
console.log('[Viewport] Picked expressId:', pickedId);
|
|
204
|
+
setSelectedEntityId(pickedId);
|
|
205
|
+
lastClickTimeRef.current = now;
|
|
206
|
+
lastClickPosRef.current = clickPos;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Touch controls
|
|
211
|
+
canvas.addEventListener('touchstart', (e) => {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
touchState.touches = Array.from(e.touches);
|
|
214
|
+
|
|
215
|
+
if (touchState.touches.length === 1) {
|
|
216
|
+
touchState.lastCenter = {
|
|
217
|
+
x: touchState.touches[0].clientX,
|
|
218
|
+
y: touchState.touches[0].clientY,
|
|
219
|
+
};
|
|
220
|
+
} else if (touchState.touches.length === 2) {
|
|
221
|
+
const dx = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
222
|
+
const dy = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
223
|
+
touchState.lastDistance = Math.sqrt(dx * dx + dy * dy);
|
|
224
|
+
touchState.lastCenter = {
|
|
225
|
+
x: (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2,
|
|
226
|
+
y: (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
canvas.addEventListener('touchmove', (e) => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
touchState.touches = Array.from(e.touches);
|
|
234
|
+
|
|
235
|
+
if (touchState.touches.length === 1) {
|
|
236
|
+
// Single finger: orbit
|
|
237
|
+
const dx = touchState.touches[0].clientX - touchState.lastCenter.x;
|
|
238
|
+
const dy = touchState.touches[0].clientY - touchState.lastCenter.y;
|
|
239
|
+
camera.orbit(dx, dy, false);
|
|
240
|
+
touchState.lastCenter = {
|
|
241
|
+
x: touchState.touches[0].clientX,
|
|
242
|
+
y: touchState.touches[0].clientY,
|
|
243
|
+
};
|
|
244
|
+
renderer.render();
|
|
245
|
+
} else if (touchState.touches.length === 2) {
|
|
246
|
+
// Two fingers: pan and zoom
|
|
247
|
+
const dx1 = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
248
|
+
const dy1 = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
249
|
+
const distance = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
|
250
|
+
|
|
251
|
+
// Pan
|
|
252
|
+
const centerX = (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2;
|
|
253
|
+
const centerY = (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2;
|
|
254
|
+
const panDx = centerX - touchState.lastCenter.x;
|
|
255
|
+
const panDy = centerY - touchState.lastCenter.y;
|
|
256
|
+
camera.pan(panDx, panDy, false);
|
|
257
|
+
|
|
258
|
+
// Zoom (pinch) towards center of pinch gesture
|
|
259
|
+
const zoomDelta = distance - touchState.lastDistance;
|
|
260
|
+
const rect = canvas.getBoundingClientRect();
|
|
261
|
+
camera.zoom(zoomDelta * 10, false, centerX - rect.left, centerY - rect.top, canvas.width, canvas.height);
|
|
262
|
+
|
|
263
|
+
touchState.lastDistance = distance;
|
|
264
|
+
touchState.lastCenter = { x: centerX, y: centerY };
|
|
265
|
+
renderer.render();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
canvas.addEventListener('touchend', (e) => {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
touchState.touches = Array.from(e.touches);
|
|
272
|
+
if (touchState.touches.length === 0) {
|
|
273
|
+
camera.stopInertia();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Keyboard controls
|
|
278
|
+
const keyState: { [key: string]: boolean } = {};
|
|
279
|
+
|
|
280
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
281
|
+
// Only handle if canvas is focused or no input is focused
|
|
282
|
+
if (document.activeElement?.tagName === 'INPUT' ||
|
|
283
|
+
document.activeElement?.tagName === 'TEXTAREA') {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
keyState[e.key.toLowerCase()] = true;
|
|
288
|
+
|
|
289
|
+
// Preset views
|
|
290
|
+
if (e.key === '1') camera.setPresetView('top');
|
|
291
|
+
if (e.key === '2') camera.setPresetView('bottom');
|
|
292
|
+
if (e.key === '3') camera.setPresetView('front');
|
|
293
|
+
if (e.key === '4') camera.setPresetView('back');
|
|
294
|
+
if (e.key === '5') camera.setPresetView('left');
|
|
295
|
+
if (e.key === '6') camera.setPresetView('right');
|
|
296
|
+
|
|
297
|
+
// Frame selection
|
|
298
|
+
if (e.key === 'f' || e.key === 'F') {
|
|
299
|
+
if (selectedEntityId) {
|
|
300
|
+
const bounds = {
|
|
301
|
+
min: { x: -10, y: -10, z: -10 },
|
|
302
|
+
max: { x: 10, y: 10, z: 10 },
|
|
303
|
+
};
|
|
304
|
+
camera.zoomToFit(bounds.min, bounds.max, 500);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Home view (reset)
|
|
309
|
+
if (e.key === 'h' || e.key === 'H') {
|
|
310
|
+
const bounds = {
|
|
311
|
+
min: { x: -100, y: -100, z: -100 },
|
|
312
|
+
max: { x: 100, y: 100, z: 100 },
|
|
313
|
+
};
|
|
314
|
+
camera.zoomToFit(bounds.min, bounds.max, 500);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Toggle first-person mode
|
|
318
|
+
if (e.key === 'c' || e.key === 'C') {
|
|
319
|
+
firstPersonModeRef.current = !firstPersonModeRef.current;
|
|
320
|
+
camera.enableFirstPersonMode(firstPersonModeRef.current);
|
|
321
|
+
console.log('[Viewport] First-person mode:', firstPersonModeRef.current ? 'enabled' : 'disabled');
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
326
|
+
keyState[e.key.toLowerCase()] = false;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Store handlers in ref for cleanup
|
|
330
|
+
keyboardHandlersRef.current.handleKeyDown = handleKeyDown;
|
|
331
|
+
keyboardHandlersRef.current.handleKeyUp = handleKeyUp;
|
|
332
|
+
|
|
333
|
+
// Continuous keyboard movement
|
|
334
|
+
const keyboardMove = () => {
|
|
335
|
+
if (aborted) return;
|
|
336
|
+
|
|
337
|
+
let moved = false;
|
|
338
|
+
const panSpeed = 5;
|
|
339
|
+
const zoomSpeed = 0.1;
|
|
340
|
+
|
|
341
|
+
if (firstPersonModeRef.current) {
|
|
342
|
+
// First-person movement
|
|
343
|
+
if (keyState['w'] || keyState['arrowup']) {
|
|
344
|
+
camera.moveFirstPerson(1, 0, 0);
|
|
345
|
+
moved = true;
|
|
346
|
+
}
|
|
347
|
+
if (keyState['s'] || keyState['arrowdown']) {
|
|
348
|
+
camera.moveFirstPerson(-1, 0, 0);
|
|
349
|
+
moved = true;
|
|
350
|
+
}
|
|
351
|
+
if (keyState['a'] || keyState['arrowleft']) {
|
|
352
|
+
camera.moveFirstPerson(0, -1, 0);
|
|
353
|
+
moved = true;
|
|
354
|
+
}
|
|
355
|
+
if (keyState['d'] || keyState['arrowright']) {
|
|
356
|
+
camera.moveFirstPerson(0, 1, 0);
|
|
357
|
+
moved = true;
|
|
358
|
+
}
|
|
359
|
+
if (keyState['q']) {
|
|
360
|
+
camera.moveFirstPerson(0, 0, -1);
|
|
361
|
+
moved = true;
|
|
362
|
+
}
|
|
363
|
+
if (keyState['e']) {
|
|
364
|
+
camera.moveFirstPerson(0, 0, 1);
|
|
365
|
+
moved = true;
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
// Orbit mode movement
|
|
369
|
+
if (keyState['w'] || keyState['arrowup']) {
|
|
370
|
+
camera.pan(0, panSpeed, false);
|
|
371
|
+
moved = true;
|
|
372
|
+
}
|
|
373
|
+
if (keyState['s'] || keyState['arrowdown']) {
|
|
374
|
+
camera.pan(0, -panSpeed, false);
|
|
375
|
+
moved = true;
|
|
376
|
+
}
|
|
377
|
+
if (keyState['a'] || keyState['arrowleft']) {
|
|
378
|
+
camera.pan(-panSpeed, 0, false);
|
|
379
|
+
moved = true;
|
|
380
|
+
}
|
|
381
|
+
if (keyState['d'] || keyState['arrowright']) {
|
|
382
|
+
camera.pan(panSpeed, 0, false);
|
|
383
|
+
moved = true;
|
|
384
|
+
}
|
|
385
|
+
if (keyState['q']) {
|
|
386
|
+
camera.zoom(-zoomSpeed * 100, false);
|
|
387
|
+
moved = true;
|
|
388
|
+
}
|
|
389
|
+
if (keyState['e']) {
|
|
390
|
+
camera.zoom(zoomSpeed * 100, false);
|
|
391
|
+
moved = true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (moved) {
|
|
396
|
+
renderer.render();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
requestAnimationFrame(keyboardMove);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
403
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
404
|
+
keyboardMove();
|
|
405
|
+
|
|
406
|
+
// Handle resize
|
|
407
|
+
resizeObserver = new ResizeObserver(() => {
|
|
408
|
+
if (aborted) return;
|
|
409
|
+
const rect = canvas.getBoundingClientRect();
|
|
410
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
411
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
412
|
+
renderer.resize(width, height);
|
|
413
|
+
renderer.render();
|
|
414
|
+
});
|
|
415
|
+
resizeObserver.observe(canvas);
|
|
416
|
+
|
|
417
|
+
renderer.render();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
return () => {
|
|
421
|
+
aborted = true;
|
|
422
|
+
if (animationFrameRef.current !== null) {
|
|
423
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
424
|
+
}
|
|
425
|
+
if (resizeObserver) {
|
|
426
|
+
resizeObserver.disconnect();
|
|
427
|
+
}
|
|
428
|
+
if (keyboardHandlersRef.current.handleKeyDown) {
|
|
429
|
+
window.removeEventListener('keydown', keyboardHandlersRef.current.handleKeyDown);
|
|
430
|
+
}
|
|
431
|
+
if (keyboardHandlersRef.current.handleKeyUp) {
|
|
432
|
+
window.removeEventListener('keyup', keyboardHandlersRef.current.handleKeyUp);
|
|
433
|
+
}
|
|
434
|
+
setIsInitialized(false);
|
|
435
|
+
rendererRef.current = null;
|
|
436
|
+
};
|
|
437
|
+
}, [setSelectedEntityId]);
|
|
438
|
+
|
|
439
|
+
// Track processed meshes for incremental updates
|
|
440
|
+
const processedMeshIdsRef = useRef<Set<number>>(new Set());
|
|
441
|
+
const lastGeometryLengthRef = useRef<number>(0);
|
|
442
|
+
const lastGeometryRef = useRef<MeshData[] | null>(null);
|
|
443
|
+
const cameraFittedRef = useRef<boolean>(false);
|
|
444
|
+
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
const renderer = rendererRef.current;
|
|
447
|
+
|
|
448
|
+
console.log('[Viewport] Geometry effect:', {
|
|
449
|
+
hasRenderer: !!renderer,
|
|
450
|
+
hasGeometry: !!geometry,
|
|
451
|
+
isInitialized,
|
|
452
|
+
geometryLength: geometry?.length,
|
|
453
|
+
lastLength: lastGeometryLengthRef.current
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
if (!renderer || !geometry || !isInitialized) return;
|
|
457
|
+
|
|
458
|
+
// Use the safe getGPUDevice() method that returns null if not ready
|
|
459
|
+
const device = renderer.getGPUDevice();
|
|
460
|
+
if (!device) {
|
|
461
|
+
console.warn('[Viewport] Device not ready, skipping geometry processing');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const scene = renderer.getScene();
|
|
466
|
+
const currentLength = geometry.length;
|
|
467
|
+
const lastLength = lastGeometryLengthRef.current;
|
|
468
|
+
const isIncremental = currentLength > lastLength;
|
|
469
|
+
|
|
470
|
+
// Check if geometry array reference changed (filtering scenario)
|
|
471
|
+
const geometryChanged = lastGeometryRef.current !== geometry;
|
|
472
|
+
const lastGeometry = lastGeometryRef.current;
|
|
473
|
+
|
|
474
|
+
console.log(`[Viewport] Geometry update check: current=${currentLength}, last=${lastLength}, incremental=${isIncremental}, geometryChanged=${geometryChanged}`);
|
|
475
|
+
|
|
476
|
+
// If geometry array reference changed, we need to rebuild (filtering scenario)
|
|
477
|
+
if (geometryChanged && lastGeometry !== null) {
|
|
478
|
+
console.log('[Viewport] Geometry array reference changed (filtering), clearing and rebuilding');
|
|
479
|
+
scene.clear();
|
|
480
|
+
processedMeshIdsRef.current.clear();
|
|
481
|
+
lastGeometryLengthRef.current = 0;
|
|
482
|
+
lastGeometryRef.current = geometry;
|
|
483
|
+
} else if (isIncremental) {
|
|
484
|
+
// Incremental update: only add new meshes
|
|
485
|
+
console.log(`[Viewport] Incremental update: adding ${currentLength - lastLength} new meshes (total: ${currentLength})`);
|
|
486
|
+
lastGeometryRef.current = geometry;
|
|
487
|
+
} else if (currentLength === 0) {
|
|
488
|
+
// Clear scene if geometry was cleared
|
|
489
|
+
scene.clear();
|
|
490
|
+
processedMeshIdsRef.current.clear();
|
|
491
|
+
cameraFittedRef.current = false;
|
|
492
|
+
lastGeometryLengthRef.current = 0;
|
|
493
|
+
lastGeometryRef.current = null;
|
|
494
|
+
return;
|
|
495
|
+
} else if (currentLength === lastGeometryLengthRef.current && !geometryChanged) {
|
|
496
|
+
// Same length and same reference - might be a re-render, skip if already processed
|
|
497
|
+
return;
|
|
498
|
+
} else {
|
|
499
|
+
// Length decreased or changed - this means a new file was loaded or filter changed, clear and rebuild from scratch
|
|
500
|
+
console.log('[Viewport] Geometry length changed, clearing and rebuilding from scratch');
|
|
501
|
+
scene.clear();
|
|
502
|
+
processedMeshIdsRef.current.clear();
|
|
503
|
+
cameraFittedRef.current = false;
|
|
504
|
+
lastGeometryLengthRef.current = 0; // Reset so we process all new meshes
|
|
505
|
+
lastGeometryRef.current = geometry;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Ensure lastGeometryRef is set if it wasn't set above
|
|
509
|
+
if (lastGeometryRef.current === null) {
|
|
510
|
+
lastGeometryRef.current = geometry;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Process only new meshes (for incremental updates)
|
|
514
|
+
// If we cleared the scene (filtering or length change), process all meshes
|
|
515
|
+
const startIndex = lastGeometryLengthRef.current;
|
|
516
|
+
const meshesToAdd = geometry.slice(startIndex);
|
|
517
|
+
|
|
518
|
+
console.log(`[Viewport] Processing ${meshesToAdd.length} meshes (starting at index ${startIndex})`);
|
|
519
|
+
|
|
520
|
+
// Create GPU buffers for new meshes only
|
|
521
|
+
// Note: Coordinates have already been shifted to origin by CoordinateHandler
|
|
522
|
+
// if large coordinates were detected. Use shifted bounds from coordinateInfo.
|
|
523
|
+
for (const meshData of meshesToAdd) {
|
|
524
|
+
// Skip if already processed (safety check)
|
|
525
|
+
// This check is important for incremental updates, but if we cleared the scene,
|
|
526
|
+
// processedMeshIdsRef will be empty, so all meshes will be processed
|
|
527
|
+
if (processedMeshIdsRef.current.has(meshData.expressId)) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Build interleaved buffer
|
|
532
|
+
const vertexCount = meshData.positions.length / 3;
|
|
533
|
+
const interleaved = new Float32Array(vertexCount * 6);
|
|
534
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
535
|
+
const base = i * 6;
|
|
536
|
+
const posBase = i * 3;
|
|
537
|
+
const normBase = i * 3;
|
|
538
|
+
interleaved[base] = meshData.positions[posBase];
|
|
539
|
+
interleaved[base + 1] = meshData.positions[posBase + 1];
|
|
540
|
+
interleaved[base + 2] = meshData.positions[posBase + 2];
|
|
541
|
+
interleaved[base + 3] = meshData.normals[normBase];
|
|
542
|
+
interleaved[base + 4] = meshData.normals[normBase + 1];
|
|
543
|
+
interleaved[base + 5] = meshData.normals[normBase + 2];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const vertexBuffer = device.createBuffer({
|
|
547
|
+
size: interleaved.byteLength,
|
|
548
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
549
|
+
});
|
|
550
|
+
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
551
|
+
|
|
552
|
+
const indexBuffer = device.createBuffer({
|
|
553
|
+
size: meshData.indices.byteLength,
|
|
554
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
555
|
+
});
|
|
556
|
+
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
557
|
+
|
|
558
|
+
scene.addMesh({
|
|
559
|
+
expressId: meshData.expressId,
|
|
560
|
+
vertexBuffer,
|
|
561
|
+
indexBuffer,
|
|
562
|
+
indexCount: meshData.indices.length,
|
|
563
|
+
transform: MathUtils.identity(),
|
|
564
|
+
color: meshData.color,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
processedMeshIdsRef.current.add(meshData.expressId);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Update last length
|
|
571
|
+
lastGeometryLengthRef.current = currentLength;
|
|
572
|
+
|
|
573
|
+
console.log('[Viewport] Meshes added:', scene.getMeshes().length);
|
|
574
|
+
|
|
575
|
+
// Fit camera only once (on first batch or when we have coordinate info)
|
|
576
|
+
// For incremental updates, fit camera when we get valid bounds
|
|
577
|
+
if (!cameraFittedRef.current && coordinateInfo && coordinateInfo.shiftedBounds) {
|
|
578
|
+
const shiftedBounds = coordinateInfo.shiftedBounds;
|
|
579
|
+
const size = {
|
|
580
|
+
x: shiftedBounds.max.x - shiftedBounds.min.x,
|
|
581
|
+
y: shiftedBounds.max.y - shiftedBounds.min.y,
|
|
582
|
+
z: shiftedBounds.max.z - shiftedBounds.min.z,
|
|
583
|
+
};
|
|
584
|
+
const maxSize = Math.max(size.x, size.y, size.z);
|
|
585
|
+
|
|
586
|
+
// Only fit camera if bounds are valid (non-zero size)
|
|
587
|
+
if (maxSize > 0 && Number.isFinite(maxSize)) {
|
|
588
|
+
console.log('[Viewport] Fitting camera to bounds:', {
|
|
589
|
+
shiftedBounds,
|
|
590
|
+
size,
|
|
591
|
+
maxSize,
|
|
592
|
+
isGeoReferenced: coordinateInfo.isGeoReferenced,
|
|
593
|
+
originShift: coordinateInfo.originShift,
|
|
594
|
+
});
|
|
595
|
+
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
596
|
+
cameraFittedRef.current = true;
|
|
597
|
+
} else {
|
|
598
|
+
console.warn('[Viewport] Invalid bounds, skipping camera fit:', { shiftedBounds, maxSize });
|
|
599
|
+
}
|
|
600
|
+
} else if (!cameraFittedRef.current && geometry.length > 0) {
|
|
601
|
+
// Fallback: calculate bounds from current geometry if no coordinate info yet
|
|
602
|
+
console.log('[Viewport] Calculating bounds from current geometry');
|
|
603
|
+
const fallbackBounds = {
|
|
604
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
605
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
for (const meshData of geometry) {
|
|
609
|
+
const positions = meshData.positions;
|
|
610
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
611
|
+
const x = positions[i];
|
|
612
|
+
const y = positions[i + 1];
|
|
613
|
+
const z = positions[i + 2];
|
|
614
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
|
|
615
|
+
fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
|
|
616
|
+
fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
|
|
617
|
+
fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
|
|
618
|
+
fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
|
|
619
|
+
fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
|
|
620
|
+
fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const hasValidBounds =
|
|
626
|
+
fallbackBounds.min.x !== Infinity && fallbackBounds.max.x !== -Infinity &&
|
|
627
|
+
fallbackBounds.min.y !== Infinity && fallbackBounds.max.y !== -Infinity &&
|
|
628
|
+
fallbackBounds.min.z !== Infinity && fallbackBounds.max.z !== -Infinity;
|
|
629
|
+
|
|
630
|
+
if (hasValidBounds) {
|
|
631
|
+
renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
|
|
632
|
+
cameraFittedRef.current = true;
|
|
633
|
+
}
|
|
634
|
+
} else if (!cameraFittedRef.current) {
|
|
635
|
+
// Fallback: calculate bounds from positions (shouldn't happen if coordinate handler worked)
|
|
636
|
+
console.warn('[Viewport] No coordinateInfo, calculating bounds from positions');
|
|
637
|
+
const fallbackBounds = {
|
|
638
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
639
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
for (const meshData of geometry) {
|
|
643
|
+
const positions = meshData.positions;
|
|
644
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
645
|
+
const x = positions[i];
|
|
646
|
+
const y = positions[i + 1];
|
|
647
|
+
const z = positions[i + 2];
|
|
648
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
|
|
649
|
+
fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
|
|
650
|
+
fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
|
|
651
|
+
fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
|
|
652
|
+
fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
|
|
653
|
+
fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
|
|
654
|
+
fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const hasValidBounds =
|
|
660
|
+
fallbackBounds.min.x !== Infinity && fallbackBounds.max.x !== -Infinity &&
|
|
661
|
+
fallbackBounds.min.y !== Infinity && fallbackBounds.max.y !== -Infinity &&
|
|
662
|
+
fallbackBounds.min.z !== Infinity && fallbackBounds.max.z !== -Infinity;
|
|
663
|
+
|
|
664
|
+
if (hasValidBounds) {
|
|
665
|
+
const size = {
|
|
666
|
+
x: fallbackBounds.max.x - fallbackBounds.min.x,
|
|
667
|
+
y: fallbackBounds.max.y - fallbackBounds.min.y,
|
|
668
|
+
z: fallbackBounds.max.z - fallbackBounds.min.z,
|
|
669
|
+
};
|
|
670
|
+
const maxSize = Math.max(size.x, size.y, size.z);
|
|
671
|
+
if (maxSize > 0) {
|
|
672
|
+
console.log('[Viewport] Fitting camera to calculated bounds:', { fallbackBounds, size, maxSize });
|
|
673
|
+
renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
|
|
674
|
+
cameraFittedRef.current = true;
|
|
675
|
+
} else {
|
|
676
|
+
console.warn('[Viewport] Calculated bounds have zero size, trying scene bounds');
|
|
677
|
+
const sceneBounds = renderer.getScene().getBounds();
|
|
678
|
+
if (sceneBounds) {
|
|
679
|
+
const sceneSize = {
|
|
680
|
+
x: sceneBounds.max.x - sceneBounds.min.x,
|
|
681
|
+
y: sceneBounds.max.y - sceneBounds.min.y,
|
|
682
|
+
z: sceneBounds.max.z - sceneBounds.min.z,
|
|
683
|
+
};
|
|
684
|
+
const sceneMaxSize = Math.max(sceneSize.x, sceneSize.y, sceneSize.z);
|
|
685
|
+
if (sceneMaxSize > 0) {
|
|
686
|
+
console.log('[Viewport] Fitting camera to scene bounds:', { sceneBounds, sceneSize, sceneMaxSize });
|
|
687
|
+
renderer.getCamera().fitToBounds(sceneBounds.min, sceneBounds.max);
|
|
688
|
+
cameraFittedRef.current = true;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
console.warn('[Viewport] Invalid bounds, using scene bounds fallback');
|
|
694
|
+
const sceneBounds = renderer.getScene().getBounds();
|
|
695
|
+
if (sceneBounds) {
|
|
696
|
+
const sceneSize = {
|
|
697
|
+
x: sceneBounds.max.x - sceneBounds.min.x,
|
|
698
|
+
y: sceneBounds.max.y - sceneBounds.min.y,
|
|
699
|
+
z: sceneBounds.max.z - sceneBounds.min.z,
|
|
700
|
+
};
|
|
701
|
+
const sceneMaxSize = Math.max(sceneSize.x, sceneSize.y, sceneSize.z);
|
|
702
|
+
if (sceneMaxSize > 0) {
|
|
703
|
+
console.log('[Viewport] Fitting camera to scene bounds:', { sceneBounds, sceneSize, sceneMaxSize });
|
|
704
|
+
renderer.getCamera().fitToBounds(sceneBounds.min, sceneBounds.max);
|
|
705
|
+
cameraFittedRef.current = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
renderer.render();
|
|
711
|
+
}, [geometry, isInitialized]);
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
<canvas
|
|
715
|
+
ref={canvasRef}
|
|
716
|
+
style={{
|
|
717
|
+
width: '100%',
|
|
718
|
+
height: '100%',
|
|
719
|
+
display: 'block',
|
|
720
|
+
}}
|
|
721
|
+
/>
|
|
722
|
+
);
|
|
723
|
+
}
|