From ce03c85811afe5f5e4a79a7f5fae6e03f6b69fdb Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 20:51:33 +0800 Subject: [PATCH] fix: stabilize world canvas navigation controls --- components/canvas/world3d/World3DViewer.tsx | 60 +++++++++++++++++---- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index a69981287..61d8032a5 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -54,6 +54,11 @@ type SparkRevealProfile = { opacity: number; }; +type WorldCameraPose = { + cameraPosition: THREE.Vector3; + targetPosition: THREE.Vector3; +}; + type World3DViewerProps = { mode: World3DViewMode; worldUrl?: string | null; @@ -285,12 +290,12 @@ function positionCameraInsideWorld({ controls: { target?: THREE.Vector3; update?: () => void } | null; splatMesh: SparkSplatMesh; groundPlaneOffset: number | null; -}) { - if (!(camera instanceof THREE.PerspectiveCamera)) return; +}): WorldCameraPose | null { + if (!(camera instanceof THREE.PerspectiveCamera)) return null; splatMesh.updateMatrixWorld(true); const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld); - if (worldBounds.isEmpty()) return; + if (worldBounds.isEmpty()) return null; const center = worldBounds.getCenter(new THREE.Vector3()); const size = worldBounds.getSize(new THREE.Vector3()); @@ -299,10 +304,16 @@ function positionCameraInsideWorld({ const majorSpan = Math.max(horizontalAxis.z === 1 ? size.z : size.x, 1.4); const marginX = Math.max(size.x * 0.12, 0.35); const marginZ = Math.max(size.z * 0.12, 0.35); - const eyeY = + const rawEyeY = groundPlaneOffset !== null ? groundPlaneOffset + 1.58 : center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9); + const minEyeY = worldBounds.min.y + Math.max(size.y * 0.1, 0.55); + const maxEyeY = worldBounds.max.y - Math.max(size.y * 0.14, 0.38); + const eyeY = + minEyeY <= maxEyeY + ? THREE.MathUtils.clamp(rawEyeY, minEyeY, maxEyeY) + : center.y; const anchor = new THREE.Vector3(center.x, eyeY, center.z); const cameraPosition = anchor .clone() @@ -333,6 +344,11 @@ function positionCameraInsideWorld({ controls.target.copy(targetPosition); controls.update?.(); } + + return { + cameraPosition: cameraPosition.clone(), + targetPosition: targetPosition.clone(), + }; } function WorldEmptyState({ @@ -731,7 +747,10 @@ function WorldSplatScene({ const renderer = useThree((state) => state.gl); const orbitControlsRef = useRef(null); const hasAppliedInitialViewRef = useRef(false); + const lastCameraPoseRef = useRef(null); + const lastResetViewTokenRef = useRef(resetViewToken); const revealCompletedRef = useRef(false); + const [controlsMountVersion, setControlsMountVersion] = useState(0); const [particleCloudData, setParticleCloudData] = useState(null); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), @@ -758,6 +777,10 @@ function WorldSplatScene({ typeof semanticsMetadata?.ground_plane_offset === "number" ? -semanticsMetadata.ground_plane_offset : null; + const handleOrbitControlsRef = React.useCallback((controls: any | null) => { + orbitControlsRef.current = controls; + setControlsMountVersion((version) => version + 1); + }, []); useEffect(() => { sparkRenderer.frustumCulled = false; @@ -780,6 +803,8 @@ function WorldSplatScene({ useEffect(() => { hasAppliedInitialViewRef.current = false; + lastCameraPoseRef.current = null; + lastResetViewTokenRef.current = resetViewToken; revealCompletedRef.current = false; setParticleCloudData(null); splatMesh.frustumCulled = false; @@ -794,7 +819,7 @@ function WorldSplatScene({ if (cancelled) return; window.requestAnimationFrame(() => { if (cancelled || hasAppliedInitialViewRef.current) return; - positionCameraInsideWorld({ + lastCameraPoseRef.current = positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, groundPlaneOffset, @@ -834,7 +859,9 @@ function WorldSplatScene({ useEffect(() => { if (!hasAppliedInitialViewRef.current) return; - positionCameraInsideWorld({ + if (lastResetViewTokenRef.current === resetViewToken) return; + lastResetViewTokenRef.current = resetViewToken; + lastCameraPoseRef.current = positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, groundPlaneOffset, @@ -842,6 +869,18 @@ function WorldSplatScene({ }); }, [camera, groundPlaneOffset, resetViewToken, splatMesh]); + useEffect(() => { + if (!orbitControlsRef.current || !lastCameraPoseRef.current) return; + + camera.position.copy(lastCameraPoseRef.current.cameraPosition); + camera.lookAt(lastCameraPoseRef.current.targetPosition); + if (camera instanceof THREE.PerspectiveCamera) { + camera.updateProjectionMatrix(); + } + orbitControlsRef.current.target?.copy(lastCameraPoseRef.current.targetPosition); + orbitControlsRef.current.update?.(); + }, [camera, controlsMountVersion]); + useFrame(() => { const phase = revealPhaseRef.current; const revealElapsed = @@ -936,15 +975,16 @@ function WorldSplatScene({ enablePan={false} enableRotate enableZoom - makeDefault maxDistance={16} + key="world-splat-orbit" + makeDefault mouseButtons={{ LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE, }} minDistance={0.5} - ref={orbitControlsRef} + ref={handleOrbitControlsRef} /> ) : ( )}