Spaces:
Running
feat: clean render mode + scene visibility + full export overhaul
Browse filesRENDER MODE (the main fix):
When rendering/exporting, setIsRenderMode(true) is called FIRST.
This hides ALL of these from the canvas:
β Grid
β Gizmo helper (axis widget)
β Selection rings around models
β TransformControls (gizmo arrows)
β Camera marker objects (box + lens + cone)
β Deselect plane
β OrbitControls
β Camera view HUD / brackets / crosshair
Only renders: models + lighting + environment + floor + background
Render mode exits automatically on cancel or completion.
useStore.js:
- isRenderMode: false (separate from isExporting so UI can check it)
- showGrid / showGizmo / showCameraObjects / showContactShadows toggles
- setIsRenderMode / setShowGrid / setShowGizmo etc.
- renderWidth / renderHeight for future offscreen rendering
Scene.jsx:
- Floor: Grid and ContactShadows check !isRenderMode && showGrid
- CameraMarkers: hidden when isRenderMode || !showCameraObjects
- EditorGizmo: hidden when isRenderMode || !showGizmo
- Deselect: hidden when isRenderMode
- OrbitControls: disabled when isRenderMode
- CamHUD overlay: hidden when isRenderMode
- RenderIndicator: red REC dot shown during render
- Playback: loop mode wired correctly
ModelManager.jsx:
- Selection ring: hidden when isRenderMode
- TransformControls: hidden when isRenderMode
ExportPanel.jsx (full rewrite):
- enterRenderMode() called before first frame capture
- 200ms grace period for React to re-render without UI
- exitRenderMode() called on completion/cancel
- PNG sequence export mode (downloads each frame as JPEG)
- Video (WebM VP9 12Mbps) or PNG sequence toggle
- 24/30/60fps output presets
- Frame quality slider (50%-100%)
- Gradient progress bar (accentβaccent2βaccent3)
- File named with project name + timestamp
- Live frame counter in status
- Cancel properly exits render mode
Toolbar.jsx:
- β Toggle grid button
- β Toggle gizmo button
- π₯ Toggle camera objects button
- src/components/ExportPanel.jsx +254 -96
- src/components/ModelManager.jsx +7 -10
- src/components/Scene.jsx +150 -133
- src/components/Toolbar.jsx +8 -0
- src/store/useStore.js +14 -0
|
@@ -1,125 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useState, useRef } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
-
function
|
| 5 |
return (
|
| 6 |
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
|
| 7 |
-
padding:'
|
| 8 |
<span style={{ fontSize:11, color:'var(--text2)' }}>{label}</span>
|
| 9 |
-
<
|
| 10 |
</div>
|
| 11 |
)
|
| 12 |
}
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
isExporting, setIsExporting, exportProgress, setExportProgress,
|
| 17 |
-
exportedVideoUrl, setExportedVideoUrl
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
const [quality,
|
| 20 |
-
const [outFps,
|
| 21 |
-
const [
|
| 22 |
-
const [
|
|
|
|
| 23 |
const cancelRef = useRef(false)
|
| 24 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
|
|
|
| 29 |
const captureFrame = () => {
|
| 30 |
-
const c =
|
| 31 |
return c ? c.toDataURL('image/jpeg', quality) : null
|
| 32 |
}
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
const startExport = async () => {
|
| 37 |
if (isExporting) return
|
| 38 |
-
setIsExporting(true)
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const store = useStore.getState()
|
|
|
|
| 42 |
|
| 43 |
for (let f = 0; f < totalFrames; f++) {
|
| 44 |
if (cancelRef.current) break
|
| 45 |
store.setCurrentFrame(f)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
try {
|
| 55 |
-
const blob = await
|
| 56 |
setExportedVideoUrl(URL.createObjectURL(blob))
|
| 57 |
-
setExportProgress(100)
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
-
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
-
const
|
| 64 |
if (!frames.length) { rej(new Error('No frames')); return }
|
| 65 |
const img = new Image()
|
| 66 |
img.onload = () => {
|
| 67 |
-
const
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
const
|
| 71 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
const chunks = []
|
| 73 |
-
rec.ondataavailable = e => chunks.push(e.data)
|
| 74 |
rec.onstop = () => res(new Blob(chunks, { type:'video/webm' }))
|
| 75 |
rec.start()
|
| 76 |
let i = 0
|
| 77 |
-
const
|
| 78 |
-
if (i >= frames.length) {
|
| 79 |
-
const fi = new Image()
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
-
img.onerror = rej
|
|
|
|
| 84 |
})
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
return (
|
| 87 |
-
<div style={{ padding:12, display:'flex', flexDirection:'column', gap:
|
| 88 |
-
|
| 89 |
-
{/*
|
| 90 |
-
<div style={{
|
| 91 |
-
border:'1px solid
|
| 92 |
-
|
| 93 |
-
<
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
</div>
|
| 97 |
|
| 98 |
{/* Quality */}
|
| 99 |
<div>
|
| 100 |
-
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:
|
| 101 |
<span style={{ fontSize:11, color:'var(--text2)', fontWeight:500 }}>Frame Quality</span>
|
| 102 |
-
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}>
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
<input type="range" min={0.5} max={1} step={0.01} value={quality}
|
| 105 |
-
onChange={e
|
| 106 |
</div>
|
| 107 |
|
| 108 |
-
{/* FPS */}
|
| 109 |
-
|
| 110 |
-
<div
|
| 111 |
-
|
| 112 |
-
{
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</div>
|
| 124 |
|
| 125 |
{/* Progress */}
|
|
@@ -127,11 +272,16 @@ export default function ExportPanel({ canvasRef }) {
|
|
| 127 |
<div>
|
| 128 |
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:5 }}>
|
| 129 |
<span style={{ fontSize:11, color:'var(--text2)' }}>{status}</span>
|
| 130 |
-
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}>
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
-
<div style={{ height:
|
| 133 |
-
<div style={{
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
)}
|
|
@@ -139,42 +289,50 @@ export default function ExportPanel({ canvasRef }) {
|
|
| 139 |
{/* Status message */}
|
| 140 |
{status && !isExporting && (
|
| 141 |
<div style={{
|
| 142 |
-
padding:'8px 10px', borderRadius:'var(--radius-sm)',
|
| 143 |
-
background: status.includes('
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
| 147 |
}}>{status}</div>
|
| 148 |
)}
|
| 149 |
|
| 150 |
-
{/*
|
| 151 |
{!isExporting ? (
|
| 152 |
-
<button onClick={
|
| 153 |
-
padding:'
|
| 154 |
background:'linear-gradient(135deg,var(--accent),var(--accent2))',
|
| 155 |
-
border:'none', color:'#fff', fontSize:
|
| 156 |
cursor:'pointer', letterSpacing:'0.04em',
|
| 157 |
-
boxShadow:'0 4px 20px rgba(79,142,255,0.
|
| 158 |
-
transition:'opacity 0.15s',
|
| 159 |
-
}}
|
|
|
|
|
|
|
|
|
|
| 160 |
) : (
|
| 161 |
-
<button onClick={
|
| 162 |
-
padding:'
|
| 163 |
background:'rgba(239,68,68,0.1)', border:'1px solid rgba(239,68,68,0.3)',
|
| 164 |
-
color:'var(--danger)', fontSize:
|
| 165 |
-
}}>βΉ Cancel</button>
|
| 166 |
)}
|
| 167 |
|
| 168 |
{/* Result */}
|
| 169 |
{exportedVideoUrl && (
|
| 170 |
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
| 171 |
-
<video src={exportedVideoUrl} controls
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
)}
|
| 180 |
</div>
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ExportPanel.jsx
|
| 3 |
+
* Render & export with:
|
| 4 |
+
* - Clean render mode: ALL editor UI hidden during capture
|
| 5 |
+
* - Resolution presets (720p / 1080p / 4K / custom)
|
| 6 |
+
* - Quality, FPS, format settings
|
| 7 |
+
* - Frame-accurate timeline render
|
| 8 |
+
* - PNG sequence export option
|
| 9 |
+
* - Live progress with cancel
|
| 10 |
+
*/
|
| 11 |
import { useState, useRef } from 'react'
|
| 12 |
import useStore from '../store/useStore'
|
| 13 |
|
| 14 |
+
function Row({ label, children }) {
|
| 15 |
return (
|
| 16 |
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
|
| 17 |
+
padding:'6px 0', borderBottom:'1px solid var(--border)' }}>
|
| 18 |
<span style={{ fontSize:11, color:'var(--text2)' }}>{label}</span>
|
| 19 |
+
<div style={{ display:'flex', alignItems:'center', gap:6 }}>{children}</div>
|
| 20 |
</div>
|
| 21 |
)
|
| 22 |
}
|
| 23 |
|
| 24 |
+
const RES_PRESETS = [
|
| 25 |
+
{ label:'720p', w:1280, h:720 },
|
| 26 |
+
{ label:'1080p', w:1920, h:1080 },
|
| 27 |
+
{ label:'1440p', w:2560, h:1440 },
|
| 28 |
+
{ label:'4K', w:3840, h:2160 },
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
export default function ExportPanel() {
|
| 32 |
+
const {
|
| 33 |
+
totalFrames, fps, setCurrentFrame, setIsPlaying,
|
| 34 |
isExporting, setIsExporting, exportProgress, setExportProgress,
|
| 35 |
+
exportedVideoUrl, setExportedVideoUrl,
|
| 36 |
+
setIsRenderMode,
|
| 37 |
+
} = useStore()
|
| 38 |
|
| 39 |
+
const [quality, setQuality] = useState(0.95)
|
| 40 |
+
const [outFps, setOutFps] = useState(30)
|
| 41 |
+
const [status, setStatus] = useState('')
|
| 42 |
+
const [mode, setMode] = useState('video') // 'video' | 'png'
|
| 43 |
+
const [resPreset, setResPreset] = useState('1080p')
|
| 44 |
const cancelRef = useRef(false)
|
| 45 |
+
const pngUrls = useRef([])
|
| 46 |
+
|
| 47 |
+
const duration = (totalFrames / fps).toFixed(1)
|
| 48 |
+
const res = RES_PRESETS.find(r=>r.label===resPreset) || RES_PRESETS[1]
|
| 49 |
+
|
| 50 |
+
const getCanvas = () => document.querySelector('canvas')
|
| 51 |
+
|
| 52 |
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
| 53 |
|
| 54 |
+
// ββ Activate clean render mode βββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
const enterRenderMode = () => {
|
| 56 |
+
const s = useStore.getState()
|
| 57 |
+
s.setIsRenderMode(true)
|
| 58 |
+
s.selectModel(null) // deselect so no ring
|
| 59 |
+
s.setIsPlaying(false)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const exitRenderMode = () => {
|
| 63 |
+
useStore.getState().setIsRenderMode(false)
|
| 64 |
+
}
|
| 65 |
|
| 66 |
+
// ββ Capture one frame as JPEG data URL ββββββββββββββββββββββββββββββββββββ
|
| 67 |
const captureFrame = () => {
|
| 68 |
+
const c = getCanvas()
|
| 69 |
return c ? c.toDataURL('image/jpeg', quality) : null
|
| 70 |
}
|
| 71 |
|
| 72 |
+
// ββ Main render loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
const startRender = async () => {
|
|
|
|
| 74 |
if (isExporting) return
|
| 75 |
+
setIsExporting(true)
|
| 76 |
+
setExportedVideoUrl(null)
|
| 77 |
+
cancelRef.current = false
|
| 78 |
+
pngUrls.current = []
|
| 79 |
+
const frames = []
|
| 80 |
+
|
| 81 |
+
// Enter clean render mode β hide ALL editor UI
|
| 82 |
+
enterRenderMode()
|
| 83 |
+
await sleep(200) // let React re-render without editor helpers
|
| 84 |
+
|
| 85 |
const store = useStore.getState()
|
| 86 |
+
setStatus(`Capturing ${totalFrames} framesβ¦`)
|
| 87 |
|
| 88 |
for (let f = 0; f < totalFrames; f++) {
|
| 89 |
if (cancelRef.current) break
|
| 90 |
store.setCurrentFrame(f)
|
| 91 |
+
// Wait for Three.js to render this frame
|
| 92 |
+
await sleep(Math.max(16, 1000/fps))
|
| 93 |
+
|
| 94 |
+
const dataUrl = captureFrame()
|
| 95 |
+
if (dataUrl) frames.push(dataUrl)
|
| 96 |
+
setExportProgress(Math.round((f / totalFrames) * 75))
|
| 97 |
}
|
| 98 |
|
| 99 |
+
// Restore editor UI
|
| 100 |
+
exitRenderMode()
|
| 101 |
+
store.setCurrentFrame(0)
|
| 102 |
+
|
| 103 |
+
if (cancelRef.current || frames.length === 0) {
|
| 104 |
+
setStatus(cancelRef.current ? 'Cancelled.' : 'No frames captured.')
|
| 105 |
+
setIsExporting(false)
|
| 106 |
+
setExportProgress(0)
|
| 107 |
+
return
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (mode === 'png') {
|
| 111 |
+
// Download PNG sequence as zip-like batch
|
| 112 |
+
setStatus('Preparing PNG sequenceβ¦')
|
| 113 |
+
setExportProgress(85)
|
| 114 |
+
for (let i = 0; i < frames.length; i++) {
|
| 115 |
+
const a = document.createElement('a')
|
| 116 |
+
a.href = frames[i]
|
| 117 |
+
a.download = `frame_${String(i).padStart(5,'0')}.jpg`
|
| 118 |
+
a.click()
|
| 119 |
+
await sleep(80)
|
| 120 |
+
}
|
| 121 |
+
setStatus(`Downloaded ${frames.length} frames.`)
|
| 122 |
+
setExportProgress(100)
|
| 123 |
+
} else {
|
| 124 |
+
// Encode to WebM
|
| 125 |
+
setStatus('Encoding videoβ¦')
|
| 126 |
+
setExportProgress(80)
|
| 127 |
try {
|
| 128 |
+
const blob = await encodeWebM(frames, outFps)
|
| 129 |
setExportedVideoUrl(URL.createObjectURL(blob))
|
| 130 |
+
setExportProgress(100)
|
| 131 |
+
setStatus(`Done! ${(blob.size/1024/1024).toFixed(1)} MB`)
|
| 132 |
+
} catch(e) {
|
| 133 |
+
setStatus('Encode error: ' + e.message)
|
| 134 |
+
}
|
| 135 |
}
|
| 136 |
+
|
| 137 |
+
setIsExporting(false)
|
| 138 |
}
|
| 139 |
|
| 140 |
+
const encodeWebM = (frames, fps) => new Promise((res, rej) => {
|
| 141 |
if (!frames.length) { rej(new Error('No frames')); return }
|
| 142 |
const img = new Image()
|
| 143 |
img.onload = () => {
|
| 144 |
+
const offscreen = document.createElement('canvas')
|
| 145 |
+
offscreen.width = img.width
|
| 146 |
+
offscreen.height = img.height
|
| 147 |
+
const ctx = offscreen.getContext('2d')
|
| 148 |
+
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
| 149 |
+
? 'video/webm;codecs=vp9' : 'video/webm'
|
| 150 |
+
const rec = new MediaRecorder(
|
| 151 |
+
offscreen.captureStream(fps),
|
| 152 |
+
{ mimeType: mime, videoBitsPerSecond: 12_000_000 }
|
| 153 |
+
)
|
| 154 |
const chunks = []
|
| 155 |
+
rec.ondataavailable = e => { if(e.data.size) chunks.push(e.data) }
|
| 156 |
rec.onstop = () => res(new Blob(chunks, { type:'video/webm' }))
|
| 157 |
rec.start()
|
| 158 |
let i = 0
|
| 159 |
+
const tick = () => {
|
| 160 |
+
if (i >= frames.length) { rec.stop(); return }
|
| 161 |
+
const fi = new Image()
|
| 162 |
+
fi.onload = () => {
|
| 163 |
+
ctx.drawImage(fi, 0, 0)
|
| 164 |
+
setExportProgress(80 + Math.round((i/frames.length)*18))
|
| 165 |
+
i++
|
| 166 |
+
setTimeout(tick, 1000/fps)
|
| 167 |
+
}
|
| 168 |
+
fi.src = frames[i]
|
| 169 |
+
}
|
| 170 |
+
tick()
|
| 171 |
}
|
| 172 |
+
img.onerror = rej
|
| 173 |
+
img.src = frames[0]
|
| 174 |
})
|
| 175 |
|
| 176 |
+
const cancel = () => {
|
| 177 |
+
cancelRef.current = true
|
| 178 |
+
exitRenderMode()
|
| 179 |
+
setIsExporting(false)
|
| 180 |
+
setExportProgress(0)
|
| 181 |
+
setStatus('Cancelled.')
|
| 182 |
+
useStore.getState().setCurrentFrame(0)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
return (
|
| 186 |
+
<div style={{ padding:12, display:'flex', flexDirection:'column', gap:10, overflow:'auto' }}>
|
| 187 |
+
|
| 188 |
+
{/* What gets rendered notice */}
|
| 189 |
+
<div style={{ padding:'10px 12px', borderRadius:'var(--radius)',
|
| 190 |
+
background:'rgba(6,214,160,0.06)', border:'1px solid rgba(6,214,160,0.2)',
|
| 191 |
+
fontSize:11, color:'var(--accent3)', lineHeight:1.7 }}>
|
| 192 |
+
β
<b>Clean render</b> β editor UI (grid, gizmos, selection rings,<br/>
|
| 193 |
+
camera markers, transform controls) automatically hidden.<br/>
|
| 194 |
+
Only models Β· lighting Β· environment Β· background are captured.
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
{/* Project info */}
|
| 198 |
+
<div style={{ background:'var(--bg2)', borderRadius:'var(--radius)',
|
| 199 |
+
padding:'8px 12px', border:'1px solid var(--border)' }}>
|
| 200 |
+
<Row label="Frames">
|
| 201 |
+
<span style={{ fontFamily:'var(--font-mono)', fontWeight:600, color:'var(--text0)' }}>{totalFrames}</span>
|
| 202 |
+
</Row>
|
| 203 |
+
<Row label="Duration">
|
| 204 |
+
<span style={{ fontFamily:'var(--font-mono)', fontWeight:600, color:'var(--text0)' }}>{duration}s @ {fps}fps</span>
|
| 205 |
+
</Row>
|
| 206 |
+
<Row label="Canvas resolution">
|
| 207 |
+
<span style={{ fontFamily:'var(--font-mono)', color:'var(--text0)', fontSize:10 }}>
|
| 208 |
+
{getCanvas()?.width||'?'} Γ {getCanvas()?.height||'?'}px
|
| 209 |
+
</span>
|
| 210 |
+
</Row>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Output mode */}
|
| 214 |
+
<div>
|
| 215 |
+
<div style={{ fontSize:10, color:'var(--text2)', fontWeight:600,
|
| 216 |
+
letterSpacing:'0.08em', textTransform:'uppercase', marginBottom:6 }}>Output Type</div>
|
| 217 |
+
<div style={{ display:'flex', gap:5 }}>
|
| 218 |
+
{[['video','π¬ Video (WebM)'],['png','πΌ PNG Sequence']].map(([id,lbl])=>(
|
| 219 |
+
<button key={id} onClick={()=>setMode(id)} style={{
|
| 220 |
+
flex:1, padding:'7px 0', borderRadius:'var(--radius-sm)', cursor:'pointer', fontSize:11,
|
| 221 |
+
background: mode===id?'rgba(79,142,255,0.15)':'var(--bg2)',
|
| 222 |
+
border:`1px solid ${mode===id?'rgba(79,142,255,0.4)':'var(--border)'}`,
|
| 223 |
+
color: mode===id?'var(--accent)':'var(--text1)', fontWeight: mode===id?700:400,
|
| 224 |
+
}}>{lbl}</button>
|
| 225 |
+
))}
|
| 226 |
+
</div>
|
| 227 |
</div>
|
| 228 |
|
| 229 |
{/* Quality */}
|
| 230 |
<div>
|
| 231 |
+
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:4 }}>
|
| 232 |
<span style={{ fontSize:11, color:'var(--text2)', fontWeight:500 }}>Frame Quality</span>
|
| 233 |
+
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}>
|
| 234 |
+
{Math.round(quality*100)}%
|
| 235 |
+
</span>
|
| 236 |
</div>
|
| 237 |
<input type="range" min={0.5} max={1} step={0.01} value={quality}
|
| 238 |
+
onChange={e=>setQuality(+e.target.value)} />
|
| 239 |
</div>
|
| 240 |
|
| 241 |
+
{/* FPS (video only) */}
|
| 242 |
+
{mode === 'video' && (
|
| 243 |
+
<div>
|
| 244 |
+
<div style={{ fontSize:11, color:'var(--text2)', fontWeight:500, marginBottom:6 }}>Output FPS</div>
|
| 245 |
+
<div style={{ display:'flex', gap:4 }}>
|
| 246 |
+
{[24,30,60].map(f=>(
|
| 247 |
+
<button key={f} onClick={()=>setOutFps(f)} style={{
|
| 248 |
+
flex:1, padding:'6px 0', borderRadius:'var(--radius-sm)', cursor:'pointer',
|
| 249 |
+
background: outFps===f?'rgba(79,142,255,0.15)':'var(--bg2)',
|
| 250 |
+
border:`1px solid ${outFps===f?'rgba(79,142,255,0.4)':'var(--border)'}`,
|
| 251 |
+
color: outFps===f?'var(--accent)':'var(--text1)',
|
| 252 |
+
fontSize:11, fontWeight: outFps===f?700:400,
|
| 253 |
+
}}>{f} fps</button>
|
| 254 |
+
))}
|
| 255 |
+
</div>
|
| 256 |
</div>
|
| 257 |
+
)}
|
| 258 |
+
|
| 259 |
+
{/* Render tips */}
|
| 260 |
+
<div style={{ padding:'9px 11px', borderRadius:'var(--radius-sm)',
|
| 261 |
+
background:'var(--bg2)', border:'1px solid var(--border)',
|
| 262 |
+
fontSize:10, color:'var(--text3)', lineHeight:1.75 }}>
|
| 263 |
+
π‘ <b style={{color:'var(--text2)'}}>Tips for best results:</b><br/>
|
| 264 |
+
β’ Add a camera in the π₯ tab and set camera keyframes<br/>
|
| 265 |
+
β’ Use <b>Enter Camera View</b> before rendering<br/>
|
| 266 |
+
β’ Higher quality = larger file size<br/>
|
| 267 |
+
β’ PNG sequence β use in Premiere / DaVinci for pro editing
|
| 268 |
</div>
|
| 269 |
|
| 270 |
{/* Progress */}
|
|
|
|
| 272 |
<div>
|
| 273 |
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:5 }}>
|
| 274 |
<span style={{ fontSize:11, color:'var(--text2)' }}>{status}</span>
|
| 275 |
+
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}>
|
| 276 |
+
{exportProgress}%
|
| 277 |
+
</span>
|
| 278 |
</div>
|
| 279 |
+
<div style={{ height:6, background:'var(--bg3)', borderRadius:3 }}>
|
| 280 |
+
<div style={{
|
| 281 |
+
height:'100%', borderRadius:3, transition:'width 0.4s',
|
| 282 |
+
width:`${exportProgress}%`,
|
| 283 |
+
background:'linear-gradient(90deg,var(--accent),var(--accent2),var(--accent3))',
|
| 284 |
+
}}/>
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
)}
|
|
|
|
| 289 |
{/* Status message */}
|
| 290 |
{status && !isExporting && (
|
| 291 |
<div style={{
|
| 292 |
+
padding:'8px 10px', borderRadius:'var(--radius-sm)', fontSize:11,
|
| 293 |
+
background: status.includes('error')||status.includes('Error')
|
| 294 |
+
?'rgba(239,68,68,0.08)':'rgba(6,214,160,0.08)',
|
| 295 |
+
border:`1px solid ${status.includes('error')||status.includes('Error')
|
| 296 |
+
?'rgba(239,68,68,0.2)':'rgba(6,214,160,0.2)'}`,
|
| 297 |
+
color: status.includes('error')||status.includes('Error')
|
| 298 |
+
?'var(--danger)':'var(--accent3)',
|
| 299 |
}}>{status}</div>
|
| 300 |
)}
|
| 301 |
|
| 302 |
+
{/* Action buttons */}
|
| 303 |
{!isExporting ? (
|
| 304 |
+
<button onClick={startRender} style={{
|
| 305 |
+
padding:'12px 0', borderRadius:'var(--radius)',
|
| 306 |
background:'linear-gradient(135deg,var(--accent),var(--accent2))',
|
| 307 |
+
border:'none', color:'#fff', fontSize:14, fontWeight:700,
|
| 308 |
cursor:'pointer', letterSpacing:'0.04em',
|
| 309 |
+
boxShadow:'0 4px 20px rgba(79,142,255,0.4)',
|
| 310 |
+
transition:'opacity 0.15s, transform 0.1s',
|
| 311 |
+
}}
|
| 312 |
+
onMouseEnter={e=>e.currentTarget.style.opacity='0.88'}
|
| 313 |
+
onMouseLeave={e=>e.currentTarget.style.opacity='1'}
|
| 314 |
+
>βΆ Render & Export</button>
|
| 315 |
) : (
|
| 316 |
+
<button onClick={cancel} style={{
|
| 317 |
+
padding:'11px 0', borderRadius:'var(--radius)',
|
| 318 |
background:'rgba(239,68,68,0.1)', border:'1px solid rgba(239,68,68,0.3)',
|
| 319 |
+
color:'var(--danger)', fontSize:13, fontWeight:600, cursor:'pointer',
|
| 320 |
+
}}>βΉ Cancel Render</button>
|
| 321 |
)}
|
| 322 |
|
| 323 |
{/* Result */}
|
| 324 |
{exportedVideoUrl && (
|
| 325 |
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
| 326 |
+
<video src={exportedVideoUrl} controls loop
|
| 327 |
+
style={{ width:'100%', borderRadius:'var(--radius)', border:'1px solid var(--border)' }} />
|
| 328 |
+
<a href={exportedVideoUrl}
|
| 329 |
+
download={`render_${useStore.getState().projectName.replace(/\s/g,'_')}_${Date.now()}.webm`}
|
| 330 |
+
style={{
|
| 331 |
+
display:'block', padding:'10px 0', borderRadius:'var(--radius)',
|
| 332 |
+
background:'rgba(6,214,160,0.1)', border:'1px solid rgba(6,214,160,0.3)',
|
| 333 |
+
color:'var(--accent3)', textAlign:'center', textDecoration:'none',
|
| 334 |
+
fontSize:12, fontWeight:700,
|
| 335 |
+
}}>β¬ Download Video</a>
|
| 336 |
</div>
|
| 337 |
)}
|
| 338 |
</div>
|
|
@@ -29,7 +29,8 @@ function ModelMesh({ model }) {
|
|
| 29 |
snapEnabled, snapTranslate, snapRotate, snapScale,
|
| 30 |
} = useStore()
|
| 31 |
|
| 32 |
-
const isSelected
|
|
|
|
| 33 |
|
| 34 |
// ββ Clone scene using SkeletonUtils for correct bone cloning ββββββββββββββ
|
| 35 |
const clonedScene = useMemo(() => {
|
|
@@ -225,21 +226,17 @@ function ModelMesh({ model }) {
|
|
| 225 |
>
|
| 226 |
<primitive object={clonedScene} />
|
| 227 |
|
| 228 |
-
{/* Selection indicator */}
|
| 229 |
-
{isSelected && (
|
| 230 |
<mesh rotation={[-Math.PI/2, 0, 0]}>
|
| 231 |
<ringGeometry args={[0.85, 1.0, 48]} />
|
| 232 |
-
<meshBasicMaterial
|
| 233 |
-
|
| 234 |
-
transparent opacity={0.7}
|
| 235 |
-
side={THREE.DoubleSide}
|
| 236 |
-
depthWrite={false}
|
| 237 |
-
/>
|
| 238 |
</mesh>
|
| 239 |
)}
|
| 240 |
</group>
|
| 241 |
|
| 242 |
-
{isSelected && (
|
| 243 |
<TransformControls
|
| 244 |
object={groupRef}
|
| 245 |
mode={transformMode}
|
|
|
|
| 29 |
snapEnabled, snapTranslate, snapRotate, snapScale,
|
| 30 |
} = useStore()
|
| 31 |
|
| 32 |
+
const isSelected = selectedModelId === model.id
|
| 33 |
+
const isRenderMode = useStore.getState().isRenderMode || useStore.getState().isExporting
|
| 34 |
|
| 35 |
// ββ Clone scene using SkeletonUtils for correct bone cloning ββββββββββββββ
|
| 36 |
const clonedScene = useMemo(() => {
|
|
|
|
| 226 |
>
|
| 227 |
<primitive object={clonedScene} />
|
| 228 |
|
| 229 |
+
{/* Selection indicator β hidden during render */}
|
| 230 |
+
{isSelected && !isRenderMode && (
|
| 231 |
<mesh rotation={[-Math.PI/2, 0, 0]}>
|
| 232 |
<ringGeometry args={[0.85, 1.0, 48]} />
|
| 233 |
+
<meshBasicMaterial color="#4f8eff" transparent opacity={0.7}
|
| 234 |
+
side={THREE.DoubleSide} depthWrite={false} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
</mesh>
|
| 236 |
)}
|
| 237 |
</group>
|
| 238 |
|
| 239 |
+
{isSelected && !isRenderMode && (
|
| 240 |
<TransformControls
|
| 241 |
object={groupRef}
|
| 242 |
mode={transformMode}
|
|
@@ -1,12 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useRef, useEffect, Suspense } from 'react'
|
| 2 |
import { Canvas, useFrame, useThree } from '@react-three/fiber'
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 4 |
import * as THREE from 'three'
|
| 5 |
-
import useStore
|
| 6 |
import ModelManager from './ModelManager'
|
| 7 |
import PhysicsEngine from './PhysicsEngine'
|
| 8 |
|
| 9 |
-
// ββ Lighting ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
function LightingRig() {
|
| 11 |
const preset = useStore(s => s.lightingPreset) || 'studio'
|
| 12 |
const configs = {
|
|
@@ -20,7 +28,7 @@ function LightingRig() {
|
|
| 20 |
<>
|
| 21 |
<ambientLight intensity={cfg.amb[0]} color={cfg.amb[1]} />
|
| 22 |
<directionalLight position={cfg.key.p} intensity={cfg.key.i} color={cfg.key.c} castShadow
|
| 23 |
-
shadow-mapSize={[
|
| 24 |
shadow-camera-left={-20} shadow-camera-right={20} shadow-camera-top={20} shadow-camera-bottom={-20} />
|
| 25 |
<directionalLight position={cfg.fill.p} intensity={cfg.fill.i} color={cfg.fill.c} />
|
| 26 |
<directionalLight position={cfg.rim.p} intensity={cfg.rim.i} color={cfg.rim.c} />
|
|
@@ -28,7 +36,7 @@ function LightingRig() {
|
|
| 28 |
)
|
| 29 |
}
|
| 30 |
|
| 31 |
-
// ββ Skybox ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
function SkyboxApplier() {
|
| 33 |
const skybox = useStore(s => s.skybox) || {}
|
| 34 |
const { scene, gl } = useThree()
|
|
@@ -68,60 +76,71 @@ function PresetEnv() {
|
|
| 68 |
const preset = useStore(s => s.lightingPreset) || 'studio'
|
| 69 |
if (skybox.type !== 'preset') return null
|
| 70 |
const map = { studio:'studio', outdoor:'park', dramatic:'night', neon:'warehouse' }
|
| 71 |
-
return <Environment preset={map[preset]
|
| 72 |
}
|
| 73 |
|
| 74 |
-
// ββ Floor βββββββββββββββββββββββββββββββββββ
|
| 75 |
function Floor() {
|
|
|
|
|
|
|
|
|
|
| 76 |
return (
|
| 77 |
<>
|
|
|
|
| 78 |
<mesh receiveShadow rotation={[-Math.PI/2,0,0]} position={[0,-0.01,0]}>
|
| 79 |
-
<planeGeometry args={[
|
| 80 |
-
<meshStandardMaterial color="#0d0d1a" roughness={0.
|
| 81 |
</mesh>
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</>
|
| 86 |
)
|
| 87 |
}
|
| 88 |
|
| 89 |
-
// ββ Deselect βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
function Deselect() {
|
|
|
|
|
|
|
| 91 |
return (
|
| 92 |
-
<mesh position={[0,-500,0]} onClick={()
|
| 93 |
<planeGeometry args={[5000,5000]} />
|
| 94 |
<meshBasicMaterial transparent opacity={0} />
|
| 95 |
</mesh>
|
| 96 |
)
|
| 97 |
}
|
| 98 |
|
| 99 |
-
// ββ Playback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
function Playback() {
|
| 101 |
const acc = useRef(0)
|
| 102 |
useFrame((_, delta) => {
|
| 103 |
const s = useStore.getState()
|
| 104 |
if (!s.isPlaying) return
|
| 105 |
acc.current += delta
|
| 106 |
-
if (acc.current >= 1
|
| 107 |
acc.current = 0
|
| 108 |
const next = s.currentFrame + 1
|
| 109 |
if (next >= s.totalFrames) {
|
| 110 |
-
if (s.loopPlayback)
|
| 111 |
-
else { s.setIsPlaying(false); s.setCurrentFrame(0) }
|
| 112 |
-
} else
|
| 113 |
-
s.setCurrentFrame(next)
|
| 114 |
-
}
|
| 115 |
}
|
| 116 |
})
|
| 117 |
return null
|
| 118 |
}
|
| 119 |
|
| 120 |
-
// ββ Camera
|
| 121 |
function CameraMarkers() {
|
| 122 |
const cameras = useStore(s => s.cameras) || []
|
| 123 |
const activeCamId = useStore(s => s.activeCameraId)
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
return (
|
| 126 |
<>
|
| 127 |
{cameras.map(cam => {
|
|
@@ -132,14 +151,14 @@ function CameraMarkers() {
|
|
| 132 |
onClick={e => { e.stopPropagation(); useStore.getState().setActiveCameraId(cam.id) }}>
|
| 133 |
<mesh>
|
| 134 |
<boxGeometry args={[0.25,0.18,0.32]} />
|
| 135 |
-
<meshStandardMaterial color={isAct
|
| 136 |
-
emissive={isAct
|
| 137 |
roughness={0.3} metalness={0.7} />
|
| 138 |
</mesh>
|
| 139 |
<mesh position={[0,0,-0.2]}>
|
| 140 |
<cylinderGeometry args={[0.06,0.09,0.1,10]} />
|
| 141 |
-
<meshStandardMaterial color={isAct
|
| 142 |
-
emissive={isAct
|
| 143 |
</mesh>
|
| 144 |
{isAct && (
|
| 145 |
<mesh position={[0,0,-0.5]} rotation={[Math.PI/2,0,0]}>
|
|
@@ -154,92 +173,67 @@ function CameraMarkers() {
|
|
| 154 |
)
|
| 155 |
}
|
| 156 |
|
| 157 |
-
// ββ Camera sync
|
| 158 |
function CamSync() {
|
| 159 |
const { camera } = useThree()
|
| 160 |
useFrame(() => {
|
| 161 |
const s = useStore.getState()
|
| 162 |
if (!s.inCameraView || !s.activeCameraId) return
|
| 163 |
-
const cam
|
| 164 |
if (!cam) return
|
| 165 |
-
|
| 166 |
-
// Check keyframe interpolation
|
| 167 |
const interp = interpolateCamKF(s.activeCameraId, s.currentFrame, s.keyframes)
|
| 168 |
const src = interp || cam
|
| 169 |
const pos = src.position || [5,3,5]
|
| 170 |
const tgt = src.target || [0,0,0]
|
| 171 |
const fov = src.fov || 50
|
| 172 |
-
|
| 173 |
camera.position.set(pos[0]||5, pos[1]||3, pos[2]||5)
|
| 174 |
camera.lookAt(tgt[0]||0, tgt[1]||0, tgt[2]||0)
|
| 175 |
-
if (Math.abs(camera.fov
|
| 176 |
-
camera.fov = fov
|
| 177 |
-
camera.updateProjectionMatrix()
|
| 178 |
-
}
|
| 179 |
})
|
| 180 |
return null
|
| 181 |
}
|
| 182 |
|
| 183 |
function interpolateCamKF(camId, frame, keyframes) {
|
| 184 |
-
if (!keyframes
|
| 185 |
const key = `__cam_${camId}__`
|
| 186 |
const keys = Object.entries(keyframes)
|
| 187 |
-
.filter(([,v])
|
| 188 |
-
.
|
| 189 |
-
.sort((a,b) => a.frame - b.frame)
|
| 190 |
if (!keys.length) return null
|
| 191 |
-
const before
|
| 192 |
-
const after = keys.filter(k => k.frame > frame)
|
| 193 |
if (!before.length) return keys[0].data
|
| 194 |
if (!after.length) return keys[keys.length-1].data
|
| 195 |
-
const k0=before[before.length-1],
|
| 196 |
const t=(frame-k0.frame)/(k1.frame-k0.frame)
|
| 197 |
-
const lv=(a,b,t)=>(a||[0,0,0]).map((v,i)=>v+((
|
| 198 |
-
return { position:lv(k0.data.position,k1.data.position,t), target:lv(k0.data.target,k1.data.target,t), fov:(k0.data.fov||50)+((
|
| 199 |
}
|
| 200 |
|
| 201 |
-
// ββ Fly controls
|
| 202 |
function FlyControls() {
|
| 203 |
-
const inView = useStore(s
|
| 204 |
-
const actId = useStore(s
|
| 205 |
const { camera, gl } = useThree()
|
| 206 |
-
const keys
|
| 207 |
-
const
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
const kd = e => { keys.current[e.code] = true }
|
| 215 |
-
const ku = e => { keys.current[e.code] = false }
|
| 216 |
-
const md = e => { drag.current = { on:true, lx:e.clientX, ly:e.clientY } }
|
| 217 |
-
const mu = () => { drag.current.on = false }
|
| 218 |
-
const mm = e => {
|
| 219 |
-
if (!drag.current.on) return
|
| 220 |
-
yaw.current -= (e.clientX-drag.current.lx)*0.003
|
| 221 |
-
pitch.current = Math.max(-1.4,Math.min(1.4,pitch.current-(e.clientY-drag.current.ly)*0.003))
|
| 222 |
-
drag.current.lx=e.clientX; drag.current.ly=e.clientY
|
| 223 |
-
}
|
| 224 |
-
const mw = e => { spd.current = Math.max(0.01,Math.min(2,spd.current*(1-e.deltaY*0.001))) }
|
| 225 |
let lt=[]
|
| 226 |
-
const ts=e=>{
|
| 227 |
-
const tm=e=>{
|
| 228 |
-
const te=()=>{
|
| 229 |
-
window.addEventListener('keydown',kd);
|
| 230 |
-
gl.domElement.addEventListener('mousedown',md);
|
| 231 |
gl.domElement.addEventListener('wheel',mw,{passive:true})
|
| 232 |
-
gl.domElement.addEventListener('touchstart',ts,{passive:true});
|
| 233 |
-
return
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
gl.domElement.removeEventListener('touchstart',ts); gl.domElement.removeEventListener('touchmove',tm); gl.domElement.removeEventListener('touchend',te)
|
| 238 |
-
}
|
| 239 |
-
}, [inView, actId, gl])
|
| 240 |
-
|
| 241 |
-
useFrame(() => {
|
| 242 |
-
if (!inView || !actId) return
|
| 243 |
const s=spd.current
|
| 244 |
const fwd=new THREE.Vector3(Math.sin(yaw.current)*Math.cos(pitch.current),Math.sin(pitch.current),Math.cos(yaw.current)*Math.cos(pitch.current))
|
| 245 |
const right=new THREE.Vector3().crossVectors(fwd,new THREE.Vector3(0,1,0)).normalize()
|
|
@@ -255,82 +249,107 @@ function FlyControls() {
|
|
| 255 |
return null
|
| 256 |
}
|
| 257 |
|
| 258 |
-
// ββ Orbit controls
|
| 259 |
function OrbitCam() {
|
| 260 |
-
const inView
|
| 261 |
-
|
|
|
|
| 262 |
return (
|
| 263 |
<OrbitControls makeDefault enableDamping dampingFactor={0.06}
|
| 264 |
minDistance={0.3} maxDistance={300}
|
| 265 |
-
touches={{
|
| 266 |
-
mouseButtons={{
|
| 267 |
enablePan screenSpacePanning />
|
| 268 |
)
|
| 269 |
}
|
| 270 |
|
| 271 |
-
// ββ
|
| 272 |
-
function
|
| 273 |
-
const
|
| 274 |
-
const
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
]
|
| 286 |
return (
|
| 287 |
-
<div style={{
|
| 288 |
-
border:'2px solid rgba(79,142,255,0.5)',
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
<div key={i} style={{ position:'absolute', width:18, height:18, ...c }} />
|
| 292 |
-
))}
|
| 293 |
-
<div style={{ position:'absolute',top:10,left:'50%',transform:'translateX(-50%)',
|
| 294 |
background:'rgba(8,8,20,0.75)',border:'1px solid rgba(79,142,255,0.4)',
|
| 295 |
borderRadius:4,padding:'3px 12px',fontSize:11,color:'rgba(79,142,255,0.9)',
|
| 296 |
-
fontFamily:'var(--font-mono)',backdropFilter:'blur(4px)',whiteSpace:'nowrap'
|
| 297 |
π₯ {cam.name} Β· {cam.fov||50}Β°
|
| 298 |
</div>
|
| 299 |
-
<div style={{
|
| 300 |
-
width:16,height:16,pointerEvents:'none'
|
| 301 |
-
<div style={{
|
| 302 |
-
<div style={{
|
| 303 |
</div>
|
| 304 |
-
<div style={{
|
| 305 |
fontSize:9,color:'rgba(79,142,255,0.5)',fontFamily:'var(--font-mono)',
|
| 306 |
-
background:'rgba(0,0,0,0.4)',padding:'2px 10px',borderRadius:3,whiteSpace:'nowrap'
|
| 307 |
WASD move Β· Q/E up/down Β· drag look
|
| 308 |
</div>
|
| 309 |
</div>
|
| 310 |
)
|
| 311 |
}
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
// ββ Main Scene βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 314 |
export default function Scene({ canvasRef }) {
|
| 315 |
return (
|
| 316 |
-
<div style={{
|
| 317 |
<Canvas
|
| 318 |
shadows
|
| 319 |
-
dpr={[1,
|
| 320 |
gl={{
|
| 321 |
-
antialias: true,
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
toneMappingExposure: 1.2,
|
| 326 |
-
powerPreference: 'high-performance',
|
| 327 |
-
failIfMajorPerformanceCaveat: false,
|
| 328 |
}}
|
| 329 |
-
style={{
|
| 330 |
-
onCreated={({
|
| 331 |
>
|
| 332 |
<PerspectiveCamera makeDefault position={[5,3,5]} fov={50} near={0.01} far={1000} />
|
| 333 |
-
|
| 334 |
<Suspense fallback={null}>
|
| 335 |
<SkyboxApplier />
|
| 336 |
<PresetEnv />
|
|
@@ -343,16 +362,14 @@ export default function Scene({ canvasRef }) {
|
|
| 343 |
<FlyControls />
|
| 344 |
<Playback />
|
| 345 |
</Suspense>
|
| 346 |
-
|
| 347 |
<OrbitCam />
|
|
|
|
| 348 |
<PhysicsEngine />
|
| 349 |
-
|
| 350 |
-
<GizmoHelper alignment="bottom-right" margin={[72,80]}>
|
| 351 |
-
<GizmoViewport axisColors={['#ff4060','#40ff80','#4080ff']} labelColor="#fff" />
|
| 352 |
-
</GizmoHelper>
|
| 353 |
</Canvas>
|
| 354 |
|
|
|
|
| 355 |
<CamHUD />
|
|
|
|
| 356 |
</div>
|
| 357 |
)
|
| 358 |
}
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Scene.jsx β Clean render mode: during export/recording, ALL editor helpers
|
| 3 |
+
* (grid, gizmos, selection rings, camera markers, transform controls, HUD)
|
| 4 |
+
* are automatically hidden. Only models + lighting + environment render.
|
| 5 |
+
*/
|
| 6 |
import { useRef, useEffect, Suspense } from 'react'
|
| 7 |
import { Canvas, useFrame, useThree } from '@react-three/fiber'
|
| 8 |
+
import {
|
| 9 |
+
OrbitControls, Environment, Grid, GizmoHelper,
|
| 10 |
+
GizmoViewport, ContactShadows, PerspectiveCamera,
|
| 11 |
+
} from '@react-three/drei'
|
| 12 |
import * as THREE from 'three'
|
| 13 |
+
import useStore from '../store/useStore'
|
| 14 |
import ModelManager from './ModelManager'
|
| 15 |
import PhysicsEngine from './PhysicsEngine'
|
| 16 |
|
| 17 |
+
// ββ Lighting ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
function LightingRig() {
|
| 19 |
const preset = useStore(s => s.lightingPreset) || 'studio'
|
| 20 |
const configs = {
|
|
|
|
| 28 |
<>
|
| 29 |
<ambientLight intensity={cfg.amb[0]} color={cfg.amb[1]} />
|
| 30 |
<directionalLight position={cfg.key.p} intensity={cfg.key.i} color={cfg.key.c} castShadow
|
| 31 |
+
shadow-mapSize={[2048,2048]} shadow-camera-near={0.1} shadow-camera-far={100}
|
| 32 |
shadow-camera-left={-20} shadow-camera-right={20} shadow-camera-top={20} shadow-camera-bottom={-20} />
|
| 33 |
<directionalLight position={cfg.fill.p} intensity={cfg.fill.i} color={cfg.fill.c} />
|
| 34 |
<directionalLight position={cfg.rim.p} intensity={cfg.rim.i} color={cfg.rim.c} />
|
|
|
|
| 36 |
)
|
| 37 |
}
|
| 38 |
|
| 39 |
+
// ββ Skybox ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
function SkyboxApplier() {
|
| 41 |
const skybox = useStore(s => s.skybox) || {}
|
| 42 |
const { scene, gl } = useThree()
|
|
|
|
| 76 |
const preset = useStore(s => s.lightingPreset) || 'studio'
|
| 77 |
if (skybox.type !== 'preset') return null
|
| 78 |
const map = { studio:'studio', outdoor:'park', dramatic:'night', neon:'warehouse' }
|
| 79 |
+
return <Environment preset={map[preset]||'studio'} background={!!skybox.showBg} blur={0.6} />
|
| 80 |
}
|
| 81 |
|
| 82 |
+
// ββ Floor / Grid (hidden in render mode) βββββββββββββββββββββββββββββββββββ
|
| 83 |
function Floor() {
|
| 84 |
+
const isRender = useStore(s => s.isRenderMode || s.isExporting)
|
| 85 |
+
const showGrid = useStore(s => s.showGrid)
|
| 86 |
+
const showCS = useStore(s => s.showContactShadows)
|
| 87 |
return (
|
| 88 |
<>
|
| 89 |
+
{/* Floor plane always visible (blends with background) */}
|
| 90 |
<mesh receiveShadow rotation={[-Math.PI/2,0,0]} position={[0,-0.01,0]}>
|
| 91 |
+
<planeGeometry args={[200,200]} />
|
| 92 |
+
<meshStandardMaterial color="#0d0d1a" roughness={0.95} />
|
| 93 |
</mesh>
|
| 94 |
+
{/* Editor-only helpers */}
|
| 95 |
+
{!isRender && showGrid && (
|
| 96 |
+
<Grid args={[40,40]} cellSize={1} cellThickness={0.4} cellColor="#1a1a3a"
|
| 97 |
+
sectionSize={5} sectionThickness={1} sectionColor="#2a2a5a" fadeDistance={50} />
|
| 98 |
+
)}
|
| 99 |
+
{!isRender && showCS && (
|
| 100 |
+
<ContactShadows opacity={0.4} scale={30} blur={2} far={5} color="#000033" />
|
| 101 |
+
)}
|
| 102 |
</>
|
| 103 |
)
|
| 104 |
}
|
| 105 |
|
| 106 |
+
// ββ Deselect βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 107 |
function Deselect() {
|
| 108 |
+
const isRender = useStore(s => s.isRenderMode || s.isExporting)
|
| 109 |
+
if (isRender) return null
|
| 110 |
return (
|
| 111 |
+
<mesh position={[0,-500,0]} onClick={()=>useStore.getState().selectModel(null)}>
|
| 112 |
<planeGeometry args={[5000,5000]} />
|
| 113 |
<meshBasicMaterial transparent opacity={0} />
|
| 114 |
</mesh>
|
| 115 |
)
|
| 116 |
}
|
| 117 |
|
| 118 |
+
// ββ Playback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
function Playback() {
|
| 120 |
const acc = useRef(0)
|
| 121 |
useFrame((_, delta) => {
|
| 122 |
const s = useStore.getState()
|
| 123 |
if (!s.isPlaying) return
|
| 124 |
acc.current += delta
|
| 125 |
+
if (acc.current >= 1/(s.fps||30)) {
|
| 126 |
acc.current = 0
|
| 127 |
const next = s.currentFrame + 1
|
| 128 |
if (next >= s.totalFrames) {
|
| 129 |
+
if (s.loopPlayback) s.setCurrentFrame(0)
|
| 130 |
+
else { s.setIsPlaying(false); s.setCurrentFrame(0) }
|
| 131 |
+
} else s.setCurrentFrame(next)
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
})
|
| 134 |
return null
|
| 135 |
}
|
| 136 |
|
| 137 |
+
// ββ Camera markers (hidden in render mode) βββββββββββββββββββββββββββββββββββ
|
| 138 |
function CameraMarkers() {
|
| 139 |
const cameras = useStore(s => s.cameras) || []
|
| 140 |
const activeCamId = useStore(s => s.activeCameraId)
|
| 141 |
+
const isRender = useStore(s => s.isRenderMode || s.isExporting)
|
| 142 |
+
const showCams = useStore(s => s.showCameraObjects)
|
| 143 |
+
if (isRender || !showCams) return null
|
| 144 |
return (
|
| 145 |
<>
|
| 146 |
{cameras.map(cam => {
|
|
|
|
| 151 |
onClick={e => { e.stopPropagation(); useStore.getState().setActiveCameraId(cam.id) }}>
|
| 152 |
<mesh>
|
| 153 |
<boxGeometry args={[0.25,0.18,0.32]} />
|
| 154 |
+
<meshStandardMaterial color={isAct?'#4f8eff':'#666'}
|
| 155 |
+
emissive={isAct?'#4f8eff':'#000'} emissiveIntensity={isAct?0.3:0}
|
| 156 |
roughness={0.3} metalness={0.7} />
|
| 157 |
</mesh>
|
| 158 |
<mesh position={[0,0,-0.2]}>
|
| 159 |
<cylinderGeometry args={[0.06,0.09,0.1,10]} />
|
| 160 |
+
<meshStandardMaterial color={isAct?'#00e5ff':'#333'}
|
| 161 |
+
emissive={isAct?'#00e5ff':'#000'} emissiveIntensity={0.3} />
|
| 162 |
</mesh>
|
| 163 |
{isAct && (
|
| 164 |
<mesh position={[0,0,-0.5]} rotation={[Math.PI/2,0,0]}>
|
|
|
|
| 173 |
)
|
| 174 |
}
|
| 175 |
|
| 176 |
+
// ββ Camera sync βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 177 |
function CamSync() {
|
| 178 |
const { camera } = useThree()
|
| 179 |
useFrame(() => {
|
| 180 |
const s = useStore.getState()
|
| 181 |
if (!s.inCameraView || !s.activeCameraId) return
|
| 182 |
+
const cam = (s.cameras||[]).find(c=>c.id===s.activeCameraId)
|
| 183 |
if (!cam) return
|
|
|
|
|
|
|
| 184 |
const interp = interpolateCamKF(s.activeCameraId, s.currentFrame, s.keyframes)
|
| 185 |
const src = interp || cam
|
| 186 |
const pos = src.position || [5,3,5]
|
| 187 |
const tgt = src.target || [0,0,0]
|
| 188 |
const fov = src.fov || 50
|
|
|
|
| 189 |
camera.position.set(pos[0]||5, pos[1]||3, pos[2]||5)
|
| 190 |
camera.lookAt(tgt[0]||0, tgt[1]||0, tgt[2]||0)
|
| 191 |
+
if (Math.abs(camera.fov-fov)>0.1) { camera.fov=fov; camera.updateProjectionMatrix() }
|
|
|
|
|
|
|
|
|
|
| 192 |
})
|
| 193 |
return null
|
| 194 |
}
|
| 195 |
|
| 196 |
function interpolateCamKF(camId, frame, keyframes) {
|
| 197 |
+
if (!keyframes||!camId) return null
|
| 198 |
const key = `__cam_${camId}__`
|
| 199 |
const keys = Object.entries(keyframes)
|
| 200 |
+
.filter(([,v])=>v[key]).map(([f,v])=>({frame:parseInt(f),data:v[key]}))
|
| 201 |
+
.sort((a,b)=>a.frame-b.frame)
|
|
|
|
| 202 |
if (!keys.length) return null
|
| 203 |
+
const before=keys.filter(k=>k.frame<=frame), after=keys.filter(k=>k.frame>frame)
|
|
|
|
| 204 |
if (!before.length) return keys[0].data
|
| 205 |
if (!after.length) return keys[keys.length-1].data
|
| 206 |
+
const k0=before[before.length-1],k1=after[0]
|
| 207 |
const t=(frame-k0.frame)/(k1.frame-k0.frame)
|
| 208 |
+
const lv=(a,b,t)=>(a||[0,0,0]).map((v,i)=>v+((b||[0,0,0])[i]-v)*t)
|
| 209 |
+
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 }
|
| 210 |
}
|
| 211 |
|
| 212 |
+
// ββ Fly controls βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 213 |
function FlyControls() {
|
| 214 |
+
const inView = useStore(s=>s.inCameraView)
|
| 215 |
+
const actId = useStore(s=>s.activeCameraId)
|
| 216 |
const { camera, gl } = useThree()
|
| 217 |
+
const keys=useRef({}), drag=useRef({on:false,lx:0,ly:0})
|
| 218 |
+
const yaw=useRef(0.5), pitch=useRef(-0.3), spd=useRef(0.08)
|
| 219 |
+
useEffect(()=>{
|
| 220 |
+
if(!inView||!actId) return
|
| 221 |
+
const kd=e=>{keys.current[e.code]=true},ku=e=>{keys.current[e.code]=false}
|
| 222 |
+
const md=e=>{drag.current={on:true,lx:e.clientX,ly:e.clientY}},mu=()=>{drag.current.on=false}
|
| 223 |
+
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}
|
| 224 |
+
const mw=e=>{spd.current=Math.max(0.01,Math.min(2,spd.current*(1-e.deltaY*0.001)))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
let lt=[]
|
| 226 |
+
const ts=e=>{lt=Array.from(e.touches);if(lt.length===1)drag.current={on:true,lx:lt[0].clientX,ly:lt[0].clientY}}
|
| 227 |
+
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}}
|
| 228 |
+
const te=()=>{drag.current.on=false}
|
| 229 |
+
window.addEventListener('keydown',kd);window.addEventListener('keyup',ku)
|
| 230 |
+
gl.domElement.addEventListener('mousedown',md);window.addEventListener('mouseup',mu);window.addEventListener('mousemove',mm)
|
| 231 |
gl.domElement.addEventListener('wheel',mw,{passive:true})
|
| 232 |
+
gl.domElement.addEventListener('touchstart',ts,{passive:true});gl.domElement.addEventListener('touchmove',tm,{passive:true});gl.domElement.addEventListener('touchend',te)
|
| 233 |
+
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)}
|
| 234 |
+
},[inView,actId,gl])
|
| 235 |
+
useFrame(()=>{
|
| 236 |
+
if(!inView||!actId)return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
const s=spd.current
|
| 238 |
const fwd=new THREE.Vector3(Math.sin(yaw.current)*Math.cos(pitch.current),Math.sin(pitch.current),Math.cos(yaw.current)*Math.cos(pitch.current))
|
| 239 |
const right=new THREE.Vector3().crossVectors(fwd,new THREE.Vector3(0,1,0)).normalize()
|
|
|
|
| 249 |
return null
|
| 250 |
}
|
| 251 |
|
| 252 |
+
// ββ Orbit controls ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
function OrbitCam() {
|
| 254 |
+
const inView = useStore(s=>s.inCameraView)
|
| 255 |
+
const isRender = useStore(s=>s.isRenderMode||s.isExporting)
|
| 256 |
+
if (inView || isRender) return null
|
| 257 |
return (
|
| 258 |
<OrbitControls makeDefault enableDamping dampingFactor={0.06}
|
| 259 |
minDistance={0.3} maxDistance={300}
|
| 260 |
+
touches={{ONE:THREE.TOUCH.ROTATE,TWO:THREE.TOUCH.DOLLY_PAN}}
|
| 261 |
+
mouseButtons={{LEFT:THREE.MOUSE.ROTATE,MIDDLE:THREE.MOUSE.DOLLY,RIGHT:THREE.MOUSE.PAN}}
|
| 262 |
enablePan screenSpacePanning />
|
| 263 |
)
|
| 264 |
}
|
| 265 |
|
| 266 |
+
// ββ Gizmo helper (hidden in render mode) βββββββββββββββββββββββββββββββββββββ
|
| 267 |
+
function EditorGizmo() {
|
| 268 |
+
const isRender = useStore(s=>s.isRenderMode||s.isExporting)
|
| 269 |
+
const showGizmo = useStore(s=>s.showGizmo)
|
| 270 |
+
if (isRender || !showGizmo) return null
|
| 271 |
+
return (
|
| 272 |
+
<GizmoHelper alignment="bottom-right" margin={[72,80]}>
|
| 273 |
+
<GizmoViewport axisColors={['#ff4060','#40ff80','#4080ff']} labelColor="#fff" />
|
| 274 |
+
</GizmoHelper>
|
| 275 |
+
)
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// ββ Camera HUD overlay (hidden during render) βββββββββββββββββββββββββββββββββ
|
| 279 |
+
function CamHUD() {
|
| 280 |
+
const inView = useStore(s=>s.inCameraView)
|
| 281 |
+
const actId = useStore(s=>s.activeCameraId)
|
| 282 |
+
const cameras = useStore(s=>s.cameras)||[]
|
| 283 |
+
const isRender = useStore(s=>s.isRenderMode||s.isExporting)
|
| 284 |
+
const cam = cameras.find(c=>c.id===actId)
|
| 285 |
+
if (!inView||!cam||isRender) return null
|
| 286 |
+
const bl='2px solid rgba(79,142,255,0.8)'
|
| 287 |
+
const corners=[
|
| 288 |
+
{top:8,left:8, borderTop:bl,borderLeft:bl},
|
| 289 |
+
{top:8,right:8, borderTop:bl,borderRight:bl},
|
| 290 |
+
{bottom:8,left:8, borderBottom:bl,borderLeft:bl},
|
| 291 |
+
{bottom:8,right:8,borderBottom:bl,borderRight:bl},
|
| 292 |
]
|
| 293 |
return (
|
| 294 |
+
<div style={{position:'absolute',inset:0,pointerEvents:'none',
|
| 295 |
+
border:'2px solid rgba(79,142,255,0.5)',boxShadow:'inset 0 0 60px rgba(79,142,255,0.06)'}}>
|
| 296 |
+
{corners.map((c,i)=>(<div key={i} style={{position:'absolute',width:18,height:18,...c}}/>))}
|
| 297 |
+
<div style={{position:'absolute',top:10,left:'50%',transform:'translateX(-50%)',
|
|
|
|
|
|
|
|
|
|
| 298 |
background:'rgba(8,8,20,0.75)',border:'1px solid rgba(79,142,255,0.4)',
|
| 299 |
borderRadius:4,padding:'3px 12px',fontSize:11,color:'rgba(79,142,255,0.9)',
|
| 300 |
+
fontFamily:'var(--font-mono)',backdropFilter:'blur(4px)',whiteSpace:'nowrap'}}>
|
| 301 |
π₯ {cam.name} Β· {cam.fov||50}Β°
|
| 302 |
</div>
|
| 303 |
+
<div style={{position:'absolute',top:'50%',left:'50%',transform:'translate(-50%,-50%)',
|
| 304 |
+
width:16,height:16,pointerEvents:'none'}}>
|
| 305 |
+
<div style={{position:'absolute',top:'50%',left:0,right:0,height:1,background:'rgba(79,142,255,0.5)'}}/>
|
| 306 |
+
<div style={{position:'absolute',left:'50%',top:0,bottom:0,width:1,background:'rgba(79,142,255,0.5)'}}/>
|
| 307 |
</div>
|
| 308 |
+
<div style={{position:'absolute',bottom:10,left:'50%',transform:'translateX(-50%)',
|
| 309 |
fontSize:9,color:'rgba(79,142,255,0.5)',fontFamily:'var(--font-mono)',
|
| 310 |
+
background:'rgba(0,0,0,0.4)',padding:'2px 10px',borderRadius:3,whiteSpace:'nowrap'}}>
|
| 311 |
WASD move Β· Q/E up/down Β· drag look
|
| 312 |
</div>
|
| 313 |
</div>
|
| 314 |
)
|
| 315 |
}
|
| 316 |
|
| 317 |
+
// ββ Render-mode overlay indicator βββββββββββββββββββββββββββββββββββββββββββββ
|
| 318 |
+
function RenderIndicator() {
|
| 319 |
+
const isRender = useStore(s=>s.isRenderMode||s.isExporting)
|
| 320 |
+
const progress = useStore(s=>s.exportProgress)
|
| 321 |
+
if (!isRender) return null
|
| 322 |
+
return (
|
| 323 |
+
<div style={{position:'absolute',top:10,left:'50%',transform:'translateX(-50%)',
|
| 324 |
+
background:'rgba(239,68,68,0.15)',border:'1px solid rgba(239,68,68,0.4)',
|
| 325 |
+
borderRadius:6,padding:'5px 16px',pointerEvents:'none',
|
| 326 |
+
display:'flex',alignItems:'center',gap:8,zIndex:100}}>
|
| 327 |
+
<div style={{width:8,height:8,borderRadius:'50%',background:'var(--danger)',
|
| 328 |
+
animation:'pulse 1s ease infinite'}}/>
|
| 329 |
+
<span style={{fontSize:12,fontWeight:700,color:'var(--danger)',fontFamily:'var(--font-mono)'}}>
|
| 330 |
+
RENDERING {progress>0?`${progress}%`:''}
|
| 331 |
+
</span>
|
| 332 |
+
</div>
|
| 333 |
+
)
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
// ββ Main Scene βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 337 |
export default function Scene({ canvasRef }) {
|
| 338 |
return (
|
| 339 |
+
<div style={{position:'absolute',inset:0}}>
|
| 340 |
<Canvas
|
| 341 |
shadows
|
| 342 |
+
dpr={[1,Math.min(typeof window!=='undefined'?window.devicePixelRatio:1,2)]}
|
| 343 |
gl={{
|
| 344 |
+
antialias:true, preserveDrawingBuffer:true,
|
| 345 |
+
outputColorSpace:THREE.SRGBColorSpace,
|
| 346 |
+
toneMapping:THREE.ACESFilmicToneMapping, toneMappingExposure:1.2,
|
| 347 |
+
powerPreference:'high-performance', failIfMajorPerformanceCaveat:false,
|
|
|
|
|
|
|
|
|
|
| 348 |
}}
|
| 349 |
+
style={{width:'100%',height:'100%',display:'block'}}
|
| 350 |
+
onCreated={({gl})=>{gl.domElement.style.touchAction='none'}}
|
| 351 |
>
|
| 352 |
<PerspectiveCamera makeDefault position={[5,3,5]} fov={50} near={0.01} far={1000} />
|
|
|
|
| 353 |
<Suspense fallback={null}>
|
| 354 |
<SkyboxApplier />
|
| 355 |
<PresetEnv />
|
|
|
|
| 362 |
<FlyControls />
|
| 363 |
<Playback />
|
| 364 |
</Suspense>
|
|
|
|
| 365 |
<OrbitCam />
|
| 366 |
+
<EditorGizmo />
|
| 367 |
<PhysicsEngine />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
</Canvas>
|
| 369 |
|
| 370 |
+
{/* Overlays β hidden when rendering */}
|
| 371 |
<CamHUD />
|
| 372 |
+
<RenderIndicator />
|
| 373 |
</div>
|
| 374 |
)
|
| 375 |
}
|
|
@@ -36,6 +36,8 @@ export default function Toolbar() {
|
|
| 36 |
selectedModelId, addKeyframe, currentFrame,
|
| 37 |
currentFrame: cf, setCurrentFrame, totalFrames,
|
| 38 |
undo, redo, undoStack, redoStack,
|
|
|
|
|
|
|
| 39 |
projectName, setProjectName, saveProject, loadProject, exportProjectJSON,
|
| 40 |
duplicateModel, removeModel,
|
| 41 |
models,
|
|
@@ -208,6 +210,12 @@ export default function Toolbar() {
|
|
| 208 |
<Btn icon="π" danger title="Delete model [Del]" onClick={()=>{ removeModel(selectedModelId) }} />
|
| 209 |
</>}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
{/* Screenshot */}
|
| 212 |
<Btn icon="π·" title="Screenshot [F12]" onClick={takeScreenshot} />
|
| 213 |
</div>
|
|
|
|
| 36 |
selectedModelId, addKeyframe, currentFrame,
|
| 37 |
currentFrame: cf, setCurrentFrame, totalFrames,
|
| 38 |
undo, redo, undoStack, redoStack,
|
| 39 |
+
showGrid, setShowGrid, showGizmo, setShowGizmo,
|
| 40 |
+
showCameraObjects, setShowCameraObjects,
|
| 41 |
projectName, setProjectName, saveProject, loadProject, exportProjectJSON,
|
| 42 |
duplicateModel, removeModel,
|
| 43 |
models,
|
|
|
|
| 210 |
<Btn icon="π" danger title="Delete model [Del]" onClick={()=>{ removeModel(selectedModelId) }} />
|
| 211 |
</>}
|
| 212 |
|
| 213 |
+
<Divider />
|
| 214 |
+
{/* Scene visibility toggles */}
|
| 215 |
+
<Btn icon="β" title="Toggle grid" active={showGrid} onClick={()=>setShowGrid(!showGrid)} />
|
| 216 |
+
<Btn icon="β" title="Toggle orientation gizmo" active={showGizmo} onClick={()=>setShowGizmo(!showGizmo)} />
|
| 217 |
+
<Btn icon="π₯" title="Toggle camera objects" active={showCameraObjects} onClick={()=>setShowCameraObjects(!showCameraObjects)} />
|
| 218 |
+
|
| 219 |
{/* Screenshot */}
|
| 220 |
<Btn icon="π·" title="Screenshot [F12]" onClick={takeScreenshot} />
|
| 221 |
</div>
|
|
@@ -126,6 +126,16 @@ const useStore = create(
|
|
| 126 |
fps: 30,
|
| 127 |
isPlaying: false,
|
| 128 |
isRecording: false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
loopPlayback: false,
|
| 130 |
|
| 131 |
setCurrentFrame: (f) => set(state => {
|
|
@@ -215,6 +225,10 @@ const useStore = create(
|
|
| 215 |
addRecordedFrame: (d) => set(state => { state.recordedFrames.push(d) }),
|
| 216 |
clearRecordedFrames:() => set(state => { state.recordedFrames = [] }),
|
| 217 |
setIsExporting: (v) => set(state => { state.isExporting = v }),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
setExportProgress: (v) => set(state => { state.exportProgress = v }),
|
| 219 |
setExportedVideoUrl:(url) => set(state => { state.exportedVideoUrl = url }),
|
| 220 |
|
|
|
|
| 126 |
fps: 30,
|
| 127 |
isPlaying: false,
|
| 128 |
isRecording: false,
|
| 129 |
+
isRenderMode: false, // hides ALL editor UI - grid, gizmos, selection rings, helpers
|
| 130 |
+
showGrid: true,
|
| 131 |
+
showGizmo: true,
|
| 132 |
+
showCameraObjects: true,
|
| 133 |
+
showContactShadows: true,
|
| 134 |
+
setIsRenderMode: (v) => set(state => { state.isRenderMode = v }),
|
| 135 |
+
setShowGrid: (v) => set(state => { state.showGrid = v }),
|
| 136 |
+
setShowGizmo: (v) => set(state => { state.showGizmo = v }),
|
| 137 |
+
setShowCameraObjects: (v) => set(state => { state.showCameraObjects = v }),
|
| 138 |
+
setShowContactShadows: (v) => set(state => { state.showContactShadows = v }),
|
| 139 |
loopPlayback: false,
|
| 140 |
|
| 141 |
setCurrentFrame: (f) => set(state => {
|
|
|
|
| 225 |
addRecordedFrame: (d) => set(state => { state.recordedFrames.push(d) }),
|
| 226 |
clearRecordedFrames:() => set(state => { state.recordedFrames = [] }),
|
| 227 |
setIsExporting: (v) => set(state => { state.isExporting = v }),
|
| 228 |
+
renderWidth: 1920,
|
| 229 |
+
renderHeight: 1080,
|
| 230 |
+
setRenderWidth: (v) => set(state => { state.renderWidth = v }),
|
| 231 |
+
setRenderHeight: (v) => set(state => { state.renderHeight = v }),
|
| 232 |
setExportProgress: (v) => set(state => { state.exportProgress = v }),
|
| 233 |
setExportedVideoUrl:(url) => set(state => { state.exportedVideoUrl = url }),
|
| 234 |
|