Skip to content

Commit 2d4e7be

Browse files
authored
Merge pull request #478 from pascalorg/fix/compass-sync
editor: compass sync + fast align-to-north
2 parents 58339c6 + d4f1053 commit 2d4e7be

2 files changed

Lines changed: 95 additions & 13 deletions

File tree

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

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -369,10 +369,15 @@ export const CustomCameraControls = () => {
369369
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
370370
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
371371
const lastApplied2dNavigationRevision = useRef(0)
372+
const savedSmoothTimeRef = useRef<number | null>(null)
372373
const maxPolarAngle =
373374
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
374375
const clearPendingFloorplanNavigationPose = useCallback(() => {
375376
pendingFloorplanNavigationPose.current = null
377+
if (savedSmoothTimeRef.current !== null && controls.current) {
378+
controls.current.smoothTime = savedSmoothTimeRef.current
379+
savedSmoothTimeRef.current = null
380+
}
376381
}, [])
377382

378383
const camera = useThree((state) => state.camera)
@@ -478,6 +483,13 @@ export const CustomCameraControls = () => {
478483
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
479484
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
480485
}
486+
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
487+
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
488+
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
489+
if (savedSmoothTimeRef.current === null) {
490+
savedSmoothTimeRef.current = control.smoothTime
491+
}
492+
control.smoothTime = 0.18
481493
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
482494
control.rotateTo(targetAzimuth, control.polarAngle, true)
483495
applyCameraViewWidth(control, viewWidthUpdate)
@@ -499,7 +511,7 @@ export const CustomCameraControls = () => {
499511
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
500512
) {
501513
lastPublishedNavigationSync.current = pendingFloorplanPose
502-
pendingFloorplanNavigationPose.current = null
514+
clearPendingFloorplanNavigationPose()
503515
if (pendingFloorplanPose.publishOnComplete) {
504516
useEditor.getState().publishNavigationSyncPose({
505517
source: '3d',
@@ -540,7 +552,7 @@ export const CustomCameraControls = () => {
540552
azimuth: syncSpherical.theta,
541553
viewWidth,
542554
})
543-
}, [camera, isFirstPersonMode, viewportSize])
555+
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
544556

545557
useEffect(() => {
546558
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
@@ -573,7 +585,7 @@ export const CustomCameraControls = () => {
573585
)
574586
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
575587

576-
pendingFloorplanNavigationPose.current = null
588+
clearPendingFloorplanNavigationPose()
577589
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
578590
if (vertical !== 0) control.forward(vertical * step, true)
579591
})
@@ -725,7 +737,7 @@ export const CustomCameraControls = () => {
725737
!isEditableKeyboardTarget(event.target)
726738
) {
727739
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
728-
pendingFloorplanNavigationPose.current = null
740+
clearPendingFloorplanNavigationPose()
729741
event.preventDefault()
730742
event.stopPropagation()
731743
}
@@ -788,7 +800,7 @@ export const CustomCameraControls = () => {
788800

789801
const onPointerDown = (event: PointerEvent) => {
790802
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
791-
pendingFloorplanNavigationPose.current = null
803+
clearPendingFloorplanNavigationPose()
792804
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
793805

794806
panPointerId = event.pointerId
@@ -797,7 +809,7 @@ export const CustomCameraControls = () => {
797809
}
798810

799811
const onWheel = () => {
800-
pendingFloorplanNavigationPose.current = null
812+
clearPendingFloorplanNavigationPose()
801813
}
802814

803815
const onPointerUp = (event: PointerEvent) => {
@@ -839,7 +851,25 @@ export const CustomCameraControls = () => {
839851
clearKeyboardPanKeys()
840852
clearNavigationCursor()
841853
}
842-
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
854+
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
855+
856+
// Cancel any in-progress 2D-origin navigation pose when the user starts
857+
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
858+
// fires only for user pointer interactions — not for programmatic
859+
// moveTo/rotateTo which emit `transitionstart` instead.
860+
useEffect(() => {
861+
if (isFirstPersonMode) return
862+
const control = controls.current
863+
if (!control) return
864+
865+
const onControlStart = () => {
866+
clearPendingFloorplanNavigationPose()
867+
}
868+
control.addEventListener('controlstart', onControlStart)
869+
return () => {
870+
control.removeEventListener('controlstart', onControlStart)
871+
}
872+
}, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
843873

844874
// Preview mode: auto-navigate camera to selected node (viewer behavior)
845875
const previewTargetNodeId = isPreviewMode

packages/editor/src/components/editor/floorplan-panel.tsx

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -457,9 +457,11 @@ type GuideHandleHintAnchor = {
457457
function FloorplanCompassButton({
458458
northRotationDeg,
459459
onAlignNorth,
460+
needleRef,
460461
}: {
461462
northRotationDeg: number
462463
onAlignNorth: () => void
464+
needleRef?: React.RefObject<SVGSVGElement | null>
463465
}) {
464466
return (
465467
<Tooltip>
@@ -480,7 +482,8 @@ function FloorplanCompassButton({
480482
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
481483
<svg
482484
aria-hidden="true"
483-
className="h-6 w-6 transition-transform duration-150 ease-out"
485+
className="h-6 w-6"
486+
ref={needleRef}
484487
style={{ transform: `rotate(${northRotationDeg}deg)` }}
485488
viewBox="0 0 48 48"
486489
>
@@ -5078,6 +5081,7 @@ export function FloorplanPanel({
50785081
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
50795082
useEditor.getState().navigationSyncPose,
50805083
)
5084+
const compassNeedleRef = useRef<SVGSVGElement | null>(null)
50815085
const levelId = useViewer((state) => state.selection.levelId)
50825086
const buildingId = useViewer((state) => state.selection.buildingId)
50835087
const selectedZoneId = useViewer((state) => state.selection.zoneId)
@@ -5177,7 +5181,11 @@ export function FloorplanPanel({
51775181
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
51785182
const floorplanSceneRotationDeg =
51795183
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
5180-
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
5184+
// Only sync ref from state when floorplan is open (state is source of truth).
5185+
// When hidden, the imperative 3D path owns the ref and must not be clobbered.
5186+
if (isFloorplanOpenRef.current) {
5187+
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
5188+
}
51815189

51825190
// Draft START points stay in panel state (set per click). The live END points
51835191
// are the per-move hot values — they live in `useFloorplanDraftPreview` so a
@@ -6539,19 +6547,18 @@ export function FloorplanPanel({
65396547
)
65406548

65416549
useEffect(() => {
6550+
if (!isFloorplanOpen) return
6551+
65426552
const pose = useEditor.getState().navigationSyncPose
65436553
if (!pose) {
65446554
return
65456555
}
65466556

65476557
latestNavigationSyncPoseRef.current = pose
65486558
if (pose.source === '3d') {
6549-
// Re-runs when the panel reopens (`isFloorplanOpen`) so the viewport
6550-
// catches up to the camera after the per-frame sync was skipped while
6551-
// hidden; a no-op while closed (the sync early-returns).
65526559
syncFloorplanViewportToNavigationPose(pose)
65536560
}
6554-
}, [syncFloorplanViewportToNavigationPose])
6561+
}, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
65556562

65566563
useEffect(() => {
65576564
return useEditor.subscribe((state) => {
@@ -6563,11 +6570,35 @@ export function FloorplanPanel({
65636570
latestNavigationSyncPoseRef.current = pose
65646571

65656572
if (pose.source === '3d') {
6573+
if (!isFloorplanOpenRef.current) {
6574+
// Panel hidden — drive the compass needle imperatively without
6575+
// triggering React state (setViewport) that would re-render the
6576+
// full floorplan SVG every camera frame.
6577+
const nextDeg = floorplanRotationFromCameraAzimuth(
6578+
pose.azimuth,
6579+
latestFloorplanUserRotationDegRef.current,
6580+
)
6581+
latestFloorplanUserRotationDegRef.current = nextDeg
6582+
if (compassNeedleRef.current) {
6583+
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
6584+
}
6585+
return
6586+
}
65666587
syncFloorplanViewportToNavigationPose(pose)
65676588
}
65686589
})
65696590
}, [syncFloorplanViewportToNavigationPose])
65706591

6592+
// When the panel is hidden the imperative path owns the compass needle.
6593+
// React re-renders can overwrite the needle's inline transform with stale
6594+
// state; this layout effect restores the authoritative ref value before
6595+
// the browser paints so the needle never visibly snaps to a stale angle.
6596+
useLayoutEffect(() => {
6597+
if (!isFloorplanOpen && compassNeedleRef.current) {
6598+
compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)`
6599+
}
6600+
})
6601+
65716602
useEffect(() => {
65726603
const host = viewportHostRef.current
65736604
if (!host) {
@@ -7345,6 +7376,25 @@ export function FloorplanPanel({
73457376
)
73467377

73477378
const alignFloorplanViewToNorth = useCallback(() => {
7379+
if (!isFloorplanOpenRef.current) {
7380+
// Panel hidden — derive from the live 3D camera pose and publish
7381+
// directly. The compass animates via the imperative subscription as
7382+
// the 3D camera transitions.
7383+
const pose = latestNavigationSyncPoseRef.current
7384+
if (!pose) return
7385+
const currentRotation = latestFloorplanUserRotationDegRef.current
7386+
const northAzimuth = cameraAzimuthFromFloorplanRotation(
7387+
nearestEquivalentDegrees(0, currentRotation),
7388+
)
7389+
useEditor.getState().publishNavigationSyncPose({
7390+
source: '2d',
7391+
target: [...pose.target],
7392+
azimuth: northAzimuth,
7393+
viewWidth: pose.viewWidth,
7394+
})
7395+
return
7396+
}
7397+
73487398
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
73497399
if (!currentViewport) {
73507400
return
@@ -10761,13 +10811,15 @@ export function FloorplanPanel({
1076110811
(compassHost ? (
1076210812
createPortal(
1076310813
<FloorplanCompassButton
10814+
needleRef={compassNeedleRef}
1076410815
northRotationDeg={floorplanUserRotationDeg}
1076510816
onAlignNorth={alignFloorplanViewToNorth}
1076610817
/>,
1076710818
compassHost,
1076810819
)
1076910820
) : (
1077010821
<FloorplanCompassButton
10822+
needleRef={compassNeedleRef}
1077110823
northRotationDeg={floorplanUserRotationDeg}
1077210824
onAlignNorth={alignFloorplanViewToNorth}
1077310825
/>

0 commit comments

Comments
 (0)