@ifc-lite/viewer 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* 3D viewport component
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
10
|
-
import { Renderer
|
|
9
|
+
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
|
10
|
+
import { Renderer } from '@ifc-lite/renderer';
|
|
11
11
|
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
12
12
|
import { useViewerStore, type MeasurePoint, type SnapVisualization } from '@/store';
|
|
13
13
|
import {
|
|
@@ -26,14 +26,18 @@ import { useModelSelection } from '../../hooks/useModelSelection.js';
|
|
|
26
26
|
import {
|
|
27
27
|
getEntityBounds,
|
|
28
28
|
getEntityCenter,
|
|
29
|
-
buildRenderOptions,
|
|
30
|
-
getRenderThrottleMs,
|
|
31
29
|
getThemeClearColor,
|
|
32
|
-
calculateScaleBarSize,
|
|
33
30
|
type ViewportStateRefs,
|
|
34
31
|
} from '../../utils/viewportUtils.js';
|
|
35
32
|
import { setGlobalCanvasRef, setGlobalRendererRef, clearGlobalRefs } from '../../hooks/useBCF.js';
|
|
36
33
|
|
|
34
|
+
import { useMouseControls, type MouseState } from './useMouseControls.js';
|
|
35
|
+
import { useTouchControls, type TouchState } from './useTouchControls.js';
|
|
36
|
+
import { useKeyboardControls } from './useKeyboardControls.js';
|
|
37
|
+
import { useAnimationLoop } from './useAnimationLoop.js';
|
|
38
|
+
import { useGeometryStreaming } from './useGeometryStreaming.js';
|
|
39
|
+
import { useRenderUpdates } from './useRenderUpdates.js';
|
|
40
|
+
|
|
37
41
|
interface ViewportProps {
|
|
38
42
|
geometry: MeshData[] | null;
|
|
39
43
|
coordinateInfo?: CoordinateInfo;
|
|
@@ -74,7 +78,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
74
78
|
// IMPORTANT: pickResult.expressId is now a globalId (transformed at load time)
|
|
75
79
|
// We use the store-based resolver to find (modelId, originalExpressId)
|
|
76
80
|
// This is more reliable than the singleton registry which can have bundling issues
|
|
77
|
-
const handlePickForSelection = (pickResult: PickResult | null) => {
|
|
81
|
+
const handlePickForSelection = useCallback((pickResult: import('@ifc-lite/renderer').PickResult | null) => {
|
|
78
82
|
if (!pickResult) {
|
|
79
83
|
setSelectedEntityId(null);
|
|
80
84
|
return;
|
|
@@ -101,7 +105,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
|
-
};
|
|
108
|
+
}, [setSelectedEntityId, setSelectedEntity, resolveGlobalIdFromModels, modelIndexToId]);
|
|
109
|
+
|
|
110
|
+
// Ref to always access latest handlePickForSelection from event handlers
|
|
111
|
+
// (useMouseControls/useTouchControls capture this at effect setup time)
|
|
112
|
+
const handlePickForSelectionRef = useRef(handlePickForSelection);
|
|
113
|
+
useEffect(() => { handlePickForSelectionRef.current = handlePickForSelection; }, [handlePickForSelection]);
|
|
105
114
|
|
|
106
115
|
// Visibility state - use computedIsolatedIds from parent (includes storey selection)
|
|
107
116
|
// Fall back to store isolation if computedIsolatedIds is not provided
|
|
@@ -173,28 +182,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
173
182
|
// Tokyo Night storm: #1a1b26 = rgb(26, 27, 38)
|
|
174
183
|
const clearColorRef = useRef<[number, number, number, number]>([0.102, 0.106, 0.149, 1]);
|
|
175
184
|
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
// Update clear color when theme changes
|
|
178
|
-
clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
|
|
179
|
-
// Re-render with new clear color
|
|
180
|
-
const renderer = rendererRef.current;
|
|
181
|
-
if (renderer && isInitialized) {
|
|
182
|
-
renderer.render({
|
|
183
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
184
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
185
|
-
selectedId: selectedEntityIdRef.current,
|
|
186
|
-
selectedModelIndex: selectedModelIndexRef.current,
|
|
187
|
-
clearColor: clearColorRef.current,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
}, [theme, isInitialized]);
|
|
191
|
-
|
|
192
185
|
// Animation frame ref
|
|
193
186
|
const animationFrameRef = useRef<number | null>(null);
|
|
194
187
|
const lastFrameTimeRef = useRef<number>(0);
|
|
195
188
|
|
|
196
189
|
// Mouse state
|
|
197
|
-
const mouseStateRef = useRef({
|
|
190
|
+
const mouseStateRef = useRef<MouseState>({
|
|
198
191
|
isDragging: false,
|
|
199
192
|
isPanning: false,
|
|
200
193
|
lastX: 0,
|
|
@@ -206,7 +199,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
206
199
|
});
|
|
207
200
|
|
|
208
201
|
// Touch state
|
|
209
|
-
const touchStateRef = useRef({
|
|
202
|
+
const touchStateRef = useRef<TouchState>({
|
|
210
203
|
touches: [] as Touch[],
|
|
211
204
|
lastDistance: 0,
|
|
212
205
|
lastCenter: { x: 0, y: 0 },
|
|
@@ -342,6 +335,47 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
342
335
|
}
|
|
343
336
|
}, [activeTool, activeMeasurement, cancelMeasurement]);
|
|
344
337
|
|
|
338
|
+
// Helper: calculate scale bar value (world-space size for 96px scale bar)
|
|
339
|
+
const calculateScale = () => {
|
|
340
|
+
const canvas = canvasRef.current;
|
|
341
|
+
const renderer = rendererRef.current;
|
|
342
|
+
if (!canvas || !renderer) return;
|
|
343
|
+
|
|
344
|
+
const camera = renderer.getCamera();
|
|
345
|
+
const viewportHeight = canvas.height;
|
|
346
|
+
const scaleBarPixels = 96; // w-24 = 6rem = 96px
|
|
347
|
+
|
|
348
|
+
let worldSize: number;
|
|
349
|
+
if (camera.getProjectionMode() === 'orthographic') {
|
|
350
|
+
// Orthographic: orthoSize is half-height in world units, so full height = orthoSize * 2
|
|
351
|
+
worldSize = (scaleBarPixels / viewportHeight) * (camera.getOrthoSize() * 2);
|
|
352
|
+
} else {
|
|
353
|
+
const distance = camera.getDistance();
|
|
354
|
+
const fov = camera.getFOV();
|
|
355
|
+
// Calculate world-space size: (screen pixels / viewport height) * (distance * tan(FOV/2) * 2)
|
|
356
|
+
worldSize = (scaleBarPixels / viewportHeight) * (distance * Math.tan(fov / 2) * 2);
|
|
357
|
+
}
|
|
358
|
+
updateScaleRealtime(worldSize);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// Helper: get pick options with visibility filtering
|
|
362
|
+
const getPickOptions = () => {
|
|
363
|
+
const currentProgress = useViewerStore.getState().progress;
|
|
364
|
+
const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
|
|
365
|
+
return {
|
|
366
|
+
isStreaming: currentIsStreaming,
|
|
367
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
368
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
369
|
+
};
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Helper: check if there are pending measurements
|
|
373
|
+
const hasPendingMeasurements = () => {
|
|
374
|
+
const state = useViewerStore.getState();
|
|
375
|
+
return state.measurements.length > 0 || state.activeMeasurement !== null;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// ===== Renderer initialization =====
|
|
345
379
|
useEffect(() => {
|
|
346
380
|
const canvas = canvasRef.current;
|
|
347
381
|
if (!canvas) return;
|
|
@@ -377,78 +411,6 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
377
411
|
setIsInitialized(true);
|
|
378
412
|
|
|
379
413
|
const camera = renderer.getCamera();
|
|
380
|
-
const mouseState = mouseStateRef.current;
|
|
381
|
-
const touchState = touchStateRef.current;
|
|
382
|
-
|
|
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;
|
|
388
|
-
return {
|
|
389
|
-
isStreaming: currentIsStreaming,
|
|
390
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
391
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
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 },
|
|
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
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
setSnapVisualization(viz);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Note: getEntityBounds and getEntityCenter are now imported from viewportUtils.ts
|
|
452
414
|
|
|
453
415
|
// Register camera callbacks for ViewCube and other controls
|
|
454
416
|
setCameraCallbacks({
|
|
@@ -549,38 +511,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
549
511
|
},
|
|
550
512
|
projectToScreen: (worldPos: { x: number; y: number; z: number }) => {
|
|
551
513
|
// Project 3D world position to 2D screen coordinates
|
|
552
|
-
const
|
|
553
|
-
if (!
|
|
554
|
-
return camera.projectToScreen(worldPos,
|
|
514
|
+
const c = canvasRef.current;
|
|
515
|
+
if (!c) return null;
|
|
516
|
+
return camera.projectToScreen(worldPos, c.width, c.height);
|
|
555
517
|
},
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
// Calculate scale bar value (world-space size for 96px scale bar)
|
|
559
|
-
const calculateScale = () => {
|
|
560
|
-
const canvas = canvasRef.current;
|
|
561
|
-
if (!canvas) return;
|
|
562
|
-
|
|
563
|
-
const viewportHeight = canvas.height;
|
|
564
|
-
const distance = camera.getDistance();
|
|
565
|
-
const fov = camera.getFOV();
|
|
566
|
-
const scaleBarPixels = 96; // w-24 = 6rem = 96px
|
|
567
|
-
|
|
568
|
-
// Calculate world-space size: (screen pixels / viewport height) * (distance * tan(FOV/2) * 2)
|
|
569
|
-
const worldSize = (scaleBarPixels / viewportHeight) * (distance * Math.tan(fov / 2) * 2);
|
|
570
|
-
updateScaleRealtime(worldSize);
|
|
571
|
-
};
|
|
572
|
-
|
|
573
|
-
// Animation loop - update ViewCube in real-time
|
|
574
|
-
let lastRotationUpdate = 0;
|
|
575
|
-
let lastScaleUpdate = 0;
|
|
576
|
-
const animate = (currentTime: number) => {
|
|
577
|
-
if (aborted) return;
|
|
578
|
-
|
|
579
|
-
const deltaTime = currentTime - lastFrameTimeRef.current;
|
|
580
|
-
lastFrameTimeRef.current = currentTime;
|
|
581
|
-
|
|
582
|
-
const isAnimating = camera.update(deltaTime);
|
|
583
|
-
if (isAnimating) {
|
|
518
|
+
setProjectionMode: (mode) => {
|
|
519
|
+
camera.setProjectionMode(mode);
|
|
584
520
|
renderer.render({
|
|
585
521
|
hiddenIds: hiddenEntitiesRef.current,
|
|
586
522
|
isolatedIds: isolatedEntitiesRef.current,
|
|
@@ -593,1007 +529,10 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
593
529
|
max: sectionRangeRef.current?.max,
|
|
594
530
|
} : undefined,
|
|
595
531
|
});
|
|
596
|
-
// Update ViewCube during camera animation (e.g., preset view transitions)
|
|
597
|
-
updateCameraRotationRealtime(camera.getRotation());
|
|
598
532
|
calculateScale();
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
lastRotationUpdate = currentTime;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Update scale bar (throttled to every 500ms - scale rarely needs frequent updates)
|
|
606
|
-
if (currentTime - lastScaleUpdate > 500) {
|
|
607
|
-
calculateScale();
|
|
608
|
-
lastScaleUpdate = currentTime;
|
|
609
|
-
}
|
|
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
|
-
|
|
655
|
-
animationFrameRef.current = requestAnimationFrame(animate);
|
|
656
|
-
};
|
|
657
|
-
lastFrameTimeRef.current = performance.now();
|
|
658
|
-
animationFrameRef.current = requestAnimationFrame(animate);
|
|
659
|
-
|
|
660
|
-
// Mouse controls - respect active tool
|
|
661
|
-
canvas.addEventListener('mousedown', async (e) => {
|
|
662
|
-
e.preventDefault();
|
|
663
|
-
mouseState.isDragging = true;
|
|
664
|
-
mouseState.button = e.button;
|
|
665
|
-
mouseState.lastX = e.clientX;
|
|
666
|
-
mouseState.lastY = e.clientY;
|
|
667
|
-
mouseState.startX = e.clientX;
|
|
668
|
-
mouseState.startY = e.clientY;
|
|
669
|
-
mouseState.didDrag = false;
|
|
670
|
-
|
|
671
|
-
// Determine action based on active tool and mouse button
|
|
672
|
-
const tool = activeToolRef.current;
|
|
673
|
-
|
|
674
|
-
const willOrbit = !(tool === 'pan' || e.button === 1 || e.button === 2 ||
|
|
675
|
-
(tool === 'select' && e.shiftKey) ||
|
|
676
|
-
(tool !== 'orbit' && tool !== 'select' && e.shiftKey));
|
|
677
|
-
|
|
678
|
-
// Set orbit pivot to what user clicks on (standard CAD/BIM behavior)
|
|
679
|
-
// Simple and predictable: orbit around clicked geometry, or model center if empty space
|
|
680
|
-
if (willOrbit && tool !== 'measure' && tool !== 'walk') {
|
|
681
|
-
const rect = canvas.getBoundingClientRect();
|
|
682
|
-
const x = e.clientX - rect.left;
|
|
683
|
-
const y = e.clientY - rect.top;
|
|
684
|
-
|
|
685
|
-
// Pick at cursor position - orbit around what user is clicking on
|
|
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);
|
|
690
|
-
if (center) {
|
|
691
|
-
camera.setOrbitPivot(center);
|
|
692
|
-
} else {
|
|
693
|
-
camera.setOrbitPivot(null);
|
|
694
|
-
}
|
|
695
|
-
} else {
|
|
696
|
-
// No geometry under cursor - orbit around current target (model center)
|
|
697
|
-
camera.setOrbitPivot(null);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (tool === 'pan' || e.button === 1 || e.button === 2) {
|
|
702
|
-
mouseState.isPanning = true;
|
|
703
|
-
canvas.style.cursor = 'move';
|
|
704
|
-
} else if (tool === 'orbit') {
|
|
705
|
-
mouseState.isPanning = false;
|
|
706
|
-
canvas.style.cursor = 'grabbing';
|
|
707
|
-
} else if (tool === 'select') {
|
|
708
|
-
// Select tool: shift+drag = pan, normal drag = orbit
|
|
709
|
-
mouseState.isPanning = e.shiftKey;
|
|
710
|
-
canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
|
|
711
|
-
} else if (tool === 'measure') {
|
|
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
|
-
}
|
|
805
|
-
} else {
|
|
806
|
-
// Default behavior
|
|
807
|
-
mouseState.isPanning = e.shiftKey;
|
|
808
|
-
canvas.style.cursor = e.shiftKey ? 'move' : 'grabbing';
|
|
809
|
-
}
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
canvas.addEventListener('mousemove', async (e) => {
|
|
813
|
-
const rect = canvas.getBoundingClientRect();
|
|
814
|
-
const x = e.clientX - rect.left;
|
|
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
|
-
});
|
|
1035
|
-
|
|
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)) {
|
|
1067
|
-
const dx = e.clientX - mouseState.lastX;
|
|
1068
|
-
const dy = e.clientY - mouseState.lastY;
|
|
1069
|
-
|
|
1070
|
-
// Check if this counts as a drag (moved more than 5px from start)
|
|
1071
|
-
const totalDx = e.clientX - mouseState.startX;
|
|
1072
|
-
const totalDy = e.clientY - mouseState.startY;
|
|
1073
|
-
if (Math.abs(totalDx) > 5 || Math.abs(totalDy) > 5) {
|
|
1074
|
-
mouseState.didDrag = true;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// Always update camera state immediately (feels responsive)
|
|
1078
|
-
if (mouseState.isPanning || tool === 'pan') {
|
|
1079
|
-
// Negate dy: mouse Y increases downward, but we want upward drag to pan up
|
|
1080
|
-
camera.pan(dx, -dy, false);
|
|
1081
|
-
} else if (tool === 'walk') {
|
|
1082
|
-
// Walk mode: left/right rotates, up/down moves forward/backward
|
|
1083
|
-
camera.orbit(dx * 0.5, 0, false); // Only horizontal rotation
|
|
1084
|
-
if (Math.abs(dy) > 2) {
|
|
1085
|
-
camera.zoom(dy * 2, false); // Forward/backward movement
|
|
1086
|
-
}
|
|
1087
|
-
} else {
|
|
1088
|
-
camera.orbit(dx, dy, false);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
mouseState.lastX = e.clientX;
|
|
1092
|
-
mouseState.lastY = e.clientY;
|
|
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
|
-
}
|
|
1141
|
-
// Clear hover while dragging
|
|
1142
|
-
clearHover();
|
|
1143
|
-
} else if (hoverTooltipsEnabledRef.current) {
|
|
1144
|
-
// Hover detection (throttled) - only if tooltips are enabled
|
|
1145
|
-
const now = Date.now();
|
|
1146
|
-
if (now - lastHoverCheckRef.current > hoverThrottleMs) {
|
|
1147
|
-
lastHoverCheckRef.current = now;
|
|
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 });
|
|
1152
|
-
} else {
|
|
1153
|
-
clearHover();
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
});
|
|
1158
|
-
|
|
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
|
-
|
|
1275
|
-
mouseState.isDragging = false;
|
|
1276
|
-
mouseState.isPanning = false;
|
|
1277
|
-
canvas.style.cursor = tool === 'pan' ? 'grab' : (tool === 'orbit' ? 'grab' : (tool === 'measure' ? 'crosshair' : 'default'));
|
|
1278
|
-
// Clear orbit pivot after each orbit operation
|
|
1279
|
-
camera.setOrbitPivot(null);
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
canvas.addEventListener('mouseleave', () => {
|
|
1283
|
-
const tool = activeToolRef.current;
|
|
1284
|
-
mouseState.isDragging = false;
|
|
1285
|
-
mouseState.isPanning = false;
|
|
1286
|
-
camera.stopInertia();
|
|
1287
|
-
camera.setOrbitPivot(null);
|
|
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
|
-
}
|
|
1296
|
-
clearHover();
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
canvas.addEventListener('contextmenu', async (e) => {
|
|
1300
|
-
e.preventDefault();
|
|
1301
|
-
const rect = canvas.getBoundingClientRect();
|
|
1302
|
-
const x = e.clientX - rect.left;
|
|
1303
|
-
const y = e.clientY - rect.top;
|
|
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);
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
canvas.addEventListener('wheel', (e) => {
|
|
1310
|
-
e.preventDefault();
|
|
1311
|
-
const rect = canvas.getBoundingClientRect();
|
|
1312
|
-
const mouseX = e.clientX - rect.left;
|
|
1313
|
-
const mouseY = e.clientY - rect.top;
|
|
1314
|
-
camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
|
|
1315
|
-
renderer.render({
|
|
1316
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
1317
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
1318
|
-
selectedId: selectedEntityIdRef.current,
|
|
1319
|
-
selectedModelIndex: selectedModelIndexRef.current,
|
|
1320
|
-
clearColor: clearColorRef.current,
|
|
1321
|
-
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1322
|
-
...sectionPlaneRef.current,
|
|
1323
|
-
min: sectionRangeRef.current?.min,
|
|
1324
|
-
max: sectionRangeRef.current?.max,
|
|
1325
|
-
} : undefined,
|
|
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();
|
|
1348
|
-
});
|
|
1349
|
-
|
|
1350
|
-
// Click handling
|
|
1351
|
-
canvas.addEventListener('click', async (e) => {
|
|
1352
|
-
const rect = canvas.getBoundingClientRect();
|
|
1353
|
-
const x = e.clientX - rect.left;
|
|
1354
|
-
const y = e.clientY - rect.top;
|
|
1355
|
-
const tool = activeToolRef.current;
|
|
1356
|
-
|
|
1357
|
-
// Skip selection if user was dragging (orbiting/panning)
|
|
1358
|
-
if (mouseState.didDrag) {
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
// Skip selection for orbit/pan tools - they don't select
|
|
1363
|
-
if (tool === 'orbit' || tool === 'pan' || tool === 'walk') {
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
// Measure tool now uses drag interaction (see mousedown/mousemove/mouseup)
|
|
1368
|
-
if (tool === 'measure') {
|
|
1369
|
-
return; // Skip click handling for measure tool
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
const now = Date.now();
|
|
1373
|
-
const timeSinceLastClick = now - lastClickTimeRef.current;
|
|
1374
|
-
const clickPos = { x, y };
|
|
1375
|
-
|
|
1376
|
-
if (lastClickPosRef.current &&
|
|
1377
|
-
timeSinceLastClick < 300 &&
|
|
1378
|
-
Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
|
|
1379
|
-
Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
|
|
1380
|
-
// Double-click - isolate element
|
|
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);
|
|
1385
|
-
}
|
|
1386
|
-
lastClickTimeRef.current = 0;
|
|
1387
|
-
lastClickPosRef.current = null;
|
|
1388
|
-
} else {
|
|
1389
|
-
// Single click - uses visibility filtering so only visible elements can be selected
|
|
1390
|
-
const pickResult = await renderer.pick(x, y, getPickOptions());
|
|
1391
|
-
|
|
1392
|
-
// Multi-selection with Ctrl/Cmd
|
|
1393
|
-
if (e.ctrlKey || e.metaKey) {
|
|
1394
|
-
if (pickResult) {
|
|
1395
|
-
toggleSelection(pickResult.expressId);
|
|
1396
|
-
}
|
|
1397
|
-
} else {
|
|
1398
|
-
handlePickForSelection(pickResult);
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
lastClickTimeRef.current = now;
|
|
1402
|
-
lastClickPosRef.current = clickPos;
|
|
1403
|
-
}
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
// Helper function to get approximate world position (for measurement tool)
|
|
1408
|
-
function getApproximateWorldPosition(
|
|
1409
|
-
geom: MeshData[] | null,
|
|
1410
|
-
entityId: number,
|
|
1411
|
-
_screenX: number,
|
|
1412
|
-
_screenY: number,
|
|
1413
|
-
_canvasWidth: number,
|
|
1414
|
-
_canvasHeight: number
|
|
1415
|
-
): { x: number; y: number; z: number } {
|
|
1416
|
-
return getEntityCenter(geom, entityId) || { x: 0, y: 0, z: 0 };
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
// Touch controls
|
|
1420
|
-
canvas.addEventListener('touchstart', async (e) => {
|
|
1421
|
-
e.preventDefault();
|
|
1422
|
-
touchState.touches = Array.from(e.touches);
|
|
1423
|
-
|
|
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) {
|
|
1430
|
-
touchState.lastCenter = {
|
|
1431
|
-
x: touchState.touches[0].clientX,
|
|
1432
|
-
y: touchState.touches[0].clientY,
|
|
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;
|
|
1441
|
-
|
|
1442
|
-
// Set orbit pivot to what user touches (same as mouse click behavior)
|
|
1443
|
-
const rect = canvas.getBoundingClientRect();
|
|
1444
|
-
const x = touchState.touches[0].clientX - rect.left;
|
|
1445
|
-
const y = touchState.touches[0].clientY - rect.top;
|
|
1446
|
-
|
|
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);
|
|
1451
|
-
if (center) {
|
|
1452
|
-
camera.setOrbitPivot(center);
|
|
1453
|
-
} else {
|
|
1454
|
-
camera.setOrbitPivot(null);
|
|
1455
|
-
}
|
|
1456
|
-
} else {
|
|
1457
|
-
camera.setOrbitPivot(null);
|
|
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
|
-
};
|
|
1465
|
-
} else if (touchState.touches.length === 2) {
|
|
1466
|
-
const dx = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
1467
|
-
const dy = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
1468
|
-
touchState.lastDistance = Math.sqrt(dx * dx + dy * dy);
|
|
1469
|
-
touchState.lastCenter = {
|
|
1470
|
-
x: (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2,
|
|
1471
|
-
y: (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2,
|
|
1472
|
-
};
|
|
1473
|
-
}
|
|
1474
|
-
});
|
|
1475
|
-
|
|
1476
|
-
canvas.addEventListener('touchmove', (e) => {
|
|
1477
|
-
e.preventDefault();
|
|
1478
|
-
touchState.touches = Array.from(e.touches);
|
|
1479
|
-
|
|
1480
|
-
if (touchState.touches.length === 1) {
|
|
1481
|
-
const dx = touchState.touches[0].clientX - touchState.lastCenter.x;
|
|
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
|
-
|
|
1491
|
-
camera.orbit(dx, dy, false);
|
|
1492
|
-
touchState.lastCenter = {
|
|
1493
|
-
x: touchState.touches[0].clientX,
|
|
1494
|
-
y: touchState.touches[0].clientY,
|
|
1495
|
-
};
|
|
1496
|
-
renderer.render({
|
|
1497
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
1498
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
1499
|
-
selectedId: selectedEntityIdRef.current,
|
|
1500
|
-
selectedModelIndex: selectedModelIndexRef.current,
|
|
1501
|
-
clearColor: clearColorRef.current,
|
|
1502
|
-
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1503
|
-
...sectionPlaneRef.current,
|
|
1504
|
-
min: sectionRangeRef.current?.min,
|
|
1505
|
-
max: sectionRangeRef.current?.max,
|
|
1506
|
-
} : undefined,
|
|
1507
|
-
});
|
|
1508
|
-
} else if (touchState.touches.length === 2) {
|
|
1509
|
-
const dx1 = touchState.touches[1].clientX - touchState.touches[0].clientX;
|
|
1510
|
-
const dy1 = touchState.touches[1].clientY - touchState.touches[0].clientY;
|
|
1511
|
-
const distance = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
|
1512
|
-
|
|
1513
|
-
const centerX = (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2;
|
|
1514
|
-
const centerY = (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2;
|
|
1515
|
-
const panDx = centerX - touchState.lastCenter.x;
|
|
1516
|
-
const panDy = centerY - touchState.lastCenter.y;
|
|
1517
|
-
camera.pan(panDx, panDy, false);
|
|
1518
|
-
|
|
1519
|
-
const zoomDelta = distance - touchState.lastDistance;
|
|
1520
|
-
const rect = canvas.getBoundingClientRect();
|
|
1521
|
-
camera.zoom(zoomDelta * 10, false, centerX - rect.left, centerY - rect.top, canvas.width, canvas.height);
|
|
1522
|
-
|
|
1523
|
-
touchState.lastDistance = distance;
|
|
1524
|
-
touchState.lastCenter = { x: centerX, y: centerY };
|
|
1525
|
-
renderer.render({
|
|
1526
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
1527
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
1528
|
-
selectedId: selectedEntityIdRef.current,
|
|
1529
|
-
selectedModelIndex: selectedModelIndexRef.current,
|
|
1530
|
-
clearColor: clearColorRef.current,
|
|
1531
|
-
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1532
|
-
...sectionPlaneRef.current,
|
|
1533
|
-
min: sectionRangeRef.current?.min,
|
|
1534
|
-
max: sectionRangeRef.current?.max,
|
|
1535
|
-
} : undefined,
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
});
|
|
1539
|
-
|
|
1540
|
-
canvas.addEventListener('touchend', async (e) => {
|
|
1541
|
-
e.preventDefault();
|
|
1542
|
-
const previousTouchCount = touchState.touches.length;
|
|
1543
|
-
const wasMultiTouch = touchState.multiTouch;
|
|
1544
|
-
touchState.touches = Array.from(e.touches);
|
|
1545
|
-
|
|
1546
|
-
if (touchState.touches.length === 0) {
|
|
1547
|
-
camera.stopInertia();
|
|
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;
|
|
1579
|
-
}
|
|
1580
|
-
});
|
|
1581
|
-
|
|
1582
|
-
// Keyboard controls
|
|
1583
|
-
const keyState: { [key: string]: boolean } = {};
|
|
1584
|
-
|
|
1585
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1586
|
-
if (document.activeElement?.tagName === 'INPUT' ||
|
|
1587
|
-
document.activeElement?.tagName === 'TEXTAREA') {
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
keyState[e.key.toLowerCase()] = true;
|
|
1592
|
-
|
|
1593
|
-
// Preset views - set view and re-render
|
|
1594
|
-
const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => {
|
|
1595
|
-
const rotation = coordinateInfoRef.current?.buildingRotation;
|
|
1596
|
-
camera.setPresetView(view, geometryBoundsRef.current, rotation);
|
|
533
|
+
},
|
|
534
|
+
toggleProjectionMode: () => {
|
|
535
|
+
camera.toggleProjectionMode();
|
|
1597
536
|
renderer.render({
|
|
1598
537
|
hiddenIds: hiddenEntitiesRef.current,
|
|
1599
538
|
isolatedIds: isolatedEntitiesRef.current,
|
|
@@ -1606,108 +545,19 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
1606
545
|
max: sectionRangeRef.current?.max,
|
|
1607
546
|
} : undefined,
|
|
1608
547
|
});
|
|
1609
|
-
updateCameraRotationRealtime(camera.getRotation());
|
|
1610
548
|
calculateScale();
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
if (e.key === '2') setViewAndRender('bottom');
|
|
1615
|
-
if (e.key === '3') setViewAndRender('front');
|
|
1616
|
-
if (e.key === '4') setViewAndRender('back');
|
|
1617
|
-
if (e.key === '5') setViewAndRender('left');
|
|
1618
|
-
if (e.key === '6') setViewAndRender('right');
|
|
1619
|
-
|
|
1620
|
-
// Frame selection (F) - zoom to fit selection, or fit all if nothing selected
|
|
1621
|
-
if (e.key === 'f' || e.key === 'F') {
|
|
1622
|
-
const selectedId = selectedEntityIdRef.current;
|
|
1623
|
-
if (selectedId !== null) {
|
|
1624
|
-
// Frame selection - zoom to fit selected element
|
|
1625
|
-
const bounds = getEntityBounds(geometryRef.current, selectedId);
|
|
1626
|
-
if (bounds) {
|
|
1627
|
-
camera.frameBounds(bounds.min, bounds.max, 300);
|
|
1628
|
-
}
|
|
1629
|
-
} else {
|
|
1630
|
-
// No selection - fit all
|
|
1631
|
-
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
1632
|
-
}
|
|
1633
|
-
calculateScale();
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
// Home view (H) - reset to isometric
|
|
1637
|
-
if (e.key === 'h' || e.key === 'H') {
|
|
1638
|
-
camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
|
|
1639
|
-
calculateScale();
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
// Fit all / Zoom extents (Z)
|
|
1643
|
-
if (e.key === 'z' || e.key === 'Z') {
|
|
1644
|
-
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
1645
|
-
calculateScale();
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// Toggle first-person mode
|
|
1649
|
-
if (e.key === 'c' || e.key === 'C') {
|
|
1650
|
-
firstPersonModeRef.current = !firstPersonModeRef.current;
|
|
1651
|
-
camera.enableFirstPersonMode(firstPersonModeRef.current);
|
|
1652
|
-
}
|
|
1653
|
-
};
|
|
1654
|
-
|
|
1655
|
-
const handleKeyUp = (e: KeyboardEvent) => {
|
|
1656
|
-
keyState[e.key.toLowerCase()] = false;
|
|
1657
|
-
};
|
|
1658
|
-
|
|
1659
|
-
keyboardHandlersRef.current.handleKeyDown = handleKeyDown;
|
|
1660
|
-
keyboardHandlersRef.current.handleKeyUp = handleKeyUp;
|
|
1661
|
-
|
|
1662
|
-
const keyboardMove = () => {
|
|
1663
|
-
if (aborted) return;
|
|
1664
|
-
|
|
1665
|
-
let moved = false;
|
|
1666
|
-
const panSpeed = 5;
|
|
1667
|
-
const zoomSpeed = 0.1;
|
|
1668
|
-
|
|
1669
|
-
if (firstPersonModeRef.current) {
|
|
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; }
|
|
1675
|
-
} else {
|
|
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; }
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
if (moved) {
|
|
1684
|
-
renderer.render({
|
|
1685
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
1686
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
1687
|
-
selectedId: selectedEntityIdRef.current,
|
|
1688
|
-
selectedModelIndex: selectedModelIndexRef.current,
|
|
1689
|
-
clearColor: clearColorRef.current,
|
|
1690
|
-
sectionPlane: activeToolRef.current === 'section' ? {
|
|
1691
|
-
...sectionPlaneRef.current,
|
|
1692
|
-
min: sectionRangeRef.current?.min,
|
|
1693
|
-
max: sectionRangeRef.current?.max,
|
|
1694
|
-
} : undefined,
|
|
1695
|
-
});
|
|
1696
|
-
}
|
|
1697
|
-
requestAnimationFrame(keyboardMove);
|
|
1698
|
-
};
|
|
1699
|
-
|
|
1700
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
1701
|
-
window.addEventListener('keyup', handleKeyUp);
|
|
1702
|
-
keyboardMove();
|
|
549
|
+
},
|
|
550
|
+
getProjectionMode: () => camera.getProjectionMode(),
|
|
551
|
+
});
|
|
1703
552
|
|
|
553
|
+
// ResizeObserver
|
|
1704
554
|
resizeObserver = new ResizeObserver(() => {
|
|
1705
555
|
if (aborted) return;
|
|
1706
556
|
const rect = canvas.getBoundingClientRect();
|
|
1707
557
|
// Use same WebGPU alignment as initialization
|
|
1708
|
-
const
|
|
1709
|
-
const
|
|
1710
|
-
renderer.resize(
|
|
558
|
+
const w = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
|
|
559
|
+
const h = Math.max(1, Math.floor(rect.height));
|
|
560
|
+
renderer.resize(w, h);
|
|
1711
561
|
renderer.render({
|
|
1712
562
|
hiddenIds: hiddenEntitiesRef.current,
|
|
1713
563
|
isolatedIds: isolatedEntitiesRef.current,
|
|
@@ -1723,6 +573,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
1723
573
|
});
|
|
1724
574
|
resizeObserver.observe(canvas);
|
|
1725
575
|
|
|
576
|
+
// Initial render
|
|
1726
577
|
renderer.render({
|
|
1727
578
|
hiddenIds: hiddenEntitiesRef.current,
|
|
1728
579
|
isolatedIds: isolatedEntitiesRef.current,
|
|
@@ -1745,17 +596,6 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
1745
596
|
if (resizeObserver) {
|
|
1746
597
|
resizeObserver.disconnect();
|
|
1747
598
|
}
|
|
1748
|
-
if (keyboardHandlersRef.current.handleKeyDown) {
|
|
1749
|
-
window.removeEventListener('keydown', keyboardHandlersRef.current.handleKeyDown);
|
|
1750
|
-
}
|
|
1751
|
-
if (keyboardHandlersRef.current.handleKeyUp) {
|
|
1752
|
-
window.removeEventListener('keyup', keyboardHandlersRef.current.handleKeyUp);
|
|
1753
|
-
}
|
|
1754
|
-
// Cancel pending raycast requests
|
|
1755
|
-
if (measureRaycastFrameRef.current !== null) {
|
|
1756
|
-
cancelAnimationFrame(measureRaycastFrameRef.current);
|
|
1757
|
-
measureRaycastFrameRef.current = null;
|
|
1758
|
-
}
|
|
1759
599
|
setIsInitialized(false);
|
|
1760
600
|
rendererRef.current = null;
|
|
1761
601
|
// Clear BCF global refs to prevent memory leaks
|
|
@@ -1766,438 +606,176 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
|
|
|
1766
606
|
// Adding selectedEntityId would destroy/recreate the renderer on every selection change
|
|
1767
607
|
}, [setSelectedEntityId]);
|
|
1768
608
|
|
|
1769
|
-
//
|
|
1770
|
-
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
const cameraFittedRef = useRef<boolean>(false);
|
|
1775
|
-
const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
|
|
1776
|
-
|
|
1777
|
-
// Render throttling during streaming
|
|
1778
|
-
const lastStreamRenderTimeRef = useRef<number>(0);
|
|
1779
|
-
const STREAM_RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
|
|
609
|
+
// ===== Drawing 2D state for render updates =====
|
|
610
|
+
const drawing2D = useViewerStore((s) => s.drawing2D);
|
|
611
|
+
const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
|
|
612
|
+
|
|
613
|
+
// ===== Streaming progress =====
|
|
1780
614
|
const progress = useViewerStore((state) => state.progress);
|
|
1781
615
|
const isStreaming = progress !== null && progress.percent < 100;
|
|
1782
616
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
};
|
|
1849
|
-
} else if (!isIncremental && currentLength !== lastLength) {
|
|
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
|
-
}
|
|
1879
|
-
} else if (currentLength === lastLength) {
|
|
1880
|
-
// No geometry change - but check if we need to update bounds when streaming completes
|
|
1881
|
-
if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
|
|
1882
|
-
const shiftedBounds = coordinateInfo.shiftedBounds;
|
|
1883
|
-
const newMaxSize = Math.max(
|
|
1884
|
-
shiftedBounds.max.x - shiftedBounds.min.x,
|
|
1885
|
-
shiftedBounds.max.y - shiftedBounds.min.y,
|
|
1886
|
-
shiftedBounds.max.z - shiftedBounds.min.z
|
|
1887
|
-
);
|
|
1888
|
-
|
|
1889
|
-
if (newMaxSize > 0 && Number.isFinite(newMaxSize)) {
|
|
1890
|
-
// Only refit camera for LARGE models (>1000 meshes) where geometry streamed in multiple batches
|
|
1891
|
-
// Small models complete in one batch, so their initial camera fit is already correct
|
|
1892
|
-
const isLargeModel = geometry.length > 1000;
|
|
1893
|
-
|
|
1894
|
-
if (isLargeModel) {
|
|
1895
|
-
const oldBounds = geometryBoundsRef.current;
|
|
1896
|
-
const oldMaxSize = Math.max(
|
|
1897
|
-
oldBounds.max.x - oldBounds.min.x,
|
|
1898
|
-
oldBounds.max.y - oldBounds.min.y,
|
|
1899
|
-
oldBounds.max.z - oldBounds.min.z
|
|
1900
|
-
);
|
|
1901
|
-
|
|
1902
|
-
// Refit camera if bounds expanded significantly (>10% larger)
|
|
1903
|
-
// This handles skyscrapers where upper floors arrive in later batches
|
|
1904
|
-
const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
|
|
1905
|
-
|
|
1906
|
-
if (boundsExpanded) {
|
|
1907
|
-
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
// Always update bounds for accurate zoom-to-fit, home view, etc.
|
|
1912
|
-
geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
|
|
1913
|
-
finalBoundsRefittedRef.current = true;
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
return;
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
// For incremental batches: update reference and continue to add new meshes
|
|
1920
|
-
if (isIncremental) {
|
|
1921
|
-
lastGeometryRef.current = geometry;
|
|
1922
|
-
} else if (lastGeometryRef.current === null) {
|
|
1923
|
-
lastGeometryRef.current = geometry;
|
|
1924
|
-
}
|
|
1925
|
-
|
|
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
|
|
1932
|
-
|
|
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.
|
|
1936
|
-
const newMeshes: MeshData[] = [];
|
|
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)) {
|
|
1946
|
-
newMeshes.push(meshData);
|
|
1947
|
-
processedMeshIdsRef.current.add(compoundKey);
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
if (newMeshes.length > 0) {
|
|
1952
|
-
// Batch meshes by color for efficient rendering (reduces draw calls from N to ~100-500)
|
|
1953
|
-
// This dramatically improves performance for large models (50K+ meshes)
|
|
1954
|
-
const pipeline = renderer.getPipeline();
|
|
1955
|
-
if (pipeline) {
|
|
1956
|
-
// Use batched rendering - groups meshes by color into single draw calls
|
|
1957
|
-
// Pass isStreaming flag to enable throttled batch rebuilding (reduces O(N²) cost)
|
|
1958
|
-
(scene as any).appendToBatches(newMeshes, device, pipeline, isStreaming);
|
|
1959
|
-
|
|
1960
|
-
// Note: addMeshData is now called inside appendToBatches, no need to duplicate
|
|
1961
|
-
} else {
|
|
1962
|
-
// Fallback: add individual meshes if pipeline not ready
|
|
1963
|
-
for (const meshData of newMeshes) {
|
|
1964
|
-
const vertexCount = meshData.positions.length / 3;
|
|
1965
|
-
const interleaved = new Float32Array(vertexCount * 6);
|
|
1966
|
-
for (let i = 0; i < vertexCount; i++) {
|
|
1967
|
-
const base = i * 6;
|
|
1968
|
-
const posBase = i * 3;
|
|
1969
|
-
interleaved[base] = meshData.positions[posBase];
|
|
1970
|
-
interleaved[base + 1] = meshData.positions[posBase + 1];
|
|
1971
|
-
interleaved[base + 2] = meshData.positions[posBase + 2];
|
|
1972
|
-
interleaved[base + 3] = meshData.normals[posBase];
|
|
1973
|
-
interleaved[base + 4] = meshData.normals[posBase + 1];
|
|
1974
|
-
interleaved[base + 5] = meshData.normals[posBase + 2];
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
const vertexBuffer = device.createBuffer({
|
|
1978
|
-
size: interleaved.byteLength,
|
|
1979
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
1980
|
-
});
|
|
1981
|
-
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
1982
|
-
|
|
1983
|
-
const indexBuffer = device.createBuffer({
|
|
1984
|
-
size: meshData.indices.byteLength,
|
|
1985
|
-
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
1986
|
-
});
|
|
1987
|
-
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
1988
|
-
|
|
1989
|
-
scene.addMesh({
|
|
1990
|
-
expressId: meshData.expressId,
|
|
1991
|
-
vertexBuffer,
|
|
1992
|
-
indexBuffer,
|
|
1993
|
-
indexCount: meshData.indices.length,
|
|
1994
|
-
transform: MathUtils.identity(),
|
|
1995
|
-
color: meshData.color,
|
|
1996
|
-
});
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
// Invalidate caches when new geometry is added
|
|
2001
|
-
renderer.clearCaches();
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
lastGeometryLengthRef.current = currentLength;
|
|
2005
|
-
|
|
2006
|
-
// Fit camera and store bounds
|
|
2007
|
-
// IMPORTANT: Fit camera immediately when we have valid bounds to avoid starting inside model
|
|
2008
|
-
// The default camera position (50, 50, 100) is inside most models that are shifted to origin
|
|
2009
|
-
if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
|
|
2010
|
-
const shiftedBounds = coordinateInfo.shiftedBounds;
|
|
2011
|
-
const maxSize = Math.max(
|
|
2012
|
-
shiftedBounds.max.x - shiftedBounds.min.x,
|
|
2013
|
-
shiftedBounds.max.y - shiftedBounds.min.y,
|
|
2014
|
-
shiftedBounds.max.z - shiftedBounds.min.z
|
|
2015
|
-
);
|
|
2016
|
-
// Fit camera immediately when we have valid bounds
|
|
2017
|
-
// For streaming: the first batch already has complete bounds from coordinate handler
|
|
2018
|
-
// (bounds are calculated from ALL geometry before streaming starts)
|
|
2019
|
-
// Waiting for streaming to complete causes the camera to start inside the model
|
|
2020
|
-
if (maxSize > 0 && Number.isFinite(maxSize)) {
|
|
2021
|
-
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
2022
|
-
geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
|
|
2023
|
-
cameraFittedRef.current = true;
|
|
2024
|
-
}
|
|
2025
|
-
} else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
|
|
2026
|
-
// Fallback: calculate bounds from geometry array (only when streaming is complete)
|
|
2027
|
-
// This ensures we have complete bounds before fitting camera
|
|
2028
|
-
const fallbackBounds = {
|
|
2029
|
-
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
2030
|
-
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
2031
|
-
};
|
|
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
|
-
|
|
2037
|
-
for (const meshData of geometry) {
|
|
2038
|
-
for (let i = 0; i < meshData.positions.length; i += 3) {
|
|
2039
|
-
const x = meshData.positions[i];
|
|
2040
|
-
const y = meshData.positions[i + 1];
|
|
2041
|
-
const z = meshData.positions[i + 2];
|
|
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) {
|
|
2046
|
-
fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
|
|
2047
|
-
fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
|
|
2048
|
-
fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
|
|
2049
|
-
fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
|
|
2050
|
-
fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
|
|
2051
|
-
fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
const maxSize = Math.max(
|
|
2057
|
-
fallbackBounds.max.x - fallbackBounds.min.x,
|
|
2058
|
-
fallbackBounds.max.y - fallbackBounds.min.y,
|
|
2059
|
-
fallbackBounds.max.z - fallbackBounds.min.z
|
|
2060
|
-
);
|
|
2061
|
-
|
|
2062
|
-
if (fallbackBounds.min.x !== Infinity && maxSize > 0 && Number.isFinite(maxSize)) {
|
|
2063
|
-
renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
|
|
2064
|
-
geometryBoundsRef.current = fallbackBounds;
|
|
2065
|
-
cameraFittedRef.current = true;
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
// Note: Background instancing conversion removed
|
|
2070
|
-
// Regular MeshData meshes are rendered directly with their correct positions
|
|
2071
|
-
// Instancing conversion would require preserving actual mesh transforms, which is complex
|
|
2072
|
-
// For now, we render regular meshes directly (fast enough for most cases)
|
|
2073
|
-
|
|
2074
|
-
// Render throttling: During streaming, only render every STREAM_RENDER_THROTTLE_MS
|
|
2075
|
-
// This prevents rendering 28K+ meshes from blocking WASM batch processing
|
|
2076
|
-
const now = Date.now();
|
|
2077
|
-
const timeSinceLastRender = now - lastStreamRenderTimeRef.current;
|
|
2078
|
-
const shouldRender = !isStreaming || timeSinceLastRender >= STREAM_RENDER_THROTTLE_MS;
|
|
2079
|
-
|
|
2080
|
-
if (shouldRender) {
|
|
2081
|
-
renderer.render();
|
|
2082
|
-
lastStreamRenderTimeRef.current = now;
|
|
2083
|
-
}
|
|
2084
|
-
}, [geometry, coordinateInfo, isInitialized, isStreaming]);
|
|
2085
|
-
|
|
2086
|
-
// Force render when streaming completes (progress goes from <100% to 100% or null)
|
|
2087
|
-
const prevIsStreamingRef = useRef(isStreaming);
|
|
2088
|
-
useEffect(() => {
|
|
2089
|
-
const renderer = rendererRef.current;
|
|
2090
|
-
if (!renderer || !isInitialized) return;
|
|
2091
|
-
|
|
2092
|
-
// If streaming just completed (was streaming, now not), rebuild pending batches and render
|
|
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
|
-
|
|
2103
|
-
renderer.render();
|
|
2104
|
-
lastStreamRenderTimeRef.current = Date.now();
|
|
2105
|
-
}
|
|
2106
|
-
prevIsStreamingRef.current = isStreaming;
|
|
2107
|
-
}, [isStreaming, isInitialized]);
|
|
2108
|
-
|
|
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;
|
|
617
|
+
// Mouse isDragging proxy ref for animation loop
|
|
618
|
+
// The animation loop reads this to decide whether to update rotation
|
|
619
|
+
// We wrap mouseStateRef to provide a { current: boolean } interface
|
|
620
|
+
const mouseIsDraggingRef = useRef(false);
|
|
621
|
+
// Sync on every render since mouseState is mutated directly by event handlers
|
|
622
|
+
mouseIsDraggingRef.current = mouseStateRef.current.isDragging;
|
|
623
|
+
|
|
624
|
+
// ===== Extracted hooks =====
|
|
625
|
+
useMouseControls({
|
|
626
|
+
canvasRef,
|
|
627
|
+
rendererRef,
|
|
628
|
+
isInitialized,
|
|
629
|
+
mouseStateRef,
|
|
630
|
+
activeToolRef,
|
|
631
|
+
activeMeasurementRef,
|
|
632
|
+
snapEnabledRef,
|
|
633
|
+
edgeLockStateRef,
|
|
634
|
+
measurementConstraintEdgeRef,
|
|
635
|
+
hiddenEntitiesRef,
|
|
636
|
+
isolatedEntitiesRef,
|
|
637
|
+
selectedEntityIdRef,
|
|
638
|
+
selectedModelIndexRef,
|
|
639
|
+
clearColorRef,
|
|
640
|
+
sectionPlaneRef,
|
|
641
|
+
sectionRangeRef,
|
|
642
|
+
geometryRef,
|
|
643
|
+
measureRaycastPendingRef,
|
|
644
|
+
measureRaycastFrameRef,
|
|
645
|
+
lastMeasureRaycastDurationRef,
|
|
646
|
+
lastHoverSnapTimeRef,
|
|
647
|
+
lastHoverCheckRef,
|
|
648
|
+
hoverTooltipsEnabledRef,
|
|
649
|
+
lastRenderTimeRef,
|
|
650
|
+
renderPendingRef,
|
|
651
|
+
lastClickTimeRef,
|
|
652
|
+
lastClickPosRef,
|
|
653
|
+
lastCameraStateRef,
|
|
654
|
+
handlePickForSelection: (pickResult) => handlePickForSelectionRef.current(pickResult),
|
|
655
|
+
setHoverState,
|
|
656
|
+
clearHover,
|
|
657
|
+
openContextMenu,
|
|
658
|
+
startMeasurement,
|
|
659
|
+
updateMeasurement,
|
|
660
|
+
finalizeMeasurement,
|
|
661
|
+
setSnapTarget,
|
|
662
|
+
setSnapVisualization,
|
|
663
|
+
setEdgeLock,
|
|
664
|
+
updateEdgeLockPosition,
|
|
665
|
+
clearEdgeLock,
|
|
666
|
+
incrementEdgeLockStrength,
|
|
667
|
+
setMeasurementConstraintEdge,
|
|
668
|
+
updateConstraintActiveAxis,
|
|
669
|
+
updateMeasurementScreenCoords,
|
|
670
|
+
updateCameraRotationRealtime,
|
|
671
|
+
toggleSelection,
|
|
672
|
+
calculateScale,
|
|
673
|
+
getPickOptions,
|
|
674
|
+
hasPendingMeasurements,
|
|
675
|
+
HOVER_SNAP_THROTTLE_MS,
|
|
676
|
+
SLOW_RAYCAST_THRESHOLD_MS,
|
|
677
|
+
hoverThrottleMs,
|
|
678
|
+
RENDER_THROTTLE_MS_SMALL,
|
|
679
|
+
RENDER_THROTTLE_MS_LARGE,
|
|
680
|
+
RENDER_THROTTLE_MS_HUGE,
|
|
681
|
+
});
|
|
2119
682
|
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
683
|
+
useTouchControls({
|
|
684
|
+
canvasRef,
|
|
685
|
+
rendererRef,
|
|
686
|
+
isInitialized,
|
|
687
|
+
touchStateRef,
|
|
688
|
+
activeToolRef,
|
|
689
|
+
hiddenEntitiesRef,
|
|
690
|
+
isolatedEntitiesRef,
|
|
691
|
+
selectedEntityIdRef,
|
|
692
|
+
selectedModelIndexRef,
|
|
693
|
+
clearColorRef,
|
|
694
|
+
sectionPlaneRef,
|
|
695
|
+
sectionRangeRef,
|
|
696
|
+
geometryRef,
|
|
697
|
+
handlePickForSelection: (pickResult) => handlePickForSelectionRef.current(pickResult),
|
|
698
|
+
getPickOptions,
|
|
699
|
+
});
|
|
2123
700
|
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
701
|
+
useKeyboardControls({
|
|
702
|
+
rendererRef,
|
|
703
|
+
isInitialized,
|
|
704
|
+
keyboardHandlersRef,
|
|
705
|
+
firstPersonModeRef,
|
|
706
|
+
geometryBoundsRef,
|
|
707
|
+
coordinateInfoRef,
|
|
708
|
+
geometryRef,
|
|
709
|
+
selectedEntityIdRef,
|
|
710
|
+
hiddenEntitiesRef,
|
|
711
|
+
isolatedEntitiesRef,
|
|
712
|
+
selectedModelIndexRef,
|
|
713
|
+
clearColorRef,
|
|
714
|
+
activeToolRef,
|
|
715
|
+
sectionPlaneRef,
|
|
716
|
+
sectionRangeRef,
|
|
717
|
+
updateCameraRotationRealtime,
|
|
718
|
+
calculateScale,
|
|
719
|
+
});
|
|
2130
720
|
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
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
|
-
}
|
|
721
|
+
useAnimationLoop({
|
|
722
|
+
canvasRef,
|
|
723
|
+
rendererRef,
|
|
724
|
+
isInitialized,
|
|
725
|
+
animationFrameRef,
|
|
726
|
+
lastFrameTimeRef,
|
|
727
|
+
mouseIsDraggingRef,
|
|
728
|
+
activeToolRef,
|
|
729
|
+
hiddenEntitiesRef,
|
|
730
|
+
isolatedEntitiesRef,
|
|
731
|
+
selectedEntityIdRef,
|
|
732
|
+
selectedModelIndexRef,
|
|
733
|
+
clearColorRef,
|
|
734
|
+
sectionPlaneRef,
|
|
735
|
+
sectionRangeRef,
|
|
736
|
+
lastCameraStateRef,
|
|
737
|
+
updateCameraRotationRealtime,
|
|
738
|
+
calculateScale,
|
|
739
|
+
updateMeasurementScreenCoords,
|
|
740
|
+
hasPendingMeasurements,
|
|
741
|
+
});
|
|
2164
742
|
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
min: sectionRangeRef.current?.min,
|
|
2176
|
-
max: sectionRangeRef.current?.max,
|
|
2177
|
-
} : undefined,
|
|
2178
|
-
});
|
|
2179
|
-
}, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay]);
|
|
743
|
+
useGeometryStreaming({
|
|
744
|
+
rendererRef,
|
|
745
|
+
isInitialized,
|
|
746
|
+
geometry,
|
|
747
|
+
coordinateInfo,
|
|
748
|
+
isStreaming,
|
|
749
|
+
geometryBoundsRef,
|
|
750
|
+
pendingColorUpdates,
|
|
751
|
+
clearPendingColorUpdates,
|
|
752
|
+
});
|
|
2180
753
|
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
754
|
+
useRenderUpdates({
|
|
755
|
+
rendererRef,
|
|
756
|
+
isInitialized,
|
|
757
|
+
theme,
|
|
758
|
+
clearColorRef,
|
|
759
|
+
hiddenEntities,
|
|
760
|
+
isolatedEntities,
|
|
761
|
+
selectedEntityId,
|
|
762
|
+
selectedEntityIds,
|
|
763
|
+
selectedModelIndex,
|
|
764
|
+
activeTool,
|
|
765
|
+
sectionPlane,
|
|
766
|
+
sectionRange,
|
|
767
|
+
coordinateInfo,
|
|
768
|
+
hiddenEntitiesRef,
|
|
769
|
+
isolatedEntitiesRef,
|
|
770
|
+
selectedEntityIdRef,
|
|
771
|
+
selectedModelIndexRef,
|
|
772
|
+
selectedEntityIdsRef,
|
|
773
|
+
sectionPlaneRef,
|
|
774
|
+
sectionRangeRef,
|
|
775
|
+
activeToolRef,
|
|
776
|
+
drawing2D,
|
|
777
|
+
show3DOverlay,
|
|
778
|
+
});
|
|
2201
779
|
|
|
2202
780
|
return (
|
|
2203
781
|
<canvas
|