@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
@@ -0,0 +1,558 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Measurement handler functions extracted from useMouseControls.
7
+ * Pure functions that operate on a MouseHandlerContext — no React dependency.
8
+ */
9
+
10
+ import type { SnapTarget } from '@ifc-lite/renderer';
11
+ import type { MeasurePoint, SnapVisualization } from '@/store';
12
+ import type { MeasurementConstraintEdge, OrthogonalAxis, Vec3 } from '@/store/types.js';
13
+ import type { MouseHandlerContext, Camera } from './mouseHandlerTypes.js';
14
+ import { getEntityCenter } from '../../utils/viewportUtils.js';
15
+ import type { MeshData } from '@ifc-lite/geometry';
16
+
17
+ /**
18
+ * Projects a world position onto the closest orthogonal constraint axis.
19
+ * Used by measurement tool when shift is held for axis-aligned measurements.
20
+ *
21
+ * Computes the dot product of the displacement vector (startWorld -> currentWorld)
22
+ * with each of the three orthogonal axes, then projects onto whichever axis has
23
+ * the largest absolute dot product (i.e., the axis most aligned with the cursor direction).
24
+ */
25
+ export function projectOntoConstraintAxis(
26
+ startWorld: Vec3,
27
+ currentWorld: Vec3,
28
+ constraint: MeasurementConstraintEdge,
29
+ ): { projectedPos: Vec3; activeAxis: OrthogonalAxis } {
30
+ const dx = currentWorld.x - startWorld.x;
31
+ const dy = currentWorld.y - startWorld.y;
32
+ const dz = currentWorld.z - startWorld.z;
33
+
34
+ const { axis1, axis2, axis3 } = constraint.axes;
35
+ const dot1 = dx * axis1.x + dy * axis1.y + dz * axis1.z;
36
+ const dot2 = dx * axis2.x + dy * axis2.y + dz * axis2.z;
37
+ const dot3 = dx * axis3.x + dy * axis3.y + dz * axis3.z;
38
+
39
+ const absDot1 = Math.abs(dot1);
40
+ const absDot2 = Math.abs(dot2);
41
+ const absDot3 = Math.abs(dot3);
42
+
43
+ let activeAxis: OrthogonalAxis;
44
+ let chosenDot: number;
45
+ let chosenDir: Vec3;
46
+
47
+ if (absDot1 >= absDot2 && absDot1 >= absDot3) {
48
+ activeAxis = 'axis1';
49
+ chosenDot = dot1;
50
+ chosenDir = axis1;
51
+ } else if (absDot2 >= absDot3) {
52
+ activeAxis = 'axis2';
53
+ chosenDot = dot2;
54
+ chosenDir = axis2;
55
+ } else {
56
+ activeAxis = 'axis3';
57
+ chosenDot = dot3;
58
+ chosenDir = axis3;
59
+ }
60
+
61
+ const projectedPos: Vec3 = {
62
+ x: startWorld.x + chosenDot * chosenDir.x,
63
+ y: startWorld.y + chosenDot * chosenDir.y,
64
+ z: startWorld.z + chosenDot * chosenDir.z,
65
+ };
66
+
67
+ return { projectedPos, activeAxis };
68
+ }
69
+
70
+ /**
71
+ * Compute snap visualization (edge highlights, sliding dot, corner rings, plane indicators).
72
+ * Stores 3D coordinates so edge highlights stay positioned correctly during camera rotation.
73
+ */
74
+ export function updateSnapViz(
75
+ ctx: MouseHandlerContext,
76
+ snapTarget: SnapTarget | null,
77
+ edgeLockInfo?: { edgeT: number; isCorner: boolean; cornerValence: number },
78
+ ): void {
79
+ if (!snapTarget || !ctx.canvas) {
80
+ ctx.setSnapVisualization(null);
81
+ return;
82
+ }
83
+
84
+ const viz: Partial<SnapVisualization> = {};
85
+
86
+ // For edge snaps: store 3D world coordinates (will be projected to screen by ToolOverlays)
87
+ if ((snapTarget.type === 'edge' || snapTarget.type === 'vertex') && snapTarget.metadata?.vertices) {
88
+ const [v0, v1] = snapTarget.metadata.vertices;
89
+
90
+ // Store 3D coordinates - these will be projected dynamically during rendering
91
+ viz.edgeLine3D = {
92
+ v0: { x: v0.x, y: v0.y, z: v0.z },
93
+ v1: { x: v1.x, y: v1.y, z: v1.z },
94
+ };
95
+
96
+ // Add sliding dot t-parameter along the edge
97
+ if (edgeLockInfo) {
98
+ viz.slidingDot = { t: edgeLockInfo.edgeT };
99
+
100
+ // Add corner rings if at a corner with high valence
101
+ if (edgeLockInfo.isCorner && edgeLockInfo.cornerValence >= 2) {
102
+ viz.cornerRings = {
103
+ atStart: edgeLockInfo.edgeT < 0.5,
104
+ valence: edgeLockInfo.cornerValence,
105
+ };
106
+ }
107
+ } else {
108
+ // No edge lock info - calculate t from snap position
109
+ const edge = { x: v1.x - v0.x, y: v1.y - v0.y, z: v1.z - v0.z };
110
+ const toSnap = { x: snapTarget.position.x - v0.x, y: snapTarget.position.y - v0.y, z: snapTarget.position.z - v0.z };
111
+ const edgeLenSq = edge.x * edge.x + edge.y * edge.y + edge.z * edge.z;
112
+ const t = edgeLenSq > 0 ? (toSnap.x * edge.x + toSnap.y * edge.y + toSnap.z * edge.z) / edgeLenSq : 0.5;
113
+ viz.slidingDot = { t: Math.max(0, Math.min(1, t)) };
114
+ }
115
+ }
116
+
117
+ // For face snaps: show plane indicator (still screen-space since it's just an indicator)
118
+ if ((snapTarget.type === 'face' || snapTarget.type === 'face_center') && snapTarget.normal) {
119
+ const pos = ctx.camera.projectToScreen(snapTarget.position, ctx.canvas.width, ctx.canvas.height);
120
+ if (pos) {
121
+ viz.planeIndicator = {
122
+ x: pos.x,
123
+ y: pos.y,
124
+ normal: snapTarget.normal,
125
+ };
126
+ }
127
+ }
128
+
129
+ ctx.setSnapVisualization(viz);
130
+ }
131
+
132
+ /**
133
+ * Get approximate world position for an entity (for measurement tool fallback).
134
+ */
135
+ export function getApproximateWorldPosition(
136
+ geom: MeshData[] | null,
137
+ entityId: number,
138
+ _screenX: number,
139
+ _screenY: number,
140
+ _canvasWidth: number,
141
+ _canvasHeight: number,
142
+ ): { x: number; y: number; z: number } {
143
+ return getEntityCenter(geom, entityId) || { x: 0, y: 0, z: 0 };
144
+ }
145
+
146
+ /**
147
+ * Handle mousedown for measurement tool (non-shift).
148
+ * Returns true if the event was handled (caller should early-return).
149
+ */
150
+ export function handleMeasureDown(ctx: MouseHandlerContext, e: PointerEvent): boolean {
151
+ const { canvas, renderer, camera, mouseState } = ctx;
152
+
153
+ mouseState.isDragging = true;
154
+ canvas.style.cursor = 'crosshair';
155
+
156
+ // Calculate canvas-relative coordinates
157
+ const rect = canvas.getBoundingClientRect();
158
+ const x = e.clientX - rect.left;
159
+ const y = e.clientY - rect.top;
160
+
161
+ // Use magnetic snap for better edge locking
162
+ const currentLock = ctx.edgeLockStateRef.current;
163
+ const result = renderer.raycastSceneMagnetic(x, y, {
164
+ edge: currentLock.edge,
165
+ meshExpressId: currentLock.meshExpressId,
166
+ lockStrength: currentLock.lockStrength,
167
+ }, {
168
+ hiddenIds: ctx.hiddenEntitiesRef.current,
169
+ isolatedIds: ctx.isolatedEntitiesRef.current,
170
+ snapOptions: ctx.snapEnabledRef.current ? {
171
+ snapToVertices: true,
172
+ snapToEdges: true,
173
+ snapToFaces: true,
174
+ screenSnapRadius: 60,
175
+ } : {
176
+ snapToVertices: false,
177
+ snapToEdges: false,
178
+ snapToFaces: false,
179
+ screenSnapRadius: 0,
180
+ },
181
+ });
182
+
183
+ if (result.intersection || result.snapTarget) {
184
+ const snapPoint = result.snapTarget || result.intersection;
185
+ const pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
186
+
187
+ if (pos) {
188
+ // Project snapped 3D position to screen - measurement starts from indicator, not cursor
189
+ const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
190
+ const measurePoint: MeasurePoint = {
191
+ x: pos.x,
192
+ y: pos.y,
193
+ z: pos.z,
194
+ screenX: screenPos?.x ?? x,
195
+ screenY: screenPos?.y ?? y,
196
+ };
197
+
198
+ ctx.startMeasurement(measurePoint);
199
+
200
+ if (result.snapTarget) {
201
+ ctx.setSnapTarget(result.snapTarget);
202
+ }
203
+
204
+ // Update edge lock state
205
+ if (result.edgeLock.shouldRelease) {
206
+ ctx.clearEdgeLock();
207
+ updateSnapViz(ctx, result.snapTarget || null);
208
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
209
+ ctx.setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
210
+ updateSnapViz(ctx, result.snapTarget, {
211
+ edgeT: result.edgeLock.edgeT,
212
+ isCorner: result.edgeLock.isCorner,
213
+ cornerValence: result.edgeLock.cornerValence,
214
+ });
215
+ } else {
216
+ updateSnapViz(ctx, result.snapTarget);
217
+ }
218
+
219
+ // Set up orthogonal constraint for shift+drag - always use world axes
220
+ ctx.setMeasurementConstraintEdge({
221
+ axes: {
222
+ axis1: { x: 1, y: 0, z: 0 }, // World X
223
+ axis2: { x: 0, y: 1, z: 0 }, // World Y (vertical)
224
+ axis3: { x: 0, y: 0, z: 1 }, // World Z
225
+ },
226
+ colors: {
227
+ axis1: '#F44336', // Red - X axis
228
+ axis2: '#8BC34A', // Lime - Y axis (vertical)
229
+ axis3: '#2196F3', // Blue - Z axis
230
+ },
231
+ activeAxis: null,
232
+ });
233
+ }
234
+ }
235
+
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * Handle mousemove for measurement tool while dragging with active measurement.
241
+ * Returns true if the event was handled (caller should early-return).
242
+ */
243
+ export function handleMeasureDrag(ctx: MouseHandlerContext, e: MouseEvent, x: number, y: number): boolean {
244
+ const { canvas, renderer, camera, mouseState } = ctx;
245
+
246
+ // Check if shift is held for orthogonal constraint
247
+ const useOrthogonalConstraint = e.shiftKey && ctx.measurementConstraintEdgeRef.current;
248
+
249
+ // Throttle raycasting to 60fps max using requestAnimationFrame
250
+ if (!ctx.measureRaycastPendingRef.current) {
251
+ ctx.measureRaycastPendingRef.current = true;
252
+
253
+ ctx.measureRaycastFrameRef.current = requestAnimationFrame(() => {
254
+ ctx.measureRaycastPendingRef.current = false;
255
+ ctx.measureRaycastFrameRef.current = null;
256
+
257
+ const raycastStart = performance.now();
258
+
259
+ // When using orthogonal constraint (shift held), use simpler raycasting
260
+ // since the final position will be projected onto an axis anyway
261
+ const snapOn = ctx.snapEnabledRef.current && !useOrthogonalConstraint;
262
+
263
+ // If last raycast was slow, reduce complexity to prevent UI freezes
264
+ const wasSlowLastTime = ctx.lastMeasureRaycastDurationRef.current > ctx.SLOW_RAYCAST_THRESHOLD_MS;
265
+ const reduceComplexity = wasSlowLastTime && !useOrthogonalConstraint;
266
+
267
+ // Use magnetic snap for edge sliding behavior (only when not in orthogonal mode)
268
+ const currentLock = useOrthogonalConstraint
269
+ ? { edge: null, meshExpressId: null, lockStrength: 0 }
270
+ : ctx.edgeLockStateRef.current;
271
+
272
+ const result = renderer.raycastSceneMagnetic(x, y, {
273
+ edge: currentLock.edge,
274
+ meshExpressId: currentLock.meshExpressId,
275
+ lockStrength: currentLock.lockStrength,
276
+ }, {
277
+ hiddenIds: ctx.hiddenEntitiesRef.current,
278
+ isolatedIds: ctx.isolatedEntitiesRef.current,
279
+ // Reduce snap complexity when using orthogonal constraint or when slow
280
+ snapOptions: snapOn ? {
281
+ snapToVertices: !reduceComplexity, // Skip vertex snapping when slow
282
+ snapToEdges: true,
283
+ snapToFaces: true,
284
+ screenSnapRadius: reduceComplexity ? 40 : 60, // Smaller radius when slow
285
+ } : useOrthogonalConstraint ? {
286
+ // In orthogonal mode, snap to edges and vertices only (no faces)
287
+ snapToVertices: true,
288
+ snapToEdges: true,
289
+ snapToFaces: false,
290
+ screenSnapRadius: 40,
291
+ } : {
292
+ snapToVertices: false,
293
+ snapToEdges: false,
294
+ snapToFaces: false,
295
+ screenSnapRadius: 0,
296
+ },
297
+ });
298
+
299
+ // Track raycast duration for adaptive throttling
300
+ ctx.lastMeasureRaycastDurationRef.current = performance.now() - raycastStart;
301
+
302
+ if (result.intersection || result.snapTarget) {
303
+ const snapPoint = result.snapTarget || result.intersection;
304
+ let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
305
+
306
+ if (pos) {
307
+ // Apply orthogonal constraint if shift is held and we have a constraint
308
+ if (useOrthogonalConstraint && ctx.activeMeasurementRef.current) {
309
+ const constraint = ctx.measurementConstraintEdgeRef.current!;
310
+ const start = ctx.activeMeasurementRef.current.start;
311
+ const projected = projectOntoConstraintAxis(start, pos, constraint);
312
+ pos = projected.projectedPos;
313
+
314
+ // Update active axis for visualization
315
+ ctx.updateConstraintActiveAxis(projected.activeAxis);
316
+ } else if (!useOrthogonalConstraint && ctx.measurementConstraintEdgeRef.current?.activeAxis) {
317
+ // Clear active axis when shift is released
318
+ ctx.updateConstraintActiveAxis(null);
319
+ }
320
+
321
+ // Project snapped 3D position to screen - indicator position, not raw cursor
322
+ const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
323
+ const measurePoint: MeasurePoint = {
324
+ x: pos.x,
325
+ y: pos.y,
326
+ z: pos.z,
327
+ screenX: screenPos?.x ?? x,
328
+ screenY: screenPos?.y ?? y,
329
+ };
330
+
331
+ ctx.updateMeasurement(measurePoint);
332
+ ctx.setSnapTarget(result.snapTarget || null);
333
+
334
+ // Update edge lock state and snap visualization (even in orthogonal mode)
335
+ if (result.edgeLock.shouldRelease) {
336
+ ctx.clearEdgeLock();
337
+ updateSnapViz(ctx, result.snapTarget || null);
338
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
339
+ // Check if we're on the same edge to preserve lock strength (hysteresis)
340
+ const sameDirection = currentLock.edge &&
341
+ Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v0.x) < 0.0001 &&
342
+ Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v0.y) < 0.0001 &&
343
+ Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v0.z) < 0.0001 &&
344
+ Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v1.x) < 0.0001 &&
345
+ Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v1.y) < 0.0001 &&
346
+ Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v1.z) < 0.0001;
347
+ const reversedDirection = currentLock.edge &&
348
+ Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v1.x) < 0.0001 &&
349
+ Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v1.y) < 0.0001 &&
350
+ Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v1.z) < 0.0001 &&
351
+ Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v0.x) < 0.0001 &&
352
+ Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v0.y) < 0.0001 &&
353
+ Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v0.z) < 0.0001;
354
+ const isSameEdge = currentLock.edge &&
355
+ currentLock.meshExpressId === result.edgeLock.meshExpressId &&
356
+ (sameDirection || reversedDirection);
357
+
358
+ if (isSameEdge) {
359
+ ctx.updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
360
+ ctx.incrementEdgeLockStrength();
361
+ } else {
362
+ ctx.setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
363
+ ctx.updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
364
+ }
365
+ updateSnapViz(ctx, result.snapTarget, {
366
+ edgeT: result.edgeLock.edgeT,
367
+ isCorner: result.edgeLock.isCorner,
368
+ cornerValence: result.edgeLock.cornerValence,
369
+ });
370
+ } else {
371
+ updateSnapViz(ctx, result.snapTarget || null);
372
+ }
373
+ }
374
+ }
375
+ });
376
+ }
377
+
378
+ // Mark as dragged (any movement counts for measure tool)
379
+ mouseState.didDrag = true;
380
+ return true;
381
+ }
382
+
383
+ /**
384
+ * Handle mousemove for measurement tool hover preview (before dragging starts).
385
+ * Shows snap indicators to help user see where they can snap.
386
+ * Returns true if the event was handled (caller should early-return).
387
+ */
388
+ export function handleMeasureHover(ctx: MouseHandlerContext, x: number, y: number): boolean {
389
+ const { renderer } = ctx;
390
+
391
+ // Throttle hover snap detection more aggressively (100ms) to avoid performance issues
392
+ const now = Date.now();
393
+ if (now - ctx.lastHoverSnapTimeRef.current < ctx.HOVER_SNAP_THROTTLE_MS) {
394
+ return true; // Skip hover snap detection if throttled
395
+ }
396
+ ctx.lastHoverSnapTimeRef.current = now;
397
+
398
+ // Throttle raycasting to avoid performance issues
399
+ if (!ctx.measureRaycastPendingRef.current) {
400
+ ctx.measureRaycastPendingRef.current = true;
401
+
402
+ ctx.measureRaycastFrameRef.current = requestAnimationFrame(() => {
403
+ ctx.measureRaycastPendingRef.current = false;
404
+ ctx.measureRaycastFrameRef.current = null;
405
+
406
+ // Use magnetic snap for hover preview
407
+ const currentLock = ctx.edgeLockStateRef.current;
408
+ const result = renderer.raycastSceneMagnetic(x, y, {
409
+ edge: currentLock.edge,
410
+ meshExpressId: currentLock.meshExpressId,
411
+ lockStrength: currentLock.lockStrength,
412
+ }, {
413
+ hiddenIds: ctx.hiddenEntitiesRef.current,
414
+ isolatedIds: ctx.isolatedEntitiesRef.current,
415
+ snapOptions: {
416
+ snapToVertices: true,
417
+ snapToEdges: true,
418
+ snapToFaces: true,
419
+ screenSnapRadius: 40, // Good radius for hover snap detection
420
+ },
421
+ });
422
+
423
+ // Update snap target for visual feedback
424
+ if (result.snapTarget) {
425
+ ctx.setSnapTarget(result.snapTarget);
426
+
427
+ // Update edge lock state for hover
428
+ if (result.edgeLock.shouldRelease) {
429
+ ctx.clearEdgeLock();
430
+ updateSnapViz(ctx, result.snapTarget);
431
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
432
+ ctx.setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
433
+ updateSnapViz(ctx, result.snapTarget, {
434
+ edgeT: result.edgeLock.edgeT,
435
+ isCorner: result.edgeLock.isCorner,
436
+ cornerValence: result.edgeLock.cornerValence,
437
+ });
438
+ } else {
439
+ updateSnapViz(ctx, result.snapTarget);
440
+ }
441
+ } else {
442
+ ctx.setSnapTarget(null);
443
+ ctx.clearEdgeLock();
444
+ updateSnapViz(ctx, null);
445
+ }
446
+ });
447
+ }
448
+ return true; // Don't fall through to other tool handlers
449
+ }
450
+
451
+ /**
452
+ * Handle mouseup for measurement tool with active measurement.
453
+ * Returns true if the event was handled (caller should early-return).
454
+ */
455
+ export function handleMeasureUp(ctx: MouseHandlerContext, e: PointerEvent): boolean {
456
+ const { canvas, renderer, camera, mouseState } = ctx;
457
+
458
+ // Cancel any pending raycast to avoid stale updates
459
+ if (ctx.measureRaycastFrameRef.current) {
460
+ cancelAnimationFrame(ctx.measureRaycastFrameRef.current);
461
+ ctx.measureRaycastFrameRef.current = null;
462
+ ctx.measureRaycastPendingRef.current = false;
463
+ }
464
+
465
+ // Do a final synchronous raycast at the mouseup position to ensure accurate end point
466
+ const rect = canvas.getBoundingClientRect();
467
+ const mx = e.clientX - rect.left;
468
+ const my = e.clientY - rect.top;
469
+
470
+ const useOrthogonalConstraint = e.shiftKey && ctx.measurementConstraintEdgeRef.current;
471
+ const currentLock = ctx.edgeLockStateRef.current;
472
+
473
+ // Use simpler snap options in orthogonal mode (no magnetic locking needed)
474
+ const finalLock = useOrthogonalConstraint
475
+ ? { edge: null, meshExpressId: null, lockStrength: 0 }
476
+ : currentLock;
477
+
478
+ const result = renderer.raycastSceneMagnetic(mx, my, {
479
+ edge: finalLock.edge,
480
+ meshExpressId: finalLock.meshExpressId,
481
+ lockStrength: finalLock.lockStrength,
482
+ }, {
483
+ hiddenIds: ctx.hiddenEntitiesRef.current,
484
+ isolatedIds: ctx.isolatedEntitiesRef.current,
485
+ snapOptions: ctx.snapEnabledRef.current && !useOrthogonalConstraint ? {
486
+ snapToVertices: true,
487
+ snapToEdges: true,
488
+ snapToFaces: true,
489
+ screenSnapRadius: 60,
490
+ } : useOrthogonalConstraint ? {
491
+ // In orthogonal mode, snap to edges and vertices only (no faces)
492
+ snapToVertices: true,
493
+ snapToEdges: true,
494
+ snapToFaces: false,
495
+ screenSnapRadius: 40,
496
+ } : {
497
+ snapToVertices: false,
498
+ snapToEdges: false,
499
+ snapToFaces: false,
500
+ screenSnapRadius: 0,
501
+ },
502
+ });
503
+
504
+ // Update measurement with final position before finalizing
505
+ if (result.intersection || result.snapTarget) {
506
+ const snapPoint = result.snapTarget || result.intersection;
507
+ let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
508
+
509
+ if (pos) {
510
+ // Apply orthogonal constraint if shift is held
511
+ if (useOrthogonalConstraint && ctx.activeMeasurementRef.current) {
512
+ const constraint = ctx.measurementConstraintEdgeRef.current!;
513
+ const start = ctx.activeMeasurementRef.current.start;
514
+ const projected = projectOntoConstraintAxis(start, pos, constraint);
515
+ pos = projected.projectedPos;
516
+ }
517
+
518
+ const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
519
+ const measurePoint: MeasurePoint = {
520
+ x: pos.x,
521
+ y: pos.y,
522
+ z: pos.z,
523
+ screenX: screenPos?.x ?? mx,
524
+ screenY: screenPos?.y ?? my,
525
+ };
526
+ ctx.updateMeasurement(measurePoint);
527
+ }
528
+ }
529
+
530
+ ctx.finalizeMeasurement();
531
+ ctx.clearEdgeLock(); // Clear edge lock after measurement complete
532
+ mouseState.isDragging = false;
533
+ mouseState.didDrag = false;
534
+ canvas.style.cursor = 'crosshair';
535
+ return true;
536
+ }
537
+
538
+ /**
539
+ * Update measurement screen coordinates during zoom (wheel event).
540
+ * Called when in measure mode with pending measurements.
541
+ */
542
+ export function updateMeasureScreenCoords(ctx: MouseHandlerContext): void {
543
+ const { canvas, camera } = ctx;
544
+ ctx.updateMeasurementScreenCoords((worldPos) => {
545
+ return camera.projectToScreen(worldPos, canvas.width, canvas.height);
546
+ });
547
+ // Update camera state tracking to prevent duplicate update in animation loop
548
+ const cameraPos = camera.getPosition();
549
+ const cameraRot = camera.getRotation();
550
+ const cameraDist = camera.getDistance();
551
+ ctx.lastCameraStateRef.current = {
552
+ position: cameraPos,
553
+ rotation: cameraRot,
554
+ distance: cameraDist,
555
+ canvasWidth: canvas.width,
556
+ canvasHeight: canvas.height,
557
+ };
558
+ }
@@ -0,0 +1,108 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Shared types for extracted mouse handler functions.
7
+ * Used by measureHandlers.ts, selectionHandlers.ts, and useMouseControls.ts.
8
+ */
9
+
10
+ import type { MutableRefObject } from 'react';
11
+ import type { Renderer, PickResult, SnapTarget } from '@ifc-lite/renderer';
12
+ import type { MeshData } from '@ifc-lite/geometry';
13
+ import type {
14
+ MeasurePoint,
15
+ SnapVisualization,
16
+ ActiveMeasurement,
17
+ EdgeLockState,
18
+ SectionPlane,
19
+ } from '@/store';
20
+ import type { MeasurementConstraintEdge, OrthogonalAxis, Vec3 } from '@/store/types.js';
21
+
22
+ export interface MouseState {
23
+ isDragging: boolean;
24
+ isPanning: boolean;
25
+ lastX: number;
26
+ lastY: number;
27
+ button: number;
28
+ startX: number;
29
+ startY: number;
30
+ didDrag: boolean;
31
+ }
32
+
33
+ /**
34
+ * Camera interface matching the subset of renderer Camera used by mouse handlers.
35
+ */
36
+ export interface Camera {
37
+ projectToScreen(pos: { x: number; y: number; z: number }, width: number, height: number): { x: number; y: number } | null;
38
+ getPosition(): { x: number; y: number; z: number };
39
+ getRotation(): { azimuth: number; elevation: number };
40
+ getDistance(): number;
41
+ }
42
+
43
+ /**
44
+ * Shared context passed to all extracted handler functions.
45
+ * Contains refs, callbacks, and constants that the handlers need.
46
+ */
47
+ export interface MouseHandlerContext {
48
+ canvas: HTMLCanvasElement;
49
+ renderer: Renderer;
50
+ camera: Camera;
51
+ mouseState: MouseState;
52
+
53
+ // Tool/state refs
54
+ activeToolRef: MutableRefObject<string>;
55
+ activeMeasurementRef: MutableRefObject<ActiveMeasurement | null>;
56
+ snapEnabledRef: MutableRefObject<boolean>;
57
+ edgeLockStateRef: MutableRefObject<EdgeLockState>;
58
+ measurementConstraintEdgeRef: MutableRefObject<MeasurementConstraintEdge | null>;
59
+
60
+ // Visibility refs
61
+ hiddenEntitiesRef: MutableRefObject<Set<number>>;
62
+ isolatedEntitiesRef: MutableRefObject<Set<number> | null>;
63
+
64
+ // Geometry refs
65
+ geometryRef: MutableRefObject<MeshData[] | null>;
66
+
67
+ // Measure raycast refs
68
+ measureRaycastPendingRef: MutableRefObject<boolean>;
69
+ measureRaycastFrameRef: MutableRefObject<number | null>;
70
+ lastMeasureRaycastDurationRef: MutableRefObject<number>;
71
+ lastHoverSnapTimeRef: MutableRefObject<number>;
72
+
73
+ // Camera tracking
74
+ lastCameraStateRef: MutableRefObject<{
75
+ position: { x: number; y: number; z: number };
76
+ rotation: { azimuth: number; elevation: number };
77
+ distance: number;
78
+ canvasWidth: number;
79
+ canvasHeight: number;
80
+ } | null>;
81
+
82
+ // Click detection refs
83
+ lastClickTimeRef: MutableRefObject<number>;
84
+ lastClickPosRef: MutableRefObject<{ x: number; y: number } | null>;
85
+
86
+ // Callbacks
87
+ startMeasurement: (point: MeasurePoint) => void;
88
+ updateMeasurement: (point: MeasurePoint) => void;
89
+ finalizeMeasurement: () => void;
90
+ setSnapTarget: (target: SnapTarget | null) => void;
91
+ setSnapVisualization: (viz: Partial<SnapVisualization> | null) => void;
92
+ setEdgeLock: (edge: { v0: { x: number; y: number; z: number }; v1: { x: number; y: number; z: number } }, meshExpressId: number, edgeT: number) => void;
93
+ updateEdgeLockPosition: (edgeT: number, isCorner: boolean, cornerValence: number) => void;
94
+ clearEdgeLock: () => void;
95
+ incrementEdgeLockStrength: () => void;
96
+ setMeasurementConstraintEdge: (edge: MeasurementConstraintEdge) => void;
97
+ updateConstraintActiveAxis: (axis: OrthogonalAxis | null) => void;
98
+ updateMeasurementScreenCoords: (projector: (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null) => void;
99
+ handlePickForSelection: (pickResult: PickResult | null) => void;
100
+ toggleSelection: (entityId: number) => void;
101
+ openContextMenu: (entityId: number | null, screenX: number, screenY: number) => void;
102
+ hasPendingMeasurements: () => boolean;
103
+ getPickOptions: () => { isStreaming: boolean; hiddenIds: Set<number>; isolatedIds: Set<number> | null };
104
+
105
+ // Constants
106
+ HOVER_SNAP_THROTTLE_MS: number;
107
+ SLOW_RAYCAST_THRESHOLD_MS: number;
108
+ }