@ifc-lite/viewer 1.6.0 → 1.7.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 (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -6,8 +6,8 @@
6
6
  * 3D viewport component
7
7
  */
8
8
 
9
- import { useEffect, useRef, useState, useMemo } from 'react';
10
- import { Renderer, MathUtils, type SnapTarget, type PickResult } from '@ifc-lite/renderer';
9
+ import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
+ import { Renderer } from '@ifc-lite/renderer';
11
11
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
12
12
  import { useViewerStore, type MeasurePoint, type SnapVisualization } from '@/store';
13
13
  import {
@@ -26,14 +26,18 @@ import { useModelSelection } from '../../hooks/useModelSelection.js';
26
26
  import {
27
27
  getEntityBounds,
28
28
  getEntityCenter,
29
- buildRenderOptions,
30
- getRenderThrottleMs,
31
29
  getThemeClearColor,
32
- calculateScaleBarSize,
33
30
  type ViewportStateRefs,
34
31
  } from '../../utils/viewportUtils.js';
35
32
  import { setGlobalCanvasRef, setGlobalRendererRef, clearGlobalRefs } from '../../hooks/useBCF.js';
36
33
 
34
+ import { useMouseControls, type MouseState } from './useMouseControls.js';
35
+ import { useTouchControls, type TouchState } from './useTouchControls.js';
36
+ import { useKeyboardControls } from './useKeyboardControls.js';
37
+ import { useAnimationLoop } from './useAnimationLoop.js';
38
+ import { useGeometryStreaming } from './useGeometryStreaming.js';
39
+ import { useRenderUpdates } from './useRenderUpdates.js';
40
+
37
41
  interface ViewportProps {
38
42
  geometry: MeshData[] | null;
39
43
  coordinateInfo?: CoordinateInfo;
@@ -74,7 +78,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
74
78
  // IMPORTANT: pickResult.expressId is now a globalId (transformed at load time)
75
79
  // We use the store-based resolver to find (modelId, originalExpressId)
76
80
  // This is more reliable than the singleton registry which can have bundling issues
77
- const handlePickForSelection = (pickResult: PickResult | null) => {
81
+ const handlePickForSelection = useCallback((pickResult: import('@ifc-lite/renderer').PickResult | null) => {
78
82
  if (!pickResult) {
79
83
  setSelectedEntityId(null);
80
84
  return;
@@ -101,7 +105,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
101
105
  }
102
106
  }
103
107
  }
104
- };
108
+ }, [setSelectedEntityId, setSelectedEntity, resolveGlobalIdFromModels, modelIndexToId]);
109
+
110
+ // Ref to always access latest handlePickForSelection from event handlers
111
+ // (useMouseControls/useTouchControls capture this at effect setup time)
112
+ const handlePickForSelectionRef = useRef(handlePickForSelection);
113
+ useEffect(() => { handlePickForSelectionRef.current = handlePickForSelection; }, [handlePickForSelection]);
105
114
 
106
115
  // Visibility state - use computedIsolatedIds from parent (includes storey selection)
107
116
  // Fall back to store isolation if computedIsolatedIds is not provided
@@ -173,28 +182,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
173
182
  // Tokyo Night storm: #1a1b26 = rgb(26, 27, 38)
174
183
  const clearColorRef = useRef<[number, number, number, number]>([0.102, 0.106, 0.149, 1]);
175
184
 
176
- useEffect(() => {
177
- // Update clear color when theme changes
178
- clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
179
- // Re-render with new clear color
180
- const renderer = rendererRef.current;
181
- if (renderer && isInitialized) {
182
- renderer.render({
183
- hiddenIds: hiddenEntitiesRef.current,
184
- isolatedIds: isolatedEntitiesRef.current,
185
- selectedId: selectedEntityIdRef.current,
186
- selectedModelIndex: selectedModelIndexRef.current,
187
- clearColor: clearColorRef.current,
188
- });
189
- }
190
- }, [theme, isInitialized]);
191
-
192
185
  // Animation frame ref
193
186
  const animationFrameRef = useRef<number | null>(null);
194
187
  const lastFrameTimeRef = useRef<number>(0);
195
188
 
196
189
  // Mouse state
197
- const mouseStateRef = useRef({
190
+ const mouseStateRef = useRef<MouseState>({
198
191
  isDragging: false,
199
192
  isPanning: false,
200
193
  lastX: 0,
@@ -206,7 +199,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
206
199
  });
207
200
 
208
201
  // Touch state
209
- const touchStateRef = useRef({
202
+ const touchStateRef = useRef<TouchState>({
210
203
  touches: [] as Touch[],
211
204
  lastDistance: 0,
212
205
  lastCenter: { x: 0, y: 0 },
@@ -342,6 +335,47 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
342
335
  }
343
336
  }, [activeTool, activeMeasurement, cancelMeasurement]);
344
337
 
338
+ // Helper: calculate scale bar value (world-space size for 96px scale bar)
339
+ const calculateScale = () => {
340
+ const canvas = canvasRef.current;
341
+ const renderer = rendererRef.current;
342
+ if (!canvas || !renderer) return;
343
+
344
+ const camera = renderer.getCamera();
345
+ const viewportHeight = canvas.height;
346
+ const scaleBarPixels = 96; // w-24 = 6rem = 96px
347
+
348
+ let worldSize: number;
349
+ if (camera.getProjectionMode() === 'orthographic') {
350
+ // Orthographic: orthoSize is half-height in world units, so full height = orthoSize * 2
351
+ worldSize = (scaleBarPixels / viewportHeight) * (camera.getOrthoSize() * 2);
352
+ } else {
353
+ const distance = camera.getDistance();
354
+ const fov = camera.getFOV();
355
+ // Calculate world-space size: (screen pixels / viewport height) * (distance * tan(FOV/2) * 2)
356
+ worldSize = (scaleBarPixels / viewportHeight) * (distance * Math.tan(fov / 2) * 2);
357
+ }
358
+ updateScaleRealtime(worldSize);
359
+ };
360
+
361
+ // Helper: get pick options with visibility filtering
362
+ const getPickOptions = () => {
363
+ const currentProgress = useViewerStore.getState().progress;
364
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
365
+ return {
366
+ isStreaming: currentIsStreaming,
367
+ hiddenIds: hiddenEntitiesRef.current,
368
+ isolatedIds: isolatedEntitiesRef.current,
369
+ };
370
+ };
371
+
372
+ // Helper: check if there are pending measurements
373
+ const hasPendingMeasurements = () => {
374
+ const state = useViewerStore.getState();
375
+ return state.measurements.length > 0 || state.activeMeasurement !== null;
376
+ };
377
+
378
+ // ===== Renderer initialization =====
345
379
  useEffect(() => {
346
380
  const canvas = canvasRef.current;
347
381
  if (!canvas) return;
@@ -377,78 +411,6 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
377
411
  setIsInitialized(true);
378
412
 
379
413
  const camera = renderer.getCamera();
380
- const mouseState = mouseStateRef.current;
381
- const touchState = touchStateRef.current;
382
-
383
- // Helper function to get current pick options with visibility filtering
384
- // This ensures users can only select visible elements (respects hide/isolate/type visibility)
385
- function getPickOptions() {
386
- const currentProgress = useViewerStore.getState().progress;
387
- const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
388
- return {
389
- isStreaming: currentIsStreaming,
390
- hiddenIds: hiddenEntitiesRef.current,
391
- isolatedIds: isolatedEntitiesRef.current,
392
- };
393
- }
394
-
395
- // Helper function to compute snap visualization (edge highlights, sliding dot, corner rings, plane indicators)
396
- // Stores 3D coordinates so edge highlights stay positioned correctly during camera rotation
397
- function updateSnapVisualization(snapTarget: SnapTarget | null, edgeLockInfo?: { edgeT: number; isCorner: boolean; cornerValence: number }) {
398
- if (!snapTarget || !canvas) {
399
- setSnapVisualization(null);
400
- return;
401
- }
402
-
403
- const viz: Partial<SnapVisualization> = {};
404
-
405
- // For edge snaps: store 3D world coordinates (will be projected to screen by ToolOverlays)
406
- if ((snapTarget.type === 'edge' || snapTarget.type === 'vertex') && snapTarget.metadata?.vertices) {
407
- const [v0, v1] = snapTarget.metadata.vertices;
408
-
409
- // Store 3D coordinates - these will be projected dynamically during rendering
410
- viz.edgeLine3D = {
411
- v0: { x: v0.x, y: v0.y, z: v0.z },
412
- v1: { x: v1.x, y: v1.y, z: v1.z },
413
- };
414
-
415
- // Add sliding dot t-parameter along the edge
416
- if (edgeLockInfo) {
417
- viz.slidingDot = { t: edgeLockInfo.edgeT };
418
-
419
- // Add corner rings if at a corner with high valence
420
- if (edgeLockInfo.isCorner && edgeLockInfo.cornerValence >= 2) {
421
- viz.cornerRings = {
422
- atStart: edgeLockInfo.edgeT < 0.5,
423
- valence: edgeLockInfo.cornerValence,
424
- };
425
- }
426
- } else {
427
- // No edge lock info - calculate t from snap position
428
- const edge = { x: v1.x - v0.x, y: v1.y - v0.y, z: v1.z - v0.z };
429
- const toSnap = { x: snapTarget.position.x - v0.x, y: snapTarget.position.y - v0.y, z: snapTarget.position.z - v0.z };
430
- const edgeLenSq = edge.x * edge.x + edge.y * edge.y + edge.z * edge.z;
431
- const t = edgeLenSq > 0 ? (toSnap.x * edge.x + toSnap.y * edge.y + toSnap.z * edge.z) / edgeLenSq : 0.5;
432
- viz.slidingDot = { t: Math.max(0, Math.min(1, t)) };
433
- }
434
- }
435
-
436
- // For face snaps: show plane indicator (still screen-space since it's just an indicator)
437
- if ((snapTarget.type === 'face' || snapTarget.type === 'face_center') && snapTarget.normal) {
438
- const pos = camera.projectToScreen(snapTarget.position, canvas.width, canvas.height);
439
- if (pos) {
440
- viz.planeIndicator = {
441
- x: pos.x,
442
- y: pos.y,
443
- normal: snapTarget.normal,
444
- };
445
- }
446
- }
447
-
448
- setSnapVisualization(viz);
449
- }
450
-
451
- // Note: getEntityBounds and getEntityCenter are now imported from viewportUtils.ts
452
414
 
453
415
  // Register camera callbacks for ViewCube and other controls
454
416
  setCameraCallbacks({
@@ -549,38 +511,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
549
511
  },
550
512
  projectToScreen: (worldPos: { x: number; y: number; z: number }) => {
551
513
  // Project 3D world position to 2D screen coordinates
552
- const canvas = canvasRef.current;
553
- if (!canvas) return null;
554
- return camera.projectToScreen(worldPos, canvas.width, canvas.height);
514
+ const c = canvasRef.current;
515
+ if (!c) return null;
516
+ return camera.projectToScreen(worldPos, c.width, c.height);
555
517
  },
556
- });
557
-
558
- // Calculate scale bar value (world-space size for 96px scale bar)
559
- const calculateScale = () => {
560
- const canvas = canvasRef.current;
561
- if (!canvas) return;
562
-
563
- const viewportHeight = canvas.height;
564
- const distance = camera.getDistance();
565
- const fov = camera.getFOV();
566
- const scaleBarPixels = 96; // w-24 = 6rem = 96px
567
-
568
- // Calculate world-space size: (screen pixels / viewport height) * (distance * tan(FOV/2) * 2)
569
- const worldSize = (scaleBarPixels / viewportHeight) * (distance * Math.tan(fov / 2) * 2);
570
- updateScaleRealtime(worldSize);
571
- };
572
-
573
- // Animation loop - update ViewCube in real-time
574
- let lastRotationUpdate = 0;
575
- let lastScaleUpdate = 0;
576
- const animate = (currentTime: number) => {
577
- if (aborted) return;
578
-
579
- const deltaTime = currentTime - lastFrameTimeRef.current;
580
- lastFrameTimeRef.current = currentTime;
581
-
582
- const isAnimating = camera.update(deltaTime);
583
- if (isAnimating) {
518
+ setProjectionMode: (mode) => {
519
+ camera.setProjectionMode(mode);
584
520
  renderer.render({
585
521
  hiddenIds: hiddenEntitiesRef.current,
586
522
  isolatedIds: isolatedEntitiesRef.current,
@@ -593,1007 +529,10 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
593
529
  max: sectionRangeRef.current?.max,
594
530
  } : undefined,
595
531
  });
596
- // Update ViewCube during camera animation (e.g., preset view transitions)
597
- updateCameraRotationRealtime(camera.getRotation());
598
532
  calculateScale();
599
- } else if (!mouseState.isDragging && currentTime - lastRotationUpdate > 500) {
600
- // Update camera rotation for ViewCube when not dragging (throttled to every 500ms when idle)
601
- updateCameraRotationRealtime(camera.getRotation());
602
- lastRotationUpdate = currentTime;
603
- }
604
-
605
- // Update scale bar (throttled to every 500ms - scale rarely needs frequent updates)
606
- if (currentTime - lastScaleUpdate > 500) {
607
- calculateScale();
608
- lastScaleUpdate = currentTime;
609
- }
610
-
611
- // Update measurement screen coordinates only when:
612
- // 1. Measure tool is active (not in other modes)
613
- // 2. Measurements exist
614
- // 3. Camera actually changed
615
- // This prevents unnecessary store updates and re-renders when not measuring
616
- if (activeToolRef.current === 'measure') {
617
- const state = useViewerStore.getState();
618
- if (state.measurements.length > 0 || state.activeMeasurement) {
619
- const canvas = canvasRef.current;
620
- if (canvas) {
621
- const cameraPos = camera.getPosition();
622
- const cameraRot = camera.getRotation();
623
- const cameraDist = camera.getDistance();
624
- const currentCameraState = {
625
- position: cameraPos,
626
- rotation: cameraRot,
627
- distance: cameraDist,
628
- canvasWidth: canvas.width,
629
- canvasHeight: canvas.height,
630
- };
631
-
632
- // Check if camera state changed
633
- const lastState = lastCameraStateRef.current;
634
- const cameraChanged =
635
- !lastState ||
636
- lastState.position.x !== currentCameraState.position.x ||
637
- lastState.position.y !== currentCameraState.position.y ||
638
- lastState.position.z !== currentCameraState.position.z ||
639
- lastState.rotation.azimuth !== currentCameraState.rotation.azimuth ||
640
- lastState.rotation.elevation !== currentCameraState.rotation.elevation ||
641
- lastState.distance !== currentCameraState.distance ||
642
- lastState.canvasWidth !== currentCameraState.canvasWidth ||
643
- lastState.canvasHeight !== currentCameraState.canvasHeight;
644
-
645
- if (cameraChanged) {
646
- lastCameraStateRef.current = currentCameraState;
647
- updateMeasurementScreenCoords((worldPos) => {
648
- return camera.projectToScreen(worldPos, canvas.width, canvas.height);
649
- });
650
- }
651
- }
652
- }
653
- }
654
-
655
- animationFrameRef.current = requestAnimationFrame(animate);
656
- };
657
- lastFrameTimeRef.current = performance.now();
658
- animationFrameRef.current = requestAnimationFrame(animate);
659
-
660
- // Mouse controls - respect active tool
661
- canvas.addEventListener('mousedown', async (e) => {
662
- e.preventDefault();
663
- mouseState.isDragging = true;
664
- mouseState.button = e.button;
665
- mouseState.lastX = e.clientX;
666
- mouseState.lastY = e.clientY;
667
- mouseState.startX = e.clientX;
668
- mouseState.startY = e.clientY;
669
- mouseState.didDrag = false;
670
-
671
- // Determine action based on active tool and mouse button
672
- const tool = activeToolRef.current;
673
-
674
- const willOrbit = !(tool === 'pan' || e.button === 1 || e.button === 2 ||
675
- (tool === 'select' && e.shiftKey) ||
676
- (tool !== 'orbit' && tool !== 'select' && e.shiftKey));
677
-
678
- // Set orbit pivot to what user clicks on (standard CAD/BIM behavior)
679
- // Simple and predictable: orbit around clicked geometry, or model center if empty space
680
- if (willOrbit && tool !== 'measure' && tool !== 'walk') {
681
- const rect = canvas.getBoundingClientRect();
682
- const x = e.clientX - rect.left;
683
- const y = e.clientY - rect.top;
684
-
685
- // Pick at cursor position - orbit around what user is clicking on
686
- // Uses visibility filtering so hidden elements don't affect orbit pivot
687
- const pickResult = await renderer.pick(x, y, getPickOptions());
688
- if (pickResult !== null) {
689
- const center = getEntityCenter(geometryRef.current, pickResult.expressId);
690
- if (center) {
691
- camera.setOrbitPivot(center);
692
- } else {
693
- camera.setOrbitPivot(null);
694
- }
695
- } else {
696
- // No geometry under cursor - orbit around current target (model center)
697
- camera.setOrbitPivot(null);
698
- }
699
- }
700
-
701
- if (tool === 'pan' || e.button === 1 || e.button === 2) {
702
- mouseState.isPanning = true;
703
- canvas.style.cursor = 'move';
704
- } else if (tool === 'orbit') {
705
- mouseState.isPanning = false;
706
- canvas.style.cursor = 'grabbing';
707
- } else if (tool === 'select') {
708
- // Select tool: shift+drag = pan, normal drag = orbit
709
- mouseState.isPanning = e.shiftKey;
710
- canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
711
- } else if (tool === 'measure') {
712
- // Measure tool - shift+drag = orbit, normal drag = measure
713
- if (e.shiftKey) {
714
- // Shift pressed: allow orbit (not pan) when no measurement is active
715
- mouseState.isDragging = true;
716
- mouseState.isPanning = false;
717
- canvas.style.cursor = 'grabbing';
718
- // Fall through to allow orbit handling in mousemove
719
- } else {
720
- // Normal drag: start measurement
721
- mouseState.isDragging = true; // Mark as dragging for measure tool
722
- canvas.style.cursor = 'crosshair';
723
-
724
- // Calculate canvas-relative coordinates
725
- const rect = canvas.getBoundingClientRect();
726
- const x = e.clientX - rect.left;
727
- const y = e.clientY - rect.top;
728
-
729
- // Use magnetic snap for better edge locking
730
- const currentLock = edgeLockStateRef.current;
731
- const result = renderer.raycastSceneMagnetic(x, y, {
732
- edge: currentLock.edge,
733
- meshExpressId: currentLock.meshExpressId,
734
- lockStrength: currentLock.lockStrength,
735
- }, {
736
- hiddenIds: hiddenEntitiesRef.current,
737
- isolatedIds: isolatedEntitiesRef.current,
738
- snapOptions: snapEnabled ? {
739
- snapToVertices: true,
740
- snapToEdges: true,
741
- snapToFaces: true,
742
- screenSnapRadius: 60,
743
- } : {
744
- snapToVertices: false,
745
- snapToEdges: false,
746
- snapToFaces: false,
747
- screenSnapRadius: 0,
748
- },
749
- });
750
-
751
- if (result.intersection || result.snapTarget) {
752
- const snapPoint = result.snapTarget || result.intersection;
753
- const pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
754
-
755
- if (pos) {
756
- // Project snapped 3D position to screen - measurement starts from indicator, not cursor
757
- const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
758
- const measurePoint: MeasurePoint = {
759
- x: pos.x,
760
- y: pos.y,
761
- z: pos.z,
762
- screenX: screenPos?.x ?? x,
763
- screenY: screenPos?.y ?? y,
764
- };
765
-
766
- startMeasurement(measurePoint);
767
-
768
- if (result.snapTarget) {
769
- setSnapTarget(result.snapTarget);
770
- }
771
-
772
- // Update edge lock state
773
- if (result.edgeLock.shouldRelease) {
774
- clearEdgeLock();
775
- updateSnapVisualization(result.snapTarget || null);
776
- } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
777
- setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId, result.edgeLock.edgeT);
778
- updateSnapVisualization(result.snapTarget, {
779
- edgeT: result.edgeLock.edgeT,
780
- isCorner: result.edgeLock.isCorner,
781
- cornerValence: result.edgeLock.cornerValence,
782
- });
783
- } else {
784
- updateSnapVisualization(result.snapTarget);
785
- }
786
-
787
- // Set up orthogonal constraint for shift+drag - always use world axes
788
- setMeasurementConstraintEdge({
789
- axes: {
790
- axis1: { x: 1, y: 0, z: 0 }, // World X
791
- axis2: { x: 0, y: 1, z: 0 }, // World Y (vertical)
792
- axis3: { x: 0, y: 0, z: 1 }, // World Z
793
- },
794
- colors: {
795
- axis1: '#F44336', // Red - X axis
796
- axis2: '#8BC34A', // Lime - Y axis (vertical)
797
- axis3: '#2196F3', // Blue - Z axis
798
- },
799
- activeAxis: null,
800
- });
801
- }
802
- }
803
- return; // Early return for measure tool (non-shift)
804
- }
805
- } else {
806
- // Default behavior
807
- mouseState.isPanning = e.shiftKey;
808
- canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
809
- }
810
- });
811
-
812
- canvas.addEventListener('mousemove', async (e) => {
813
- const rect = canvas.getBoundingClientRect();
814
- const x = e.clientX - rect.left;
815
- const y = e.clientY - rect.top;
816
- const tool = activeToolRef.current;
817
-
818
- // Handle measure tool live preview while dragging
819
- // IMPORTANT: Check tool first, not activeMeasurement, to prevent orbit conflict
820
- if (tool === 'measure' && mouseState.isDragging && activeMeasurementRef.current) {
821
- // Only process measurement dragging if we have an active measurement
822
- // If shift is held without active measurement, fall through to orbit handling
823
-
824
- // Check if shift is held for orthogonal constraint
825
- const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
826
-
827
- // Throttle raycasting to 60fps max using requestAnimationFrame
828
- if (!measureRaycastPendingRef.current) {
829
- measureRaycastPendingRef.current = true;
830
-
831
- measureRaycastFrameRef.current = requestAnimationFrame(() => {
832
- measureRaycastPendingRef.current = false;
833
- measureRaycastFrameRef.current = null;
834
-
835
- const raycastStart = performance.now();
836
-
837
- // When using orthogonal constraint (shift held), use simpler raycasting
838
- // since the final position will be projected onto an axis anyway
839
- const snapEnabled = snapEnabledRef.current && !useOrthogonalConstraint;
840
-
841
- // If last raycast was slow, reduce complexity to prevent UI freezes
842
- const wasSlowLastTime = lastMeasureRaycastDurationRef.current > SLOW_RAYCAST_THRESHOLD_MS;
843
- const reduceComplexity = wasSlowLastTime && !useOrthogonalConstraint;
844
-
845
- // Use magnetic snap for edge sliding behavior (only when not in orthogonal mode)
846
- const currentLock = useOrthogonalConstraint
847
- ? { edge: null, meshExpressId: null, lockStrength: 0 }
848
- : edgeLockStateRef.current;
849
-
850
- const result = renderer.raycastSceneMagnetic(x, y, {
851
- edge: currentLock.edge,
852
- meshExpressId: currentLock.meshExpressId,
853
- lockStrength: currentLock.lockStrength,
854
- }, {
855
- hiddenIds: hiddenEntitiesRef.current,
856
- isolatedIds: isolatedEntitiesRef.current,
857
- // Reduce snap complexity when using orthogonal constraint or when slow
858
- snapOptions: snapEnabled ? {
859
- snapToVertices: !reduceComplexity, // Skip vertex snapping when slow
860
- snapToEdges: true,
861
- snapToFaces: true,
862
- screenSnapRadius: reduceComplexity ? 40 : 60, // Smaller radius when slow
863
- } : useOrthogonalConstraint ? {
864
- // In orthogonal mode, snap to edges and vertices only (no faces)
865
- snapToVertices: true,
866
- snapToEdges: true,
867
- snapToFaces: false,
868
- screenSnapRadius: 40,
869
- } : {
870
- snapToVertices: false,
871
- snapToEdges: false,
872
- snapToFaces: false,
873
- screenSnapRadius: 0,
874
- },
875
- });
876
-
877
- // Track raycast duration for adaptive throttling
878
- lastMeasureRaycastDurationRef.current = performance.now() - raycastStart;
879
-
880
- if (result.intersection || result.snapTarget) {
881
- const snapPoint = result.snapTarget || result.intersection;
882
- let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
883
-
884
- if (pos) {
885
- // Apply orthogonal constraint if shift is held and we have a constraint
886
- if (useOrthogonalConstraint && activeMeasurementRef.current) {
887
- const constraint = measurementConstraintEdgeRef.current!;
888
- const start = activeMeasurementRef.current.start;
889
-
890
- // Vector from start to cursor position
891
- const dx = pos.x - start.x;
892
- const dy = pos.y - start.y;
893
- const dz = pos.z - start.z;
894
-
895
- // Calculate dot product with each orthogonal axis
896
- const { axis1, axis2, axis3 } = constraint.axes;
897
- const dot1 = dx * axis1.x + dy * axis1.y + dz * axis1.z;
898
- const dot2 = dx * axis2.x + dy * axis2.y + dz * axis2.z;
899
- const dot3 = dx * axis3.x + dy * axis3.y + dz * axis3.z;
900
-
901
- // Find the axis with the largest absolute dot product (closest to cursor direction)
902
- const absDot1 = Math.abs(dot1);
903
- const absDot2 = Math.abs(dot2);
904
- const absDot3 = Math.abs(dot3);
905
-
906
- let activeAxis: 'axis1' | 'axis2' | 'axis3';
907
- let chosenDot: number;
908
- let chosenDir: { x: number; y: number; z: number };
909
-
910
- if (absDot1 >= absDot2 && absDot1 >= absDot3) {
911
- activeAxis = 'axis1';
912
- chosenDot = dot1;
913
- chosenDir = axis1;
914
- } else if (absDot2 >= absDot3) {
915
- activeAxis = 'axis2';
916
- chosenDot = dot2;
917
- chosenDir = axis2;
918
- } else {
919
- activeAxis = 'axis3';
920
- chosenDot = dot3;
921
- chosenDir = axis3;
922
- }
923
-
924
- // Project cursor position onto the chosen axis
925
- pos = {
926
- x: start.x + chosenDot * chosenDir.x,
927
- y: start.y + chosenDot * chosenDir.y,
928
- z: start.z + chosenDot * chosenDir.z,
929
- };
930
-
931
- // Update active axis for visualization
932
- updateConstraintActiveAxis(activeAxis);
933
- } else if (!useOrthogonalConstraint && measurementConstraintEdgeRef.current?.activeAxis) {
934
- // Clear active axis when shift is released
935
- updateConstraintActiveAxis(null);
936
- }
937
-
938
- // Project snapped 3D position to screen - indicator position, not raw cursor
939
- const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
940
- const measurePoint: MeasurePoint = {
941
- x: pos.x,
942
- y: pos.y,
943
- z: pos.z,
944
- screenX: screenPos?.x ?? x,
945
- screenY: screenPos?.y ?? y,
946
- };
947
-
948
- updateMeasurement(measurePoint);
949
- setSnapTarget(result.snapTarget || null);
950
-
951
- // Update edge lock state and snap visualization (even in orthogonal mode)
952
- if (result.edgeLock.shouldRelease) {
953
- clearEdgeLock();
954
- updateSnapVisualization(result.snapTarget || null);
955
- } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
956
- // Check if we're on the same edge to preserve lock strength (hysteresis)
957
- const sameDirection = currentLock.edge &&
958
- Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v0.x) < 0.0001 &&
959
- Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v0.y) < 0.0001 &&
960
- Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v0.z) < 0.0001 &&
961
- Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v1.x) < 0.0001 &&
962
- Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v1.y) < 0.0001 &&
963
- Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v1.z) < 0.0001;
964
- const reversedDirection = currentLock.edge &&
965
- Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v1.x) < 0.0001 &&
966
- Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v1.y) < 0.0001 &&
967
- Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v1.z) < 0.0001 &&
968
- Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v0.x) < 0.0001 &&
969
- Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v0.y) < 0.0001 &&
970
- Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v0.z) < 0.0001;
971
- const isSameEdge = currentLock.edge &&
972
- currentLock.meshExpressId === result.edgeLock.meshExpressId &&
973
- (sameDirection || reversedDirection);
974
-
975
- if (isSameEdge) {
976
- updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
977
- incrementEdgeLockStrength();
978
- } else {
979
- setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId, result.edgeLock.edgeT);
980
- updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
981
- }
982
- updateSnapVisualization(result.snapTarget, {
983
- edgeT: result.edgeLock.edgeT,
984
- isCorner: result.edgeLock.isCorner,
985
- cornerValence: result.edgeLock.cornerValence,
986
- });
987
- } else {
988
- updateSnapVisualization(result.snapTarget || null);
989
- }
990
- }
991
- }
992
- });
993
- }
994
-
995
- // Mark as dragged (any movement counts for measure tool)
996
- mouseState.didDrag = true;
997
- return;
998
- }
999
-
1000
- // Handle measure tool hover preview (BEFORE dragging starts)
1001
- // Show snap indicators to help user see where they can snap
1002
- if (tool === 'measure' && !mouseState.isDragging && snapEnabledRef.current) {
1003
- // Throttle hover snap detection more aggressively (100ms) to avoid performance issues
1004
- // Active measurement still uses 60fps throttling via requestAnimationFrame
1005
- const now = Date.now();
1006
- if (now - lastHoverSnapTimeRef.current < HOVER_SNAP_THROTTLE_MS) {
1007
- return; // Skip hover snap detection if throttled
1008
- }
1009
- lastHoverSnapTimeRef.current = now;
1010
-
1011
- // Throttle raycasting to avoid performance issues
1012
- if (!measureRaycastPendingRef.current) {
1013
- measureRaycastPendingRef.current = true;
1014
-
1015
- measureRaycastFrameRef.current = requestAnimationFrame(() => {
1016
- measureRaycastPendingRef.current = false;
1017
- measureRaycastFrameRef.current = null;
1018
-
1019
- // Use magnetic snap for hover preview
1020
- const currentLock = edgeLockStateRef.current;
1021
- const result = renderer.raycastSceneMagnetic(x, y, {
1022
- edge: currentLock.edge,
1023
- meshExpressId: currentLock.meshExpressId,
1024
- lockStrength: currentLock.lockStrength,
1025
- }, {
1026
- hiddenIds: hiddenEntitiesRef.current,
1027
- isolatedIds: isolatedEntitiesRef.current,
1028
- snapOptions: {
1029
- snapToVertices: true,
1030
- snapToEdges: true,
1031
- snapToFaces: true,
1032
- screenSnapRadius: 40, // Good radius for hover snap detection
1033
- },
1034
- });
1035
-
1036
- // Update snap target for visual feedback
1037
- if (result.snapTarget) {
1038
- setSnapTarget(result.snapTarget);
1039
-
1040
- // Update edge lock state for hover
1041
- if (result.edgeLock.shouldRelease) {
1042
- // Clear stale lock when release is signaled
1043
- clearEdgeLock();
1044
- updateSnapVisualization(result.snapTarget);
1045
- } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
1046
- setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId, result.edgeLock.edgeT);
1047
- updateSnapVisualization(result.snapTarget, {
1048
- edgeT: result.edgeLock.edgeT,
1049
- isCorner: result.edgeLock.isCorner,
1050
- cornerValence: result.edgeLock.cornerValence,
1051
- });
1052
- } else {
1053
- updateSnapVisualization(result.snapTarget);
1054
- }
1055
- } else {
1056
- setSnapTarget(null);
1057
- clearEdgeLock();
1058
- updateSnapVisualization(null);
1059
- }
1060
- });
1061
- }
1062
- return; // Don't fall through to other tool handlers
1063
- }
1064
-
1065
- // Handle orbit/pan for other tools (or measure tool with shift+drag or no active measurement)
1066
- if (mouseState.isDragging && (tool !== 'measure' || !activeMeasurementRef.current)) {
1067
- const dx = e.clientX - mouseState.lastX;
1068
- const dy = e.clientY - mouseState.lastY;
1069
-
1070
- // Check if this counts as a drag (moved more than 5px from start)
1071
- const totalDx = e.clientX - mouseState.startX;
1072
- const totalDy = e.clientY - mouseState.startY;
1073
- if (Math.abs(totalDx) > 5 || Math.abs(totalDy) > 5) {
1074
- mouseState.didDrag = true;
1075
- }
1076
-
1077
- // Always update camera state immediately (feels responsive)
1078
- if (mouseState.isPanning || tool === 'pan') {
1079
- // Negate dy: mouse Y increases downward, but we want upward drag to pan up
1080
- camera.pan(dx, -dy, false);
1081
- } else if (tool === 'walk') {
1082
- // Walk mode: left/right rotates, up/down moves forward/backward
1083
- camera.orbit(dx * 0.5, 0, false); // Only horizontal rotation
1084
- if (Math.abs(dy) > 2) {
1085
- camera.zoom(dy * 2, false); // Forward/backward movement
1086
- }
1087
- } else {
1088
- camera.orbit(dx, dy, false);
1089
- }
1090
-
1091
- mouseState.lastX = e.clientX;
1092
- mouseState.lastY = e.clientY;
1093
-
1094
- // PERFORMANCE: Adaptive throttle based on model size
1095
- // Small models: 60fps, Large: 40fps, Huge: 30fps
1096
- const meshCount = geometryRef.current?.length ?? 0;
1097
- const throttleMs = meshCount > 50000 ? RENDER_THROTTLE_MS_HUGE
1098
- : meshCount > 10000 ? RENDER_THROTTLE_MS_LARGE
1099
- : RENDER_THROTTLE_MS_SMALL;
1100
-
1101
- const now = performance.now();
1102
- if (now - lastRenderTimeRef.current >= throttleMs) {
1103
- lastRenderTimeRef.current = now;
1104
- renderer.render({
1105
- hiddenIds: hiddenEntitiesRef.current,
1106
- isolatedIds: isolatedEntitiesRef.current,
1107
- selectedId: selectedEntityIdRef.current,
1108
- selectedModelIndex: selectedModelIndexRef.current,
1109
- clearColor: clearColorRef.current,
1110
- sectionPlane: activeToolRef.current === 'section' ? {
1111
- ...sectionPlaneRef.current,
1112
- min: sectionRangeRef.current?.min,
1113
- max: sectionRangeRef.current?.max,
1114
- } : undefined,
1115
- });
1116
- // Update ViewCube rotation in real-time during drag
1117
- updateCameraRotationRealtime(camera.getRotation());
1118
- calculateScale();
1119
- } else if (!renderPendingRef.current) {
1120
- // Schedule a final render for when throttle expires
1121
- // This ensures we always render the final position
1122
- renderPendingRef.current = true;
1123
- requestAnimationFrame(() => {
1124
- renderPendingRef.current = false;
1125
- renderer.render({
1126
- hiddenIds: hiddenEntitiesRef.current,
1127
- isolatedIds: isolatedEntitiesRef.current,
1128
- selectedId: selectedEntityIdRef.current,
1129
- selectedModelIndex: selectedModelIndexRef.current,
1130
- clearColor: clearColorRef.current,
1131
- sectionPlane: activeToolRef.current === 'section' ? {
1132
- ...sectionPlaneRef.current,
1133
- min: sectionRangeRef.current?.min,
1134
- max: sectionRangeRef.current?.max,
1135
- } : undefined,
1136
- });
1137
- updateCameraRotationRealtime(camera.getRotation());
1138
- calculateScale();
1139
- });
1140
- }
1141
- // Clear hover while dragging
1142
- clearHover();
1143
- } else if (hoverTooltipsEnabledRef.current) {
1144
- // Hover detection (throttled) - only if tooltips are enabled
1145
- const now = Date.now();
1146
- if (now - lastHoverCheckRef.current > hoverThrottleMs) {
1147
- lastHoverCheckRef.current = now;
1148
- // Uses visibility filtering so hidden elements don't show hover tooltips
1149
- const pickResult = await renderer.pick(x, y, getPickOptions());
1150
- if (pickResult) {
1151
- setHoverState({ entityId: pickResult.expressId, screenX: e.clientX, screenY: e.clientY });
1152
- } else {
1153
- clearHover();
1154
- }
1155
- }
1156
- }
1157
- });
1158
-
1159
- canvas.addEventListener('mouseup', (e) => {
1160
- const tool = activeToolRef.current;
1161
-
1162
- // Handle measure tool completion
1163
- if (tool === 'measure' && activeMeasurementRef.current) {
1164
- // Cancel any pending raycast to avoid stale updates
1165
- if (measureRaycastFrameRef.current) {
1166
- cancelAnimationFrame(measureRaycastFrameRef.current);
1167
- measureRaycastFrameRef.current = null;
1168
- measureRaycastPendingRef.current = false;
1169
- }
1170
-
1171
- // Do a final synchronous raycast at the mouseup position to ensure accurate end point
1172
- const rect = canvas.getBoundingClientRect();
1173
- const x = e.clientX - rect.left;
1174
- const y = e.clientY - rect.top;
1175
-
1176
- const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
1177
- const currentLock = edgeLockStateRef.current;
1178
-
1179
- // Use simpler snap options in orthogonal mode (no magnetic locking needed)
1180
- const finalLock = useOrthogonalConstraint
1181
- ? { edge: null, meshExpressId: null, lockStrength: 0 }
1182
- : currentLock;
1183
-
1184
- const result = renderer.raycastSceneMagnetic(x, y, {
1185
- edge: finalLock.edge,
1186
- meshExpressId: finalLock.meshExpressId,
1187
- lockStrength: finalLock.lockStrength,
1188
- }, {
1189
- hiddenIds: hiddenEntitiesRef.current,
1190
- isolatedIds: isolatedEntitiesRef.current,
1191
- snapOptions: snapEnabledRef.current && !useOrthogonalConstraint ? {
1192
- snapToVertices: true,
1193
- snapToEdges: true,
1194
- snapToFaces: true,
1195
- screenSnapRadius: 60,
1196
- } : useOrthogonalConstraint ? {
1197
- // In orthogonal mode, snap to edges and vertices only (no faces)
1198
- snapToVertices: true,
1199
- snapToEdges: true,
1200
- snapToFaces: false,
1201
- screenSnapRadius: 40,
1202
- } : {
1203
- snapToVertices: false,
1204
- snapToEdges: false,
1205
- snapToFaces: false,
1206
- screenSnapRadius: 0,
1207
- },
1208
- });
1209
-
1210
- // Update measurement with final position before finalizing
1211
- if (result.intersection || result.snapTarget) {
1212
- const snapPoint = result.snapTarget || result.intersection;
1213
- let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
1214
-
1215
- if (pos) {
1216
- // Apply orthogonal constraint if shift is held
1217
- if (useOrthogonalConstraint && activeMeasurementRef.current) {
1218
- const constraint = measurementConstraintEdgeRef.current!;
1219
- const start = activeMeasurementRef.current.start;
1220
-
1221
- const dx = pos.x - start.x;
1222
- const dy = pos.y - start.y;
1223
- const dz = pos.z - start.z;
1224
-
1225
- const { axis1, axis2, axis3 } = constraint.axes;
1226
- const dot1 = dx * axis1.x + dy * axis1.y + dz * axis1.z;
1227
- const dot2 = dx * axis2.x + dy * axis2.y + dz * axis2.z;
1228
- const dot3 = dx * axis3.x + dy * axis3.y + dz * axis3.z;
1229
-
1230
- const absDot1 = Math.abs(dot1);
1231
- const absDot2 = Math.abs(dot2);
1232
- const absDot3 = Math.abs(dot3);
1233
-
1234
- let chosenDot: number;
1235
- let chosenDir: { x: number; y: number; z: number };
1236
-
1237
- if (absDot1 >= absDot2 && absDot1 >= absDot3) {
1238
- chosenDot = dot1;
1239
- chosenDir = axis1;
1240
- } else if (absDot2 >= absDot3) {
1241
- chosenDot = dot2;
1242
- chosenDir = axis2;
1243
- } else {
1244
- chosenDot = dot3;
1245
- chosenDir = axis3;
1246
- }
1247
-
1248
- pos = {
1249
- x: start.x + chosenDot * chosenDir.x,
1250
- y: start.y + chosenDot * chosenDir.y,
1251
- z: start.z + chosenDot * chosenDir.z,
1252
- };
1253
- }
1254
-
1255
- const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
1256
- const measurePoint: MeasurePoint = {
1257
- x: pos.x,
1258
- y: pos.y,
1259
- z: pos.z,
1260
- screenX: screenPos?.x ?? x,
1261
- screenY: screenPos?.y ?? y,
1262
- };
1263
- updateMeasurement(measurePoint);
1264
- }
1265
- }
1266
-
1267
- finalizeMeasurement();
1268
- clearEdgeLock(); // Clear edge lock after measurement complete
1269
- mouseState.isDragging = false;
1270
- mouseState.didDrag = false;
1271
- canvas.style.cursor = 'crosshair';
1272
- return;
1273
- }
1274
-
1275
- mouseState.isDragging = false;
1276
- mouseState.isPanning = false;
1277
- canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : (tool === 'measure' ? 'crosshair' : 'default'));
1278
- // Clear orbit pivot after each orbit operation
1279
- camera.setOrbitPivot(null);
1280
- });
1281
-
1282
- canvas.addEventListener('mouseleave', () => {
1283
- const tool = activeToolRef.current;
1284
- mouseState.isDragging = false;
1285
- mouseState.isPanning = false;
1286
- camera.stopInertia();
1287
- camera.setOrbitPivot(null);
1288
- // Restore cursor based on active tool
1289
- if (tool === 'measure') {
1290
- canvas.style.cursor = 'crosshair';
1291
- } else if (tool === 'pan' || tool === 'orbit') {
1292
- canvas.style.cursor = 'grab';
1293
- } else {
1294
- canvas.style.cursor = 'default';
1295
- }
1296
- clearHover();
1297
- });
1298
-
1299
- canvas.addEventListener('contextmenu', async (e) => {
1300
- e.preventDefault();
1301
- const rect = canvas.getBoundingClientRect();
1302
- const x = e.clientX - rect.left;
1303
- const y = e.clientY - rect.top;
1304
- // Uses visibility filtering so hidden elements don't appear in context menu
1305
- const pickResult = await renderer.pick(x, y, getPickOptions());
1306
- openContextMenu(pickResult?.expressId ?? null, e.clientX, e.clientY);
1307
- });
1308
-
1309
- canvas.addEventListener('wheel', (e) => {
1310
- e.preventDefault();
1311
- const rect = canvas.getBoundingClientRect();
1312
- const mouseX = e.clientX - rect.left;
1313
- const mouseY = e.clientY - rect.top;
1314
- camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
1315
- renderer.render({
1316
- hiddenIds: hiddenEntitiesRef.current,
1317
- isolatedIds: isolatedEntitiesRef.current,
1318
- selectedId: selectedEntityIdRef.current,
1319
- selectedModelIndex: selectedModelIndexRef.current,
1320
- clearColor: clearColorRef.current,
1321
- sectionPlane: activeToolRef.current === 'section' ? {
1322
- ...sectionPlaneRef.current,
1323
- min: sectionRangeRef.current?.min,
1324
- max: sectionRangeRef.current?.max,
1325
- } : undefined,
1326
- });
1327
- // Update measurement screen coordinates immediately during zoom (only in measure mode)
1328
- if (activeToolRef.current === 'measure') {
1329
- const state = useViewerStore.getState();
1330
- if (state.measurements.length > 0 || state.activeMeasurement) {
1331
- updateMeasurementScreenCoords((worldPos) => {
1332
- return camera.projectToScreen(worldPos, canvas.width, canvas.height);
1333
- });
1334
- // Update camera state tracking to prevent duplicate update in animation loop
1335
- const cameraPos = camera.getPosition();
1336
- const cameraRot = camera.getRotation();
1337
- const cameraDist = camera.getDistance();
1338
- lastCameraStateRef.current = {
1339
- position: cameraPos,
1340
- rotation: cameraRot,
1341
- distance: cameraDist,
1342
- canvasWidth: canvas.width,
1343
- canvasHeight: canvas.height,
1344
- };
1345
- }
1346
- }
1347
- calculateScale();
1348
- });
1349
-
1350
- // Click handling
1351
- canvas.addEventListener('click', async (e) => {
1352
- const rect = canvas.getBoundingClientRect();
1353
- const x = e.clientX - rect.left;
1354
- const y = e.clientY - rect.top;
1355
- const tool = activeToolRef.current;
1356
-
1357
- // Skip selection if user was dragging (orbiting/panning)
1358
- if (mouseState.didDrag) {
1359
- return;
1360
- }
1361
-
1362
- // Skip selection for orbit/pan tools - they don't select
1363
- if (tool === 'orbit' || tool === 'pan' || tool === 'walk') {
1364
- return;
1365
- }
1366
-
1367
- // Measure tool now uses drag interaction (see mousedown/mousemove/mouseup)
1368
- if (tool === 'measure') {
1369
- return; // Skip click handling for measure tool
1370
- }
1371
-
1372
- const now = Date.now();
1373
- const timeSinceLastClick = now - lastClickTimeRef.current;
1374
- const clickPos = { x, y };
1375
-
1376
- if (lastClickPosRef.current &&
1377
- timeSinceLastClick < 300 &&
1378
- Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
1379
- Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
1380
- // Double-click - isolate element
1381
- // Uses visibility filtering so only visible elements can be selected
1382
- const pickResult = await renderer.pick(x, y, getPickOptions());
1383
- if (pickResult) {
1384
- handlePickForSelection(pickResult);
1385
- }
1386
- lastClickTimeRef.current = 0;
1387
- lastClickPosRef.current = null;
1388
- } else {
1389
- // Single click - uses visibility filtering so only visible elements can be selected
1390
- const pickResult = await renderer.pick(x, y, getPickOptions());
1391
-
1392
- // Multi-selection with Ctrl/Cmd
1393
- if (e.ctrlKey || e.metaKey) {
1394
- if (pickResult) {
1395
- toggleSelection(pickResult.expressId);
1396
- }
1397
- } else {
1398
- handlePickForSelection(pickResult);
1399
- }
1400
-
1401
- lastClickTimeRef.current = now;
1402
- lastClickPosRef.current = clickPos;
1403
- }
1404
- });
1405
-
1406
-
1407
- // Helper function to get approximate world position (for measurement tool)
1408
- function getApproximateWorldPosition(
1409
- geom: MeshData[] | null,
1410
- entityId: number,
1411
- _screenX: number,
1412
- _screenY: number,
1413
- _canvasWidth: number,
1414
- _canvasHeight: number
1415
- ): { x: number; y: number; z: number } {
1416
- return getEntityCenter(geom, entityId) || { x: 0, y: 0, z: 0 };
1417
- }
1418
-
1419
- // Touch controls
1420
- canvas.addEventListener('touchstart', async (e) => {
1421
- e.preventDefault();
1422
- touchState.touches = Array.from(e.touches);
1423
-
1424
- // Track multi-touch to prevent false tap-select after pinch/zoom
1425
- if (touchState.touches.length > 1) {
1426
- touchState.multiTouch = true;
1427
- }
1428
-
1429
- if (touchState.touches.length === 1 && !touchState.multiTouch) {
1430
- touchState.lastCenter = {
1431
- x: touchState.touches[0].clientX,
1432
- y: touchState.touches[0].clientY,
1433
- };
1434
- // Record tap start for tap-to-select detection
1435
- touchState.tapStartTime = Date.now();
1436
- touchState.tapStartPos = {
1437
- x: touchState.touches[0].clientX,
1438
- y: touchState.touches[0].clientY,
1439
- };
1440
- touchState.didMove = false;
1441
-
1442
- // Set orbit pivot to what user touches (same as mouse click behavior)
1443
- const rect = canvas.getBoundingClientRect();
1444
- const x = touchState.touches[0].clientX - rect.left;
1445
- const y = touchState.touches[0].clientY - rect.top;
1446
-
1447
- // Uses visibility filtering so hidden elements don't affect orbit pivot
1448
- const pickResult = await renderer.pick(x, y, getPickOptions());
1449
- if (pickResult !== null) {
1450
- const center = getEntityCenter(geometryRef.current, pickResult.expressId);
1451
- if (center) {
1452
- camera.setOrbitPivot(center);
1453
- } else {
1454
- camera.setOrbitPivot(null);
1455
- }
1456
- } else {
1457
- camera.setOrbitPivot(null);
1458
- }
1459
- } else if (touchState.touches.length === 1) {
1460
- // Single touch after multi-touch - just update center for orbit
1461
- touchState.lastCenter = {
1462
- x: touchState.touches[0].clientX,
1463
- y: touchState.touches[0].clientY,
1464
- };
1465
- } else if (touchState.touches.length === 2) {
1466
- const dx = touchState.touches[1].clientX - touchState.touches[0].clientX;
1467
- const dy = touchState.touches[1].clientY - touchState.touches[0].clientY;
1468
- touchState.lastDistance = Math.sqrt(dx * dx + dy * dy);
1469
- touchState.lastCenter = {
1470
- x: (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2,
1471
- y: (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2,
1472
- };
1473
- }
1474
- });
1475
-
1476
- canvas.addEventListener('touchmove', (e) => {
1477
- e.preventDefault();
1478
- touchState.touches = Array.from(e.touches);
1479
-
1480
- if (touchState.touches.length === 1) {
1481
- const dx = touchState.touches[0].clientX - touchState.lastCenter.x;
1482
- const dy = touchState.touches[0].clientY - touchState.lastCenter.y;
1483
-
1484
- // Mark as moved if significant movement (prevents tap-select during drag)
1485
- const totalDx = touchState.touches[0].clientX - touchState.tapStartPos.x;
1486
- const totalDy = touchState.touches[0].clientY - touchState.tapStartPos.y;
1487
- if (Math.abs(totalDx) > 10 || Math.abs(totalDy) > 10) {
1488
- touchState.didMove = true;
1489
- }
1490
-
1491
- camera.orbit(dx, dy, false);
1492
- touchState.lastCenter = {
1493
- x: touchState.touches[0].clientX,
1494
- y: touchState.touches[0].clientY,
1495
- };
1496
- renderer.render({
1497
- hiddenIds: hiddenEntitiesRef.current,
1498
- isolatedIds: isolatedEntitiesRef.current,
1499
- selectedId: selectedEntityIdRef.current,
1500
- selectedModelIndex: selectedModelIndexRef.current,
1501
- clearColor: clearColorRef.current,
1502
- sectionPlane: activeToolRef.current === 'section' ? {
1503
- ...sectionPlaneRef.current,
1504
- min: sectionRangeRef.current?.min,
1505
- max: sectionRangeRef.current?.max,
1506
- } : undefined,
1507
- });
1508
- } else if (touchState.touches.length === 2) {
1509
- const dx1 = touchState.touches[1].clientX - touchState.touches[0].clientX;
1510
- const dy1 = touchState.touches[1].clientY - touchState.touches[0].clientY;
1511
- const distance = Math.sqrt(dx1 * dx1 + dy1 * dy1);
1512
-
1513
- const centerX = (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2;
1514
- const centerY = (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2;
1515
- const panDx = centerX - touchState.lastCenter.x;
1516
- const panDy = centerY - touchState.lastCenter.y;
1517
- camera.pan(panDx, panDy, false);
1518
-
1519
- const zoomDelta = distance - touchState.lastDistance;
1520
- const rect = canvas.getBoundingClientRect();
1521
- camera.zoom(zoomDelta * 10, false, centerX - rect.left, centerY - rect.top, canvas.width, canvas.height);
1522
-
1523
- touchState.lastDistance = distance;
1524
- touchState.lastCenter = { x: centerX, y: centerY };
1525
- renderer.render({
1526
- hiddenIds: hiddenEntitiesRef.current,
1527
- isolatedIds: isolatedEntitiesRef.current,
1528
- selectedId: selectedEntityIdRef.current,
1529
- selectedModelIndex: selectedModelIndexRef.current,
1530
- clearColor: clearColorRef.current,
1531
- sectionPlane: activeToolRef.current === 'section' ? {
1532
- ...sectionPlaneRef.current,
1533
- min: sectionRangeRef.current?.min,
1534
- max: sectionRangeRef.current?.max,
1535
- } : undefined,
1536
- });
1537
- }
1538
- });
1539
-
1540
- canvas.addEventListener('touchend', async (e) => {
1541
- e.preventDefault();
1542
- const previousTouchCount = touchState.touches.length;
1543
- const wasMultiTouch = touchState.multiTouch;
1544
- touchState.touches = Array.from(e.touches);
1545
-
1546
- if (touchState.touches.length === 0) {
1547
- camera.stopInertia();
1548
- camera.setOrbitPivot(null);
1549
-
1550
- // Tap-to-select: detect quick tap without significant movement
1551
- const tapDuration = Date.now() - touchState.tapStartTime;
1552
- const tool = activeToolRef.current;
1553
-
1554
- // Only select if:
1555
- // - Was a single-finger touch (not after multi-touch gesture)
1556
- // - Tap was quick (< 300ms)
1557
- // - Didn't move significantly
1558
- // - Tool supports selection (not orbit/pan/walk/measure)
1559
- if (
1560
- previousTouchCount === 1 &&
1561
- !wasMultiTouch &&
1562
- tapDuration < 300 &&
1563
- !touchState.didMove &&
1564
- tool !== 'orbit' &&
1565
- tool !== 'pan' &&
1566
- tool !== 'walk' &&
1567
- tool !== 'measure'
1568
- ) {
1569
- const rect = canvas.getBoundingClientRect();
1570
- const x = touchState.tapStartPos.x - rect.left;
1571
- const y = touchState.tapStartPos.y - rect.top;
1572
-
1573
- const pickResult = await renderer.pick(x, y, getPickOptions());
1574
- handlePickForSelection(pickResult);
1575
- }
1576
-
1577
- // Reset multi-touch flag when all touches end
1578
- touchState.multiTouch = false;
1579
- }
1580
- });
1581
-
1582
- // Keyboard controls
1583
- const keyState: { [key: string]: boolean } = {};
1584
-
1585
- const handleKeyDown = (e: KeyboardEvent) => {
1586
- if (document.activeElement?.tagName === 'INPUT' ||
1587
- document.activeElement?.tagName === 'TEXTAREA') {
1588
- return;
1589
- }
1590
-
1591
- keyState[e.key.toLowerCase()] = true;
1592
-
1593
- // Preset views - set view and re-render
1594
- const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => {
1595
- const rotation = coordinateInfoRef.current?.buildingRotation;
1596
- camera.setPresetView(view, geometryBoundsRef.current, rotation);
533
+ },
534
+ toggleProjectionMode: () => {
535
+ camera.toggleProjectionMode();
1597
536
  renderer.render({
1598
537
  hiddenIds: hiddenEntitiesRef.current,
1599
538
  isolatedIds: isolatedEntitiesRef.current,
@@ -1606,108 +545,19 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
1606
545
  max: sectionRangeRef.current?.max,
1607
546
  } : undefined,
1608
547
  });
1609
- updateCameraRotationRealtime(camera.getRotation());
1610
548
  calculateScale();
1611
- };
1612
-
1613
- if (e.key === '1') setViewAndRender('top');
1614
- if (e.key === '2') setViewAndRender('bottom');
1615
- if (e.key === '3') setViewAndRender('front');
1616
- if (e.key === '4') setViewAndRender('back');
1617
- if (e.key === '5') setViewAndRender('left');
1618
- if (e.key === '6') setViewAndRender('right');
1619
-
1620
- // Frame selection (F) - zoom to fit selection, or fit all if nothing selected
1621
- if (e.key === 'f' || e.key === 'F') {
1622
- const selectedId = selectedEntityIdRef.current;
1623
- if (selectedId !== null) {
1624
- // Frame selection - zoom to fit selected element
1625
- const bounds = getEntityBounds(geometryRef.current, selectedId);
1626
- if (bounds) {
1627
- camera.frameBounds(bounds.min, bounds.max, 300);
1628
- }
1629
- } else {
1630
- // No selection - fit all
1631
- camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
1632
- }
1633
- calculateScale();
1634
- }
1635
-
1636
- // Home view (H) - reset to isometric
1637
- if (e.key === 'h' || e.key === 'H') {
1638
- camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
1639
- calculateScale();
1640
- }
1641
-
1642
- // Fit all / Zoom extents (Z)
1643
- if (e.key === 'z' || e.key === 'Z') {
1644
- camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
1645
- calculateScale();
1646
- }
1647
-
1648
- // Toggle first-person mode
1649
- if (e.key === 'c' || e.key === 'C') {
1650
- firstPersonModeRef.current = !firstPersonModeRef.current;
1651
- camera.enableFirstPersonMode(firstPersonModeRef.current);
1652
- }
1653
- };
1654
-
1655
- const handleKeyUp = (e: KeyboardEvent) => {
1656
- keyState[e.key.toLowerCase()] = false;
1657
- };
1658
-
1659
- keyboardHandlersRef.current.handleKeyDown = handleKeyDown;
1660
- keyboardHandlersRef.current.handleKeyUp = handleKeyUp;
1661
-
1662
- const keyboardMove = () => {
1663
- if (aborted) return;
1664
-
1665
- let moved = false;
1666
- const panSpeed = 5;
1667
- const zoomSpeed = 0.1;
1668
-
1669
- if (firstPersonModeRef.current) {
1670
- // Arrow keys for first-person navigation (camera-relative)
1671
- if (keyState['arrowup']) { camera.moveFirstPerson(-1, 0, 0); moved = true; }
1672
- if (keyState['arrowdown']) { camera.moveFirstPerson(1, 0, 0); moved = true; }
1673
- if (keyState['arrowleft']) { camera.moveFirstPerson(0, 1, 0); moved = true; }
1674
- if (keyState['arrowright']) { camera.moveFirstPerson(0, -1, 0); moved = true; }
1675
- } else {
1676
- // Arrow keys for panning (camera-relative: arrow direction = camera movement)
1677
- if (keyState['arrowup']) { camera.pan(0, -panSpeed, false); moved = true; }
1678
- if (keyState['arrowdown']) { camera.pan(0, panSpeed, false); moved = true; }
1679
- if (keyState['arrowleft']) { camera.pan(panSpeed, 0, false); moved = true; }
1680
- if (keyState['arrowright']) { camera.pan(-panSpeed, 0, false); moved = true; }
1681
- }
1682
-
1683
- if (moved) {
1684
- renderer.render({
1685
- hiddenIds: hiddenEntitiesRef.current,
1686
- isolatedIds: isolatedEntitiesRef.current,
1687
- selectedId: selectedEntityIdRef.current,
1688
- selectedModelIndex: selectedModelIndexRef.current,
1689
- clearColor: clearColorRef.current,
1690
- sectionPlane: activeToolRef.current === 'section' ? {
1691
- ...sectionPlaneRef.current,
1692
- min: sectionRangeRef.current?.min,
1693
- max: sectionRangeRef.current?.max,
1694
- } : undefined,
1695
- });
1696
- }
1697
- requestAnimationFrame(keyboardMove);
1698
- };
1699
-
1700
- window.addEventListener('keydown', handleKeyDown);
1701
- window.addEventListener('keyup', handleKeyUp);
1702
- keyboardMove();
549
+ },
550
+ getProjectionMode: () => camera.getProjectionMode(),
551
+ });
1703
552
 
