Fix camera floorplan navigation sync (#387)

This commit is contained in:
Wassim SAMAD
2026-06-08 20:34:09 -04:00
committed by GitHub
parent 5d29363704
commit b620c9d502
2 changed files with 148 additions and 70 deletions
@@ -32,11 +32,6 @@ const tempSize = new Vector3()
const tempTarget = new Vector3() const tempTarget = new Vector3()
const syncTarget = new Vector3() const syncTarget = new Vector3()
const syncSpherical = new Spherical() 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 keyboardPanSpherical = new Spherical()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
@@ -57,6 +52,13 @@ type NavigationCameraPoseSnapshot = {
azimuth: number azimuth: number
viewWidth: 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) { function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
tuple[0] = vector.x 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)) 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( function isCameraAtNavigationPose(
pose: NavigationCameraPoseSnapshot, pose: NavigationCameraPoseSnapshot,
target: Vector3, 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 return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
} }
function applyCameraViewWidth( function resolveCameraViewWidthUpdate(
control: CameraControlsImpl, control: CameraControlsImpl,
camera: Camera, camera: Camera,
viewWidth: number, viewWidth: number,
size: CameraViewportSize, size: CameraViewportSize,
) { ): CameraViewWidthUpdate {
const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size) const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
if (nextDistance !== null) { if (nextDistance !== null) {
control.dollyTo(nextDistance, true) const appliedDistance = clampCameraControlDistance(control, nextDistance)
return return {
type: 'distance',
distance: appliedDistance,
viewWidth: getCameraViewWidth(camera, appliedDistance, size),
}
} }
const nextZoom = getCameraZoomForViewWidth(camera, viewWidth) const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
if (nextZoom !== null) { 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 currentLevelId = selection.levelId
const firstLoad = useRef(true) const firstLoad = useRef(true)
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null) const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
const pendingFloorplanNavigationPose = useRef<NavigationCameraPoseSnapshot | null>(null) const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0) const lastApplied2dNavigationRevision = useRef(0)
const maxPolarAngle = const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const clearPendingFloorplanNavigationPose = useCallback(() => {
pendingFloorplanNavigationPose.current = null
}, [])
const camera = useThree((state) => state.camera) const camera = useThree((state) => state.camera)
const gl = useThree((state) => state.gl) const gl = useThree((state) => state.gl)
@@ -336,11 +393,19 @@ export const CustomCameraControls = () => {
if (!controls.current) return if (!controls.current) return
if (firstLoad.current) { if (firstLoad.current) {
firstLoad.current = false firstLoad.current = false
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
} }
controls.current.getTarget(currentTarget) controls.current.getTarget(currentTarget)
clearPendingFloorplanNavigationPose()
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) }, [
clearPendingFloorplanNavigationPose,
currentLevelId,
isPreviewMode,
isFirstPersonMode,
isRestoringFirstPersonPose,
])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode || !controls.current) return if (isFirstPersonMode || !controls.current) return
@@ -368,6 +433,7 @@ export const CustomCameraControls = () => {
controls.current.getTarget(tempTarget) controls.current.getTarget(tempTarget)
tempDelta.copy(tempCenter).sub(tempTarget) tempDelta.copy(tempCenter).sub(tempTarget)
clearPendingFloorplanNavigationPose()
controls.current.setLookAt( controls.current.setLookAt(
tempPosition.x + tempDelta.x, tempPosition.x + tempDelta.x,
tempPosition.y + tempDelta.y, tempPosition.y + tempDelta.y,
@@ -378,7 +444,7 @@ export const CustomCameraControls = () => {
true, true,
) )
}, },
[isPreviewMode, isFirstPersonMode], [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
) )
useEffect(() => { useEffect(() => {
@@ -397,14 +463,24 @@ export const CustomCameraControls = () => {
if (!control) return if (!control) return
lastApplied2dNavigationRevision.current = pose.revision lastApplied2dNavigationRevision.current = pose.revision
const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
const viewWidthUpdate = resolveCameraViewWidthUpdate(
control,
camera,
pose.viewWidth,
viewportSize,
)
pendingFloorplanNavigationPose.current = { pendingFloorplanNavigationPose.current = {
target: [...pose.target], target: [...pose.target],
azimuth: pose.azimuth, azimuth: targetAzimuth,
viewWidth: pose.viewWidth, 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.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(pose.azimuth, control.polarAngle, true) control.rotateTo(targetAzimuth, control.polarAngle, true)
applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize) applyCameraViewWidth(control, viewWidthUpdate)
}) })
}, [camera, isFirstPersonMode, viewportSize]) }, [camera, isFirstPersonMode, viewportSize])
@@ -424,6 +500,18 @@ export const CustomCameraControls = () => {
) { ) {
lastPublishedNavigationSync.current = pendingFloorplanPose lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null 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 return
} }
@@ -476,32 +564,6 @@ export const CustomCameraControls = () => {
if (horizontal === 0 && vertical === 0) return if (horizontal === 0 && vertical === 0) return
const control = controls.current 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) control.getSpherical(keyboardPanSpherical, false)
const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize) 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) 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 pendingFloorplanNavigationPose.current = null
control.setLookAt( if (horizontal !== 0) control.truck(horizontal * step, 0, true)
keyboardPanPosition.x + keyboardPanDelta.x, if (vertical !== 0) control.forward(vertical * step, true)
keyboardPanPosition.y,
keyboardPanPosition.z + keyboardPanDelta.z,
keyboardPanTarget.x + keyboardPanDelta.x,
keyboardPanTarget.y,
keyboardPanTarget.z + keyboardPanDelta.z,
false,
)
}) })
// Configure mouse buttons based on control mode and camera mode // Configure mouse buttons based on control mode and camera mode
@@ -991,6 +1041,7 @@ export const CustomCameraControls = () => {
if (!node?.camera) return if (!node?.camera) return
const { position, target } = node.camera const { position, target } = node.camera
clearPendingFloorplanNavigationPose()
controls.current.setLookAt( controls.current.setLookAt(
position[0], position[0],
position[1], position[1],
@@ -1011,6 +1062,7 @@ export const CustomCameraControls = () => {
// Otherwise, go to top view (0°) // Otherwise, go to top view (0°)
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
clearPendingFloorplanNavigationPose()
controls.current.rotatePolarTo(targetAngle, true) controls.current.rotatePolarTo(targetAngle, true)
} }
@@ -1023,6 +1075,7 @@ export const CustomCameraControls = () => {
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded - Math.PI / 2 const target = rounded - Math.PI / 2
clearPendingFloorplanNavigationPose()
controls.current.rotateTo(target, currentPolar, true) 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 rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded + Math.PI / 2 const target = rounded + Math.PI / 2
clearPendingFloorplanNavigationPose()
controls.current.rotateTo(target, currentPolar, true) controls.current.rotateTo(target, currentPolar, true)
} }
@@ -1046,6 +1100,7 @@ export const CustomCameraControls = () => {
if (isFirstPersonMode || !controls.current || isPreviewMode) return if (isFirstPersonMode || !controls.current || isPreviewMode) return
if (!bounds) { if (!bounds) {
// Restore default framing pose when no bounds were computed. // Restore default framing pose when no bounds were computed.
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
return return
} }
@@ -1056,6 +1111,7 @@ export const CustomCameraControls = () => {
const maxExtent = Math.max(w, d) const maxExtent = Math.max(w, d)
const distance = Math.max(maxExtent * 1.4, 15) const distance = Math.max(maxExtent * 1.4, 15)
const height = Math.max(maxExtent * 0.8, 10) 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) 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:orbit-ccw', handleOrbitCCW)
emitter.off('camera-controls:fit-scene', handleFitScene) emitter.off('camera-controls:fit-scene', handleFitScene)
} }
}, [focusNode, isPreviewMode, isFirstPersonMode]) }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
const onTransitionStart = useCallback(() => { const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true) useViewer.getState().setCameraDragging(true)
@@ -231,6 +231,7 @@ type FloorplanViewport = {
type FloorplanNavigationViewOptions = { type FloorplanNavigationViewOptions = {
smooth?: boolean smooth?: boolean
clampViewWidth?: boolean
} }
type FloorplanViewAnimationTarget = { type FloorplanViewAnimationTarget = {
@@ -832,6 +833,26 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max) 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) { function roundPlanMeters(value: number) {
return Math.round(value * 100) / 100 return Math.round(value * 100) / 100
} }
@@ -6046,9 +6067,12 @@ export function FloorplanPanel() {
FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg
const centerSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg) const centerSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg)
const fitted = latestFittedViewportRef.current const fitted = latestFittedViewportRef.current
const minWidth = fitted ? fitted.width * MIN_VIEWPORT_WIDTH_RATIO : 0.001 const nextWidth = resolveFloorplanViewWidth(
const maxWidth = fitted ? fitted.width * MAX_VIEWPORT_WIDTH_RATIO : Number.POSITIVE_INFINITY viewWidth ?? currentViewport.width,
const nextWidth = clamp(viewWidth ?? currentViewport.width, minWidth, maxWidth) currentViewport.width,
fitted,
options?.clampViewWidth !== false,
)
const nextViewport = { const nextViewport = {
centerX: centerSvg.x, centerX: centerSvg.x,
@@ -6101,7 +6125,9 @@ export function FloorplanPanel() {
buildingRotationY, buildingRotationY,
) )
applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth) applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth, {
clampViewWidth: false,
})
}, },
[applyFloorplanNavigationView, buildingPosition, buildingRotationY], [applyFloorplanNavigationView, buildingPosition, buildingRotationY],
) )
@@ -6387,9 +6413,6 @@ export function FloorplanPanel() {
viewBox, viewBox,
]) ])
const minViewportWidth = fittedViewport.width * MIN_VIEWPORT_WIDTH_RATIO
const maxViewportWidth = fittedViewport.width * MAX_VIEWPORT_WIDTH_RATIO
const palette = useMemo( const palette = useMemo(
() => () =>
isDark isDark
@@ -7089,10 +7112,11 @@ export function FloorplanPanel() {
const currentViewport = viewport ?? fittedViewport const currentViewport = viewport ?? fittedViewport
const currentViewBox = viewBox const currentViewBox = viewBox
const nextWidth = clamp( const nextWidth = resolveFloorplanViewWidth(
currentViewport.width * widthFactor, currentViewport.width * widthFactor,
minViewportWidth, currentViewport.width,
maxViewportWidth, fittedViewport,
true,
) )
const nextHeight = nextWidth / svgAspectRatio const nextHeight = nextWidth / svgAspectRatio
const normalizedX = (svgPoint.x - currentViewBox.minX) / currentViewBox.width const normalizedX = (svgPoint.x - currentViewBox.minX) / currentViewBox.width
@@ -7121,8 +7145,6 @@ export function FloorplanPanel() {
fittedViewport, fittedViewport,
floorplanSceneRotationDeg, floorplanSceneRotationDeg,
getSvgPointFromClientPoint, getSvgPointFromClientPoint,
maxViewportWidth,
minViewportWidth,
publishFloorplanNavigationPose, publishFloorplanNavigationPose,
smoothFloorplanNavigationView, smoothFloorplanNavigationView,
svgAspectRatio, svgAspectRatio,