Merge pull request #478 from pascalorg/fix/compass-sync
editor: compass sync + fast align-to-north
This commit is contained in:
@@ -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
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
// 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user