Spaces:
Running
Running
🎬 Auto-deploy GLB Studio — 9af8035 fix: keyframe animation workflow + selection drop +
b6aaffe verified | import { useRef, useEffect, Suspense, useCallback } from 'react' | |
| import { Canvas, useFrame, useThree } from '@react-three/fiber' | |
| import { | |
| OrbitControls, Environment, Grid, GizmoHelper, | |
| GizmoViewport, ContactShadows, PerspectiveCamera, TransformControls, | |
| } from '@react-three/drei' | |
| import * as THREE from 'three' | |
| import useStore from '../store/useStore' | |
| import ModelManager from './ModelManager' | |
| import PhysicsEngine from './PhysicsEngine' | |
| function SceneLights() { | |
| const lights = useStore(s => s.sceneLights) || [] | |
| return ( | |
| <> | |
| {lights.filter(l => l?.visible).map(l => { | |
| const pos = l.position || [0,5,0] | |
| const col = l.color || '#ffffff' | |
| const int = l.intensity ?? 1 | |
| const sms = l.shadowMapSize || 1024 | |
| if (l.type==='ambient') return <ambientLight key={l.id} color={col} intensity={int} /> | |
| if (l.type==='hemisphere') return <hemisphereLight key={l.id} args={[l.skyColor||'#88aaff',l.groundColor||'#443322',int]} /> | |
| if (l.type==='directional') return <directionalLight key={l.id} color={col} intensity={int} position={pos} castShadow={!!l.castShadow} shadow-mapSize={[sms,sms]} shadow-camera-near={0.1} shadow-camera-far={200} shadow-camera-left={-30} shadow-camera-right={30} shadow-camera-top={30} shadow-camera-bottom={-30} /> | |
| if (l.type==='point') return <pointLight key={l.id} color={col} intensity={int} position={pos} distance={l.distance||0} castShadow={!!l.castShadow} shadow-mapSize={[sms,sms]} /> | |
| if (l.type==='spot') return <spotLight key={l.id} color={col} intensity={int} position={pos} angle={l.angle||0.4} penumbra={l.penumbra||0.2} distance={l.distance||0} castShadow={!!l.castShadow} shadow-mapSize={[sms,sms]} /> | |
| return null | |
| })} | |
| </> | |
| ) | |
| } | |
| function LightingRig() { | |
| const preset = useStore(s => s.lightingPreset) || 'studio' | |
| const cfgs = { | |
| studio: { amb:[0.4,'#fff8f0'], key:{p:[5,8,3], i:2,c:'#fff5e0'}, fill:{p:[-4,4,-2],i:0.6,c:'#c0d8ff'}, rim:{p:[0,6,-6], i:0.8,c:'#ffefcc'} }, | |
| outdoor: { amb:[0.6,'#b0c8ff'], key:{p:[10,20,5], i:3,c:'#fff8e1'}, fill:{p:[-8,5,-3],i:0.4,c:'#d0e8ff'}, rim:{p:[0,8,-8], i:0.3,c:'#90b8ff'} }, | |
| dramatic: { amb:[0.1,'#1a1a2e'], key:{p:[3,10,2], i:4,c:'#ff6020'}, fill:{p:[-6,2,-2],i:0.2,c:'#200840'}, rim:{p:[0,4,-8], i:1.2,c:'#8040ff'} }, | |
| neon: { amb:[0.15,'#0a0020'],key:{p:[5,6,3], i:2,c:'#00ffff'}, fill:{p:[-5,3,-3],i:1.5,c:'#ff00aa'}, rim:{p:[0,8,-6], i:1.0,c:'#aaff00'} }, | |
| } | |
| const cfg = cfgs[preset] || cfgs.studio | |
| return ( | |
| <> | |
| <ambientLight intensity={cfg.amb[0]} color={cfg.amb[1]} /> | |
| <directionalLight position={cfg.key.p} intensity={cfg.key.i} color={cfg.key.c} castShadow shadow-mapSize={[2048,2048]} shadow-camera-near={0.1} shadow-camera-far={100} shadow-camera-left={-20} shadow-camera-right={20} shadow-camera-top={20} shadow-camera-bottom={-20} /> | |
| <directionalLight position={cfg.fill.p} intensity={cfg.fill.i} color={cfg.fill.c} /> | |
| <directionalLight position={cfg.rim.p} intensity={cfg.rim.i} color={cfg.rim.c} /> | |
| </> | |
| ) | |
| } | |
| function SkyboxApplier() { | |
| const skybox = useStore(s => s.skybox) || {} | |
| const { scene, gl } = useThree() | |
| useEffect(() => { | |
| if (!skybox.showBg || skybox.type==='color' || !skybox.value) { | |
| scene.background = new THREE.Color(skybox.bgColor || '#080810') | |
| return | |
| } | |
| let disposed=false, tex=null | |
| const apply = async () => { | |
| try { | |
| if (skybox.type==='hdr') { | |
| const { RGBELoader } = await import('three/examples/jsm/loaders/RGBELoader.js') | |
| new RGBELoader().load(skybox.value, t => { if(disposed)return t.dispose(); t.mapping=THREE.EquirectangularReflectionMapping; scene.background=scene.environment=tex=t }) | |
| } else { | |
| new THREE.TextureLoader().load(skybox.value, t => { if(disposed)return t.dispose(); t.mapping=THREE.EquirectangularReflectionMapping; t.colorSpace=THREE.SRGBColorSpace; scene.background=scene.environment=tex=t }) | |
| } | |
| } catch(e) { console.warn('[Skybox]',e) } | |
| } | |
| apply() | |
| return () => { disposed=true; tex?.dispose() } | |
| }, [skybox, scene, gl]) | |
| return null | |
| } | |
| function PresetEnv() { | |
| const skybox = useStore(s => s.skybox) || {} | |
| const preset = useStore(s => s.lightingPreset) || 'studio' | |
| if (skybox.type !== 'preset') return null | |
| const map = { studio:'studio', outdoor:'park', dramatic:'night', neon:'warehouse' } | |
| return <Environment preset={map[preset]||'studio'} background={!!skybox.showBg} blur={0.6} /> | |
| } | |
| function Floor() { | |
| const isRender = useStore(s => s.isRenderMode||s.isExporting) | |
| const showGrid = useStore(s => s.showGrid) | |
| const showCS = useStore(s => s.showContactShadows) | |
| return ( | |
| <> | |
| <mesh receiveShadow rotation={[-Math.PI/2,0,0]} position={[0,-0.01,0]}> | |
| <planeGeometry args={[200,200]} /> | |
| <meshStandardMaterial color="#0d0d1a" roughness={0.95} /> | |
| </mesh> | |
| {!isRender && showGrid && ( | |
| <Grid args={[40,40]} cellSize={1} cellThickness={0.4} cellColor="#1a1a3a" | |
| sectionSize={5} sectionThickness={1} sectionColor="#2a2a5a" fadeDistance={50} /> | |
| )} | |
| {!isRender && showCS && ( | |
| <ContactShadows opacity={0.4} scale={30} blur={2} far={5} color="#000033" /> | |
| )} | |
| </> | |
| ) | |
| } | |
| function Deselect() { | |
| const isRender = useStore(s => s.isRenderMode||s.isExporting) | |
| if (isRender) return null | |
| return ( | |
| <mesh position={[0,-500,0]} onClick={() => { | |
| useStore.getState().selectModel(null) | |
| useStore.getState().selectCamera?.(null) | |
| }}> | |
| <planeGeometry args={[5000,5000]} /> | |
| <meshBasicMaterial transparent opacity={0} depthWrite={false} /> | |
| </mesh> | |
| ) | |
| } | |
| function Playback() { | |
| const acc = useRef(0) | |
| useFrame((_, delta) => { | |
| const s = useStore.getState() | |
| if (!s.isPlaying) return | |
| acc.current += delta | |
| if (acc.current >= 1/(s.fps||30)) { | |
| acc.current = 0 | |
| const next = s.currentFrame + 1 | |
| if (next >= s.totalFrames) { | |
| if (s.loopPlayback) s.setCurrentFrame(0) | |
| else { s.setIsPlaying(false); s.setCurrentFrame(0) } | |
| } else s.setCurrentFrame(next) | |
| } | |
| }) | |
| return null | |
| } | |
| function CameraMarkers() { | |
| const cameras = useStore(s => s.cameras) || [] | |
| const activeCamId = useStore(s => s.activeCameraId) | |
| const selectedCamId = useStore(s => s.selectedCameraId) | |
| const isRender = useStore(s => s.isRenderMode||s.isExporting) | |
| const showCams = useStore(s => s.showCameraObjects) | |
| if (isRender || !showCams) return null | |
| return ( | |
| <> | |
| {cameras.map(cam => { | |
| const pos = cam.position || [0,2,5] | |
| const isAct = cam.id === activeCamId | |
| const isSel = cam.id === selectedCamId | |
| return ( | |
| <group key={cam.id} position={pos} | |
| onClick={e => { | |
| e.stopPropagation() | |
| useStore.getState().setActiveCameraId?.(cam.id) | |
| useStore.getState().selectCamera?.(cam.id) | |
| }}> | |
| <mesh> | |
| <boxGeometry args={[0.25,0.18,0.32]} /> | |
| <meshStandardMaterial color={isAct?'#4f8eff':'#666'} emissive={isAct?'#4f8eff':'#000'} emissiveIntensity={isAct?0.3:0} roughness={0.3} metalness={0.7} /> | |
| </mesh> | |
| <mesh position={[0,0,-0.2]}> | |
| <cylinderGeometry args={[0.06,0.09,0.1,10]} /> | |
| <meshStandardMaterial color={isAct?'#00e5ff':'#333'} emissive={isAct?'#00e5ff':'#000'} emissiveIntensity={0.3} /> | |
| </mesh> | |
| {isAct && ( | |
| <mesh position={[0,0,-0.5]} rotation={[Math.PI/2,0,0]}> | |
| <coneGeometry args={[0.45,0.9,4,1,true]} /> | |
| <meshBasicMaterial color="#4f8eff" wireframe transparent opacity={0.2} /> | |
| </mesh> | |
| )} | |
| {isSel && ( | |
| <mesh rotation={[-Math.PI/2,0,0]}> | |
| <ringGeometry args={[0.32,0.42,32]} /> | |
| <meshBasicMaterial color="#4f8eff" transparent opacity={0.85} side={THREE.DoubleSide} depthWrite={false} /> | |
| </mesh> | |
| )} | |
| </group> | |
| ) | |
| })} | |
| </> | |
| ) | |
| } | |
| // Camera move/rotate gizmo — proxy group drives TransformControls | |
| function CameraGizmo() { | |
| const selId = useStore(s => s.selectedCameraId) | |
| const mode = useStore(s => s.cameraTransformMode) || 'translate' | |
| const cameras = useStore(s => s.cameras) || [] | |
| const inView = useStore(s => s.inCameraView) | |
| const isRend = useStore(s => s.isRenderMode||s.isExporting) | |
| const update = useStore(s => s.updateCamera) | |
| const groupRef = useRef() | |
| const cam = cameras.find(c => c.id === selId) | |
| useEffect(() => { | |
| if (!groupRef.current || !cam) return | |
| const p = cam.position || [0,2,5] | |
| groupRef.current.position.set(p[0],p[1],p[2]) | |
| groupRef.current.rotation.set(0,0,0) | |
| }, [selId, cam?.position?.toString()]) | |
| const onChange = useCallback(() => { | |
| if (!groupRef.current || !selId) return | |
| const p = groupRef.current.position | |
| const q = groupRef.current.quaternion | |
| if (mode === 'translate') { | |
| update(selId, { position:[p.x,p.y,p.z] }) | |
| } else { | |
| const dir = new THREE.Vector3(0,0,-1).applyQuaternion(q).multiplyScalar(5).add(p) | |
| update(selId, { target:[dir.x,dir.y,dir.z] }) | |
| } | |
| }, [selId, mode, update]) | |
| if (!cam || inView || isRend || !groupRef) return ( | |
| cam && !inView && !isRend ? <group ref={groupRef} position={cam.position||[0,2,5]} /> : null | |
| ) | |
| return ( | |
| <group ref={groupRef} position={cam.position||[0,2,5]}> | |
| {groupRef.current && ( | |
| <TransformControls | |
| key={`camtc-${selId}-${mode}`} | |
| object={groupRef.current} | |
| mode={mode} | |
| onChange={onChange} | |
| size={0.75} | |
| /> | |
| )} | |
| </group> | |
| ) | |
| } | |
| function CamSync() { | |
| const { camera } = useThree() | |
| useFrame(() => { | |
| const s = useStore.getState() | |
| if (!s.inCameraView || !s.activeCameraId) return | |
| const cam = (s.cameras||[]).find(c => c.id===s.activeCameraId) | |
| if (!cam) return | |
| const interp = interpolateCamKF(s.activeCameraId, s.currentFrame, s.keyframes) | |
| const src = interp || cam | |
| const pos = src.position || [5,3,5] | |
| const tgt = src.target || [0,0,0] | |
| const fov = src.fov || 50 | |
| camera.position.set(pos[0]||5,pos[1]||3,pos[2]||5) | |
| camera.lookAt(tgt[0]||0,tgt[1]||0,tgt[2]||0) | |
| if (Math.abs(camera.fov-fov)>0.1) { camera.fov=fov; camera.updateProjectionMatrix() } | |
| }) | |
| return null | |
| } | |
| function interpolateCamKF(camId, frame, keyframes) { | |
| if (!keyframes||!camId) return null | |
| const key = `__cam_${camId}__` | |
| const keys = Object.entries(keyframes) | |
| .filter(([,v])=>v?.[key]).map(([f,v])=>({frame:parseInt(f),data:v[key]})) | |
| .sort((a,b)=>a.frame-b.frame) | |
| if (!keys.length) return null | |
| const before=keys.filter(k=>k.frame<=frame), after=keys.filter(k=>k.frame>frame) | |
| if (!before.length) return keys[0].data | |
| if (!after.length) return keys[keys.length-1].data | |
| const k0=before[before.length-1],k1=after[0],t=(frame-k0.frame)/(k1.frame-k0.frame) | |
| const lv=(a,b,t)=>(a||[0,0,0]).map((v,i)=>v+((b||[0,0,0])[i]-v)*t) | |
| return {position:lv(k0.data.position,k1.data.position,t),target:lv(k0.data.target,k1.data.target,t),fov:(k0.data.fov||50)+((k1.data.fov||50)-(k0.data.fov||50))*t} | |
| } | |
| function FlyControls() { | |
| const inView=useStore(s=>s.inCameraView), actId=useStore(s=>s.activeCameraId) | |
| const { camera, gl } = useThree() | |
| const keys=useRef({}),drag=useRef({on:false,lx:0,ly:0}),yaw=useRef(0.5),pitch=useRef(-0.3),spd=useRef(0.08) | |
| useEffect(()=>{ | |
| if(!inView||!actId) return | |
| const kd=e=>{keys.current[e.code]=true},ku=e=>{keys.current[e.code]=false} | |
| const md=e=>{drag.current={on:true,lx:e.clientX,ly:e.clientY}},mu=()=>{drag.current.on=false} | |
| const mm=e=>{if(!drag.current.on)return;yaw.current-=(e.clientX-drag.current.lx)*0.003;pitch.current=Math.max(-1.4,Math.min(1.4,pitch.current-(e.clientY-drag.current.ly)*0.003));drag.current.lx=e.clientX;drag.current.ly=e.clientY} | |
| const mw=e=>{spd.current=Math.max(0.005,Math.min(5,spd.current*(1-e.deltaY*0.001)))} | |
| const ts=e=>{if(e.touches.length===1)drag.current={on:true,lx:e.touches[0].clientX,ly:e.touches[0].clientY}} | |
| const tm=e=>{if(e.touches.length===1&&drag.current.on){yaw.current-=(e.touches[0].clientX-drag.current.lx)*0.004;pitch.current=Math.max(-1.4,Math.min(1.4,pitch.current-(e.touches[0].clientY-drag.current.ly)*0.004));drag.current.lx=e.touches[0].clientX;drag.current.ly=e.touches[0].clientY}} | |
| const te=()=>{drag.current.on=false} | |
| window.addEventListener('keydown',kd);window.addEventListener('keyup',ku) | |
| gl.domElement.addEventListener('mousedown',md);window.addEventListener('mouseup',mu);window.addEventListener('mousemove',mm) | |
| gl.domElement.addEventListener('wheel',mw,{passive:true}) | |
| gl.domElement.addEventListener('touchstart',ts,{passive:true});gl.domElement.addEventListener('touchmove',tm,{passive:true});gl.domElement.addEventListener('touchend',te) | |
| return()=>{window.removeEventListener('keydown',kd);window.removeEventListener('keyup',ku);gl.domElement.removeEventListener('mousedown',md);window.removeEventListener('mouseup',mu);window.removeEventListener('mousemove',mm);gl.domElement.removeEventListener('wheel',mw);gl.domElement.removeEventListener('touchstart',ts);gl.domElement.removeEventListener('touchmove',tm);gl.domElement.removeEventListener('touchend',te)} | |
| },[inView,actId,gl]) | |
| useFrame(()=>{ | |
| if(!inView||!actId)return | |
| const s=spd.current,fwd=new THREE.Vector3(Math.sin(yaw.current)*Math.cos(pitch.current),Math.sin(pitch.current),Math.cos(yaw.current)*Math.cos(pitch.current)) | |
| const right=new THREE.Vector3().crossVectors(fwd,new THREE.Vector3(0,1,0)).normalize() | |
| if(keys.current.KeyW||keys.current.ArrowUp) camera.position.addScaledVector(fwd,s) | |
| if(keys.current.KeyS||keys.current.ArrowDown) camera.position.addScaledVector(fwd,-s) | |
| if(keys.current.KeyA||keys.current.ArrowLeft) camera.position.addScaledVector(right,-s) | |
| if(keys.current.KeyD||keys.current.ArrowRight) camera.position.addScaledVector(right,s) | |
| if(keys.current.KeyQ||keys.current.KeyZ)camera.position.y-=s | |
| if(keys.current.KeyE||keys.current.KeyX)camera.position.y+=s | |
| const tgt=new THREE.Vector3().copy(camera.position).addScaledVector(fwd,5) | |
| useStore.getState().updateCamera?.(actId,{position:[camera.position.x,camera.position.y,camera.position.z],target:[tgt.x,tgt.y,tgt.z]}) | |
| }) | |
| return null | |
| } | |
| // Orbit controls ref exposed globally so TC can disable it during drag | |
| export let orbitControlsRef = { current: null } | |
| function OrbitCam() { | |
| const inView = useStore(s=>s.inCameraView) | |
| const isRend = useStore(s=>s.isRenderMode||s.isExporting) | |
| if (inView||isRend) return null | |
| return ( | |
| <OrbitControls | |
| ref={orbitControlsRef} | |
| makeDefault | |
| enableDamping dampingFactor={0.07} | |
| rotateSpeed={0.8} | |
| zoomSpeed={1.4} | |
| panSpeed={0.9} | |
| minDistance={0.02} | |
| maxDistance={1000} | |
| minPolarAngle={0} | |
| maxPolarAngle={Math.PI} | |
| enablePan screenSpacePanning | |
| mouseButtons={{ LEFT:THREE.MOUSE.ROTATE, MIDDLE:THREE.MOUSE.DOLLY, RIGHT:THREE.MOUSE.PAN }} | |
| touches={{ ONE:THREE.TOUCH.ROTATE, TWO:THREE.TOUCH.DOLLY_PAN }} | |
| /> | |
| ) | |
| } | |
| function EditorGizmo() { | |
| const isRend=useStore(s=>s.isRenderMode||s.isExporting), show=useStore(s=>s.showGizmo) | |
| if (isRend||!show) return null | |
| return ( | |
| <GizmoHelper alignment="bottom-right" margin={[72,80]}> | |
| <GizmoViewport axisColors={['#ff4060','#40ff80','#4080ff']} labelColor="#fff" /> | |
| </GizmoHelper> | |
| ) | |
| } | |
| function CamHUD() { | |
| const inView=useStore(s=>s.inCameraView), actId=useStore(s=>s.activeCameraId) | |
| const cameras=useStore(s=>s.cameras)||[], isRend=useStore(s=>s.isRenderMode||s.isExporting) | |
| const cam=cameras.find(c=>c.id===actId) | |
| if(!inView||!cam||isRend) return null | |
| const bl='2px solid rgba(79,142,255,0.8)' | |
| return ( | |
| <div style={{position:'absolute',inset:0,pointerEvents:'none',border:'2px solid rgba(79,142,255,0.5)'}}> | |
| {[{top:8,left:8,borderTop:bl,borderLeft:bl},{top:8,right:8,borderTop:bl,borderRight:bl},{bottom:8,left:8,borderBottom:bl,borderLeft:bl},{bottom:8,right:8,borderBottom:bl,borderRight:bl}] | |
| .map((c,i)=><div key={i} style={{position:'absolute',width:18,height:18,...c}}/>)} | |
| <div style={{position:'absolute',top:10,left:'50%',transform:'translateX(-50%)',background:'rgba(8,8,20,0.75)',border:'1px solid rgba(79,142,255,0.4)',borderRadius:4,padding:'3px 12px',fontSize:11,color:'rgba(79,142,255,0.9)',fontFamily:'var(--font-mono)',backdropFilter:'blur(4px)',whiteSpace:'nowrap'}}> | |
| 🎥 {cam.name} · {cam.fov||50}° | |
| </div> | |
| <div style={{position:'absolute',top:'50%',left:'50%',transform:'translate(-50%,-50%)',width:16,height:16,pointerEvents:'none'}}> | |
| <div style={{position:'absolute',top:'50%',left:0,right:0,height:1,background:'rgba(79,142,255,0.5)'}}/> | |
| <div style={{position:'absolute',left:'50%',top:0,bottom:0,width:1,background:'rgba(79,142,255,0.5)'}}/> | |
| </div> | |
| <div style={{position:'absolute',bottom:10,left:'50%',transform:'translateX(-50%)',fontSize:9,color:'rgba(79,142,255,0.5)',fontFamily:'var(--font-mono)',background:'rgba(0,0,0,0.4)',padding:'2px 10px',borderRadius:3,whiteSpace:'nowrap'}}> | |
| WASD move · Q/E up/down · drag look · scroll speed | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function RenderIndicator() { | |
| const isRend=useStore(s=>s.isRenderMode||s.isExporting), pct=useStore(s=>s.exportProgress) | |
| if(!isRend)return null | |
| return ( | |
| <div style={{position:'absolute',top:10,left:'50%',transform:'translateX(-50%)',background:'rgba(239,68,68,0.15)',border:'1px solid rgba(239,68,68,0.4)',borderRadius:6,padding:'5px 16px',pointerEvents:'none',display:'flex',alignItems:'center',gap:8,zIndex:100}}> | |
| <div style={{width:8,height:8,borderRadius:'50%',background:'var(--danger)',animation:'pulse 1s ease infinite'}}/> | |
| <span style={{fontSize:12,fontWeight:700,color:'var(--danger)',fontFamily:'var(--font-mono)'}}>RENDERING {pct>0?`${pct}%`:''}</span> | |
| </div> | |
| ) | |
| } | |
| export default function Scene({ canvasRef }) { | |
| return ( | |
| <div style={{position:'absolute',inset:0}}> | |
| <Canvas | |
| shadows | |
| dpr={[1,Math.min(typeof window!=='undefined'?window.devicePixelRatio:1,2)]} | |
| gl={{ antialias:true, preserveDrawingBuffer:true, outputColorSpace:THREE.SRGBColorSpace, toneMapping:THREE.ACESFilmicToneMapping, toneMappingExposure:1.2, powerPreference:'high-performance', failIfMajorPerformanceCaveat:false }} | |
| style={{width:'100%',height:'100%',display:'block'}} | |
| onCreated={({gl})=>{gl.domElement.style.touchAction='none'}} | |
| > | |
| <PerspectiveCamera makeDefault position={[5,3,5]} fov={50} near={0.01} far={2000} /> | |
| <Suspense fallback={null}> | |
| <SkyboxApplier /> | |
| <PresetEnv /> | |
| <LightingRig /> | |
| <SceneLights /> | |
| <Floor /> | |
| <Deselect /> | |
| <ModelManager /> | |
| <CameraMarkers /> | |
| <CameraGizmo /> | |
| <CamSync /> | |
| <FlyControls /> | |
| <Playback /> | |
| </Suspense> | |
| <OrbitCam /> | |
| <EditorGizmo /> | |
| <PhysicsEngine /> | |
| </Canvas> | |
| <CamHUD /> | |
| <RenderIndicator /> | |
| </div> | |
| ) | |
| } | |