fix: stabilize world canvas navigation controls
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-28 20:51:33 +08:00
parent fca2552412
commit ce03c85811

View File

@ -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<any>(null);
const hasAppliedInitialViewRef = useRef(false);
const lastCameraPoseRef = useRef<WorldCameraPose | null>(null);
const lastResetViewTokenRef = useRef<number | undefined>(resetViewToken);
const revealCompletedRef = useRef(false);
const [controlsMountVersion, setControlsMountVersion] = useState(0);
const [particleCloudData, setParticleCloudData] = useState<SplatParticleCloudData | null>(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}
/>
) : (
<OrbitControls
@ -953,12 +993,14 @@ function WorldSplatScene({
enablePan={false}
enableRotate
enableZoom={false}
key="world-splat-first-person"
makeDefault
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.ROTATE,
}}
ref={orbitControlsRef}
ref={handleOrbitControlsRef}
/>
)}
<Html className="pointer-events-none" fullscreen>