GLB Studio Deploy commited on
Commit
fd8bcfa
·
1 Parent(s): b7eb43c

redesign: complete modern UI overhaul + new features

Browse files

Design System:
- Full CSS variables design system (bg0-bg4, accent, text0-3, radius, shadows)
- Inter (UI) + JetBrains Mono (numbers/code) + Syne (brand) font stack
- Consistent border, hover, focus, transition styles everywhere
- All global range/input/scrollbar styling in index.css

Layout:
- Blender-style icon rail (48px) on right edge with slide-out panel (260px)
- Panel animates open/close with smooth width transition
- Panel header with title + close button
- Mobile: slide-up drawer (55% height) with tab bar at bottom
- Canvas always fills 100% of remaining viewport

Toolbar:
- Compact, clean — brand / transform tools / transport / lighting / keyframe shortcut
- Active states with accent color background + border
- Styled play button with glow shadow
- Frame counter with monospace font in bg3 chip

ModelsPanel:
- Large drag-and-drop zone with pulse animation on hover
- Grid layout for demo model buttons
- Model list with color dots, anim count badges, hover states
- Inline visibility + remove actions

PropertiesPanel:
- Collapsible sections (Transform / Animations / Keyframes)
- XYZ inputs with colored axis labels, grouped border focus
- Reset Transform button
- Keyframe list with frame navigation

Timeline:
- Diamond-shaped keyframe markers
- Arrow playhead with top indicator
- Settings row for frames/fps presets
- Cleaner ruler with section markers

AnimationPlayer / SkyboxPanel / ExportPanel:
- All fully restyled to match design system
- SkyboxPanel: toggle switch, color grid, upload, URL input
- ExportPanel: stats table, gradient render button, progress bar

src/App.jsx CHANGED
@@ -1,273 +1,255 @@
1
  import { useRef, useState, Suspense } from 'react'
2
- import Scene from './components/Scene'
3
- import Toolbar from './components/Toolbar'
4
- import Timeline from './components/Timeline'
5
- import ModelsPanel from './components/ModelsPanel'
6
  import PropertiesPanel from './components/PropertiesPanel'
7
- import ExportPanel from './components/ExportPanel'
8
  import AnimationPlayer from './components/AnimationPlayer'
9
- import CameraMode from './components/CameraMode'
10
- import SkyboxPanel from './components/SkyboxPanel'
11
- import useStore from './store/useStore'
12
-
13
- const PANEL_TABS = [
14
- { id: 'models', label: 'MODELS', icon: '' },
15
- { id: 'properties', label: 'PROPS', icon: '' },
16
- { id: 'animations', label: 'ANIMS', icon: '🎞' },
17
- { id: 'camera', label: 'CAMERA', icon: '🎥' },
18
- { id: 'skybox', label: 'SKYBOX', icon: '🌐' },
19
- { id: 'export', label: 'EXPORT', icon: '' },
20
  ]
21
 
22
- const TOOLBAR_H = 42 // px
23
- const TIMELINE_H = 148 // px when visible
24
- const PANEL_W = 210 // side panel width
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- // ─────────────────────────────────────────────────────────────────────────────
27
- // Desktop side panel (collapsible)
28
- // ─────────────────────────────────────────────────────────────────────────────
29
- function SidePanel({ canvasRef, collapsed, onCollapse }) {
30
  const { activePanel, setActivePanel } = useStore()
 
31
 
32
  return (
33
- <div style={{
34
- width: collapsed ? 36 : PANEL_W,
35
- flexShrink: 0,
36
- background: 'rgba(6,6,18,0.98)',
37
- borderLeft: '1px solid rgba(0,245,255,0.1)',
38
- display: 'flex',
39
- flexDirection: 'column',
40
- transition: 'width 0.22s ease',
41
- overflow: 'hidden',
42
- zIndex: 150,
43
- position: 'relative',
44
- }}>
45
- {/* Collapse toggle */}
46
- <button
47
- onClick={onCollapse}
48
- title={collapsed ? 'Expand panel' : 'Collapse panel'}
49
- style={{
50
- position: 'absolute', top: 6, right: collapsed ? 4 : 6,
51
- width: 24, height: 24, borderRadius: 4,
52
- background: 'rgba(0,245,255,0.08)',
53
- border: '1px solid rgba(0,245,255,0.15)',
54
- color: '#00f5ff', cursor: 'pointer', fontSize: 11,
55
- display: 'flex', alignItems: 'center', justifyContent: 'center',
56
- zIndex: 10, flexShrink: 0,
57
- }}
58
- >{collapsed ? '◁' : '▷'}</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
- {!collapsed && <>
61
- {/* Tab icons */}
62
  <div style={{
63
- display: 'flex', flexWrap: 'wrap',
64
- borderBottom: '1px solid rgba(255,255,255,0.07)',
65
- background: 'rgba(0,0,8,0.6)',
66
- paddingTop: 2,
67
  }}>
68
- {PANEL_TABS.map(tab => (
69
- <button
70
- key={tab.id}
71
- onClick={() => setActivePanel(activePanel === tab.id ? null : tab.id)}
72
- title={tab.label}
73
  style={{
74
- flex: '1 1 30%', minWidth: 0,
75
- padding: '7px 2px 5px',
76
- background: activePanel === tab.id ? 'rgba(0,245,255,0.09)' : 'transparent',
77
- border: 'none',
78
- borderBottom: `2px solid ${activePanel === tab.id ? '#00f5ff' : 'transparent'}`,
79
- color: activePanel === tab.id ? '#00f5ff' : '#3a3a5a',
80
- cursor: 'pointer', fontSize: 9,
81
- fontFamily: 'Space Mono, monospace',
82
- display: 'flex', flexDirection: 'column',
83
- alignItems: 'center', gap: 2,
84
- transition: 'all 0.15s',
85
  }}
86
- >
87
- <span style={{ fontSize: 13 }}>{tab.icon}</span>
88
- <span style={{ fontSize: 8, letterSpacing: '0.04em' }}>{tab.label}</span>
89
- </button>
90
  ))}
91
  </div>
92
-
93
- {/* Content */}
94
- <div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
95
- {activePanel === 'models' && <ModelsPanel />}
96
- {activePanel === 'properties' && <PropertiesPanel />}
97
- {activePanel === 'animations' && <AnimationPlayer />}
98
- {activePanel === 'camera' && <CameraMode sceneRef={canvasRef} />}
99
- {activePanel === 'skybox' && <SkyboxPanel />}
100
- {activePanel === 'export' && <ExportPanel canvasRef={canvasRef} />}
101
- </div>
102
- </>}
103
  </div>
104
  )
105
  }
106
 
107
- // ─────────────────────────────────────────────────────────────────────────────
108
- // Mobile bottom bar + slide-up drawer
109
- // ─────────────────────────────────────────────────────────────────────────────
110
- function MobileBar() {
111
  const { activePanel, setActivePanel } = useStore()
 
112
  return (
113
- <div style={{
114
- display: 'flex', overflowX: 'auto',
115
- borderTop: '1px solid rgba(0,245,255,0.1)',
116
- background: 'rgba(6,6,18,0.98)',
117
- zIndex: 200, flexShrink: 0,
118
- }}>
119
- {PANEL_TABS.map(tab => (
120
- <button
121
- key={tab.id}
122
- onClick={() => setActivePanel(activePanel === tab.id ? null : tab.id)}
123
- style={{
124
- flex: '0 0 auto', minWidth: 52,
125
- padding: '7px 6px 5px',
126
- background: activePanel === tab.id ? 'rgba(0,245,255,0.09)' : 'transparent',
127
- border: 'none',
128
- borderTop: `2px solid ${activePanel === tab.id ? '#00f5ff' : 'transparent'}`,
129
- color: activePanel === tab.id ? '#00f5ff' : '#444',
130
- cursor: 'pointer',
131
- display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
132
- }}
133
- >
134
- <span style={{ fontSize: 18 }}>{tab.icon}</span>
135
- <span style={{ fontSize: 8, fontFamily: 'Space Mono', letterSpacing: '0.04em', color: 'inherit' }}>
136
- {tab.label}
137
- </span>
138
- </button>
139
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </div>
141
  )
142
  }
143
 
144
- function MobileDrawer({ canvasRef }) {
145
- const { activePanel } = useStore()
146
- if (!activePanel) return null
147
  return (
148
- <div style={{
149
- position: 'absolute', bottom: 0, left: 0, right: 0,
150
- height: 300,
151
- background: 'rgba(6,6,18,0.98)',
152
- borderTop: '1px solid rgba(0,245,255,0.18)',
153
- zIndex: 180, overflow: 'auto',
154
- }}>
155
- {activePanel === 'models' && <ModelsPanel />}
156
- {activePanel === 'properties' && <PropertiesPanel />}
157
- {activePanel === 'animations' && <AnimationPlayer />}
158
- {activePanel === 'camera' && <CameraMode sceneRef={canvasRef} />}
159
- {activePanel === 'skybox' && <SkyboxPanel />}
160
- {activePanel === 'export' && <ExportPanel canvasRef={canvasRef} />}
161
  </div>
162
  )
163
  }
164
 
165
- // ─────────────────────────────────────────────────────────────────────────────
166
- // Root App
167
- // ─────────────────────────────────────────────────────────────────────────────
168
  export default function App() {
169
  const canvasRef = useRef()
170
  const showTimeline = useStore(s => s.showTimeline)
171
- const [collapsed, setCollapsed] = useState(false)
172
-
173
- const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
174
-
175
- const tlH = showTimeline ? TIMELINE_H : 28
176
- const mobBarH = isMobile ? 46 : 0
177
- const panelW = isMobile ? 0 : (collapsed ? 36 : PANEL_W)
178
 
179
  return (
180
  <div style={{
181
- width: '100vw', height: '100vh',
182
- display: 'flex', flexDirection: 'column',
183
- background: '#050508',
184
- fontFamily: 'Space Mono, monospace',
185
- overflow: 'hidden',
186
  }}>
187
- {/* ── Toolbar (fixed top) ── */}
188
- <div style={{ flexShrink: 0, zIndex: 300 }}>
189
- <Toolbar />
190
- </div>
191
 
192
- {/* ── Main row: canvas + side panel ── */}
193
- <div style={{
194
- flex: 1,
195
- display: 'flex',
196
- overflow: 'hidden',
197
- // Reserve space for toolbar above and timeline+mobile bar below
198
- marginBottom: tlH + mobBarH,
199
- }}>
200
-
201
- {/* 3D Canvas — takes ALL remaining width */}
202
- <div
203
- ref={canvasRef}
204
- style={{
205
- flex: 1,
206
- position: 'relative',
207
- overflow: 'hidden',
208
- // Ensure canvas fills entire height of this row
209
- minHeight: 0,
210
- }}
211
- >
212
- <Suspense fallback={
213
- <div style={{
214
- position: 'absolute', inset: 0,
215
- display: 'flex', alignItems: 'center', justifyContent: 'center',
216
- background: '#050508',
217
- }}>
218
- <div style={{ textAlign: 'center', color: '#00f5ff', fontFamily: 'Orbitron, monospace' }}>
219
- <div style={{ fontSize: 36, marginBottom: 10,
220
- animation: 'spin 1.6s linear infinite' }}>◈</div>
221
- <div style={{ fontSize: 12, letterSpacing: '0.25em', opacity: 0.7 }}>
222
- LOADING SCENE
223
- </div>
224
- </div>
225
- </div>
226
- }>
227
- <Scene canvasRef={canvasRef} />
228
- </Suspense>
229
-
230
- {/* Mobile slide-up drawer sits inside canvas layer */}
231
- {isMobile && <MobileDrawer canvasRef={canvasRef} />}
232
- </div>
233
-
234
- {/* Desktop side panel */}
235
- {!isMobile && (
236
- <SidePanel
237
- canvasRef={canvasRef}
238
- collapsed={collapsed}
239
- onCollapse={() => setCollapsed(c => !c)}
240
- />
241
- )}
242
  </div>
243
 
244
- {/* ── Timeline — absolute, above mobile bar ── */}
245
- <div style={{
246
- position: 'fixed',
247
- bottom: mobBarH,
248
- left: 0,
249
- right: panelW,
250
- zIndex: 200,
251
- }}>
252
  <Timeline />
253
  </div>
254
-
255
- {/* ── Mobile bottom tab bar ── */}
256
- {isMobile && (
257
- <div style={{ flexShrink: 0, zIndex: 300 }}>
258
- <MobileBar />
259
- </div>
260
- )}
261
-
262
- <style>{`
263
- @keyframes spin { to { transform: rotate(360deg); } }
264
- * { -webkit-tap-highlight-color: transparent; }
265
- input[type=range] { accent-color: #00f5ff; }
266
- ::-webkit-scrollbar { width: 4px; height: 4px; }
267
- ::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); }
268
- ::-webkit-scrollbar-thumb { background: rgba(0,245,255,0.2); border-radius: 2px; }
269
- canvas { display: block !important; }
270
- `}</style>
271
  </div>
272
  )
273
  }
 
1
  import { useRef, useState, Suspense } from 'react'
2
+ import Scene from './components/Scene'
3
+ import Toolbar from './components/Toolbar'
4
+ import Timeline from './components/Timeline'
5
+ import ModelsPanel from './components/ModelsPanel'
6
  import PropertiesPanel from './components/PropertiesPanel'
7
+ import ExportPanel from './components/ExportPanel'
8
  import AnimationPlayer from './components/AnimationPlayer'
9
+ import CameraMode from './components/CameraMode'
10
+ import SkyboxPanel from './components/SkyboxPanel'
11
+ import useStore from './store/useStore'
12
+
13
+ const TABS = [
14
+ { id:'models', icon:'📦', label:'Models' },
15
+ { id:'properties', icon:'', label:'Properties' },
16
+ { id:'animations', icon:'🎞', label:'Animations' },
17
+ { id:'camera', icon:'🎥', label:'Camera' },
18
+ { id:'skybox', icon:'🌐', label:'Skybox' },
19
+ { id:'export', icon:'', label:'Export' },
20
  ]
21
 
22
+ // ── Shared panel tab strip ─────────────────────────────────────────────────────
23
+ function TabStrip({ orientation = 'horizontal', onSelect, active }) {
24
+ return (
25
+ <div style={{
26
+ display:'flex',
27
+ flexDirection: orientation === 'vertical' ? 'column' : 'row',
28
+ background:'var(--bg1)',
29
+ borderBottom: orientation === 'horizontal' ? '1px solid var(--border)' : 'none',
30
+ borderRight: orientation === 'vertical' ? '1px solid var(--border)' : 'none',
31
+ overflow: orientation === 'horizontal' ? 'auto hidden' : 'visible',
32
+ }}>
33
+ {TABS.map(t => (
34
+ <button key={t.id}
35
+ onClick={() => onSelect(active === t.id ? null : t.id)}
36
+ title={t.label}
37
+ style={{
38
+ flexShrink: 0,
39
+ padding: orientation === 'vertical' ? '12px 10px' : '8px 14px',
40
+ background: active===t.id ? 'var(--bg2)' : 'transparent',
41
+ border:'none',
42
+ borderLeft: orientation==='vertical' && active===t.id ? '2px solid var(--accent)' : orientation==='vertical' ? '2px solid transparent' : 'none',
43
+ borderBottom: orientation==='horizontal' && active===t.id ? '2px solid var(--accent)' : orientation==='horizontal' ? '2px solid transparent' : 'none',
44
+ color: active===t.id ? 'var(--text0)' : 'var(--text2)',
45
+ cursor:'pointer', fontSize: orientation==='vertical' ? 18 : 12,
46
+ display:'flex', flexDirection: orientation==='vertical' ? 'column' : 'row',
47
+ alignItems:'center', gap: orientation==='vertical' ? 4 : 6,
48
+ transition:'all 0.12s',
49
+ minWidth: orientation==='horizontal' ? 80 : 48,
50
+ }}
51
+ >
52
+ <span style={{ fontSize: orientation==='vertical' ? 16 : 14 }}>{t.icon}</span>
53
+ <span style={{ fontSize:10, fontWeight: active===t.id ? 600 : 400,
54
+ letterSpacing:'0.04em', whiteSpace:'nowrap',
55
+ display: orientation==='vertical' ? 'block' : 'none',
56
+ }}>{t.label}</span>
57
+ </button>
58
+ ))}
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function PanelContent({ id, canvasRef }) {
64
+ return (
65
+ <div style={{ flex:1, overflow:'auto', minHeight:0 }}>
66
+ {id==='models' && <ModelsPanel />}
67
+ {id==='properties' && <PropertiesPanel />}
68
+ {id==='animations' && <AnimationPlayer />}
69
+ {id==='camera' && <CameraMode sceneRef={canvasRef} />}
70
+ {id==='skybox' && <SkyboxPanel />}
71
+ {id==='export' && <ExportPanel canvasRef={canvasRef} />}
72
+ </div>
73
+ )
74
+ }
75
 
76
+ // ── Desktop layout: icon sidebar + slide-out panel ────────────────────────────
77
+ function DesktopLayout({ canvasRef }) {
 
 
78
  const { activePanel, setActivePanel } = useStore()
79
+ const PANEL_W = 260
80
 
81
  return (
82
+ <div style={{ display:'flex', flex:1, overflow:'hidden' }}>
83
+ {/* 3D Canvas */}
84
+ <div ref={canvasRef} style={{ flex:1, position:'relative', overflow:'hidden', minWidth:0 }}>
85
+ <Suspense fallback={<Loading />}>
86
+ <Scene canvasRef={canvasRef} />
87
+ </Suspense>
88
+ </div>
89
+
90
+ {/* Vertical icon rail + slide panel */}
91
+ <div style={{ display:'flex', flexShrink:0, zIndex:150 }}>
92
+ {/* Slide-out panel */}
93
+ <div style={{
94
+ width: activePanel ? PANEL_W : 0,
95
+ overflow:'hidden',
96
+ background:'var(--bg1)',
97
+ borderLeft:'1px solid var(--border)',
98
+ display:'flex', flexDirection:'column',
99
+ transition:'width 0.2s ease',
100
+ }}>
101
+ {activePanel && (
102
+ <div style={{ width:PANEL_W, display:'flex', flexDirection:'column', height:'100%', overflow:'hidden' }}>
103
+ {/* Panel header */}
104
+ <div style={{
105
+ padding:'10px 14px', borderBottom:'1px solid var(--border)',
106
+ display:'flex', alignItems:'center', justifyContent:'space-between',
107
+ flexShrink:0,
108
+ }}>
109
+ <span style={{ fontSize:12, fontWeight:700, color:'var(--text0)', letterSpacing:'0.04em' }}>
110
+ {TABS.find(t=>t.id===activePanel)?.icon}&nbsp;
111
+ {TABS.find(t=>t.id===activePanel)?.label}
112
+ </span>
113
+ <button onClick={() => setActivePanel(null)}
114
+ style={{ background:'none', border:'none', color:'var(--text2)', cursor:'pointer',
115
+ fontSize:16, lineHeight:1, padding:2 }}>×</button>
116
+ </div>
117
+ <PanelContent id={activePanel} canvasRef={canvasRef} />
118
+ </div>
119
+ )}
120
+ </div>
121
 
122
+ {/* Icon rail */}
 
123
  <div style={{
124
+ width:48, background:'var(--bg1)',
125
+ borderLeft:'1px solid var(--border)',
126
+ display:'flex', flexDirection:'column',
127
+ alignItems:'center', paddingTop:8, gap:2,
128
  }}>
129
+ {TABS.map(t => (
130
+ <button key={t.id}
131
+ onClick={() => setActivePanel(activePanel===t.id ? null : t.id)}
132
+ title={t.label}
 
133
  style={{
134
+ width:36, height:36, borderRadius:'var(--radius-sm)',
135
+ background: activePanel===t.id ? 'rgba(79,142,255,0.15)' : 'transparent',
136
+ border:`1px solid ${activePanel===t.id ? 'rgba(79,142,255,0.3)' : 'transparent'}`,
137
+ color: activePanel===t.id ? 'var(--accent)' : 'var(--text2)',
138
+ cursor:'pointer', fontSize:16, transition:'all 0.12s',
139
+ display:'flex', alignItems:'center', justifyContent:'center',
 
 
 
 
 
140
  }}
141
+ onMouseEnter={e => { if(activePanel!==t.id){ e.currentTarget.style.background='var(--bg3)'; e.currentTarget.style.color='var(--text0)' }}}
142
+ onMouseLeave={e => { if(activePanel!==t.id){ e.currentTarget.style.background='transparent'; e.currentTarget.style.color='var(--text2)' }}}
143
+ >{t.icon}</button>
 
144
  ))}
145
  </div>
146
+ </div>
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
  )
149
  }
150
 
151
+ // ── Mobile layout ─────────────────────────────────────────────────────────────
152
+ function MobileLayout({ canvasRef }) {
 
 
153
  const { activePanel, setActivePanel } = useStore()
154
+
155
  return (
156
+ <div style={{ display:'flex', flexDirection:'column', flex:1, overflow:'hidden' }}>
157
+ {/* Canvas */}
158
+ <div ref={canvasRef} style={{ flex:1, position:'relative', overflow:'hidden', minHeight:0 }}>
159
+ <Suspense fallback={<Loading />}>
160
+ <Scene canvasRef={canvasRef} />
161
+ </Suspense>
162
+
163
+ {/* Slide-up drawer */}
164
+ {activePanel && (
165
+ <div style={{
166
+ position:'absolute', bottom:0, left:0, right:0,
167
+ height:'55%', background:'var(--bg1)',
168
+ borderTop:'1px solid var(--border)',
169
+ display:'flex', flexDirection:'column',
170
+ zIndex:180, animation:'fadeUp 0.18s ease',
171
+ }}>
172
+ {/* Drawer handle */}
173
+ <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
174
+ padding:'8px 14px', borderBottom:'1px solid var(--border)', flexShrink:0 }}>
175
+ <span style={{ fontSize:12, fontWeight:700, color:'var(--text0)' }}>
176
+ {TABS.find(t=>t.id===activePanel)?.icon}&nbsp;
177
+ {TABS.find(t=>t.id===activePanel)?.label}
178
+ </span>
179
+ <button onClick={() => setActivePanel(null)}
180
+ style={{ background:'none', border:'none', color:'var(--text2)', cursor:'pointer', fontSize:18 }}>×</button>
181
+ </div>
182
+ <PanelContent id={activePanel} canvasRef={canvasRef} />
183
+ </div>
184
+ )}
185
+ </div>
186
+
187
+ {/* Bottom tab bar */}
188
+ <div style={{
189
+ display:'flex', background:'var(--bg1)',
190
+ borderTop:'1px solid var(--border)', flexShrink:0,
191
+ overflowX:'auto',
192
+ }}>
193
+ {TABS.map(t => (
194
+ <button key={t.id}
195
+ onClick={() => setActivePanel(activePanel===t.id ? null : t.id)}
196
+ style={{
197
+ flex:'0 0 auto', minWidth:52, padding:'8px 6px 6px',
198
+ background: activePanel===t.id ? 'var(--bg2)' : 'transparent',
199
+ border:'none',
200
+ borderTop:`2px solid ${activePanel===t.id ? 'var(--accent)' : 'transparent'}`,
201
+ color: activePanel===t.id ? 'var(--accent)' : 'var(--text2)',
202
+ cursor:'pointer', display:'flex', flexDirection:'column',
203
+ alignItems:'center', gap:2, transition:'all 0.12s',
204
+ }}
205
+ >
206
+ <span style={{ fontSize:18 }}>{t.icon}</span>
207
+ <span style={{ fontSize:9, fontWeight: activePanel===t.id ? 600 : 400 }}>{t.label}</span>
208
+ </button>
209
+ ))}
210
+ </div>
211
  </div>
212
  )
213
  }
214
 
215
+ // ── Loading ────────────────────────────────────────────────────────────────────
216
+ function Loading() {
 
217
  return (
218
+ <div style={{ position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center',
219
+ background:'var(--bg0)', flexDirection:'column', gap:16 }}>
220
+ <div style={{ width:40, height:40, border:'3px solid var(--bg4)',
221
+ borderTopColor:'var(--accent)', borderRadius:'50%', animation:'spin 0.8s linear infinite' }} />
222
+ <span style={{ fontSize:12, color:'var(--text2)', letterSpacing:'0.1em' }}>LOADING SCENE</span>
 
 
 
 
 
 
 
 
223
  </div>
224
  )
225
  }
226
 
227
+ // ── Root ───────────────────────────────────────────────────────────────────────
 
 
228
  export default function App() {
229
  const canvasRef = useRef()
230
  const showTimeline = useStore(s => s.showTimeline)
231
+ const [mobile] = useState(() => window.innerWidth < 640)
232
+ const TL_H = showTimeline ? 148 : 0
 
 
 
 
 
233
 
234
  return (
235
  <div style={{
236
+ width:'100vw', height:'100vh',
237
+ display:'flex', flexDirection:'column',
238
+ background:'var(--bg0)', overflow:'hidden',
239
+ fontFamily:'var(--font-ui)',
 
240
  }}>
241
+ <Toolbar />
 
 
 
242
 
243
+ {/* Main content height minus toolbar and timeline */}
244
+ <div style={{ flex:1, overflow:'hidden', display:'flex', flexDirection:'column',
245
+ paddingBottom: TL_H }}>
246
+ {mobile ? <MobileLayout canvasRef={canvasRef} /> : <DesktopLayout canvasRef={canvasRef} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  </div>
248
 
249
+ {/* Timeline — fixed at bottom */}
250
+ <div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:200 }}>
 
 
 
 
 
 
251
  <Timeline />
252
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  </div>
254
  )
255
  }
src/components/AnimationPlayer.jsx CHANGED
@@ -1,163 +1,93 @@
1
- /**
2
- * AnimationPlayer.jsx
3
- * Detects all animations in loaded GLB models and provides
4
- * per-model animation control: play/pause/loop/speed/blend.
5
- * Drop-in component — no changes to existing files required.
6
- */
7
- import { useEffect, useRef, useState } from 'react'
8
  import useStore from '../store/useStore'
9
 
10
- const COLORS = ['#00f5ff','#ff4080','#40ff80','#ffaa00','#aa40ff','#ff8040']
11
-
12
- function AnimBar({ modelId, modelName, colorIdx }) {
13
- const models = useStore(s => s.models)
14
- const model = models.find(m => m.id === modelId)
15
- const {
16
- setModelActiveAnimation,
17
- setModelAnimSpeed,
18
- } = useStore.getState()
19
-
20
- const [playing, setPlaying] = useState(true)
21
- const [loop, setLoop] = useState(true)
22
- const [blend, setBlend] = useState(0) // 0-1 crossfade weight
23
- const [prevAnim,setPrevAnim] = useState(null)
24
-
25
- if (!model || model.animations.length === 0) return null
26
 
 
 
 
 
27
  const c = COLORS[colorIdx % COLORS.length]
28
- const anims = model.animations
29
 
30
- const switchAnim = (name) => {
31
- setPrevAnim(model.activeAnimation)
32
- setModelActiveAnimation(modelId, name)
33
- setPlaying(true)
34
- }
35
 
36
  return (
37
- <div style={{
38
- background: 'rgba(255,255,255,0.03)',
39
- border: `1px solid ${c}22`,
40
- borderRadius: 8,
41
- padding: '10px 12px',
42
- marginBottom: 8,
43
- }}>
44
  {/* Model name */}
45
- <div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:8 }}>
46
- <div style={{ width:8, height:8, borderRadius:'50%', background:c, boxShadow:`0 0 6px ${c}` }} />
47
- <span style={{ fontSize:11, color:c, fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
48
- {modelName}
49
- </span>
50
- <span style={{ fontSize:10, color:'#444', marginLeft:'auto' }}>
51
- {anims.length} anim{anims.length>1?'s':''}
52
- </span>
53
  </div>
54
 
55
- {/* Animation list */}
56
  <div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:8 }}>
57
- {anims.map(anim => (
58
- <button
59
- key={anim}
60
- onClick={() => switchAnim(anim)}
61
  style={{
62
- padding:'4px 8px',
63
- background: model.activeAnimation===anim ? `${c}22` : 'rgba(255,255,255,0.04)',
64
- border: `1px solid ${model.activeAnimation===anim ? c : 'rgba(255,255,255,0.1)'}`,
65
- color: model.activeAnimation===anim ? c : '#666',
66
- borderRadius:4, cursor:'pointer',
67
- fontSize:10, fontFamily:'Space Mono,monospace',
68
- whiteSpace:'nowrap',
69
- transition:'all 0.15s',
70
  }}
71
- >
72
- {model.activeAnimation===anim && playing ? '▶ ' : ''}{anim}
73
- </button>
74
  ))}
75
  </div>
76
 
77
- {/* Controls row */}
78
  <div style={{ display:'flex', gap:6, alignItems:'center' }}>
79
- <button
80
- onClick={() => setPlaying(!playing)}
81
- style={{
82
- padding:'4px 8px',
83
- background: playing ? 'rgba(255,64,96,0.12)' : 'rgba(64,255,128,0.12)',
84
- border: `1px solid ${playing ? '#ff4060' : '#40ff80'}`,
85
- color: playing ? '#ff4060' : '#40ff80',
86
- borderRadius:4, cursor:'pointer', fontSize:12,
87
- }}
88
- >{playing ? '⏸' : '▶'}</button>
89
 
90
- <button
91
- onClick={() => setLoop(!loop)}
92
- style={{
93
- padding:'4px 8px',
94
- background: loop ? 'rgba(0,245,255,0.1)' : 'rgba(255,255,255,0.04)',
95
- border: `1px solid ${loop ? '#00f5ff' : 'rgba(255,255,255,0.1)'}`,
96
- color: loop ? '#00f5ff' : '#555',
97
- borderRadius:4, cursor:'pointer', fontSize:11,
98
- fontFamily:'Space Mono',
99
- }}
100
- >⟳ LOOP</button>
101
 
102
- {/* Speed */}
103
- <div style={{ display:'flex', alignItems:'center', gap:4, flex:1 }}>
104
- <span style={{ fontSize:9, color:'#444' }}>SPD</span>
105
- <input
106
- type="range" min={0.1} max={3} step={0.05}
107
- value={model.animationSpeed}
108
- onChange={e => setModelAnimSpeed(modelId, parseFloat(e.target.value))}
109
- style={{ flex:1, accentColor: c }}
110
- />
111
- <span style={{ fontSize:10, color:c, minWidth:28 }}>
112
- {model.animationSpeed.toFixed(1)}x
113
  </span>
114
  </div>
115
  </div>
116
-
117
- {/* Crossfade blend — only show when there are 2+ anims */}
118
- {anims.length > 1 && prevAnim && prevAnim !== model.activeAnimation && (
119
- <div style={{ display:'flex', alignItems:'center', gap:6, marginTop:6 }}>
120
- <span style={{ fontSize:9, color:'#444', whiteSpace:'nowrap' }}>BLEND</span>
121
- <input
122
- type="range" min={0} max={1} step={0.01}
123
- value={blend}
124
- onChange={e => setBlend(parseFloat(e.target.value))}
125
- style={{ flex:1, accentColor:'#ffaa00' }}
126
- />
127
- <span style={{ fontSize:10, color:'#ffaa00', minWidth:28 }}>
128
- {Math.round(blend*100)}%
129
- </span>
130
- </div>
131
- )}
132
  </div>
133
  )
134
  }
