canva3d / Viewer3D.tsx
artel3D's picture
feat: update Gumroad URL to live product
71521f7
import React, { useState, useRef, useEffect, Suspense, useMemo, useCallback } from 'react';
import { Canvas, useFrame, useLoader } from '@react-three/fiber';
import { OrbitControls, Environment, useGLTF, Html, useAnimations, ContactShadows, PerspectiveCamera } from '@react-three/drei';
import * as THREE from 'three';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader';
import { USDZLoader } from 'three/examples/jsm/loaders/USDZLoader';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader';
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader';
import {
Upload, RotateCw, Smartphone, Monitor, Square, Image as ImageIcon,
Palette, X, Layers, ChevronDown, ChevronUp, Box, Grid, Play, Pause,
Film, SkipForward, Video, StopCircle, RefreshCcw, Sun, Trash2, Globe
} from 'lucide-react';
// --- Types ---
type WireframeMode = 'none' | 'overlay' | 'pure' | 'points';
type ExportFormat = 'webm' | 'mp4' | 'mov';
type BgType = 'color' | 'hdri';
const DRACO_URL = 'https://www.gstatic.com/draco/versioned/decoders/1.5.5/';
const GUMROAD_URL = 'https://artel3d.gumroad.com/l/canva3Dpro';
// --- Loader Components ---
const GLBModel = ({ url, onLoaded }: any) => {
// Fix: Cast useGLTF result to any to avoid ambiguity between object and array return types
const { scene, animations } = useGLTF(url, DRACO_URL) as any;
useEffect(() => { if (scene) onLoaded(scene, animations); }, [scene, animations, onLoaded]);
return <primitive object={scene} />;
};
const FBXModel = ({ url, onLoaded }: any) => {
// Fix: Cast useLoader result to any to safely access the animations property
const fbx = useLoader(FBXLoader, url) as any;
useEffect(() => { if (fbx) onLoaded(fbx, fbx.animations || []); }, [fbx, onLoaded]);
return <primitive object={fbx} />;
};
const OBJModel = ({ url, onLoaded }: any) => {
const obj = useLoader(OBJLoader, url);
useEffect(() => { if (obj) onLoaded(obj, []); }, [obj, onLoaded]);
return <primitive object={obj} />;
};
const STLModel = ({ url, onLoaded }: any) => {
// Fix: Cast useLoader result to any because STLLoader returns a single BufferGeometry for one URL
const geometry = useLoader(STLLoader, url) as any;
const mesh = useMemo(() => new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()), [geometry]);
useEffect(() => { if (mesh) onLoaded(mesh, []); }, [mesh, onLoaded]);
return <primitive object={mesh} />;
};
const USDZModel = ({ url, onLoaded }: any) => {
// Fix: Cast useLoader result to any to safely access the animations property
const usdz = useLoader(USDZLoader, url) as any;
useEffect(() => { if (usdz) onLoaded(usdz, usdz.animations || []); }, [usdz, onLoaded]);
return <primitive object={usdz} />;
};
// --- Custom Environment ---
const CustomEnvironment = ({ url, type, background, intensity }: { url: string, type: string, background: boolean, intensity: number }) => {
let loader: any = THREE.TextureLoader;
if (type === 'hdr') loader = RGBELoader;
if (type === 'exr') loader = EXRLoader;
const texture = useLoader(loader, url, (loaderInstance: any) => {
if (type === 'exr' || type === 'hdr') {
loaderInstance.setDataType(THREE.HalfFloatType);
}
}) as THREE.Texture;
// Configure synchronously during render so drei receives the correct mapping immediately.
// useEffect is too late — drei processes the texture before the effect fires, causing black screen.
texture.mapping = THREE.EquirectangularReflectionMapping;
if (type === 'exr' || type === 'hdr') {
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
} else {
texture.colorSpace = THREE.SRGBColorSpace;
}
// Use a large sphere for the background to avoid WebGL crashes with large EXR DataTextures on scene.background
return (
<>
<Environment map={texture} background={false} environmentIntensity={intensity} resolution={256} />
{background && (
<mesh scale={500}>
<sphereGeometry args={[1, 64, 64]} />
<meshBasicMaterial map={texture} side={THREE.BackSide} depthWrite={false} />
</mesh>
)}
</>
);
};
// --- Model Wrapper ---
const ModelManager = ({
url, type, isAlbedo, isNeutral, wireframeMode, techColor,
animationIndex, isPlaying, onAnimsLoaded,
rotationSpeed, rotationDirection
}: any) => {
const groupRef = useRef<THREE.Group>(null);
const [model, setModel] = useState<THREE.Object3D | null>(null);
const [animations, setAnimations] = useState<THREE.AnimationClip[]>([]);
const { actions, names } = useAnimations(animations, groupRef);
const handleLoaded = useCallback((obj: THREE.Object3D, anims: THREE.AnimationClip[]) => {
if (groupRef.current) groupRef.current.rotation.set(0, 0, 0);
const box = new THREE.Box3().setFromObject(obj);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 3.5 / (maxDim || 1);
obj.scale.setScalar(scale);
obj.position.set(-center.x * scale, -center.y * scale + (size.y * scale) / 2, -center.z * scale);
setModel(obj);
setAnimations(anims);
}, []);
useEffect(() => { if (names.length > 0) onAnimsLoaded(names); }, [names, onAnimsLoaded]);
useEffect(() => {
if (actions && names.length > 0) {
const action = actions[names[animationIndex % names.length]];
if (action) {
if (isPlaying) action.reset().fadeIn(0.2).play();
else action.fadeOut(0.2);
}
return () => { action?.fadeOut(0.2); };
}
}, [animationIndex, isPlaying, actions, names]);
useFrame((state, delta) => {
if (groupRef.current && rotationSpeed > 0) {
groupRef.current.rotation.y += delta * rotationSpeed * rotationDirection * 0.5;
}
});
useEffect(() => {
if (!model) return;
model.traverse((child: any) => {
if (child.isMesh) {
if (!child.userData.originalMat) child.userData.originalMat = child.material.clone();
const orig = child.userData.originalMat;
if (wireframeMode === 'pure') {
child.material = new THREE.MeshStandardMaterial({ color: techColor, wireframe: true, emissive: techColor, emissiveIntensity: 0.5 });
} else if (isNeutral) {
child.material = new THREE.MeshStandardMaterial({ color: '#444', roughness: 0.8, metalness: 0.2 });
} else if (isAlbedo) {
child.material = new THREE.MeshBasicMaterial({ map: orig.map, color: 'white' });
} else {
child.material = orig;
child.material.wireframe = (wireframeMode === 'overlay');
}
}
});
}, [model, wireframeMode, isNeutral, isAlbedo, techColor]);
const ext = type.toLowerCase();
return (
<group ref={groupRef}>
<Suspense fallback={null}>
{(ext === 'glb' || ext === 'gltf') && <GLBModel url={url} onLoaded={handleLoaded} />}
{ext === 'fbx' && <FBXModel url={url} onLoaded={handleLoaded} />}
{ext === 'obj' && <OBJModel url={url} onLoaded={handleLoaded} />}
{ext === 'stl' && <STLModel url={url} onLoaded={handleLoaded} />}
{ext === 'usdz' && <USDZModel url={url} onLoaded={handleLoaded} />}
</Suspense>
</group>
);
};
// --- Props Interface ---
interface Viewer3DProps {
modelUrl: string | null;
onModelLoaded?: () => void;
}
// --- Main Component ---
export default function Viewer3D({ modelUrl, onModelLoaded }: Viewer3DProps) {
const [fileUrl, setFileUrl] = useState<string | null>(null);
const [fileType, setFileType] = useState('');
const [anims, setAnims] = useState<string[]>([]);
const [animIndex, setAnimIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [wireframe, setWireframe] = useState<WireframeMode>('none');
const [isNeutral, setIsNeutral] = useState(false);
const [isAlbedo, setIsAlbedo] = useState(false);
const [bgColor, setBgColor] = useState('#050505');
const [rotationSpeed, setRotationSpeed] = useState(0.4);
const [rotationDirection, setRotationDirection] = useState<1 | -1>(1);
const [lightIntensity, setLightIntensity] = useState(1.5);
const [aspect, setAspect] = useState<'9:16' | '1:1' | '16:9'>('1:1');
const [uiCollapsed, setUiCollapsed] = useState(false);
// Background & HDRI
const [bgType, setBgType] = useState<BgType>('color');
const [hdriFile, setHdriFile] = useState<{url: string, type: string} | null>(null);
const [hdriPreset, setHdriPreset] = useState('city');
// Export
const [isRecording, setIsRecording] = useState(false);
const [recordTime, setRecordTime] = useState(0);
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
const canvasRef = useRef<HTMLCanvasElement>(null);
const controlsRef = useRef<any>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const hdriInputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<number | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// Load model from external URL when prop changes
useEffect(() => {
if (modelUrl) {
setFileUrl(modelUrl);
setFileType('glb');
if (onModelLoaded) onModelLoaded();
}
}, [modelUrl]);
const handleUpload = (file: File) => {
if (fileUrl) URL.revokeObjectURL(fileUrl);
setAnims([]);
setAnimIndex(0);
const ext = file.name.split('.').pop()?.toLowerCase() || '';
setFileType(ext);
setFileUrl(URL.createObjectURL(file));
};
const handleHdriUpload = (file: File) => {
if (hdriFile) URL.revokeObjectURL(hdriFile.url);
const ext = file.name.split('.').pop()?.toLowerCase() || 'hdr';
setHdriFile({ url: URL.createObjectURL(file), type: ext });
setBgType('hdri');
};
const startRecording = () => {
if (!canvasRef.current) return;
chunksRef.current = [];
const stream = canvasRef.current.captureStream(60);
let mimeType = 'video/webm;codecs=vp9';
if (exportFormat === 'mp4') {
mimeType = MediaRecorder.isTypeSupported('video/mp4') ? 'video/mp4' : 'video/webm;codecs=h264';
} else if (exportFormat === 'mov') {
mimeType = 'video/quicktime';
}
try {
const recorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported(mimeType) ? mimeType : 'video/webm',
videoBitsPerSecond: 12000000
});
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `3d-studio-render-${Date.now()}.${exportFormat}`;
a.click();
};
recorder.start();
mediaRecorderRef.current = recorder;
setIsRecording(true);
setRecordTime(0);
timerRef.current = window.setInterval(() => setRecordTime(t => t + 1), 1000);
} catch (e) { alert("Recording error: Check browser compatibility for this format."); }
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setIsRecording(false);
if (timerRef.current) clearInterval(timerRef.current);
};
const dim = useMemo(() => {
const base = Math.min(window.innerWidth, window.innerHeight) * 0.75;
if (aspect === '9:16') return { w: base * 0.56, h: base };
if (aspect === '16:9') return { w: base * 1.4, h: base * 0.8 };
return { w: base, h: base };
}, [aspect]);
return (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#000', color: 'white', overflow: 'hidden', fontFamily: 'sans-serif' }}>
{/* Bouton Get Pro */}
<a
href={GUMROAD_URL}
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 200,
background: '#f43f5e',
color: 'white',
padding: '6px 14px',
borderRadius: '20px',
textDecoration: 'none',
fontSize: '0.8rem',
fontWeight: 'bold',
letterSpacing: '0.05em',
}}
>
⬆ Get Pro
</a>
{isRecording && (
<div style={{ position: 'absolute', top: 30, zIndex: 100, background: '#ef4444', padding: '12px 24px', borderRadius: '40px', fontWeight: 'bold', boxShadow: '0 0 30px rgba(239, 68, 68, 0.5)', display: 'flex', alignItems: 'center', gap: 10, animation: 'pulse 1s infinite' }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: 'white' }}></div>
{Math.floor(recordTime/60)}:{(recordTime%60).toString().padStart(2, '0')} - RECORDING {exportFormat.toUpperCase()}
</div>
)}
{!fileUrl ? (
<div style={{ width: '400px', height: '250px', border: '2px dashed #333', borderRadius: '40px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: '#080808' }} onClick={() => fileInputRef.current?.click()}>
<Upload size={48} color="#3b82f6" style={{ marginBottom: 15 }} />
<h2 style={{ margin: 0 }}>CANVA 3D</h2>
<p style={{ opacity: 0.4, fontSize: '0.9rem' }}>GLB, FBX, OBJ, STL</p>
</div>
) : (
<div style={{ width: dim.w, height: dim.h, borderRadius: '32px', background: bgColor, position: 'relative', border: '1px solid #1a1a1a', overflow: 'hidden' }}>
<Canvas ref={canvasRef} gl={{ preserveDrawingBuffer: true, antialias: true }} shadows dpr={[1, 2]}>
<PerspectiveCamera makeDefault position={[0, 0, 8]} fov={35} />
<Suspense fallback={<Html center><div className="loader"></div></Html>}>
{bgType === 'color' ? <color attach="background" args={[bgColor]} /> : null}
{hdriFile ? (
<CustomEnvironment
url={hdriFile.url}
type={hdriFile.type}
background={bgType === 'hdri'}
intensity={bgType === 'hdri' ? 1 : 0.5}
/>
) : (
<Environment
preset={hdriPreset as any}
background={bgType === 'hdri'}
environmentIntensity={bgType === 'hdri' ? 1 : 0.5}
/>
)}
<ambientLight intensity={0.4} />
<hemisphereLight intensity={0.6} color="white" groundColor="#333" />
<directionalLight position={[10, 10, 5]} intensity={lightIntensity} castShadow />
<directionalLight position={[-10, 5, -5]} intensity={lightIntensity * 0.5} color="#3b82f6" />
<ModelManager
url={fileUrl} type={fileType}
isAlbedo={isAlbedo} isNeutral={isNeutral}
wireframeMode={wireframe} techColor="#3b82f6"
animationIndex={animIndex} isPlaying={isPlaying}
onAnimsLoaded={setAnims}
rotationSpeed={rotationSpeed} rotationDirection={rotationDirection}
/>
<ContactShadows opacity={0.4} scale={15} blur={2} far={4} />
<OrbitControls ref={controlsRef} makeDefault enableDamping dampingFactor={0.05} maxDistance={400} />
</Suspense>
</Canvas>
<button onClick={() => controlsRef.current?.reset()} style={{ position: 'absolute', bottom: 20, right: 20, background: 'rgba(0,0,0,0.5)', borderRadius: '50%', width: 40, height: 40, padding: 0 }}><RefreshCcw size={16} /></button>
</div>
)}
{fileUrl && (
<div className="ui-panel" style={{ position: 'absolute', bottom: 30, width: '90%', maxWidth: '480px', background: 'rgba(15,15,15,0.95)', padding: '20px', borderRadius: '28px', border: '1px solid rgba(255,255,255,0.05)', backdropFilter: 'blur(20px)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
<div style={{ display: 'flex', gap: 6 }}>
<button className={aspect === '9:16' ? 'active' : ''} onClick={() => setAspect('9:16')}><Smartphone size={14} /></button>
<button className={aspect === '1:1' ? 'active' : ''} onClick={() => setAspect('1:1')}><Square size={14} /></button>
<button className={aspect === '16:9' ? 'active' : ''} onClick={() => setAspect('16:9')}><Monitor size={14} /></button>
</div>
<button onClick={() => setUiCollapsed(!uiCollapsed)} style={{ background: 'none' }}>{uiCollapsed ? <ChevronUp size={20} /> : <ChevronDown size={20} />}</button>
</div>
{!uiCollapsed && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button className={wireframe !== 'none' ? 'active' : ''} onClick={() => setWireframe(w => w === 'none' ? 'overlay' : w === 'overlay' ? 'pure' : 'none')}><Grid size={16} /></button>
<button className={isAlbedo ? 'active' : ''} onClick={() => { setIsAlbedo(!isAlbedo); setIsNeutral(false); }}><Layers size={16} /></button>
<button className={isNeutral ? 'active' : ''} onClick={() => { setIsNeutral(!isNeutral); setIsAlbedo(false); }}><Box size={16} /></button>
<div style={{ width: 1, height: 20, background: '#333', margin: '0 5px' }} />
<button className={bgType === 'color' ? 'active' : ''} onClick={() => setBgType('color')} title="Mode Couleur"><Palette size={16} /></button>
<button className={bgType === 'hdri' ? 'active' : ''} onClick={() => setBgType('hdri')} title="Mode HDRI"><Globe size={16} /></button>
</div>
<div style={{ display: 'flex', gap: 10, background: 'rgba(255,255,255,0.03)', padding: 10, borderRadius: '15px', alignItems: 'center' }}>
{bgType === 'color' ? (
<div style={{ flex: 1, fontSize: '0.7rem', opacity: 0.5, textAlign: 'center' }}>COULEUR UNIE</div>
) : (
<div style={{ display: 'flex', gap: 5, flex: 1 }}>
<button onClick={() => setHdriPreset('city')} className={hdriPreset === 'city' ? 'active' : ''} style={{fontSize:'0.6rem'}}>CITY</button>
<button onClick={() => setHdriPreset('studio')} className={hdriPreset === 'studio' ? 'active' : ''} style={{fontSize:'0.6rem'}}>STUDIO</button>
<button onClick={() => hdriInputRef.current?.click()} style={{fontSize:'0.6rem', border: '1px solid #3b82f6'}}><ImageIcon size={12} /> HDR</button>
</div>
)}
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.1)' }} />
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} title="Couleur de fond" style={{ width: 28, height: 28, background: 'none', border: 'none', cursor: 'pointer' }} />
<button onClick={() => setFileUrl(null)} style={{ color: '#ef4444', background: 'none' }}><Trash2 size={16} /></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<label style={{ fontSize: '0.6rem', opacity: 0.5 }}>FORMAT D'EXPORT</label>
<div style={{ display: 'flex', gap: 5 }}>
<button className={exportFormat === 'mp4' ? 'active' : ''} onClick={() => setExportFormat('mp4')} style={{flex: 1, fontSize: '0.7rem'}}>MP4</button>
<button className={exportFormat === 'mov' ? 'active' : ''} onClick={() => setExportFormat('mov')} style={{flex: 1, fontSize: '0.7rem'}}>MOV</button>
<button className={exportFormat === 'webm' ? 'active' : ''} onClick={() => setExportFormat('webm')} style={{flex: 1, fontSize: '0.7rem'}}>WEBM</button>
</div>
</div>
<button className={isRecording ? 'recording' : ''} onClick={isRecording ? stopRecording : startRecording} style={{ background: isRecording ? '#ef4444' : '#3b82f6', borderRadius: '15px', padding: '12px', fontSize: '0.9rem', fontWeight: 900, width: '100%' }}>
{isRecording ? <StopCircle size={20} /> : <Video size={20} />}
{isRecording ? 'STOP' : `CAPTURER .${exportFormat.toUpperCase()}`}
</button>
</div>
)}
</div>
)}
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".glb,.gltf,.fbx,.obj,.stl" onChange={e => e.target.files && handleUpload(e.target.files[0])} />
<input type="file" ref={hdriInputRef} style={{ display: 'none' }} accept=".hdr,.exr,.jpg,.jpeg,.png" onChange={e => e.target.files && handleHdriUpload(e.target.files[0])} />
<style>{`
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.7; } 100% { opacity: 1; } }
.loader { border: 2px solid rgba(255,255,255,0.1); border-top: 2px solid #3b82f6; border-radius: 50%; width: 28px; height: 28px; animation: spin 0.8s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
button { border: none; background: rgba(255,255,255,0.05); color: white; padding: 8px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: all 0.2s; }
button:hover { background: rgba(255,255,255,0.1); }
button.active { background: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); }
input[type="range"] { width: 100%; accent-color: #3b82f6; cursor: pointer; }
`}</style>
</div>
);
}