553
+ // ResizeObserver
1704
554
  resizeObserver = new ResizeObserver(() => {
1705
555
  if (aborted) return;
1706
556
  const rect = canvas.getBoundingClientRect();
1707
557
  // Use same WebGPU alignment as initialization
1708
- const width = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
1709
- const height = Math.max(1, Math.floor(rect.height));
1710
- renderer.resize(width, height);
558
+ const w = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
559
+ const h = Math.max(1, Math.floor(rect.height));
560
+ renderer.resize(w, h);
1711
561
  renderer.render({
1712
562
  hiddenIds: hiddenEntitiesRef.current,
1713
563
  isolatedIds: isolatedEntitiesRef.current,
@@ -1723,6 +573,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
1723
573
  });
1724
574
  resizeObserver.observe(canvas);
1725
575
 
576
+ // Initial render
1726
577
  renderer.render({
1727
578
  hiddenIds: hiddenEntitiesRef.current,
1728
579
  isolatedIds: isolatedEntitiesRef.current,
@@ -1745,17 +596,6 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
1745
596
  if (resizeObserver) {
1746
597
  resizeObserver.disconnect();
1747
598
  }
1748
- if (keyboardHandlersRef.current.handleKeyDown) {
1749
- window.removeEventListener('keydown', keyboardHandlersRef.current.handleKeyDown);
1750
- }
1751
- if (keyboardHandlersRef.current.handleKeyUp) {
1752
- window.removeEventListener('keyup', keyboardHandlersRef.current.handleKeyUp);
1753
- }
1754
- // Cancel pending raycast requests
1755
- if (measureRaycastFrameRef.current !== null) {
1756
- cancelAnimationFrame(measureRaycastFrameRef.current);
1757
- measureRaycastFrameRef.current = null;
1758
- }
1759
599
  setIsInitialized(false);
1760
600
  rendererRef.current = null;
1761
601
  // Clear BCF global refs to prevent memory leaks
@@ -1766,438 +606,176 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
1766
606
  // Adding selectedEntityId would destroy/recreate the renderer on every selection change
1767
607
  }, [setSelectedEntityId]);
1768
608
 
1769
- // Track processed meshes for incremental updates
1770
- // Uses string keys to support compound keys (expressId:color) for submeshes
1771
- const processedMeshIdsRef = useRef<Set<string>>(new Set());
1772
- const lastGeometryLengthRef = useRef<number>(0);
1773
- const lastGeometryRef = useRef<MeshData[] | null>(null);
1774
- const cameraFittedRef = useRef<boolean>(false);
1775
- const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
1776
-
1777
- // Render throttling during streaming
1778
- const lastStreamRenderTimeRef = useRef<number>(0);
1779
- const STREAM_RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
609
+ // ===== Drawing 2D state for render updates =====
610
+ const drawing2D = useViewerStore((s) => s.drawing2D);
611
+ const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
612
+
613
+ // ===== Streaming progress =====
1780
614
  const progress = useViewerStore((state) => state.progress);
1781
615
  const isStreaming = progress !== null && progress.percent < 100;
1782
616
 
1783
- useEffect(() => {
1784
- const renderer = rendererRef.current;
1785
-
1786
- // Handle geometry cleared/null - reset refs so next load is treated as new file
1787
- if (!geometry) {
1788
- if (lastGeometryLengthRef.current > 0 || lastGeometryRef.current !== null) {
1789
- // Geometry was cleared - reset tracking refs
1790
- lastGeometryLengthRef.current = 0;
1791
- lastGeometryRef.current = null;
1792
- processedMeshIdsRef.current.clear();
1793
- cameraFittedRef.current = false;
1794
- finalBoundsRefittedRef.current = false;
1795
- // Clear scene if renderer is ready
1796
- if (renderer && isInitialized) {
1797
- renderer.getScene().clear();
1798
- renderer.getCamera().reset();
1799
- geometryBoundsRef.current = {
1800
- min: { x: -100, y: -100, z: -100 },
1801
- max: { x: 100, y: 100, z: 100 },
1802
- };
1803
- }
1804
- }
1805
- return;
1806
- }
1807
-
1808
- if (!renderer || !isInitialized) return;
1809
-
1810
- const device = renderer.getGPUDevice();
1811
- if (!device) return;
1812
-
1813
- const scene = renderer.getScene();
1814
- const currentLength = geometry.length;
1815
- const lastLength = lastGeometryLengthRef.current;
1816
-
1817
- // Use length-based detection instead of reference comparison
1818
- // React creates new array references on every appendGeometryBatch call,
1819
- // so reference comparison would always trigger scene.clear()
1820
- const isIncremental = currentLength > lastLength && lastLength > 0;
1821
- const isNewFile = currentLength > 0 && lastLength === 0;
1822
- const isCleared = currentLength === 0;
1823
-
1824
- if (isCleared) {
1825
- // Geometry cleared (could be visibility change or file unload)
1826
- // Clear scene but DON'T reset camera - user may just be hiding models
1827
- scene.clear();
1828
- processedMeshIdsRef.current.clear();
1829
- // Keep cameraFittedRef to preserve camera position when models are shown again
1830
- lastGeometryLengthRef.current = 0;
1831
- lastGeometryRef.current = null;
1832
- // Note: Don't reset camera or bounds - preserve user's view
1833
- return;
1834
- } else if (isNewFile) {
1835
- // New file loaded - reset camera and bounds
1836
- scene.clear();
1837
- processedMeshIdsRef.current.clear();
1838
- cameraFittedRef.current = false;
1839
- finalBoundsRefittedRef.current = false;
1840
- lastGeometryLengthRef.current = 0;
1841
- lastGeometryRef.current = geometry;
1842
- // Reset camera state (clear orbit pivot, stop inertia, cancel animations)
1843
- renderer.getCamera().reset();
1844
- // Reset geometry bounds to default
1845
- geometryBoundsRef.current = {
1846
- min: { x: -100, y: -100, z: -100 },
1847
- max: { x: 100, y: 100, z: 100 },
1848
- };
1849
- } else if (!isIncremental && currentLength !== lastLength) {
1850
- // Length changed but not incremental - could be:
1851
- // 1. Length decreased (model hidden) - DON'T reset camera
1852
- // 2. Length increased but lastLength > 0 (new file loaded while another was open) - DO reset
1853
- const isLengthDecrease = currentLength < lastLength;
1854
-
1855
- if (isLengthDecrease) {
1856
- // Model visibility changed (hidden) - rebuild scene but keep camera
1857
- scene.clear();
1858
- processedMeshIdsRef.current.clear();
1859
- // Don't reset cameraFittedRef - keep current camera position
1860
- lastGeometryLengthRef.current = 0; // Reset so meshes get re-added
1861
- lastGeometryRef.current = geometry;
1862
- // Note: Don't reset camera or bounds - user wants to keep their view
1863
- } else {
1864
- // New file loaded while another was open - full reset
1865
- scene.clear();
1866
- processedMeshIdsRef.current.clear();
1867
- cameraFittedRef.current = false;
1868
- finalBoundsRefittedRef.current = false;
1869
- lastGeometryLengthRef.current = 0;
1870
- lastGeometryRef.current = geometry;
1871
- // Reset camera state
1872
- renderer.getCamera().reset();
1873
- // Reset geometry bounds to default
1874
- geometryBoundsRef.current = {
1875
- min: { x: -100, y: -100, z: -100 },
1876
- max: { x: 100, y: 100, z: 100 },
1877
- };
1878
- }
1879
- } else if (currentLength === lastLength) {
1880
- // No geometry change - but check if we need to update bounds when streaming completes
1881
- if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
1882
- const shiftedBounds = coordinateInfo.shiftedBounds;
1883
- const newMaxSize = Math.max(
1884
- shiftedBounds.max.x - shiftedBounds.min.x,
1885
- shiftedBounds.max.y - shiftedBounds.min.y,
1886
- shiftedBounds.max.z - shiftedBounds.min.z
1887
- );
1888
-
1889
- if (newMaxSize > 0 && Number.isFinite(newMaxSize)) {
1890
- // Only refit camera for LARGE models (>1000 meshes) where geometry streamed in multiple batches
1891
- // Small models complete in one batch, so their initial camera fit is already correct
1892
- const isLargeModel = geometry.length > 1000;
1893
-
1894
- if (isLargeModel) {
1895
- const oldBounds = geometryBoundsRef.current;
1896
- const oldMaxSize = Math.max(
1897
- oldBounds.max.x - oldBounds.min.x,
1898
- oldBounds.max.y - oldBounds.min.y,
1899
- oldBounds.max.z - oldBounds.min.z
1900
- );
1901
-
1902
- // Refit camera if bounds expanded significantly (>10% larger)
1903
- // This handles skyscrapers where upper floors arrive in later batches
1904
- const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
1905
-
1906
- if (boundsExpanded) {
1907
- renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
1908
- }
1909
- }
1910
-
1911
- // Always update bounds for accurate zoom-to-fit, home view, etc.
1912
- geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
1913
- finalBoundsRefittedRef.current = true;
1914
- }
1915
- }
1916
- return;
1917
- }
1918
-
1919
- // For incremental batches: update reference and continue to add new meshes
1920
- if (isIncremental) {
1921
- lastGeometryRef.current = geometry;
1922
- } else if (lastGeometryRef.current === null) {
1923
- lastGeometryRef.current = geometry;
1924
- }
1925
-
1926
- // FIX: When not streaming (type visibility toggle), new meshes can be ANYWHERE in the array,
1927
- // not just at the end. During streaming, new meshes ARE appended, so slice is safe.
1928
- // After streaming completes, filter changes can insert meshes at any position.
1929
- const meshesToAdd = isStreaming
1930
- ? geometry.slice(lastGeometryLengthRef.current) // Streaming: new meshes at end
1931
- : geometry; // Post-streaming: scan entire array for unprocessed meshes
1932
-
1933
- // Filter out already processed meshes
1934
- // NOTE: Multiple meshes can share the same expressId AND same color (e.g., door inner framing pieces),
1935
- // so we use expressId + array index as a compound key to ensure all submeshes are processed.
1936
- const newMeshes: MeshData[] = [];
1937
- const startIndex = isStreaming ? lastGeometryLengthRef.current : 0;
1938
- for (let i = 0; i < meshesToAdd.length; i++) {
1939
- const meshData = meshesToAdd[i];
1940
- // Use expressId + global array index as key to ensure each mesh is unique
1941
- // (same expressId can have multiple submeshes with same color, e.g., door framing)
1942
- const globalIndex = startIndex + i;
1943
- const compoundKey = `${meshData.expressId}:${globalIndex}`;
1944
-
1945
- if (!processedMeshIdsRef.current.has(compoundKey)) {
1946
- newMeshes.push(meshData);
1947
- processedMeshIdsRef.current.add(compoundKey);
1948
- }
1949
- }
1950
-
1951
- if (newMeshes.length > 0) {
1952
- // Batch meshes by color for efficient rendering (reduces draw calls from N to ~100-500)
1953
- // This dramatically improves performance for large models (50K+ meshes)
1954
- const pipeline = renderer.getPipeline();
1955
- if (pipeline) {
1956
- // Use batched rendering - groups meshes by color into single draw calls
1957
- // Pass isStreaming flag to enable throttled batch rebuilding (reduces O(N²) cost)
1958
- (scene as any).appendToBatches(newMeshes, device, pipeline, isStreaming);
1959
-
1960
- // Note: addMeshData is now called inside appendToBatches, no need to duplicate
1961
- } else {
1962
- // Fallback: add individual meshes if pipeline not ready
1963
- for (const meshData of newMeshes) {
1964
- const vertexCount = meshData.positions.length / 3;
1965
- const interleaved = new Float32Array(vertexCount * 6);
1966
- for (let i = 0; i < vertexCount; i++) {
1967
- const base = i * 6;
1968
- const posBase = i * 3;
1969
- interleaved[base] = meshData.positions[posBase];
1970
- interleaved[base + 1] = meshData.positions[posBase + 1];
1971
- interleaved[base + 2] = meshData.positions[posBase + 2];
1972
- interleaved[base + 3] = meshData.normals[posBase];
1973
- interleaved[base + 4] = meshData.normals[posBase + 1];
1974
- interleaved[base + 5] = meshData.normals[posBase + 2];
1975
- }
1976
-
1977
- const vertexBuffer = device.createBuffer({
1978
- size: interleaved.byteLength,
1979
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1980
- });
1981
- device.queue.writeBuffer(vertexBuffer, 0, interleaved);
1982
-
1983
- const indexBuffer = device.createBuffer({
1984
- size: meshData.indices.byteLength,
1985
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1986
- });
1987
- device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
1988
-
1989
- scene.addMesh({
1990
- expressId: meshData.expressId,
1991
- vertexBuffer,
1992
- indexBuffer,
1993
- indexCount: meshData.indices.length,
1994
- transform: MathUtils.identity(),
1995
- color: meshData.color,
1996
- });
1997
- }
1998
- }
1999
-
2000
- // Invalidate caches when new geometry is added
2001
- renderer.clearCaches();
2002
- }
2003
-
2004
- lastGeometryLengthRef.current = currentLength;
2005
-
2006
- // Fit camera and store bounds
2007
- // IMPORTANT: Fit camera immediately when we have valid bounds to avoid starting inside model
2008
- // The default camera position (50, 50, 100) is inside most models that are shifted to origin
2009
- if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
2010
- const shiftedBounds = coordinateInfo.shiftedBounds;
2011
- const maxSize = Math.max(
2012
- shiftedBounds.max.x - shiftedBounds.min.x,
2013
- shiftedBounds.max.y - shiftedBounds.min.y,
2014
- shiftedBounds.max.z - shiftedBounds.min.z
2015
- );
2016
- // Fit camera immediately when we have valid bounds
2017
- // For streaming: the first batch already has complete bounds from coordinate handler
2018
- // (bounds are calculated from ALL geometry before streaming starts)
2019
- // Waiting for streaming to complete causes the camera to start inside the model
2020
- if (maxSize > 0 && Number.isFinite(maxSize)) {
2021
- renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
2022
- geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
2023
- cameraFittedRef.current = true;
2024
- }
2025
- } else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
2026
- // Fallback: calculate bounds from geometry array (only when streaming is complete)
2027
- // This ensures we have complete bounds before fitting camera
2028
- const fallbackBounds = {
2029
- min: { x: Infinity, y: Infinity, z: Infinity },
2030
- max: { x: -Infinity, y: -Infinity, z: -Infinity },
2031
- };
2032
-
2033
- // Max coordinate threshold - matches CoordinateHandler's NORMAL_COORD_THRESHOLD
2034
- // Coordinates beyond this are likely corrupted or unshifted original coordinates
2035
- const MAX_VALID_COORD = 10000;
2036
-
2037
- for (const meshData of geometry) {
2038
- for (let i = 0; i < meshData.positions.length; i += 3) {
2039
- const x = meshData.positions[i];
2040
- const y = meshData.positions[i + 1];
2041
- const z = meshData.positions[i + 2];
2042
- // Filter out corrupted/unshifted vertices (> 10km from origin)
2043
- const isValid = Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) &&
2044
- Math.abs(x) < MAX_VALID_COORD && Math.abs(y) < MAX_VALID_COORD && Math.abs(z) < MAX_VALID_COORD;
2045
- if (isValid) {
2046
- fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
2047
- fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
2048
- fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
2049
- fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
2050
- fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
2051
- fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
2052
- }
2053
- }
2054
- }
2055
-
2056
- const maxSize = Math.max(
2057
- fallbackBounds.max.x - fallbackBounds.min.x,
2058
- fallbackBounds.max.y - fallbackBounds.min.y,
2059
- fallbackBounds.max.z - fallbackBounds.min.z
2060
- );
2061
-
2062
- if (fallbackBounds.min.x !== Infinity && maxSize > 0 && Number.isFinite(maxSize)) {
2063
- renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
2064
- geometryBoundsRef.current = fallbackBounds;
2065
- cameraFittedRef.current = true;
2066
- }
2067
- }
2068
-
2069
- // Note: Background instancing conversion removed
2070
- // Regular MeshData meshes are rendered directly with their correct positions
2071
- // Instancing conversion would require preserving actual mesh transforms, which is complex
2072
- // For now, we render regular meshes directly (fast enough for most cases)
2073
-
2074
- // Render throttling: During streaming, only render every STREAM_RENDER_THROTTLE_MS
2075
- // This prevents rendering 28K+ meshes from blocking WASM batch processing
2076
- const now = Date.now();
2077
- const timeSinceLastRender = now - lastStreamRenderTimeRef.current;
2078
- const shouldRender = !isStreaming || timeSinceLastRender >= STREAM_RENDER_THROTTLE_MS;
2079
-
2080
- if (shouldRender) {
2081
- renderer.render();
2082
- lastStreamRenderTimeRef.current = now;
2083
- }
2084
- }, [geometry, coordinateInfo, isInitialized, isStreaming]);
2085
-
2086
- // Force render when streaming completes (progress goes from <100% to 100% or null)
2087
- const prevIsStreamingRef = useRef(isStreaming);
2088
- useEffect(() => {
2089
- const renderer = rendererRef.current;
2090
- if (!renderer || !isInitialized) return;
2091
-
2092
- // If streaming just completed (was streaming, now not), rebuild pending batches and render
2093
- if (prevIsStreamingRef.current && !isStreaming) {
2094
- const device = renderer.getGPUDevice();
2095
- const pipeline = renderer.getPipeline();
2096
- const scene = renderer.getScene();
2097
-
2098
- // Rebuild any pending batches that were deferred during streaming
2099
- if (device && pipeline && (scene as any).hasPendingBatches?.()) {
2100
- (scene as any).rebuildPendingBatches(device, pipeline);
2101
- }
2102
-
2103
- renderer.render();
2104
- lastStreamRenderTimeRef.current = Date.now();
2105
- }
2106
- prevIsStreamingRef.current = isStreaming;
2107
- }, [isStreaming, isInitialized]);
2108
-
2109
- // Apply pending color updates to WebGPU scene
2110
- // Note: Color updates may arrive before viewport is initialized, so we wait
2111
- useEffect(() => {
2112
- if (!pendingColorUpdates || pendingColorUpdates.size === 0) return;
2113
-
2114
- // Wait until viewport is initialized before applying color updates
2115
- if (!isInitialized) return;
2116
-
2117
- const renderer = rendererRef.current;
2118
- if (!renderer) return;
617
+ // Mouse isDragging proxy ref for animation loop
618
+ // The animation loop reads this to decide whether to update rotation
619
+ // We wrap mouseStateRef to provide a { current: boolean } interface
620
+ const mouseIsDraggingRef = useRef(false);
621
+ // Sync on every render since mouseState is mutated directly by event handlers
622
+ mouseIsDraggingRef.current = mouseStateRef.current.isDragging;
623
+
624
+ // ===== Extracted hooks =====
625
+ useMouseControls({
626
+ canvasRef,
627
+ rendererRef,
628
+ isInitialized,
629
+ mouseStateRef,
630
+ activeToolRef,
631
+ activeMeasurementRef,
632
+ snapEnabledRef,
633
+ edgeLockStateRef,
634
+ measurementConstraintEdgeRef,
635
+ hiddenEntitiesRef,
636
+ isolatedEntitiesRef,
637
+ selectedEntityIdRef,
638
+ selectedModelIndexRef,
639
+ clearColorRef,
640
+ sectionPlaneRef,
641
+ sectionRangeRef,
642
+ geometryRef,
643
+ measureRaycastPendingRef,
644
+ measureRaycastFrameRef,
645
+ lastMeasureRaycastDurationRef,
646
+ lastHoverSnapTimeRef,
647
+ lastHoverCheckRef,
648
+ hoverTooltipsEnabledRef,
649
+ lastRenderTimeRef,
650
+ renderPendingRef,
651
+ lastClickTimeRef,
652
+ lastClickPosRef,
653
+ lastCameraStateRef,
654
+ handlePickForSelection: (pickResult) => handlePickForSelectionRef.current(pickResult),
655
+ setHoverState,
656
+ clearHover,
657
+ openContextMenu,
658
+ startMeasurement,
659
+ updateMeasurement,
660
+ finalizeMeasurement,
661
+ setSnapTarget,
662
+ setSnapVisualization,
663
+ setEdgeLock,
664
+ updateEdgeLockPosition,
665
+ clearEdgeLock,
666
+ incrementEdgeLockStrength,
667
+ setMeasurementConstraintEdge,
668
+ updateConstraintActiveAxis,
669
+ updateMeasurementScreenCoords,
670
+ updateCameraRotationRealtime,
671
+ toggleSelection,
672
+ calculateScale,
673
+ getPickOptions,
674
+ hasPendingMeasurements,
675
+ HOVER_SNAP_THROTTLE_MS,
676
+ SLOW_RAYCAST_THRESHOLD_MS,
677
+ hoverThrottleMs,
678
+ RENDER_THROTTLE_MS_SMALL,
679
+ RENDER_THROTTLE_MS_LARGE,
680
+ RENDER_THROTTLE_MS_HUGE,
681
+ });
2119
682
 
2120
- const device = renderer.getGPUDevice();
2121
- const pipeline = renderer.getPipeline();
2122
- const scene = renderer.getScene();
683
+ useTouchControls({
684
+ canvasRef,
685
+ rendererRef,
686
+ isInitialized,
687
+ touchStateRef,
688
+ activeToolRef,
689
+ hiddenEntitiesRef,
690
+ isolatedEntitiesRef,
691
+ selectedEntityIdRef,
692
+ selectedModelIndexRef,
693
+ clearColorRef,
694
+ sectionPlaneRef,
695
+ sectionRangeRef,
696
+ geometryRef,
697
+ handlePickForSelection: (pickResult) => handlePickForSelectionRef.current(pickResult),
698
+ getPickOptions,
699
+ });
2123
700
 
2124
- if (device && pipeline && (scene as any).updateMeshColors) {
2125
- (scene as any).updateMeshColors(pendingColorUpdates, device, pipeline);
2126
- renderer.render();
2127
- clearPendingColorUpdates();
2128
- }
2129
- }, [pendingColorUpdates, isInitialized, clearPendingColorUpdates]);
701
+ useKeyboardControls({
702
+ rendererRef,
703
+ isInitialized,
704
+ keyboardHandlersRef,
705
+ firstPersonModeRef,
706
+ geometryBoundsRef,
707
+ coordinateInfoRef,
708
+ geometryRef,
709
+ selectedEntityIdRef,
710
+ hiddenEntitiesRef,
711
+ isolatedEntitiesRef,
712
+ selectedModelIndexRef,
713
+ clearColorRef,
714
+ activeToolRef,
715
+ sectionPlaneRef,
716
+ sectionRangeRef,
717
+ updateCameraRotationRealtime,
718
+ calculateScale,
719
+ });
2130
720
 