135
 
136
  export default function AnimationPlayer() {
137
  const models = useStore(s => s.models)
138
- const modelsWithAnims = models.filter(m => m.animations.length > 0)
139
 
140
- if (modelsWithAnims.length === 0) {
141
- return (
142
- <div style={{ padding:'16px 12px', textAlign:'center', color:'#333', fontSize:11 }}>
143
- <div style={{ fontSize:22, marginBottom:6, opacity:0.3 }}>🎞</div>
144
- No animations detected.<br/>Load a GLB with built-in animations.
 
145
  </div>
146
- )
147
- }
148
 
149
  return (
150
- <div style={{ padding:'10px', fontFamily:'Space Mono,monospace', overflow:'auto', maxHeight:'100%' }}>
151
- <div style={{ fontSize:10, color:'#555', letterSpacing:'0.12em', marginBottom:8 }}>
152
- ANIMATIONS ({modelsWithAnims.length} model{modelsWithAnims.length>1?'s':''})
 
153
  </div>
154
- {modelsWithAnims.map((m, i) => (
155
- <AnimBar
156
- key={m.id}
157
- modelId={m.id}
158
- modelName={m.name}
159
- colorIdx={models.indexOf(m)}
160
- />
161
  ))}
162
  </div>
163
  )
 
1
+ import { useState } from 'react'
 
 
 
 
 
 
2
  import useStore from '../store/useStore'
3
 
4
+ const COLORS = ['#4f8eff','#ef4444','#22c55e','#f59e0b','#8b5cf6','#f97316']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ function AnimBlock({ model, colorIdx }) {
7
+ const [playing, setPlaying] = useState(true)
8
+ const [loop, setLoop] = useState(true)
9
+ const { setModelActiveAnimation, setModelAnimSpeed } = useStore.getState()
10
  const c = COLORS[colorIdx % COLORS.length]
 
11
 
12
+ if (!model.animations.length) return null
 
 
 
 
13
 
14
  return (
15
+ <div style={{ padding:'10px 12px', borderBottom:'1px solid var(--border)' }}>
 
 
 
 
 
 
16
  {/* Model name */}
17
+ <div style={{ display:'flex', alignItems:'center', gap:7, marginBottom:8 }}>
18
+ <div style={{ width:7, height:7, borderRadius:2, rotate:'45deg',
19
+ background:c, boxShadow:`0 0 6px ${c}` }} />
20
+ <span style={{ fontSize:12, fontWeight:600, color:'var(--text0)', flex:1,
21
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{model.name}</span>
22
+ <span style={{ fontSize:10, color:'var(--text3)', background:'var(--bg3)',
23
+ padding:'2px 6px', borderRadius:3 }}>{model.animations.length} clips</span>
 
24
  </div>
25
 
26
+ {/* Clip buttons */}
27
  <div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:8 }}>
28
+ {model.animations.map(anim => (
29
+ <button key={anim} onClick={() => { setModelActiveAnimation(model.id, anim); setPlaying(true) }}
 
 
30
  style={{
31
+ padding:'4px 9px', borderRadius:4, cursor:'pointer', fontSize:10, fontWeight:500,
32
+ background: model.activeAnimation===anim ? `${c}18` : 'var(--bg3)',
33
+ border:`1px solid ${model.activeAnimation===anim ? `${c}44` : 'var(--border)'}`,
34
+ color: model.activeAnimation===anim ? c : 'var(--text1)',
35
+ transition:'all 0.12s', maxWidth:120, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
 
 
 
36
  }}
37
+ >{model.activeAnimation===anim && playing ? '▶ ' : ''}{anim}</button>
 
 
38
  ))}
39
  </div>
40
 
41
+ {/* Controls */}
42
  <div style={{ display:'flex', gap:6, alignItems:'center' }}>
43
+ <button onClick={() => setPlaying(!playing)} style={{
44
+ padding:'4px 10px', borderRadius:4, fontSize:12, cursor:'pointer',
45
+ background: playing ? 'rgba(239,68,68,0.1)' : 'rgba(34,197,94,0.1)',
46
+ border:`1px solid ${playing ? 'rgba(239,68,68,0.3)' : 'rgba(34,197,94,0.3)'}`,
47
+ color: playing ? 'var(--danger)' : '#22c55e',
48
+ }}>{playing ? '' : ''}</button>
 
 
 
 
49
 
50
+ <button onClick={() => setLoop(!loop)} style={{
51
+ padding:'4px 10px', borderRadius:4, fontSize:10, cursor:'pointer',
52
+ background: loop ? 'rgba(79,142,255,0.1)' : 'var(--bg3)',
53
+ border:`1px solid ${loop ? 'rgba(79,142,255,0.3)' : 'var(--border)'}`,
54
+ color: loop ? 'var(--accent)' : 'var(--text2)',
55
+ }}>⟳ Loop</button>
 
 
 
 
 
56
 
57
+ <div style={{ display:'flex', alignItems:'center', gap:6, flex:1 }}>
58
+ <input type="range" min={0.1} max={3} step={0.05} value={model.animationSpeed}
59
+ onChange={e => setModelAnimSpeed(model.id, +e.target.value)} />
60
+ <span style={{ fontSize:10, fontFamily:'var(--font-mono)', color:c, minWidth:30 }}>
61
+ {model.animationSpeed.toFixed(1)}×
 
 
 
 
 
 
62
  </span>
63
  </div>
64
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </div>
66
  )
67
  }
68
 
69
  export default function AnimationPlayer() {
70
  const models = useStore(s => s.models)
71
+ const animated = models.filter(m => m.animations.length > 0)
72
 
73
+ if (!animated.length) return (
74
+ <div style={{ padding:24, textAlign:'center' }}>
75
+ <div style={{ fontSize:32, opacity:0.15, marginBottom:10 }}>🎞</div>
76
+ <div style={{ fontSize:12, color:'var(--text2)' }}>No animations detected</div>
77
+ <div style={{ fontSize:11, color:'var(--text3)', marginTop:4 }}>
78
+ Load a GLB with built-in animation clips
79
  </div>
80
+ </div>
81
+ )
82
 
83
  return (
84
+ <div>
85
+ <div style={{ padding:'8px 12px', borderBottom:'1px solid var(--border)',
86
+ fontSize:11, color:'var(--text2)' }}>
87
+ {animated.length} animated model{animated.length>1?'s':''} · click a clip to switch
88
  </div>
89
+ {animated.map((m) => (
90
+ <AnimBlock key={m.id} model={m} colorIdx={models.indexOf(m)} />
 
 
 
 
 
91
  ))}
92
  </div>
93
  )
src/components/ExportPanel.jsx CHANGED
@@ -1,261 +1,180 @@
1
  import { useState, useRef } from 'react'
2
  import useStore from '../store/useStore'
3
 
 
 
 
 
 
 
 
 
 
 
4
  export default function ExportPanel({ canvasRef }) {
5
- const {
6
- totalFrames, fps, setCurrentFrame, setIsPlaying,
7
  isExporting, setIsExporting, exportProgress, setExportProgress,
8
- exportedVideoUrl, setExportedVideoUrl,
9
- clearRecordedFrames,
10
- } = useStore()
11
 
12
- const [quality, setQuality] = useState(0.92)
13
- const [outFps, setOutFps] = useState(30)
14
- const [status, setStatus] = useState('')
15
- const captureRef = useRef(false)
 
16
  const framesRef = useRef([])
17
 
 
 
 
18
  const captureFrame = () => {
19
- const canvas = canvasRef?.current
20
- if (!canvas) return null
21
- // Find the actual canvas element inside the wrapper
22
- const c = canvas.tagName === 'CANVAS' ? canvas : canvas.querySelector('canvas')
23
- if (!c) return null
24
- return c.toDataURL('image/jpeg', quality)
25
  }
26
 
27
  const sleep = ms => new Promise(r => setTimeout(r, ms))
28
 
29
  const startExport = async () => {
30
  if (isExporting) return
31
- setIsExporting(true)
32
- setExportedVideoUrl(null)
33
- framesRef.current = []
34
- captureRef.current = true
35
-
36
- setStatus('Capturing frames...')
37
  const store = useStore.getState()
38
 
39
- for (let frame = 0; frame < totalFrames; frame++) {
40
- if (!captureRef.current) break
41
- store.setCurrentFrame(frame)
42
- await sleep(1000 / fps + 10) // wait for render
43
-
44
- const dataUrl = captureFrame()
45
- if (dataUrl) framesRef.current.push(dataUrl)
46
- setExportProgress(Math.round((frame / totalFrames) * 80))
47
- }
48
-
49
- if (!captureRef.current) {
50
- setIsExporting(false)
51
- setStatus('Cancelled')
52
- return
53
  }
54
 
55
- setStatus('Encoding video...')
56
- setExportProgress(85)
57
-
58
- try {
59
- // Create video using Canvas + MediaRecorder fallback (FFmpeg.wasm needs SharedArrayBuffer)
60
- const videoBlob = await encodeFramesToVideo(framesRef.current, outFps)
61
- const url = URL.createObjectURL(videoBlob)
62
- setExportedVideoUrl(url)
63
- setExportProgress(100)
64
- setStatus('Done! Video ready.')
65
- } catch (err) {
66
- setStatus(`Error: ${err.message}`)
67
- console.error(err)
68
  }
69
-
70
- setIsExporting(false)
71
- store.setCurrentFrame(0)
72
  }
73
 
74
- const encodeFramesToVideo = (frames, fps) => {
75
- return new Promise((resolve, reject) => {
76
- if (frames.length === 0) { reject(new Error('No frames captured')); return }
77
-
78
- const img = new Image()
79
- img.onload = () => {
80
- const canvas = document.createElement('canvas')
81
- canvas.width = img.width
82
- canvas.height = img.height
83
- const ctx = canvas.getContext('2d')
84
-
85
- const stream = canvas.captureStream(fps)
86
- const recorder = new MediaRecorder(stream, {
87
- mimeType: MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
88
- ? 'video/webm;codecs=vp9'
89
- : 'video/webm',
90
- videoBitsPerSecond: 8_000_000,
91
- })
92
-
93
- const chunks = []
94
- recorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data) }
95
- recorder.onstop = () => {
96
- resolve(new Blob(chunks, { type: 'video/webm' }))
97
- }
98
-
99
- recorder.start()
100
-
101
- let i = 0
102
- const interval = setInterval(() => {
103
- if (i >= frames.length) {
104
- clearInterval(interval)
105
- recorder.stop()
106
- return
107
- }
108
- const frame = new Image()
109
- frame.onload = () => ctx.drawImage(frame, 0, 0)
110
- frame.src = frames[i++]
111
- setExportProgress(85 + Math.round((i / frames.length) * 14))
112
- }, 1000 / fps)
113
- }
114
- img.onerror = reject
115
- img.src = frames[0]
116
- })
117
- }
118
-
119
- const cancel = () => {
120
- captureRef.current = false
121
- setIsExporting(false)
122
- setStatus('Cancelled')
123
- setExportProgress(0)
124
- }
125
-
126
- const duration = (totalFrames / fps).toFixed(1)
127
- const estimatedSize = Math.round((totalFrames / fps) * outFps * 0.3)
128
 
129
  return (
130
- <div style={{ padding: 10, fontFamily: 'Space Mono', overflow: 'auto', maxHeight: '100%' }}>
131
- <div style={{ fontSize: 10, color: '#666', marginBottom: 10, letterSpacing: '0.1em' }}>EXPORT VIDEO</div>
132
-
133
- <div style={{ background: 'rgba(0,245,255,0.04)', border: '1px solid rgba(0,245,255,0.1)', borderRadius: 6, padding: 10, marginBottom: 12 }}>
134
- <div style={{ fontSize: 10, color: '#555', marginBottom: 4 }}>PROJECT INFO</div>
135
- <div style={{ fontSize: 11, color: '#888', lineHeight: 1.8 }}>
136
- <div>Frames: <span style={{ color: '#00f5ff' }}>{totalFrames}</span></div>
137
- <div>Timeline: <span style={{ color: '#00f5ff' }}>{duration}s @ {fps}fps</span></div>
138
- <div>Output: <span style={{ color: '#00f5ff' }}>WebM (VP9)</span></div>
139
- </div>
140
  </div>
141
 
142
  {/* Quality */}
143
- <div style={{ marginBottom: 10 }}>
144
- <div style={{ fontSize: 10, color: '#666', marginBottom: 4 }}>FRAME QUALITY</div>
145
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
146
- <input type="range" min={0.5} max={1} step={0.01}
147
- value={quality}
148
- onChange={e => setQuality(parseFloat(e.target.value))}
149
- style={{ flex: 1 }}
150
- />
151
- <span style={{ color: '#00f5ff', fontSize: 11 }}>{Math.round(quality * 100)}%</span>
152
  </div>
 
 
153
  </div>
154
 
155
  {/* FPS */}
156
- <div style={{ marginBottom: 12 }}>
157
- <div style={{ fontSize: 10, color: '#666', marginBottom: 4 }}>OUTPUT FPS</div>
158
- <div style={{ display: 'flex', gap: 6 }}>
159
- {[15, 24, 30, 60].map(f => (
160
- <button
161
- key={f}
162
- onClick={() => setOutFps(f)}
163
- style={{
164
- flex: 1, padding: '6px 0',
165
- background: outFps === f ? 'rgba(0,245,255,0.15)' : 'rgba(255,255,255,0.04)',
166
- border: `1px solid ${outFps === f ? '#00f5ff' : 'rgba(255,255,255,0.1)'}`,
167
- color: outFps === f ? '#00f5ff' : '#666',
168
- borderRadius: 4, cursor: 'pointer',
169
- fontSize: 11, fontFamily: 'Space Mono',
170
- }}
171
- >{f}</button>
172
  ))}
173
  </div>
174
  </div>
175
 
176
  {/* Progress */}
177
  {isExporting && (
178
- <div style={{ marginBottom: 10 }}>
179
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
180
- <span style={{ fontSize: 10, color: '#888' }}>{status}</span>
181
- <span style={{ fontSize: 10, color: '#00f5ff' }}>{exportProgress}%</span>
182
  </div>
183
- <div style={{ height: 4, background: 'rgba(255,255,255,0.08)', borderRadius: 2 }}>
184
- <div style={{
185
- height: '100%', width: `${exportProgress}%`,
186
- background: 'linear-gradient(90deg, #00f5ff, #0080ff)',
187
- borderRadius: 2, transition: 'width 0.3s',
188
- }} />
189
  </div>
190
  </div>
191
  )}
192
 
 
193
  {status && !isExporting && (
194
  <div style={{
195
- fontSize: 11, color: status.includes('Error') ? '#ff4060' : '#40ff80',
196
- marginBottom: 10, padding: '6px 8px',
197
- background: status.includes('Error') ? 'rgba(255,64,96,0.08)' : 'rgba(64,255,128,0.08)',
198
- borderRadius: 4, border: `1px solid ${status.includes('Error') ? 'rgba(255,64,96,0.2)' : 'rgba(64,255,128,0.2)'}`,
199
- }}>
200
- {status}
201
- </div>
202
  )}
203
 
204
  {/* Buttons */}
205
  {!isExporting ? (
206
- <button
207
- onClick={startExport}
208
- style={{
209
- width: '100%', padding: '10px 0',
210
- background: 'linear-gradient(135deg, rgba(0,245,255,0.2), rgba(0,128,255,0.2))',
211
- border: '1px solid rgba(0,245,255,0.4)',
212
- color: '#00f5ff', borderRadius: 6,
213
- cursor: 'pointer', fontSize: 12,
214
- fontFamily: 'Space Mono', fontWeight: 'bold',
215
- letterSpacing: '0.1em',
216
- }}
217
- >
218
- ▶ RENDER & EXPORT
219
- </button>
220
  ) : (
221
- <button
222
- onClick={cancel}
223
- style={{
224
- width: '100%', padding: '10px 0',
225
- background: 'rgba(255,64,96,0.1)',
226
- border: '1px solid rgba(255,64,96,0.3)',
227
- color: '#ff4060', borderRadius: 6,
228
- cursor: 'pointer', fontSize: 12,
229
- fontFamily: 'Space Mono',
230
- }}
231
- >
232
- ⏹ CANCEL
233
- </button>
234
  )}
235
 
236
- {/* Download */}
237
  {exportedVideoUrl && (
238
- <div style={{ marginTop: 12 }}>
239
- <div style={{ fontSize: 10, color: '#40ff80', marginBottom: 8 }}>✓ VIDEO READY</div>
240
- <video
241
- src={exportedVideoUrl}
242
- controls
243
- style={{ width: '100%', borderRadius: 6, marginBottom: 8 }}
244
- />
245
- <a
246
- href={exportedVideoUrl}
247
- download={`animation_${Date.now()}.webm`}
248
- style={{
249
- display: 'block', width: '100%', padding: '8px 0',
250
- background: 'rgba(64,255,128,0.1)',
251
- border: '1px solid rgba(64,255,128,0.3)',
252
- color: '#40ff80', borderRadius: 6,
253
- textAlign: 'center', textDecoration: 'none',
254
- fontSize: 11, fontFamily: 'Space Mono',
255
- }}
256
- >
257
- ⬇ DOWNLOAD VIDEO
258
- </a>
259
  </div>
260
  )}
261
  </div>
 
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 */}
126
  {isExporting && (
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
  )}
138
 
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>
src/components/ModelsPanel.jsx CHANGED
@@ -1,195 +1,204 @@
1
  import { useState, useRef } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
- const SAMPLE_MODELS = [
5
- { name: 'Fox', url: 'https://threejs.org/examples/models/gltf/Fox/glTF/Fox.gltf' },
6
- { name: 'Horse', url: 'https://threejs.org/examples/models/gltf/Horse.glb' },
 
7
  { name: 'Flamingo', url: 'https://threejs.org/examples/models/gltf/Flamingo.glb' },
8
- { name: 'Stork', url: 'https://threejs.org/examples/models/gltf/Stork.glb' },
9
- { name: 'Parrot', url: 'https://threejs.org/examples/models/gltf/Parrot.glb' },
10
- { name: 'Robot', url: 'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb' },
11
- { name: 'Soldier', url: 'https://threejs.org/examples/models/gltf/Soldier.glb' },
12
  ]
13
 
 
 
14
  export default function ModelsPanel() {
15
  const { models, selectedModelId, addModel, removeModel, selectModel, toggleModelVisibility } = useStore()
16
- const [urlInput, setUrlInput] = useState('')
17
- const [nameInput, setNameInput] = useState('')
18
- const [showSamples, setShowSamples] = useState(false)
19
- const [loading, setLoading] = useState(false)
20
  const fileRef = useRef()
21
 
22
- const handleAddUrl = () => {
23
- if (!urlInput.trim()) return
24
- setLoading(true)
25
- addModel(urlInput.trim(), nameInput.trim() || null)
26
- setUrlInput('')
27
- setNameInput('')
28
- setTimeout(() => setLoading(false), 1500)
29
  }
30
 
31
- const handleFileUpload = (e) => {
32
  const file = e.target.files[0]
33
  if (!file) return
34
- const url = URL.createObjectURL(file)
35
- addModel(url, file.name.replace(/\.[^.]+$/, ''))
36
  }
37
 
38
- const colors = ['#00f5ff', '#ff4080', '#40ff80', '#ffaa00', '#aa40ff', '#ff8040']
 
 
 
 
 
 
39
 
40
  return (
41
- <div style={{ padding: 10, fontFamily: 'Space Mono', overflow: 'auto', maxHeight: '100%' }}>
42
- {/* Add by URL */}
43
- <div style={{ marginBottom: 10 }}>
44
- <div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>ADD MODEL</div>
45
- <input
46
- placeholder="Name (optional)"
47
- value={nameInput}
48
- onChange={e => setNameInput(e.target.value)}
49
- style={inputStyle}
50
- />
51
- <input
52
- placeholder="GLB/GLTF URL..."
53
- value={urlInput}
54
- onChange={e => setUrlInput(e.target.value)}
55
- onKeyDown={e => e.key === 'Enter' && handleAddUrl()}
56
- style={{ ...inputStyle, marginTop: 4 }}
57
- />
58
- <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
59
- <button onClick={handleAddUrl} style={primaryBtn} disabled={loading}>
60
- {loading ? '⏳' : '+ URL'}
61
- </button>
62
- <button onClick={() => fileRef.current?.click()} style={secondaryBtn}>
63
- 📁 FILE
64
- </button>
65
- <button
66
- onClick={() => setShowSamples(!showSamples)}
67
- style={{ ...secondaryBtn, color: showSamples ? '#00f5ff' : '#888' }}
68
- >
69
- ◈ DEMO
70
- </button>
71
  </div>
72
- <input ref={fileRef} type="file" accept=".glb,.gltf" style={{ display: 'none' }} onChange={handleFileUpload} />
 
73
  </div>
74
 
75
- {/* Sample models */}
76
- {showSamples && (
77
- <div style={{ marginBottom: 10 }}>
78
- <div style={{ fontSize: 10, color: '#666', marginBottom: 4, letterSpacing: '0.1em' }}>SAMPLE MODELS</div>
79
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
80
- {SAMPLE_MODELS.map(sm => (
81
- <button
82
- key={sm.url}
83
- onClick={() => { addModel(sm.url, sm.name); setShowSamples(false) }}
84
- style={{
85
- background: 'rgba(0,245,255,0.06)',
86
- border: '1px solid rgba(0,245,255,0.15)',
87
- color: '#9dd', padding: '6px 10px',
88
- borderRadius: 4, cursor: 'pointer',
89
- fontSize: 11, textAlign: 'left',
90
- fontFamily: 'Space Mono',
91
- }}
92
- >
93
- ◈ {sm.name}
94
- </button>
95
- ))}
96
- </div>
97
  </div>
98
- )}
99
-
100
- <div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
101
-
102
- {/* Model list */}
103
- <div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>
104
- MODELS ({models.length})
105
  </div>
106
 
107
- {models.length === 0 && (
108
- <div style={{ color: '#333', fontSize: 11, textAlign: 'center', padding: 16 }}>
109
- No models loaded.<br />Add a GLB URL or upload a file.
110
- </div>
111
- )}
 
 
 
 
 
 
112
 
113
- {models.map((m, i) => {
114
- const isSelected = m.id === selectedModelId
115
- const color = colors[i % colors.length]
116
- return (
117
- <div
118
- key={m.id}
119
- onClick={() => selectModel(m.id)}
120
- style={{
121
- display: 'flex', alignItems: 'center', gap: 8,
122
- padding: '8px 8px',
123
- marginBottom: 4,
124
- background: isSelected ? `${color}11` : 'rgba(255,255,255,0.03)',
125
- border: `1px solid ${isSelected ? color + '44' : 'rgba(255,255,255,0.06)'}`,
126
- borderRadius: 6, cursor: 'pointer',
127
- transition: 'all 0.15s',
128
- }}
129
- >
130
- <div style={{
131
- width: 8, height: 8, borderRadius: '50%',
132
- background: m.visible ? color : '#333',
133
- boxShadow: m.visible ? `0 0 6px ${color}` : 'none',
134
- flexShrink: 0,
135
- }} />
136
- <span style={{
137
- flex: 1, fontSize: 11, color: isSelected ? '#fff' : '#aaa',
138
- overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
139
- }}>
140
- {m.name}
141
- </span>
142
- {/* Visibility toggle */}
143
- <button
144
- onClick={e => { e.stopPropagation(); toggleModelVisibility(m.id) }}
145
  style={{
146
- background: 'none', border: 'none',
147
- color: m.visible ? '#aaa' : '#333',
148
- cursor: 'pointer', fontSize: 12, padding: 0,
 
149
  }}
150
- title="Toggle visibility"
 
151
  >
152
- {m.visible ? '👁' : '🙈'}
153
  </button>
154
- <button
155
- onClick={e => { e.stopPropagation(); removeModel(m.id) }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  style={{
157
- background: 'none', border: 'none',
158
- color: '#444', cursor: 'pointer', fontSize: 12, padding: 0,
 
 
 
 
159
  }}
160
- title="Remove"
161
- >✕</button>
162
- </div>
163
- )
164
- })}
165
- </div>
166
- )
167
- }
 
 
168
 
169
- const inputStyle = {
170
- width: '100%', background: 'rgba(255,255,255,0.04)',
171
- border: '1px solid rgba(255,255,255,0.1)',
172
- color: '#ddd', padding: '7px 9px',
173
- borderRadius: 5, fontSize: 11,
174
- fontFamily: 'Space Mono, monospace',
175
- outline: 'none',
176
- display: 'block',
177
- }
178
 
179
- const primaryBtn = {
180
- flex: 1, padding: '7px 0',
181
- background: 'rgba(0,245,255,0.12)',
182
- border: '1px solid rgba(0,245,255,0.3)',
183
- color: '#00f5ff', borderRadius: 5,
184
- cursor: 'pointer', fontSize: 11,
185
- fontFamily: 'Space Mono',
186
- }
187
 
188
- const secondaryBtn = {
189
- flex: 1, padding: '7px 0',
190
- background: 'rgba(255,255,255,0.05)',
191
- border: '1px solid rgba(255,255,255,0.1)',
192
- color: '#888', borderRadius: 5,
193
- cursor: 'pointer', fontSize: 11,
194
- fontFamily: 'Space Mono',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  }
 
1
  import { useState, useRef } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
+ const SAMPLES = [
5
+ { name: 'Fox', url: 'https://threejs.org/examples/models/gltf/Fox/glTF/Fox.gltf' },
6
+ { name: 'Robot', url: 'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb' },
7
+ { name: 'Soldier', url: 'https://threejs.org/examples/models/gltf/Soldier.glb' },
8
  { name: 'Flamingo', url: 'https://threejs.org/examples/models/gltf/Flamingo.glb' },
9
+ { name: 'Horse', url: 'https://threejs.org/examples/models/gltf/Horse.glb' },
10
+ { name: 'Parrot', url: 'https://threejs.org/examples/models/gltf/Parrot.glb' },
 
 
11
  ]
12
 
13
+ const COLORS = ['#4f8eff','#ef4444','#06d6a0','#f59e0b','#8b5cf6','#f97316']
14
+
15
  export default function ModelsPanel() {
16
  const { models, selectedModelId, addModel, removeModel, selectModel, toggleModelVisibility } = useStore()
17
+ const [url, setUrl] = useState('')
18
+ const [name, setName] = useState('')
19
+ const [samples, setSamples] = useState(false)
20
+ const [dragging, setDragging] = useState(false)
21
  const fileRef = useRef()
22
 
23
+ const handleAdd = () => {
24
+ if (!url.trim()) return
25
+ addModel(url.trim(), name.trim() || null)
26
+ setUrl(''); setName('')
 
 
 
27
  }
28
 
29
+ const handleFile = (e) => {
30
  const file = e.target.files[0]
31
  if (!file) return
32
+ addModel(URL.createObjectURL(file), file.name.replace(/\.[^.]+$/, ''))
 
33
  }
34
 
35
+ const handleDrop = (e) => {
36
+ e.preventDefault(); setDragging(false)
37
+ const file = e.dataTransfer.files[0]
38
+ if (file && (file.name.endsWith('.glb') || file.name.endsWith('.gltf'))) {
39
+ addModel(URL.createObjectURL(file), file.name.replace(/\.[^.]+$/, ''))
40
+ }
41
+ }
42
 
43
  return (
44
+ <div style={{ padding: 12, display:'flex', flexDirection:'column', gap:10 }}>
45
+
46
+ {/* Drop zone */}
47
+ <div
48
+ onDrop={handleDrop}
49
+ onDragOver={e => { e.preventDefault(); setDragging(true) }}
50
+ onDragLeave={() => setDragging(false)}
51
+ onClick={() => fileRef.current?.click()}
52
+ style={{
53
+ border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border-hi)'}`,
54
+ borderRadius: 'var(--radius)',
55
+ padding: '20px 12px',
56
+ textAlign: 'center',
57
+ cursor: 'pointer',
58
+ background: dragging ? 'rgba(79,142,255,0.06)' : 'var(--bg2)',
59
+ transition: 'all 0.15s',
60
+ animation: dragging ? 'pulse 1s ease infinite' : 'none',
61
+ }}
62
+ >
63
+ <div style={{ fontSize: 28, marginBottom: 6 }}>📦</div>
64
+ <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>
65
+ {dragging ? 'Drop to load' : 'Drop GLB / GLTF here'}
 
 
 
 
 
 
 
 
66
  </div>
67
+ <div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 3 }}>or click to browse</div>
68
+ <input ref={fileRef} type="file" accept=".glb,.gltf" style={{ display:'none' }} onChange={handleFile} />
69
  </div>
70
 
71
+ {/* URL input */}
72
+ <div style={{ display:'flex', flexDirection:'column', gap:5 }}>
73
+ <input value={name} onChange={e=>setName(e.target.value)}
74
+ placeholder="Name (optional)" style={{}} />
75
+ <div style={{ display:'flex', gap:5 }}>
76
+ <input value={url} onChange={e=>setUrl(e.target.value)}
77
+ onKeyDown={e=>e.key==='Enter'&&handleAdd()}
78
+ placeholder="Paste GLB / GLTF URL…" style={{ flex:1 }} />
79
+ <button onClick={handleAdd} style={{
80
+ padding:'5px 12px', borderRadius:'var(--radius-sm)',
81
+ background:'var(--accent)', border:'none', color:'#fff',
82
+ fontSize:11, fontWeight:600, cursor:'pointer', flexShrink:0,
83
+ transition:'opacity 0.15s',
84
+ }}>Add</button>
 
 
 
 
 
 
 
 
85
  </div>
 
 
 
 
 
 
 
86
  </div>
87
 
88
+ {/* Sample models */}
89
+ <button onClick={() => setSamples(!samples)} style={{
90
+ padding:'6px 10px', borderRadius:'var(--radius-sm)',
91
+ background: samples ? 'var(--bg4)' : 'var(--bg2)',
92
+ border:'1px solid var(--border-hi)',
93
+ color:'var(--text1)', fontSize:11, cursor:'pointer',
94
+ display:'flex', alignItems:'center', justifyContent:'space-between',
95
+ }}>
96
+ <span>⚡ Demo models</span>
97
+ <span style={{ color:'var(--text2)' }}>{samples ? '▲' : '▼'}</span>
98
+ </button>
99
 
100
+ {samples && (
101
+ <div style={{
102
+ display:'grid', gridTemplateColumns:'1fr 1fr',
103
+ gap:5, animation:'fadeUp 0.15s ease',
104
+ }}>
105
+ {SAMPLES.map(s => (
106
+ <button key={s.url}
107
+ onClick={() => { addModel(s.url, s.name); setSamples(false) }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  style={{
109
+ padding:'8px 10px', borderRadius:'var(--radius-sm)',
110
+ background:'var(--bg3)', border:'1px solid var(--border)',
111
+ color:'var(--text1)', fontSize:11, cursor:'pointer',
112
+ textAlign:'left', transition:'all 0.12s',
113
  }}
114
+ onMouseEnter={e => { e.currentTarget.style.background='var(--bg4)'; e.currentTarget.style.color='var(--text0)' }}
115
+ onMouseLeave={e => { e.currentTarget.style.background='var(--bg3)'; e.currentTarget.style.color='var(--text1)' }}
116
  >
117
+ 📦 {s.name}
118
  </button>
119
+ ))}
120
+ </div>
121
+ )}
122
+
123
+ {/* Divider + count */}
124
+ {models.length > 0 && (
125
+ <div style={{ display:'flex', alignItems:'center', gap:8 }}>
126
+ <div style={{ flex:1, height:1, background:'var(--border)' }} />
127
+ <span style={{ fontSize:10, color:'var(--text3)', fontWeight:600 }}>
128
+ {models.length} MODEL{models.length>1?'S':''}
129
+ </span>
130
+ <div style={{ flex:1, height:1, background:'var(--border)' }} />
131
+ </div>
132
+ )}
133
+
134
+ {/* Model list */}
135
+ <div style={{ display:'flex', flexDirection:'column', gap:3 }}>
136
+ {models.map((m, i) => {
137
+ const sel = m.id === selectedModelId
138
+ const c = COLORS[i % COLORS.length]
139
+ return (
140
+ <div
141
+ key={m.id}
142
+ onClick={() => selectModel(m.id)}
143
  style={{
144
+ display:'flex', alignItems:'center', gap:8,
145
+ padding:'8px 10px',
146
+ borderRadius:'var(--radius-sm)',
147
+ background: sel ? 'rgba(79,142,255,0.1)' : 'var(--bg2)',
148
+ border:`1px solid ${sel ? 'rgba(79,142,255,0.3)' : 'var(--border)'}`,
149
+ cursor:'pointer', transition:'all 0.12s',
150
  }}
151
+ onMouseEnter={e => { if (!sel) e.currentTarget.style.background='var(--bg3)' }}
152
+ onMouseLeave={e => { if (!sel) e.currentTarget.style.background='var(--bg2)' }}
153
+ >
154
+ {/* Color dot */}
155
+ <div style={{
156
+ width:8, height:8, borderRadius:'50%', flexShrink:0,
157
+ background: m.visible ? c : 'var(--text3)',
158
+ boxShadow: m.visible ? `0 0 6px ${c}88` : 'none',
159
+ transition:'all 0.15s',
160
+ }} />
161
 
162
+ {/* Name */}
163
+ <span style={{
164
+ flex:1, fontSize:12, fontWeight: sel ? 600 : 400,
165
+ color: sel ? 'var(--text0)' : 'var(--text1)',
166
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
167
+ }}>{m.name}</span>
 
 
 
168
 
169
+ {/* Anims badge */}
170
+ {m.animations.length > 0 && (
171
+ <span style={{
172
+ fontSize:9, padding:'2px 5px', borderRadius:3,
173
+ background:'rgba(6,214,160,0.12)', color:'var(--accent3)',
174
+ border:'1px solid rgba(6,214,160,0.2)', flexShrink:0,
175
+ }}>{m.animations.length}</span>
176
+ )}
177
 
178
+ {/* Actions */}
179
+ <button
180
+ onClick={e => { e.stopPropagation(); toggleModelVisibility(m.id) }}
181
+ title="Toggle visibility"
182
+ style={{ background:'none', border:'none', color: m.visible ? 'var(--text2)' : 'var(--text3)', fontSize:13, cursor:'pointer', flexShrink:0, padding:2 }}
183
+ >{m.visible ? '👁' : '🙈'}</button>
184
+
185
+ <button
186
+ onClick={e => { e.stopPropagation(); removeModel(m.id) }}
187
+ title="Remove model"
188
+ style={{ background:'none', border:'none', color:'var(--text3)', fontSize:12, cursor:'pointer', flexShrink:0, padding:2, transition:'color 0.12s' }}
189
+ onMouseEnter={e => e.currentTarget.style.color='var(--danger)'}
190
+ onMouseLeave={e => e.currentTarget.style.color='var(--text3)'}
191
+ >✕</button>
192
+ </div>
193
+ )
194
+ })}
195
+
196
+ {models.length === 0 && (
197
+ <div style={{ textAlign:'center', color:'var(--text3)', fontSize:12, padding:'12px 0' }}>
198
+ No models loaded yet
199
+ </div>
200
+ )}
201
+ </div>
202
+ </div>
203
+ )
204
  }
