@ifc-lite/viewer 1.16.0 → 1.17.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 (55) hide show
  1. package/.turbo/turbo-build.log +46 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/CHANGELOG.md +15 -0
  4. package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-CcoDLP6E.js} +1 -1
  5. package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-FtbS__bG.js} +1 -1
  6. package/dist/assets/{browser-vWDubxDI.js → browser-CXd3z0DO.js} +1 -1
  7. package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
  8. package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
  9. package/dist/assets/index-Ba4eoTe7.css +1 -0
  10. package/dist/assets/{index-BImINgzG.js → index-D99fzcwI.js} +28962 -26947
  11. package/dist/assets/{index-RXIK18da.js → index-DqNiuQep.js} +4 -4
  12. package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-DjDj2M6p.js} +1 -1
  13. package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-CDTF4ZQc.js} +1 -1
  14. package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
  15. package/dist/index.html +2 -2
  16. package/package.json +15 -14
  17. package/src/components/viewer/BCFPanel.tsx +12 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  19. package/src/components/viewer/CommandPalette.tsx +0 -6
  20. package/src/components/viewer/DataConnector.tsx +489 -284
  21. package/src/components/viewer/ExportDialog.tsx +66 -6
  22. package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
  23. package/src/components/viewer/MainToolbar.tsx +1 -5
  24. package/src/components/viewer/Viewport.tsx +42 -56
  25. package/src/components/viewer/ViewportContainer.tsx +3 -0
  26. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  27. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  28. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  29. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  30. package/src/components/viewer/measureHandlers.ts +558 -0
  31. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  32. package/src/components/viewer/selectionHandlers.ts +86 -0
  33. package/src/components/viewer/useAnimationLoop.ts +116 -44
  34. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  35. package/src/components/viewer/useKeyboardControls.ts +30 -46
  36. package/src/components/viewer/useMouseControls.ts +169 -695
  37. package/src/components/viewer/useRenderUpdates.ts +9 -59
  38. package/src/components/viewer/useTouchControls.ts +55 -40
  39. package/src/hooks/bcfIdLookup.ts +70 -0
  40. package/src/hooks/useBCF.ts +12 -31
  41. package/src/hooks/useIfcCache.ts +2 -20
  42. package/src/hooks/useIfcFederation.ts +5 -11
  43. package/src/hooks/useIfcLoader.ts +47 -56
  44. package/src/hooks/useIfcServer.ts +9 -1
  45. package/src/hooks/useKeyboardShortcuts.ts +0 -10
  46. package/src/hooks/useLatestRef.ts +24 -0
  47. package/src/sdk/adapters/export-adapter.ts +2 -2
  48. package/src/sdk/adapters/model-adapter.ts +1 -0
  49. package/src/sdk/local-backend.ts +2 -0
  50. package/src/store/basketVisibleSet.ts +12 -0
  51. package/src/store/slices/bcfSlice.ts +9 -0
  52. package/src/utils/loadingUtils.ts +46 -0
  53. package/src/utils/serverDataModel.ts +4 -3
  54. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  55. package/dist/assets/index-ax1X2WPd.css +0 -1
@@ -3,8 +3,10 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  /**
6
- * Mouse controls hook for the 3D viewport
7
- * Handles mouse event handlers (orbit, pan, select, measure, context menu)
6
+ * Mouse controls orchestrator hook for the 3D viewport.
7
+ * Handles orbit, pan, wheel, hover, and mouse-leave logic directly.
8
+ * Delegates measurement interactions to measureHandlers.ts and
9
+ * selection/context-menu interactions to selectionHandlers.ts.
8
10
  */
9
11
 
10
12
  import { useEffect, type MutableRefObject, type RefObject } from 'react';
@@ -19,6 +21,15 @@ import type {
19
21
  } from '@/store';
20
22
  import type { MeasurementConstraintEdge, OrthogonalAxis, Vec3 } from '@/store/types.js';
21
23
  import { getEntityCenter } from '../../utils/viewportUtils.js';
24
+ import type { MouseHandlerContext } from './mouseHandlerTypes.js';
25
+ import {
26
+ handleMeasureDown,
27
+ handleMeasureDrag,
28
+ handleMeasureHover,
29
+ handleMeasureUp,
30
+ updateMeasureScreenCoords,
31
+ } from './measureHandlers.js';
32
+ import { handleSelectionClick, handleContextMenu as handleContextMenuSelection } from './selectionHandlers.js';
22
33
 
