@ifc-lite/viewer 1.16.0 → 1.17.1
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 +42 -0
- package/.turbo/turbo-typecheck.log +44 -0
- package/CHANGELOG.md +25 -0
- package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-DuPUrOxJ.js} +1 -1
- package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-DetjPnvt.js} +1 -1
- package/dist/assets/{browser-vWDubxDI.js → browser-BQdwnOUt.js} +1 -1
- package/dist/assets/geometry.worker-Bjm-ukng.js +1 -0
- package/dist/assets/ifc-lite_bg-DD0A7Yow.wasm +0 -0
- package/dist/assets/{index-RXIK18da.js → index-B3X21yXA.js} +4 -4
- package/dist/assets/index-Ba4eoTe7.css +1 -0
- package/dist/assets/{index-BImINgzG.js → index-BybGZJTW.js} +29281 -27174
- package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-CN0ZMR2t.js} +1 -1
- package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-D0bALkma.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +14 -13
- 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/PropertiesPanel.tsx +6 -7
- 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/hierarchy/treeDataBuilder.test.ts +70 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +35 -10
- package/src/components/viewer/hierarchy/types.ts +24 -2
- 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/adapters/visibility-adapter.ts +7 -49
- package/src/sdk/local-backend.ts +2 -0
- package/src/store/basketVisibleSet.test.ts +73 -3
- package/src/store/basketVisibleSet.ts +58 -75
- package/src/store/slices/bcfSlice.ts +9 -0
- package/src/utils/loadingUtils.ts +46 -0
- package/src/utils/serverDataModel.test.ts +90 -0
- package/src/utils/serverDataModel.ts +26 -37
- package/src/utils/spatialHierarchy.test.ts +38 -0
- package/src/utils/spatialHierarchy.ts +13 -23
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- 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
|
+
}
|