Replace the dark surface plane with a grey background and a spotlight, size the contact shadow to the table, and scale its blur and fade with the shadow plane. Disable depth test on the stacking curve overlay.
128 lines
4.6 KiB
TypeScript
128 lines
4.6 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.
|
|
*/
|
|
export default function Scene({
|
|
children,
|
|
autoRotate = true,
|
|
enablePan = false,
|
|
fullscreen = false,
|
|
overlay,
|
|
shadowScale = 22,
|
|
}: {
|
|
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;
|
|
}) {
|
|
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}
|
|
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>
|
|
);
|
|
}
|