@ifc-lite/viewer 1.1.7 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-Dgd6vzw_.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
  15. package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. 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
- const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
24
- const setSelectedEntityId = useViewerStore((state) => state.setSelectedEntityId);
25
- const hiddenEntities = useViewerStore((state) => state.hiddenEntities);
26
- const isolatedEntities = useViewerStore((state) => state.isolatedEntities);
27
- const activeTool = useViewerStore((state) => state.activeTool);
28
- const updateCameraRotationRealtime = useViewerStore((state) => state.updateCameraRotationRealtime);
29
- const updateScaleRealtime = useViewerStore((state) => state.updateScaleRealtime);
30
- const setCameraCallbacks = useViewerStore((state) => state.setCameraCallbacks);
31
- const theme = useViewerStore((state) => state.theme);
32
-
33
- // New store subscriptions for enhanced features
34
- const setHoverState = useViewerStore((state) => state.setHoverState);
35
- const clearHover = useViewerStore((state) => state.clearHover);
36
- const hoverTooltipsEnabled = useViewerStore((state) => state.hoverTooltipsEnabled);
37
- const openContextMenu = useViewerStore((state) => state.openContextMenu);
38
- const startBoxSelect = useViewerStore((state) => state.startBoxSelect);
39
- const updateBoxSelect = useViewerStore((state) => state.updateBoxSelect);
40
- const endBoxSelect = useViewerStore((state) => state.endBoxSelect);
41
- const boxSelect = useViewerStore((state) => state.boxSelect);
42
- const setSelectedEntityIds = useViewerStore((state) => state.setSelectedEntityIds);
43
- const toggleSelection = useViewerStore((state) => state.toggleSelection);
44
- const pendingMeasurePoint = useViewerStore((state) => state.pendingMeasurePoint);
45
- const addMeasurePoint = useViewerStore((state) => state.addMeasurePoint);
46
- const completeMeasurement = useViewerStore((state) => state.completeMeasurement);
47
- const sectionPlane = useViewerStore((state) => state.sectionPlane);
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
- const clearColorRef = useRef<[number, number, number, number]>([0.1, 0.1, 0.1, 1]);
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
- if (theme === 'light') {
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 boxSelectRef = useRef(boxSelect);
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(() => { boxSelectRef.current = boxSelect; }, [boxSelect]);
136
- useEffect(() => { geometryRef.current = geometry; }, [geometry]);
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 entity bounds (min/max) - defined early for callbacks
173
- function getEntityBounds(
174
- geom: MeshData[] | null,
175
- entityId: number
176
- ): { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null {
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
- min: { x: minX, y: minY, z: minZ },
208
- max: { x: maxX, y: maxY, z: maxZ },
389
+ isStreaming: currentIsStreaming,
390
+ hiddenIds: hiddenEntitiesRef.current,
391
+ isolatedIds: isolatedEntitiesRef.current,
209
392
  };
210
393
  }
211
394
 
212
- // Helper function to get entity center from geometry (uses bounding box center)
213
- function getEntityCenter(
214
- geom: MeshData[] | null,
215
- entityId: number
216
- ): { x: number; y: number; z: number } | null {
217
- const bounds = getEntityBounds(geom, entityId);
218
- if (bounds) {
219
- return {
220
- x: (bounds.min.x + bounds.max.x) / 2,
221
- y: (bounds.min.y + bounds.max.y) / 2,
222
- z: (bounds.min.z + bounds.max.z) / 2,
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
- return null;
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
- camera.setPresetView(view, geometryBoundsRef.current);
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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 > 100) {
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 100ms)
349
- if (currentTime - lastScaleUpdate > 100) {
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
- const currentProgress = useViewerStore.getState().progress;
393
- const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
394
- const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
395
- if (pickedId !== null) {
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 - cursor indicates measurement mode
420
- canvas.style.cursor = 'crosshair';
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
- if (mouseState.isDragging) {
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
- // Handle box selection
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
- camera.pan(dx, dy, false);
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
- renderer.render({
466
- hiddenIds: hiddenEntitiesRef.current,
467
- isolatedIds: isolatedEntitiesRef.current,
468
- selectedId: selectedEntityIdRef.current,
469
- clearColor: clearColorRef.current,
470
- sectionPlane: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
471
- });
472
- // Update ViewCube rotation in real-time during drag
473
- updateCameraRotationRealtime(camera.getRotation());
474
- calculateScale();
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
- const currentProgress = useViewerStore.getState().progress;
483
- const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
484
- const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
485
- if (pickedId) {
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
- const tool = activeToolRef.current;
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
- canvas.style.cursor = 'default';
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
- const pickedId = await renderer.pick(x, y);
518
- openContextMenu(pickedId, e.clientX, e.clientY);
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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
- // Handle measure tool clicks
1367
+ // Measure tool now uses drag interaction (see mousedown/mousemove/mouseup)
554
1368
  if (tool === 'measure') {
555
- const currentProgress = useViewerStore.getState().progress;
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
- const currentProgress = useViewerStore.getState().progress;
666
- const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
667
- const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
668
- if (pickedId) {
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
- // Get current progress state (not from closure)
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 (pickedId) {
683
- toggleSelection(pickedId);
1394
+ if (pickResult) {
1395
+ toggleSelection(pickResult.expressId);
684
1396
  }
685
1397
  } else {
686
- setSelectedEntityId(pickedId);
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
- if (touchState.touches.length === 1) {
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
- const currentProgress = useViewerStore.getState().progress;
724
- const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
725
- const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
726
- if (pickedId !== null) {
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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
- camera.setPresetView(view, geometryBoundsRef.current);
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
- if (keyState['w'] || keyState['arrowup']) { camera.moveFirstPerson(1, 0, 0); moved = true; }
883
- if (keyState['s'] || keyState['arrowdown']) { camera.moveFirstPerson(-1, 0, 0); moved = true; }
884
- if (keyState['a'] || keyState['arrowleft']) { camera.moveFirstPerson(0, -1, 0); moved = true; }
885
- if (keyState['d'] || keyState['arrowright']) { camera.moveFirstPerson(0, 1, 0); moved = true; }
886
- if (keyState['q']) { camera.moveFirstPerson(0, 0, -1); moved = true; }
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
- if (keyState['w'] || keyState['arrowup']) { camera.pan(0, panSpeed, false); moved = true; }
890
- if (keyState['s'] || keyState['arrowdown']) { camera.pan(0, -panSpeed, false); moved = true; }
891
- if (keyState['a'] || keyState['arrowleft']) { camera.pan(-panSpeed, 0, false); moved = true; }
892
- if (keyState['d'] || keyState['arrowright']) { camera.pan(panSpeed, 0, false); moved = true; }
893
- if (keyState['q']) { camera.zoom(-zoomSpeed * 100, false); moved = true; }
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: sectionPlaneRef.current.enabled ? sectionPlaneRef.current : undefined,
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
- const width = Math.max(1, Math.floor(rect.width));
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
- const processedMeshIdsRef = useRef<Set<number>>(new Set());
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 lastRenderTimeRef = useRef<number>(0);
967
- const RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
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
- if (!renderer || !geometry || !isInitialized) return;
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 && lastGeometryRef.current !== null;
1821
+ const isNewFile = currentLength > 0 && lastLength === 0;
988
1822
  const isCleared = currentLength === 0;
989
1823
 
990
1824
  if (isCleared) {
991
- // Geometry cleared - reset camera and bounds
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.current = false;
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
- // Reset camera state
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 decreased (shouldn't happen during streaming) - reset
1023
- scene.clear();
1024
- processedMeshIdsRef.current.clear();
1025
- cameraFittedRef.current = false;
1026
- finalBoundsRefittedRef.current = false;
1027
- lastGeometryLengthRef.current = 0;
1028
- lastGeometryRef.current = geometry;
1029
- // Reset camera state
1030
- renderer.getCamera().reset();
1031
- // Reset geometry bounds to default
1032
- geometryBoundsRef.current = {
1033
- min: { x: -100, y: -100, z: -100 },
1034
- max: { x: 100, y: 100, z: 100 },
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
- const startIndex = lastGeometryLengthRef.current;
1089
- const meshesToAdd = geometry.slice(startIndex);
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
- for (const meshData of meshesToAdd) {
1094
- if (!processedMeshIdsRef.current.has(meshData.expressId)) {
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(meshData.expressId);
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
- (scene as any).appendToBatches(newMeshes, device, pipeline);
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
- // Store mesh data for on-demand selection rendering
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
- if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
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 RENDER_THROTTLE_MS
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 - lastRenderTimeRef.current;
1222
- const shouldRender = !isStreaming || timeSinceLastRender >= RENDER_THROTTLE_MS;
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
- lastRenderTimeRef.current = now;
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), force immediate render
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
- lastRenderTimeRef.current = Date.now();
2104
+ lastStreamRenderTimeRef.current = Date.now();
1240
2105
  }
1241
2106
  prevIsStreamingRef.current = isStreaming;
1242
2107
  }, [isStreaming, isInitialized]);
1243
2108
 
1244
- // Get selectedEntityIds from store for multi-selection
1245
- const selectedEntityIds = useViewerStore((state) => state.selectedEntityIds);
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: sectionPlane.enabled ? sectionPlane : undefined,
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