@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
@@ -0,0 +1,1009 @@
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
+ * Mouse controls hook for the 3D viewport
7
+ * Handles mouse event handlers (orbit, pan, select, measure, context menu)
8
+ */
9
+
10
+ import { useEffect, type MutableRefObject, type RefObject } from 'react';
11
+ import type { Renderer, PickResult, SnapTarget } from '@ifc-lite/renderer';
12
+ import type { MeshData } from '@ifc-lite/geometry';
13
+ import type {
14
+ MeasurePoint,
15
+ SnapVisualization,
16
+ ActiveMeasurement,
17
+ EdgeLockState,
18
+ SectionPlane,
19
+ } from '@/store';
20
+ import type { MeasurementConstraintEdge, OrthogonalAxis, Vec3 } from '@/store/types.js';
21
+ import { getEntityCenter } from '../../utils/viewportUtils.js';
22
+
23
+ export interface MouseState {
24
+ isDragging: boolean;
25
+ isPanning: boolean;
26
+ lastX: number;
27
+ lastY: number;
28
+ button: number;
29
+ startX: number;
30
+ startY: number;
31
+ didDrag: boolean;
32
+ }
33
+
34
+ export interface UseMouseControlsParams {
35
+ canvasRef: RefObject<HTMLCanvasElement | null>;
36
+ rendererRef: MutableRefObject<Renderer | null>;
37
+ isInitialized: boolean;
38
+
39
+ // Mouse state
40
+ mouseStateRef: MutableRefObject<MouseState>;
41
+
42
+ // Tool/state refs
43
+ activeToolRef: MutableRefObject<string>;
44
+ activeMeasurementRef: MutableRefObject<ActiveMeasurement | null>;
45
+ snapEnabledRef: MutableRefObject<boolean>;
46
+ edgeLockStateRef: MutableRefObject<EdgeLockState>;
47
+ measurementConstraintEdgeRef: MutableRefObject<MeasurementConstraintEdge | null>;
48
+
49
+ // Visibility/selection refs
50
+ hiddenEntitiesRef: MutableRefObject<Set<number>>;
51
+ isolatedEntitiesRef: MutableRefObject<Set<number> | null>;
52
+ selectedEntityIdRef: MutableRefObject<number | null>;
53
+ selectedModelIndexRef: MutableRefObject<number | undefined>;
54
+ clearColorRef: MutableRefObject<[number, number, number, number]>;
55
+
56
+ // Section/geometry refs
57
+ sectionPlaneRef: MutableRefObject<SectionPlane>;
58
+ sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
59
+ geometryRef: MutableRefObject<MeshData[] | null>;
60
+
61
+ // Measure raycast refs
62
+ measureRaycastPendingRef: MutableRefObject<boolean>;
63
+ measureRaycastFrameRef: MutableRefObject<number | null>;
64
+ lastMeasureRaycastDurationRef: MutableRefObject<number>;
65
+ lastHoverSnapTimeRef: MutableRefObject<number>;
66
+
67
+ // Hover refs
68
+ lastHoverCheckRef: MutableRefObject<number>;
69
+ hoverTooltipsEnabledRef: MutableRefObject<boolean>;
70
+
71
+ // Render throttle refs
72
+ lastRenderTimeRef: MutableRefObject<number>;
73
+ renderPendingRef: MutableRefObject<boolean>;
74
+
75
+ // Click detection refs
76
+ lastClickTimeRef: MutableRefObject<number>;
77
+ lastClickPosRef: MutableRefObject<{ x: number; y: number } | null>;
78
+
79
+ // Camera tracking
80
+ lastCameraStateRef: MutableRefObject<{
81
+ position: { x: number; y: number; z: number };
82
+ rotation: { azimuth: number; elevation: number };
83
+ distance: number;
84
+ canvasWidth: number;
85
+ canvasHeight: number;
86
+ } | null>;
87
+
88
+ // Callbacks
89
+ handlePickForSelection: (pickResult: PickResult | null) => void;
90
+ setHoverState: (state: { entityId: number; screenX: number; screenY: number }) => void;
91
+ clearHover: () => void;
92
+ openContextMenu: (entityId: number | null, screenX: number, screenY: number) => void;
93
+ startMeasurement: (point: MeasurePoint) => void;
94
+ updateMeasurement: (point: MeasurePoint) => void;
95
+ finalizeMeasurement: () => void;
96
+ setSnapTarget: (target: SnapTarget | null) => void;
97
+ setSnapVisualization: (viz: Partial<SnapVisualization> | null) => void;
98
+ setEdgeLock: (edge: { v0: { x: number; y: number; z: number }; v1: { x: number; y: number; z: number } }, meshExpressId: number, edgeT: number) => void;
99
+ updateEdgeLockPosition: (edgeT: number, isCorner: boolean, cornerValence: number) => void;
100
+ clearEdgeLock: () => void;
101
+ incrementEdgeLockStrength: () => void;
102
+ setMeasurementConstraintEdge: (edge: MeasurementConstraintEdge) => void;
103
+ updateConstraintActiveAxis: (axis: OrthogonalAxis | null) => void;
104
+ updateMeasurementScreenCoords: (projector: (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null) => void;
105
+ updateCameraRotationRealtime: (rotation: { azimuth: number; elevation: number }) => void;
106
+ toggleSelection: (entityId: number) => void;
107
+ calculateScale: () => void;
108
+ getPickOptions: () => { isStreaming: boolean; hiddenIds: Set<number>; isolatedIds: Set<number> | null };
109
+ hasPendingMeasurements: () => boolean;
110
+
111
+ // Constants
112
+ HOVER_SNAP_THROTTLE_MS: number;
113
+ SLOW_RAYCAST_THRESHOLD_MS: number;
114
+ hoverThrottleMs: number;
115
+ RENDER_THROTTLE_MS_SMALL: number;
116
+ RENDER_THROTTLE_MS_LARGE: number;
117
+ RENDER_THROTTLE_MS_HUGE: number;
118
+ }
119
+
120
+ /**
121
+ * Projects a world position onto the closest orthogonal constraint axis.
122
+ * Used by measurement tool when shift is held for axis-aligned measurements.
123
+ *
124
+ * Computes the dot product of the displacement vector (startWorld -> currentWorld)
125
+ * with each of the three orthogonal axes, then projects onto whichever axis has
126
+ * the largest absolute dot product (i.e., the axis most aligned with the cursor direction).
127
+ */
128
+ function projectOntoConstraintAxis(
129
+ startWorld: Vec3,
130
+ currentWorld: Vec3,
131
+ constraint: MeasurementConstraintEdge,
132
+ ): { projectedPos: Vec3; activeAxis: OrthogonalAxis } {
133
+ const dx = currentWorld.x - startWorld.x;
134
+ const dy = currentWorld.y - startWorld.y;
135
+ const dz = currentWorld.z - startWorld.z;
136
+
137
+ const { axis1, axis2, axis3 } = constraint.axes;
138
+ const dot1 = dx * axis1.x + dy * axis1.y + dz * axis1.z;
139
+ const dot2 = dx * axis2.x + dy * axis2.y + dz * axis2.z;
140
+ const dot3 = dx * axis3.x + dy * axis3.y + dz * axis3.z;
141
+
142
+ const absDot1 = Math.abs(dot1);
143
+ const absDot2 = Math.abs(dot2);
144
+ const absDot3 = Math.abs(dot3);
145
+
146
+ let activeAxis: OrthogonalAxis;
147
+ let chosenDot: number;
148
+ let chosenDir: Vec3;
149
+
150
+ if (absDot1 >= absDot2 && absDot1 >= absDot3) {
151
+ activeAxis = 'axis1';
152
+ chosenDot = dot1;
153
+ chosenDir = axis1;
154
+ } else if (absDot2 >= absDot3) {
155
+ activeAxis = 'axis2';
156
+ chosenDot = dot2;
157
+ chosenDir = axis2;
158
+ } else {
159
+ activeAxis = 'axis3';
160
+ chosenDot = dot3;
161
+ chosenDir = axis3;
162
+ }
163
+
164
+ const projectedPos: Vec3 = {
165
+ x: startWorld.x + chosenDot * chosenDir.x,
166
+ y: startWorld.y + chosenDot * chosenDir.y,
167
+ z: startWorld.z + chosenDot * chosenDir.z,
168
+ };
169
+
170
+ return { projectedPos, activeAxis };
171
+ }
172
+
173
+ export function useMouseControls(params: UseMouseControlsParams): void {
174
+ const {
175
+ canvasRef,
176
+ rendererRef,
177
+ isInitialized,
178
+ mouseStateRef,
179
+ activeToolRef,
180
+ activeMeasurementRef,
181
+ snapEnabledRef,
182
+ edgeLockStateRef,
183
+ measurementConstraintEdgeRef,
184
+ hiddenEntitiesRef,
185
+ isolatedEntitiesRef,
186
+ selectedEntityIdRef,
187
+ selectedModelIndexRef,
188
+ clearColorRef,
189
+ sectionPlaneRef,
190
+ sectionRangeRef,
191
+ geometryRef,
192
+ measureRaycastPendingRef,
193
+ measureRaycastFrameRef,
194
+ lastMeasureRaycastDurationRef,
195
+ lastHoverSnapTimeRef,
196
+ lastHoverCheckRef,
197
+ hoverTooltipsEnabledRef,
198
+ lastRenderTimeRef,
199
+ renderPendingRef,
200
+ lastClickTimeRef,
201
+ lastClickPosRef,
202
+ lastCameraStateRef,
203
+ handlePickForSelection,
204
+ setHoverState,
205
+ clearHover,
206
+ openContextMenu,
207
+ startMeasurement,
208
+ updateMeasurement,
209
+ finalizeMeasurement,
210
+ setSnapTarget,
211
+ setSnapVisualization,
212
+ setEdgeLock,
213
+ updateEdgeLockPosition,
214
+ clearEdgeLock,
215
+ incrementEdgeLockStrength,
216
+ setMeasurementConstraintEdge,
217
+ updateConstraintActiveAxis,
218
+ updateMeasurementScreenCoords,
219
+ updateCameraRotationRealtime,
220
+ toggleSelection,
221
+ calculateScale,
222
+ getPickOptions,
223
+ hasPendingMeasurements,
224
+ HOVER_SNAP_THROTTLE_MS,
225
+ SLOW_RAYCAST_THRESHOLD_MS,
226
+ hoverThrottleMs,
227
+ RENDER_THROTTLE_MS_SMALL,
228
+ RENDER_THROTTLE_MS_LARGE,
229
+ RENDER_THROTTLE_MS_HUGE,
230
+ } = params;
231
+
232
+ useEffect(() => {
233
+ const canvas = canvasRef.current;
234
+ const renderer = rendererRef.current;
235
+ if (!canvas || !renderer || !isInitialized) return;
236
+
237
+ const camera = renderer.getCamera();
238
+ const mouseState = mouseStateRef.current;
239
+
240
+ // Helper function to compute snap visualization (edge highlights, sliding dot, corner rings, plane indicators)
241
+ // Stores 3D coordinates so edge highlights stay positioned correctly during camera rotation
242
+ function updateSnapViz(snapTarget: SnapTarget | null, edgeLockInfo?: { edgeT: number; isCorner: boolean; cornerValence: number }) {
243
+ if (!snapTarget || !canvas) {
244
+ setSnapVisualization(null);
245
+ return;
246
+ }
247
+
248
+ const viz: Partial<SnapVisualization> = {};
249
+
250
+ // For edge snaps: store 3D world coordinates (will be projected to screen by ToolOverlays)
251
+ if ((snapTarget.type === 'edge' || snapTarget.type === 'vertex') && snapTarget.metadata?.vertices) {
252
+ const [v0, v1] = snapTarget.metadata.vertices;
253
+
254
+ // Store 3D coordinates - these will be projected dynamically during rendering
255
+ viz.edgeLine3D = {
256
+ v0: { x: v0.x, y: v0.y, z: v0.z },
257
+ v1: { x: v1.x, y: v1.y, z: v1.z },
258
+ };
259
+
260
+ // Add sliding dot t-parameter along the edge
261
+ if (edgeLockInfo) {
262
+ viz.slidingDot = { t: edgeLockInfo.edgeT };
263
+
264
+ // Add corner rings if at a corner with high valence
265
+ if (edgeLockInfo.isCorner && edgeLockInfo.cornerValence >= 2) {
266
+ viz.cornerRings = {
267
+ atStart: edgeLockInfo.edgeT < 0.5,
268
+ valence: edgeLockInfo.cornerValence,
269
+ };
270
+ }
271
+ } else {
272
+ // No edge lock info - calculate t from snap position
273
+ const edge = { x: v1.x - v0.x, y: v1.y - v0.y, z: v1.z - v0.z };
274
+ const toSnap = { x: snapTarget.position.x - v0.x, y: snapTarget.position.y - v0.y, z: snapTarget.position.z - v0.z };
275
+ const edgeLenSq = edge.x * edge.x + edge.y * edge.y + edge.z * edge.z;
276
+ const t = edgeLenSq > 0 ? (toSnap.x * edge.x + toSnap.y * edge.y + toSnap.z * edge.z) / edgeLenSq : 0.5;
277
+ viz.slidingDot = { t: Math.max(0, Math.min(1, t)) };
278
+ }
279
+ }
280
+
281
+ // For face snaps: show plane indicator (still screen-space since it's just an indicator)
282
+ if ((snapTarget.type === 'face' || snapTarget.type === 'face_center') && snapTarget.normal) {
283
+ const pos = camera.projectToScreen(snapTarget.position, canvas.width, canvas.height);
284
+ if (pos) {
285
+ viz.planeIndicator = {
286
+ x: pos.x,
287
+ y: pos.y,
288
+ normal: snapTarget.normal,
289
+ };
290
+ }
291
+ }
292
+
293
+ setSnapVisualization(viz);
294
+ }
295
+
296
+ // Helper function to get approximate world position (for measurement tool)
297
+ function _getApproximateWorldPosition(
298
+ geom: MeshData[] | null,
299
+ entityId: number,
300
+ _screenX: number,
301
+ _screenY: number,
302
+ _canvasWidth: number,
303
+ _canvasHeight: number
304
+ ): { x: number; y: number; z: number } {
305
+ return getEntityCenter(geom, entityId) || { x: 0, y: 0, z: 0 };
306
+ }
307
+
308
+ // Mouse controls - respect active tool
309
+ const handleMouseDown = async (e: MouseEvent) => {
310
+ e.preventDefault();
311
+ mouseState.isDragging = true;
312
+ mouseState.button = e.button;
313
+ mouseState.lastX = e.clientX;
314
+ mouseState.lastY = e.clientY;
315
+ mouseState.startX = e.clientX;
316
+ mouseState.startY = e.clientY;
317
+ mouseState.didDrag = false;
318
+
319
+ // Determine action based on active tool and mouse button
320
+ const tool = activeToolRef.current;
321
+
322
+ const willOrbit = !(tool === 'pan' || e.button === 1 || e.button === 2 ||
323
+ (tool === 'select' && e.shiftKey) ||
324
+ (tool !== 'orbit' && tool !== 'select' && e.shiftKey));
325
+
326
+ // Set orbit pivot to what user clicks on (standard CAD/BIM behavior)
327
+ // Simple and predictable: orbit around clicked geometry, or model center if empty space
328
+ if (willOrbit && tool !== 'measure' && tool !== 'walk') {
329
+ const rect = canvas.getBoundingClientRect();
330
+ const x = e.clientX - rect.left;
331
+ const y = e.clientY - rect.top;
332
+
333
+ // Pick at cursor position - orbit around what user is clicking on
334
+ // Uses visibility filtering so hidden elements don't affect orbit pivot
335
+ const pickResult = await renderer.pick(x, y, getPickOptions());
336
+ if (pickResult !== null) {
337
+ const center = getEntityCenter(geometryRef.current, pickResult.expressId);
338
+ if (center) {
339
+ camera.setOrbitPivot(center);
340
+ } else {
341
+ camera.setOrbitPivot(null);
342
+ }
343
+ } else {
344
+ // No geometry under cursor - orbit around current target (model center)
345
+ camera.setOrbitPivot(null);
346
+ }
347
+ }
348
+
349
+ if (tool === 'pan' || e.button === 1 || e.button === 2) {
350
+ mouseState.isPanning = true;
351
+ canvas.style.cursor = 'move';
352
+ } else if (tool === 'orbit') {
353
+ mouseState.isPanning = false;
354
+ canvas.style.cursor = 'grabbing';
355
+ } else if (tool === 'select') {
356
+ // Select tool: shift+drag = pan, normal drag = orbit
357
+ mouseState.isPanning = e.shiftKey;
358
+ canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
359
+ } else if (tool === 'measure') {
360
+ // Measure tool - shift+drag = orbit, normal drag = measure
361
+ if (e.shiftKey) {
362
+ // Shift pressed: allow orbit (not pan) when no measurement is active
363
+ mouseState.isDragging = true;
364
+ mouseState.isPanning = false;
365
+ canvas.style.cursor = 'grabbing';
366
+ // Fall through to allow orbit handling in mousemove
367
+ } else {
368
+ // Normal drag: start measurement
369
+ mouseState.isDragging = true; // Mark as dragging for measure tool
370
+ canvas.style.cursor = 'crosshair';
371
+
372
+ // Calculate canvas-relative coordinates
373
+ const rect = canvas.getBoundingClientRect();
374
+ const x = e.clientX - rect.left;
375
+ const y = e.clientY - rect.top;
376
+
377
+ // Use magnetic snap for better edge locking
378
+ const currentLock = edgeLockStateRef.current;
379
+ const result = renderer.raycastSceneMagnetic(x, y, {
380
+ edge: currentLock.edge,
381
+ meshExpressId: currentLock.meshExpressId,
382
+ lockStrength: currentLock.lockStrength,
383
+ }, {
384
+ hiddenIds: hiddenEntitiesRef.current,
385
+ isolatedIds: isolatedEntitiesRef.current,
386
+ snapOptions: snapEnabledRef.current ? {
387
+ snapToVertices: true,
388
+ snapToEdges: true,
389
+ snapToFaces: true,
390
+ screenSnapRadius: 60,
391
+ } : {
392
+ snapToVertices: false,
393
+ snapToEdges: false,
394
+ snapToFaces: false,
395
+ screenSnapRadius: 0,
396
+ },
397
+ });
398
+
399
+ if (result.intersection || result.snapTarget) {
400
+ const snapPoint = result.snapTarget || result.intersection;
401
+ const pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
402
+
403
+ if (pos) {
404
+ // Project snapped 3D position to screen - measurement starts from indicator, not cursor
405
+ const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
406
+ const measurePoint: MeasurePoint = {
407
+ x: pos.x,
408
+ y: pos.y,
409
+ z: pos.z,
410
+ screenX: screenPos?.x ?? x,
411
+ screenY: screenPos?.y ?? y,
412
+ };
413
+
414
+ startMeasurement(measurePoint);
415
+
416
+ if (result.snapTarget) {
417
+ setSnapTarget(result.snapTarget);
418
+ }
419
+
420
+ // Update edge lock state
421
+ if (result.edgeLock.shouldRelease) {
422
+ clearEdgeLock();
423
+ updateSnapViz(result.snapTarget || null);
424
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
425
+ setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
426
+ updateSnapViz(result.snapTarget, {
427
+ edgeT: result.edgeLock.edgeT,
428
+ isCorner: result.edgeLock.isCorner,
429
+ cornerValence: result.edgeLock.cornerValence,
430
+ });
431
+ } else {
432
+ updateSnapViz(result.snapTarget);
433
+ }
434
+
435
+ // Set up orthogonal constraint for shift+drag - always use world axes
436
+ setMeasurementConstraintEdge({
437
+ axes: {
438
+ axis1: { x: 1, y: 0, z: 0 }, // World X
439
+ axis2: { x: 0, y: 1, z: 0 }, // World Y (vertical)
440
+ axis3: { x: 0, y: 0, z: 1 }, // World Z
441
+ },
442
+ colors: {
443
+ axis1: '#F44336', // Red - X axis
444
+ axis2: '#8BC34A', // Lime - Y axis (vertical)
445
+ axis3: '#2196F3', // Blue - Z axis
446
+ },
447
+ activeAxis: null,
448
+ });
449
+ }
450
+ }
451
+ return; // Early return for measure tool (non-shift)
452
+ }
453
+ } else {
454
+ // Default behavior
455
+ mouseState.isPanning = e.shiftKey;
456
+ canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
457
+ }
458
+ };
459
+
460
+ const handleMouseMove = async (e: MouseEvent) => {
461
+ const rect = canvas.getBoundingClientRect();
462
+ const x = e.clientX - rect.left;
463
+ const y = e.clientY - rect.top;
464
+ const tool = activeToolRef.current;
465
+
466
+ // Handle measure tool live preview while dragging
467
+ // IMPORTANT: Check tool first, not activeMeasurement, to prevent orbit conflict
468
+ if (tool === 'measure' && mouseState.isDragging && activeMeasurementRef.current) {
469
+ // Only process measurement dragging if we have an active measurement
470
+ // If shift is held without active measurement, fall through to orbit handling
471
+
472
+ // Check if shift is held for orthogonal constraint
473
+ const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
474
+
475
+ // Throttle raycasting to 60fps max using requestAnimationFrame
476
+ if (!measureRaycastPendingRef.current) {
477
+ measureRaycastPendingRef.current = true;
478
+
479
+ measureRaycastFrameRef.current = requestAnimationFrame(() => {
480
+ measureRaycastPendingRef.current = false;
481
+ measureRaycastFrameRef.current = null;
482
+
483
+ const raycastStart = performance.now();
484
+
485
+ // When using orthogonal constraint (shift held), use simpler raycasting
486
+ // since the final position will be projected onto an axis anyway
487
+ const snapOn = snapEnabledRef.current && !useOrthogonalConstraint;
488
+
489
+ // If last raycast was slow, reduce complexity to prevent UI freezes
490
+ const wasSlowLastTime = lastMeasureRaycastDurationRef.current > SLOW_RAYCAST_THRESHOLD_MS;
491
+ const reduceComplexity = wasSlowLastTime && !useOrthogonalConstraint;
492
+
493
+ // Use magnetic snap for edge sliding behavior (only when not in orthogonal mode)
494
+ const currentLock = useOrthogonalConstraint
495
+ ? { edge: null, meshExpressId: null, lockStrength: 0 }
496
+ : edgeLockStateRef.current;
497
+
498
+ const result = renderer.raycastSceneMagnetic(x, y, {
499
+ edge: currentLock.edge,
500
+ meshExpressId: currentLock.meshExpressId,
501
+ lockStrength: currentLock.lockStrength,
502
+ }, {
503
+ hiddenIds: hiddenEntitiesRef.current,
504
+ isolatedIds: isolatedEntitiesRef.current,
505
+ // Reduce snap complexity when using orthogonal constraint or when slow
506
+ snapOptions: snapOn ? {
507
+ snapToVertices: !reduceComplexity, // Skip vertex snapping when slow
508
+ snapToEdges: true,
509
+ snapToFaces: true,
510
+ screenSnapRadius: reduceComplexity ? 40 : 60, // Smaller radius when slow
511
+ } : useOrthogonalConstraint ? {
512
+ // In orthogonal mode, snap to edges and vertices only (no faces)
513
+ snapToVertices: true,
514
+ snapToEdges: true,
515
+ snapToFaces: false,
516
+ screenSnapRadius: 40,
517
+ } : {
518
+ snapToVertices: false,
519
+ snapToEdges: false,
520
+ snapToFaces: false,
521
+ screenSnapRadius: 0,
522
+ },
523
+ });
524
+
525
+ // Track raycast duration for adaptive throttling
526
+ lastMeasureRaycastDurationRef.current = performance.now() - raycastStart;
527
+
528
+ if (result.intersection || result.snapTarget) {
529
+ const snapPoint = result.snapTarget || result.intersection;
530
+ let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
531
+
532
+ if (pos) {
533
+ // Apply orthogonal constraint if shift is held and we have a constraint
534
+ if (useOrthogonalConstraint && activeMeasurementRef.current) {
535
+ const constraint = measurementConstraintEdgeRef.current!;
536
+ const start = activeMeasurementRef.current.start;
537
+ const result = projectOntoConstraintAxis(start, pos, constraint);
538
+ pos = result.projectedPos;
539
+
540
+ // Update active axis for visualization
541
+ updateConstraintActiveAxis(result.activeAxis);
542
+ } else if (!useOrthogonalConstraint && measurementConstraintEdgeRef.current?.activeAxis) {
543
+ // Clear active axis when shift is released
544
+ updateConstraintActiveAxis(null);
545
+ }
546
+
547
+ // Project snapped 3D position to screen - indicator position, not raw cursor
548
+ const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
549
+ const measurePoint: MeasurePoint = {
550
+ x: pos.x,
551
+ y: pos.y,
552
+ z: pos.z,
553
+ screenX: screenPos?.x ?? x,
554
+ screenY: screenPos?.y ?? y,
555
+ };
556
+
557
+ updateMeasurement(measurePoint);
558
+ setSnapTarget(result.snapTarget || null);
559
+
560
+ // Update edge lock state and snap visualization (even in orthogonal mode)
561
+ if (result.edgeLock.shouldRelease) {
562
+ clearEdgeLock();
563
+ updateSnapViz(result.snapTarget || null);
564
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
565
+ // Check if we're on the same edge to preserve lock strength (hysteresis)
566
+ const sameDirection = currentLock.edge &&
567
+ Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v0.x) < 0.0001 &&
568
+ Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v0.y) < 0.0001 &&
569
+ Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v0.z) < 0.0001 &&
570
+ Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v1.x) < 0.0001 &&
571
+ Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v1.y) < 0.0001 &&
572
+ Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v1.z) < 0.0001;
573
+ const reversedDirection = currentLock.edge &&
574
+ Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v1.x) < 0.0001 &&
575
+ Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v1.y) < 0.0001 &&
576
+ Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v1.z) < 0.0001 &&
577
+ Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v0.x) < 0.0001 &&
578
+ Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v0.y) < 0.0001 &&
579
+ Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v0.z) < 0.0001;
580
+ const isSameEdge = currentLock.edge &&
581
+ currentLock.meshExpressId === result.edgeLock.meshExpressId &&
582
+ (sameDirection || reversedDirection);
583
+
584
+ if (isSameEdge) {
585
+ updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
586
+ incrementEdgeLockStrength();
587
+ } else {
588
+ setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
589
+ updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
590
+ }
591
+ updateSnapViz(result.snapTarget, {
592
+ edgeT: result.edgeLock.edgeT,
593
+ isCorner: result.edgeLock.isCorner,
594
+ cornerValence: result.edgeLock.cornerValence,
595
+ });
596
+ } else {
597
+ updateSnapViz(result.snapTarget || null);
598
+ }
599
+ }
600
+ }
601
+ });
602
+ }
603
+
604
+ // Mark as dragged (any movement counts for measure tool)
605
+ mouseState.didDrag = true;
606
+ return;
607
+ }
608
+
609
+ // Handle measure tool hover preview (BEFORE dragging starts)
610
+ // Show snap indicators to help user see where they can snap
611
+ if (tool === 'measure' && !mouseState.isDragging && snapEnabledRef.current) {
612
+ // Throttle hover snap detection more aggressively (100ms) to avoid performance issues
613
+ // Active measurement still uses 60fps throttling via requestAnimationFrame
614
+ const now = Date.now();
615
+ if (now - lastHoverSnapTimeRef.current < HOVER_SNAP_THROTTLE_MS) {
616
+ return; // Skip hover snap detection if throttled
617
+ }
618
+ lastHoverSnapTimeRef.current = now;
619
+
620
+ // Throttle raycasting to avoid performance issues
621
+ if (!measureRaycastPendingRef.current) {
622
+ measureRaycastPendingRef.current = true;
623
+
624
+ measureRaycastFrameRef.current = requestAnimationFrame(() => {
625
+ measureRaycastPendingRef.current = false;
626
+ measureRaycastFrameRef.current = null;
627
+
628
+ // Use magnetic snap for hover preview
629
+ const currentLock = edgeLockStateRef.current;
630
+ const result = renderer.raycastSceneMagnetic(x, y, {
631
+ edge: currentLock.edge,
632
+ meshExpressId: currentLock.meshExpressId,
633
+ lockStrength: currentLock.lockStrength,
634
+ }, {
635
+ hiddenIds: hiddenEntitiesRef.current,
636
+ isolatedIds: isolatedEntitiesRef.current,
637
+ snapOptions: {
638
+ snapToVertices: true,
639
+ snapToEdges: true,
640
+ snapToFaces: true,
641
+ screenSnapRadius: 40, // Good radius for hover snap detection
642
+ },
643
+ });
644
+
645
+ // Update snap target for visual feedback
646
+ if (result.snapTarget) {
647
+ setSnapTarget(result.snapTarget);
648
+
649
+ // Update edge lock state for hover
650
+ if (result.edgeLock.shouldRelease) {
651
+ // Clear stale lock when release is signaled
652
+ clearEdgeLock();
653
+ updateSnapViz(result.snapTarget);
654
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
655
+ setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
656
+ updateSnapViz(result.snapTarget, {
657
+ edgeT: result.edgeLock.edgeT,
658
+ isCorner: result.edgeLock.isCorner,
659
+ cornerValence: result.edgeLock.cornerValence,
660
+ });
661
+ } else {
662
+ updateSnapViz(result.snapTarget);
663
+ }
664
+ } else {
665
+ setSnapTarget(null);
666
+ clearEdgeLock();
667
+ updateSnapViz(null);
668
+ }
669
+ });
670
+ }
671
+ return; // Don't fall through to other tool handlers
672
+ }
673
+
674
+ // Handle orbit/pan for other tools (or measure tool with shift+drag or no active measurement)
675
+ if (mouseState.isDragging && (tool !== 'measure' || !activeMeasurementRef.current)) {
676
+ const dx = e.clientX - mouseState.lastX;
677
+ const dy = e.clientY - mouseState.lastY;
678
+
679
+ // Check if this counts as a drag (moved more than 5px from start)
680
+ const totalDx = e.clientX - mouseState.startX;
681
+ const totalDy = e.clientY - mouseState.startY;
682
+ if (Math.abs(totalDx) > 5 || Math.abs(totalDy) > 5) {
683
+ mouseState.didDrag = true;
684
+ }
685
+
686
+ // Always update camera state immediately (feels responsive)
687
+ if (mouseState.isPanning || tool === 'pan') {
688
+ // Negate dy: mouse Y increases downward, but we want upward drag to pan up
689
+ camera.pan(dx, -dy, false);
690
+ } else if (tool === 'walk') {
691
+ // Walk mode: left/right rotates, up/down moves forward/backward
692
+ camera.orbit(dx * 0.5, 0, false); // Only horizontal rotation
693
+ if (Math.abs(dy) > 2) {
694
+ camera.zoom(dy * 2, false); // Forward/backward movement
695
+ }
696
+ } else {
697
+ camera.orbit(dx, dy, false);
698
+ }
699
+
700
+ mouseState.lastX = e.clientX;
701
+ mouseState.lastY = e.clientY;
702
+
703
+ // PERFORMANCE: Adaptive throttle based on model size
704
+ // Small models: 60fps, Large: 40fps, Huge: 30fps
705
+ const meshCount = geometryRef.current?.length ?? 0;
706
+ const throttleMs = meshCount > 50000 ? RENDER_THROTTLE_MS_HUGE
707
+ : meshCount > 10000 ? RENDER_THROTTLE_MS_LARGE
708
+ : RENDER_THROTTLE_MS_SMALL;
709
+
710
+ const now = performance.now();
711
+ if (now - lastRenderTimeRef.current >= throttleMs) {
712
+ lastRenderTimeRef.current = now;
713
+ renderer.render({
714
+ hiddenIds: hiddenEntitiesRef.current,
715
+ isolatedIds: isolatedEntitiesRef.current,
716
+ selectedId: selectedEntityIdRef.current,
717
+ selectedModelIndex: selectedModelIndexRef.current,
718
+ clearColor: clearColorRef.current,
719
+ sectionPlane: activeToolRef.current === 'section' ? {
720
+ ...sectionPlaneRef.current,
721
+ min: sectionRangeRef.current?.min,
722
+ max: sectionRangeRef.current?.max,
723
+ } : undefined,
724
+ });
725
+ // Update ViewCube rotation in real-time during drag
726
+ updateCameraRotationRealtime(camera.getRotation());
727
+ calculateScale();
728
+ } else if (!renderPendingRef.current) {
729
+ // Schedule a final render for when throttle expires
730
+ // This ensures we always render the final position
731
+ renderPendingRef.current = true;
732
+ requestAnimationFrame(() => {
733
+ renderPendingRef.current = false;
734
+ renderer.render({
735
+ hiddenIds: hiddenEntitiesRef.current,
736
+ isolatedIds: isolatedEntitiesRef.current,
737
+ selectedId: selectedEntityIdRef.current,
738
+ selectedModelIndex: selectedModelIndexRef.current,
739
+ clearColor: clearColorRef.current,
740
+ sectionPlane: activeToolRef.current === 'section' ? {
741
+ ...sectionPlaneRef.current,
742
+ min: sectionRangeRef.current?.min,
743
+ max: sectionRangeRef.current?.max,
744
+ } : undefined,
745
+ });
746
+ updateCameraRotationRealtime(camera.getRotation());
747
+ calculateScale();
748
+ });
749
+ }
750
+ // Clear hover while dragging
751
+ clearHover();
752
+ } else if (hoverTooltipsEnabledRef.current) {
753
+ // Hover detection (throttled) - only if tooltips are enabled
754
+ const now = Date.now();
755
+ if (now - lastHoverCheckRef.current > hoverThrottleMs) {
756
+ lastHoverCheckRef.current = now;
757
+ // Uses visibility filtering so hidden elements don't show hover tooltips
758
+ const pickResult = await renderer.pick(x, y, getPickOptions());
759
+ if (pickResult) {
760
+ setHoverState({ entityId: pickResult.expressId, screenX: e.clientX, screenY: e.clientY });
761
+ } else {
762
+ clearHover();
763
+ }
764
+ }
765
+ }
766
+ };
767
+
768
+ const handleMouseUp = (e: MouseEvent) => {
769
+ const tool = activeToolRef.current;
770
+
771
+ // Handle measure tool completion
772
+ if (tool === 'measure' && activeMeasurementRef.current) {
773
+ // Cancel any pending raycast to avoid stale updates
774
+ if (measureRaycastFrameRef.current) {
775
+ cancelAnimationFrame(measureRaycastFrameRef.current);
776
+ measureRaycastFrameRef.current = null;
777
+ measureRaycastPendingRef.current = false;
778
+ }
779
+
780
+ // Do a final synchronous raycast at the mouseup position to ensure accurate end point
781
+ const rect = canvas.getBoundingClientRect();
782
+ const mx = e.clientX - rect.left;
783
+ const my = e.clientY - rect.top;
784
+
785
+ const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
786
+ const currentLock = edgeLockStateRef.current;
787
+
788
+ // Use simpler snap options in orthogonal mode (no magnetic locking needed)
789
+ const finalLock = useOrthogonalConstraint
790
+ ? { edge: null, meshExpressId: null, lockStrength: 0 }
791
+ : currentLock;
792
+
793
+ const result = renderer.raycastSceneMagnetic(mx, my, {
794
+ edge: finalLock.edge,
795
+ meshExpressId: finalLock.meshExpressId,
796
+ lockStrength: finalLock.lockStrength,
797
+ }, {
798
+ hiddenIds: hiddenEntitiesRef.current,
799
+ isolatedIds: isolatedEntitiesRef.current,
800
+ snapOptions: snapEnabledRef.current && !useOrthogonalConstraint ? {
801
+ snapToVertices: true,
802
+ snapToEdges: true,
803
+ snapToFaces: true,
804
+ screenSnapRadius: 60,
805
+ } : useOrthogonalConstraint ? {
806
+ // In orthogonal mode, snap to edges and vertices only (no faces)
807
+ snapToVertices: true,
808
+ snapToEdges: true,
809
+ snapToFaces: false,
810
+ screenSnapRadius: 40,
811
+ } : {
812
+ snapToVertices: false,
813
+ snapToEdges: false,
814
+ snapToFaces: false,
815
+ screenSnapRadius: 0,
816
+ },
817
+ });
818
+
819
+ // Update measurement with final position before finalizing
820
+ if (result.intersection || result.snapTarget) {
821
+ const snapPoint = result.snapTarget || result.intersection;
822
+ let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
823
+
824
+ if (pos) {
825
+ // Apply orthogonal constraint if shift is held
826
+ if (useOrthogonalConstraint && activeMeasurementRef.current) {
827
+ const constraint = measurementConstraintEdgeRef.current!;
828
+ const start = activeMeasurementRef.current.start;
829
+ const result = projectOntoConstraintAxis(start, pos, constraint);
830
+ pos = result.projectedPos;
831
+ }
832
+
833
+ const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
834
+ const measurePoint: MeasurePoint = {
835
+ x: pos.x,
836
+ y: pos.y,
837
+ z: pos.z,
838
+ screenX: screenPos?.x ?? mx,
839
+ screenY: screenPos?.y ?? my,
840
+ };
841
+ updateMeasurement(measurePoint);
842
+ }
843
+ }
844
+
845
+ finalizeMeasurement();
846
+ clearEdgeLock(); // Clear edge lock after measurement complete
847
+ mouseState.isDragging = false;
848
+ mouseState.didDrag = false;
849
+ canvas.style.cursor = 'crosshair';
850
+ return;
851
+ }
852
+
853
+ mouseState.isDragging = false;
854
+ mouseState.isPanning = false;
855
+ canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : (tool === 'measure' ? 'crosshair' : 'default'));
856
+ // Clear orbit pivot after each orbit operation
857
+ camera.setOrbitPivot(null);
858
+ };
859
+
860
+ const handleMouseLeave = () => {
861
+ const tool = activeToolRef.current;
862
+ mouseState.isDragging = false;
863
+ mouseState.isPanning = false;
864
+ camera.stopInertia();
865
+ camera.setOrbitPivot(null);
866
+ // Restore cursor based on active tool
867
+ if (tool === 'measure') {
868
+ canvas.style.cursor = 'crosshair';
869
+ } else if (tool === 'pan' || tool === 'orbit') {
870
+ canvas.style.cursor = 'grab';
871
+ } else {
872
+ canvas.style.cursor = 'default';
873
+ }
874
+ clearHover();
875
+ };
876
+
877
+ const handleContextMenu = async (e: MouseEvent) => {
878
+ e.preventDefault();
879
+ const rect = canvas.getBoundingClientRect();
880
+ const x = e.clientX - rect.left;
881
+ const y = e.clientY - rect.top;
882
+ // Uses visibility filtering so hidden elements don't appear in context menu
883
+ const pickResult = await renderer.pick(x, y, getPickOptions());
884
+ openContextMenu(pickResult?.expressId ?? null, e.clientX, e.clientY);
885
+ };
886
+
887
+ const handleWheel = (e: WheelEvent) => {
888
+ e.preventDefault();
889
+ const rect = canvas.getBoundingClientRect();
890
+ const mouseX = e.clientX - rect.left;
891
+ const mouseY = e.clientY - rect.top;
892
+ camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
893
+ renderer.render({
894
+ hiddenIds: hiddenEntitiesRef.current,
895
+ isolatedIds: isolatedEntitiesRef.current,
896
+ selectedId: selectedEntityIdRef.current,
897
+ selectedModelIndex: selectedModelIndexRef.current,
898
+ clearColor: clearColorRef.current,
899
+ sectionPlane: activeToolRef.current === 'section' ? {
900
+ ...sectionPlaneRef.current,
901
+ min: sectionRangeRef.current?.min,
902
+ max: sectionRangeRef.current?.max,
903
+ } : undefined,
904
+ });
905
+ // Update measurement screen coordinates immediately during zoom (only in measure mode)
906
+ if (activeToolRef.current === 'measure') {
907
+ if (hasPendingMeasurements()) {
908
+ updateMeasurementScreenCoords((worldPos) => {
909
+ return camera.projectToScreen(worldPos, canvas.width, canvas.height);
910
+ });
911
+ // Update camera state tracking to prevent duplicate update in animation loop
912
+ const cameraPos = camera.getPosition();
913
+ const cameraRot = camera.getRotation();
914
+ const cameraDist = camera.getDistance();
915
+ lastCameraStateRef.current = {
916
+ position: cameraPos,
917
+ rotation: cameraRot,
918
+ distance: cameraDist,
919
+ canvasWidth: canvas.width,
920
+ canvasHeight: canvas.height,
921
+ };
922
+ }
923
+ }
924
+ calculateScale();
925
+ };
926
+
927
+ // Click handling
928
+ const handleClick = async (e: MouseEvent) => {
929
+ const rect = canvas.getBoundingClientRect();
930
+ const x = e.clientX - rect.left;
931
+ const y = e.clientY - rect.top;
932
+ const tool = activeToolRef.current;
933
+
934
+ // Skip selection if user was dragging (orbiting/panning)
935
+ if (mouseState.didDrag) {
936
+ return;
937
+ }
938
+
939
+ // Skip selection for orbit/pan tools - they don't select
940
+ if (tool === 'orbit' || tool === 'pan' || tool === 'walk') {
941
+ return;
942
+ }
943
+
944
+ // Measure tool now uses drag interaction (see mousedown/mousemove/mouseup)
945
+ if (tool === 'measure') {
946
+ return; // Skip click handling for measure tool
947
+ }
948
+
949
+ const now = Date.now();
950
+ const timeSinceLastClick = now - lastClickTimeRef.current;
951
+ const clickPos = { x, y };
952
+
953
+ if (lastClickPosRef.current &&
954
+ timeSinceLastClick < 300 &&
955
+ Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
956
+ Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
957
+ // Double-click - isolate element
958
+ // Uses visibility filtering so only visible elements can be selected
959
+ const pickResult = await renderer.pick(x, y, getPickOptions());
960
+ if (pickResult) {
961
+ handlePickForSelection(pickResult);
962
+ }
963
+ lastClickTimeRef.current = 0;
964
+ lastClickPosRef.current = null;
965
+ } else {
966
+ // Single click - uses visibility filtering so only visible elements can be selected
967
+ const pickResult = await renderer.pick(x, y, getPickOptions());
968
+
969
+ // Multi-selection with Ctrl/Cmd
970
+ if (e.ctrlKey || e.metaKey) {
971
+ if (pickResult) {
972
+ toggleSelection(pickResult.expressId);
973
+ }
974
+ } else {
975
+ handlePickForSelection(pickResult);
976
+ }
977
+
978
+ lastClickTimeRef.current = now;
979
+ lastClickPosRef.current = clickPos;
980
+ }
981
+ };
982
+
983
+ canvas.addEventListener('mousedown', handleMouseDown);
984
+ canvas.addEventListener('mousemove', handleMouseMove);
985
+ canvas.addEventListener('mouseup', handleMouseUp);
986
+ canvas.addEventListener('mouseleave', handleMouseLeave);
987
+ canvas.addEventListener('contextmenu', handleContextMenu);
988
+ canvas.addEventListener('wheel', handleWheel, { passive: false });
989
+ canvas.addEventListener('click', handleClick);
990
+
991
+ return () => {
992
+ canvas.removeEventListener('mousedown', handleMouseDown);
993
+ canvas.removeEventListener('mousemove', handleMouseMove);
994
+ canvas.removeEventListener('mouseup', handleMouseUp);
995
+ canvas.removeEventListener('mouseleave', handleMouseLeave);
996
+ canvas.removeEventListener('contextmenu', handleContextMenu);
997
+ canvas.removeEventListener('wheel', handleWheel);
998
+ canvas.removeEventListener('click', handleClick);
999
+
1000
+ // Cancel pending raycast requests
1001
+ if (measureRaycastFrameRef.current !== null) {
1002
+ cancelAnimationFrame(measureRaycastFrameRef.current);
1003
+ measureRaycastFrameRef.current = null;
1004
+ }
1005
+ };
1006
+ }, [isInitialized]);
1007
+ }
1008
+
1009
+ export default useMouseControls;