diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 33e0e90ec..92dc15c23 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -6,11 +6,6 @@ import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber" import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark"; import { AlertCircle, - ArrowDown, - ArrowLeft, - ArrowRight, - ArrowUp, - Box, Crosshair, Globe, ImageIcon, @@ -206,6 +201,73 @@ function getSemanticFacts(metadata?: Record | null) { return facts; } +function positionCameraInsideWorld({ + camera, + controls, + splatMesh, + groundPlaneOffset, +}: { + camera: THREE.Camera; + controls: { target?: THREE.Vector3; update?: () => void } | null; + splatMesh: SparkSplatMesh; + groundPlaneOffset: number | null; +}) { + if (!(camera instanceof THREE.PerspectiveCamera)) return; + + splatMesh.updateMatrixWorld(true); + const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld); + if (worldBounds.isEmpty()) return; + + const center = worldBounds.getCenter(new THREE.Vector3()); + const size = worldBounds.getSize(new THREE.Vector3()); + const horizontalAxis = + size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); + 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 = + groundPlaneOffset !== null + ? groundPlaneOffset + 1.58 + : center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9); + const anchor = new THREE.Vector3(center.x, eyeY, center.z); + const cameraPosition = anchor + .clone() + .addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65)); + const targetPosition = anchor + .clone() + .addScaledVector(horizontalAxis, -Math.max(majorSpan * 0.2, 1.1)); + + cameraPosition.x = THREE.MathUtils.clamp( + cameraPosition.x, + worldBounds.min.x + marginX, + worldBounds.max.x - marginX + ); + cameraPosition.z = THREE.MathUtils.clamp( + cameraPosition.z, + worldBounds.min.z + marginZ, + worldBounds.max.z - marginZ + ); + targetPosition.x = THREE.MathUtils.clamp( + targetPosition.x, + worldBounds.min.x + marginX * 0.5, + worldBounds.max.x - marginX * 0.5 + ); + targetPosition.z = THREE.MathUtils.clamp( + targetPosition.z, + worldBounds.min.z + marginZ * 0.5, + worldBounds.max.z - marginZ * 0.5 + ); + + camera.position.copy(cameraPosition); + camera.lookAt(targetPosition); + camera.updateProjectionMatrix(); + + if (controls?.target) { + controls.target.copy(targetPosition); + controls.update?.(); + } +} + function WorldEmptyState({ icon: Icon, title, @@ -291,87 +353,13 @@ function createMovementState(): MovementState { return { ...EMPTY_MOVEMENT_STATE }; } -function DirectionPad({ - onCommandChange, -}: { - onCommandChange: (command: MovementCommand, active: boolean) => void; -}) { - const pressHandlers = (command: MovementCommand) => ({ - onMouseDown: () => onCommandChange(command, true), - onMouseUp: () => onCommandChange(command, false), - onMouseLeave: () => onCommandChange(command, false), - onTouchStart: () => onCommandChange(command, true), - onTouchEnd: () => onCommandChange(command, false), - onTouchCancel: () => onCommandChange(command, false), - }); - - return ( -
-
方向移动
-
-
- - - - -
- 前后左右 -
- - -
- - -
-
- ); -} - function CameraMovementControls({ enabled, firstPerson, - virtualMovement, orbitControlsRef, }: { enabled: boolean; firstPerson: boolean; - virtualMovement: MovementState; orbitControlsRef: React.MutableRefObject; }) { const { camera } = useThree(); @@ -427,8 +415,7 @@ function CameraMovementControls({ if (!enabled) return; const pressed = pressedKeysRef.current; - const hasMovement = - Object.values(pressed).some(Boolean) || Object.values(virtualMovement).some(Boolean); + const hasMovement = Object.values(pressed).some(Boolean); if (!hasMovement) return; const moveSpeed = 3.8; @@ -441,22 +428,22 @@ function CameraMovementControls({ moveRef.current.set(0, 0, 0); - if (pressed.forward || virtualMovement.forward) { + if (pressed.forward) { moveRef.current.add(directionRef.current); } - if (pressed.backward || virtualMovement.backward) { + if (pressed.backward) { moveRef.current.addScaledVector(directionRef.current, -1); } - if (pressed.left || virtualMovement.left) { + if (pressed.left) { moveRef.current.addScaledVector(rightRef.current, -1); } - if (pressed.right || virtualMovement.right) { + if (pressed.right) { moveRef.current.add(rightRef.current); } - if (pressed.up || virtualMovement.up) { + if (pressed.up) { moveRef.current.add(upRef.current); } - if (pressed.down || virtualMovement.down) { + if (pressed.down) { moveRef.current.addScaledVector(upRef.current, -1); } @@ -523,10 +510,10 @@ function WorldSplatScene({ semanticsMetadata?: Record | null; onLoadStateChange?: (state: "ready" | "error") => void; }) { + const camera = useThree((state) => state.camera); const renderer = useThree((state) => state.gl); const orbitControlsRef = useRef(null); - const [virtualMovement, setVirtualMovement] = - useState(createMovementState); + const hasAppliedInitialViewRef = useRef(false); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] @@ -543,6 +530,10 @@ function WorldSplatScene({ [renderer] ); const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]); + const groundPlaneOffset = + typeof semanticsMetadata?.ground_plane_offset === "number" + ? -semanticsMetadata.ground_plane_offset + : null; useEffect(() => { sparkRenderer.frustumCulled = false; @@ -561,6 +552,7 @@ function WorldSplatScene({ }, [revealPhase]); useEffect(() => { + hasAppliedInitialViewRef.current = false; splatMesh.frustumCulled = false; splatMesh.rotation.x = Math.PI; applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); @@ -569,7 +561,17 @@ function WorldSplatScene({ splatMesh.initialized .then(() => { if (cancelled) return; - onLoadStateChange?.("ready"); + window.requestAnimationFrame(() => { + if (cancelled || hasAppliedInitialViewRef.current) return; + positionCameraInsideWorld({ + camera, + controls: orbitControlsRef.current, + groundPlaneOffset, + splatMesh, + }); + hasAppliedInitialViewRef.current = true; + onLoadStateChange?.("ready"); + }); }) .catch(() => { if (!cancelled) onLoadStateChange?.("error"); @@ -578,7 +580,7 @@ function WorldSplatScene({ cancelled = true; splatMesh.dispose(); }; - }, [onLoadStateChange, sparkRenderer, splatMesh]); + }, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]); useFrame(() => { const phase = revealPhaseRef.current; @@ -624,21 +626,6 @@ function WorldSplatScene({ sparkRenderer.needsUpdate = true; }); - const groundPlaneOffset = - typeof semanticsMetadata?.ground_plane_offset === "number" - ? -semanticsMetadata.ground_plane_offset - : null; - - const handleDirectionChange = (command: MovementCommand, active: boolean) => { - setVirtualMovement((prev) => { - if (prev[command] === active) return prev; - return { - ...prev, - [command]: active, - }; - }); - }; - const handlePointerDown = (event: ThreeEvent) => { if (interactionMode !== "hotspot") return; event.stopPropagation(); @@ -675,11 +662,12 @@ function WorldSplatScene({ enabled={interactionMode === "navigate"} firstPerson={firstPerson} orbitControlsRef={orbitControlsRef} - virtualMovement={virtualMovement} /> {!firstPerson ? ( ) : ( ) : null}
-
-
- {firstPerson - ? "方向键 / WASD 前后左右 · Q/E 或 PgUp/PgDn 上下 · 鼠标观察 · Esc 退出" - : "拖拽旋转 · 滚轮缩放 · 方向键 / 按钮可做六向移动"} + {(firstPerson || interactionMode === "hotspot") && ( +
+
+ +
-
- {interactionMode === "navigate" ? ( - - ) : null} - {(firstPerson || interactionMode === "hotspot") && ( -
- -
- )} -
-
+ )}