GLB Studio Deploy commited on
Commit
ad2f294
Β·
1 Parent(s): 72b08e2

feat: clean render mode + scene visibility + full export overhaul

Browse files

RENDER 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 CHANGED
@@ -1,125 +1,270 @@
 
 
 
 
 
 
 
 
 
 
1
  import { useState, useRef } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
- function Stat({ label, value }) {
5
  return (
6
  <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
7
- padding:'5px 0', borderBottom:'1px solid var(--border)' }}>
8
  <span style={{ fontSize:11, color:'var(--text2)' }}>{label}</span>
9
- <span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--text0)', fontWeight:600 }}>{value}</span>
10
  </div>
11
  )
12
  }
13
 
14
- export default function ExportPanel({ canvasRef }) {
15
- const { totalFrames, fps, setCurrentFrame, setIsPlaying,
 
 
 
 
 
 
 
 
16
  isExporting, setIsExporting, exportProgress, setExportProgress,
17
- exportedVideoUrl, setExportedVideoUrl } = useStore()
 
 
18
 
19
- const [quality, setQuality] = useState(0.9)
20
- const [outFps, setOutFps] = useState(30)
21
- const [format, setFormat] = useState('webm')
22
- const [status, setStatus] = useState('')
 
23
  const cancelRef = useRef(false)
24
- const framesRef = useRef([])
 
 
 
 
 
 
 
25
 
26
- const duration = (totalFrames / fps).toFixed(1)
27
- const estSize = Math.round((totalFrames / fps) * outFps * 0.25)
 
 
 
 
 
 
 
 
 
28
 
 
29
  const captureFrame = () => {
30
- const c = document.querySelector('canvas')
31
  return c ? c.toDataURL('image/jpeg', quality) : null
32
  }
33
 
34
- const sleep = ms => new Promise(r => setTimeout(r, ms))
35
-
36
- const startExport = async () => {
37
  if (isExporting) return
38
- setIsExporting(true); setExportedVideoUrl(null)
39
- cancelRef.current = false; framesRef.current = []
40
- setStatus('Capturing frames…')
 
 
 
 
 
 
 
41
  const store = useStore.getState()
 
42
 
43
  for (let f = 0; f < totalFrames; f++) {
44
  if (cancelRef.current) break
45
  store.setCurrentFrame(f)
46
- await sleep(1000/fps + 16)
47
- const d = captureFrame()
48
- if (d) framesRef.current.push(d)
49
- setExportProgress(Math.round((f/totalFrames)*78))
 
 
50
  }
51
 
52
- if (!cancelRef.current && framesRef.current.length > 0) {
53
- setStatus('Encoding video…'); setExportProgress(82)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  try {
55
- const blob = await encodeVideo(framesRef.current, outFps)
56
  setExportedVideoUrl(URL.createObjectURL(blob))
57
- setExportProgress(100); setStatus('Done!')
58
- } catch(e) { setStatus('Error: ' + e.message) }
 
 
 
59
  }
60
- setIsExporting(false); store.setCurrentFrame(0)
 
61
  }
62
 
63
- const encodeVideo = (frames, fps) => new Promise((res, rej) => {
64
  if (!frames.length) { rej(new Error('No frames')); return }
65
  const img = new Image()
66
  img.onload = () => {
67
- const canvas = document.createElement('canvas')
68
- canvas.width = img.width; canvas.height = img.height
69
- const ctx = canvas.getContext('2d')
70
- const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm'
71
- const rec = new MediaRecorder(canvas.captureStream(fps), { mimeType:mime, videoBitsPerSecond:8_000_000 })
 
 
 
 
 
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 iv = setInterval(() => {
78
- if (i >= frames.length) { clearInterval(iv); rec.stop(); return }
79
- const fi = new Image(); fi.onload = () => ctx.drawImage(fi,0,0); fi.src = frames[i++]
80
- setExportProgress(82 + Math.round((i/frames.length)*17))
81
- }, 1000/fps)
 
 
 
 
 
 
 
82
  }
83
- img.onerror = rej; img.src = frames[0]
 
84
  })
85
 
 
 
 
 
 
 
 
 
 
86
  return (
87
- <div style={{ padding:12, display:'flex', flexDirection:'column', gap:12 }}>
88
-
89
- {/* Stats */}
90
- <div style={{ background:'var(--bg2)', borderRadius:'var(--radius)', padding:'10px 12px',
91
- border:'1px solid var(--border)' }}>
92
- <Stat label="Frames" value={totalFrames} />
93
- <Stat label="Duration" value={`${duration}s`} />
94
- <Stat label="Timeline" value={`${fps} fps`} />
95
- <Stat label="Format" value="WebM VP9" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  </div>
97
 
98
  {/* Quality */}
99
  <div>
100
- <div style={{ display:'flex', justifyContent:'space-between', marginBottom:5 }}>
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)' }}>{Math.round(quality*100)}%</span>
 
 
103
  </div>
104
  <input type="range" min={0.5} max={1} step={0.01} value={quality}
105
- onChange={e => setQuality(+e.target.value)} />
106
  </div>
107
 
108
- {/* FPS */}
109
- <div>
110
- <div style={{ fontSize:11, color:'var(--text2)', fontWeight:500, marginBottom:6 }}>Output FPS</div>
111
- <div style={{ display:'flex', gap:5 }}>
112
- {[15,24,30,60].map(f => (
113
- <button key={f} onClick={() => setOutFps(f)} style={{
114
- flex:1, padding:'6px 0', borderRadius:'var(--radius-sm)',
115
- background: outFps===f ? 'rgba(79,142,255,0.15)' : 'var(--bg2)',
116
- border:`1px solid ${outFps===f ? 'rgba(79,142,255,0.4)' : 'var(--border)'}`,
117
- color: outFps===f ? 'var(--accent)' : 'var(--text1)',
118
- fontSize:11, fontWeight: outFps===f ? 700 : 400, cursor:'pointer',
119
- transition:'all 0.12s',
120
- }}>{f}</button>
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)' }}>{exportProgress}%</span>
 
 
131
  </div>
132
- <div style={{ height:4, background:'var(--bg3)', borderRadius:2 }}>
133
- <div style={{ height:'100%', borderRadius:2, transition:'width 0.3s',
134
- width:`${exportProgress}%`, background:'linear-gradient(90deg,var(--accent),var(--accent2))' }} />
 
 
 
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('Error') ? 'rgba(239,68,68,0.08)' : 'rgba(6,214,160,0.08)',
144
- border:`1px solid ${status.includes('Error') ? 'rgba(239,68,68,0.2)' : 'rgba(6,214,160,0.2)'}`,
145
- color: status.includes('Error') ? 'var(--danger)' : 'var(--accent3)',
146
- fontSize:11,
 
 
147
  }}>{status}</div>
148
  )}