src/components/PropertiesPanel.jsx CHANGED
@@ -1,35 +1,71 @@
 
1
  import useStore from '../store/useStore'
2
 
3
  const DEG = 180 / Math.PI
4
 
5
- function VecInput({ label, value, onChange, step = 0.01, decimals = 3, scale = 1 }) {
6
- const axes = ['X', 'Y', 'Z']
7
  return (
8
- <div style={{ marginBottom: 10 }}>
9
- <div style={{ fontSize: 10, color: '#666', marginBottom: 4, letterSpacing: '0.1em' }}>{label}</div>
10
- <div style={{ display: 'flex', gap: 4 }}>
11
- {axes.map((axis, i) => (
12
- <div key={axis} style={{ flex: 1 }}>
13
- <div style={{ fontSize: 9, color: ['#ff5060', '#60ff80', '#4080ff'][i], marginBottom: 2 }}>{axis}</div>
14
- <input
15
- type="number"
16
- value={(value[i] * scale).toFixed(decimals)}
17
- step={step}
18
- onChange={e => {
19
- const v = parseFloat(e.target.value) || 0
20
- const arr = [...value]
21
- arr[i] = v / scale
22
- onChange(arr)
23
- }}
24
- style={{
25
- width: '100%', background: 'rgba(255,255,255,0.04)',
26
- border: `1px solid ${['rgba(255,80,96,0.3)', 'rgba(96,255,128,0.3)', 'rgba(64,128,255,0.3)'][i]}`,
27
- color: '#ddd', padding: '4px 6px',
28
- borderRadius: 4, fontSize: 11,
29
- fontFamily: 'Space Mono, monospace',
30
- outline: 'none',
31
- }}
32
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  </div>
34
  ))}
35
  </div>
@@ -39,194 +75,158 @@ function VecInput({ label, value, onChange, step = 0.01, decimals = 3, scale = 1
39
 
40
  export default function PropertiesPanel() {
41
  const {
42
- models, selectedModelId, updateModelTransform,
43
- setModelActiveAnimation, setModelAnimSpeed,
44
- currentFrame, addKeyframe, removeKeyframe,
45
- keyframes, getKeyframesForModel,
46
- selectModel, removeModel
47
  } = useStore()
48
 
49
  const model = models.find(m => m.id === selectedModelId)
50
 
51
- if (!model) {
52
- return (
53
- <div style={{
54
- padding: 16, color: '#444', fontSize: 12,
55
- fontFamily: 'Space Mono', textAlign: 'center',
56
- lineHeight: 1.8,
57
- }}>
58
- <div style={{ fontSize: 24, marginBottom: 8, opacity: 0.3 }}>◎</div>
59
- <div>Tap a model in the scene</div>
60
- <div>to select it</div>
61
  </div>
62
- )
63
- }
64
 
65
- const modelKeyframes = getKeyframesForModel(model.id)
66
- const hasKfAtFrame = keyframes[currentFrame]?.[model.id]
67
 
68
  return (
69
- <div style={{ padding: '10px', fontFamily: 'Space Mono, monospace', overflow: 'auto', maxHeight: '100%' }}>
70
- {/* Model name */}
71
- <div style={{ marginBottom: 12 }}>
72
- <div style={{ fontSize: 10, color: '#00f5ff', letterSpacing: '0.15em', marginBottom: 4 }}>SELECTED</div>
73
- <div style={{
74
- fontSize: 12, color: '#fff', fontWeight: 'bold',
75
- overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
76
- }}>
 
77
  {model.name}
78
- </div>
 
 
 
 
 
 
 
79
  </div>
80
 
81
- <div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
82
-
83
  {/* Transform */}
84
- <VecInput
85
- label="POSITION"
86
- value={model.position}
87
- step={0.1}
88
- decimals={2}
89
- onChange={v => updateModelTransform(model.id, 'position', v)}
90
- />
91
- <VecInput
92
- label="ROTATION (deg)"
93
- value={model.rotation}
94
- step={1}
95
- decimals={1}
96
- scale={DEG}
97
- onChange={v => updateModelTransform(model.id, 'rotation', v)}
98
- />
99
- <VecInput
100
- label="SCALE"
101
- value={model.scale}
102
- step={0.05}
103
- decimals={2}
104
- onChange={v => updateModelTransform(model.id, 'scale', v)}
105
- />
106
-
107
- <div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
108
-
109
- {/* Animation */}
110
  {model.animations.length > 0 && (
111
- <div style={{ marginBottom: 10 }}>
112
- <div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>ANIMATIONS</div>
113
- <select
114
- value={model.activeAnimation || ''}
115
- onChange={e => setModelActiveAnimation(model.id, e.target.value)}
116
- style={{
117
- width: '100%', background: '#0d0d1a',
118
- border: '1px solid rgba(255,255,255,0.1)',
119
- color: '#ddd', padding: '6px 8px',
120
- borderRadius: 4, fontSize: 11,
121
- fontFamily: 'Space Mono', marginBottom: 6,
122
- cursor: 'pointer',
123
- }}
124
- >
125
- {model.animations.map(a => (
126
- <option key={a} value={a}>{a}</option>
 
127
  ))}
128
- </select>
129
-
130
- <div style={{ fontSize: 10, color: '#666', marginBottom: 4 }}>SPEED</div>
131
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
132
- <input
133
- type="range" min={0.1} max={3} step={0.1}
134
  value={model.animationSpeed}
135
- onChange={e => setModelAnimSpeed(model.id, parseFloat(e.target.value))}
136
- style={{ flex: 1 }}
137
- />
138
- <span style={{ color: '#00f5ff', fontSize: 11, minWidth: 30 }}>
139
- {model.animationSpeed.toFixed(1)}x
140
  </span>
141
  </div>
142
- </div>
143
  )}
144
 
145
- <div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
146
-
147
- {/* Keyframe at current frame */}
148
- <div style={{ marginBottom: 8 }}>
149
- <div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>
150
- KEYFRAME @ FRAME {currentFrame}
151
- </div>
152
- <div style={{ display: 'flex', gap: 6 }}>
153
  <button
154
  onClick={() => addKeyframe(currentFrame, model.id)}
155
  style={{
156
- flex: 1, padding: '7px 0',
157
- background: hasKfAtFrame ? 'rgba(255,170,0,0.2)' : 'rgba(0,245,255,0.1)',
158
- border: `1px solid ${hasKfAtFrame ? '#ffaa00' : '#00f5ff'}`,
159
- color: hasKfAtFrame ? '#ffaa00' : '#00f5ff',
160
- borderRadius: 6, cursor: 'pointer',
161
- fontSize: 11, fontFamily: 'Space Mono',
162
  }}
163
- >
164
- {hasKfAtFrame ? '◆ UPDATE' : '◆ ADD KF'}
165
- </button>
166
- {hasKfAtFrame && (
167
- <button
168
- onClick={() => removeKeyframe(currentFrame, model.id)}
169
  style={{
170
- padding: '7px 10px',
171
- background: 'rgba(255,64,96,0.1)',
172
- border: '1px solid rgba(255,64,96,0.4)',
173
- color: '#ff4060', borderRadius: 6,
174
- cursor: 'pointer', fontSize: 11,
175
- }}
176
- >✕</button>
177
  )}
178
  </div>
179
- </div>
180
 
181
- {/* Keyframe list */}
182
- {modelKeyframes.length > 0 && (
183
- <div>
184
- <div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>
185
- ALL KEYFRAMES ({modelKeyframes.length})
186
- </div>
187
- <div style={{ maxHeight: 120, overflow: 'auto' }}>
188
- {modelKeyframes.map(({ frame }) => (
189
- <div
190
- key={frame}
191
  style={{
192
- display: 'flex', justifyContent: 'space-between', alignItems: 'center',
193
- padding: '4px 6px', marginBottom: 2,
194
- background: frame === currentFrame ? 'rgba(255,170,0,0.1)' : 'rgba(255,255,255,0.03)',
195
- border: `1px solid ${frame === currentFrame ? 'rgba(255,170,0,0.3)' : 'transparent'}`,
196
- borderRadius: 4, cursor: 'pointer',
197
  }}
198
- onClick={() => useStore.getState().setCurrentFrame(frame)}
199
  >
200
- <span style={{ fontSize: 11, color: '#aaa' }}>Frame {frame}</span>
 
 
201
  <button
202
  onClick={e => { e.stopPropagation(); removeKeyframe(frame, model.id) }}
203
- style={{
204
- background: 'none', border: 'none',
205
- color: '#555', cursor: 'pointer', fontSize: 11,
206
- }}
207
  >✕</button>
208
  </div>
209
  ))}
210
  </div>
211
- </div>
212
- )}
213
-
214
- <div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
215
-
216
- {/* Delete model */}
217
- <button
218
- onClick={() => { removeModel(model.id); selectModel(null) }}
219
- style={{
220
- width: '100%', padding: '8px 0',
221
- background: 'rgba(255,64,96,0.08)',
222
- border: '1px solid rgba(255,64,96,0.3)',
223
- color: '#ff4060', borderRadius: 6,
224
- cursor: 'pointer', fontSize: 11,
225
- fontFamily: 'Space Mono',
226
- }}
227
- >
228
- 🗑 REMOVE MODEL
229
- </button>
230
  </div>
231
  )
232
  }
 
1
+ import { useState } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
  const DEG = 180 / Math.PI
5
 
6
+ function Section({ title, children, defaultOpen = true }) {
7
+ const [open, setOpen] = useState(defaultOpen)
8
  return (
9
+ <div style={{ borderBottom: '1px solid var(--border)' }}>
10
+ <button
11
+ onClick={() => setOpen(!open)}
12
+ style={{
13
+ width:'100%', padding:'8px 12px',
14
+ display:'flex', alignItems:'center', justifyContent:'space-between',
15
+ background:'transparent', border:'none', color:'var(--text1)',
16
+ fontSize:11, fontWeight:600, cursor:'pointer', letterSpacing:'0.08em',
17
+ textTransform:'uppercase',
18
+ }}
19
+ >
20
+ {title}
21
+ <span style={{ color:'var(--text3)', transition:'transform 0.15s',
22
+ display:'inline-block', transform: open?'rotate(0deg)':'rotate(-90deg)' }}>▾</span>
23
+ </button>
24
+ {open && <div style={{ padding:'0 12px 12px' }}>{children}</div>}
25
+ </div>
26
+ )
27
+ }
28
+
29
+ function Vec3({ label, value, onChange, step=0.01, scale=1, decimals=3, min, max }) {
30
+ const axes = [
31
+ { k:'X', color:'#ef4444' },
32
+ { k:'Y', color:'#22c55e' },
33
+ { k:'Z', color:'#3b82f6' },
34
+ ]
35
+ return (
36
+ <div style={{ marginBottom:8 }}>
37
+ <div style={{ fontSize:10, color:'var(--text2)', marginBottom:5, fontWeight:500, letterSpacing:'0.06em' }}>
38
+ {label}
39
+ </div>
40
+ <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:4 }}>
41
+ {axes.map((ax, i) => (
42
+ <div key={ax.k}>
43
+ <div style={{
44
+ display:'flex', alignItems:'center',
45
+ background:'var(--bg1)', border:'1px solid var(--border)',
46
+ borderRadius:'var(--radius-sm)', overflow:'hidden',
47
+ transition:'border-color 0.15s',
48
+ }}
49
+ onFocusCapture={e => e.currentTarget.style.borderColor='var(--accent)'}
50
+ onBlurCapture={e => e.currentTarget.style.borderColor='var(--border)'}
51
+ >
52
+ <span style={{
53
+ padding:'0 5px', fontSize:10, fontWeight:700,
54
+ color: ax.color, background:'var(--bg2)',
55
+ borderRight:'1px solid var(--border)', alignSelf:'stretch',
56
+ display:'flex', alignItems:'center',
57
+ }}>{ax.k}</span>
58
+ <input
59
+ type="number" step={step} min={min} max={max}
60
+ value={(value[i] * scale).toFixed(decimals)}
61
+ onChange={e => {
62
+ const v = parseFloat(e.target.value) || 0
63
+ const arr = [...value]; arr[i] = v / scale; onChange(arr)
64
+ }}
65
+ style={{ border:'none', borderRadius:0, width:'100%', background:'transparent',
66
+ padding:'5px 5px', fontSize:11, fontFamily:'var(--font-mono)' }}
67
+ />
68
+ </div>
69
  </div>
70
  ))}
71
  </div>
 
75
 
76
  export default function PropertiesPanel() {
77
  const {
78
+ models, selectedModelId,
79
+ updateModelTransform, setModelActiveAnimation, setModelAnimSpeed,
80
+ currentFrame, addKeyframe, removeKeyframe, getKeyframesForModel, keyframes,
81
+ removeModel, selectModel,
 
82
  } = useStore()
83
 
84
  const model = models.find(m => m.id === selectedModelId)
85
 
86
+ if (!model) return (
87
+ <div style={{ padding:24, textAlign:'center' }}>
88
+ <div style={{ fontSize:32, opacity:0.15, marginBottom:10 }}>◎</div>
89
+ <div style={{ fontSize:12, color:'var(--text2)' }}>Select a model in the scene</div>
90
+ <div style={{ fontSize:11, color:'var(--text3)', marginTop:4 }}>
91
+ Click any object to inspect its properties
 
 
 
 
92
  </div>
93
+ </div>
94
+ )
95
 
96
+ const kfList = getKeyframesForModel(model.id)
97
+ const hasKfNow = keyframes[currentFrame]?.[model.id]
98
 
99
  return (
100
+ <div>
101
+ {/* Header */}
102
+ <div style={{
103
+ padding:'10px 12px 8px',
104
+ borderBottom:'1px solid var(--border)',
105
+ display:'flex', alignItems:'center', gap:8,
106
+ }}>
107
+ <div style={{ width:10, height:10, borderRadius:'50%', background:'var(--accent)', boxShadow:'0 0 8px rgba(79,142,255,0.5)' }} />
108
+ <span style={{ fontSize:13, fontWeight:600, flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
109
  {model.name}
110
+ </span>
111
+ <button
112
+ onClick={() => { removeModel(model.id); selectModel(null) }}
113
+ style={{ background:'none', border:'none', color:'var(--text3)', cursor:'pointer', fontSize:14, transition:'color 0.12s' }}
114
+ onMouseEnter={e => e.currentTarget.style.color='var(--danger)'}
115
+ onMouseLeave={e => e.currentTarget.style.color='var(--text3)'}
116
+ title="Remove model"
117
+ >🗑</button>
118
  </div>
119
 
 
 
120
  {/* Transform */}
121
+ <Section title="Transform">
122
+ <Vec3 label="Position" value={model.position} step={0.1} decimals={2}
123
+ onChange={v => updateModelTransform(model.id, 'position', v)} />
124
+ <Vec3 label="Rotation (deg)" value={model.rotation} step={1} decimals={1} scale={DEG}
125
+ onChange={v => updateModelTransform(model.id, 'rotation', v)} />
126
+ <Vec3 label="Scale" value={model.scale} step={0.05} decimals={2}
127
+ onChange={v => updateModelTransform(model.id, 'scale', v)} />
128
+ <button
129
+ onClick={() => {
130
+ updateModelTransform(model.id, 'position', [0,0,0])
131
+ updateModelTransform(model.id, 'rotation', [0,0,0])
132
+ updateModelTransform(model.id, 'scale', [1,1,1])
133
+ }}
134
+ style={{
135
+ width:'100%', marginTop:4, padding:'5px 0',
136
+ background:'var(--bg2)', border:'1px solid var(--border)',
137
+ borderRadius:'var(--radius-sm)', color:'var(--text2)',
138
+ fontSize:11, cursor:'pointer', transition:'all 0.12s',
139
+ }}
140
+ onMouseEnter={e => { e.currentTarget.style.background='var(--bg3)'; e.currentTarget.style.color='var(--text0)' }}
141
+ onMouseLeave={e => { e.currentTarget.style.background='var(--bg2)'; e.currentTarget.style.color='var(--text2)' }}
142
+ >↺ Reset Transform</button>
143
+ </Section>
144
+
145
+ {/* Animations */}
 
146
  {model.animations.length > 0 && (
147
+ <Section title="Animations">
148
+ <div style={{ display:'flex', flexDirection:'column', gap:4 }}>
149
+ {model.animations.map(anim => (
150
+ <button key={anim}
151
+ onClick={() => setModelActiveAnimation(model.id, anim)}
152
+ style={{
153
+ padding:'7px 10px', borderRadius:'var(--radius-sm)',
154
+ background: model.activeAnimation===anim ? 'rgba(6,214,160,0.1)' : 'var(--bg2)',
155
+ border:`1px solid ${model.activeAnimation===anim ? 'rgba(6,214,160,0.3)' : 'var(--border)'}`,
156
+ color: model.activeAnimation===anim ? 'var(--accent3)' : 'var(--text1)',
157
+ fontSize:11, textAlign:'left', cursor:'pointer', transition:'all 0.12s',
158
+ display:'flex', alignItems:'center', gap:6,
159
+ }}
160
+ >
161
+ <span>{model.activeAnimation===anim ? '▶' : '○'}</span>
162
+ {anim}
163
+ </button>
164
  ))}
165
+ </div>
166
+ <div style={{ display:'flex', alignItems:'center', gap:8, marginTop:8 }}>
167
+ <span style={{ fontSize:10, color:'var(--text2)' }}>Speed</span>
168
+ <input type="range" min={0.1} max={3} step={0.05}
 
 
169
  value={model.animationSpeed}
170
+ onChange={e => setModelAnimSpeed(model.id, parseFloat(e.target.value))} />
171
+ <span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)', minWidth:32 }}>
172
+ {model.animationSpeed.toFixed(1)}×
 
 
173
  </span>
174
  </div>
175
+ </Section>
176
  )}
177
 
178
+ {/* Keyframes */}
179
+ <Section title="Keyframes">
180
+ <div style={{ display:'flex', gap:5, marginBottom:8 }}>
 
 
 
 
 
181
  <button
182
  onClick={() => addKeyframe(currentFrame, model.id)}
183
  style={{
184
+ flex:1, padding:'7px 0',
185
+ background: hasKfNow ? 'rgba(245,158,11,0.15)' : 'rgba(79,142,255,0.12)',
186
+ border:`1px solid ${hasKfNow ? 'rgba(245,158,11,0.4)' : 'rgba(79,142,255,0.3)'}`,
187
+ color: hasKfNow ? 'var(--warn)' : 'var(--accent)',
188
+ borderRadius:'var(--radius-sm)', cursor:'pointer', fontSize:11, fontWeight:600,
 
189
  }}
190
+ >{hasKfNow ? '◆ Update' : '◆ Add Keyframe'}</button>
191
+ {hasKfNow && (
192
+ <button onClick={() => removeKeyframe(currentFrame, model.id)}
 
 
 
193
  style={{
194
+ padding:'7px 10px', background:'rgba(239,68,68,0.1)',
195
+ border:'1px solid rgba(239,68,68,0.3)', color:'var(--danger)',
196
+ borderRadius:'var(--radius-sm)', cursor:'pointer', fontSize:12,
197
+ }}>✕</button>
 
 
 
198
  )}
199
  </div>
 
200
 
201
+ <div style={{ fontSize:11, color:'var(--text2)', marginBottom:5 }}>
202
+ Frame {currentFrame} {hasKfNow ? '— keyframe set ◆' : '— no keyframe'}
203
+ </div>
204
+
205
+ {kfList.length > 0 && (
206
+ <div style={{ maxHeight:120, overflow:'auto', display:'flex', flexDirection:'column', gap:2 }}>
207
+ {kfList.map(({ frame }) => (
208
+ <div key={frame}
209
+ onClick={() => useStore.getState().setCurrentFrame(frame)}
 
210
  style={{
211
+ display:'flex', justifyContent:'space-between', alignItems:'center',
212
+ padding:'4px 8px', borderRadius:'var(--radius-sm)',
213
+ background: frame===currentFrame ? 'rgba(245,158,11,0.1)' : 'var(--bg2)',
214
+ border:`1px solid ${frame===currentFrame ? 'rgba(245,158,11,0.25)' : 'var(--border)'}`,
215
+ cursor:'pointer',
216
  }}
 
217
  >
218
+ <span style={{ fontSize:11, color: frame===currentFrame ? 'var(--warn)' : 'var(--text1)' }}>
219
+ Frame {frame}
220
+ </span>
221
  <button
222
  onClick={e => { e.stopPropagation(); removeKeyframe(frame, model.id) }}
223
+ style={{ background:'none', border:'none', color:'var(--text3)', cursor:'pointer', fontSize:11 }}
 
 
 
224
  >✕</button>
225
  </div>
226
  ))}
227
  </div>
228
+ )}
229
+ </Section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  </div>
231
  )
232
  }
src/components/SkyboxPanel.jsx CHANGED
@@ -1,219 +1,176 @@
1
- /**
2
- * SkyboxPanel.jsx
3
- * Lets users set the scene background / skybox:
4
- * - Preset environments (synced with lighting)
5
- * - Solid color picker
6
- * - Upload an image (JPG/PNG equirectangular)
7
- * - Paste an HDR/image URL
8
- * Drop-in component — no other files changed.
9
- */
10
  import { useRef, useState } from 'react'
11
  import useStore from '../store/useStore'
12
 
13
  const PRESETS = [
14
- { id: 'studio', label: 'Studio', icon: '💡', color: '#223' },
15
- { id: 'outdoor', label: 'Outdoor', icon: '☀️', color: '#135' },
16
- { id: 'dramatic', label: 'Dramatic', icon: '🎭', color: '#311' },
17
- { id: 'neon', label: 'Neon', icon: '🌀', color: '#031' },
18
  ]
19
 
20
- const BG_COLORS = [
21
- '#080810','#000000','#ffffff','#1a0a2e',
22
- '#0a1a2e','#1a2e0a','#2e0a0a','#2e2a0a',
23
- ]
24
 
25
  export default function SkyboxPanel() {
26
- const skybox = useStore(s => s.skybox)
27
- const lightingPreset = useStore(s => s.lightingPreset)
28
  const { setSkybox, setLightingPreset } = useStore.getState()
29
-
30
- const [urlInput, setUrlInput] = useState('')
31
- const [urlType, setUrlType] = useState('image') // 'image' | 'hdr'
32
  const fileRef = useRef()
33
 
34
- const applyPreset = (id) => {
35
- setLightingPreset(id)
36
- setSkybox({ type: 'preset', value: null, showBg: skybox.showBg })
37
- }
38
-
39
- const applyColor = (col) => {
40
- setSkybox({ type: 'color', bgColor: col, showBg: true })
41
- }
42
-
43
- const applyUrl = () => {
44
- if (!urlInput.trim()) return
45
- const isHdr = urlInput.toLowerCase().includes('.hdr') ||
46
- urlInput.toLowerCase().includes('.exr')
47
- setSkybox({ type: isHdr ? 'hdr' : 'image', value: urlInput.trim(), showBg: true })
48
- setUrlInput('')
49
- }
50
-
51
  const handleFile = (e) => {
52
- const file = e.target.files[0]
53
- if (!file) return
54
- const url = URL.createObjectURL(file)
55
- const isHdr = file.name.toLowerCase().endsWith('.hdr') ||
56
- file.name.toLowerCase().endsWith('.exr')
57
- setSkybox({ type: isHdr ? 'hdr' : 'image', value: url, showBg: true })
58
  }
59
 
60
- const clearSkybox = () => {
61
- setSkybox({ type: 'preset', value: null, showBg: false, bgColor: '#080810' })
 
 
 
62
  }
63
 
64
  return (
65
- <div style={{ padding:'10px', fontFamily:'Space Mono,monospace', overflow:'auto', maxHeight:'100%' }}>
66
 
67
  {/* Show background toggle */}
68
- <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:10 }}>
69
- <span style={{ fontSize:10, color:'#555', letterSpacing:'0.1em' }}>SHOW BACKGROUND</span>
70
- <button
71
- onClick={() => setSkybox({ showBg: !skybox.showBg })}
72
- style={{
73
- padding:'4px 12px',
74
- background: skybox.showBg ? 'rgba(0,245,255,0.15)' : 'rgba(255,255,255,0.05)',
75
- border: `1px solid ${skybox.showBg ? '#00f5ff' : 'rgba(255,255,255,0.1)'}`,
76
- color: skybox.showBg ? '#00f5ff' : '#555',
77
- borderRadius:6, cursor:'pointer', fontSize:11, fontFamily:'Space Mono',
78
- }}
79
- >{skybox.showBg ? 'ON' : 'OFF'}</button>
 
 
 
 
 
 
 
 
 
 
 
80
  </div>
81
 
82
- {/* Current skybox status */}
83
- <div style={{
84
- padding:'8px 10px', marginBottom:10,
85
- background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)',
86
- borderRadius:6, fontSize:11, color:'#556',
87
- }}>
88
- <span style={{ color:'#00f5ff' }}>Active: </span>
89
- {skybox.type === 'preset' && `Preset ${lightingPreset}`}
90
- {skybox.type === 'color' && `Color — ${skybox.bgColor}`}
91
- {skybox.type === 'image' && `Image — ${(skybox.value||'').split('/').pop().substring(0,24)}...`}
92
- {skybox.type === 'hdr' && `HDR ${(skybox.value||'').split('/').pop().substring(0,24)}...`}
93
- {(skybox.type === 'image' || skybox.type === 'hdr') && (
94
- <button onClick={clearSkybox} style={{
95
- marginLeft:8, background:'none', border:'none',
96
- color:'#ff4060', cursor:'pointer', fontSize:11,
97
- }}>✕ clear</button>
98
- )}
 
 
99
  </div>
100
 
101
- <hr style={{ border:'none', borderTop:'1px solid rgba(255,255,255,0.07)', margin:'0 0 10px' }} />
102
-
103
- {/* ── Preset environments ── */}
104
- <div style={{ fontSize:10, color:'#555', letterSpacing:'0.1em', marginBottom:6 }}>
105
- PRESET ENVIRONMENTS
106
- </div>
107
- <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:5, marginBottom:12 }}>
108
- {PRESETS.map(p => (
109
- <button key={p.id} onClick={() => applyPreset(p.id)} style={{
110
- padding:'8px 6px',
111
- background: skybox.type==='preset' && lightingPreset===p.id
112
- ? `${p.color}88` : 'rgba(255,255,255,0.03)',
113
- border: `1px solid ${skybox.type==='preset' && lightingPreset===p.id
114
- ? '#00f5ff55' : 'rgba(255,255,255,0.08)'}`,
115
- borderRadius:6, cursor:'pointer', fontSize:11,
116
- color: skybox.type==='preset' && lightingPreset===p.id ? '#fff' : '#666',
117
- fontFamily:'Space Mono',
118
- transition:'all 0.15s',
119
- }}>
120
- {p.icon} {p.label}
121
- </button>
122
- ))}
 
 
 
 
123
  </div>
124
 
125
- <hr style={{ border:'none', borderTop:'1px solid rgba(255,255,255,0.07)', margin:'0 0 10px' }} />
126
-
127
- {/* ── Solid color ── */}
128
- <div style={{ fontSize:10, color:'#555', letterSpacing:'0.1em', marginBottom:6 }}>
129
- SOLID COLOR
130
- </div>
131
- <div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:6 }}>
132
- {BG_COLORS.map(col => (
133
- <div key={col} onClick={() => applyColor(col)} style={{
134
- width:28, height:28, borderRadius:5,
135
- background: col,
136
- border: `2px solid ${skybox.type==='color' && skybox.bgColor===col
137
- ? '#00f5ff' : 'rgba(255,255,255,0.15)'}`,
138
- cursor:'pointer',
139
- boxShadow: skybox.type==='color' && skybox.bgColor===col
140
- ? '0 0 8px #00f5ff' : 'none',
141
- transition:'all 0.15s',
142
- }} />
143
- ))}
144
- {/* Custom color picker */}
145
- <label style={{ width:28, height:28, borderRadius:5, overflow:'hidden', cursor:'pointer',
146
- border:'1px dashed rgba(255,255,255,0.2)', display:'flex', alignItems:'center', justifyContent:'center' }}>
147
- <span style={{ fontSize:16, pointerEvents:'none' }}>+</span>
148
- <input type="color" defaultValue="#080810"
149
- onChange={e => applyColor(e.target.value)}
150
- style={{ position:'absolute', opacity:0, width:0, height:0 }} />
151
- </label>
152
- </div>
153
-
154
- <hr style={{ border:'none', borderTop:'1px solid rgba(255,255,255,0.07)', margin:'8px 0 10px' }} />
155
-
156
- {/* ── Upload image / HDR ── */}
157
- <div style={{ fontSize:10, color:'#555', letterSpacing:'0.1em', marginBottom:6 }}>
158
- UPLOAD SKYBOX IMAGE / HDR
159
- </div>
160
- <div style={{ display:'flex', gap:5, marginBottom:8 }}>
161
  <button onClick={() => fileRef.current?.click()} style={{
162
- flex:1, padding:'8px 0',
163
- background:'rgba(0,245,255,0.08)', border:'1px solid rgba(0,245,255,0.2)',
164
- color:'#00f5ff', borderRadius:6, cursor:'pointer',
165
- fontSize:11, fontFamily:'Space Mono',
166
- }}>
167
- 📁 UPLOAD FILE
168
- </button>
169
- <input ref={fileRef} type="file" accept=".jpg,.jpeg,.png,.hdr,.exr"
170
- style={{ display:'none' }} onChange={handleFile} />
 
 
171
  </div>
