@@ -32,11 +32,6 @@ const tempSize = new Vector3()
3232const tempTarget = new Vector3 ( )
3333const syncTarget = new Vector3 ( )
3434const 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 ( )
4035const keyboardPanSpherical = new Spherical ( )
4136const DEFAULT_MAX_POLAR_ANGLE = Math . PI / 2 - 0.1
4237const 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
6163function 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+
171201function 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