149
 
150
- {/* Buttons */}
151
  {!isExporting ? (
152
- <button onClick={startExport} style={{
153
- padding:'11px 0', borderRadius:'var(--radius)',
154
  background:'linear-gradient(135deg,var(--accent),var(--accent2))',
155
- border:'none', color:'#fff', fontSize:13, fontWeight:700,
156
  cursor:'pointer', letterSpacing:'0.04em',
157
- boxShadow:'0 4px 20px rgba(79,142,255,0.35)',
158
- transition:'opacity 0.15s',
159
- }}>β–Ά Render & Export</button>
 
 
 
160
  ) : (
161
- <button onClick={() => { cancelRef.current = true }} style={{
162
- padding:'10px 0', borderRadius:'var(--radius)',
163
  background:'rgba(239,68,68,0.1)', border:'1px solid rgba(239,68,68,0.3)',
164
- color:'var(--danger)', fontSize:12, fontWeight:600, cursor:'pointer',
165
- }}>⏹ Cancel</button>
166
  )}
167
 
168
  {/* Result */}
169
  {exportedVideoUrl && (
170
  <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
171
- <video src={exportedVideoUrl} controls style={{ width:'100%', borderRadius:'var(--radius)', border:'1px solid var(--border)' }} />
172
- <a href={exportedVideoUrl} download={`animation_${Date.now()}.webm`} style={{
173
- display:'block', padding:'9px 0', borderRadius:'var(--radius)',
174
- background:'rgba(6,214,160,0.1)', border:'1px solid rgba(6,214,160,0.3)',
175
- color:'var(--accent3)', textAlign:'center', textDecoration:'none',
176
- fontSize:12, fontWeight:600,
177
- }}>⬇ Download Video</a>
 
 
 
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>
src/components/ModelManager.jsx CHANGED
@@ -29,7 +29,8 @@ function ModelMesh({ model }) {
29
  snapEnabled, snapTranslate, snapRotate, snapScale,
30
  } = useStore()
31
 
32
- const isSelected = selectedModelId === model.id
 
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
- color="#4f8eff"
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}
src/components/Scene.jsx CHANGED
@@ -1,12 +1,20 @@
 
 
 
 
 
1
  import { useRef, useEffect, Suspense } from 'react'
2
  import { Canvas, useFrame, useThree } from '@react-three/fiber'
3
- import { OrbitControls, Environment, Grid, GizmoHelper, GizmoViewport, ContactShadows, PerspectiveCamera } from '@react-three/drei'
 
 
 
4
  import * as THREE from 'three'
5
- import useStore from '../store/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={[1024,1024]} shadow-camera-near={0.1} shadow-camera-far={100}
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] || 'studio'} background={!!skybox.showBg} blur={0.6} />
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={[100,100]} />
80
- <meshStandardMaterial color="#0d0d1a" roughness={0.9} />
81
  </mesh>