172
- <div style={{ fontSize:10, color:'#444', marginBottom:4 }}>Accepts: JPG, PNG (equirectangular), HDR, EXR</div>
173
-
174
- <hr style={{ border:'none', borderTop:'1px solid rgba(255,255,255,0.07)', margin:'8px 0 10px' }} />
175
 
176
- {/* ── Paste URL ── */}
177
- <div style={{ fontSize:10, color:'#555', letterSpacing:'0.1em', marginBottom:6 }}>
178
- PASTE IMAGE / HDR URL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
- <div style={{ display:'flex', gap:5, marginBottom:4 }}>
181
- {['image','hdr'].map(t => (
182
- <button key={t} onClick={() => setUrlType(t)} style={{
183
- flex:1, padding:'5px 0',
184
- background: urlType===t ? 'rgba(255,170,0,0.12)' : 'rgba(255,255,255,0.04)',
185
- border: `1px solid ${urlType===t ? 'rgba(255,170,0,0.4)' : 'rgba(255,255,255,0.1)'}`,
186
- color: urlType===t ? '#ffaa00' : '#555',
187
- borderRadius:4, cursor:'pointer', fontSize:10, fontFamily:'Space Mono',
188
- }}>{t.toUpperCase()}</button>
189
- ))}
190
- </div>
191
- <input
192
- value={urlInput}
193
- onChange={e => setUrlInput(e.target.value)}
194
- onKeyDown={e => e.key==='Enter' && applyUrl()}
195
- placeholder="https://...equirectangular.jpg"
196
- style={{
197
- width:'100%', padding:'7px 8px', marginBottom:6,
198
- background:'rgba(0,10,30,0.7)', border:'1px solid rgba(0,245,255,0.18)',
199
- borderRadius:6, color:'#d0e8ff', fontSize:11,
200
- fontFamily:'Space Mono', outline:'none', display:'block',
201
- }}
202
- />
203
- <button onClick={applyUrl} style={{
204
- width:'100%', padding:'8px 0',
205
- background:'rgba(255,170,0,0.1)', border:'1px solid rgba(255,170,0,0.3)',
206
- color:'#ffaa00', borderRadius:6, cursor:'pointer',
207
- fontSize:11, fontFamily:'Space Mono',
208
- }}>APPLY URL</button>
209
 