2131
- // 2D section overlay: upload drawing data to renderer when available
2132
- const drawing2D = useViewerStore((s) => s.drawing2D);
2133
- const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
2134
- useEffect(() => {
2135
- const renderer = rendererRef.current;
2136
- if (!renderer || !isInitialized) return;
2137
-
2138
- // Only show overlay when section tool is active, we have a drawing, AND 3D overlay is enabled
2139
- if (activeTool === 'section' && drawing2D && drawing2D.cutPolygons.length > 0 && show3DOverlay) {
2140
- // Convert Drawing2D format to renderer format
2141
- const polygons = drawing2D.cutPolygons.map((cp) => ({
2142
- polygon: cp.polygon,
2143
- ifcType: cp.ifcType,
2144
- expressId: cp.entityId, // DrawingPolygon uses entityId
2145
- }));
2146
-
2147
- // No hatching lines for 3D overlay (too dense)
2148
- const lines: Array<{ line: { start: { x: number; y: number }; end: { x: number; y: number } }; category: string }> = [];
2149
-
2150
- // Upload to renderer - will be drawn on the section plane
2151
- // Pass sectionRange to match exactly what render() uses for section plane position
2152
- renderer.uploadSection2DOverlay(
2153
- polygons,
2154
- lines,
2155
- sectionPlane.axis,
2156
- sectionPlane.position,
2157
- sectionRangeRef.current ?? undefined, // Same range as section plane
2158
- sectionPlane.flipped
2159
- );
2160
- } else {
2161
- // Clear overlay when not in section mode, no drawing, or overlay disabled
2162
- renderer.clearSection2DOverlay();
2163
- }
721
+ useAnimationLoop({
722
+ canvasRef,
723
+ rendererRef,
724
+ isInitialized,
725
+ animationFrameRef,
726
+ lastFrameTimeRef,
727
+ mouseIsDraggingRef,
728
+ activeToolRef,
729
+ hiddenEntitiesRef,
730
+ isolatedEntitiesRef,
731
+ selectedEntityIdRef,
732
+ selectedModelIndexRef,
733
+ clearColorRef,
734
+ sectionPlaneRef,
735
+ sectionRangeRef,
736
+ lastCameraStateRef,
737
+ updateCameraRotationRealtime,
738
+ calculateScale,
739
+ updateMeasurementScreenCoords,
740
+ hasPendingMeasurements,
741
+ });
2164
742
 
2165
- // Re-render to show/hide overlay
2166
- renderer.render({
2167
- hiddenIds: hiddenEntitiesRef.current,
2168
- isolatedIds: isolatedEntitiesRef.current,
2169
- selectedId: selectedEntityIdRef.current,
2170
- selectedIds: selectedEntityIdsRef.current,
2171
- selectedModelIndex: selectedModelIndexRef.current,
2172
- clearColor: clearColorRef.current,
2173
- sectionPlane: activeTool === 'section' ? {
2174
- ...sectionPlane,
2175
- min: sectionRangeRef.current?.min,
2176
- max: sectionRangeRef.current?.max,
2177
- } : undefined,
2178
- });
2179
- }, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay]);
743
+ useGeometryStreaming({
744
+ rendererRef,
745
+ isInitialized,
746
+ geometry,
747
+ coordinateInfo,
748
+ isStreaming,
749
+ geometryBoundsRef,
750
+ pendingColorUpdates,
751
+ clearPendingColorUpdates,
752
+ });
2180
753
 
