Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,15 @@ export const CustomCameraControls = () => {
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0)
const savedSmoothTimeRef = useRef<number | null>(null)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const clearPendingFloorplanNavigationPose = useCallback(() => {
pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
}, [])

const camera = useThree((state) => state.camera)
Expand Down Expand Up @@ -478,6 +483,13 @@ export const CustomCameraControls = () => {
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
}
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
if (savedSmoothTimeRef.current === null) {
savedSmoothTimeRef.current = control.smoothTime
}
control.smoothTime = 0.18
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(targetAzimuth, control.polarAngle, true)
applyCameraViewWidth(control, viewWidthUpdate)
Expand All @@ -499,7 +511,7 @@ export const CustomCameraControls = () => {
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
) {
lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null
clearPendingFloorplanNavigationPose()
if (pendingFloorplanPose.publishOnComplete) {
useEditor.getState().publishNavigationSyncPose({
source: '3d',
Expand Down Expand Up @@ -540,7 +552,7 @@ export const CustomCameraControls = () => {
azimuth: syncSpherical.theta,
viewWidth,
})
}, [camera, isFirstPersonMode, viewportSize])
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])

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

pendingFloorplanNavigationPose.current = null
clearPendingFloorplanNavigationPose()
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
if (vertical !== 0) control.forward(vertical * step, true)
})
Expand Down Expand Up @@ -725,7 +737,7 @@ export const CustomCameraControls = () => {
!isEditableKeyboardTarget(event.target)
) {
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
pendingFloorplanNavigationPose.current = null
clearPendingFloorplanNavigationPose()
event.preventDefault()
event.stopPropagation()
}
Expand Down Expand Up @@ -788,7 +800,7 @@ export const CustomCameraControls = () => {

const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
pendingFloorplanNavigationPose.current = null
clearPendingFloorplanNavigationPose()
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return

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

const onWheel = () => {
pendingFloorplanNavigationPose.current = null
clearPendingFloorplanNavigationPose()
}

const onPointerUp = (event: PointerEvent) => {
Expand Down Expand Up @@ -839,7 +851,25 @@ export const CustomCameraControls = () => {
clearKeyboardPanKeys()
clearNavigationCursor()
}
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])

// Cancel any in-progress 2D-origin navigation pose when the user starts
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
// fires only for user pointer interactions — not for programmatic
// moveTo/rotateTo which emit `transitionstart` instead.
useEffect(() => {
if (isFirstPersonMode) return
const control = controls.current
if (!control) return

const onControlStart = () => {
clearPendingFloorplanNavigationPose()
}
control.addEventListener('controlstart', onControlStart)
return () => {
control.removeEventListener('controlstart', onControlStart)
}
}, [isFirstPersonMode, clearPendingFloorplanNavigationPose])

// Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode
Expand Down
64 changes: 58 additions & 6 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,9 +457,11 @@ type GuideHandleHintAnchor = {
function FloorplanCompassButton({
northRotationDeg,
onAlignNorth,
needleRef,
}: {
northRotationDeg: number
onAlignNorth: () => void
needleRef?: React.RefObject<SVGSVGElement | null>
}) {
return (
<Tooltip>
Expand All @@ -480,7 +482,8 @@ function FloorplanCompassButton({
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
<svg
aria-hidden="true"
className="h-6 w-6 transition-transform duration-150 ease-out"
className="h-6 w-6"
ref={needleRef}
style={{ transform: `rotate(${northRotationDeg}deg)` }}
viewBox="0 0 48 48"
>
Expand Down Expand Up @@ -5078,6 +5081,7 @@ export function FloorplanPanel({
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
useEditor.getState().navigationSyncPose,
)
const compassNeedleRef = useRef<SVGSVGElement | null>(null)
const levelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
Expand Down Expand Up @@ -5177,7 +5181,11 @@ export function FloorplanPanel({
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const floorplanSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
// Only sync ref from state when floorplan is open (state is source of truth).
// When hidden, the imperative 3D path owns the ref and must not be clobbered.
if (isFloorplanOpenRef.current) {
latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale compass on panel reopen

Medium Severity

When the 2D panel opens after 3D-only orbit, the first render copies stale floorplanUserRotationDeg state into latestFloorplanUserRotationDegRef and reapplies it on the compass via React style, overwriting the live imperative rotation. Catch-up runs in a later useEffect, so the needle (and floorplan rotation) can visibly snap or flash wrong for at least one paint.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4f1053. Configure here.


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

useEffect(() => {
if (!isFloorplanOpen) return

const pose = useEditor.getState().navigationSyncPose
if (!pose) {
return
}

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

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

if (pose.source === '3d') {
if (!isFloorplanOpenRef.current) {
// Panel hidden — drive the compass needle imperatively without
// triggering React state (setViewport) that would re-render the
// full floorplan SVG every camera frame.
const nextDeg = floorplanRotationFromCameraAzimuth(
pose.azimuth,
latestFloorplanUserRotationDegRef.current,
)
latestFloorplanUserRotationDegRef.current = nextDeg
if (compassNeedleRef.current) {
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
}
return
}
syncFloorplanViewportToNavigationPose(pose)
}
})
}, [syncFloorplanViewportToNavigationPose])

// When the panel is hidden the imperative path owns the compass needle.
// React re-renders can overwrite the needle's inline transform with stale
// state; this layout effect restores the authoritative ref value before
// the browser paints so the needle never visibly snaps to a stale angle.
useLayoutEffect(() => {
if (!isFloorplanOpen && compassNeedleRef.current) {
compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)`
}
})

useEffect(() => {
const host = viewportHostRef.current
if (!host) {
Expand Down Expand Up @@ -7345,6 +7376,25 @@ export function FloorplanPanel({
)

const alignFloorplanViewToNorth = useCallback(() => {
if (!isFloorplanOpenRef.current) {
// Panel hidden — derive from the live 3D camera pose and publish
// directly. The compass animates via the imperative subscription as
// the 3D camera transitions.
const pose = latestNavigationSyncPoseRef.current
if (!pose) return
const currentRotation = latestFloorplanUserRotationDegRef.current
const northAzimuth = cameraAzimuthFromFloorplanRotation(
nearestEquivalentDegrees(0, currentRotation),
)
useEditor.getState().publishNavigationSyncPose({
source: '2d',
target: [...pose.target],
azimuth: northAzimuth,
viewWidth: pose.viewWidth,
})
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compass stuck after 3D north

Medium Severity

After an 'align to north' action, the compass needle and floorplan view can become stale or jump. The action publishes a source: '2d' navigation pose, but the floorplan's update logic (for the hidden compass and re-opened view) expects source: '3d' poses. This mismatch, compounded by 3d pose suppression during camera damping, prevents proper synchronization.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4f1053. Configure here.

}

const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
if (!currentViewport) {
return
Expand Down Expand Up @@ -10761,13 +10811,15 @@ export function FloorplanPanel({
(compassHost ? (
createPortal(
<FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>,
compassHost,
)
) : (
<FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>
Expand Down
Loading