82
- <Grid args={[40,40]} cellSize={1} cellThickness={0.4} cellColor="#1a1a3a"
83
- sectionSize={5} sectionThickness={1} sectionColor="#2a2a5a" fadeDistance={50} />
84
- <ContactShadows opacity={0.4} scale={30} blur={2} far={5} color="#000033" />
 
 
 
 
 
85
  </>
86
  )
87
  }
88
 
89
- // ── Deselect ───────────────────────────────────────────────────────────────────
90
  function Deselect() {
 
 
91
  return (
92
- <mesh position={[0,-500,0]} onClick={() => useStore.getState().selectModel(null)}>
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 / (s.fps || 30)) {
107
  acc.current = 0
108
  const next = s.currentFrame + 1
109
  if (next >= s.totalFrames) {
110
- if (s.loopPlayback) { s.setCurrentFrame(0) } // loop
111
- else { s.setIsPlaying(false); s.setCurrentFrame(0) } // stop
112
- } else {
113
- s.setCurrentFrame(next)
114
- }
115
  }
116
  })
117
  return null
118
  }
119
 
120
- // ── Camera objects in scene ────────────────────────────────────────────────────
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 ? '#4f8eff' : '#666'}
136
- emissive={isAct ? '#4f8eff' : '#000'} emissiveIntensity={isAct ? 0.3 : 0}
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 ? '#00e5ff' : '#333'}
142
- emissive={isAct ? '#00e5ff' : '#000'} emissiveIntensity={0.3} />
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 (runs every frame, drives R3F camera when in camera view) ──────
158
  function CamSync() {
159
  const { camera } = useThree()
160
  useFrame(() => {
161
  const s = useStore.getState()
162
  if (!s.inCameraView || !s.activeCameraId) return
163
- const cam = (s.cameras || []).find(c => c.id === s.activeCameraId)
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 - fov) > 0.1) {
176
- camera.fov = fov
177
- camera.updateProjectionMatrix()
178
- }
179
  })
180
  return null
181
  }
182
 
183
  function interpolateCamKF(camId, frame, keyframes) {
184
- if (!keyframes || !camId) return null
185
  const key = `__cam_${camId}__`
186
  const keys = Object.entries(keyframes)
187
- .filter(([,v]) => v[key])
188
- .map(([f,v]) => ({ frame:parseInt(f), data:v[key] }))
189
- .sort((a,b) => a.frame - b.frame)
190
  if (!keys.length) return null
191
- const before = keys.filter(k => k.frame <= frame)
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], k1=after[0]
196
  const t=(frame-k0.frame)/(k1.frame-k0.frame)
197
- const lv=(a,b,t)=>(a||[0,0,0]).map((v,i)=>v+(((b||[0,0,0])[i]||0)-v)*t)
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)+(((k1.data.fov||50)-(k0.data.fov||50))*t) }
199
  }
200
 