210
- <div style={{ marginTop:10, padding:'8px', background:'rgba(0,245,255,0.03)',
211
- border:'1px solid rgba(0,245,255,0.07)', borderRadius:6, fontSize:10, color:'#334455', lineHeight:1.7 }}>
212
- 💡 Free equirectangular HDRs:<br/>
 
213
  <a href="https://polyhaven.com/hdris" target="_blank"
214
- style={{ color:'#0077aa', textDecoration:'none' }}>polyhaven.com/hdris ↗</a><br/>
215
- Right-click → Copy image address → paste above
216
  </div>
 
 
 
 
 
 
 
 
 
217
  </div>
218
  )
219
  }
 
 
 
 
 
 
 
 
 
 
1
  import { useRef, useState } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
  const PRESETS = [
5
+ { id:'studio', label:'Studio', icon:'' },
6
+ { id:'outdoor', label:'Outdoor', icon:'' },
7
+ { id:'dramatic', label:'Dramatic', icon:'' },
8
+ { id:'neon', label:'Neon', icon:'' },
9
  ]
10
 
11
+ const COLORS = ['#0c0c10','#000000','#0a0a1a','#0d1a0a','#1a0a0a','#ffffff','#87ceeb','#1a0a2e']
 
 
 
12
 
13
  export default function SkyboxPanel() {
14
+ const skybox = useStore(s => s.skybox)
15
+ const lightingPreset = useStore(s => s.lightingPreset)
16
  const { setSkybox, setLightingPreset } = useStore.getState()
17
+ const [url, setUrl] = useState('')
18
+ const [urlType, setUrlType] = useState('image')
 
19
  const fileRef = useRef()
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  const handleFile = (e) => {
22
+ const f = e.target.files[0]; if(!f) return
23
+ const isHdr = /\.(hdr|exr)$/i.test(f.name)
24
+ setSkybox({ type: isHdr?'hdr':'image', value: URL.createObjectURL(f), showBg:true })
 
 
 
25
  }
26
 
27
+ const applyUrl = () => {
28
+ if(!url.trim()) return
29
+ const isHdr = /\.(hdr|exr)/i.test(url)
30
+ setSkybox({ type:isHdr?'hdr':'image', value:url.trim(), showBg:true })
31
+ setUrl('')
32
  }
33
 
34
  return (
35
+ <div style={{ padding:12, display:'flex', flexDirection:'column', gap:12 }}>
36
 
37
  {/* Show background toggle */}
38
+ <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
39
+ padding:'8px 10px', background:'var(--bg2)', borderRadius:'var(--radius)',
40
+ border:'1px solid var(--border)' }}>
41
+ <div>
42
+ <div style={{ fontSize:12, fontWeight:600, color:'var(--text0)' }}>Show Background</div>
43
+ <div style={{ fontSize:10, color:'var(--text2)', marginTop:2 }}>
44
+ {skybox.type==='preset' ? `Preset ${lightingPreset}` :
45
+ skybox.type==='color' ? `Color ${skybox.bgColor}` :
46
+ skybox.type==='image' ? 'Custom Image' : 'HDR'}
47
+ </div>
48
+ </div>
49
+ <button onClick={() => setSkybox({ showBg:!skybox.showBg })} style={{
50
+ width:40, height:22, borderRadius:11,
51
+ background: skybox.showBg ? 'var(--accent)' : 'var(--bg4)',
52
+ border:'none', cursor:'pointer', position:'relative', transition:'background 0.2s',
53
+ boxShadow: skybox.showBg ? '0 0 8px rgba(79,142,255,0.4)' : 'none',
54
+ }}>
55
+ <div style={{
56
+ position:'absolute', top:3, left: skybox.showBg ? 20 : 3,
57
+ width:16, height:16, borderRadius:8, background:'#fff',
58
+ transition:'left 0.2s', boxShadow:'0 1px 3px rgba(0,0,0,0.4)',
59
+ }}/>
60
+ </button>
61
  </div>
