Skip to content

Commit d0bfdff

Browse files
committed
Fix camera floorplan navigation sync
1 parent 5d29363 commit d0bfdff

2 files changed

Lines changed: 148 additions & 70 deletions

File tree

packages/editor/src/components/editor/custom-camera-controls.tsx

Lines changed: 114 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,6 @@ const tempSize = new Vector3()
3232
const tempTarget = new Vector3()
3333
const syncTarget = new Vector3()
3434
const syncSpherical = new Spherical()
35-
const keyboardPanPosition = new Vector3()
36-
const keyboardPanTarget = new Vector3()
37-
const keyboardPanScreenRight = new Vector3()
38-
const keyboardPanScreenUp = new Vector3()
39-
const keyboardPanDelta = new Vector3()
4035
const keyboardPanSpherical = new Spherical()
4136
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
4237
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
@@ -57,6 +52,13 @@ type NavigationCameraPoseSnapshot = {
5752
azimuth: number
5853
viewWidth: number
5954
}
55+
type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
56+
publishOnComplete: boolean
57+
}
58+
type CameraViewWidthUpdate =
59+
| { type: 'distance'; distance: number; viewWidth: number }
60+
| { type: 'zoom'; viewWidth: number; zoom: number }
61+
| { type: 'none'; viewWidth: number }
6062

6163
function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
6264
tuple[0] = vector.x
@@ -168,6 +170,34 @@ function getAngleDeltaRadians(a: number, b: number) {
168170
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
169171
}
170172

173+
function nearestEquivalentRadians(angle: number, reference: number) {
174+
return reference + getAngleDeltaRadians(angle, reference)
175+
}
176+
177+
function clampFinite(value: number, min: number, max: number) {
178+
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
179+
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
180+
return Math.min(Math.max(value, resolvedMin), resolvedMax)
181+
}
182+
183+
function clampCameraControlDistance(control: CameraControlsImpl, distance: number) {
184+
const bounds = control as { minDistance?: number; maxDistance?: number }
185+
return clampFinite(
186+
distance,
187+
bounds.minDistance ?? Number.NEGATIVE_INFINITY,
188+
bounds.maxDistance ?? Number.POSITIVE_INFINITY,
189+
)
190+
}
191+
192+
function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
193+
const bounds = control as { minZoom?: number; maxZoom?: number }
194+
return clampFinite(
195+
zoom,
196+
bounds.minZoom ?? Number.NEGATIVE_INFINITY,
197+
bounds.maxZoom ?? Number.POSITIVE_INFINITY,
198+
)
199+
}
200+
171201
function isCameraAtNavigationPose(
172202
pose: NavigationCameraPoseSnapshot,
173203
target: Vector3,
@@ -206,21 +236,45 @@ function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) {
206236
return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
207237
}
208238

209-
function applyCameraViewWidth(
239+
function resolveCameraViewWidthUpdate(
210240
control: CameraControlsImpl,
211241
camera: Camera,
212242
viewWidth: number,
213243
size: CameraViewportSize,
214-
) {
244+
): CameraViewWidthUpdate {
215245
const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
216246
if (nextDistance !== null) {
217-
control.dollyTo(nextDistance, true)
218-
return
247+
const appliedDistance = clampCameraControlDistance(control, nextDistance)
248+
return {
249+
type: 'distance',
250+
distance: appliedDistance,
251+
viewWidth: getCameraViewWidth(camera, appliedDistance, size),
252+
}
219253
}
220254

221255
const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
222256
if (nextZoom !== null) {
223-
control.zoomTo(nextZoom, true)
257+
const appliedZoom = clampCameraControlZoom(control, nextZoom)
258+
if (isOrthographicCamera(camera)) {
259+
return {
260+
type: 'zoom',
261+
zoom: appliedZoom,
262+
viewWidth: Math.max(0.001, (camera.right - camera.left) / Math.max(appliedZoom, 0.001)),
263+
}
264+
}
265+
}
266+
267+
return { type: 'none', viewWidth }
268+
}
269+
270+
function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
271+
if (update.type === 'distance') {
272+
control.dollyTo(update.distance, true)
273+
return
274+
}
275+
276+
if (update.type === 'zoom') {
277+
control.zoomTo(update.zoom, true)
224278
}
225279
}
226280

