glb-studio / src /components /Scene.jsx
varunm2004's picture
🎬 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>
)
}