23
34
  export interface MouseState {
24
35
  isDragging: boolean;
@@ -72,6 +83,9 @@ export interface UseMouseControlsParams {
72
83
  lastRenderTimeRef: MutableRefObject<number>;
73
84
  renderPendingRef: MutableRefObject<boolean>;
74
85
 
86
+ // Interaction state — set during drag, cleared on mouseup
87
+ isInteractingRef: MutableRefObject<boolean>;
88
+
75
89
  // Click detection refs
76
90
  lastClickTimeRef: MutableRefObject<number>;
77
91
  lastClickPosRef: MutableRefObject<{ x: number; y: number } | null>;
@@ -117,59 +131,6 @@ export interface UseMouseControlsParams {
117
131
  RENDER_THROTTLE_MS_HUGE: number;
118
132
  }
119
133
 
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
134
  export function useMouseControls(params: UseMouseControlsParams): void {
174
135
  const {
175
136
  canvasRef,
@@ -197,6 +158,7 @@ export function useMouseControls(params: UseMouseControlsParams): void {
197
158
  hoverTooltipsEnabledRef,
198
159
  lastRenderTimeRef,
199
160
  renderPendingRef,
161
+ isInteractingRef,
200
162
  lastClickTimeRef,
201
163
  lastClickPosRef,
202
164
  lastCameraStateRef,
@@ -237,77 +199,55 @@ export function useMouseControls(params: UseMouseControlsParams): void {
237
199
  const camera = renderer.getCamera();
238
200
  const mouseState = mouseStateRef.current;
239
201
 
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
- }
202
+ // Build shared context for extracted handler functions
203
+ const ctx: MouseHandlerContext = {
204
+ canvas,
205
+ renderer,
206
+ camera,
207
+ mouseState,
208
+ activeToolRef,
209
+ activeMeasurementRef,
210
+ snapEnabledRef,
211
+ edgeLockStateRef,
212
+ measurementConstraintEdgeRef,
213
+ hiddenEntitiesRef,
214
+ isolatedEntitiesRef,
215
+ geometryRef,
216
+ measureRaycastPendingRef,
217
+ measureRaycastFrameRef,
218
+ lastMeasureRaycastDurationRef,
219
+ lastHoverSnapTimeRef,
220
+ lastCameraStateRef,
221
+ lastClickTimeRef,
222
+ lastClickPosRef,
223
+ startMeasurement,
224
+ updateMeasurement,
225
+ finalizeMeasurement,
226
+ setSnapTarget,
227
+ setSnapVisualization,
228
+ setEdgeLock,
229
+ updateEdgeLockPosition,
230
+ clearEdgeLock,
231
+ incrementEdgeLockStrength,
232
+ setMeasurementConstraintEdge,
233
+ updateConstraintActiveAxis,
234
+ updateMeasurementScreenCoords,
235
+ handlePickForSelection,
236
+ toggleSelection,
237
+ openContextMenu,
238
+ hasPendingMeasurements,
239
+ getPickOptions,
240
+ HOVER_SNAP_THROTTLE_MS,
241
+ SLOW_RAYCAST_THRESHOLD_MS,
242
+ };
307
243
 
308
244
  // Mouse controls - respect active tool
309
- const handleMouseDown = async (e: MouseEvent) => {
245
+ // Uses pointer events + setPointerCapture so pointerup always fires,
246
+ // even when the pointer leaves the canvas (e.g. dragging across panels).
247
+ const handleMouseDown = async (e: PointerEvent) => {
310
248
  e.preventDefault();
249
+ // Capture the pointer so move/up events fire even outside the canvas
250
+ canvas.setPointerCapture(e.pointerId);
311
251
  mouseState.isDragging = true;
312
252
  mouseState.button = e.button;
313
253
  mouseState.lastX = e.clientX;
@@ -319,39 +259,72 @@ export function useMouseControls(params: UseMouseControlsParams): void {
319
259
  // Determine action based on active tool and mouse button
320
260
  const tool = activeToolRef.current;
321
261
 
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') {
262
+ // Will this mousedown lead to an orbit drag?
263
+ const isPanGesture = tool === 'pan' || e.button === 1 || e.button === 2 ||
264
+ (tool === 'select' && e.shiftKey);
265
+ const willOrbit = !isPanGesture && (
266
+ tool === 'select' ||
267
+ (tool === 'measure' && e.shiftKey) ||
268
+ !e.shiftKey // default tools: no shift = orbit
269
+ );
270
+
271
+ // Set orbit pivot to the 3D point under the cursor so rotation feels anchored
272
+ // to what the user is looking at. On miss, place pivot at current distance along
273
+ // the cursor ray so orbit always feels connected to where you're pointing.
274
+ if (willOrbit) {
329
275
  const rect = canvas.getBoundingClientRect();
330
- const x = e.clientX - rect.left;
331
- const y = e.clientY - rect.top;
276
+ const cx = e.clientX - rect.left;
277
+ const cy = e.clientY - rect.top;
278
+
279
+ // For large models, skip the expensive CPU raycast (collectVisibleMeshData +
280
+ // BVH build over 200K+ meshes can block the main thread for seconds).
281
+ // Instead, project the camera target onto the cursor ray for a fast pivot.
282
+ const scene = renderer.getScene();
283
+ const batchedMeshes = scene.getBatchedMeshes();
284
+ let totalEntities = scene.getMeshes().length;
285
+ for (const b of batchedMeshes) totalEntities += b.expressIds.length;
286
+ const isLargeModel = totalEntities > 50_000;
287
+
288
+ let hit: { intersection: { point: { x: number; y: number; z: number } } } | null = null;
289
+ if (!isLargeModel) {
290
+ hit = renderer.raycastScene(cx, cy, {
291
+ hiddenIds: hiddenEntitiesRef.current,
292
+ isolatedIds: isolatedEntitiesRef.current,
293
+ });
294
+ }
332
295
 
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);
296
+ if (hit?.intersection) {
297
+ camera.setOrbitCenter(hit.intersection.point);
298
+ } else if (selectedEntityIdRef.current) {
299
+ // No geometry under cursor but object selected — use its center
300
+ const center = getEntityCenter(geometryRef.current, selectedEntityIdRef.current);
338
301
  if (center) {
339
- camera.setOrbitPivot(center);
302
+ camera.setOrbitCenter(center);
340
303
  } else {
341
- camera.setOrbitPivot(null);
304
+ camera.setOrbitCenter(null);
342
305
  }
343
306
  } else {
344
- // No geometry under cursor - orbit around current target (model center)
345
- camera.setOrbitPivot(null);
307
+ // No geometry hit or large model project camera target onto the cursor ray.
308
+ // Places pivot at the model's depth but under the cursor.
309
+ const ray = camera.unprojectToRay(cx, cy, canvas.width, canvas.height);
310
+ const target = camera.getTarget();
311
+ const toTarget = {
312
+ x: target.x - ray.origin.x,
313
+ y: target.y - ray.origin.y,
314
+ z: target.z - ray.origin.z,
315
+ };
316
+ const d = Math.max(1, toTarget.x * ray.direction.x + toTarget.y * ray.direction.y + toTarget.z * ray.direction.z);
317
+ camera.setOrbitCenter({
318
+ x: ray.origin.x + ray.direction.x * d,
319
+ y: ray.origin.y + ray.direction.y * d,
320
+ z: ray.origin.z + ray.direction.z * d,
321
+ });
346
322
  }
347
323
  }
348
324
 
349
325
  if (tool === 'pan' || e.button === 1 || e.button === 2) {
350
326
  mouseState.isPanning = true;
351
327
  canvas.style.cursor = 'move';
352
- } else if (tool === 'orbit') {
353
- mouseState.isPanning = false;
354
- canvas.style.cursor = 'grabbing';
355
328
  } else if (tool === 'select') {
356
329
  // Select tool: shift+drag = pan, normal drag = orbit
357
330
  mouseState.isPanning = e.shiftKey;
@@ -365,90 +338,8 @@ export function useMouseControls(params: UseMouseControlsParams): void {
365
338
  canvas.style.cursor = 'grabbing';
366
339
  // Fall through to allow orbit handling in mousemove
367
340
  } 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)
341
+ // Normal drag: delegate to measurement handler
342
+ if (handleMeasureDown(ctx, e)) return;
452
343
  }
453
344
  } else {
454
345
  // Default behavior
@@ -466,209 +357,13 @@ export function useMouseControls(params: UseMouseControlsParams): void {
466
357
  // Handle measure tool live preview while dragging
467
358
  // IMPORTANT: Check tool first, not activeMeasurement, to prevent orbit conflict
468
359
  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;
360
+ if (handleMeasureDrag(ctx, e, x, y)) return;
607
361
  }
608
362
 
609
363
  // Handle measure tool hover preview (BEFORE dragging starts)
610
364
  // Show snap indicators to help user see where they can snap
611
365
  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
366
+ if (handleMeasureHover(ctx, x, y)) return;
672
367
  }
673
368
 
674
369
  // Handle orbit/pan for other tools (or measure tool with shift+drag or no active measurement)
@@ -685,14 +380,10 @@ export function useMouseControls(params: UseMouseControlsParams): void {
685
380
 
686
381
  // Always update camera state immediately (feels responsive)
687
382
  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);
383
+ camera.pan(dx, dy, false);
690
384
  } 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
- }
385
+ // Walk mode: mouse drag looks around (full orbit)
386
+ camera.orbit(dx, dy, false);
696
387
  } else {
697
388
  camera.orbit(dx, dy, false);
698
389
  }
@@ -700,57 +391,16 @@ export function useMouseControls(params: UseMouseControlsParams): void {
700
391
  mouseState.lastX = e.clientX;
701
392
  mouseState.lastY = e.clientY;
702
393
 
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;
394
+ // Signal the animation loop to render.
395
+ // No throttle needed the loop runs at display refresh rate and
396
+ // coalesces multiple requestRender() calls into one frame.
397
+ isInteractingRef.current = true;
398
+ renderer.requestRender();
399
+ updateCameraRotationRealtime(camera.getRotation());
400
+ calculateScale();
401
+
402
+
709
403
 
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
- isInteracting: true,
720
- sectionPlane: activeToolRef.current === 'section' ? {
721
- ...sectionPlaneRef.current,
722
- min: sectionRangeRef.current?.min,
723
- max: sectionRangeRef.current?.max,
724
- } : undefined,
725
- });
726
- // Update ViewCube rotation in real-time during drag
727
- updateCameraRotationRealtime(camera.getRotation());
728
- calculateScale();
729
- } else if (!renderPendingRef.current) {
730
- // Schedule a final render for when throttle expires
731
- // IMPORTANT: Keep isInteracting: true during drag to prevent flickering
732
- // caused by post-processing toggling on/off between throttled frames.
733
- // Post-processing is restored on mouseup (non-interacting render).
734
- renderPendingRef.current = true;
735
- requestAnimationFrame(() => {
736
- renderPendingRef.current = false;
737
- renderer.render({
738
- hiddenIds: hiddenEntitiesRef.current,
739
- isolatedIds: isolatedEntitiesRef.current,
740
- selectedId: selectedEntityIdRef.current,
741
- selectedModelIndex: selectedModelIndexRef.current,
742
- clearColor: clearColorRef.current,
743
- isInteracting: true,
744
- sectionPlane: activeToolRef.current === 'section' ? {
745
- ...sectionPlaneRef.current,
746
- min: sectionRangeRef.current?.min,
747
- max: sectionRangeRef.current?.max,
748
- } : undefined,
749
- });
750
- updateCameraRotationRealtime(camera.getRotation());
751
- calculateScale();
752
- });
753
- }
754
404
  // Clear hover while dragging