62
 
63
+ {/* Preset environments */}
64
+ <div>
65
+ <div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
66
+ marginBottom:6, textTransform:'uppercase' }}>Preset Environments</div>
67
+ <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:5 }}>
68
+ {PRESETS.map(p => (
69
+ <button key={p.id}
70
+ onClick={() => { setLightingPreset(p.id); setSkybox({ type:'preset', value:null }) }}
71
+ style={{
72
+ padding:'9px 8px', borderRadius:'var(--radius-sm)',
73
+ background: skybox.type==='preset' && lightingPreset===p.id ? 'rgba(79,142,255,0.12)' : 'var(--bg2)',
74
+ border:`1px solid ${skybox.type==='preset' && lightingPreset===p.id ? 'rgba(79,142,255,0.3)' : 'var(--border)'}`,
75
+ color: skybox.type==='preset' && lightingPreset===p.id ? 'var(--accent)' : 'var(--text1)',
76
+ fontSize:12, cursor:'pointer', textAlign:'left', transition:'all 0.12s',
77
+ display:'flex', alignItems:'center', gap:6,
78
+ }}
79
+ ><span style={{ opacity:0.7 }}>{p.icon}</span> {p.label}</button>
80
+ ))}
81
+ </div>
82
  </div>
83
 
84
+ {/* Solid color */}
85
+ <div>
86
+ <div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
87
+ marginBottom:6, textTransform:'uppercase' }}>Solid Color</div>
88
+ <div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
89
+ {COLORS.map(col => (
90
+ <div key={col} onClick={() => setSkybox({ type:'color', bgColor:col, showBg:true })}
91
+ style={{
92
+ width:30, height:30, borderRadius:'var(--radius-sm)',
93
+ background:col, cursor:'pointer', transition:'transform 0.1s',
94
+ border:`2px solid ${skybox.type==='color'&&skybox.bgColor===col ? 'var(--accent)' : 'rgba(255,255,255,0.1)'}`,
95
+ boxShadow: skybox.type==='color'&&skybox.bgColor===col ? '0 0 8px rgba(79,142,255,0.5)' : 'none',
96
+ }}
97
+ onMouseEnter={e=>e.currentTarget.style.transform='scale(1.1)'}
98
+ onMouseLeave={e=>e.currentTarget.style.transform='scale(1)'}
99
+ />
100
+ ))}
101
+ {/* Color picker */}
102
+ <label style={{ width:30, height:30, borderRadius:'var(--radius-sm)', cursor:'pointer',
103
+ border:'1px dashed var(--border-hi)', display:'flex', alignItems:'center', justifyContent:'center',
104
+ color:'var(--text2)', fontSize:18, overflow:'hidden' }}>
105
+ +
106
+ <input type="color" onChange={e=>setSkybox({type:'color',bgColor:e.target.value,showBg:true})}
107
+ style={{ position:'absolute', opacity:0, width:0, height:0 }} />
108
+ </label>
109
+ </div>
110
  </div>
111
 
112
+ {/* Upload */}
113
+ <div>
114
+ <div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
115
+ marginBottom:6, textTransform:'uppercase' }}>Upload Skybox</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  <button onClick={() => fileRef.current?.click()} style={{
117
+ width:'100%', padding:'10px', borderRadius:'var(--radius)',
118
+ background:'var(--bg2)', border:'2px dashed var(--border-hi)',
119
+ color:'var(--text1)', fontSize:12, cursor:'pointer', transition:'all 0.15s',
120
+ }}
121
+ onMouseEnter={e=>{e.currentTarget.style.borderColor='var(--accent)';e.currentTarget.style.color='var(--text0)'}}
122
+ onMouseLeave={e=>{e.currentTarget.style.borderColor='var(--border-hi)';e.currentTarget.style.color='var(--text1)'}}
123
+ >📁 Upload Image / HDR / EXR</button>
124
+ <input ref={fileRef} type="file" accept=".jpg,.jpeg,.png,.hdr,.exr" style={{display:'none'}} onChange={handleFile} />
125
+ <div style={{ fontSize:10, color:'var(--text3)', marginTop:4 }}>
126
+ JPG/PNG equirectangular · HDR · EXR
127
+ </div>
128
  </div>
 
 
 
129
 
130
+ {/* URL */}
131
+ <div>
132
+ <div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
133
+ marginBottom:6, textTransform:'uppercase' }}>Paste URL</div>
134
+ <div style={{ display:'flex', gap:5, marginBottom:4 }}>
135
+ {['image','hdr'].map(t=>(
136
+ <button key={t} onClick={()=>setUrlType(t)} style={{
137
+ flex:1, padding:'4px 0', borderRadius:'var(--radius-sm)',
138
+ background:urlType===t?'rgba(79,142,255,0.12)':'var(--bg2)',
139
+ border:`1px solid ${urlType===t?'rgba(79,142,255,0.3)':'var(--border)'}`,
140
+ color:urlType===t?'var(--accent)':'var(--text1)',
141
+ fontSize:10, fontWeight:600, cursor:'pointer',
142
+ }}>{t.toUpperCase()}</button>
143
+ ))}
144
+ </div>
145
+ <div style={{ display:'flex', gap:5 }}>
146
+ <input value={url} onChange={e=>setUrl(e.target.value)}
147
+ onKeyDown={e=>e.key==='Enter'&&applyUrl()}
148
+ placeholder="https://…equirectangular.jpg" style={{ flex:1 }} />
149
+ <button onClick={applyUrl} style={{
150
+ padding:'5px 10px', borderRadius:'var(--radius-sm)',
151
+ background:'var(--accent)', border:'none', color:'#fff',
152
+ fontSize:11, fontWeight:600, cursor:'pointer', flexShrink:0,
153
+ }}>Apply</button>
154
+ </div>
155
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
+ {/* Free HDR link */}
158
+ <div style={{ padding:'10px', background:'var(--bg2)', borderRadius:'var(--radius)',
159
+ border:'1px solid var(--border)', fontSize:11, color:'var(--text2)', lineHeight:1.6 }}>
160
+ 💡 Free HDRIs at{' '}
161
  <a href="https://polyhaven.com/hdris" target="_blank"
162
+ style={{ color:'var(--accent)', textDecoration:'none' }}>polyhaven.com ↗</a>
163
+ <br/>Right-click 1K JPEG → Copy image address → paste above
164
  </div>
165
+
166
+ {/* Clear */}
167
+ {(skybox.type==='image'||skybox.type==='hdr') && (
168
+ <button onClick={() => setSkybox({type:'preset',value:null,showBg:false,bgColor:'#0c0c10'})} style={{
169
+ padding:'7px 0', borderRadius:'var(--radius-sm)',
170
+ background:'rgba(239,68,68,0.08)', border:'1px solid rgba(239,68,68,0.2)',
171
+ color:'var(--danger)', fontSize:11, cursor:'pointer',
172
+ }}>✕ Clear Custom Skybox</button>
173
+ )}
174
  </div>
175
  )
176
  }
src/components/Timeline.jsx CHANGED
@@ -1,313 +1,244 @@
1
- import { useRef, useState, useCallback } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
- const FRAME_WIDTH = 4 // px per frame in timeline
5
- const TRACK_HEIGHT = 32
6
 
7
- function KeyframeDot({ frame, modelId, modelColor, timelineWidth, totalFrames, onDragEnd }) {
8
- const removeKeyframe = useStore(s => s.removeKeyframe)
9
- const moveKeyframe = useStore(s => s.moveKeyframe)
10
- const dragging = useRef(false)
11
- const startX = useRef(0)
12
- const startFrame = useRef(frame)
13
 
14
- const x = (frame / totalFrames) * timelineWidth
15
-
16
- const handlePointerDown = (e) => {
17
  e.stopPropagation()
18
- dragging.current = true
19
- startX.current = e.clientX
20
- startFrame.current = frame
21
-
22
- const handleMove = (me) => {
23
- if (!dragging.current) return
24
- const dx = me.clientX - startX.current
25
- const dFrames = Math.round((dx / timelineWidth) * totalFrames)
26
- const newFrame = Math.max(0, Math.min(totalFrames - 1, startFrame.current + dFrames))
27
- if (newFrame !== frame) {
28
- moveKeyframe(frame, newFrame, modelId)
29
- startX.current = me.clientX
30
- startFrame.current = newFrame
31
- }
32
  }
33
-
34
- const handleUp = () => {
35
- dragging.current = false
36
- window.removeEventListener('pointermove', handleMove)
37
- window.removeEventListener('pointerup', handleUp)
38
- }
39
-
40
- window.addEventListener('pointermove', handleMove)
41
- window.addEventListener('pointerup', handleUp)
42
  }
43
 
44
  return (
45
  <div
 
 
 
46
  style={{
47
- position: 'absolute',
48
- left: x - 5,
49
- top: '50%',
50
- transform: 'translateY(-50%)',
51
- width: 10,
52
- height: 10,
53
- background: modelColor,
54
- border: '1px solid rgba(255,255,255,0.5)',
55
- borderRadius: '50%',
56
- cursor: 'grab',
57
- zIndex: 10,
58
- boxShadow: `0 0 6px ${modelColor}`,
59
  }}
60
- onPointerDown={handlePointerDown}
61
- onDoubleClick={(e) => { e.stopPropagation(); removeKeyframe(frame, modelId) }}
62
- title={`Frame ${frame} — double click to delete`}
63
  />
64
  )
65
  }
66
 
67
- function TimelineTrack({ model, timelineWidth }) {
68
- const totalFrames = useStore(s => s.totalFrames)
69
- const keyframes = useStore(s => s.keyframes)
70
- const selectedModelId = useStore(s => s.selectedModelId)
71
- const selectModel = useStore(s => s.selectModel)
72
-
73
- const modelKeyframes = Object.entries(keyframes)
74
- .filter(([, kf]) => kf[model.id])
75
- .map(([f]) => parseInt(f))
76
-
77
- const colors = ['#00f5ff', '#ff4080', '#40ff80', '#ffaa00', '#aa40ff', '#ff8040']
78
- const colorIndex = useStore(s => s.models).findIndex(m => m.id === model.id) % colors.length
79
- const color = colors[colorIndex]
80
-
81
- const isSelected = selectedModelId === model.id
82
-
83
- return (
84
- <div
85
- style={{
86
- position: 'relative',
87
- height: TRACK_HEIGHT,
88
- width: timelineWidth,
89
- background: isSelected ? 'rgba(0,245,255,0.04)' : 'rgba(255,255,255,0.02)',
90
- borderBottom: '1px solid rgba(255,255,255,0.06)',
91
- cursor: 'pointer',
92
- }}
93
- onClick={() => selectModel(model.id)}
94
- >
95
- {/* Track bar */}
96
- <div style={{
97
- position: 'absolute', top: '45%', left: 0, right: 0,
98
- height: 2, background: 'rgba(255,255,255,0.08)'
99
- }} />
100
-
101
- {modelKeyframes.map(f => (
102
- <KeyframeDot
103
- key={f} frame={f} modelId={model.id}
104
- modelColor={color} timelineWidth={timelineWidth}
105
- totalFrames={totalFrames}
106
- />
107
- ))}
108
- </div>
109
- )
110
- }
111
-
112
  export default function Timeline() {
113
  const {
114
- currentFrame, setCurrentFrame, totalFrames, fps,
115
- isPlaying, setIsPlaying,
116
- models, selectedModelId, addKeyframe,
117
- showTimeline, setShowTimeline,
118
- keyframes
119
  } = useStore()
120
 
121
- const timelineRef = useRef()
122
- const [timelineWidth, setTimelineWidth] = useState(600)
123
- const containerRef = useRef()
124
 
125
- // Observe container width
126
  const measuredRef = useCallback(node => {
127
  if (!node) return
128
- const ro = new ResizeObserver(entries => {
129
- setTimelineWidth(entries[0].contentRect.width)
130
- })
131
  ro.observe(node)
132
  return () => ro.disconnect()
133
  }, [])
134
 
135
- const scrub = (e) => {
136
- if (!containerRef.current) return
137
- const rect = containerRef.current.getBoundingClientRect()
138
- const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left
139
- const frame = Math.round((x / timelineWidth) * totalFrames)
140
- setCurrentFrame(Math.max(0, Math.min(frame, totalFrames - 1)))
141
- }
142
 
143
- const handleTimelinePointerDown = (e) => {
144
- scrub(e)
145
- const move = (me) => scrub(me)
146
- const up = () => {
147
- window.removeEventListener('pointermove', move)
148
- window.removeEventListener('pointerup', up)
149
- }
150
  window.addEventListener('pointermove', move)
151
- window.addEventListener('pointerup', up)
152
  }
153
 
154
- const allKeyframeFrames = Object.keys(keyframes).map(Number)
155
- const playheadX = (currentFrame / totalFrames) * timelineWidth
156
 
157
- if (!showTimeline) {
158
- return (
159
- <button
160
- onClick={() => setShowTimeline(true)}
161
- style={{
162
- position: 'absolute', bottom: 4, left: '50%',
163
- transform: 'translateX(-50%)',
164
- background: '#1a1a2e', border: '1px solid #333',
165
- color: '#00f5ff', padding: '4px 16px',
166
- borderRadius: 8, cursor: 'pointer', fontSize: 11,
167
- fontFamily: 'Space Mono, monospace'
168
- }}
169
- >
170
- SHOW TIMELINE
171
- </button>
172
- )
173
- }
174
 
175
  return (
176
  <div style={{
177
- position: 'absolute', bottom: 0, left: 0, right: 0,
178
- background: 'rgba(8,8,20,0.97)',
179
- borderTop: '1px solid rgba(0,245,255,0.2)',
180
- zIndex: 100,
181
- userSelect: 'none',
182
  }}>
183
- {/* Timeline header */}
184
  <div style={{
185
- display: 'flex', alignItems: 'center', gap: 8,
186
- padding: '6px 10px',
187
- borderBottom: '1px solid rgba(255,255,255,0.06)',
188
- background: 'rgba(0,0,20,0.5)',
189
  }}>
190
- {/* Transport */}
191
- <button onClick={() => setCurrentFrame(0)} style={btnStyle} title="Go to start"></button>
192
- <button
193
- onClick={() => setCurrentFrame(Math.max(0, currentFrame - 1))}
194
- style={btnStyle} title="Previous frame"
195
- >◀</button>
196
- <button
197
- onClick={() => setIsPlaying(!isPlaying)}
198
- style={{ ...btnStyle, background: isPlaying ? '#ff4060' : '#00f5ff', color: '#000', minWidth: 40 }}
199
- >
200
- {isPlaying ? '⏸' : '▶'}
201
- </button>
202
- <button
203
- onClick={() => setCurrentFrame(Math.min(totalFrames - 1, currentFrame + 1))}
204
- style={btnStyle} title="Next frame"
205
- ></button>
206
- <button onClick={() => setCurrentFrame(totalFrames - 1)} style={btnStyle} title="Go to end">⏭</button>
207
-
208
- <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.15)' }} />
209
-
210
- {/* Frame counter */}
211
- <span style={{ color: '#00f5ff', fontSize: 12, fontFamily: 'Space Mono', minWidth: 80 }}>
212
- {String(currentFrame).padStart(4, '0')} / {totalFrames}
213
- </span>
214
 
215
- <span style={{ color: '#666', fontSize: 11 }}>{fps}fps</span>
216
 
217
- <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.15)' }} />
218
 
