@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.
- package/.turbo/turbo-build.log +46 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +15 -0
- package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-CcoDLP6E.js} +1 -1
- package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-FtbS__bG.js} +1 -1
- package/dist/assets/{browser-vWDubxDI.js → browser-CXd3z0DO.js} +1 -1
- package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
- package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
- package/dist/assets/index-Ba4eoTe7.css +1 -0
- package/dist/assets/{index-BImINgzG.js → index-D99fzcwI.js} +28962 -26947
- package/dist/assets/{index-RXIK18da.js → index-DqNiuQep.js} +4 -4
- package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-DjDj2M6p.js} +1 -1
- package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-CDTF4ZQc.js} +1 -1
- package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
- package/dist/index.html +2 -2
- package/package.json +15 -14
- package/src/components/viewer/BCFPanel.tsx +12 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
- package/src/components/viewer/CommandPalette.tsx +0 -6
- package/src/components/viewer/DataConnector.tsx +489 -284
- package/src/components/viewer/ExportDialog.tsx +66 -6
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
- package/src/components/viewer/MainToolbar.tsx +1 -5
- package/src/components/viewer/Viewport.tsx +42 -56
- package/src/components/viewer/ViewportContainer.tsx +3 -0
- package/src/components/viewer/ViewportOverlays.tsx +12 -10
- package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
- package/src/components/viewer/lists/ListPanel.tsx +0 -21
- package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
- package/src/components/viewer/measureHandlers.ts +558 -0
- package/src/components/viewer/mouseHandlerTypes.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +86 -0
- package/src/components/viewer/useAnimationLoop.ts +116 -44
- package/src/components/viewer/useGeometryStreaming.ts +155 -367
- package/src/components/viewer/useKeyboardControls.ts +30 -46
- package/src/components/viewer/useMouseControls.ts +169 -695
- package/src/components/viewer/useRenderUpdates.ts +9 -59
- package/src/components/viewer/useTouchControls.ts +55 -40
- package/src/hooks/bcfIdLookup.ts +70 -0
- package/src/hooks/useBCF.ts +12 -31
- package/src/hooks/useIfcCache.ts +2 -20
- package/src/hooks/useIfcFederation.ts +5 -11
- package/src/hooks/useIfcLoader.ts +47 -56
- package/src/hooks/useIfcServer.ts +9 -1
- package/src/hooks/useKeyboardShortcuts.ts +0 -10
- package/src/hooks/useLatestRef.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +2 -2
- package/src/sdk/adapters/model-adapter.ts +1 -0
- package/src/sdk/local-backend.ts +2 -0
- package/src/store/basketVisibleSet.ts +12 -0
- package/src/store/slices/bcfSlice.ts +9 -0
- package/src/utils/loadingUtils.ts +46 -0
- package/src/utils/serverDataModel.ts +4 -3
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- 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
|
|
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
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
(tool
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
331
|
-
const
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const center = getEntityCenter(geometryRef.current,
|
|
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.
|
|
302
|
+
camera.setOrbitCenter(center);
|
|
340
303
|
} else {
|
|
341
|
-
camera.
|
|
304
|
+
camera.setOrbitCenter(null);
|
|
342
305
|
}
|
|
343
306
|
} else {
|
|
344
|
-
// No geometry
|
|
345
|
-
|
|
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:
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
camera.pan(dx, -dy, false);
|
|
383
|
+
camera.pan(dx, dy, false);
|
|
690
384
|
} else if (tool === 'walk') {
|
|
691
|
-
// Walk mode:
|
|
692
|
-
camera.orbit(dx
|
|
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
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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:
|
|
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
|
-
|
|
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 === '
|
|
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'
|
|
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
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
1025
|
-
canvas.addEventListener('
|
|
1026
|
-
canvas.addEventListener('
|
|
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('
|
|
1034
|
-
canvas.removeEventListener('
|
|
1035
|
-
canvas.removeEventListener('
|
|
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) {
|