755
405
  clearHover();
756
406
  } else if (hoverTooltipsEnabledRef.current) {
@@ -769,96 +419,26 @@ export function useMouseControls(params: UseMouseControlsParams): void {
769
419
  }
770
420
  };
771
421
 
772
- const handleMouseUp = (e: MouseEvent) => {
422
+ const handleMouseUp = (e: PointerEvent) => {
423
+ // Release pointer capture (safe to call even if not captured)
424
+ canvas.releasePointerCapture(e.pointerId);
425
+
426
+ // Clear interaction flag so the animation loop restores post-processing
427
+ if (isInteractingRef.current) {
428
+ isInteractingRef.current = false;
429
+ renderer.requestRender();
430
+ }
431
+
773
432
  const tool = activeToolRef.current;
774
433
 
775
434
  // Handle measure tool completion
776
435
  if (tool === 'measure' && activeMeasurementRef.current) {
777
- // Cancel any pending raycast to avoid stale updates
778
- if (measureRaycastFrameRef.current) {
779
- cancelAnimationFrame(measureRaycastFrameRef.current);
780
- measureRaycastFrameRef.current = null;
781
- measureRaycastPendingRef.current = false;
782
- }
783
-
784
- // Do a final synchronous raycast at the mouseup position to ensure accurate end point
785
- const rect = canvas.getBoundingClientRect();
786
- const mx = e.clientX - rect.left;
787
- const my = e.clientY - rect.top;
788
-
789
- const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
790
- const currentLock = edgeLockStateRef.current;
791
-
792
- // Use simpler snap options in orthogonal mode (no magnetic locking needed)
793
- const finalLock = useOrthogonalConstraint
794
- ? { edge: null, meshExpressId: null, lockStrength: 0 }
795
- : currentLock;
796
-
797
- const result = renderer.raycastSceneMagnetic(mx, my, {
798
- edge: finalLock.edge,
799
- meshExpressId: finalLock.meshExpressId,
800
- lockStrength: finalLock.lockStrength,
801
- }, {
802
- hiddenIds: hiddenEntitiesRef.current,
803
- isolatedIds: isolatedEntitiesRef.current,
804
- snapOptions: snapEnabledRef.current && !useOrthogonalConstraint ? {
805
- snapToVertices: true,
806
- snapToEdges: true,
807
- snapToFaces: true,
808
- screenSnapRadius: 60,
809
- } : useOrthogonalConstraint ? {
810
- // In orthogonal mode, snap to edges and vertices only (no faces)
811
- snapToVertices: true,
812
- snapToEdges: true,
813
- snapToFaces: false,
814
- screenSnapRadius: 40,
815
- } : {
816
- snapToVertices: false,
817
- snapToEdges: false,
818
- snapToFaces: false,
819
- screenSnapRadius: 0,
820
- },
821
- });
822
-
823
- // Update measurement with final position before finalizing
824
- if (result.intersection || result.snapTarget) {
825
- const snapPoint = result.snapTarget || result.intersection;
826
- let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
827
-
828
- if (pos) {
829
- // Apply orthogonal constraint if shift is held
830
- if (useOrthogonalConstraint && activeMeasurementRef.current) {
831
- const constraint = measurementConstraintEdgeRef.current!;
832
- const start = activeMeasurementRef.current.start;
833
- const result = projectOntoConstraintAxis(start, pos, constraint);
834
- pos = result.projectedPos;
835
- }
836
-
837
- const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
838
- const measurePoint: MeasurePoint = {
839
- x: pos.x,
840
- y: pos.y,
841
- z: pos.z,
842
- screenX: screenPos?.x ?? mx,
843
- screenY: screenPos?.y ?? my,
844
- };
845
- updateMeasurement(measurePoint);
846
- }
847
- }
848
-
849
- finalizeMeasurement();
850
- clearEdgeLock(); // Clear edge lock after measurement complete
851
- mouseState.isDragging = false;
852
- mouseState.didDrag = false;
853
- canvas.style.cursor = 'crosshair';
854
- return;
436
+ if (handleMeasureUp(ctx, e)) return;
855
437
  }
856
438
 
857
439
  mouseState.isDragging = false;
858
440
  mouseState.isPanning = false;
859
- canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : (tool === 'measure' ? 'crosshair' : 'default'));
860
- // Clear orbit pivot after each orbit operation
861
- camera.setOrbitPivot(null);
441
+ canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'walk' ? 'crosshair' : (tool === 'measure' ? 'crosshair' : 'default'));
862
442
  };
