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