Files
tts-workshop/apps/web/src/components/viewers/Scene.tsx
T
hypercross 812b4640e2 feat(web): clamp tabletop camera to the horizon
Add a maxPolarAngle prop to Scene and use it in the tabletop view so the
camera can't dip below the table and peek under face-down cards.
2026-08-10 12:24:43 +08:00

134 lines
4.9 KiB
TypeScript

import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
import { Icon } from '@iconify/react';
import { Canvas } from '@react-three/fiber';
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
import { EffectComposer, Vignette } from '@react-three/postprocessing';
/**
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
* lighting, orbit controls, a soft contact shadow, and subtle post-processing
* (bloom + vignette). Children are wrapped in a Suspense boundary so loading
* assets (textures, models) can suspend without blanking the page.
*
* The camera is fitted to the bounds of the content on mount. `Bounds` sits
* inside the Suspense boundary, so it only mounts once the (suspending) content
* has loaded and its geometry is present.
*
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
* example, pans instead of rotating). `fullscreen` adds a toggle button that
* expands the scene to the full screen. `maxPolarAngle` (radians) clamps how
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
* peeking under face-down cards.
*/
export default function Scene({
children,
autoRotate = true,
enablePan = false,
fullscreen = false,
overlay,
shadowScale = 22,
maxPolarAngle = Math.PI,
}: {
children: ReactNode;
autoRotate?: boolean;
enablePan?: boolean;
fullscreen?: boolean;
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
overlay?: ReactNode;
/** Contact shadow plane size in world units; defaults to a generous 22. */
shadowScale?: number;
/** Max camera polar angle in radians; defaults to unrestricted (π). */
maxPolarAngle?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else void containerRef.current?.requestFullscreen();
};
return (
<div
ref={containerRef}
className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400"
>
<LoadingOverlay />
{fullscreen && (
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
className="absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} className="h-5 w-5" />
</button>
)}
{overlay}
<Canvas
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true }}
>
<ambientLight intensity={0.5} />
<directionalLight position={[4, 6, 3]} intensity={1.4} />
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
<pointLight position={[0, 3, 0]} intensity={0.3} />
<Suspense fallback={null}>
<Bounds fit observe clip>{children}</Bounds>
</Suspense>
<ContactShadows
position={[0, -0.01, 0]}
opacity={0.2}
scale={shadowScale}
blur={shadowScale * 0.005}
far={shadowScale * 0.01}
resolution={1024}
color="#000000"
/>
<OrbitControls
enablePan={enablePan}
minDistance={0.01}
maxDistance={8}
maxPolarAngle={maxPolarAngle}
autoRotate={autoRotate}
makeDefault
/>
<EffectComposer>
<Vignette eskil={false} offset={0.25} darkness={0.6} />
</EffectComposer>
</Canvas>
</div>
);
}
/**
* A loading overlay shown while assets (textures, models, traces) are being
* fetched. Reads drei's global progress store, which tracks every loader in
* the scene, so it works outside the Canvas. Hidden once loading completes.
*/
function LoadingOverlay() {
const { active, progress, item, loaded, total } = useProgress();
if (!active) return null;
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-zinc-950/70 text-sm text-zinc-300">
<div className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-600 border-t-zinc-200" />
<span>Loading assets {Math.round(progress)}%</span>
</div>
<span className="max-w-80 truncate text-xs text-zinc-500">
{loaded}/{total} {item}
</span>
</div>
);
}