863
443
 
864
444
  const handleMouseLeave = () => {
@@ -866,12 +446,13 @@ export function useMouseControls(params: UseMouseControlsParams): void {
866
446
  mouseState.isDragging = false;
867
447
  mouseState.isPanning = false;
868
448
  camera.stopInertia();
869
- camera.setOrbitPivot(null);
870
449
  // Restore cursor based on active tool
871
450
  if (tool === 'measure') {
872
451
  canvas.style.cursor = 'crosshair';
873
- } else if (tool === 'pan' || tool === 'orbit') {
452
+ } else if (tool === 'pan') {
874
453
  canvas.style.cursor = 'grab';
454
+ } else if (tool === 'walk') {
455
+ canvas.style.cursor = 'crosshair';
875
456
  } else {
876
457
  canvas.style.cursor = 'default';
877
458
  }
@@ -879,164 +460,57 @@ export function useMouseControls(params: UseMouseControlsParams): void {
879
460
  };
880
461
 
881
462
  const handleContextMenu = async (e: MouseEvent) => {
882
- e.preventDefault();
883
- const rect = canvas.getBoundingClientRect();
884
- const x = e.clientX - rect.left;
885
- const y = e.clientY - rect.top;
886
- // Uses visibility filtering so hidden elements don't appear in context menu
887
- const pickResult = await renderer.pick(x, y, getPickOptions());
888
- openContextMenu(pickResult?.expressId ?? null, e.clientX, e.clientY);
463
+ await handleContextMenuSelection(ctx, e);
889
464
  };
890
465
 
466
+ // Debounce: clear isInteracting 150ms after the last wheel event
467
+ let wheelIdleTimer: ReturnType<typeof setTimeout> | null = null;
468
+
891
469
  const handleWheel = (e: WheelEvent) => {
892
470
  e.preventDefault();
471
+ if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
472
+ wheelIdleTimer = setTimeout(() => {
473
+ isInteractingRef.current = false;
474
+ renderer.requestRender();
475
+ }, 150);
893
476
  const rect = canvas.getBoundingClientRect();
894
477
  const mouseX = e.clientX - rect.left;
895
478
  const mouseY = e.clientY - rect.top;
896
479
  camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
897
480
 
898
- // PERFORMANCE: Adaptive throttle for wheel zoom (same as orbit)
899
- // Without this, every wheel event triggers a synchronous render —
900
- // wheel events fire at 60-120Hz which overwhelms the GPU on large models.
901
- const meshCount = geometryRef.current?.length ?? 0;
902
- const throttleMs = meshCount > 50000 ? RENDER_THROTTLE_MS_HUGE
903
- : meshCount > 10000 ? RENDER_THROTTLE_MS_LARGE
904
- : RENDER_THROTTLE_MS_SMALL;
905
-
906
- const now = performance.now();
907
- if (now - lastRenderTimeRef.current >= throttleMs) {
908
- lastRenderTimeRef.current = now;
909
- renderer.render({
910
- hiddenIds: hiddenEntitiesRef.current,
911
- isolatedIds: isolatedEntitiesRef.current,
912
- selectedId: selectedEntityIdRef.current,
913
- selectedModelIndex: selectedModelIndexRef.current,
914
- clearColor: clearColorRef.current,
915
- isInteracting: true,
916
- sectionPlane: activeToolRef.current === 'section' ? {
917
- ...sectionPlaneRef.current,
918
- min: sectionRangeRef.current?.min,
919
- max: sectionRangeRef.current?.max,
920
- } : undefined,
921
- });
922
- calculateScale();
923
- } else if (!renderPendingRef.current) {
924
- // Schedule a final render to ensure we always render the last zoom position
925
- // IMPORTANT: Keep isInteracting: true to prevent flickering from post-processing
926
- // toggling. Post-processing is restored by the zoom idle timer below.
927
- renderPendingRef.current = true;
928
- requestAnimationFrame(() => {
929
- renderPendingRef.current = false;
930
- renderer.render({
931
- hiddenIds: hiddenEntitiesRef.current,
932
- isolatedIds: isolatedEntitiesRef.current,
933
- selectedId: selectedEntityIdRef.current,
934
- selectedModelIndex: selectedModelIndexRef.current,
935
- clearColor: clearColorRef.current,
936
- isInteracting: true,
937
- sectionPlane: activeToolRef.current === 'section' ? {
938
- ...sectionPlaneRef.current,
939
- min: sectionRangeRef.current?.min,
940
- max: sectionRangeRef.current?.max,
941
- } : undefined,
942
- });
943
- calculateScale();
944
- });
945
- }
481
+ isInteractingRef.current = true;
482
+ renderer.requestRender();
946
483
 
947
484
  // Update measurement screen coordinates immediately during zoom (only in measure mode)
948
485
  if (activeToolRef.current === 'measure') {
949
486
  if (hasPendingMeasurements()) {
950
- updateMeasurementScreenCoords((worldPos) => {
951
- return camera.projectToScreen(worldPos, canvas.width, canvas.height);
952
- });
953
- // Update camera state tracking to prevent duplicate update in animation loop
954
- const cameraPos = camera.getPosition();
955
- const cameraRot = camera.getRotation();
956
- const cameraDist = camera.getDistance();
957
- lastCameraStateRef.current = {
958
- position: cameraPos,
959
- rotation: cameraRot,
960
- distance: cameraDist,
961
- canvasWidth: canvas.width,
962
- canvasHeight: canvas.height,
963
- };
487
+ updateMeasureScreenCoords(ctx);
964
488
  }
965
489
  }
966
490
  };
967
491
 
968
- // Click handling
492
+ // Click handling — delegated to selectionHandlers
969
493
  const handleClick = async (e: MouseEvent) => {
970
- const rect = canvas.getBoundingClientRect();
971
- const x = e.clientX - rect.left;
972
- const y = e.clientY - rect.top;
973
- const tool = activeToolRef.current;
974
-
975
- // Skip selection if user was dragging (orbiting/panning)
976
- if (mouseState.didDrag) {
977
- return;
978
- }
979
-
980
- // Skip selection for orbit/pan tools - they don't select
981
- if (tool === 'orbit' || tool === 'pan' || tool === 'walk') {
982
- return;
983
- }
984
-
985
- // Measure tool now uses drag interaction (see mousedown/mousemove/mouseup)
986
- if (tool === 'measure') {
987
- return; // Skip click handling for measure tool
988
- }
989
-
990
- const now = Date.now();
991
- const timeSinceLastClick = now - lastClickTimeRef.current;
992
- const clickPos = { x, y };
993
-
994
- if (lastClickPosRef.current &&
995
- timeSinceLastClick < 300 &&
996
- Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
997
- Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
998
- // Double-click - isolate element
999
- // Uses visibility filtering so only visible elements can be selected
1000
- const pickResult = await renderer.pick(x, y, getPickOptions());
1001
- if (pickResult) {
1002
- handlePickForSelection(pickResult);
1003
- }
1004
- lastClickTimeRef.current = 0;
1005
- lastClickPosRef.current = null;
1006
- } else {
1007
- // Single click - uses visibility filtering so only visible elements can be selected
1008
- const pickResult = await renderer.pick(x, y, getPickOptions());
1009
-
1010
- // Multi-selection with Ctrl/Cmd
1011
- if (e.ctrlKey || e.metaKey) {
1012
- if (pickResult) {
1013
- toggleSelection(pickResult.expressId);
1014
- }
1015
- } else {
1016
- handlePickForSelection(pickResult);
1017
- }
1018
-
1019
- lastClickTimeRef.current = now;
1020
- lastClickPosRef.current = clickPos;
1021
- }
494
+ await handleSelectionClick(ctx, e);
1022
495
  };
1023
496
 
1024
- canvas.addEventListener('mousedown', handleMouseDown);
1025
- canvas.addEventListener('mousemove', handleMouseMove);
1026
- canvas.addEventListener('mouseup', handleMouseUp);
497
+ canvas.addEventListener('pointerdown', handleMouseDown);
498
+ canvas.addEventListener('pointermove', handleMouseMove);
499
+ canvas.addEventListener('pointerup', handleMouseUp);
1027
500
  canvas.addEventListener('mouseleave', handleMouseLeave);
1028
501
  canvas.addEventListener('contextmenu', handleContextMenu);
1029
502
  canvas.addEventListener('wheel', handleWheel, { passive: false });
1030
503
  canvas.addEventListener('click', handleClick);
1031
504
 
1032
505
  return () => {
1033
- canvas.removeEventListener('mousedown', handleMouseDown);
1034
- canvas.removeEventListener('mousemove', handleMouseMove);
1035
- canvas.removeEventListener('mouseup', handleMouseUp);
506
+ canvas.removeEventListener('pointerdown', handleMouseDown);
507
+ canvas.removeEventListener('pointermove', handleMouseMove);
508
+ canvas.removeEventListener('pointerup', handleMouseUp);
1036
509
  canvas.removeEventListener('mouseleave', handleMouseLeave);
1037
510
  canvas.removeEventListener('contextmenu', handleContextMenu);
1038
511
  canvas.removeEventListener('wheel', handleWheel);
1039
512
  canvas.removeEventListener('click', handleClick);
513
+ if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
1040
514
 
1041
515
  // Cancel pending raycast requests
1042
516
  if (measureRaycastFrameRef.current !== null) {