201
- // ── Fly controls inside R3F (only when in camera view) ────────────────────────
202
  function FlyControls() {
203
- const inView = useStore(s => s.inCameraView)
204
- const actId = useStore(s => s.activeCameraId)
205
  const { camera, gl } = useThree()
206
- const keys = useRef({})
207
- const drag = useRef({ on:false, lx:0, ly:0 })
208
- const yaw = useRef(0.5)
209
- const pitch = useRef(-0.3)
210
- const spd = useRef(0.08)
211
-
212
- useEffect(() => {
213
- if (!inView || !actId) return
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=>{ 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 ()=>{
234
- window.removeEventListener('keydown',kd); window.removeEventListener('keyup',ku)
235
- gl.domElement.removeEventListener('mousedown',md); window.removeEventListener('mouseup',mu); window.removeEventListener('mousemove',mm)
236
- gl.domElement.removeEventListener('wheel',mw)
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 (disabled in camera view) ───────────────────────────────────
259
  function OrbitCam() {
260
- const inView = useStore(s => s.inCameraView)
261
- if (inView) return null
 
262
  return (
263
  <OrbitControls makeDefault enableDamping dampingFactor={0.06}
264
  minDistance={0.3} maxDistance={300}
265
- touches={{ ONE:THREE.TOUCH.ROTATE, TWO:THREE.TOUCH.DOLLY_PAN }}
266
- mouseButtons={{ LEFT:THREE.MOUSE.ROTATE, MIDDLE:THREE.MOUSE.DOLLY, RIGHT:THREE.MOUSE.PAN }}
267
  enablePan screenSpacePanning />
268
  )
269
  }
270
 
271
- // ── Camera HUD overlay ─────────────────────────────────────────────────────────
272
- function CamHUD({ canvasContainer }) {
273
- const inView = useStore(s => s.inCameraView)
274
- const actId = useStore(s => s.activeCameraId)
275
- const cameras = useStore(s => s.cameras) || []
276
- const cam = cameras.find(c => c.id === actId)
277
- if (!inView || !cam) return null
278
- const br = 'var(--radius-sm)'
279
- const bl = '2px solid rgba(79,142,255,0.8)'
280
- const corners = [
281
- { top:8, left:8, borderTop:bl, borderLeft:bl },
282
- { top:8, right:8, borderTop:bl, borderRight:bl },
283
- { bottom:8, left:8, borderBottom:bl, borderLeft:bl },
284
- { bottom:8, right:8, borderBottom:bl, borderRight:bl },
 
 
 
 
 
 
 
 
 
 
 
 
285
  ]
286
  return (
287
- <div style={{ position:'absolute', inset:0, pointerEvents:'none',
288
- border:'2px solid rgba(79,142,255,0.5)',
289
- boxShadow:'inset 0 0 60px rgba(79,142,255,0.06)' }}>
290
- {corners.map((c,i) => (
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={{ position:'absolute',top:'50%',left:'50%',transform:'translate(-50%,-50%)',
300
- width:16,height:16,pointerEvents:'none' }}>
301
- <div style={{ position:'absolute',top:'50%',left:0,right:0,height:1,background:'rgba(79,142,255,0.5)' }}/>
302
- <div style={{ position:'absolute',left:'50%',top:0,bottom:0,width:1,background:'rgba(79,142,255,0.5)' }}/>
303
  </div>
304
- <div style={{ position:'absolute',bottom:10,left:'50%',transform:'translateX(-50%)',
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={{ position:'absolute', inset:0 }}>
317
  <Canvas
318
  shadows
319
- dpr={[1, Math.min(typeof window!=='undefined'?window.devicePixelRatio:1, 2)]}
320
  gl={{
321
- antialias: true,
322
- preserveDrawingBuffer: true,
323
- outputColorSpace: THREE.SRGBColorSpace,
324
- toneMapping: THREE.ACESFilmicToneMapping,
325
- toneMappingExposure: 1.2,
326
- powerPreference: 'high-performance',
327
- failIfMajorPerformanceCaveat: false,
328
  }}
329
- style={{ width:'100%', height:'100%', display:'block' }}
330
- onCreated={({ gl }) => { gl.domElement.style.touchAction = 'none' }}
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
  }
src/components/Toolbar.jsx CHANGED
@@ -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>
src/store/useStore.js CHANGED
@@ -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