2181
- // Re-render when visibility, selection, or section plane changes
2182
- useEffect(() => {
2183
- const renderer = rendererRef.current;
2184
- if (!renderer || !isInitialized) return;
2185
-
2186
- renderer.render({
2187
- hiddenIds: hiddenEntities,
2188
- isolatedIds: isolatedEntities,
2189
- selectedId: selectedEntityId,
2190
- selectedIds: selectedEntityIds,
2191
- selectedModelIndex,
2192
- clearColor: clearColorRef.current,
2193
- sectionPlane: activeTool === 'section' ? {
2194
- ...sectionPlane,
2195
- min: sectionRange?.min,
2196
- max: sectionRange?.max,
2197
- } : undefined,
2198
- buildingRotation: coordinateInfo?.buildingRotation,
2199
- });
2200
- }, [hiddenEntities, isolatedEntities, selectedEntityId, selectedEntityIds, selectedModelIndex, isInitialized, sectionPlane, activeTool, sectionRange, coordinateInfo?.buildingRotation]);
754
+ useRenderUpdates({
755
+ rendererRef,
756
+ isInitialized,
757
+ theme,
758
+ clearColorRef,
759
+ hiddenEntities,
760
+ isolatedEntities,
761
+ selectedEntityId,
762
+ selectedEntityIds,
763
+ selectedModelIndex,
764
+ activeTool,
765
+ sectionPlane,
766
+ sectionRange,
767
+ coordinateInfo,
768
+ hiddenEntitiesRef,
769
+ isolatedEntitiesRef,
770
+ selectedEntityIdRef,
771
+ selectedModelIndexRef,
772
+ selectedEntityIdsRef,
773
+ sectionPlaneRef,
774
+ sectionRangeRef,
775
+ activeToolRef,
776
+ drawing2D,
777
+ show3DOverlay,
778
+ });
2201
779
 
2202
780
  return (
2203
781
  <canvas