@ifc-lite/viewer 1.1.7 → 1.6.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/LICENSE +373 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Arrow.dom-BjDQoB2M.js +20 -0
- package/dist/assets/arrow2-bb-jcVEo.js +2 -0
- package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
- package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
- package/dist/assets/event-DIOks52T.js +1 -0
- package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-YBtrHPu3.js +65252 -0
- package/dist/assets/index-v3mcCUPN.css +1 -0
- package/dist/assets/native-bridge-CULtTDX3.js +111 -0
- package/dist/assets/wasm-bridge-CjL-lSak.js +1 -0
- package/dist/favicon-16x16-cropped.png +0 -0
- package/dist/favicon-16x16.png +0 -0
- package/dist/favicon-192x192-cropped.png +0 -0
- package/dist/favicon-192x192.png +0 -0
- package/dist/favicon-32x32-cropped.png +0 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon-48x48-cropped.png +0 -0
- package/dist/favicon-48x48.png +0 -0
- package/dist/favicon-512x512-cropped.png +0 -0
- package/dist/favicon-512x512.png +0 -0
- package/dist/favicon-64x64-cropped.png +0 -0
- package/dist/favicon-64x64.png +0 -0
- package/dist/favicon-96x96-cropped.png +0 -0
- package/dist/favicon-96x96.png +0 -0
- package/dist/favicon-square-512.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +3 -0
- package/dist/index.html +44 -0
- package/dist/logo.png +0 -0
- package/dist/manifest.json +48 -0
- package/index.html +33 -2
- package/package.json +34 -17
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16-cropped.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-192x192-cropped.png +0 -0
- package/public/favicon-192x192.png +0 -0
- package/public/favicon-32x32-cropped.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48-cropped.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon-512x512-cropped.png +0 -0
- package/public/favicon-512x512.png +0 -0
- package/public/favicon-64x64-cropped.png +0 -0
- package/public/favicon-64x64.png +0 -0
- package/public/favicon-96x96-cropped.png +0 -0
- package/public/favicon-96x96.png +0 -0
- package/public/favicon-square-512.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +3 -0
- package/public/logo.png +0 -0
- package/public/manifest.json +48 -0
- package/src/App.tsx +2 -0
- package/src/components/ui/alert.tsx +62 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/select.tsx +151 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +120 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/BCFPanel.tsx +1164 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
- package/src/components/viewer/DataConnector.tsx +840 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
- package/src/components/viewer/EntityContextMenu.tsx +45 -17
- package/src/components/viewer/ExportChangesButton.tsx +195 -0
- package/src/components/viewer/ExportDialog.tsx +402 -0
- package/src/components/viewer/HierarchyPanel.tsx +1132 -218
- package/src/components/viewer/IDSPanel.tsx +661 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
- package/src/components/viewer/MainToolbar.tsx +418 -94
- package/src/components/viewer/PropertiesPanel.tsx +1355 -91
- package/src/components/viewer/PropertyEditor.tsx +611 -0
- package/src/components/viewer/Section2DPanel.tsx +3313 -0
- package/src/components/viewer/SheetSetupPanel.tsx +502 -0
- package/src/components/viewer/StatusBar.tsx +27 -16
- package/src/components/viewer/TitleBlockEditor.tsx +437 -0
- package/src/components/viewer/ToolOverlays.tsx +935 -127
- package/src/components/viewer/ViewerLayout.tsx +40 -11
- package/src/components/viewer/Viewport.tsx +1276 -336
- package/src/components/viewer/ViewportContainer.tsx +554 -18
- package/src/components/viewer/ViewportOverlays.tsx +24 -7
- package/src/hooks/useBCF.ts +504 -0
- package/src/hooks/useIDS.ts +1065 -0
- package/src/hooks/useIfc.ts +1534 -205
- package/src/hooks/useIfcCache.ts +279 -0
- package/src/hooks/useKeyboardShortcuts.ts +50 -8
- package/src/hooks/useModelSelection.ts +61 -0
- package/src/hooks/useViewerSelectors.ts +218 -0
- package/src/hooks/useWebGPU.ts +80 -0
- package/src/index.css +265 -27
- package/src/lib/platform.ts +23 -0
- package/src/services/cacheService.ts +142 -0
- package/src/services/desktop-cache.ts +143 -0
- package/src/services/fs-cache.ts +212 -0
- package/src/services/ifc-cache.ts +14 -6
- package/src/store/constants.ts +85 -0
- package/src/store/index.ts +214 -0
- package/src/store/slices/bcfSlice.ts +372 -0
- package/src/store/slices/cameraSlice.ts +63 -0
- package/src/store/slices/dataSlice.test.ts +226 -0
- package/src/store/slices/dataSlice.ts +112 -0
- package/src/store/slices/drawing2DSlice.ts +340 -0
- package/src/store/slices/hoverSlice.ts +40 -0
- package/src/store/slices/idsSlice.ts +310 -0
- package/src/store/slices/loadingSlice.ts +33 -0
- package/src/store/slices/measurementSlice.test.ts +217 -0
- package/src/store/slices/measurementSlice.ts +293 -0
- package/src/store/slices/modelSlice.test.ts +271 -0
- package/src/store/slices/modelSlice.ts +211 -0
- package/src/store/slices/mutationSlice.ts +502 -0
- package/src/store/slices/sectionSlice.test.ts +125 -0
- package/src/store/slices/sectionSlice.ts +58 -0
- package/src/store/slices/selectionSlice.test.ts +286 -0
- package/src/store/slices/selectionSlice.ts +263 -0
- package/src/store/slices/sheetSlice.ts +565 -0
- package/src/store/slices/uiSlice.ts +58 -0
- package/src/store/slices/visibilitySlice.test.ts +304 -0
- package/src/store/slices/visibilitySlice.ts +277 -0
- package/src/store/types.test.ts +135 -0
- package/src/store/types.ts +248 -0
- package/src/store.ts +40 -515
- package/src/utils/ifcConfig.ts +82 -0
- package/src/utils/localParsingUtils.ts +287 -0
- package/src/utils/serverDataModel.ts +783 -0
- package/src/utils/spatialHierarchy.ts +283 -0
- package/src/utils/viewportUtils.ts +334 -0
- package/src/vite-env.d.ts +23 -0
- package/src/webgpu-types.d.ts +128 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +7 -0
- package/src-tauri/capabilities/default.json +18 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +21 -0
- package/src-tauri/src/main.rs +10 -0
- package/src-tauri/tauri.conf.json +39 -0
- package/vite.config.ts +174 -26
- package/public/ifc-lite_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/components/Viewport.tsx +0 -723
- package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
|
@@ -6,56 +6,176 @@
|
|
|
6
6
|
* 3D viewport component
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useRef, useState } from 'react';
|
|
10
|
-
import { Renderer, MathUtils } from '@ifc-lite/renderer';
|
|
9
|
+
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
10
|
+
import { Renderer, MathUtils, type SnapTarget, type PickResult } from '@ifc-lite/renderer';
|
|
11
11
|
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
12
|
-
import { useViewerStore, type MeasurePoint } from '@/store';
|
|
12
|
+
import { useViewerStore, type MeasurePoint, type SnapVisualization } from '@/store';
|
|
13
|
+
import {
|
|
14
|
+
useSelectionState,
|
|
15
|
+
useVisibilityState,
|
|
16
|
+
useToolState,
|
|
17
|
+
useMeasurementState,
|
|
18
|
+
useCameraState,
|
|
19
|
+
useHoverState,
|
|
20
|
+
useThemeState,
|
|
21
|
+
useContextMenuState,
|
|
22
|
+
useColorUpdateState,
|
|
23
|
+
useIfcDataState,
|
|
24
|
+
} from '../../hooks/useViewerSelectors.js';
|
|
25
|
+
import { useModelSelection } from '../../hooks/useModelSelection.js';
|
|
26
|
+
import {
|
|
27
|
+
getEntityBounds,
|
|
28
|
+
getEntityCenter,
|
|
29
|
+
buildRenderOptions,
|
|
30
|
+
getRenderThrottleMs,
|
|
31
|
+
getThemeClearColor,
|
|
32
|
+
calculateScaleBarSize,
|
|
33
|
+
type ViewportStateRefs,
|
|
34
|
+
} from '../../utils/viewportUtils.js';
|
|
35
|
+
import { setGlobalCanvasRef, setGlobalRendererRef, clearGlobalRefs } from '../../hooks/useBCF.js';
|
|
13
36
|
|
|
14
37
|
interface ViewportProps {
|
|
15
38
|
geometry: MeshData[] | null;
|
|
16
39
|
coordinateInfo?: CoordinateInfo;
|
|
40
|
+
computedIsolatedIds?: Set<number> | null;
|
|
41
|
+
modelIdToIndex?: Map<string, number>;
|
|
17
42
|
}
|
|
18
43
|
|
|
19
|
-
export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
44
|
+
export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelIdToIndex }: ViewportProps) {
|
|
20
45
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
21
46
|
const rendererRef = useRef<Renderer | null>(null);
|
|
22
47
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
// Selection state
|
|
50
|
+
const { selectedEntityId, selectedEntityIds, setSelectedEntityId, setSelectedEntity, toggleSelection, models } = useSelectionState();
|
|
51
|
+
const selectedEntity = useViewerStore((s) => s.selectedEntity);
|
|
52
|
+
// Get the bulletproof store-based resolver (more reliable than singleton)
|
|
53
|
+
const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
|
|
54
|
+
|
|
55
|
+
// Sync selectedEntityId with model-aware selectedEntity for PropertiesPanel
|
|
56
|
+
useModelSelection();
|
|
57
|
+
|
|
58
|
+
// Create reverse mapping from modelIndex to modelId for selection
|
|
59
|
+
const modelIndexToId = useMemo(() => {
|
|
60
|
+
if (!modelIdToIndex) return new Map<number, string>();
|
|
61
|
+
const reverse = new Map<number, string>();
|
|
62
|
+
for (const [modelId, index] of modelIdToIndex) {
|
|
63
|
+
reverse.set(index, modelId);
|
|
64
|
+
}
|
|
65
|
+
return reverse;
|
|
66
|
+
}, [modelIdToIndex]);
|
|
67
|
+
|
|
68
|
+
// Compute selectedModelIndex for renderer (multi-model selection highlighting)
|
|
69
|
+
const selectedModelIndex = selectedEntity && modelIdToIndex
|
|
70
|
+
? modelIdToIndex.get(selectedEntity.modelId) ?? undefined
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
// Helper to handle pick result and set selection properly
|
|
74
|
+
// IMPORTANT: pickResult.expressId is now a globalId (transformed at load time)
|
|
75
|
+
// We use the store-based resolver to find (modelId, originalExpressId)
|
|
76
|
+
// This is more reliable than the singleton registry which can have bundling issues
|
|
77
|
+
const handlePickForSelection = (pickResult: PickResult | null) => {
|
|
78
|
+
if (!pickResult) {
|
|
79
|
+
setSelectedEntityId(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const globalId = pickResult.expressId;
|
|
84
|
+
|
|
85
|
+
// Set globalId for renderer (highlighting uses globalIds directly)
|
|
86
|
+
setSelectedEntityId(globalId);
|
|
87
|
+
|
|
88
|
+
// Resolve globalId -> (modelId, originalExpressId) for property panel
|
|
89
|
+
// Use store-based resolver instead of singleton for reliability
|
|
90
|
+
const resolved = resolveGlobalIdFromModels(globalId);
|
|
91
|
+
if (resolved) {
|
|
92
|
+
// Set the EntityRef with ORIGINAL expressId (for property lookup in IfcDataStore)
|
|
93
|
+
setSelectedEntity({ modelId: resolved.modelId, expressId: resolved.expressId });
|
|
94
|
+
} else {
|
|
95
|
+
// Fallback for single-model mode (offset = 0, globalId = expressId)
|
|
96
|
+
// Try to find model from the old modelIndex if available
|
|
97
|
+
if (pickResult.modelIndex !== undefined && modelIndexToId) {
|
|
98
|
+
const modelId = modelIndexToId.get(pickResult.modelIndex);
|
|
99
|
+
if (modelId) {
|
|
100
|
+
setSelectedEntity({ modelId, expressId: globalId });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Visibility state - use computedIsolatedIds from parent (includes storey selection)
|
|
107
|
+
// Fall back to store isolation if computedIsolatedIds is not provided
|
|
108
|
+
const { hiddenEntities, isolatedEntities: storeIsolatedEntities } = useVisibilityState();
|
|
109
|
+
const isolatedEntities = computedIsolatedIds ?? storeIsolatedEntities ?? null;
|
|
110
|
+
|
|
111
|
+
// Tool state
|
|
112
|
+
const { activeTool, sectionPlane } = useToolState();
|
|
113
|
+
|
|
114
|
+
// Camera state
|
|
115
|
+
const { updateCameraRotationRealtime, updateScaleRealtime, setCameraCallbacks } = useCameraState();
|
|
116
|
+
|
|
117
|
+
// Theme state
|
|
118
|
+
const { theme } = useThemeState();
|
|
119
|
+
|
|
120
|
+
// Hover state
|
|
121
|
+
const { hoverTooltipsEnabled, setHoverState, clearHover } = useHoverState();
|
|
122
|
+
|
|
123
|
+
// Context menu state
|
|
124
|
+
const { openContextMenu } = useContextMenuState();
|
|
125
|
+
|
|
126
|
+
// Measurement state
|
|
127
|
+
const {
|
|
128
|
+
measurements,
|
|
129
|
+
pendingMeasurePoint,
|
|
130
|
+
activeMeasurement,
|
|
131
|
+
addMeasurePoint,
|
|
132
|
+
completeMeasurement,
|
|
133
|
+
startMeasurement,
|
|
134
|
+
updateMeasurement,
|
|
135
|
+
finalizeMeasurement,
|
|
136
|
+
cancelMeasurement,
|
|
137
|
+
updateMeasurementScreenCoords,
|
|
138
|
+
snapEnabled,
|
|
139
|
+
setSnapTarget,
|
|
140
|
+
setSnapVisualization,
|
|
141
|
+
edgeLockState,
|
|
142
|
+
setEdgeLock,
|
|
143
|
+
updateEdgeLockPosition,
|
|
144
|
+
clearEdgeLock,
|
|
145
|
+
incrementEdgeLockStrength,
|
|
146
|
+
measurementConstraintEdge,
|
|
147
|
+
setMeasurementConstraintEdge,
|
|
148
|
+
updateConstraintActiveAxis,
|
|
149
|
+
} = useMeasurementState();
|
|
150
|
+
|
|
151
|
+
// Color update state
|
|
152
|
+
const { pendingColorUpdates, clearPendingColorUpdates } = useColorUpdateState();
|
|
153
|
+
|
|
154
|
+
// IFC data state
|
|
155
|
+
const { ifcDataStore } = useIfcDataState();
|
|
156
|
+
|
|
157
|
+
// Calculate section plane range based on actual geometry bounds for current axis
|
|
158
|
+
const sectionRange = useMemo(() => {
|
|
159
|
+
if (!coordinateInfo?.shiftedBounds) return null;
|
|
160
|
+
|
|
161
|
+
const bounds = coordinateInfo.shiftedBounds;
|
|
162
|
+
|
|
163
|
+
// Map semantic axis to coordinate axis
|
|
164
|
+
const axisKey = sectionPlane.axis === 'side' ? 'x' : sectionPlane.axis === 'down' ? 'y' : 'z';
|
|
165
|
+
|
|
166
|
+
const min = bounds.min[axisKey];
|
|
167
|
+
const max = bounds.max[axisKey];
|
|
168
|
+
|
|
169
|
+
return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : null;
|
|
170
|
+
}, [coordinateInfo, sectionPlane.axis]);
|
|
48
171
|
|
|
49
172
|
// Theme-aware clear color ref (updated when theme changes)
|
|
50
|
-
|
|
173
|
+
// Tokyo Night storm: #1a1b26 = rgb(26, 27, 38)
|
|
174
|
+
const clearColorRef = useRef<[number, number, number, number]>([0.102, 0.106, 0.149, 1]);
|
|
51
175
|
|
|
52
176
|
useEffect(() => {
|
|
53
177
|
// Update clear color when theme changes
|
|
54
|
-
|
|
55
|
-
clearColorRef.current = [0.95, 0.95, 0.95, 1]; // Light gray/white for light mode
|
|
56
|
-
} else {
|
|
57
|
-
clearColorRef.current = [0.1, 0.1, 0.1, 1]; // Dark gray for dark mode
|
|
58
|
-
}
|
|
178
|
+
clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
|
|
59
179
|
// Re-render with new clear color
|
|
60
180
|
const renderer = rendererRef.current;
|
|
61
181
|
if (renderer && isInitialized) {
|
|
@@ -63,6 +183,7 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
63
183
|
hiddenIds: hiddenEntitiesRef.current,
|
|
64
184
|
isolatedIds: isolatedEntitiesRef.current,
|
|
65
185
|
selectedId: selectedEntityIdRef.current,
|
|
186
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
66
187
|
clearColor: clearColorRef.current,
|
|
67
188
|
});
|
|
68
189
|
}
|
|
@@ -89,6 +210,12 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
89
210
|
touches: [] as Touch[],
|
|
90
211
|
lastDistance: 0,
|
|
91
212
|
lastCenter: { x: 0, y: 0 },
|
|
213
|
+
// Tap detection for mobile selection
|
|
214
|
+
tapStartTime: 0,
|
|
215
|
+
tapStartPos: { x: 0, y: 0 },
|
|
216
|
+
didMove: false,
|
|
217
|
+
// Track if multi-touch occurred (prevents false tap-select after pinch/zoom)
|
|
218
|
+
multiTouch: false,
|
|
92
219
|
});
|
|
93
220
|
|
|
94
221
|
// Double-click detection
|
|
@@ -110,14 +237,23 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
110
237
|
max: { x: 100, y: 100, z: 100 },
|
|
111
238
|
});
|
|
112
239
|
|
|
240
|
+
// Coordinate info ref for camera callbacks (to access latest buildingRotation)
|
|
241
|
+
const coordinateInfoRef = useRef<CoordinateInfo | undefined>(coordinateInfo);
|
|
242
|
+
|
|
113
243
|
// Visibility state refs for animation loop
|
|
114
244
|
const hiddenEntitiesRef = useRef<Set<number>>(hiddenEntities);
|
|
115
245
|
const isolatedEntitiesRef = useRef<Set<number> | null>(isolatedEntities);
|
|
116
246
|
const selectedEntityIdRef = useRef<number | null>(selectedEntityId);
|
|
247
|
+
const selectedEntityIdsRef = useRef<Set<number> | undefined>(selectedEntityIds);
|
|
248
|
+
const selectedModelIndexRef = useRef<number | undefined>(selectedModelIndex);
|
|
117
249
|
const activeToolRef = useRef<string>(activeTool);
|
|
118
250
|
const pendingMeasurePointRef = useRef<MeasurePoint | null>(pendingMeasurePoint);
|
|
251
|
+
const activeMeasurementRef = useRef(activeMeasurement);
|
|
252
|
+
const snapEnabledRef = useRef(snapEnabled);
|
|
253
|
+
const edgeLockStateRef = useRef(edgeLockState);
|
|
254
|
+
const measurementConstraintEdgeRef = useRef(measurementConstraintEdge);
|
|
119
255
|
const sectionPlaneRef = useRef(sectionPlane);
|
|
120
|
-
const
|
|
256
|
+
const sectionRangeRef = useRef<{ min: number; max: number } | null>(null);
|
|
121
257
|
const geometryRef = useRef<MeshData[] | null>(geometry);
|
|
122
258
|
|
|
123
259
|
// Hover throttling
|
|
@@ -125,15 +261,51 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
125
261
|
const hoverThrottleMs = 50; // Check hover every 50ms
|
|
126
262
|
const hoverTooltipsEnabledRef = useRef(hoverTooltipsEnabled);
|
|
127
263
|
|
|
264
|
+
// Measure tool throttling (adaptive based on raycast performance)
|
|
265
|
+
const measureRaycastPendingRef = useRef(false);
|
|
266
|
+
const measureRaycastFrameRef = useRef<number | null>(null);
|
|
267
|
+
const lastMeasureRaycastDurationRef = useRef<number>(0);
|
|
268
|
+
// Hover-only snap detection throttling (100ms = 10fps max for hover, 60fps for active measurement)
|
|
269
|
+
const lastHoverSnapTimeRef = useRef<number>(0);
|
|
270
|
+
const HOVER_SNAP_THROTTLE_MS = 100;
|
|
271
|
+
// Skip visualization updates if raycast was slow (prevents UI freezes)
|
|
272
|
+
const SLOW_RAYCAST_THRESHOLD_MS = 50;
|
|
273
|
+
|
|
274
|
+
// Render throttling during orbit/pan
|
|
275
|
+
// Adaptive: 16ms (60fps) for small models, up to 33ms (30fps) for very large models
|
|
276
|
+
const lastRenderTimeRef = useRef<number>(0);
|
|
277
|
+
const renderPendingRef = useRef<boolean>(false);
|
|
278
|
+
const RENDER_THROTTLE_MS_SMALL = 16; // ~60fps for models < 10K meshes
|
|
279
|
+
const RENDER_THROTTLE_MS_LARGE = 25; // ~40fps for models 10K-50K meshes
|
|
280
|
+
const RENDER_THROTTLE_MS_HUGE = 33; // ~30fps for models > 50K meshes
|
|
281
|
+
|
|
282
|
+
// Camera state tracking for measurement updates (only update when camera actually moved)
|
|
283
|
+
const lastCameraStateRef = useRef<{
|
|
284
|
+
position: { x: number; y: number; z: number };
|
|
285
|
+
rotation: { azimuth: number; elevation: number };
|
|
286
|
+
distance: number;
|
|
287
|
+
canvasWidth: number;
|
|
288
|
+
canvasHeight: number;
|
|
289
|
+
} | null>(null);
|
|
290
|
+
|
|
128
291
|
// Keep refs in sync
|
|
292
|
+
useEffect(() => { coordinateInfoRef.current = coordinateInfo; }, [coordinateInfo]);
|
|
129
293
|
useEffect(() => { hiddenEntitiesRef.current = hiddenEntities; }, [hiddenEntities]);
|
|
130
294
|
useEffect(() => { isolatedEntitiesRef.current = isolatedEntities; }, [isolatedEntities]);
|
|
131
295
|
useEffect(() => { selectedEntityIdRef.current = selectedEntityId; }, [selectedEntityId]);
|
|
296
|
+
useEffect(() => { selectedEntityIdsRef.current = selectedEntityIds; }, [selectedEntityIds]);
|
|
297
|
+
useEffect(() => { selectedModelIndexRef.current = selectedModelIndex; }, [selectedModelIndex]);
|
|
132
298
|
useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]);
|
|
133
299
|
useEffect(() => { pendingMeasurePointRef.current = pendingMeasurePoint; }, [pendingMeasurePoint]);
|
|
300
|
+
useEffect(() => { activeMeasurementRef.current = activeMeasurement; }, [activeMeasurement]);
|
|
301
|
+
useEffect(() => { snapEnabledRef.current = snapEnabled; }, [snapEnabled]);
|
|
302
|
+
useEffect(() => { edgeLockStateRef.current = edgeLockState; }, [edgeLockState]);
|
|
303
|
+
useEffect(() => { measurementConstraintEdgeRef.current = measurementConstraintEdge; }, [measurementConstraintEdge]);
|
|
134
304
|
useEffect(() => { sectionPlaneRef.current = sectionPlane; }, [sectionPlane]);
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
useEffect(() => {
|
|
305
|
+
useEffect(() => { sectionRangeRef.current = sectionRange; }, [sectionRange]);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
geometryRef.current = geometry;
|
|
308
|
+
}, [geometry]);
|
|
137
309
|
useEffect(() => {
|
|
138
310
|
hoverTooltipsEnabledRef.current = hoverTooltipsEnabled;
|
|
139
311
|
if (!hoverTooltipsEnabled) {
|
|
@@ -142,6 +314,34 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
142
314
|
}
|
|
143
315
|
}, [hoverTooltipsEnabled, clearHover]);
|
|
144
316
|
|
|
317
|
+
// Cleanup measurement state when tool changes + set cursor
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
const canvas = canvasRef.current;
|
|
320
|
+
if (!canvas) return;
|
|
321
|
+
|
|
322
|
+
if (activeTool !== 'measure') {
|
|
323
|
+
// Cancel any active measurement
|
|
324
|
+
if (activeMeasurement) {
|
|
325
|
+
cancelMeasurement();
|
|
326
|
+
}
|
|
327
|
+
// Clear pending raycast requests
|
|
328
|
+
if (measureRaycastFrameRef.current !== null) {
|
|
329
|
+
cancelAnimationFrame(measureRaycastFrameRef.current);
|
|
330
|
+
measureRaycastFrameRef.current = null;
|
|
331
|
+
measureRaycastPendingRef.current = false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Set cursor based on active tool
|
|
336
|
+
if (activeTool === 'measure') {
|
|
337
|
+
canvas.style.cursor = 'crosshair';
|
|
338
|
+
} else if (activeTool === 'pan' || activeTool === 'orbit') {
|
|
339
|
+
canvas.style.cursor = 'grab';
|
|
340
|
+
} else {
|
|
341
|
+
canvas.style.cursor = 'default';
|
|
342
|
+
}
|
|
343
|
+
}, [activeTool, activeMeasurement, cancelMeasurement]);
|
|
344
|
+
|
|
145
345
|
useEffect(() => {
|
|
146
346
|
const canvas = canvasRef.current;
|
|
147
347
|
if (!canvas) return;
|
|
@@ -151,8 +351,15 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
151
351
|
let aborted = false;
|
|
152
352
|
let resizeObserver: ResizeObserver | null = null;
|
|
153
353
|
|
|
354
|
+
// Helper to align canvas dimensions to WebGPU requirements
|
|
355
|
+
// WebGPU texture row pitch must be aligned to 256 bytes
|
|
356
|
+
// For RGBA (4 bytes/pixel), width should be multiple of 64 pixels
|
|
357
|
+
const alignToWebGPU = (size: number): number => {
|
|
358
|
+
return Math.max(64, Math.floor(size / 64) * 64);
|
|
359
|
+
};
|
|
360
|
+
|
|
154
361
|
const rect = canvas.getBoundingClientRect();
|
|
155
|
-
const width = Math.max(1, Math.floor(rect.width));
|
|
362
|
+
const width = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
|
|
156
363
|
const height = Math.max(1, Math.floor(rect.height));
|
|
157
364
|
canvas.width = width;
|
|
158
365
|
canvas.height = height;
|
|
@@ -160,6 +367,10 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
160
367
|
const renderer = new Renderer(canvas);
|
|
161
368
|
rendererRef.current = renderer;
|
|
162
369
|
|
|
370
|
+
// Register refs for BCF hook access (snapshot capture, camera control)
|
|
371
|
+
setGlobalCanvasRef(canvasRef);
|
|
372
|
+
setGlobalRendererRef(rendererRef);
|
|
373
|
+
|
|
163
374
|
renderer.init().then(() => {
|
|
164
375
|
if (aborted) return;
|
|
165
376
|
|
|
@@ -169,74 +380,94 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
169
380
|
const mouseState = mouseStateRef.current;
|
|
170
381
|
const touchState = touchStateRef.current;
|
|
171
382
|
|
|
172
|
-
// Helper function to get
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!geom) {
|
|
178
|
-
console.warn('[Viewport] getEntityBounds: geometry is null');
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
const mesh = geom.find(m => m.expressId === entityId);
|
|
182
|
-
if (!mesh) {
|
|
183
|
-
console.warn(`[Viewport] getEntityBounds: mesh not found for entityId ${entityId}`);
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
if (mesh.positions.length < 3) {
|
|
187
|
-
console.warn(`[Viewport] getEntityBounds: mesh has insufficient positions for entityId ${entityId}`);
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
192
|
-
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
193
|
-
|
|
194
|
-
for (let i = 0; i < mesh.positions.length; i += 3) {
|
|
195
|
-
const x = mesh.positions[i];
|
|
196
|
-
const y = mesh.positions[i + 1];
|
|
197
|
-
const z = mesh.positions[i + 2];
|
|
198
|
-
minX = Math.min(minX, x);
|
|
199
|
-
minY = Math.min(minY, y);
|
|
200
|
-
minZ = Math.min(minZ, z);
|
|
201
|
-
maxX = Math.max(maxX, x);
|
|
202
|
-
maxY = Math.max(maxY, y);
|
|
203
|
-
maxZ = Math.max(maxZ, z);
|
|
204
|
-
}
|
|
205
|
-
|
|
383
|
+
// Helper function to get current pick options with visibility filtering
|
|
384
|
+
// This ensures users can only select visible elements (respects hide/isolate/type visibility)
|
|
385
|
+
function getPickOptions() {
|
|
386
|
+
const currentProgress = useViewerStore.getState().progress;
|
|
387
|
+
const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
|
|
206
388
|
return {
|
|
207
|
-
|
|
208
|
-
|
|
389
|
+
isStreaming: currentIsStreaming,
|
|
390
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
391
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
209
392
|
};
|
|
210
393
|
}
|
|
211
394
|
|
|
212
|
-
// Helper function to
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
395
|
+
// Helper function to compute snap visualization (edge highlights, sliding dot, corner rings, plane indicators)
|
|
396
|
+
// Stores 3D coordinates so edge highlights stay positioned correctly during camera rotation
|
|
397
|
+
function updateSnapVisualization(snapTarget: SnapTarget | null, edgeLockInfo?: { edgeT: number; isCorner: boolean; cornerValence: number }) {
|
|
398
|
+
if (!snapTarget || !canvas) {
|
|
399
|
+
setSnapVisualization(null);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const viz: Partial<SnapVisualization> = {};
|
|
404
|
+
|
|
405
|
+
// For edge snaps: store 3D world coordinates (will be projected to screen by ToolOverlays)
|
|
406
|
+
if ((snapTarget.type === 'edge' || snapTarget.type === 'vertex') && snapTarget.metadata?.vertices) {
|
|
407
|
+
const [v0, v1] = snapTarget.metadata.vertices;
|
|
408
|
+
|
|
409
|
+
// Store 3D coordinates - these will be projected dynamically during rendering
|
|
410
|
+
viz.edgeLine3D = {
|
|
411
|
+
v0: { x: v0.x, y: v0.y, z: v0.z },
|
|
412
|
+
v1: { x: v1.x, y: v1.y, z: v1.z },
|
|
223
413
|
};
|
|
414
|
+
|
|
415
|
+
// Add sliding dot t-parameter along the edge
|
|
416
|
+
if (edgeLockInfo) {
|
|
417
|
+
viz.slidingDot = { t: edgeLockInfo.edgeT };
|
|
418
|
+
|
|
419
|
+
// Add corner rings if at a corner with high valence
|
|
420
|
+
if (edgeLockInfo.isCorner && edgeLockInfo.cornerValence >= 2) {
|
|
421
|
+
viz.cornerRings = {
|
|
422
|
+
atStart: edgeLockInfo.edgeT < 0.5,
|
|
423
|
+
valence: edgeLockInfo.cornerValence,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
// No edge lock info - calculate t from snap position
|
|
428
|
+
const edge = { x: v1.x - v0.x, y: v1.y - v0.y, z: v1.z - v0.z };
|
|
429
|
+
const toSnap = { x: snapTarget.position.x - v0.x, y: snapTarget.position.y - v0.y, z: snapTarget.position.z - v0.z };
|
|
430
|
+
const edgeLenSq = edge.x * edge.x + edge.y * edge.y + edge.z * edge.z;
|
|
431
|
+
const t = edgeLenSq > 0 ? (toSnap.x * edge.x + toSnap.y * edge.y + toSnap.z * edge.z) / edgeLenSq : 0.5;
|
|
432
|
+
viz.slidingDot = { t: Math.max(0, Math.min(1, t)) };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// For face snaps: show plane indicator (still screen-space since it's just an indicator)
|
|
437
|
+
if ((snapTarget.type === 'face' || snapTarget.type === 'face_center') && snapTarget.normal) {
|
|
438
|
+
const pos = camera.projectToScreen(snapTarget.position, canvas.width, canvas.height);
|
|
439
|
+
if (pos) {
|
|
440
|
+
viz.planeIndicator = {
|
|
441
|
+
x: pos.x,
|
|
442
|
+
y: pos.y,
|
|
443
|
+
normal: snapTarget.normal,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
224
446
|
}
|
|
225
|
-
|
|
447
|
+
|
|
448
|
+
setSnapVisualization(viz);
|
|
226
449
|
}
|
|
227
450
|
|
|
451
|
+
// Note: getEntityBounds and getEntityCenter are now imported from viewportUtils.ts
|
|
452
|
+
|
|
228
453
|
// Register camera callbacks for ViewCube and other controls
|
|
229
454
|
setCameraCallbacks({
|
|
230
455
|
setPresetView: (view) => {
|
|
231
456
|
// Pass actual geometry bounds to avoid distance drift
|
|
232
|
-
|
|
457
|
+
const rotation = coordinateInfoRef.current?.buildingRotation;
|
|
458
|
+
camera.setPresetView(view, geometryBoundsRef.current, rotation);
|
|
233
459
|
// Initial render - animation loop will continue rendering during animation
|
|
234
460
|
renderer.render({
|
|
235
461
|
hiddenIds: hiddenEntitiesRef.current,
|
|
236
462
|
isolatedIds: isolatedEntitiesRef.current,
|
|
237
463
|
selectedId: selectedEntityIdRef.current,
|
|
464
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
238
465
|
clearColor: clearColorRef.current,
|
|
239
|
-
sectionPlane:
|
|
466
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
467
|
+
...sectionPlaneRef.current,
|
|
468
|
+
min: sectionRangeRef.current?.min,
|
|
469
|
+
max: sectionRangeRef.current?.max,
|
|
470
|
+
} : undefined,
|
|
240
471
|
});
|
|
241
472
|
calculateScale();
|
|
242
473
|
},
|
|
@@ -256,8 +487,13 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
256
487
|
hiddenIds: hiddenEntitiesRef.current,
|
|
257
488
|
isolatedIds: isolatedEntitiesRef.current,
|
|
258
489
|
selectedId: selectedEntityIdRef.current,
|
|
490
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
259
491
|
clearColor: clearColorRef.current,
|
|
260
|
-
sectionPlane:
|
|
492
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
493
|
+
...sectionPlaneRef.current,
|
|
494
|
+
min: sectionRangeRef.current?.min,
|
|
495
|
+
max: sectionRangeRef.current?.max,
|
|
496
|
+
} : undefined,
|
|
261
497
|
});
|
|
262
498
|
calculateScale();
|
|
263
499
|
},
|
|
@@ -267,8 +503,13 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
267
503
|
hiddenIds: hiddenEntitiesRef.current,
|
|
268
504
|
isolatedIds: isolatedEntitiesRef.current,
|
|
269
505
|
selectedId: selectedEntityIdRef.current,
|
|
506
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
270
507
|
clearColor: clearColorRef.current,
|
|
271
|
-
sectionPlane:
|
|
508
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
509
|
+
...sectionPlaneRef.current,
|
|
510
|
+
min: sectionRangeRef.current?.min,
|
|
511
|
+
max: sectionRangeRef.current?.max,
|
|
512
|
+
} : undefined,
|
|
272
513
|
});
|
|
273
514
|
calculateScale();
|
|
274
515
|
},
|
|
@@ -295,12 +536,23 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
295
536
|
hiddenIds: hiddenEntitiesRef.current,
|
|
296
537
|
isolatedIds: isolatedEntitiesRef.current,
|
|
297
538
|
selectedId: selectedEntityIdRef.current,
|
|
539
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
298
540
|
clearColor: clearColorRef.current,
|
|
299
|
-
sectionPlane:
|
|
541
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
542
|
+
...sectionPlaneRef.current,
|
|
543
|
+
min: sectionRangeRef.current?.min,
|
|
544
|
+
max: sectionRangeRef.current?.max,
|
|
545
|
+
} : undefined,
|
|
300
546
|
});
|
|
301
547
|
updateCameraRotationRealtime(camera.getRotation());
|
|
302
548
|
calculateScale();
|
|
303
549
|
},
|
|
550
|
+
projectToScreen: (worldPos: { x: number; y: number; z: number }) => {
|
|
551
|
+
// Project 3D world position to 2D screen coordinates
|
|
552
|
+
const canvas = canvasRef.current;
|
|
553
|
+
if (!canvas) return null;
|
|
554
|
+
return camera.projectToScreen(worldPos, canvas.width, canvas.height);
|
|
555
|
+
},
|
|
304
556
|
});
|
|
305
557
|
|
|
306
558
|
// Calculate scale bar value (world-space size for 96px scale bar)
|
|
@@ -333,24 +585,73 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
333
585
|
hiddenIds: hiddenEntitiesRef.current,
|
|
334
586
|
isolatedIds: isolatedEntitiesRef.current,
|
|
335
587
|
selectedId: selectedEntityIdRef.current,
|
|
588
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
336
589
|
clearColor: clearColorRef.current,
|
|
337
|
-
sectionPlane:
|
|
590
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
591
|
+
...sectionPlaneRef.current,
|
|
592
|
+
min: sectionRangeRef.current?.min,
|
|
593
|
+
max: sectionRangeRef.current?.max,
|
|
594
|
+
} : undefined,
|
|
338
595
|
});
|
|
339
596
|
// Update ViewCube during camera animation (e.g., preset view transitions)
|
|
340
597
|
updateCameraRotationRealtime(camera.getRotation());
|
|
341
598
|
calculateScale();
|
|
342
|
-
} else if (!mouseState.isDragging && currentTime - lastRotationUpdate >
|
|
343
|
-
// Update camera rotation for ViewCube when not dragging (throttled)
|
|
599
|
+
} else if (!mouseState.isDragging && currentTime - lastRotationUpdate > 500) {
|
|
600
|
+
// Update camera rotation for ViewCube when not dragging (throttled to every 500ms when idle)
|
|
344
601
|
updateCameraRotationRealtime(camera.getRotation());
|
|
345
602
|
lastRotationUpdate = currentTime;
|
|
346
603
|
}
|
|
347
604
|
|
|
348
|
-
// Update scale bar (throttled to every
|
|
349
|
-
if (currentTime - lastScaleUpdate >
|
|
605
|
+
// Update scale bar (throttled to every 500ms - scale rarely needs frequent updates)
|
|
606
|
+
if (currentTime - lastScaleUpdate > 500) {
|
|
350
607
|
calculateScale();
|
|
351
608
|
lastScaleUpdate = currentTime;
|
|
352
609
|
}
|
|
353
610
|
|
|
611
|
+
// Update measurement screen coordinates only when:
|
|
612
|
+
// 1. Measure tool is active (not in other modes)
|
|
613
|
+
// 2. Measurements exist
|
|
614
|
+
// 3. Camera actually changed
|
|
615
|
+
// This prevents unnecessary store updates and re-renders when not measuring
|
|
616
|
+
if (activeToolRef.current === 'measure') {
|
|
617
|
+
const state = useViewerStore.getState();
|
|
618
|
+
if (state.measurements.length > 0 || state.activeMeasurement) {
|
|
619
|
+
const canvas = canvasRef.current;
|
|
620
|
+
if (canvas) {
|
|
621
|
+
const cameraPos = camera.getPosition();
|
|
622
|
+
const cameraRot = camera.getRotation();
|
|
623
|
+
const cameraDist = camera.getDistance();
|
|
624
|
+
const currentCameraState = {
|
|
625
|
+
position: cameraPos,
|
|
626
|
+
rotation: cameraRot,
|
|
627
|
+
distance: cameraDist,
|
|
628
|
+
canvasWidth: canvas.width,
|
|
629
|
+
canvasHeight: canvas.height,
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// Check if camera state changed
|
|
633
|
+
const lastState = lastCameraStateRef.current;
|
|
634
|
+
const cameraChanged =
|
|
635
|
+
!lastState ||
|
|
636
|
+
lastState.position.x !== currentCameraState.position.x ||
|
|
637
|
+
lastState.position.y !== currentCameraState.position.y ||
|
|
638
|
+
lastState.position.z !== currentCameraState.position.z ||
|
|
639
|
+
lastState.rotation.azimuth !== currentCameraState.rotation.azimuth ||
|
|
640
|
+
lastState.rotation.elevation !== currentCameraState.rotation.elevation ||
|
|
641
|
+
lastState.distance !== currentCameraState.distance ||
|
|
642
|
+
lastState.canvasWidth !== currentCameraState.canvasWidth ||
|
|
643
|
+
lastState.canvasHeight !== currentCameraState.canvasHeight;
|
|
644
|
+
|
|
645
|
+
if (cameraChanged) {
|
|
646
|
+
lastCameraStateRef.current = currentCameraState;
|
|
647
|
+
updateMeasurementScreenCoords((worldPos) => {
|
|
648
|
+
return camera.projectToScreen(worldPos, canvas.width, canvas.height);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
354
655
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
355
656
|
};
|
|
356
657
|
lastFrameTimeRef.current = performance.now();
|
|
@@ -370,13 +671,6 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
370
671
|
// Determine action based on active tool and mouse button
|
|
371
672
|
const tool = activeToolRef.current;
|
|
372
673
|
|
|
373
|
-
// Box selection tool
|
|
374
|
-
if (tool === 'boxselect' && e.button === 0) {
|
|
375
|
-
startBoxSelect(e.clientX, e.clientY);
|
|
376
|
-
canvas.style.cursor = 'crosshair';
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
674
|
const willOrbit = !(tool === 'pan' || e.button === 1 || e.button === 2 ||
|
|
381
675
|
(tool === 'select' && e.shiftKey) ||
|
|
382
676
|
(tool !== 'orbit' && tool !== 'select' && e.shiftKey));
|
|
@@ -389,11 +683,10 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
389
683
|
const y = e.clientY - rect.top;
|
|
390
684
|
|
|
391
685
|
// Pick at cursor position - orbit around what user is clicking on
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const center = getEntityCenter(geometryRef.current, pickedId);
|
|
686
|
+
// Uses visibility filtering so hidden elements don't affect orbit pivot
|
|
687
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
688
|
+
if (pickResult !== null) {
|
|
689
|
+
const center = getEntityCenter(geometryRef.current, pickResult.expressId);
|
|
397
690
|
if (center) {
|
|
398
691
|
camera.setOrbitPivot(center);
|
|
399
692
|
} else {
|
|
@@ -416,8 +709,99 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
416
709
|
mouseState.isPanning = e.shiftKey;
|
|
417
710
|
canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
|
|
418
711
|
} else if (tool === 'measure') {
|
|
419
|
-
// Measure tool -
|
|
420
|
-
|
|
712
|
+
// Measure tool - shift+drag = orbit, normal drag = measure
|
|
713
|
+
if (e.shiftKey) {
|
|
714
|
+
// Shift pressed: allow orbit (not pan) when no measurement is active
|
|
715
|
+
mouseState.isDragging = true;
|
|
716
|
+
mouseState.isPanning = false;
|
|
717
|
+
canvas.style.cursor = 'grabbing';
|
|
718
|
+
// Fall through to allow orbit handling in mousemove
|
|
719
|
+
} else {
|
|
720
|
+
// Normal drag: start measurement
|
|
721
|
+
mouseState.isDragging = true; // Mark as dragging for measure tool
|
|
722
|
+
canvas.style.cursor = 'crosshair';
|
|
723
|
+
|
|
724
|
+
// Calculate canvas-relative coordinates
|
|
725
|
+
const rect = canvas.getBoundingClientRect();
|
|
726
|
+
const x = e.clientX - rect.left;
|
|
727
|
+
const y = e.clientY - rect.top;
|
|
728
|
+
|
|
729
|
+
// Use magnetic snap for better edge locking
|
|
730
|
+
const currentLock = edgeLockStateRef.current;
|
|
731
|
+
const result = renderer.raycastSceneMagnetic(x, y, {
|
|
732
|
+
edge: currentLock.edge,
|
|
733
|
+
meshExpressId: currentLock.meshExpressId,
|
|
734
|
+
lockStrength: currentLock.lockStrength,
|
|
735
|
+
}, {
|
|
736
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
737
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
738
|
+
snapOptions: snapEnabled ? {
|
|
739
|
+
snapToVertices: true,
|
|
740
|
+
snapToEdges: true,
|
|
741
|
+
snapToFaces: true,
|
|
742
|
+
screenSnapRadius: 60,
|
|
743
|
+
} : {
|
|
744
|
+
snapToVertices: false,
|
|
745
|
+
snapToEdges: false,
|
|
746
|
+
snapToFaces: false,
|
|
747
|
+
screenSnapRadius: 0,
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
if (result.intersection || result.snapTarget) {
|
|
752
|
+
const snapPoint = result.snapTarget || result.intersection;
|
|
753
|
+
const pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
|
|
754
|
+
|
|
755
|
+
if (pos) {
|
|
756
|
+
// Project snapped 3D position to screen - measurement starts from indicator, not cursor
|
|
757
|
+
const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
|
|
758
|
+
const measurePoint: MeasurePoint = {
|
|
759
|
+
x: pos.x,
|
|
760
|
+
y: pos.y,
|
|
761
|
+
z: pos.z,
|
|
762
|
+
screenX: screenPos?.x ?? x,
|
|
763
|
+
screenY: screenPos?.y ?? y,
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
startMeasurement(measurePoint);
|
|
767
|
+
|
|
768
|
+
if (result.snapTarget) {
|
|
769
|
+
setSnapTarget(result.snapTarget);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Update edge lock state
|
|
773
|
+
if (result.edgeLock.shouldRelease) {
|
|
774
|
+
clearEdgeLock();
|
|
775
|
+
updateSnapVisualization(result.snapTarget || null);
|
|
776
|
+
} else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
|
|
777
|
+
setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId, result.edgeLock.edgeT);
|
|
778
|
+
updateSnapVisualization(result.snapTarget, {
|
|
779
|
+
edgeT: result.edgeLock.edgeT,
|
|
780
|
+
isCorner: result.edgeLock.isCorner,
|
|
781
|
+
cornerValence: result.edgeLock.cornerValence,
|
|
782
|
+
});
|
|
783
|
+
} else {
|
|
784
|
+
updateSnapVisualization(result.snapTarget);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Set up orthogonal constraint for shift+drag - always use world axes
|
|
788
|
+
setMeasurementConstraintEdge({
|
|
789
|
+
axes: {
|
|
790
|
+
axis1: { x: 1, y: 0, z: 0 }, // World X
|
|
791
|
+
axis2: { x: 0, y: 1, z: 0 }, // World Y (vertical)
|
|
792
|
+
axis3: { x: 0, y: 0, z: 1 }, // World Z
|
|
793
|
+
},
|
|
794
|
+
colors: {
|
|
795
|
+
axis1: '#F44336', // Red - X axis
|
|
796
|
+
axis2: '#8BC34A', // Lime - Y axis (vertical)
|
|
797
|
+
axis3: '#2196F3', // Blue - Z axis
|
|
798
|
+
},
|
|
799
|
+
activeAxis: null,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return; // Early return for measure tool (non-shift)
|
|
804
|
+
}
|
|
421
805
|
} else {
|
|
422
806
|
// Default behavior
|
|
423
807
|
mouseState.isPanning = e.shiftKey;
|
|
@@ -429,11 +813,259 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
429
813
|
const rect = canvas.getBoundingClientRect();
|
|
430
814
|
const x = e.clientX - rect.left;
|
|
431
815
|
const y = e.clientY - rect.top;
|
|
816
|
+
const tool = activeToolRef.current;
|
|
817
|
+
|
|
818
|
+
// Handle measure tool live preview while dragging
|
|
819
|
+
// IMPORTANT: Check tool first, not activeMeasurement, to prevent orbit conflict
|
|
820
|
+
if (tool === 'measure' && mouseState.isDragging && activeMeasurementRef.current) {
|
|
821
|
+
// Only process measurement dragging if we have an active measurement
|
|
822
|
+
// If shift is held without active measurement, fall through to orbit handling
|
|
823
|
+
|
|
824
|
+
// Check if shift is held for orthogonal constraint
|
|
825
|
+
const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
|
|
826
|
+
|
|
827
|
+
// Throttle raycasting to 60fps max using requestAnimationFrame
|
|
828
|
+
if (!measureRaycastPendingRef.current) {
|
|
829
|
+
measureRaycastPendingRef.current = true;
|
|
830
|
+
|
|
831
|
+
measureRaycastFrameRef.current = requestAnimationFrame(() => {
|
|
832
|
+
measureRaycastPendingRef.current = false;
|
|
833
|
+
measureRaycastFrameRef.current = null;
|
|
834
|
+
|
|
835
|
+
const raycastStart = performance.now();
|
|
836
|
+
|
|
837
|
+
// When using orthogonal constraint (shift held), use simpler raycasting
|
|
838
|
+
// since the final position will be projected onto an axis anyway
|
|
839
|
+
const snapEnabled = snapEnabledRef.current && !useOrthogonalConstraint;
|
|
840
|
+
|
|
841
|
+
// If last raycast was slow, reduce complexity to prevent UI freezes
|
|
842
|
+
const wasSlowLastTime = lastMeasureRaycastDurationRef.current > SLOW_RAYCAST_THRESHOLD_MS;
|
|
843
|
+
const reduceComplexity = wasSlowLastTime && !useOrthogonalConstraint;
|
|
844
|
+
|
|
845
|
+
// Use magnetic snap for edge sliding behavior (only when not in orthogonal mode)
|
|
846
|
+
const currentLock = useOrthogonalConstraint
|
|
847
|
+
? { edge: null, meshExpressId: null, lockStrength: 0 }
|
|
848
|
+
: edgeLockStateRef.current;
|
|
849
|
+
|
|
850
|
+
const result = renderer.raycastSceneMagnetic(x, y, {
|
|
851
|
+
edge: currentLock.edge,
|
|
852
|
+
meshExpressId: currentLock.meshExpressId,
|
|
853
|
+
lockStrength: currentLock.lockStrength,
|
|
854
|
+
}, {
|
|
855
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
856
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
857
|
+
// Reduce snap complexity when using orthogonal constraint or when slow
|
|
858
|
+
snapOptions: snapEnabled ? {
|
|
859
|
+
snapToVertices: !reduceComplexity, // Skip vertex snapping when slow
|
|
860
|
+
snapToEdges: true,
|
|
861
|
+
snapToFaces: true,
|
|
862
|
+
screenSnapRadius: reduceComplexity ? 40 : 60, // Smaller radius when slow
|
|
863
|
+
} : useOrthogonalConstraint ? {
|
|
864
|
+
// In orthogonal mode, snap to edges and vertices only (no faces)
|
|
865
|
+
snapToVertices: true,
|
|
866
|
+
snapToEdges: true,
|
|
867
|
+
snapToFaces: false,
|
|
868
|
+
screenSnapRadius: 40,
|
|
869
|
+
} : {
|
|
870
|
+
snapToVertices: false,
|
|
871
|
+
snapToEdges: false,
|
|
872
|
+
snapToFaces: false,
|
|
873
|
+
screenSnapRadius: 0,
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Track raycast duration for adaptive throttling
|
|
878
|
+
lastMeasureRaycastDurationRef.current = performance.now() - raycastStart;
|
|
879
|
+
|
|
880
|
+
if (result.intersection || result.snapTarget) {
|
|
881
|
+
const snapPoint = result.snapTarget || result.intersection;
|
|
882
|
+
let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
|
|
883
|
+
|
|
884
|
+
if (pos) {
|
|
885
|
+
// Apply orthogonal constraint if shift is held and we have a constraint
|
|
886
|
+
if (useOrthogonalConstraint && activeMeasurementRef.current) {
|
|
887
|
+
const constraint = measurementConstraintEdgeRef.current!;
|
|
888
|
+
const start = activeMeasurementRef.current.start;
|
|
889
|
+
|
|
890
|
+
// Vector from start to cursor position
|
|
891
|
+
const dx = pos.x - start.x;
|
|
892
|
+
const dy = pos.y - start.y;
|
|
893
|
+
const dz = pos.z - start.z;
|
|
894
|
+
|
|
895
|
+
// Calculate dot product with each orthogonal axis
|
|
896
|
+
const { axis1, axis2, axis3 } = constraint.axes;
|
|
897
|
+
const dot1 = dx * axis1.x + dy * axis1.y + dz * axis1.z;
|
|
898
|
+
const dot2 = dx * axis2.x + dy * axis2.y + dz * axis2.z;
|
|
899
|
+
const dot3 = dx * axis3.x + dy * axis3.y + dz * axis3.z;
|
|
900
|
+
|
|
901
|
+
// Find the axis with the largest absolute dot product (closest to cursor direction)
|
|
902
|
+
const absDot1 = Math.abs(dot1);
|
|
903
|
+
const absDot2 = Math.abs(dot2);
|
|
904
|
+
const absDot3 = Math.abs(dot3);
|
|
905
|
+
|
|
906
|
+
let activeAxis: 'axis1' | 'axis2' | 'axis3';
|
|
907
|
+
let chosenDot: number;
|
|
908
|
+
let chosenDir: { x: number; y: number; z: number };
|
|
909
|
+
|
|
910
|
+
if (absDot1 >= absDot2 && absDot1 >= absDot3) {
|
|
911
|
+
activeAxis = 'axis1';
|
|
912
|
+
chosenDot = dot1;
|
|
913
|
+
chosenDir = axis1;
|
|
914
|
+
} else if (absDot2 >= absDot3) {
|
|
915
|
+
activeAxis = 'axis2';
|
|
916
|
+
chosenDot = dot2;
|
|
917
|
+
chosenDir = axis2;
|
|
918
|
+
} else {
|
|
919
|
+
activeAxis = 'axis3';
|
|
920
|
+
chosenDot = dot3;
|
|
921
|
+
chosenDir = axis3;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Project cursor position onto the chosen axis
|
|
925
|
+
pos = {
|
|
926
|
+
x: start.x + chosenDot * chosenDir.x,
|
|
927
|
+
y: start.y + chosenDot * chosenDir.y,
|
|
928
|
+
z: start.z + chosenDot * chosenDir.z,
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// Update active axis for visualization
|
|
932
|
+
updateConstraintActiveAxis(activeAxis);
|
|
933
|
+
} else if (!useOrthogonalConstraint && measurementConstraintEdgeRef.current?.activeAxis) {
|
|
934
|
+
// Clear active axis when shift is released
|
|
935
|
+
updateConstraintActiveAxis(null);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Project snapped 3D position to screen - indicator position, not raw cursor
|
|
939
|
+
const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
|
|
940
|
+
const measurePoint: MeasurePoint = {
|
|
941
|
+
x: pos.x,
|
|
942
|
+
y: pos.y,
|
|
943
|
+
z: pos.z,
|
|
944
|
+
screenX: screenPos?.x ?? x,
|
|
945
|
+
screenY: screenPos?.y ?? y,
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
updateMeasurement(measurePoint);
|
|
949
|
+
setSnapTarget(result.snapTarget || null);
|
|
950
|
+
|
|
951
|
+
// Update edge lock state and snap visualization (even in orthogonal mode)
|
|
952
|
+
if (result.edgeLock.shouldRelease) {
|
|
953
|
+
clearEdgeLock();
|
|
954
|
+
updateSnapVisualization(result.snapTarget || null);
|
|
955
|
+
} else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
|
|
956
|
+
// Check if we're on the same edge to preserve lock strength (hysteresis)
|
|
957
|
+
const sameDirection = currentLock.edge &&
|
|
958
|
+
Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v0.x) < 0.0001 &&
|
|
959
|
+
Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v0.y) < 0.0001 &&
|
|
960
|
+
Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v0.z) < 0.0001 &&
|
|
961
|
+
Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v1.x) < 0.0001 &&
|
|
962
|
+
Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v1.y) < 0.0001 &&
|
|
963
|
+
Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v1.z) < 0.0001;
|
|
964
|
+
const reversedDirection = currentLock.edge &&
|
|
965
|
+
Math.abs(currentLock.edge.v0.x - result.edgeLock.edge.v1.x) < 0.0001 &&
|
|
966
|
+
Math.abs(currentLock.edge.v0.y - result.edgeLock.edge.v1.y) < 0.0001 &&
|
|
967
|
+
Math.abs(currentLock.edge.v0.z - result.edgeLock.edge.v1.z) < 0.0001 &&
|
|
968
|
+
Math.abs(currentLock.edge.v1.x - result.edgeLock.edge.v0.x) < 0.0001 &&
|
|
969
|
+
Math.abs(currentLock.edge.v1.y - result.edgeLock.edge.v0.y) < 0.0001 &&
|
|
970
|
+
Math.abs(currentLock.edge.v1.z - result.edgeLock.edge.v0.z) < 0.0001;
|
|
971
|
+
const isSameEdge = currentLock.edge &&
|
|
972
|
+
currentLock.meshExpressId === result.edgeLock.meshExpressId &&
|
|
973
|
+
(sameDirection || reversedDirection);
|
|
974
|
+
|
|
975
|
+
if (isSameEdge) {
|
|
976
|
+
updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
|
|
977
|
+
incrementEdgeLockStrength();
|
|
978
|
+
} else {
|
|
979
|
+
setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId, result.edgeLock.edgeT);
|
|
980
|
+
updateEdgeLockPosition(result.edgeLock.edgeT, result.edgeLock.isCorner, result.edgeLock.cornerValence);
|
|
981
|
+
}
|
|
982
|
+
updateSnapVisualization(result.snapTarget, {
|
|
983
|
+
edgeT: result.edgeLock.edgeT,
|
|
984
|
+
isCorner: result.edgeLock.isCorner,
|
|
985
|
+
cornerValence: result.edgeLock.cornerValence,
|
|
986
|
+
});
|
|
987
|
+
} else {
|
|
988
|
+
updateSnapVisualization(result.snapTarget || null);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Mark as dragged (any movement counts for measure tool)
|
|
996
|
+
mouseState.didDrag = true;
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Handle measure tool hover preview (BEFORE dragging starts)
|
|
1001
|
+
// Show snap indicators to help user see where they can snap
|
|
1002
|
+
if (tool === 'measure' && !mouseState.isDragging && snapEnabledRef.current) {
|
|
1003
|
+
// Throttle hover snap detection more aggressively (100ms) to avoid performance issues
|
|
1004
|
+
// Active measurement still uses 60fps throttling via requestAnimationFrame
|
|
1005
|
+
const now = Date.now();
|
|
1006
|
+
if (now - lastHoverSnapTimeRef.current < HOVER_SNAP_THROTTLE_MS) {
|
|
1007
|
+
return; // Skip hover snap detection if throttled
|
|
1008
|
+
}
|
|
1009
|
+
lastHoverSnapTimeRef.current = now;
|
|
1010
|
+
|
|
1011
|
+
// Throttle raycasting to avoid performance issues
|
|
1012
|
+
if (!measureRaycastPendingRef.current) {
|
|
1013
|
+
measureRaycastPendingRef.current = true;
|
|
1014
|
+
|
|
1015
|
+
measureRaycastFrameRef.current = requestAnimationFrame(() => {
|
|
1016
|
+
measureRaycastPendingRef.current = false;
|
|
1017
|
+
measureRaycastFrameRef.current = null;
|
|
1018
|
+
|
|
1019
|
+
// Use magnetic snap for hover preview
|
|
1020
|
+
const currentLock = edgeLockStateRef.current;
|
|
1021
|
+
const result = renderer.raycastSceneMagnetic(x, y, {
|
|
1022
|
+
edge: currentLock.edge,
|
|
1023
|
+
meshExpressId: currentLock.meshExpressId,
|
|
1024
|
+
lockStrength: currentLock.lockStrength,
|
|
1025
|
+
}, {
|
|
1026
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
1027
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
1028
|
+
snapOptions: {
|
|
1029
|
+
snapToVertices: true,
|
|
1030
|
+
snapToEdges: true,
|
|
1031
|
+
snapToFaces: true,
|
|
1032
|
+
screenSnapRadius: 40, // Good radius for hover snap detection
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
432
1035
|
|
|
433
|
-
|
|
1036
|
+
// Update snap target for visual feedback
|
|
1037
|
+
if (result.snapTarget) {
|
|
1038
|
+
setSnapTarget(result.snapTarget);
|
|
1039
|
+
|
|
1040
|
+
// Update edge lock state for hover
|
|
1041
|
+
if (result.edgeLock.shouldRelease) {
|
|
1042
|
+
// Clear stale lock when release is signaled
|
|
1043
|
+
clearEdgeLock();
|
|
1044
|
+
updateSnapVisualization(result.snapTarget);
|
|
1045
|
+
} else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
|
|
1046
|
+
setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId, result.edgeLock.edgeT);
|
|
1047
|
+
updateSnapVisualization(result.snapTarget, {
|
|
1048
|
+
edgeT: result.edgeLock.edgeT,
|
|
1049
|
+
isCorner: result.edgeLock.isCorner,
|
|
1050
|
+
cornerValence: result.edgeLock.cornerValence,
|
|
1051
|
+
});
|
|
1052
|
+
} else {
|
|
1053
|
+
updateSnapVisualization(result.snapTarget);
|
|
1054
|
+
}
|
|
1055
|
+
} else {
|
|
1056
|
+
setSnapTarget(null);
|
|
1057
|
+
clearEdgeLock();
|
|
1058
|
+
updateSnapVisualization(null);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
return; // Don't fall through to other tool handlers
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Handle orbit/pan for other tools (or measure tool with shift+drag or no active measurement)
|
|
1066
|
+
if (mouseState.isDragging && (tool !== 'measure' || !activeMeasurementRef.current)) {
|
|
434
1067
|
const dx = e.clientX - mouseState.lastX;
|
|
435
1068
|
const dy = e.clientY - mouseState.lastY;
|
|
436
|
-
const tool = activeToolRef.current;
|
|
437
1069
|
|
|
438
1070
|
// Check if this counts as a drag (moved more than 5px from start)
|
|
439
1071
|
const totalDx = e.clientX - mouseState.startX;
|
|
@@ -442,14 +1074,10 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
442
1074
|
mouseState.didDrag = true;
|
|
443
1075
|
}
|
|
444
1076
|
|
|
445
|
-
//
|
|
446
|
-
if (tool === 'boxselect' && mouseState.button === 0) {
|
|
447
|
-
updateBoxSelect(e.clientX, e.clientY);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
|
|
1077
|
+
// Always update camera state immediately (feels responsive)
|
|
451
1078
|
if (mouseState.isPanning || tool === 'pan') {
|
|
452
|
-
|
|
1079
|
+
// Negate dy: mouse Y increases downward, but we want upward drag to pan up
|
|
1080
|
+
camera.pan(dx, -dy, false);
|
|
453
1081
|
} else if (tool === 'walk') {
|
|
454
1082
|
// Walk mode: left/right rotates, up/down moves forward/backward
|
|
455
1083
|
camera.orbit(dx * 0.5, 0, false); // Only horizontal rotation
|
|
@@ -462,16 +1090,54 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
462
1090
|
|
|
463
1091
|
mouseState.lastX = e.clientX;
|
|
464
1092
|
mouseState.lastY = e.clientY;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
1093
|
+
|
|
1094
|
+
// PERFORMANCE: Adaptive throttle based on model size
|
|
1095
|
+
// Small models: 60fps, Large: 40fps, Huge: 30fps
|
|
1096
|
+
const meshCount = geometryRef.current?.length ?? 0;
|
|
1097
|
+
const throttleMs = meshCount > 50000 ? RENDER_THROTTLE_MS_HUGE
|
|
1098
|
+
: meshCount > 10000 ? RENDER_THROTTLE_MS_LARGE
|
|
1099
|
+
: RENDER_THROTTLE_MS_SMALL;
|
|
1100
|
+
|
|
1101
|
+
const now = performance.now();
|
|
1102
|
+
if (now - lastRenderTimeRef.current >= throttleMs) {
|
|
1103
|
+
lastRenderTimeRef.current = now;
|
|
1104
|
+
renderer.render({
|
|
1105
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
1106
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
1107
|
+
selectedId: selectedEntityIdRef.current,
|
|
1108
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
1109
|
+
clearColor: clearColorRef.current,
|
|
1110
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1111
|
+
...sectionPlaneRef.current,
|
|
1112
|
+
min: sectionRangeRef.current?.min,
|
|
1113
|
+
max: sectionRangeRef.current?.max,
|
|
1114
|
+
} : undefined,
|
|
1115
|
+
});
|
|
1116
|
+
// Update ViewCube rotation in real-time during drag
|
|
1117
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
1118
|
+
calculateScale();
|
|
1119
|
+
} else if (!renderPendingRef.current) {
|
|
1120
|
+
// Schedule a final render for when throttle expires
|
|
1121
|
+
// This ensures we always render the final position
|
|
1122
|
+
renderPendingRef.current = true;
|
|
1123
|
+
requestAnimationFrame(() => {
|
|
1124
|
+
renderPendingRef.current = false;
|
|
1125
|
+
renderer.render({
|
|
1126
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
1127
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
1128
|
+
selectedId: selectedEntityIdRef.current,
|
|
1129
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
1130
|
+
clearColor: clearColorRef.current,
|
|
1131
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1132
|
+
...sectionPlaneRef.current,
|
|
1133
|
+
min: sectionRangeRef.current?.min,
|
|
1134
|
+
max: sectionRangeRef.current?.max,
|
|
1135
|
+
} : undefined,
|
|
1136
|
+
});
|
|
1137
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
1138
|
+
calculateScale();
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
475
1141
|
// Clear hover while dragging
|
|
476
1142
|
clearHover();
|
|
477
1143
|
} else if (hoverTooltipsEnabledRef.current) {
|
|
@@ -479,11 +1145,10 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
479
1145
|
const now = Date.now();
|
|
480
1146
|
if (now - lastHoverCheckRef.current > hoverThrottleMs) {
|
|
481
1147
|
lastHoverCheckRef.current = now;
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
setHoverState({ entityId: pickedId, screenX: e.clientX, screenY: e.clientY });
|
|
1148
|
+
// Uses visibility filtering so hidden elements don't show hover tooltips
|
|
1149
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
1150
|
+
if (pickResult) {
|
|
1151
|
+
setHoverState({ entityId: pickResult.expressId, screenX: e.clientX, screenY: e.clientY });
|
|
487
1152
|
} else {
|
|
488
1153
|
clearHover();
|
|
489
1154
|
}
|
|
@@ -491,21 +1156,143 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
491
1156
|
}
|
|
492
1157
|
});
|
|
493
1158
|
|
|
494
|
-
canvas.addEventListener('mouseup', () => {
|
|
1159
|
+
canvas.addEventListener('mouseup', (e) => {
|
|
1160
|
+
const tool = activeToolRef.current;
|
|
1161
|
+
|
|
1162
|
+
// Handle measure tool completion
|
|
1163
|
+
if (tool === 'measure' && activeMeasurementRef.current) {
|
|
1164
|
+
// Cancel any pending raycast to avoid stale updates
|
|
1165
|
+
if (measureRaycastFrameRef.current) {
|
|
1166
|
+
cancelAnimationFrame(measureRaycastFrameRef.current);
|
|
1167
|
+
measureRaycastFrameRef.current = null;
|
|
1168
|
+
measureRaycastPendingRef.current = false;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Do a final synchronous raycast at the mouseup position to ensure accurate end point
|
|
1172
|
+
const rect = canvas.getBoundingClientRect();
|
|
1173
|
+
const x = e.clientX - rect.left;
|
|
1174
|
+
const y = e.clientY - rect.top;
|
|
1175
|
+
|
|
1176
|
+
const useOrthogonalConstraint = e.shiftKey && measurementConstraintEdgeRef.current;
|
|
1177
|
+
const currentLock = edgeLockStateRef.current;
|
|
1178
|
+
|
|
1179
|
+
// Use simpler snap options in orthogonal mode (no magnetic locking needed)
|
|
1180
|
+
const finalLock = useOrthogonalConstraint
|
|
1181
|
+
? { edge: null, meshExpressId: null, lockStrength: 0 }
|
|
1182
|
+
: currentLock;
|
|
1183
|
+
|
|
1184
|
+
const result = renderer.raycastSceneMagnetic(x, y, {
|
|
1185
|
+
edge: finalLock.edge,
|
|
1186
|
+
meshExpressId: finalLock.meshExpressId,
|
|
1187
|
+
lockStrength: finalLock.lockStrength,
|
|
1188
|
+
}, {
|
|
1189
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
1190
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
1191
|
+
snapOptions: snapEnabledRef.current && !useOrthogonalConstraint ? {
|
|
1192
|
+
snapToVertices: true,
|
|
1193
|
+
snapToEdges: true,
|
|
1194
|
+
snapToFaces: true,
|
|
1195
|
+
screenSnapRadius: 60,
|
|
1196
|
+
} : useOrthogonalConstraint ? {
|
|
1197
|
+
// In orthogonal mode, snap to edges and vertices only (no faces)
|
|
1198
|
+
snapToVertices: true,
|
|
1199
|
+
snapToEdges: true,
|
|
1200
|
+
snapToFaces: false,
|
|
1201
|
+
screenSnapRadius: 40,
|
|
1202
|
+
} : {
|
|
1203
|
+
snapToVertices: false,
|
|
1204
|
+
snapToEdges: false,
|
|
1205
|
+
snapToFaces: false,
|
|
1206
|
+
screenSnapRadius: 0,
|
|
1207
|
+
},
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
// Update measurement with final position before finalizing
|
|
1211
|
+
if (result.intersection || result.snapTarget) {
|
|
1212
|
+
const snapPoint = result.snapTarget || result.intersection;
|
|
1213
|
+
let pos = snapPoint ? ('position' in snapPoint ? snapPoint.position : snapPoint.point) : null;
|
|
1214
|
+
|
|
1215
|
+
if (pos) {
|
|
1216
|
+
// Apply orthogonal constraint if shift is held
|
|
1217
|
+
if (useOrthogonalConstraint && activeMeasurementRef.current) {
|
|
1218
|
+
const constraint = measurementConstraintEdgeRef.current!;
|
|
1219
|
+
const start = activeMeasurementRef.current.start;
|
|
1220
|
+
|
|
1221
|
+
const dx = pos.x - start.x;
|
|
1222
|
+
const dy = pos.y - start.y;
|
|
1223
|
+
const dz = pos.z - start.z;
|
|
1224
|
+
|
|
1225
|
+
const { axis1, axis2, axis3 } = constraint.axes;
|
|
1226
|
+
const dot1 = dx * axis1.x + dy * axis1.y + dz * axis1.z;
|
|
1227
|
+
const dot2 = dx * axis2.x + dy * axis2.y + dz * axis2.z;
|
|
1228
|
+
const dot3 = dx * axis3.x + dy * axis3.y + dz * axis3.z;
|
|
1229
|
+
|
|
1230
|
+
const absDot1 = Math.abs(dot1);
|
|
1231
|
+
const absDot2 = Math.abs(dot2);
|
|
1232
|
+
const absDot3 = Math.abs(dot3);
|
|
1233
|
+
|
|
1234
|
+
let chosenDot: number;
|
|
1235
|
+
let chosenDir: { x: number; y: number; z: number };
|
|
1236
|
+
|
|
1237
|
+
if (absDot1 >= absDot2 && absDot1 >= absDot3) {
|
|
1238
|
+
chosenDot = dot1;
|
|
1239
|
+
chosenDir = axis1;
|
|
1240
|
+
} else if (absDot2 >= absDot3) {
|
|
1241
|
+
chosenDot = dot2;
|
|
1242
|
+
chosenDir = axis2;
|
|
1243
|
+
} else {
|
|
1244
|
+
chosenDot = dot3;
|
|
1245
|
+
chosenDir = axis3;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
pos = {
|
|
1249
|
+
x: start.x + chosenDot * chosenDir.x,
|
|
1250
|
+
y: start.y + chosenDot * chosenDir.y,
|
|
1251
|
+
z: start.z + chosenDot * chosenDir.z,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const screenPos = camera.projectToScreen(pos, canvas.width, canvas.height);
|
|
1256
|
+
const measurePoint: MeasurePoint = {
|
|
1257
|
+
x: pos.x,
|
|
1258
|
+
y: pos.y,
|
|
1259
|
+
z: pos.z,
|
|
1260
|
+
screenX: screenPos?.x ?? x,
|
|
1261
|
+
screenY: screenPos?.y ?? y,
|
|
1262
|
+
};
|
|
1263
|
+
updateMeasurement(measurePoint);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
finalizeMeasurement();
|
|
1268
|
+
clearEdgeLock(); // Clear edge lock after measurement complete
|
|
1269
|
+
mouseState.isDragging = false;
|
|
1270
|
+
mouseState.didDrag = false;
|
|
1271
|
+
canvas.style.cursor = 'crosshair';
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
495
1275
|
mouseState.isDragging = false;
|
|
496
1276
|
mouseState.isPanning = false;
|
|
497
|
-
|
|
498
|
-
canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : 'default');
|
|
1277
|
+
canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : (tool === 'measure' ? 'crosshair' : 'default'));
|
|
499
1278
|
// Clear orbit pivot after each orbit operation
|
|
500
1279
|
camera.setOrbitPivot(null);
|
|
501
1280
|
});
|
|
502
1281
|
|
|
503
1282
|
canvas.addEventListener('mouseleave', () => {
|
|
1283
|
+
const tool = activeToolRef.current;
|
|
504
1284
|
mouseState.isDragging = false;
|
|
505
1285
|
mouseState.isPanning = false;
|
|
506
1286
|
camera.stopInertia();
|
|
507
1287
|
camera.setOrbitPivot(null);
|
|
508
|
-
|
|
1288
|
+
// Restore cursor based on active tool
|
|
1289
|
+
if (tool === 'measure') {
|
|
1290
|
+
canvas.style.cursor = 'crosshair';
|
|
1291
|
+
} else if (tool === 'pan' || tool === 'orbit') {
|
|
1292
|
+
canvas.style.cursor = 'grab';
|
|
1293
|
+
} else {
|
|
1294
|
+
canvas.style.cursor = 'default';
|
|
1295
|
+
}
|
|
509
1296
|
clearHover();
|
|
510
1297
|
});
|
|
511
1298
|
|
|
@@ -514,8 +1301,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
514
1301
|
const rect = canvas.getBoundingClientRect();
|
|
515
1302
|
const x = e.clientX - rect.left;
|
|
516
1303
|
const y = e.clientY - rect.top;
|
|
517
|
-
|
|
518
|
-
|
|
1304
|
+
// Uses visibility filtering so hidden elements don't appear in context menu
|
|
1305
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
1306
|
+
openContextMenu(pickResult?.expressId ?? null, e.clientX, e.clientY);
|
|
519
1307
|
});
|
|
520
1308
|
|
|
521
1309
|
canvas.addEventListener('wheel', (e) => {
|
|
@@ -528,9 +1316,35 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
528
1316
|
hiddenIds: hiddenEntitiesRef.current,
|
|
529
1317
|
isolatedIds: isolatedEntitiesRef.current,
|
|
530
1318
|
selectedId: selectedEntityIdRef.current,
|
|
1319
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
531
1320
|
clearColor: clearColorRef.current,
|
|
532
|
-
sectionPlane:
|
|
1321
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1322
|
+
...sectionPlaneRef.current,
|
|
1323
|
+
min: sectionRangeRef.current?.min,
|
|
1324
|
+
max: sectionRangeRef.current?.max,
|
|
1325
|
+
} : undefined,
|
|
533
1326
|
});
|
|
1327
|
+
// Update measurement screen coordinates immediately during zoom (only in measure mode)
|
|
1328
|
+
if (activeToolRef.current === 'measure') {
|
|
1329
|
+
const state = useViewerStore.getState();
|
|
1330
|
+
if (state.measurements.length > 0 || state.activeMeasurement) {
|
|
1331
|
+
updateMeasurementScreenCoords((worldPos) => {
|
|
1332
|
+
return camera.projectToScreen(worldPos, canvas.width, canvas.height);
|
|
1333
|
+
});
|
|
1334
|
+
// Update camera state tracking to prevent duplicate update in animation loop
|
|
1335
|
+
const cameraPos = camera.getPosition();
|
|
1336
|
+
const cameraRot = camera.getRotation();
|
|
1337
|
+
const cameraDist = camera.getDistance();
|
|
1338
|
+
lastCameraStateRef.current = {
|
|
1339
|
+
position: cameraPos,
|
|
1340
|
+
rotation: cameraRot,
|
|
1341
|
+
distance: cameraDist,
|
|
1342
|
+
canvasWidth: canvas.width,
|
|
1343
|
+
canvasHeight: canvas.height,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
calculateScale();
|
|
534
1348
|
});
|
|
535
1349
|
|
|
536
1350
|
// Click handling
|
|
@@ -550,107 +1364,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
550
1364
|
return;
|
|
551
1365
|
}
|
|
552
1366
|
|
|
553
|
-
//
|
|
1367
|
+
// Measure tool now uses drag interaction (see mousedown/mousemove/mouseup)
|
|
554
1368
|
if (tool === 'measure') {
|
|
555
|
-
|
|
556
|
-
const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
|
|
557
|
-
const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
|
|
558
|
-
if (pickedId) {
|
|
559
|
-
// Get 3D position from mesh vertices (simplified - uses center of clicked entity)
|
|
560
|
-
// In a full implementation, you'd use ray-triangle intersection
|
|
561
|
-
const worldPos = getApproximateWorldPosition(geometryRef.current, pickedId, x, y, canvas.width, canvas.height);
|
|
562
|
-
const measurePoint: MeasurePoint = {
|
|
563
|
-
x: worldPos.x,
|
|
564
|
-
y: worldPos.y,
|
|
565
|
-
z: worldPos.z,
|
|
566
|
-
screenX: e.clientX,
|
|
567
|
-
screenY: e.clientY,
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
if (pendingMeasurePointRef.current) {
|
|
571
|
-
// Complete the measurement
|
|
572
|
-
completeMeasurement(measurePoint);
|
|
573
|
-
} else {
|
|
574
|
-
// Start a new measurement
|
|
575
|
-
addMeasurePoint(measurePoint);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Handle box selection completion
|
|
582
|
-
if (tool === 'boxselect') {
|
|
583
|
-
// Get box selection coordinates (in screen space relative to viewport)
|
|
584
|
-
const bs = boxSelectRef.current;
|
|
585
|
-
const geom = geometryRef.current;
|
|
586
|
-
if (bs.isSelecting && geom) {
|
|
587
|
-
const selectionRect = {
|
|
588
|
-
left: Math.min(bs.startX, bs.currentX),
|
|
589
|
-
right: Math.max(bs.startX, bs.currentX),
|
|
590
|
-
top: Math.min(bs.startY, bs.currentY),
|
|
591
|
-
bottom: Math.max(bs.startY, bs.currentY),
|
|
592
|
-
};
|
|
593
|
-
|
|
594
|
-
// Check if selection is large enough
|
|
595
|
-
const selectionWidth = selectionRect.right - selectionRect.left;
|
|
596
|
-
const selectionHeight = selectionRect.bottom - selectionRect.top;
|
|
597
|
-
|
|
598
|
-
if (selectionWidth > 5 && selectionHeight > 5) {
|
|
599
|
-
// Convert selection rect from viewport to canvas coordinates
|
|
600
|
-
const canvasRect = canvas.getBoundingClientRect();
|
|
601
|
-
const canvasLeft = selectionRect.left - canvasRect.left;
|
|
602
|
-
const canvasRight = selectionRect.right - canvasRect.left;
|
|
603
|
-
const canvasTop = selectionRect.top - canvasRect.top;
|
|
604
|
-
const canvasBottom = selectionRect.bottom - canvasRect.top;
|
|
605
|
-
|
|
606
|
-
// Find all entities whose center projects into the selection box
|
|
607
|
-
const selectedIds: number[] = [];
|
|
608
|
-
|
|
609
|
-
for (const mesh of geom) {
|
|
610
|
-
// Calculate mesh bounding box center
|
|
611
|
-
if (mesh.positions.length >= 3) {
|
|
612
|
-
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
613
|
-
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
614
|
-
|
|
615
|
-
for (let i = 0; i < mesh.positions.length; i += 3) {
|
|
616
|
-
const x = mesh.positions[i];
|
|
617
|
-
const y = mesh.positions[i + 1];
|
|
618
|
-
const z = mesh.positions[i + 2];
|
|
619
|
-
minX = Math.min(minX, x);
|
|
620
|
-
minY = Math.min(minY, y);
|
|
621
|
-
minZ = Math.min(minZ, z);
|
|
622
|
-
maxX = Math.max(maxX, x);
|
|
623
|
-
maxY = Math.max(maxY, y);
|
|
624
|
-
maxZ = Math.max(maxZ, z);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const center = {
|
|
628
|
-
x: (minX + maxX) / 2,
|
|
629
|
-
y: (minY + maxY) / 2,
|
|
630
|
-
z: (minZ + maxZ) / 2,
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// Project center to screen space
|
|
634
|
-
const screenPos = camera.projectToScreen(center, canvas.width, canvas.height);
|
|
635
|
-
|
|
636
|
-
if (screenPos) {
|
|
637
|
-
// Check if screen position is within selection box
|
|
638
|
-
if (screenPos.x >= canvasLeft && screenPos.x <= canvasRight &&
|
|
639
|
-
screenPos.y >= canvasTop && screenPos.y <= canvasBottom) {
|
|
640
|
-
selectedIds.push(mesh.expressId);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// Select all found entities
|
|
647
|
-
if (selectedIds.length > 0) {
|
|
648
|
-
setSelectedEntityIds(selectedIds);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
endBoxSelect();
|
|
653
|
-
return;
|
|
1369
|
+
return; // Skip click handling for measure tool
|
|
654
1370
|
}
|
|
655
1371
|
|
|
656
1372
|
const now = Date.now();
|
|
@@ -662,28 +1378,24 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
662
1378
|
Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
|
|
663
1379
|
Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
|
|
664
1380
|
// Double-click - isolate element
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
setSelectedEntityId(pickedId);
|
|
1381
|
+
// Uses visibility filtering so only visible elements can be selected
|
|
1382
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
1383
|
+
if (pickResult) {
|
|
1384
|
+
handlePickForSelection(pickResult);
|
|
670
1385
|
}
|
|
671
1386
|
lastClickTimeRef.current = 0;
|
|
672
1387
|
lastClickPosRef.current = null;
|
|
673
1388
|
} else {
|
|
674
|
-
// Single click
|
|
675
|
-
|
|
676
|
-
const currentProgress = useViewerStore.getState().progress;
|
|
677
|
-
const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
|
|
678
|
-
const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
|
|
1389
|
+
// Single click - uses visibility filtering so only visible elements can be selected
|
|
1390
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
679
1391
|
|
|
680
1392
|
// Multi-selection with Ctrl/Cmd
|
|
681
1393
|
if (e.ctrlKey || e.metaKey) {
|
|
682
|
-
if (
|
|
683
|
-
toggleSelection(
|
|
1394
|
+
if (pickResult) {
|
|
1395
|
+
toggleSelection(pickResult.expressId);
|
|
684
1396
|
}
|
|
685
1397
|
} else {
|
|
686
|
-
|
|
1398
|
+
handlePickForSelection(pickResult);
|
|
687
1399
|
}
|
|
688
1400
|
|
|
689
1401
|
lastClickTimeRef.current = now;
|
|
@@ -709,22 +1421,33 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
709
1421
|
e.preventDefault();
|
|
710
1422
|
touchState.touches = Array.from(e.touches);
|
|
711
1423
|
|
|
712
|
-
|
|
1424
|
+
// Track multi-touch to prevent false tap-select after pinch/zoom
|
|
1425
|
+
if (touchState.touches.length > 1) {
|
|
1426
|
+
touchState.multiTouch = true;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (touchState.touches.length === 1 && !touchState.multiTouch) {
|
|
713
1430
|
touchState.lastCenter = {
|
|
714
1431
|
x: touchState.touches[0].clientX,
|
|
715
1432
|
y: touchState.touches[0].clientY,
|
|
716
1433
|
};
|
|
1434
|
+
// Record tap start for tap-to-select detection
|
|
1435
|
+
touchState.tapStartTime = Date.now();
|
|
1436
|
+
touchState.tapStartPos = {
|
|
1437
|
+
x: touchState.touches[0].clientX,
|
|
1438
|
+
y: touchState.touches[0].clientY,
|
|
1439
|
+
};
|
|
1440
|
+
touchState.didMove = false;
|
|
717
1441
|
|
|
718
1442
|
// Set orbit pivot to what user touches (same as mouse click behavior)
|
|
719
1443
|
const rect = canvas.getBoundingClientRect();
|
|
720
1444
|
const x = touchState.touches[0].clientX - rect.left;
|
|
721
1445
|
const y = touchState.touches[0].clientY - rect.top;
|
|
722
1446
|
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const center = getEntityCenter(geometryRef.current, pickedId);
|
|
1447
|
+
// Uses visibility filtering so hidden elements don't affect orbit pivot
|
|
1448
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
1449
|
+
if (pickResult !== null) {
|
|
1450
|
+
const center = getEntityCenter(geometryRef.current, pickResult.expressId);
|
|
728
1451
|
if (center) {
|
|
729
1452
|
camera.setOrbitPivot(center);
|
|
730
1453
|
} else {
|
|
@@ -733,6 +1456,12 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
733
1456
|
} else {
|
|
734
1457
|
camera.setOrbitPivot(null);
|
|
735
1458
|
}
|
|
1459
|
+
} else if (touchState.touches.length === 1) {
|
|
1460
|
+
// Single touch after multi-touch - just update center for orbit
|
|
1461
|
+
touchState.lastCenter = {
|
|
1462
|
+
x: touchState.touches[0].clientX,
|
|
1463
|
+
y: touchState.touches[0].clientY,
|
|
1464
|
+
};
|
|
736
1465
|
} else if (touchState.touches.length === 2) {
|
|
737
1466
|
const dx = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
738
1467
|
const dy = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
@@ -751,6 +1480,14 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
751
1480
|
if (touchState.touches.length === 1) {
|
|
752
1481
|
const dx = touchState.touches[0].clientX - touchState.lastCenter.x;
|
|
753
1482
|
const dy = touchState.touches[0].clientY - touchState.lastCenter.y;
|
|
1483
|
+
|
|
1484
|
+
// Mark as moved if significant movement (prevents tap-select during drag)
|
|
1485
|
+
const totalDx = touchState.touches[0].clientX - touchState.tapStartPos.x;
|
|
1486
|
+
const totalDy = touchState.touches[0].clientY - touchState.tapStartPos.y;
|
|
1487
|
+
if (Math.abs(totalDx) > 10 || Math.abs(totalDy) > 10) {
|
|
1488
|
+
touchState.didMove = true;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
754
1491
|
camera.orbit(dx, dy, false);
|
|
755
1492
|
touchState.lastCenter = {
|
|
756
1493
|
x: touchState.touches[0].clientX,
|
|
@@ -760,7 +1497,13 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
760
1497
|
hiddenIds: hiddenEntitiesRef.current,
|
|
761
1498
|
isolatedIds: isolatedEntitiesRef.current,
|
|
762
1499
|
selectedId: selectedEntityIdRef.current,
|
|
1500
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
763
1501
|
clearColor: clearColorRef.current,
|
|
1502
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1503
|
+
...sectionPlaneRef.current,
|
|
1504
|
+
min: sectionRangeRef.current?.min,
|
|
1505
|
+
max: sectionRangeRef.current?.max,
|
|
1506
|
+
} : undefined,
|
|
764
1507
|
});
|
|
765
1508
|
} else if (touchState.touches.length === 2) {
|
|
766
1509
|
const dx1 = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
@@ -783,18 +1526,56 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
783
1526
|
hiddenIds: hiddenEntitiesRef.current,
|
|
784
1527
|
isolatedIds: isolatedEntitiesRef.current,
|
|
785
1528
|
selectedId: selectedEntityIdRef.current,
|
|
1529
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
786
1530
|
clearColor: clearColorRef.current,
|
|
787
|
-
sectionPlane:
|
|
1531
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1532
|
+
...sectionPlaneRef.current,
|
|
1533
|
+
min: sectionRangeRef.current?.min,
|
|
1534
|
+
max: sectionRangeRef.current?.max,
|
|
1535
|
+
} : undefined,
|
|
788
1536
|
});
|
|
789
1537
|
}
|
|
790
1538
|
});
|
|
791
1539
|
|
|
792
|
-
canvas.addEventListener('touchend', (e) => {
|
|
1540
|
+
canvas.addEventListener('touchend', async (e) => {
|
|
793
1541
|
e.preventDefault();
|
|
1542
|
+
const previousTouchCount = touchState.touches.length;
|
|
1543
|
+
const wasMultiTouch = touchState.multiTouch;
|
|
794
1544
|
touchState.touches = Array.from(e.touches);
|
|
1545
|
+
|
|
795
1546
|
if (touchState.touches.length === 0) {
|
|
796
1547
|
camera.stopInertia();
|
|
797
1548
|
camera.setOrbitPivot(null);
|
|
1549
|
+
|
|
1550
|
+
// Tap-to-select: detect quick tap without significant movement
|
|
1551
|
+
const tapDuration = Date.now() - touchState.tapStartTime;
|
|
1552
|
+
const tool = activeToolRef.current;
|
|
1553
|
+
|
|
1554
|
+
// Only select if:
|
|
1555
|
+
// - Was a single-finger touch (not after multi-touch gesture)
|
|
1556
|
+
// - Tap was quick (< 300ms)
|
|
1557
|
+
// - Didn't move significantly
|
|
1558
|
+
// - Tool supports selection (not orbit/pan/walk/measure)
|
|
1559
|
+
if (
|
|
1560
|
+
previousTouchCount === 1 &&
|
|
1561
|
+
!wasMultiTouch &&
|
|
1562
|
+
tapDuration < 300 &&
|
|
1563
|
+
!touchState.didMove &&
|
|
1564
|
+
tool !== 'orbit' &&
|
|
1565
|
+
tool !== 'pan' &&
|
|
1566
|
+
tool !== 'walk' &&
|
|
1567
|
+
tool !== 'measure'
|
|
1568
|
+
) {
|
|
1569
|
+
const rect = canvas.getBoundingClientRect();
|
|
1570
|
+
const x = touchState.tapStartPos.x - rect.left;
|
|
1571
|
+
const y = touchState.tapStartPos.y - rect.top;
|
|
1572
|
+
|
|
1573
|
+
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
1574
|
+
handlePickForSelection(pickResult);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Reset multi-touch flag when all touches end
|
|
1578
|
+
touchState.multiTouch = false;
|
|
798
1579
|
}
|
|
799
1580
|
});
|
|
800
1581
|
|
|
@@ -811,12 +1592,19 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
811
1592
|
|
|
812
1593
|
// Preset views - set view and re-render
|
|
813
1594
|
const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => {
|
|
814
|
-
|
|
1595
|
+
const rotation = coordinateInfoRef.current?.buildingRotation;
|
|
1596
|
+
camera.setPresetView(view, geometryBoundsRef.current, rotation);
|
|
815
1597
|
renderer.render({
|
|
816
1598
|
hiddenIds: hiddenEntitiesRef.current,
|
|
817
1599
|
isolatedIds: isolatedEntitiesRef.current,
|
|
818
1600
|
selectedId: selectedEntityIdRef.current,
|
|
1601
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
819
1602
|
clearColor: clearColorRef.current,
|
|
1603
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1604
|
+
...sectionPlaneRef.current,
|
|
1605
|
+
min: sectionRangeRef.current?.min,
|
|
1606
|
+
max: sectionRangeRef.current?.max,
|
|
1607
|
+
} : undefined,
|
|
820
1608
|
});
|
|
821
1609
|
updateCameraRotationRealtime(camera.getRotation());
|
|
822
1610
|
calculateScale();
|
|
@@ -879,19 +1667,17 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
879
1667
|
const zoomSpeed = 0.1;
|
|
880
1668
|
|
|
881
1669
|
if (firstPersonModeRef.current) {
|
|
882
|
-
|
|
883
|
-
if (keyState['
|
|
884
|
-
if (keyState['
|
|
885
|
-
if (keyState['
|
|
886
|
-
if (keyState['
|
|
887
|
-
if (keyState['e']) { camera.moveFirstPerson(0, 0, 1); moved = true; }
|
|
1670
|
+
// Arrow keys for first-person navigation (camera-relative)
|
|
1671
|
+
if (keyState['arrowup']) { camera.moveFirstPerson(-1, 0, 0); moved = true; }
|
|
1672
|
+
if (keyState['arrowdown']) { camera.moveFirstPerson(1, 0, 0); moved = true; }
|
|
1673
|
+
if (keyState['arrowleft']) { camera.moveFirstPerson(0, 1, 0); moved = true; }
|
|
1674
|
+
if (keyState['arrowright']) { camera.moveFirstPerson(0, -1, 0); moved = true; }
|
|
888
1675
|
} else {
|
|
889
|
-
|
|
890
|
-
if (keyState['
|
|
891
|
-
if (keyState['
|
|
892
|
-
if (keyState['
|
|
893
|
-
if (keyState['
|
|
894
|
-
if (keyState['e']) { camera.zoom(zoomSpeed * 100, false); moved = true; }
|
|
1676
|
+
// Arrow keys for panning (camera-relative: arrow direction = camera movement)
|
|
1677
|
+
if (keyState['arrowup']) { camera.pan(0, -panSpeed, false); moved = true; }
|
|
1678
|
+
if (keyState['arrowdown']) { camera.pan(0, panSpeed, false); moved = true; }
|
|
1679
|
+
if (keyState['arrowleft']) { camera.pan(panSpeed, 0, false); moved = true; }
|
|
1680
|
+
if (keyState['arrowright']) { camera.pan(-panSpeed, 0, false); moved = true; }
|
|
895
1681
|
}
|
|
896
1682
|
|
|
897
1683
|
if (moved) {
|
|
@@ -899,8 +1685,13 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
899
1685
|
hiddenIds: hiddenEntitiesRef.current,
|
|
900
1686
|
isolatedIds: isolatedEntitiesRef.current,
|
|
901
1687
|
selectedId: selectedEntityIdRef.current,
|
|
1688
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
902
1689
|
clearColor: clearColorRef.current,
|
|
903
|
-
sectionPlane:
|
|
1690
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1691
|
+
...sectionPlaneRef.current,
|
|
1692
|
+
min: sectionRangeRef.current?.min,
|
|
1693
|
+
max: sectionRangeRef.current?.max,
|
|
1694
|
+
} : undefined,
|
|
904
1695
|
});
|
|
905
1696
|
}
|
|
906
1697
|
requestAnimationFrame(keyboardMove);
|
|
@@ -913,14 +1704,21 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
913
1704
|
resizeObserver = new ResizeObserver(() => {
|
|
914
1705
|
if (aborted) return;
|
|
915
1706
|
const rect = canvas.getBoundingClientRect();
|
|
916
|
-
|
|
1707
|
+
// Use same WebGPU alignment as initialization
|
|
1708
|
+
const width = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
|
|
917
1709
|
const height = Math.max(1, Math.floor(rect.height));
|
|
918
1710
|
renderer.resize(width, height);
|
|
919
1711
|
renderer.render({
|
|
920
1712
|
hiddenIds: hiddenEntitiesRef.current,
|
|
921
1713
|
isolatedIds: isolatedEntitiesRef.current,
|
|
922
1714
|
selectedId: selectedEntityIdRef.current,
|
|
1715
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
923
1716
|
clearColor: clearColorRef.current,
|
|
1717
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1718
|
+
...sectionPlaneRef.current,
|
|
1719
|
+
min: sectionRangeRef.current?.min,
|
|
1720
|
+
max: sectionRangeRef.current?.max,
|
|
1721
|
+
} : undefined,
|
|
924
1722
|
});
|
|
925
1723
|
});
|
|
926
1724
|
resizeObserver.observe(canvas);
|
|
@@ -929,7 +1727,13 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
929
1727
|
hiddenIds: hiddenEntitiesRef.current,
|
|
930
1728
|
isolatedIds: isolatedEntitiesRef.current,
|
|
931
1729
|
selectedId: selectedEntityIdRef.current,
|
|
1730
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
932
1731
|
clearColor: clearColorRef.current,
|
|
1732
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1733
|
+
...sectionPlaneRef.current,
|
|
1734
|
+
min: sectionRangeRef.current?.min,
|
|
1735
|
+
max: sectionRangeRef.current?.max,
|
|
1736
|
+
} : undefined,
|
|
933
1737
|
});
|
|
934
1738
|
});
|
|
935
1739
|
|
|
@@ -947,8 +1751,15 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
947
1751
|
if (keyboardHandlersRef.current.handleKeyUp) {
|
|
948
1752
|
window.removeEventListener('keyup', keyboardHandlersRef.current.handleKeyUp);
|
|
949
1753
|
}
|
|
1754
|
+
// Cancel pending raycast requests
|
|
1755
|
+
if (measureRaycastFrameRef.current !== null) {
|
|
1756
|
+
cancelAnimationFrame(measureRaycastFrameRef.current);
|
|
1757
|
+
measureRaycastFrameRef.current = null;
|
|
1758
|
+
}
|
|
950
1759
|
setIsInitialized(false);
|
|
951
1760
|
rendererRef.current = null;
|
|
1761
|
+
// Clear BCF global refs to prevent memory leaks
|
|
1762
|
+
clearGlobalRefs();
|
|
952
1763
|
};
|
|
953
1764
|
// Note: selectedEntityId is intentionally NOT in dependencies
|
|
954
1765
|
// The click handler captures setSelectedEntityId via closure
|
|
@@ -956,22 +1767,45 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
956
1767
|
}, [setSelectedEntityId]);
|
|
957
1768
|
|
|
958
1769
|
// Track processed meshes for incremental updates
|
|
959
|
-
|
|
1770
|
+
// Uses string keys to support compound keys (expressId:color) for submeshes
|
|
1771
|
+
const processedMeshIdsRef = useRef<Set<string>>(new Set());
|
|
960
1772
|
const lastGeometryLengthRef = useRef<number>(0);
|
|
961
1773
|
const lastGeometryRef = useRef<MeshData[] | null>(null);
|
|
962
1774
|
const cameraFittedRef = useRef<boolean>(false);
|
|
963
1775
|
const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
|
|
964
1776
|
|
|
965
1777
|
// Render throttling during streaming
|
|
966
|
-
const
|
|
967
|
-
const
|
|
1778
|
+
const lastStreamRenderTimeRef = useRef<number>(0);
|
|
1779
|
+
const STREAM_RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
|
|
968
1780
|
const progress = useViewerStore((state) => state.progress);
|
|
969
1781
|
const isStreaming = progress !== null && progress.percent < 100;
|
|
970
1782
|
|
|
971
1783
|
useEffect(() => {
|
|
972
1784
|
const renderer = rendererRef.current;
|
|
973
1785
|
|
|
974
|
-
|
|
1786
|
+
// Handle geometry cleared/null - reset refs so next load is treated as new file
|
|
1787
|
+
if (!geometry) {
|
|
1788
|
+
if (lastGeometryLengthRef.current > 0 || lastGeometryRef.current !== null) {
|
|
1789
|
+
// Geometry was cleared - reset tracking refs
|
|
1790
|
+
lastGeometryLengthRef.current = 0;
|
|
1791
|
+
lastGeometryRef.current = null;
|
|
1792
|
+
processedMeshIdsRef.current.clear();
|
|
1793
|
+
cameraFittedRef.current = false;
|
|
1794
|
+
finalBoundsRefittedRef.current = false;
|
|
1795
|
+
// Clear scene if renderer is ready
|
|
1796
|
+
if (renderer && isInitialized) {
|
|
1797
|
+
renderer.getScene().clear();
|
|
1798
|
+
renderer.getCamera().reset();
|
|
1799
|
+
geometryBoundsRef.current = {
|
|
1800
|
+
min: { x: -100, y: -100, z: -100 },
|
|
1801
|
+
max: { x: 100, y: 100, z: 100 },
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
if (!renderer || !isInitialized) return;
|
|
975
1809
|
|
|
976
1810
|
const device = renderer.getGPUDevice();
|
|
977
1811
|
if (!device) return;
|
|
@@ -984,24 +1818,18 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
984
1818
|
// React creates new array references on every appendGeometryBatch call,
|
|
985
1819
|
// so reference comparison would always trigger scene.clear()
|
|
986
1820
|
const isIncremental = currentLength > lastLength && lastLength > 0;
|
|
987
|
-
const isNewFile = currentLength > 0 && lastLength === 0
|
|
1821
|
+
const isNewFile = currentLength > 0 && lastLength === 0;
|
|
988
1822
|
const isCleared = currentLength === 0;
|
|
989
1823
|
|
|
990
1824
|
if (isCleared) {
|
|
991
|
-
// Geometry cleared
|
|
1825
|
+
// Geometry cleared (could be visibility change or file unload)
|
|
1826
|
+
// Clear scene but DON'T reset camera - user may just be hiding models
|
|
992
1827
|
scene.clear();
|
|
993
1828
|
processedMeshIdsRef.current.clear();
|
|
994
|
-
cameraFittedRef
|
|
995
|
-
finalBoundsRefittedRef.current = false;
|
|
1829
|
+
// Keep cameraFittedRef to preserve camera position when models are shown again
|
|
996
1830
|
lastGeometryLengthRef.current = 0;
|
|
997
1831
|
lastGeometryRef.current = null;
|
|
998
|
-
//
|
|
999
|
-
renderer.getCamera().reset();
|
|
1000
|
-
// Reset geometry bounds to default
|
|
1001
|
-
geometryBoundsRef.current = {
|
|
1002
|
-
min: { x: -100, y: -100, z: -100 },
|
|
1003
|
-
max: { x: 100, y: 100, z: 100 },
|
|
1004
|
-
};
|
|
1832
|
+
// Note: Don't reset camera or bounds - preserve user's view
|
|
1005
1833
|
return;
|
|
1006
1834
|
} else if (isNewFile) {
|
|
1007
1835
|
// New file loaded - reset camera and bounds
|
|
@@ -1019,20 +1847,35 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1019
1847
|
max: { x: 100, y: 100, z: 100 },
|
|
1020
1848
|
};
|
|
1021
1849
|
} else if (!isIncremental && currentLength !== lastLength) {
|
|
1022
|
-
// Length
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
}
|
|
1850
|
+
// Length changed but not incremental - could be:
|
|
1851
|
+
// 1. Length decreased (model hidden) - DON'T reset camera
|
|
1852
|
+
// 2. Length increased but lastLength > 0 (new file loaded while another was open) - DO reset
|
|
1853
|
+
const isLengthDecrease = currentLength < lastLength;
|
|
1854
|
+
|
|
1855
|
+
if (isLengthDecrease) {
|
|
1856
|
+
// Model visibility changed (hidden) - rebuild scene but keep camera
|
|
1857
|
+
scene.clear();
|
|
1858
|
+
processedMeshIdsRef.current.clear();
|
|
1859
|
+
// Don't reset cameraFittedRef - keep current camera position
|
|
1860
|
+
lastGeometryLengthRef.current = 0; // Reset so meshes get re-added
|
|
1861
|
+
lastGeometryRef.current = geometry;
|
|
1862
|
+
// Note: Don't reset camera or bounds - user wants to keep their view
|
|
1863
|
+
} else {
|
|
1864
|
+
// New file loaded while another was open - full reset
|
|
1865
|
+
scene.clear();
|
|
1866
|
+
processedMeshIdsRef.current.clear();
|
|
1867
|
+
cameraFittedRef.current = false;
|
|
1868
|
+
finalBoundsRefittedRef.current = false;
|
|
1869
|
+
lastGeometryLengthRef.current = 0;
|
|
1870
|
+
lastGeometryRef.current = geometry;
|
|
1871
|
+
// Reset camera state
|
|
1872
|
+
renderer.getCamera().reset();
|
|
1873
|
+
// Reset geometry bounds to default
|
|
1874
|
+
geometryBoundsRef.current = {
|
|
1875
|
+
min: { x: -100, y: -100, z: -100 },
|
|
1876
|
+
max: { x: 100, y: 100, z: 100 },
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1036
1879
|
} else if (currentLength === lastLength) {
|
|
1037
1880
|
// No geometry change - but check if we need to update bounds when streaming completes
|
|
1038
1881
|
if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
|
|
@@ -1061,11 +1904,6 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1061
1904
|
const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
|
|
1062
1905
|
|
|
1063
1906
|
if (boundsExpanded) {
|
|
1064
|
-
console.log('[Viewport] Refitting camera after streaming complete - bounds expanded:', {
|
|
1065
|
-
oldMaxSize: oldMaxSize.toFixed(1),
|
|
1066
|
-
newMaxSize: newMaxSize.toFixed(1),
|
|
1067
|
-
expansion: ((newMaxSize / oldMaxSize - 1) * 100).toFixed(0) + '%'
|
|
1068
|
-
});
|
|
1069
1907
|
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
1070
1908
|
}
|
|
1071
1909
|
}
|
|
@@ -1085,15 +1923,28 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1085
1923
|
lastGeometryRef.current = geometry;
|
|
1086
1924
|
}
|
|
1087
1925
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1926
|
+
// FIX: When not streaming (type visibility toggle), new meshes can be ANYWHERE in the array,
|
|
1927
|
+
// not just at the end. During streaming, new meshes ARE appended, so slice is safe.
|
|
1928
|
+
// After streaming completes, filter changes can insert meshes at any position.
|
|
1929
|
+
const meshesToAdd = isStreaming
|
|
1930
|
+
? geometry.slice(lastGeometryLengthRef.current) // Streaming: new meshes at end
|
|
1931
|
+
: geometry; // Post-streaming: scan entire array for unprocessed meshes
|
|
1090
1932
|
|
|
1091
1933
|
// Filter out already processed meshes
|
|
1934
|
+
// NOTE: Multiple meshes can share the same expressId AND same color (e.g., door inner framing pieces),
|
|
1935
|
+
// so we use expressId + array index as a compound key to ensure all submeshes are processed.
|
|
1092
1936
|
const newMeshes: MeshData[] = [];
|
|
1093
|
-
|
|
1094
|
-
|
|
1937
|
+
const startIndex = isStreaming ? lastGeometryLengthRef.current : 0;
|
|
1938
|
+
for (let i = 0; i < meshesToAdd.length; i++) {
|
|
1939
|
+
const meshData = meshesToAdd[i];
|
|
1940
|
+
// Use expressId + global array index as key to ensure each mesh is unique
|
|
1941
|
+
// (same expressId can have multiple submeshes with same color, e.g., door framing)
|
|
1942
|
+
const globalIndex = startIndex + i;
|
|
1943
|
+
const compoundKey = `${meshData.expressId}:${globalIndex}`;
|
|
1944
|
+
|
|
1945
|
+
if (!processedMeshIdsRef.current.has(compoundKey)) {
|
|
1095
1946
|
newMeshes.push(meshData);
|
|
1096
|
-
processedMeshIdsRef.current.add(
|
|
1947
|
+
processedMeshIdsRef.current.add(compoundKey);
|
|
1097
1948
|
}
|
|
1098
1949
|
}
|
|
1099
1950
|
|
|
@@ -1103,15 +1954,10 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1103
1954
|
const pipeline = renderer.getPipeline();
|
|
1104
1955
|
if (pipeline) {
|
|
1105
1956
|
// Use batched rendering - groups meshes by color into single draw calls
|
|
1106
|
-
|
|
1957
|
+
// Pass isStreaming flag to enable throttled batch rebuilding (reduces O(N²) cost)
|
|
1958
|
+
(scene as any).appendToBatches(newMeshes, device, pipeline, isStreaming);
|
|
1107
1959
|
|
|
1108
|
-
//
|
|
1109
|
-
// We DON'T create GPU buffers here during streaming - that's 2x the overhead!
|
|
1110
|
-
// Instead, store MeshData references and create buffers lazily when selected
|
|
1111
|
-
for (const meshData of newMeshes) {
|
|
1112
|
-
// Store minimal mesh data for picker and lazy selection buffer creation
|
|
1113
|
-
scene.addMeshData(meshData);
|
|
1114
|
-
}
|
|
1960
|
+
// Note: addMeshData is now called inside appendToBatches, no need to duplicate
|
|
1115
1961
|
} else {
|
|
1116
1962
|
// Fallback: add individual meshes if pipeline not ready
|
|
1117
1963
|
for (const meshData of newMeshes) {
|
|
@@ -1150,6 +1996,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1150
1996
|
});
|
|
1151
1997
|
}
|
|
1152
1998
|
}
|
|
1999
|
+
|
|
2000
|
+
// Invalidate caches when new geometry is added
|
|
2001
|
+
renderer.clearCaches();
|
|
1153
2002
|
}
|
|
1154
2003
|
|
|
1155
2004
|
lastGeometryLengthRef.current = currentLength;
|
|
@@ -1181,12 +2030,19 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1181
2030
|
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
1182
2031
|
};
|
|
1183
2032
|
|
|
2033
|
+
// Max coordinate threshold - matches CoordinateHandler's NORMAL_COORD_THRESHOLD
|
|
2034
|
+
// Coordinates beyond this are likely corrupted or unshifted original coordinates
|
|
2035
|
+
const MAX_VALID_COORD = 10000;
|
|
2036
|
+
|
|
1184
2037
|
for (const meshData of geometry) {
|
|
1185
2038
|
for (let i = 0; i < meshData.positions.length; i += 3) {
|
|
1186
2039
|
const x = meshData.positions[i];
|
|
1187
2040
|
const y = meshData.positions[i + 1];
|
|
1188
2041
|
const z = meshData.positions[i + 2];
|
|
1189
|
-
|
|
2042
|
+
// Filter out corrupted/unshifted vertices (> 10km from origin)
|
|
2043
|
+
const isValid = Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) &&
|
|
2044
|
+
Math.abs(x) < MAX_VALID_COORD && Math.abs(y) < MAX_VALID_COORD && Math.abs(z) < MAX_VALID_COORD;
|
|
2045
|
+
if (isValid) {
|
|
1190
2046
|
fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
|
|
1191
2047
|
fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
|
|
1192
2048
|
fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
|
|
@@ -1215,15 +2071,15 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1215
2071
|
// Instancing conversion would require preserving actual mesh transforms, which is complex
|
|
1216
2072
|
// For now, we render regular meshes directly (fast enough for most cases)
|
|
1217
2073
|
|
|
1218
|
-
// Render throttling: During streaming, only render every
|
|
2074
|
+
// Render throttling: During streaming, only render every STREAM_RENDER_THROTTLE_MS
|
|
1219
2075
|
// This prevents rendering 28K+ meshes from blocking WASM batch processing
|
|
1220
2076
|
const now = Date.now();
|
|
1221
|
-
const timeSinceLastRender = now -
|
|
1222
|
-
const shouldRender = !isStreaming || timeSinceLastRender >=
|
|
2077
|
+
const timeSinceLastRender = now - lastStreamRenderTimeRef.current;
|
|
2078
|
+
const shouldRender = !isStreaming || timeSinceLastRender >= STREAM_RENDER_THROTTLE_MS;
|
|
1223
2079
|
|
|
1224
2080
|
if (shouldRender) {
|
|
1225
2081
|
renderer.render();
|
|
1226
|
-
|
|
2082
|
+
lastStreamRenderTimeRef.current = now;
|
|
1227
2083
|
}
|
|
1228
2084
|
}, [geometry, coordinateInfo, isInitialized, isStreaming]);
|
|
1229
2085
|
|
|
@@ -1233,16 +2089,94 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1233
2089
|
const renderer = rendererRef.current;
|
|
1234
2090
|
if (!renderer || !isInitialized) return;
|
|
1235
2091
|
|
|
1236
|
-
// If streaming just completed (was streaming, now not),
|
|
2092
|
+
// If streaming just completed (was streaming, now not), rebuild pending batches and render
|
|
1237
2093
|
if (prevIsStreamingRef.current && !isStreaming) {
|
|
2094
|
+
const device = renderer.getGPUDevice();
|
|
2095
|
+
const pipeline = renderer.getPipeline();
|
|
2096
|
+
const scene = renderer.getScene();
|
|
2097
|
+
|
|
2098
|
+
// Rebuild any pending batches that were deferred during streaming
|
|
2099
|
+
if (device && pipeline && (scene as any).hasPendingBatches?.()) {
|
|
2100
|
+
(scene as any).rebuildPendingBatches(device, pipeline);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1238
2103
|
renderer.render();
|
|
1239
|
-
|
|
2104
|
+
lastStreamRenderTimeRef.current = Date.now();
|
|
1240
2105
|
}
|
|
1241
2106
|
prevIsStreamingRef.current = isStreaming;
|
|
1242
2107
|
}, [isStreaming, isInitialized]);
|
|
1243
2108
|
|
|
1244
|
-
//
|
|
1245
|
-
|
|
2109
|
+
// Apply pending color updates to WebGPU scene
|
|
2110
|
+
// Note: Color updates may arrive before viewport is initialized, so we wait
|
|
2111
|
+
useEffect(() => {
|
|
2112
|
+
if (!pendingColorUpdates || pendingColorUpdates.size === 0) return;
|
|
2113
|
+
|
|
2114
|
+
// Wait until viewport is initialized before applying color updates
|
|
2115
|
+
if (!isInitialized) return;
|
|
2116
|
+
|
|
2117
|
+
const renderer = rendererRef.current;
|
|
2118
|
+
if (!renderer) return;
|
|
2119
|
+
|
|
2120
|
+
const device = renderer.getGPUDevice();
|
|
2121
|
+
const pipeline = renderer.getPipeline();
|
|
2122
|
+
const scene = renderer.getScene();
|
|
2123
|
+
|
|
2124
|
+
if (device && pipeline && (scene as any).updateMeshColors) {
|
|
2125
|
+
(scene as any).updateMeshColors(pendingColorUpdates, device, pipeline);
|
|
2126
|
+
renderer.render();
|
|
2127
|
+
clearPendingColorUpdates();
|
|
2128
|
+
}
|
|
2129
|
+
}, [pendingColorUpdates, isInitialized, clearPendingColorUpdates]);
|
|
2130
|
+
|
|
2131
|
+
// 2D section overlay: upload drawing data to renderer when available
|
|
2132
|
+
const drawing2D = useViewerStore((s) => s.drawing2D);
|
|
2133
|
+
const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
|
|
2134
|
+
useEffect(() => {
|
|
2135
|
+
const renderer = rendererRef.current;
|
|
2136
|
+
if (!renderer || !isInitialized) return;
|
|
2137
|
+
|
|
2138
|
+
// Only show overlay when section tool is active, we have a drawing, AND 3D overlay is enabled
|
|
2139
|
+
if (activeTool === 'section' && drawing2D && drawing2D.cutPolygons.length > 0 && show3DOverlay) {
|
|
2140
|
+
// Convert Drawing2D format to renderer format
|
|
2141
|
+
const polygons = drawing2D.cutPolygons.map((cp) => ({
|
|
2142
|
+
polygon: cp.polygon,
|
|
2143
|
+
ifcType: cp.ifcType,
|
|
2144
|
+
expressId: cp.entityId, // DrawingPolygon uses entityId
|
|
2145
|
+
}));
|
|
2146
|
+
|
|
2147
|
+
// No hatching lines for 3D overlay (too dense)
|
|
2148
|
+
const lines: Array<{ line: { start: { x: number; y: number }; end: { x: number; y: number } }; category: string }> = [];
|
|
2149
|
+
|
|
2150
|
+
// Upload to renderer - will be drawn on the section plane
|
|
2151
|
+
// Pass sectionRange to match exactly what render() uses for section plane position
|
|
2152
|
+
renderer.uploadSection2DOverlay(
|
|
2153
|
+
polygons,
|
|
2154
|
+
lines,
|
|
2155
|
+
sectionPlane.axis,
|
|
2156
|
+
sectionPlane.position,
|
|
2157
|
+
sectionRangeRef.current ?? undefined, // Same range as section plane
|
|
2158
|
+
sectionPlane.flipped
|
|
2159
|
+
);
|
|
2160
|
+
} else {
|
|
2161
|
+
// Clear overlay when not in section mode, no drawing, or overlay disabled
|
|
2162
|
+
renderer.clearSection2DOverlay();
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// Re-render to show/hide overlay
|
|
2166
|
+
renderer.render({
|
|
2167
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
2168
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
2169
|
+
selectedId: selectedEntityIdRef.current,
|
|
2170
|
+
selectedIds: selectedEntityIdsRef.current,
|
|
2171
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
2172
|
+
clearColor: clearColorRef.current,
|
|
2173
|
+
sectionPlane: activeTool === 'section' ? {
|
|
2174
|
+
...sectionPlane,
|
|
2175
|
+
min: sectionRangeRef.current?.min,
|
|
2176
|
+
max: sectionRangeRef.current?.max,
|
|
2177
|
+
} : undefined,
|
|
2178
|
+
});
|
|
2179
|
+
}, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay]);
|
|
1246
2180
|
|
|
1247
2181
|
// Re-render when visibility, selection, or section plane changes
|
|
1248
2182
|
useEffect(() => {
|
|
@@ -1254,10 +2188,16 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
|
|
|
1254
2188
|
isolatedIds: isolatedEntities,
|
|
1255
2189
|
selectedId: selectedEntityId,
|
|
1256
2190
|
selectedIds: selectedEntityIds,
|
|
2191
|
+
selectedModelIndex,
|
|
1257
2192
|
clearColor: clearColorRef.current,
|
|
1258
|
-
sectionPlane:
|
|
2193
|
+
sectionPlane: activeTool === 'section' ? {
|
|
2194
|
+
...sectionPlane,
|
|
2195
|
+
min: sectionRange?.min,
|
|
2196
|
+
max: sectionRange?.max,
|
|
2197
|
+
} : undefined,
|
|
2198
|
+
buildingRotation: coordinateInfo?.buildingRotation,
|
|
1259
2199
|
});
|
|
1260
|
-
}, [hiddenEntities, isolatedEntities, selectedEntityId, selectedEntityIds, isInitialized, sectionPlane]);
|
|
2200
|
+
}, [hiddenEntities, isolatedEntities, selectedEntityId, selectedEntityIds, selectedModelIndex, isInitialized, sectionPlane, activeTool, sectionRange, coordinateInfo?.buildingRotation]);
|
|
1261
2201
|
|
|
1262
2202
|
return (
|
|
1263
2203
|
<canvas
|