219
- {/* Add keyframe button */}
220
  {selectedModelId && (
221
  <button
222
  onClick={() => addKeyframe(currentFrame, selectedModelId)}
223
  style={{
224
- ...btnStyle,
225
- background: 'rgba(255,170,0,0.15)',
226
- borderColor: '#ffaa00',
227
- color: '#ffaa00',
228
  }}
229
- title="Add keyframe for selected model at current frame"
230
- >
231
- ◆ ADD KF
232
- </button>
233
  )}
234
 
235
- <div style={{ flex: 1 }} />
236
- <button onClick={() => setShowTimeline(false)} style={btnStyle} title="Hide timeline">✕</button>
 
 
237
  </div>
238
 
239
- {/* Track labels + scrubber area */}
240
- <div style={{ display: 'flex', maxHeight: 120, overflow: 'hidden' }}>
241
- {/* Track labels */}
242
- <div style={{ width: 100, flexShrink: 0, borderRight: '1px solid rgba(255,255,255,0.08)' }}>
243
- <div style={{
244
- height: 20, borderBottom: '1px solid rgba(255,255,255,0.06)',
245
- padding: '2px 6px', fontSize: 10, color: '#444',
246
- }}>TRACKS</div>
247
- {models.map((m, i) => {
248
- const colors = ['#00f5ff', '#ff4080', '#40ff80', '#ffaa00', '#aa40ff', '#ff8040']
249
- const c = colors[i % colors.length]
250
- return (
251
- <div key={m.id} style={{
252
- height: TRACK_HEIGHT, display: 'flex', alignItems: 'center',
253
- padding: '0 8px', fontSize: 10, color: c,
254
- borderBottom: '1px solid rgba(255,255,255,0.04)',
255
- overflow: 'hidden', whiteSpace: 'nowrap',
256
- }}>
257
- <span style={{ marginRight: 4 }}>●</span>
258
- {m.name.substring(0, 8)}
259
- </div>
260
- )
261
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  </div>
263
 
264
- {/* Scrollable timeline */}
265
- <div style={{ flex: 1, overflow: 'auto hidden' }}>
266
- {/* Frame ruler */}
267
- <div
268
- ref={el => { measuredRef(el); containerRef.current = el }}
269
- style={{ position: 'relative', height: 20, borderBottom: '1px solid rgba(255,255,255,0.06)', cursor: 'crosshair' }}
270
- onPointerDown={handleTimelinePointerDown}
 
271
  >
272
- {Array.from({ length: Math.ceil(totalFrames / 10) }, (_, i) => {
273
- const f = i * 10
274
- const x = (f / totalFrames) * timelineWidth
275
  return (
276
- <div key={f} style={{
277
- position: 'absolute', left: x,
278
- top: 0, bottom: 0,
279
- borderLeft: '1px solid rgba(255,255,255,0.1)',
280
- paddingLeft: 2,
281
- }}>
282
- <span style={{ fontSize: 8, color: '#444', lineHeight: '20px' }}>{f}</span>
283
  </div>
284
  )
285
  })}
286
-
287
- {/* Playhead on ruler */}
288
- <div style={{
289
- position: 'absolute', left: playheadX, top: 0, bottom: 0,
290
- width: 2, background: '#00f5ff',
291
- boxShadow: '0 0 8px #00f5ff',
292
- pointerEvents: 'none', zIndex: 20,
293
- }} />
294
  </div>
295
 
296
  {/* Tracks */}
297
- <div
298
- style={{ position: 'relative' }}
299
- ref={timelineRef}
300
- >
301
- {models.map(m => (
302
- <TimelineTrack key={m.id} model={m} timelineWidth={timelineWidth} />
303
- ))}
304
-
305
- {/* Playhead line across tracks */}
306
- <div style={{
307
- position: 'absolute', left: playheadX, top: 0, bottom: 0,
308
- width: 2, background: 'rgba(0,245,255,0.5)',
309
- pointerEvents: 'none', zIndex: 20,
310
- }} />
 
 
 
 
 
 
 
 
 
311
  </div>
312
  </div>
313
  </div>
@@ -315,13 +246,9 @@ export default function Timeline() {
315
  )
316
  }
317
 
318
- const btnStyle = {
319
- background: 'rgba(255,255,255,0.06)',
320
- border: '1px solid rgba(255,255,255,0.12)',
321
- color: '#ccc',
322
- borderRadius: 4,
323
- padding: '3px 8px',
324
- cursor: 'pointer',
325
- fontSize: 12,
326
- fontFamily: 'Space Mono, monospace',
327
  }
 
1
+ import { useRef, useState, useCallback, useEffect } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
+ const TRACK_H = 28
5
+ const COLORS = ['#4f8eff','#ef4444','#22c55e','#f59e0b','#8b5cf6','#f97316']
6
 
7
+ function KeyframeDot({ frame, modelId, color, trackW, totalFrames }) {
8
+ const { moveKeyframe, removeKeyframe } = useStore.getState()
9
+ const x = (frame / totalFrames) * trackW
 
 
 
10
 
11
+ const onPointerDown = (e) => {
 
 
12
  e.stopPropagation()
13
+ const startX = e.clientX, startF = frame
14
+ const move = me => {
15
+ const dx = me.clientX - startX
16
+ const newF = Math.max(0, Math.min(totalFrames-1, Math.round(startF + (dx/trackW)*totalFrames)))
17
+ if (newF !== frame) moveKeyframe(frame, newF, modelId)
 
 
 
 
 
 
 
 
 
18
  }
19
+ const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
20
+ window.addEventListener('pointermove', move)
21
+ window.addEventListener('pointerup', up)
 
 
 
 
 
 
22
  }
23
 
24
  return (
25
  <div
26
+ onPointerDown={onPointerDown}
27
+ onDoubleClick={e => { e.stopPropagation(); removeKeyframe(frame, modelId) }}
28
+ title={`Frame ${frame} — drag to move, dbl-click to delete`}
29
  style={{
30
+ position:'absolute', left: x - 5, top:'50%', transform:'translateY(-50%)',
31
+ width:10, height:10, borderRadius:2,
32
+ background: color, cursor:'ew-resize', zIndex:10,
33
+ boxShadow:`0 0 6px ${color}88`,
34
+ border:'1px solid rgba(255,255,255,0.3)',
35
+ rotate:'45deg',
36
+ transition:'transform 0.1s',
 
 
 
 
 
37
  }}
 
 
 
38
  />
39
  )
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  export default function Timeline() {
43
  const {
44
+ models, keyframes, currentFrame, totalFrames, fps, isPlaying,
45
+ setCurrentFrame, setIsPlaying, addKeyframe, selectedModelId,
46
+ showTimeline, setShowTimeline, setTotalFrames, setFps,
 
 
47
  } = useStore()
48
 
49
+ const rulerRef = useRef()
50
+ const [trackW, setTrackW] = useState(600)
51
+ const [settings, setSettings] = useState(false)
52
 
 
53
  const measuredRef = useCallback(node => {
54
  if (!node) return
55
+ const ro = new ResizeObserver(e => setTrackW(e[0].contentRect.width))
 
 
56
  ro.observe(node)
57
  return () => ro.disconnect()
58
  }, [])
59
 
60
+ const scrub = useCallback((clientX) => {
61
+ const rect = rulerRef.current?.getBoundingClientRect()
62
+ if (!rect) return
63
+ const x = clientX - rect.left
64
+ setCurrentFrame(Math.round(Math.max(0, Math.min(totalFrames-1, (x/trackW)*totalFrames))))
65
+ }, [trackW, totalFrames])
 
66
 
67
+ const handlePointerDown = (e) => {
68
+ scrub(e.touches ? e.touches[0].clientX : e.clientX)
69
+ const move = me => scrub(me.touches ? me.touches[0].clientX : me.clientX)
70
+ const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
 
 
 
71
  window.addEventListener('pointermove', move)
72
+ window.addEventListener('pointerup', up)
73
  }
74
 
75
+ const playheadX = (currentFrame / totalFrames) * trackW
76
+ const duration = (totalFrames / fps).toFixed(1)
77
 
78
+ if (!showTimeline) return (
79
+ <div style={{ position:'absolute', bottom:8, left:'50%', transform:'translateX(-50%)', zIndex:200 }}>
80
+ <button onClick={() => setShowTimeline(true)} style={{
81
+ padding:'5px 16px', borderRadius:20, border:'1px solid var(--border-hi)',
82
+ background:'var(--bg2)', color:'var(--text1)', fontSize:11, cursor:'pointer',
83
+ }}>Show Timeline</button>
84
+ </div>
85
+ )
 
 
 
 
 
 
 
 
 
86
 
87
  return (
88
  <div style={{
89
+ position:'absolute', bottom:0, left:0, right:0,
90
+ background:'var(--bg1)',
91
+ borderTop:'1px solid var(--border)',
92
+ zIndex:200, userSelect:'none',
 
93
  }}>
94
+ {/* Transport bar */}
95
  <div style={{
96
+ display:'flex', alignItems:'center', gap:6,
97
+ padding:'5px 10px',
98
+ borderBottom:'1px solid var(--border)',
 
99
  }}>
100
+ <button onClick={() => setCurrentFrame(0)} style={tbtn} title="First frame">⏮</button>
101
+ <button onClick={() => setCurrentFrame(Math.max(0,currentFrame-1))} style={tbtn}></button>
102
+ <button onClick={() => setIsPlaying(!isPlaying)} style={{
103
+ ...tbtn,
104
+ background: isPlaying ? 'var(--danger)' : 'var(--accent)',
105
+ color:'#fff', minWidth:32,
106
+ boxShadow: isPlaying ? '0 0 8px rgba(239,68,68,0.4)' : '0 0 8px rgba(79,142,255,0.4)',
107
+ }}>{isPlaying ? '⏸' : '▶'}</button>
108
+ <button onClick={() => setCurrentFrame(Math.min(totalFrames-1,currentFrame+1))} style={tbtn}>▶</button>
109
+ <button onClick={() => setCurrentFrame(totalFrames-1)} style={tbtn}>⏭</button>
110
+
111
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--text1)',
112
+ background:'var(--bg3)', padding:'3px 8px', borderRadius:'var(--radius-sm)',
113
+ border:'1px solid var(--border)', flexShrink:0 }}>
114
+ <span style={{ color:'var(--text0)', fontWeight:600 }}>{String(currentFrame).padStart(4,'0')}</span>
115
+ <span style={{ color:'var(--text3)' }}>/{totalFrames}</span>
116
+ </div>
 
 
 
 
 
 
 
117
 
118
+ <span style={{ fontSize:10, color:'var(--text3)' }}>{duration}s</span>
119
 
120
+ <div style={{ width:1, height:16, background:'var(--border)', margin:'0 2px' }} />
121
 
 
122
  {selectedModelId && (
123
  <button
124
  onClick={() => addKeyframe(currentFrame, selectedModelId)}
125
  style={{
126
+ ...tbtn,
127
+ background:'rgba(245,158,11,0.12)',
128
+ borderColor:'rgba(245,158,11,0.3)',
129
+ color:'var(--warn)', fontWeight:600,
130
  }}
131
+ >◆ Key</button>
 
 
 
132
  )}
133
 
134
+ <div style={{ flex:1 }} />
135
+
136
+ <button onClick={() => setSettings(!settings)} style={{ ...tbtn, opacity: settings ? 1 : 0.5 }} title="Timeline settings">⚙</button>
137
+ <button onClick={() => setShowTimeline(false)} style={tbtn} title="Hide timeline">✕</button>
138
  </div>
139
 
140
+ {/* Settings row */}
141
+ {settings && (
142
+ <div style={{
143
+ display:'flex', gap:12, padding:'6px 12px',
144
+ borderBottom:'1px solid var(--border)',
145
+ background:'var(--bg2)', fontSize:11, color:'var(--text1)',
146
+ alignItems:'center',
147
+ }}>
148
+ <span>Frames:</span>
149
+ {[120,200,300,500].map(f => (
150
+ <button key={f} onClick={() => setTotalFrames(f)} style={{
151
+ ...tbtn, padding:'2px 8px', fontSize:10,
152
+ background: totalFrames===f ? 'rgba(79,142,255,0.15)' : 'var(--bg3)',
153
+ borderColor: totalFrames===f ? 'rgba(79,142,255,0.4)' : 'var(--border)',
154
+ color: totalFrames===f ? 'var(--accent)' : 'var(--text1)',
155
+ }}>{f}</button>
156
+ ))}
157
+ <span style={{ marginLeft:8 }}>FPS:</span>
158
+ {[24,30,60].map(f => (
159
+ <button key={f} onClick={() => setFps(f)} style={{
160
+ ...tbtn, padding:'2px 8px', fontSize:10,
161
+ background: fps===f ? 'rgba(79,142,255,0.15)' : 'var(--bg3)',
162
+ borderColor: fps===f ? 'rgba(79,142,255,0.4)' : 'var(--border)',
163
+ color: fps===f ? 'var(--accent)' : 'var(--text1)',
164
+ }}>{f}</button>
165
+ ))}
166
+ </div>
167
+ )}
168
+
169
+ {/* Track area */}
170
+ <div style={{ display:'flex', maxHeight:160, overflow:'hidden' }}>
171
+ {/* Labels */}
172
+ <div style={{ width:100, flexShrink:0, borderRight:'1px solid var(--border)' }}>
173
+ <div style={{ height:20, display:'flex', alignItems:'center', padding:'0 8px',
174
+ borderBottom:'1px solid var(--border)', fontSize:9, color:'var(--text3)', fontWeight:600, letterSpacing:'0.1em' }}>
175
+ LAYERS
176
+ </div>
177
+ {models.map((m,i) => (
178
+ <div key={m.id} style={{
179
+ height:TRACK_H, display:'flex', alignItems:'center',
180
+ padding:'0 8px', gap:6, fontSize:11,
181
+ color: COLORS[i%COLORS.length],
182
+ borderBottom:'1px solid var(--border)',
183
+ overflow:'hidden',
184
+ }}>
185
+ <div style={{ width:6, height:6, borderRadius:1, background:COLORS[i%COLORS.length], flexShrink:0, rotate:'45deg' }} />
186
+ <span style={{ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontSize:10 }}>
187
+ {m.name.substring(0,10)}
188
+ </span>
189
+ </div>
190
+ ))}
191
  </div>
192
 
193
+ {/* Scrollable ruler + tracks */}
194
+ <div style={{ flex:1, overflow:'auto hidden' }}>
195
+ {/* Ruler */}
196
+ <div ref={el => { measuredRef(el); rulerRef.current = el }}
197
+ style={{ position:'relative', height:20, borderBottom:'1px solid var(--border)',
198
+ cursor:'crosshair', background:'var(--bg2)' }}
199
+ onPointerDown={handlePointerDown}
200
+ onTouchStart={e => handlePointerDown(e)}
201
  >
202
+ {Array.from({ length: Math.ceil(totalFrames/10) }, (_,i) => {
203
+ const f = i*10, x = (f/totalFrames)*trackW
 
204
  return (
205
+ <div key={f} style={{ position:'absolute', left:x, top:0, bottom:0,
206
+ borderLeft:`1px solid ${f%50===0 ? 'var(--border-hi)' : 'var(--border)'}` }}>
207
+ {f%10===0 && <span style={{ fontSize:8, color:'var(--text3)', paddingLeft:2, lineHeight:'20px', pointerEvents:'none' }}>{f}</span>}
 
 
 
 
208
  </div>
209
  )
210
  })}
211
+ <div style={{ position:'absolute', left:playheadX, top:0, bottom:0, width:2,
212
+ background:'var(--accent)', boxShadow:'0 0 6px rgba(79,142,255,0.6)', pointerEvents:'none', zIndex:20 }}>
213
+ <div style={{ position:'absolute', top:-1, left:-3, width:8, height:8,
214
+ background:'var(--accent)', borderRadius:'0 0 3px 3px', clipPath:'polygon(50% 0,100% 100%,0 100%)' }} />
215
+ </div>
 
 
 
216
  </div>
217
 
218
  {/* Tracks */}
219
+ <div style={{ position:'relative' }}>
220
+ {models.map((m,i) => {
221
+ const c = COLORS[i%COLORS.length]
222
+ const mKfs = Object.entries(keyframes).filter(([,kf])=>kf[m.id]).map(([f])=>parseInt(f))
223
+ return (
224
+ <div key={m.id} style={{
225
+ position:'relative', height:TRACK_H,
226
+ background: m.id===selectedModelId ? `${c}08` : 'transparent',
227
+ borderBottom:'1px solid var(--border)',
228
+ }}>
229
+ {/* Track line */}
230
+ <div style={{ position:'absolute', top:'50%', left:0, right:0,
231
+ height:1, background:'var(--border-hi)', opacity:0.5 }} />
232
+ {mKfs.map(f => (
233
+ <KeyframeDot key={f} frame={f} modelId={m.id}
234
+ color={c} trackW={trackW} totalFrames={totalFrames} />
235
+ ))}
236
+ </div>
237
+ )
238
+ })}
239
+ {/* Playhead */}
240
+ <div style={{ position:'absolute', left:playheadX, top:0, bottom:0,
241
+ width:1, background:'rgba(79,142,255,0.35)', pointerEvents:'none', zIndex:5 }} />
242
  </div>
243
  </div>
244
  </div>
 
246
  )
247
  }
