From b620c9d5027b2a032a35906d74827ecabee4ce0d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 8 Jun 2026 20:34:09 -0400 Subject: [PATCH] Fix camera floorplan navigation sync (#387) --- .../editor/custom-camera-controls.tsx | 172 ++++++++++++------ .../src/components/editor/floorplan-panel.tsx | 46 +++-- 2 files changed, 148 insertions(+), 70 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index f8508dcc..7dd50917 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -32,11 +32,6 @@ const tempSize = new Vector3() const tempTarget = new Vector3() const syncTarget = new Vector3() const syncSpherical = new Spherical() -const keyboardPanPosition = new Vector3() -const keyboardPanTarget = new Vector3() -const keyboardPanScreenRight = new Vector3() -const keyboardPanScreenUp = new Vector3() -const keyboardPanDelta = new Vector3() const keyboardPanSpherical = new Spherical() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 @@ -57,6 +52,13 @@ type NavigationCameraPoseSnapshot = { azimuth: number viewWidth: number } +type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & { + publishOnComplete: boolean +} +type CameraViewWidthUpdate = + | { type: 'distance'; distance: number; viewWidth: number } + | { type: 'zoom'; viewWidth: number; zoom: number } + | { type: 'none'; viewWidth: number } function writeVectorTuple(tuple: [number, number, number], vector: Vector3) { tuple[0] = vector.x @@ -168,6 +170,34 @@ function getAngleDeltaRadians(a: number, b: number) { return Math.atan2(Math.sin(a - b), Math.cos(a - b)) } +function nearestEquivalentRadians(angle: number, reference: number) { + return reference + getAngleDeltaRadians(angle, reference) +} + +function clampFinite(value: number, min: number, max: number) { + const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY + const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY + return Math.min(Math.max(value, resolvedMin), resolvedMax) +} + +function clampCameraControlDistance(control: CameraControlsImpl, distance: number) { + const bounds = control as { minDistance?: number; maxDistance?: number } + return clampFinite( + distance, + bounds.minDistance ?? Number.NEGATIVE_INFINITY, + bounds.maxDistance ?? Number.POSITIVE_INFINITY, + ) +} + +function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) { + const bounds = control as { minZoom?: number; maxZoom?: number } + return clampFinite( + zoom, + bounds.minZoom ?? Number.NEGATIVE_INFINITY, + bounds.maxZoom ?? Number.POSITIVE_INFINITY, + ) +} + function isCameraAtNavigationPose( pose: NavigationCameraPoseSnapshot, target: Vector3, @@ -206,21 +236,45 @@ function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) { return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null } -function applyCameraViewWidth( +function resolveCameraViewWidthUpdate( control: CameraControlsImpl, camera: Camera, viewWidth: number, size: CameraViewportSize, -) { +): CameraViewWidthUpdate { const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size) if (nextDistance !== null) { - control.dollyTo(nextDistance, true) - return + const appliedDistance = clampCameraControlDistance(control, nextDistance) + return { + type: 'distance', + distance: appliedDistance, + viewWidth: getCameraViewWidth(camera, appliedDistance, size), + } } const nextZoom = getCameraZoomForViewWidth(camera, viewWidth) if (nextZoom !== null) { - control.zoomTo(nextZoom, true) + const appliedZoom = clampCameraControlZoom(control, nextZoom) + if (isOrthographicCamera(camera)) { + return { + type: 'zoom', + zoom: appliedZoom, + viewWidth: Math.max(0.001, (camera.right - camera.left) / Math.max(appliedZoom, 0.001)), + } + } + } + + return { type: 'none', viewWidth } +} + +function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) { + if (update.type === 'distance') { + control.dollyTo(update.distance, true) + return + } + + if (update.type === 'zoom') { + control.zoomTo(update.zoom, true) } } @@ -308,10 +362,13 @@ export const CustomCameraControls = () => { const currentLevelId = selection.levelId const firstLoad = useRef(true) const lastPublishedNavigationSync = useRef(null) - const pendingFloorplanNavigationPose = useRef(null) + const pendingFloorplanNavigationPose = useRef(null) const lastApplied2dNavigationRevision = useRef(0) const maxPolarAngle = !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE + const clearPendingFloorplanNavigationPose = useCallback(() => { + pendingFloorplanNavigationPose.current = null + }, []) const camera = useThree((state) => state.camera) const gl = useThree((state) => state.gl) @@ -336,11 +393,19 @@ export const CustomCameraControls = () => { if (!controls.current) return if (firstLoad.current) { firstLoad.current = false + clearPendingFloorplanNavigationPose() controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) } controls.current.getTarget(currentTarget) + clearPendingFloorplanNavigationPose() controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) - }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) + }, [ + clearPendingFloorplanNavigationPose, + currentLevelId, + isPreviewMode, + isFirstPersonMode, + isRestoringFirstPersonPose, + ]) useEffect(() => { if (isFirstPersonMode || !controls.current) return @@ -368,6 +433,7 @@ export const CustomCameraControls = () => { controls.current.getTarget(tempTarget) tempDelta.copy(tempCenter).sub(tempTarget) + clearPendingFloorplanNavigationPose() controls.current.setLookAt( tempPosition.x + tempDelta.x, tempPosition.y + tempDelta.y, @@ -378,7 +444,7 @@ export const CustomCameraControls = () => { true, ) }, - [isPreviewMode, isFirstPersonMode], + [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode], ) useEffect(() => { @@ -397,14 +463,24 @@ export const CustomCameraControls = () => { if (!control) return lastApplied2dNavigationRevision.current = pose.revision + const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle) + const viewWidthUpdate = resolveCameraViewWidthUpdate( + control, + camera, + pose.viewWidth, + viewportSize, + ) pendingFloorplanNavigationPose.current = { target: [...pose.target], - azimuth: pose.azimuth, - viewWidth: pose.viewWidth, + azimuth: targetAzimuth, + viewWidth: viewWidthUpdate.viewWidth, + publishOnComplete: + Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >= + NAVIGATION_SYNC_VIEW_WIDTH_EPSILON, } control.moveTo(pose.target[0], pose.target[1], pose.target[2], true) - control.rotateTo(pose.azimuth, control.polarAngle, true) - applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize) + control.rotateTo(targetAzimuth, control.polarAngle, true) + applyCameraViewWidth(control, viewWidthUpdate) }) }, [camera, isFirstPersonMode, viewportSize]) @@ -424,6 +500,18 @@ export const CustomCameraControls = () => { ) { lastPublishedNavigationSync.current = pendingFloorplanPose pendingFloorplanNavigationPose.current = null + if (pendingFloorplanPose.publishOnComplete) { + useEditor.getState().publishNavigationSyncPose({ + source: '3d', + target: [ + pendingFloorplanPose.target[0], + pendingFloorplanPose.target[1], + pendingFloorplanPose.target[2], + ], + azimuth: pendingFloorplanPose.azimuth, + viewWidth: pendingFloorplanPose.viewWidth, + }) + } } return } @@ -476,32 +564,6 @@ export const CustomCameraControls = () => { if (horizontal === 0 && vertical === 0) return const control = controls.current - control.getPosition(keyboardPanPosition) - control.getTarget(keyboardPanTarget) - - camera.updateMatrixWorld() - keyboardPanScreenRight.setFromMatrixColumn(camera.matrixWorld, 0) - keyboardPanScreenRight.y = 0 - keyboardPanScreenUp.setFromMatrixColumn(camera.matrixWorld, 1) - keyboardPanScreenUp.y = 0 - - if (keyboardPanScreenRight.lengthSq() < 1e-6) { - keyboardPanScreenRight.set(1, 0, 0) - } else { - keyboardPanScreenRight.normalize() - } - - if (keyboardPanScreenUp.lengthSq() < 1e-6) { - keyboardPanScreenUp.copy(keyboardPanTarget).sub(keyboardPanPosition) - keyboardPanScreenUp.y = 0 - if (keyboardPanScreenUp.lengthSq() < 1e-6) { - keyboardPanScreenUp.set(0, 0, -1) - } else { - keyboardPanScreenUp.normalize() - } - } else { - keyboardPanScreenUp.normalize() - } control.getSpherical(keyboardPanSpherical, false) const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize) @@ -511,21 +573,9 @@ export const CustomCameraControls = () => { ) const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical) - keyboardPanDelta - .set(0, 0, 0) - .addScaledVector(keyboardPanScreenRight, horizontal * step) - .addScaledVector(keyboardPanScreenUp, vertical * step) - pendingFloorplanNavigationPose.current = null - control.setLookAt( - keyboardPanPosition.x + keyboardPanDelta.x, - keyboardPanPosition.y, - keyboardPanPosition.z + keyboardPanDelta.z, - keyboardPanTarget.x + keyboardPanDelta.x, - keyboardPanTarget.y, - keyboardPanTarget.z + keyboardPanDelta.z, - false, - ) + if (horizontal !== 0) control.truck(horizontal * step, 0, true) + if (vertical !== 0) control.forward(vertical * step, true) }) // Configure mouse buttons based on control mode and camera mode @@ -991,6 +1041,7 @@ export const CustomCameraControls = () => { if (!node?.camera) return const { position, target } = node.camera + clearPendingFloorplanNavigationPose() controls.current.setLookAt( position[0], position[1], @@ -1011,6 +1062,7 @@ export const CustomCameraControls = () => { // Otherwise, go to top view (0°) const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 + clearPendingFloorplanNavigationPose() controls.current.rotatePolarTo(targetAngle, true) } @@ -1023,6 +1075,7 @@ export const CustomCameraControls = () => { const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) const target = rounded - Math.PI / 2 + clearPendingFloorplanNavigationPose() controls.current.rotateTo(target, currentPolar, true) } @@ -1035,6 +1088,7 @@ export const CustomCameraControls = () => { const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) const target = rounded + Math.PI / 2 + clearPendingFloorplanNavigationPose() controls.current.rotateTo(target, currentPolar, true) } @@ -1046,6 +1100,7 @@ export const CustomCameraControls = () => { if (isFirstPersonMode || !controls.current || isPreviewMode) return if (!bounds) { // Restore default framing pose when no bounds were computed. + clearPendingFloorplanNavigationPose() controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) return } @@ -1056,6 +1111,7 @@ export const CustomCameraControls = () => { const maxExtent = Math.max(w, d) const distance = Math.max(maxExtent * 1.4, 15) const height = Math.max(maxExtent * 0.8, 10) + clearPendingFloorplanNavigationPose() controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true) } @@ -1076,7 +1132,7 @@ export const CustomCameraControls = () => { emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) emitter.off('camera-controls:fit-scene', handleFitScene) } - }, [focusNode, isPreviewMode, isFirstPersonMode]) + }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { useViewer.getState().setCameraDragging(true) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index c71a62b8..39a8d880 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -231,6 +231,7 @@ type FloorplanViewport = { type FloorplanNavigationViewOptions = { smooth?: boolean + clampViewWidth?: boolean } type FloorplanViewAnimationTarget = { @@ -832,6 +833,26 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } +function resolveFloorplanViewWidth( + requestedWidth: number, + currentWidth: number, + fittedViewport: FloorplanViewport | null, + clampViewWidth: boolean, +) { + if (!(Number.isFinite(requestedWidth) && requestedWidth > 0)) { + return currentWidth + } + + if (!clampViewWidth || !fittedViewport) { + return Math.max(0.001, requestedWidth) + } + + const minWidth = fittedViewport.width * MIN_VIEWPORT_WIDTH_RATIO + const maxWidth = fittedViewport.width * MAX_VIEWPORT_WIDTH_RATIO + + return clamp(requestedWidth, Math.min(minWidth, currentWidth), Math.max(maxWidth, currentWidth)) +} + function roundPlanMeters(value: number) { return Math.round(value * 100) / 100 } @@ -6046,9 +6067,12 @@ export function FloorplanPanel() { FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg const centerSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg) const fitted = latestFittedViewportRef.current - const minWidth = fitted ? fitted.width * MIN_VIEWPORT_WIDTH_RATIO : 0.001 - const maxWidth = fitted ? fitted.width * MAX_VIEWPORT_WIDTH_RATIO : Number.POSITIVE_INFINITY - const nextWidth = clamp(viewWidth ?? currentViewport.width, minWidth, maxWidth) + const nextWidth = resolveFloorplanViewWidth( + viewWidth ?? currentViewport.width, + currentViewport.width, + fitted, + options?.clampViewWidth !== false, + ) const nextViewport = { centerX: centerSvg.x, @@ -6101,7 +6125,9 @@ export function FloorplanPanel() { buildingRotationY, ) - applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth) + applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth, { + clampViewWidth: false, + }) }, [applyFloorplanNavigationView, buildingPosition, buildingRotationY], ) @@ -6387,9 +6413,6 @@ export function FloorplanPanel() { viewBox, ]) - const minViewportWidth = fittedViewport.width * MIN_VIEWPORT_WIDTH_RATIO - const maxViewportWidth = fittedViewport.width * MAX_VIEWPORT_WIDTH_RATIO - const palette = useMemo( () => isDark @@ -7089,10 +7112,11 @@ export function FloorplanPanel() { const currentViewport = viewport ?? fittedViewport const currentViewBox = viewBox - const nextWidth = clamp( + const nextWidth = resolveFloorplanViewWidth( currentViewport.width * widthFactor, - minViewportWidth, - maxViewportWidth, + currentViewport.width, + fittedViewport, + true, ) const nextHeight = nextWidth / svgAspectRatio const normalizedX = (svgPoint.x - currentViewBox.minX) / currentViewBox.width @@ -7121,8 +7145,6 @@ export function FloorplanPanel() { fittedViewport, floorplanSceneRotationDeg, getSvgPointFromClientPoint, - maxViewportWidth, - minViewportWidth, publishFloorplanNavigationPose, smoothFloorplanNavigationView, svgAspectRatio,