176 lines
6.6 KiB
TypeScript
176 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import React, { Suspense, useRef, useCallback, useState } from 'react';
|
|
import { Canvas } from '@react-three/fiber';
|
|
import {
|
|
Environment,
|
|
ContactShadows,
|
|
OrbitControls,
|
|
useProgress,
|
|
Html,
|
|
} from '@react-three/drei';
|
|
import { useThree } from '@react-three/fiber';
|
|
import { RobotModel } from './RobotModel';
|
|
import { snapshotStore } from '@/store/useSnapshotStore';
|
|
import type { WebGLRenderer, Scene, Camera } from 'three';
|
|
|
|
function Loader() {
|
|
const { progress } = useProgress();
|
|
return (
|
|
<Html center>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
|
|
<div style={{
|
|
width: '48px', height: '48px',
|
|
border: '3px solid rgba(59, 130, 246, 0.15)',
|
|
borderTopColor: '#3b82f6',
|
|
borderRadius: '50%',
|
|
animation: 'spin 1s linear infinite',
|
|
}} />
|
|
<p style={{ fontSize: '0.875rem', color: '#64748b', fontFamily: 'system-ui, sans-serif' }}>
|
|
{progress.toFixed(0)}% loaded
|
|
</p>
|
|
</div>
|
|
</Html>
|
|
);
|
|
}
|
|
|
|
function SceneCapture({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
|
|
const { gl, scene, camera } = useThree();
|
|
React.useEffect(() => {
|
|
onCapture(gl, scene, camera);
|
|
}, [gl, scene, camera, onCapture]);
|
|
return null;
|
|
}
|
|
|
|
function SceneContent({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
|
|
return (
|
|
<>
|
|
<SceneCapture onCapture={onCapture} />
|
|
<Environment preset="city" />
|
|
<ambientLight intensity={0.8} />
|
|
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
|
|
<directionalLight position={[-4, 3, 2]} intensity={0.8} color="#e8f0ff" />
|
|
<directionalLight position={[0, 3, -5]} intensity={0.8} color="#94a3b8" />
|
|
<spotLight position={[0, 8, 0]} intensity={1.0} angle={0.6} penumbra={0.5} color="#ffffff" />
|
|
<directionalLight position={[0, 2, 5]} intensity={0.7} color="#ffffff" />
|
|
<RobotModel />
|
|
<OrbitControls enablePan enableZoom enableRotate minDistance={2} maxDistance={10} minPolarAngle={0} maxPolarAngle={Math.PI} dampingFactor={0.05} enableDamping />
|
|
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function RobotCanvas() {
|
|
const glRef = useRef<WebGLRenderer | null>(null);
|
|
const sceneRef = useRef<Scene | null>(null);
|
|
const cameraRef = useRef<Camera | null>(null);
|
|
const [isCapturing, setIsCapturing] = useState(false);
|
|
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
|
|
|
|
const handleCapture = useCallback((gl: WebGLRenderer, scene: Scene, camera: Camera) => {
|
|
glRef.current = gl;
|
|
sceneRef.current = scene;
|
|
cameraRef.current = camera;
|
|
|
|
// Register a programmatic capture function for the checkout snapshot
|
|
snapshotStore.getState().registerCapture(() => {
|
|
if (!glRef.current || !sceneRef.current || !cameraRef.current) return null;
|
|
try {
|
|
glRef.current.render(sceneRef.current, cameraRef.current);
|
|
return glRef.current.domElement.toDataURL('image/jpeg', 0.75);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const handleShare = useCallback(async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(window.location.href);
|
|
setShareStatus('copied');
|
|
setTimeout(() => setShareStatus('idle'), 2000);
|
|
} catch {
|
|
setShareStatus('failed');
|
|
setTimeout(() => setShareStatus('idle'), 2000);
|
|
}
|
|
}, []);
|
|
|
|
const handleSnapshot = useCallback(() => {
|
|
if (!glRef.current || !sceneRef.current || !cameraRef.current) return;
|
|
setIsCapturing(true);
|
|
try {
|
|
glRef.current.render(sceneRef.current, cameraRef.current);
|
|
const dataUrl = glRef.current.domElement.toDataURL('image/png');
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const link = document.createElement('a');
|
|
link.download = `g1-robot-${timestamp}.png`;
|
|
link.href = dataUrl;
|
|
link.click();
|
|
} catch (error) {
|
|
console.error('Failed to capture snapshot:', error);
|
|
} finally {
|
|
setIsCapturing(false);
|
|
}
|
|
}, []);
|
|
|
|
const btnBase: React.CSSProperties = {
|
|
position: 'absolute',
|
|
top: '1rem',
|
|
padding: '0.5rem 1rem',
|
|
backdropFilter: 'blur(8px)',
|
|
borderRadius: '0.5rem',
|
|
cursor: 'pointer',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 500,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
transition: 'all 0.2s ease',
|
|
zIndex: 10,
|
|
};
|
|
|
|
return (
|
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
<Canvas
|
|
dpr={[1, 2]}
|
|
camera={{ position: [0, 1, 5], fov: 50 }}
|
|
gl={{ antialias: true, powerPreference: 'high-performance' }}
|
|
style={{ background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)' }}
|
|
>
|
|
<Suspense fallback={<Loader />}>
|
|
<SceneContent onCapture={handleCapture} />
|
|
</Suspense>
|
|
</Canvas>
|
|
|
|
<button
|
|
onClick={handleSnapshot}
|
|
disabled={isCapturing}
|
|
style={{ ...btnBase, left: '1rem', backgroundColor: 'rgba(255,255,255,0.8)', border: '1px solid #e2e8f0', color: '#1a1a2e' }}
|
|
aria-label="Capture 3D scene snapshot"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
{isCapturing ? 'Capturing...' : 'Snapshot'}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleShare}
|
|
style={{
|
|
...btnBase,
|
|
left: '8.5rem',
|
|
backgroundColor: shareStatus === 'copied' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.8)',
|
|
border: `1px solid ${shareStatus === 'copied' ? '#86efac' : '#e2e8f0'}`,
|
|
color: shareStatus === 'copied' ? '#16a34a' : '#1a1a2e',
|
|
}}
|
|
aria-label="Share configuration link"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
|
</svg>
|
|
{shareStatus === 'copied' ? 'Copied!' : 'Share'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|