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.
134 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|