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
if (l.type==='hemisphere') return
if (l.type==='directional') return
if (l.type==='point') return
if (l.type==='spot') return
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 (
<>
>
)
}
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
}
function Floor() {
const isRender = useStore(s => s.isRenderMode||s.isExporting)
const showGrid = useStore(s => s.showGrid)
const showCS = useStore(s => s.showContactShadows)
return (
<>
{!isRender && showGrid && (
)}
{!isRender && showCS && (
)}
>
)
}
function Deselect() {
const isRender = useStore(s => s.isRenderMode||s.isExporting)
if (isRender) return null
return (
{
useStore.getState().selectModel(null)
useStore.getState().selectCamera?.(null)
}}>
)
}
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 (
{
e.stopPropagation()
useStore.getState().setActiveCameraId?.(cam.id)
useStore.getState().selectCamera?.(cam.id)
}}>
{isAct && (
)}
{isSel && (
)}
)
})}
>
)
}
// 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 ? : null
)
return (
{groupRef.current && (
)}
)
}
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 (
)
}
function EditorGizmo() {
const isRend=useStore(s=>s.isRenderMode||s.isExporting), show=useStore(s=>s.showGizmo)
if (isRend||!show) return null
return (
)
}
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 (
{[{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)=>
)}
🎥 {cam.name} · {cam.fov||50}°
WASD move · Q/E up/down · drag look · scroll speed
)
}
function RenderIndicator() {
const isRend=useStore(s=>s.isRenderMode||s.isExporting), pct=useStore(s=>s.exportProgress)
if(!isRend)return null
return (
RENDERING {pct>0?`${pct}%`:''}
)
}
export default function Scene({ canvasRef }) {
return (
)
}