Merge pull request #478 from pascalorg/fix/compass-sync

editor: compass sync + fast align-to-north
This commit is contained in:
Aymeric Rabot
2026-07-09 16:20:40 +02:00
committed by GitHub
2 changed files with 95 additions and 13 deletions
@@ -369,10 +369,15 @@ export const CustomCameraControls = () => {
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null) const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null) const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0) const lastApplied2dNavigationRevision = useRef(0)
const savedSmoothTimeRef = useRef<number | null>(null)
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(() => { const clearPendingFloorplanNavigationPose = useCallback(() => {
pendingFloorplanNavigationPose.current = null pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
}, []) }, [])
const camera = useThree((state) => state.camera) const camera = useThree((state) => state.camera)
@@ -478,6 +483,13 @@ export const CustomCameraControls = () => {
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >= Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON, 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.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(targetAzimuth, control.polarAngle, true) control.rotateTo(targetAzimuth, control.polarAngle, true)
applyCameraViewWidth(control, viewWidthUpdate) applyCameraViewWidth(control, viewWidthUpdate)
@@ -499,7 +511,7 @@ export const CustomCameraControls = () => {
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth) isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
) { ) {
lastPublishedNavigationSync.current = pendingFloorplanPose lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
if (pendingFloorplanPose.publishOnComplete) { if (pendingFloorplanPose.publishOnComplete) {
useEditor.getState().publishNavigationSyncPose({ useEditor.getState().publishNavigationSyncPose({
source: '3d', source: '3d',
@@ -540,7 +552,7 @@ export const CustomCameraControls = () => {
azimuth: syncSpherical.theta, azimuth: syncSpherical.theta,
viewWidth, viewWidth,
}) })
}, [camera, isFirstPersonMode, viewportSize]) }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
@@ -573,7 +585,7 @@ 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)
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
if (horizontal !== 0) control.truck(horizontal * step, 0, true) if (horizontal !== 0) control.truck(horizontal * step, 0, true)
if (vertical !== 0) control.forward(vertical * step, true) if (vertical !== 0) control.forward(vertical * step, true)
}) })
@@ -725,7 +737,7 @@ export const CustomCameraControls = () => {
!isEditableKeyboardTarget(event.target) !isEditableKeyboardTarget(event.target)
) { ) {
setKeyboardPanKey(keyboardPanKeys.current, event.code, true) setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
@@ -788,7 +800,7 @@ export const CustomCameraControls = () => {
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return 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 if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
panPointerId = event.pointerId panPointerId = event.pointerId
@@ -797,7 +809,7 @@ export const CustomCameraControls = () => {
} }
const onWheel = () => { const onWheel = () => {
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
} }
const onPointerUp = (event: PointerEvent) => { const onPointerUp = (event: PointerEvent) => {
@@ -839,7 +851,25 @@ export const CustomCameraControls = () => {
clearKeyboardPanKeys() clearKeyboardPanKeys()
clearNavigationCursor() 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) // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode const previewTargetNodeId = isPreviewMode
@@ -457,9 +457,11 @@ type GuideHandleHintAnchor = {
function FloorplanCompassButton({ function FloorplanCompassButton({
northRotationDeg, northRotationDeg,
onAlignNorth, onAlignNorth,
needleRef,
}: { }: {
northRotationDeg: number northRotationDeg: number
onAlignNorth: () => void onAlignNorth: () => void
needleRef?: React.RefObject<SVGSVGElement | null>
}) { }) {
return ( return (
<Tooltip> <Tooltip>
@@ -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"> <span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
<svg <svg
aria-hidden="true" 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)` }} style={{ transform: `rotate(${northRotationDeg}deg)` }}
viewBox="0 0 48 48" viewBox="0 0 48 48"
> >
@@ -5078,6 +5081,7 @@ export function FloorplanPanel({
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>( const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
useEditor.getState().navigationSyncPose, useEditor.getState().navigationSyncPose,
) )
const compassNeedleRef = useRef<SVGSVGElement | null>(null)
const levelId = useViewer((state) => state.selection.levelId) const levelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
@@ -5177,7 +5181,11 @@ export function FloorplanPanel({
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const floorplanSceneRotationDeg = const floorplanSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
// 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 latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
}
// Draft START points stay in panel state (set per click). The live END points // 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 // are the per-move hot values — they live in `useFloorplanDraftPreview` so a
@@ -6539,6 +6547,8 @@ export function FloorplanPanel({
) )
useEffect(() => { useEffect(() => {
if (!isFloorplanOpen) return
const pose = useEditor.getState().navigationSyncPose const pose = useEditor.getState().navigationSyncPose
if (!pose) { if (!pose) {
return return
@@ -6546,12 +6556,9 @@ export function FloorplanPanel({
latestNavigationSyncPoseRef.current = pose latestNavigationSyncPoseRef.current = pose
if (pose.source === '3d') { 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(pose)
} }
}, [syncFloorplanViewportToNavigationPose]) }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
useEffect(() => { useEffect(() => {
return useEditor.subscribe((state) => { return useEditor.subscribe((state) => {
@@ -6563,11 +6570,35 @@ export function FloorplanPanel({
latestNavigationSyncPoseRef.current = pose latestNavigationSyncPoseRef.current = pose
if (pose.source === '3d') { 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(pose)
} }
}) })
}, [syncFloorplanViewportToNavigationPose]) }, [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(() => { useEffect(() => {
const host = viewportHostRef.current const host = viewportHostRef.current
if (!host) { if (!host) {
@@ -7345,6 +7376,25 @@ export function FloorplanPanel({
) )
const alignFloorplanViewToNorth = useCallback(() => { 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
}
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
if (!currentViewport) { if (!currentViewport) {
return return
@@ -10761,6 +10811,7 @@ export function FloorplanPanel({
(compassHost ? ( (compassHost ? (
createPortal( createPortal(
<FloorplanCompassButton <FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg} northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth} onAlignNorth={alignFloorplanViewToNorth}
/>, />,
@@ -10768,6 +10819,7 @@ export function FloorplanPanel({
) )
) : ( ) : (
<FloorplanCompassButton <FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg} northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth} onAlignNorth={alignFloorplanViewToNorth}
/> />