@@ -308,10 +362,13 @@ export const CustomCameraControls = () => {
308362
const currentLevelId = selection.levelId
309363
const firstLoad = useRef(true)
310364
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
311-
const pendingFloorplanNavigationPose = useRef<NavigationCameraPoseSnapshot | null>(null)
365+
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
312366
const lastApplied2dNavigationRevision = useRef(0)
313367
const maxPolarAngle =
314368
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
369+
const clearPendingFloorplanNavigationPose = useCallback(() => {
370+
pendingFloorplanNavigationPose.current = null
371+
}, [])
315372

316373
const camera = useThree((state) => state.camera)
317374
const gl = useThree((state) => state.gl)
@@ -336,11 +393,19 @@ export const CustomCameraControls = () => {
336393
if (!controls.current) return
337394
if (firstLoad.current) {
338395
firstLoad.current = false
396+
clearPendingFloorplanNavigationPose()
339397
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
340398
}
341399
controls.current.getTarget(currentTarget)
400+
clearPendingFloorplanNavigationPose()
342401
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
343-
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
402+
}, [
403+
clearPendingFloorplanNavigationPose,
404+
currentLevelId,
405+
isPreviewMode,
406+
isFirstPersonMode,
407+
isRestoringFirstPersonPose,
408+
])
344409

345410
useEffect(() => {
346411
if (isFirstPersonMode || !controls.current) return
@@ -368,6 +433,7 @@ export const CustomCameraControls = () => {
368433
controls.current.getTarget(tempTarget)
369434
tempDelta.copy(tempCenter).sub(tempTarget)
370435

436+
clearPendingFloorplanNavigationPose()
371437
controls.current.setLookAt(
372438
tempPosition.x + tempDelta.x,
373439
tempPosition.y + tempDelta.y,
@@ -378,7 +444,7 @@ export const CustomCameraControls = () => {
378444
true,
379445
)
380446
},
381-
[isPreviewMode, isFirstPersonMode],
447+
[clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
382448
)
383449

384450
useEffect(() => {
@@ -397,14 +463,24 @@ export const CustomCameraControls = () => {
397463
if (!control) return
398464

399465
lastApplied2dNavigationRevision.current = pose.revision
466+
const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
467+
const viewWidthUpdate = resolveCameraViewWidthUpdate(
468+
control,
469+
camera,
470+
pose.viewWidth,
471+
viewportSize,
472+
)
400473
pendingFloorplanNavigationPose.current = {
401474
target: [...pose.target],
402-
azimuth: pose.azimuth,
403-
viewWidth: pose.viewWidth,
475+
azimuth: targetAzimuth,
476+
viewWidth: viewWidthUpdate.viewWidth,
477+
publishOnComplete:
478+
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
479+
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
404480
}
405481
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
406-
control.rotateTo(pose.azimuth, control.polarAngle, true)
407-
applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize)
482+
control.rotateTo(targetAzimuth, control.polarAngle, true)
483+
applyCameraViewWidth(control, viewWidthUpdate)
408484
})
409485
}, [camera, isFirstPersonMode, viewportSize])
410486

@@ -424,6 +500,18 @@ export const CustomCameraControls = () => {
424500
) {
425501
lastPublishedNavigationSync.current = pendingFloorplanPose
426502
pendingFloorplanNavigationPose.current = null
503+
if (pendingFloorplanPose.publishOnComplete) {
504+
useEditor.getState().publishNavigationSyncPose({
505+
source: '3d',
506+
target: [
507+
pendingFloorplanPose.target[0],
508+
pendingFloorplanPose.target[1],
509+
pendingFloorplanPose.target[2],
510+
],
511+
azimuth: pendingFloorplanPose.azimuth,
512+
viewWidth: pendingFloorplanPose.viewWidth,
513+
})
514+
}
427515
}
428516
return
429517
}
@@ -476,32 +564,6 @@ export const CustomCameraControls = () => {
476564
if (horizontal === 0 && vertical === 0) return
477565

478566
const control = controls.current
479-
control.getPosition(keyboardPanPosition)
480-
control.getTarget(keyboardPanTarget)
481-
482-
camera.updateMatrixWorld()
483-
keyboardPanScreenRight.setFromMatrixColumn(camera.matrixWorld, 0)
484-
keyboardPanScreenRight.y = 0
485-
keyboardPanScreenUp.setFromMatrixColumn(camera.matrixWorld, 1)
486-
keyboardPanScreenUp.y = 0
487-
488-
if (keyboardPanScreenRight.lengthSq() < 1e-6) {
489-
keyboardPanScreenRight.set(1, 0, 0)
490-
} else {
491-
keyboardPanScreenRight.normalize()
492-
}
493-
494-
if (keyboardPanScreenUp.lengthSq() < 1e-6) {
495-
keyboardPanScreenUp.copy(keyboardPanTarget).sub(keyboardPanPosition)
496-
keyboardPanScreenUp.y = 0
497-
if (keyboardPanScreenUp.lengthSq() < 1e-6) {
498-
keyboardPanScreenUp.set(0, 0, -1)
499-
} else {
500-
keyboardPanScreenUp.normalize()
501-
}
502-
} else {
503-
keyboardPanScreenUp.normalize()
504-
}
505567

506568
control.getSpherical(keyboardPanSpherical, false)
507569
const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
@@ -511,21 +573,9 @@ export const CustomCameraControls = () => {
511573
)
512574
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
513575

514-
keyboardPanDelta
515-
.set(0, 0, 0)
516-
.addScaledVector(keyboardPanScreenRight, horizontal * step)
517-
.addScaledVector(keyboardPanScreenUp, vertical * step)
518-
519576
pendingFloorplanNavigationPose.current = null
520-
control.setLookAt(
521-
keyboardPanPosition.x + keyboardPanDelta.x,
522-
keyboardPanPosition.y,
523-
keyboardPanPosition.z + keyboardPanDelta.z,
524-
keyboardPanTarget.x + keyboardPanDelta.x,
525-
keyboardPanTarget.y,
526-
keyboardPanTarget.z + keyboardPanDelta.z,
527-
false,
528-
)
577+
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
578+
if (vertical !== 0) control.forward(vertical * step, true)
529579
})
530580

