@ifc-lite/viewer 1.0.0

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