248
 
249
+ const tbtn = {
250
+ padding:'4px 8px', borderRadius:'var(--radius-sm)',
251
+ background:'var(--bg3)', border:'1px solid var(--border)',
252
+ color:'var(--text1)', fontSize:12, cursor:'pointer', flexShrink:0,
253
+ transition:'all 0.12s',
 
 
 
 
254
  }
src/components/Toolbar.jsx CHANGED
@@ -1,129 +1,189 @@
 
1
  import useStore from '../store/useStore'
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  export default function Toolbar() {
4
  const {
5
  transformMode, setTransformMode,
6
  lightingPreset, setLightingPreset,
7
- selectedModelId, addKeyframe, currentFrame,
8
  isPlaying, setIsPlaying,
 
 
9
  } = useStore()
10
 
11
- const modes = [
12
- { id: 'translate', icon: '', label: 'Move' },
13
- { id: 'rotate', icon: '↻', label: 'Rotate' },
14
- { id: 'scale', icon: '⤡', label: 'Scale' },
15
  ]
16
 
17
  const lights = [
18
- { id: 'studio', icon: '💡' },
19
- { id: 'outdoor', icon: '☀️' },
20
- { id: 'dramatic', icon: '🎭' },
21
- { id: 'neon', icon: '🌀' },
22
  ]
23
 
24
  return (
25
- <div style={{
26
- position: 'absolute', top: 0, left: 0, right: 0,
27
- display: 'flex', alignItems: 'center', gap: 6,
28
- padding: '6px 10px',
29
- background: 'rgba(8,8,20,0.92)',
30
- backdropFilter: 'blur(12px)',
31
- borderBottom: '1px solid rgba(0,245,255,0.12)',
32
- zIndex: 200,
33
- overflowX: 'auto',
34
- }}>
35
- {/* App title */}
36
- <div style={{
37
- fontFamily: 'Orbitron, monospace',
38
- fontSize: 13, fontWeight: 900,
39
- color: '#00f5ff',
40
- letterSpacing: '0.08em',
41
- whiteSpace: 'nowrap',
42
- textShadow: '0 0 20px rgba(0,245,255,0.5)',
43
- marginRight: 6,
44
- }}>
45
- GLB<span style={{ color: '#ff4080' }}>STUDIO</span>
46
  </div>
47
 
48
- <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.12)' }} />
49
 
50
- {/* Transform mode */}
51
- {modes.map(m => (
 
 
52
  <button
53
- key={m.id}
54
- onClick={() => setTransformMode(m.id)}
55
- title={m.label}
56
  style={{
57
- padding: '5px 10px',
58
- background: transformMode === m.id ? 'rgba(0,245,255,0.15)' : 'rgba(255,255,255,0.04)',
59
- border: `1px solid ${transformMode === m.id ? '#00f5ff' : 'rgba(255,255,255,0.1)'}`,
60
- color: transformMode === m.id ? '#00f5ff' : '#888',
61
- borderRadius: 5, cursor: 'pointer',
62
- fontSize: 14, fontWeight: 'bold',
63
- lineHeight: 1,
64
  transition: 'all 0.15s',
65
- minWidth: 36,
66
- textAlign: 'center',
67
  }}
68
- >
69
- {m.icon}
70
- </button>
71
- ))}
72
 
73
- <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.12)' }} />
 
 
 
 
 
 
 
 
 
74
 
75
- {/* Playback */}
76
- <button
77
- onClick={() => setIsPlaying(!isPlaying)}
78
- style={{
79
- padding: '5px 12px',
80
- background: isPlaying ? 'rgba(255,64,96,0.15)' : 'rgba(64,255,128,0.12)',
81
- border: `1px solid ${isPlaying ? '#ff4060' : '#40ff80'}`,
82
- color: isPlaying ? '#ff4060' : '#40ff80',
83
- borderRadius: 5, cursor: 'pointer',
84
- fontSize: 13,
85
- }}
86
- >
87
- {isPlaying ? '⏸' : '▶'}
88
- </button>
89
-
90
- <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.12)' }} />
91
 
92
  {/* Lighting */}
93
- {lights.map(l => (
94
- <button
95
- key={l.id}
96
- onClick={() => setLightingPreset(l.id)}
97
- title={l.id}
98
- style={{
99
- padding: '5px 8px',
100
- background: lightingPreset === l.id ? 'rgba(255,170,0,0.12)' : 'rgba(255,255,255,0.03)',
101
- border: `1px solid ${lightingPreset === l.id ? '#ffaa00' : 'rgba(255,255,255,0.08)'}`,
102
- borderRadius: 5, cursor: 'pointer',
103
- fontSize: 14, opacity: lightingPreset === l.id ? 1 : 0.5,
104
- }}
105
- >
106
- {l.icon}
107
- </button>
108
- ))}
109
 
110
  <div style={{ flex: 1 }} />
111
 
112
- {/* Quick add keyframe */}
113
  {selectedModelId && (
114
  <button
115
  onClick={() => addKeyframe(currentFrame, selectedModelId)}
116
  style={{
117
- padding: '5px 12px',
118
- background: 'rgba(255,170,0,0.12)',
119
- border: '1px solid rgba(255,170,0,0.4)',
120
- color: '#ffaa00', borderRadius: 5,
121
- cursor: 'pointer', fontSize: 11,
122
- fontFamily: 'Space Mono', whiteSpace: 'nowrap',
123
  }}
124
- >
125
- ◆ KEY
126
- </button>
127
  )}
128
  </div>
129
  )
 
1
+ import { useState } from 'react'
2
  import useStore from '../store/useStore'
3
 
4
+ const S = {
5
+ bar: {
6
+ position: 'relative', zIndex: 300,
7
+ display: 'flex', alignItems: 'center', gap: 2,
8
+ padding: '0 10px',
9
+ height: 46,
10
+ background: 'var(--bg1)',
11
+ borderBottom: '1px solid var(--border)',
12
+ boxShadow: '0 1px 0 rgba(255,255,255,0.04)',
13
+ overflowX: 'auto', overflowY: 'hidden',
14
+ flexShrink: 0,
15
+ },
16
+ brand: {
17
+ fontFamily: 'var(--font-brand)',
18
+ fontSize: 15, fontWeight: 800,
19
+ letterSpacing: '-0.01em',
20
+ color: 'var(--text0)',
21
+ marginRight: 8,
22
+ whiteSpace: 'nowrap',
23
+ userSelect: 'none',
24
+ },
25
+ div: {
26
+ width: 1, height: 20,
27
+ background: 'var(--border)',
28
+ margin: '0 6px', flexShrink: 0,
29
+ },
30
+ }
31
+
32
+ function ToolBtn({ icon, label, active, onClick, color, shortcut }) {
33
+ const [hover, setHover] = useState(false)
34
+ return (
35
+ <button
36
+ onClick={onClick}
37
+ onMouseEnter={() => setHover(true)}
38
+ onMouseLeave={() => setHover(false)}
39
+ title={shortcut ? `${label} [${shortcut}]` : label}
40
+ style={{
41
+ display: 'flex', alignItems: 'center', gap: 5,
42
+ padding: '5px 10px',
43
+ borderRadius: 'var(--radius-sm)',
44
+ border: active
45
+ ? `1px solid ${color || 'var(--accent)'}44`
46
+ : `1px solid ${hover ? 'var(--border-hi)' : 'transparent'}`,
47
+ background: active
48
+ ? `${color || 'var(--accent)'}18`
49
+ : hover ? 'var(--bg3)' : 'transparent',
50
+ color: active ? (color || 'var(--accent)') : hover ? 'var(--text0)' : 'var(--text1)',
51
+ fontSize: 12, fontWeight: 500,
52
+ transition: 'all 0.12s',
53
+ whiteSpace: 'nowrap', flexShrink: 0,
54
+ }}
55
+ >
56
+ <span style={{ fontSize: 14, lineHeight: 1 }}>{icon}</span>
57
+ <span style={{ fontSize: 11 }}>{label}</span>
58
+ </button>
59
+ )
60
+ }
61
+
62
+ function IconBtn({ icon, active, onClick, title, color }) {
63
+ const [h, setH] = useState(false)
64
+ return (
65
+ <button onClick={onClick} title={title}
66
+ onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
67
+ style={{
68
+ width: 32, height: 32, borderRadius: 'var(--radius-sm)',
69
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
70
+ border: active ? `1px solid ${color||'var(--accent)'}44` : `1px solid ${h?'var(--border-hi)':'transparent'}`,
71
+ background: active ? `${color||'var(--accent)'}18` : h ? 'var(--bg3)' : 'transparent',
72
+ color: active ? (color||'var(--accent)') : h ? 'var(--text0)' : 'var(--text1)',
73
+ fontSize: 15, transition: 'all 0.12s', flexShrink: 0,
74
+ }}
75
+ >{icon}</button>
76
+ )
77
+ }
78
+
79
  export default function Toolbar() {
80
  const {
81
  transformMode, setTransformMode,
82
  lightingPreset, setLightingPreset,
 
83
  isPlaying, setIsPlaying,
84
+ selectedModelId, addKeyframe, currentFrame,
85
+ currentFrame: cf, setCurrentFrame, totalFrames,
86
  } = useStore()
87
 
88
+ const tools = [
89
+ { id: 'translate', icon: '', label: 'Move', short: 'G' },
90
+ { id: 'rotate', icon: '↻', label: 'Rotate', short: 'R' },
91
+ { id: 'scale', icon: '⤡', label: 'Scale', short: 'S' },
92
  ]
93
 
94
  const lights = [
95
+ { id: 'studio', icon: '', label: 'Studio' },
96
+ { id: 'outdoor', icon: '', label: 'Outdoor' },
97
+ { id: 'dramatic', icon: '', label: 'Dramatic' },
98
+ { id: 'neon', icon: '', label: 'Neon' },
99
  ]
100
 
101
  return (
102
+ <div style={S.bar}>
103
+ {/* Brand */}
104
+ <div style={S.brand}>
105
+ <span style={{ color: 'var(--accent)' }}>GLB</span>
106
+ <span style={{ color: 'var(--text1)', fontWeight: 400 }}>Studio</span>
107
+ </div>
108
+
109
+ <div style={S.div} />
110
+
111
+ {/* Transform tools */}
112
+ <div style={{ display:'flex', gap:2, flexShrink:0 }}>
113
+ {tools.map(t => (
114
+ <ToolBtn key={t.id}
115
+ icon={t.icon} label={t.label} shortcut={t.short}
116
+ active={transformMode === t.id}
117
+ onClick={() => setTransformMode(t.id)}
118
+ />
119
+ ))}
 
 
 
120
  </div>
121
 
122
+ <div style={S.div} />
123
 
124
+ {/* Playback */}
125
+ <div style={{ display:'flex', gap:2, alignItems:'center', flexShrink:0 }}>
126
+ <IconBtn icon="⏮" title="First frame" onClick={() => setCurrentFrame(0)} />
127
+ <IconBtn icon="◀" title="Prev frame" onClick={() => setCurrentFrame(Math.max(0, cf-1))} />
128
  <button
129
+ onClick={() => setIsPlaying(!isPlaying)}
 
 
130
  style={{
131
+ width: 34, height: 34, borderRadius: 'var(--radius-sm)',
132
+ background: isPlaying ? 'var(--danger)' : 'var(--accent)',
133
+ border: 'none', color: '#fff', fontSize: 14,
134
+ display:'flex', alignItems:'center', justifyContent:'center',
135
+ flexShrink: 0, cursor: 'pointer',
136
+ boxShadow: isPlaying ? '0 0 12px rgba(239,68,68,0.4)' : '0 0 12px rgba(79,142,255,0.4)',
 
137
  transition: 'all 0.15s',
 
 
138
  }}
139
+ >{isPlaying ? '⏸' : '▶'}</button>
140
+ <IconBtn icon="▶" title="Next frame" onClick={() => setCurrentFrame(Math.min(totalFrames-1,cf+1))} />
141
+ <IconBtn icon="⏭" title="Last frame" onClick={() => setCurrentFrame(totalFrames-1)} />
 
142
 
143
+ <div style={{
144
+ fontFamily: 'var(--font-mono)', fontSize: 11,
145
+ color: 'var(--text1)', marginLeft: 6, flexShrink: 0,
146
+ background: 'var(--bg3)', padding: '4px 8px',
147
+ borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
148
+ }}>
149
+ <span style={{ color: 'var(--text0)' }}>{String(cf).padStart(4,'0')}</span>
150
+ <span style={{ color: 'var(--text3)' }}>/{totalFrames}</span>
151
+ </div>
152
+ </div>
153
 
154
+ <div style={S.div} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  {/* Lighting */}
157
+ <div style={{ display:'flex', gap:2, flexShrink:0 }}>
158
+ {lights.map(l => (
159
+ <button key={l.id}
160
+ onClick={() => setLightingPreset(l.id)}
161
+ title={l.label}
162
+ style={{
163
+ padding: '4px 7px', borderRadius: 'var(--radius-sm)',
164
+ background: lightingPreset===l.id ? 'var(--bg4)' : 'transparent',
165
+ border: `1px solid ${lightingPreset===l.id ? 'var(--border-hi)' : 'transparent'}`,
166
+ color: lightingPreset===l.id ? 'var(--warn)' : 'var(--text2)',
167
+ fontSize: 13, cursor:'pointer', transition:'all 0.12s',
168
+ }}
169
+ >{l.icon}</button>
170
+ ))}
171
+ </div>
 
172
 
173
  <div style={{ flex: 1 }} />
174
 
175
+ {/* Keyframe shortcut */}
176
  {selectedModelId && (
177
  <button
178
  onClick={() => addKeyframe(currentFrame, selectedModelId)}
179
  style={{
180
+ padding: '5px 12px', borderRadius: 'var(--radius-sm)',
181
+ background: 'rgba(245,158,11,0.12)',
182
+ border: '1px solid rgba(245,158,11,0.3)',
183
+ color: 'var(--warn)', fontSize: 11, fontWeight: 600,
184
+ cursor: 'pointer', flexShrink: 0, transition: 'all 0.12s',
 
185
  }}
186
+ >◆ Keyframe</button>
 
 
187
  )}
188
  </div>
189
  )
src/index.css ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@700;800&display=swap');
2
+
3
+ :root {
4
+ --bg0: #0c0c10;
5
+ --bg1: #111116;
6
+ --bg2: #17171e;
7
+ --bg3: #1e1e28;
8
+ --bg4: #252532;
9
+ --border: rgba(255,255,255,0.07);
10
+ --border-hi: rgba(255,255,255,0.14);
11
+ --accent: #4f8eff;
12
+ --accent2: #7c3aed;
13
+ --accent3: #06d6a0;
14
+ --warn: #f59e0b;
15
+ --danger: #ef4444;
16
+ --text0: #f0f0f8;
17
+ --text1: #a0a0b8;
18
+ --text2: #606078;
19
+ --text3: #383848;
20
+ --radius-sm: 4px;
21
+ --radius: 8px;
22
+ --radius-lg: 12px;
23
+ --font-ui: 'Inter', system-ui, sans-serif;
24
+ --font-mono: 'JetBrains Mono', monospace;
25
+ --font-brand: 'Syne', sans-serif;
26
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
27
+ --shadow: 0 4px 16px rgba(0,0,0,0.5);
28
+ --shadow-lg: 0 8px 32px rgba(0,0,0,0.7);
29
+ --glow: 0 0 20px rgba(79,142,255,0.25);
30
+ --glow-green: 0 0 20px rgba(6,214,160,0.25);
31
+ }
32
+
33
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
+
35
+ html, body, #root {
36
+ width: 100%; height: 100%;
37
+ overflow: hidden;
38
+ background: var(--bg0);
39
+ color: var(--text0);
40
+ font-family: var(--font-ui);
41
+ font-size: 13px;
42
+ -webkit-font-smoothing: antialiased;
43
+ -moz-osx-font-smoothing: grayscale;
44
+ }
45
+
46
+ /* ── Scrollbar ─────────────────────────────────────────────────────── */
47
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
48
+ ::-webkit-scrollbar-track { background: transparent; }
49
+ ::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; }
50
+ ::-webkit-scrollbar-thumb:hover { background: var(--text3); }
51
+
52
+ /* ── Global inputs ─────────────────────────────────────────────────── */
53
+ input[type=range] {
54
+ -webkit-appearance: none;
55
+ height: 3px;
56
+ background: var(--bg4);
57
+ border-radius: 2px;
58
+ cursor: pointer;
59
+ width: 100%;
60
+ }
61
+ input[type=range]::-webkit-slider-thumb {
62
+ -webkit-appearance: none;
63
+ width: 13px; height: 13px;
64
+ background: var(--accent);
65
+ border-radius: 50%;
66
+ box-shadow: 0 0 0 3px rgba(79,142,255,0.2);
67
+ transition: transform 0.15s;
68
+ }
69
+ input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); }
70
+ input[type=range]:focus { outline: none; }
71
+
72
+ input[type=number], input[type=text], select, textarea {
73
+ background: var(--bg1);
74
+ border: 1px solid var(--border);
75
+ border-radius: var(--radius-sm);
76
+ color: var(--text0);
77
+ font-family: var(--font-mono);
78
+ font-size: 11px;
79
+ padding: 5px 7px;
80
+ outline: none;
81
+ transition: border-color 0.15s, box-shadow 0.15s;
82
+ width: 100%;
83
+ }
84
+ input[type=number]:focus,
85
+ input[type=text]:focus,
86
+ select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79,142,255,0.15); }
87
+
88
+ button { font-family: var(--font-ui); cursor: pointer; outline: none; border: none; }
89
+
90
+ * { -webkit-tap-highlight-color: transparent; }
91
+
92
+ /* ── Animations ────────────────────────────────────────────────────── */
93
+ @keyframes spin { to { transform: rotate(360deg); } }
94
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
95
+ @keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
96
+ @keyframes shimmer {
97
+ 0% { background-position: -200% 0; }
98
+ 100% { background-position: 200% 0; }
99
+ }
src/main.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import React from 'react'
2
  import ReactDOM from 'react-dom/client'
 
3
  import App from './App'
4
 
5
  ReactDOM.createRoot(document.getElementById('root')).render(
 
1
  import React from 'react'
2
  import ReactDOM from 'react-dom/client'
3
+ import './index.css'
4
  import App from './App'
5
 
6
  ReactDOM.createRoot(document.getElementById('root')).render(