531581
// Configure mouse buttons based on control mode and camera mode
@@ -991,6 +1041,7 @@ export const CustomCameraControls = () => {
9911041
if (!node?.camera) return
9921042
const { position, target } = node.camera
9931043

1044+
clearPendingFloorplanNavigationPose()
9941045
controls.current.setLookAt(
9951046
position[0],
9961047
position[1],
@@ -1011,6 +1062,7 @@ export const CustomCameraControls = () => {
10111062
// Otherwise, go to top view (0°)
10121063
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
10131064

1065+
clearPendingFloorplanNavigationPose()
10141066
controls.current.rotatePolarTo(targetAngle, true)
10151067
}
10161068

@@ -1023,6 +1075,7 @@ export const CustomCameraControls = () => {
10231075
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
10241076
const target = rounded - Math.PI / 2
10251077

1078+
clearPendingFloorplanNavigationPose()
10261079
controls.current.rotateTo(target, currentPolar, true)
10271080
}
10281081

@@ -1035,6 +1088,7 @@ export const CustomCameraControls = () => {
10351088
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
10361089
const target = rounded + Math.PI / 2
10371090

1091+
clearPendingFloorplanNavigationPose()
10381092
controls.current.rotateTo(target, currentPolar, true)
10391093
}
10401094

@@ -1046,6 +1100,7 @@ export const CustomCameraControls = () => {
10461100
if (isFirstPersonMode || !controls.current || isPreviewMode) return
10471101
if (!bounds) {
10481102
// Restore default framing pose when no bounds were computed.
1103+
clearPendingFloorplanNavigationPose()
10491104
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
10501105
return
10511106
}
@@ -1056,6 +1111,7 @@ export const CustomCameraControls = () => {
10561111
const maxExtent = Math.max(w, d)
10571112
const distance = Math.max(maxExtent * 1.4, 15)
10581113
const height = Math.max(maxExtent * 0.8, 10)
1114+
clearPendingFloorplanNavigationPose()
10591115
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
10601116
}
10611117

@@ -1076,7 +1132,7 @@ export const CustomCameraControls = () => {
10761132
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
10771133
emitter.off('camera-controls:fit-scene', handleFitScene)
10781134
}
1079-
}, [focusNode, isPreviewMode, isFirstPersonMode])
1135+
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
10801136

10811137
const onTransitionStart = useCallback(() => {
10821138
useViewer.getState().setCameraDragging(true)

0 commit comments

Comments
 (0)