@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,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
+ }