GLB Studio Deploy commited on
Commit
d9dc1a9
Β·
1 Parent(s): d496807

fix: animation detection + AI 404 errors

Browse files

ModelManager.jsx:
- Uses THREE.AnimationMixer directly (not useAnimations hook) so clips
work on cloned scenes correctly
- Auto-plays first clip on model load
- Cross-fades between clips (fadeOut 0.3s / fadeIn 0.3s)
- Mixer ticked via useFrame (clips actually animate now)
- Manual skeleton clone for SkinnedMesh models (no SkeletonUtils dep)
- Separate refs: mixerRef, actionsRef, currentAnimRef
- Responds to animationPlaying, activeAnimation, animationSpeed from store

AnimationPlayer.jsx:
- Play/Pause button actually toggles model.animationPlaying in store
- Stop button pauses and resets
- Now-playing indicator with pulse dot (green=playing, grey=paused)
- Play All / Pause All header button
- Clip buttons show β–Ά / ⏸ prefix for active clip state

useStore.js:
- Added setModelAnimPlaying(id, playing) action
- selectModel now preserves lastSelectedModelId on deselect

AIController.jsx:
- 10 verified free models (old llama-3.1-8b had no free endpoints)
- Handles HTTP 404, 429, 500, 503, 529 β†’ skip to next model
- Also detects 'No endpoints', 'not found', 'unavailable' in body
- New list: gemma-2-9b, mistral-7b, phi-3-mini, qwen-2-7b, zephyr-7b,
openchat-7b, mythomist-7b, llama-3.2-3b, deepseek-r1-qwen-7b, capybara-7b

CommandInterpreter.js:
- Added pause_animation action
- set_animation now calls setModelAnimPlaying(true)

src/components/AIController.jsx CHANGED
@@ -17,12 +17,19 @@ import {
17
  } from './CommandInterpreter'
18
 
19
  const BASE_URL = 'https://openrouter.ai/api/v1'
 
 
20
  const FREE_MODELS = [
21
- 'meta-llama/llama-3.1-8b-instruct:free',
22
- 'google/gemma-3-4b-it:free',
23
  'mistralai/mistral-7b-instruct:free',
24
- 'qwen/qwen3-8b:free',
25
- 'stepfun/step-3.5-flash:free',
 
 
 
 
 
 
26
  ]
27
 
28
  // ── System prompt ──────────────────────────────────────────────────────────────
@@ -154,15 +161,21 @@ async function callAI(messages, apiKey, modelIdx=0) {
154
  return callAI(messages, apiKey, modelIdx+1)
155
  }
156
 
157
- if (res.status === 429 || res.status === 503) {
158
- console.info(`[AI] ${model} rate-limited β†’ trying next…`)
 
159
  return callAI(messages, apiKey, modelIdx+1)
160
  }
161
  if (!res.ok) {
162
  const txt = await res.text()
163
- if (txt.includes('429') || txt.includes('rate') || txt.includes('Provider returned error'))
 
 
 
 
164
  return callAI(messages, apiKey, modelIdx+1)
165
- throw new Error(`API ${res.status}: ${txt.slice(0,250)}`)
 
166
  }
167
  const data = await res.json()
168
  return { data, model }
 
17
  } from './CommandInterpreter'
18
 
19
  const BASE_URL = 'https://openrouter.ai/api/v1'
20
+ // Verified working free models on OpenRouter (updated list)
21
+ // Ordered by reliability - falls through on 404/429/503
22
  const FREE_MODELS = [
23
+ 'google/gemma-2-9b-it:free',
 
24
  'mistralai/mistral-7b-instruct:free',
25
+ 'microsoft/phi-3-mini-128k-instruct:free',
26
+ 'qwen/qwen-2-7b-instruct:free',
27
+ 'huggingfaceh4/zephyr-7b-beta:free',
28
+ 'openchat/openchat-7b:free',
29
+ 'gryphe/mythomist-7b:free',
30
+ 'meta-llama/llama-3.2-3b-instruct:free',
31
+ 'deepseek/deepseek-r1-distill-qwen-7b:free',
32
+ 'nousresearch/nous-capybara-7b:free',
33
  ]
34
 
35
  // ── System prompt ──────────────────────────────────────────────────────────────
 
161
  return callAI(messages, apiKey, modelIdx+1)
162
  }
163
 
164
+ // Skip to next model on any of these status codes
165
+ if ([404, 429, 500, 503, 529].includes(res.status)) {
166
+ console.info(`[AI] ${model} β†’ ${res.status}, trying next model…`)
167
  return callAI(messages, apiKey, modelIdx+1)
168
  }
169
  if (!res.ok) {
170
  const txt = await res.text()
171
+ // Also skip on provider errors, rate limits in body, or endpoint not found
172
+ if (txt.includes('429') || txt.includes('rate') || txt.includes('No endpoints')
173
+ || txt.includes('Provider returned') || txt.includes('model_not_found')
174
+ || txt.includes('not found') || txt.includes('unavailable')) {
175
+ console.info(`[AI] ${model} β†’ body error, trying next…`)
176
  return callAI(messages, apiKey, modelIdx+1)
177
+ }
178
+ throw new Error(`API ${res.status}: ${txt.slice(0,300)}`)
179
  }
180
  const data = await res.json()
181
  return { data, model }
src/components/AnimationPlayer.jsx CHANGED
@@ -1,65 +1,133 @@
 
 
 
 
 
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>
@@ -70,23 +138,47 @@ 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>
 
1
+ /**
2
+ * AnimationPlayer.jsx
3
+ * Per-model animation controls with working play/pause/loop/speed/switch.
4
+ * Connects to ModelManager's mixer via the store.
5
+ */
6
  import { useState } from 'react'
7
  import useStore from '../store/useStore'
8
 
9
  const COLORS = ['#4f8eff','#ef4444','#22c55e','#f59e0b','#8b5cf6','#f97316']
10
 
11
  function AnimBlock({ model, colorIdx }) {
12
+ const { setModelActiveAnimation, setModelAnimSpeed, setModelAnimPlaying } = useStore.getState()
 
 
13
  const c = COLORS[colorIdx % COLORS.length]
14
 
15
+ const playing = model.animationPlaying
16
+
17
+ const switchTo = (name) => {
18
+ setModelActiveAnimation(model.id, name)
19
+ setModelAnimPlaying(model.id, true)
20
+ }
21
+
22
+ const togglePlay = () => {
23
+ setModelAnimPlaying(model.id, !playing)
24
+ }
25
+
26
  if (!model.animations.length) return null
27
 
28
  return (
29
+ <div style={{ borderBottom:'1px solid var(--border)' }}>
30
+ {/* Header */}
31
+ <div style={{
32
+ padding:'10px 12px 6px',
33
+ display:'flex', alignItems:'center', gap:8,
34
+ }}>
35
+ <div style={{ width:7, height:7, borderRadius:1, rotate:'45deg',
36
+ background: c, boxShadow:`0 0 8px ${c}88`, flexShrink:0 }} />
37
+ <span style={{ fontSize:12, fontWeight:700, color:'var(--text0)', flex:1,
38
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
39
+ {model.name}
40
+ </span>
41
+ <span style={{
42
+ fontSize:9, padding:'2px 7px', borderRadius:3, fontWeight:700,
43
+ background:`${c}15`, color:c, border:`1px solid ${c}33`,
44
+ }}>{model.animations.length} clip{model.animations.length>1?'s':''}</span>
45
  </div>
46
 
47
+ {/* Now playing indicator */}
48
+ {model.activeAnimation && (
49
+ <div style={{
50
+ margin:'0 12px 6px',
51
+ padding:'5px 9px',
52
+ background:'rgba(255,255,255,0.04)',
53
+ border:`1px solid ${c}22`,
54
+ borderRadius:'var(--radius-sm)',
55
+ display:'flex', alignItems:'center', gap:7,
56
+ }}>
57
+ <div style={{
58
+ width:6, height:6, borderRadius:'50%', flexShrink:0,
59
+ background: playing ? c : 'var(--text3)',
60
+ boxShadow: playing ? `0 0 6px ${c}` : 'none',
61
+ animation: playing ? 'pulse 1.5s ease infinite' : 'none',
62
+ }}/>
63
+ <span style={{ fontSize:10, color:'var(--text1)', flex:1,
64
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
65
+ fontWeight:600 }}>
66
+ {model.activeAnimation}
67
+ </span>
68
+ <span style={{ fontSize:9, color:'var(--text3)' }}>
69
+ {playing ? 'PLAYING' : 'PAUSED'}
70
+ </span>
71
+ </div>
72
+ )}
73
+
74
+ {/* Clip list */}
75
+ <div style={{ padding:'0 12px', display:'flex', flexWrap:'wrap', gap:4, marginBottom:8 }}>
76
+ {model.animations.map(anim => {
77
+ const isActive = model.activeAnimation === anim
78
+ return (
79
+ <button key={anim}
80
+ onClick={() => switchTo(anim)}
81
+ style={{
82
+ padding:'5px 10px', borderRadius:'var(--radius-sm)',
83
+ background: isActive ? `${c}18` : 'var(--bg3)',
84
+ border:`1px solid ${isActive ? `${c}55` : 'var(--border)'}`,
85
+ color: isActive ? c : 'var(--text1)',
86
+ fontSize:11, fontWeight: isActive ? 700 : 400,
87
+ cursor:'pointer', transition:'all 0.12s',
88
+ maxWidth:130, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
89
+ }}
90
+ title={anim}
91
+ >
92
+ {isActive && playing ? 'β–Ά ' : isActive ? '⏸ ' : ''}{anim}
93
+ </button>
94
+ )
95
+ })}
96
  </div>
97
 
98
+ {/* Controls row */}
99
+ <div style={{ padding:'0 12px 12px', display:'flex', gap:6, alignItems:'center' }}>
100
+ {/* Play/Pause */}
101
+ <button onClick={togglePlay} style={{
102
+ padding:'5px 12px', borderRadius:'var(--radius-sm)',
103
+ background: playing ? 'rgba(239,68,68,0.12)' : `${c}18`,
104
+ border:`1px solid ${playing ? 'rgba(239,68,68,0.35)' : `${c}44`}`,
105
+ color: playing ? 'var(--danger)' : c,
106
+ fontSize:13, cursor:'pointer', flexShrink:0, fontWeight:700,
107
+ transition:'all 0.15s',
108
  }}>{playing ? '⏸' : 'β–Ά'}</button>
109
 
110
+ {/* Stop */}
111
+ <button onClick={() => { setModelAnimPlaying(model.id, false) }}
112
+ title="Stop"
113
+ style={{
114
+ width:30, height:30, borderRadius:'var(--radius-sm)',
115
+ background:'var(--bg3)', border:'1px solid var(--border)',
116
+ color:'var(--text2)', fontSize:12, cursor:'pointer', flexShrink:0,
117
+ display:'flex', alignItems:'center', justifyContent:'center',
118
+ }}>⏹</button>
119
 
120
+ {/* Speed slider */}
121
  <div style={{ display:'flex', alignItems:'center', gap:6, flex:1 }}>
122
+ <span style={{ fontSize:9, color:'var(--text3)', flexShrink:0 }}>SPD</span>
123
+ <input type="range" min={0.1} max={3} step={0.05}
124
+ value={model.animationSpeed}
125
+ onChange={e => setModelAnimSpeed(model.id, +e.target.value)}
126
+ style={{ flex:1 }} />
127
+ <span style={{
128
+ fontSize:10, fontFamily:'var(--font-mono)',
129
+ color:c, minWidth:30, textAlign:'right',
130
+ }}>{model.animationSpeed.toFixed(1)}Γ—</span>
131
  </div>
132
  </div>
133
  </div>
 
138
  const models = useStore(s => s.models)
139
  const animated = models.filter(m => m.animations.length > 0)
140
 
141
+ if (!animated.length) {
142
+ return (
143
+ <div style={{ padding:24, textAlign:'center' }}>
144
+ <div style={{ fontSize:36, opacity:0.12, marginBottom:12 }}>🎞</div>
145
+ <div style={{ fontSize:13, fontWeight:600, color:'var(--text1)' }}>
146
+ No animations detected
147
+ </div>
148
+ <div style={{ fontSize:11, color:'var(--text3)', marginTop:6, lineHeight:1.7 }}>
149
+ Load a GLB with built-in animation clips.<br/>
150
+ Try: <span style={{ color:'var(--accent)' }}>Fox, Robot, Soldier, Flamingo</span>
151
+ </div>
152
  </div>
153
+ )
154
+ }
155
 
156
  return (
157
  <div>
158
+ <div style={{
159
+ padding:'8px 12px', borderBottom:'1px solid var(--border)',
160
+ display:'flex', alignItems:'center', justifyContent:'space-between',
161
+ }}>
162
+ <span style={{ fontSize:11, color:'var(--text2)' }}>
163
+ {animated.length} animated model{animated.length>1?'s':''}
164
+ </span>
165
+ <button
166
+ onClick={() => {
167
+ const { models, setModelAnimPlaying } = useStore.getState()
168
+ const allPlaying = animated.every(m => m.animationPlaying)
169
+ animated.forEach(m => setModelAnimPlaying(m.id, !allPlaying))
170
+ }}
171
+ style={{
172
+ padding:'4px 10px', borderRadius:'var(--radius-sm)',
173
+ background:'var(--bg3)', border:'1px solid var(--border)',
174
+ color:'var(--text1)', fontSize:10, cursor:'pointer',
175
+ }}
176
+ >
177
+ {animated.every(m => m.animationPlaying) ? '⏸ Pause All' : 'β–Ά Play All'}
178
+ </button>
179
  </div>
180
+
181
+ {animated.map(m => (
182
  <AnimBlock key={m.id} model={m} colorIdx={models.indexOf(m)} />
183
  ))}
184
  </div>
src/components/CommandInterpreter.js CHANGED
@@ -238,10 +238,16 @@ export async function applyCommand(cmd) {
238
  for (const m of targets) {
239
  if (cmd.animation) s.setModelActiveAnimation(m.id, cmd.animation)
240
  if (cmd.speed != null) s.setModelAnimSpeed(m.id, cmd.speed)
 
241
  }
242
  break
243
  }
244
 
 
 
 
 
 
245
  // ── TIMELINE ─────────────────────────────────────────────────────────────
246
  case 'play': s.setIsPlaying(true); break
247
  case 'pause': s.setIsPlaying(false); break
 
238
  for (const m of targets) {
239
  if (cmd.animation) s.setModelActiveAnimation(m.id, cmd.animation)
240
  if (cmd.speed != null) s.setModelAnimSpeed(m.id, cmd.speed)
241
+ s.setModelAnimPlaying?.(m.id, true)
242
  }
243
  break
244
  }
245
 
246
+ case 'pause_animation': {
247
+ for (const m of targets) s.setModelAnimPlaying?.(m.id, false)
248
+ break
249
+ }
250
+
251
  // ── TIMELINE ─────────────────────────────────────────────────────────────
252
  case 'play': s.setIsPlaying(true); break
253
  case 'pause': s.setIsPlaying(false); break
src/components/ModelManager.jsx CHANGED
@@ -1,88 +1,183 @@
 
 
 
 
 
 
 
 
 
 
1
  import { useEffect, useRef, useMemo } from 'react'
2
- import { useGLTF, useAnimations } from '@react-three/drei'
3
- import { useFrame } from '@react-three/fiber'
4
- import { TransformControls } from '@react-three/drei'
5
- import * as THREE from 'three'
6
- import useStore from '../store/useStore'
7
 
8
  function ModelMesh({ model }) {
9
- const groupRef = useRef()
10
- const transformRef = useRef()
 
 
 
11
  const { scene, animations } = useGLTF(model.url)
12
- const { actions, mixer } = useAnimations(animations, groupRef)
13
 
14
  const {
15
  selectedModelId, transformMode,
16
- updateModelTransform, setModelAnimations,
17
- selectModel, currentFrame, isPlaying,
18
- interpolateAtFrame, keyframes
19
  } = useStore()
20
 
21
  const isSelected = selectedModelId === model.id
22
 
23
- // Clone scene to avoid sharing
24
  const clonedScene = useMemo(() => {
 
25
  const clone = scene.clone(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  clone.traverse(child => {
27
  if (child.isMesh) {
28
- child.castShadow = true
29
  child.receiveShadow = true
 
 
 
 
 
30
  }
31
  })
32
  return clone
33
  }, [scene])
34
 
35
- // Register available animations
36
  useEffect(() => {
37
- if (animations.length > 0) {
38
- setModelAnimations(model.id, animations.map(a => a.name))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  }
40
- }, [animations, model.id])
41
 
42
- // Play/pause animation
 
 
 
 
 
 
43
  useEffect(() => {
44
- if (!actions || !model.activeAnimation) return
45
- Object.values(actions).forEach(a => a?.stop())
46
- const action = actions[model.activeAnimation]
47
- if (action) {
48
- action.setEffectiveTimeScale(model.animationSpeed)
49
- if (model.animationPlaying || isPlaying) {
50
- action.play()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
 
 
 
52
  }
53
- }, [model.activeAnimation, model.animationPlaying, model.animationSpeed, actions, isPlaying])
54
 
55
- // Apply interpolated transforms during playback / scrubbing
56
  useEffect(() => {
57
- const interpolated = interpolateAtFrame(model.id, currentFrame)
58
- if (interpolated && groupRef.current) {
59
- groupRef.current.position.set(...interpolated.position)
60
- groupRef.current.rotation.set(...interpolated.rotation)
61
- groupRef.current.scale.set(...interpolated.scale)
62
- } else if (groupRef.current) {
63
- groupRef.current.position.set(...model.position)
64
- groupRef.current.rotation.set(...model.rotation)
65
- groupRef.current.scale.set(...model.scale)
66
- }
67
- }, [currentFrame, keyframes, model.position, model.rotation, model.scale])
68
 
69
- // Apply model transforms
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  useEffect(() => {
71
  if (!groupRef.current) return
72
- groupRef.current.position.set(...model.position)
73
- groupRef.current.rotation.set(...model.rotation)
74
- groupRef.current.scale.set(...model.scale)
75
- }, [model.position, model.rotation, model.scale])
 
 
 
76
 
77
- // Listen to transform control changes
78
- const handleTransformChange = () => {
79
  if (!groupRef.current) return
80
- const pos = groupRef.current.position
81
- const rot = groupRef.current.rotation
82
  const sc = groupRef.current.scale
83
- updateModelTransform(model.id, 'position', [pos.x, pos.y, pos.z])
84
- updateModelTransform(model.id, 'rotation', [rot.x, rot.y, rot.z])
85
- updateModelTransform(model.id, 'scale', [sc.x, sc.y, sc.z])
86
  }
87
 
88
  if (!model.visible) return null
@@ -91,25 +186,30 @@ function ModelMesh({ model }) {
91
  <>
92
  <group
93
  ref={groupRef}
94
- onClick={(e) => { e.stopPropagation(); selectModel(model.id) }}
95
  >
96
  <primitive object={clonedScene} />
97
- {/* Selection ring */}
 
98
  {isSelected && (
99
- <mesh>
100
- <ringGeometry args={[0.8, 0.9, 32]} />
101
- <meshBasicMaterial color="#00f5ff" transparent opacity={0.6} side={THREE.DoubleSide} />
 
 
 
 
 
102
  </mesh>
103
  )}
104
  </group>
105
 
106
  {isSelected && (
107
  <TransformControls
108
- ref={transformRef}
109
  object={groupRef}
110
  mode={transformMode}
111
- onObjectChange={handleTransformChange}
112
- size={0.7}
113
  />
114
  )}
115
  </>
@@ -121,7 +221,7 @@ export default function ModelManager() {
121
  return (
122
  <>
123
  {models.map(model => (
124
- <ModelMesh key={model.id} model={model} />
125
  ))}
126
  </>
127
  )
 
1
+ /**
2
+ * ModelManager.jsx
3
+ * Renders all GLB models in the Three.js scene.
4
+ * Fixes:
5
+ * - Animation detection: registers ALL clips from GLB immediately on load
6
+ * - Animation playback: auto-plays first clip, responds to animationPlaying state
7
+ * - Mixer update: driven by useFrame so clips actually animate
8
+ * - Transform sync: bidirectional (store ↔ TransformControls)
9
+ * - Clone: properly clones scene so multiple instances don't share geometry
10
+ */
11
  import { useEffect, useRef, useMemo } from 'react'
12
+ import { useGLTF, useAnimations, TransformControls } from '@react-three/drei'
13
+ import { useFrame } from '@react-three/fiber'
14
+ import * as THREE from 'three'
15
+ import useStore from '../store/useStore'
 
16
 
17
  function ModelMesh({ model }) {
18
+ const groupRef = useRef()
19
+ const mixerRef = useRef(null)
20
+ const actionsRef = useRef({})
21
+ const currentAnimRef = useRef(null)
22
+
23
  const { scene, animations } = useGLTF(model.url)
 
24
 
25
  const {
26
  selectedModelId, transformMode,
27
+ updateModelTransform, setModelAnimations, setModelAnimPlaying,
28
+ selectModel, currentFrame, keyframes,
 
29
  } = useStore()
30
 
31
  const isSelected = selectedModelId === model.id
32
 
33
+ // ── Clone scene using SkeletonUtils for correct bone cloning ──────────────
34
  const clonedScene = useMemo(() => {
35
+ // Deep clone preserving skeleton/skinned mesh properly
36
  const clone = scene.clone(true)
37
+ // Re-bind skinned meshes to cloned skeleton
38
+ const srcBones = []
39
+ const dstBones = []
40
+ scene.traverse(n => { if (n.isBone) srcBones.push(n) })
41
+ clone.traverse(n => { if (n.isBone) dstBones.push(n) })
42
+ clone.traverse(child => {
43
+ if (child.isSkinnedMesh && child.skeleton) {
44
+ const newBones = child.skeleton.bones.map(b => {
45
+ const idx = srcBones.indexOf(b)
46
+ return idx !== -1 ? dstBones[idx] : b
47
+ })
48
+ child.skeleton = new THREE.Skeleton(newBones, child.skeleton.boneInverses)
49
+ child.bind(child.skeleton, child.bindMatrix)
50
+ }
51
+ })
52
  clone.traverse(child => {
53
  if (child.isMesh) {
54
+ child.castShadow = true
55
  child.receiveShadow = true
56
+ if (child.material) {
57
+ child.material = Array.isArray(child.material)
58
+ ? child.material.map(m => m.clone())
59
+ : child.material.clone()
60
+ }
61
  }
62
  })
63
  return clone
64
  }, [scene])
65
 
66
+ // ── Set up AnimationMixer + register all clips ────────────────────────────
67
  useEffect(() => {
68
+ if (!clonedScene) return
69
+
70
+ // Create mixer on the cloned scene root
71
+ const mixer = new THREE.AnimationMixer(clonedScene)
72
+ mixerRef.current = mixer
73
+
74
+ if (animations && animations.length > 0) {
75
+ const actionMap = {}
76
+ animations.forEach(clip => {
77
+ // Re-target clip to cloned scene bones
78
+ const action = mixer.clipAction(clip, clonedScene)
79
+ action.setLoop(THREE.LoopRepeat, Infinity)
80
+ action.clampWhenFinished = false
81
+ actionMap[clip.name] = action
82
+ })
83
+ actionsRef.current = actionMap
84
+
85
+ // Register animation names to store
86
+ const names = animations.map(a => a.name)
87
+ setModelAnimations(model.id, names)
88
+
89
+ // Auto-play first clip if model has no active animation set
90
+ const activeAnim = useStore.getState().models.find(m => m.id === model.id)?.activeAnimation
91
+ const firstClip = activeAnim || names[0]
92
+ if (firstClip && actionMap[firstClip]) {
93
+ actionMap[firstClip].play()
94
+ currentAnimRef.current = firstClip
95
+ }
96
+
97
+ // Mark as playing
98
+ setModelAnimPlaying(model.id, true)
99
  }
 
100
 
101
+ return () => {
102
+ mixer.stopAllAction()
103
+ mixer.uncacheRoot(clonedScene)
104
+ }
105
+ }, [clonedScene, animations, model.id])
106
+
107
+ // ── React to animation changes from store ─────────────────────────────────
108
  useEffect(() => {
109
+ const mixer = mixerRef.current
110
+ const actions = actionsRef.current
111
+ if (!mixer || !model.activeAnimation) return
112
+
113
+ const targetAnim = model.activeAnimation
114
+ if (currentAnimRef.current === targetAnim && model.animationPlaying) return
115
+
116
+ // Cross-fade to new animation
117
+ const prevAction = actions[currentAnimRef.current]
118
+ const nextAction = actions[targetAnim]
119
+
120
+ if (nextAction) {
121
+ if (prevAction && prevAction !== nextAction) {
122
+ prevAction.fadeOut(0.3)
123
+ nextAction.reset().fadeIn(0.3)
124
+ } else {
125
+ nextAction.reset()
126
+ }
127
+
128
+ if (model.animationPlaying) {
129
+ nextAction.play()
130
+ } else {
131
+ nextAction.play()
132
+ nextAction.paused = true
133
  }
134
+
135
+ nextAction.setEffectiveTimeScale(model.animationSpeed || 1)
136
+ currentAnimRef.current = targetAnim
137
  }
138
+ }, [model.activeAnimation, model.animationPlaying, model.animationSpeed])
139
 
140
+ // ── Play/pause toggle ─────────────────────────────────────────────────────
141
  useEffect(() => {
142
+ const actions = actionsRef.current
143
+ const cur = currentAnimRef.current
144
+ if (!cur || !actions[cur]) return
145
+ actions[cur].paused = !model.animationPlaying
146
+ }, [model.animationPlaying])
 
 
 
 
 
 
147
 
148
+ // ── Speed changes ─────────────────────────────────────────────────────────
149
+ useEffect(() => {
150
+ const actions = actionsRef.current
151
+ Object.values(actions).forEach(a => {
152
+ a.setEffectiveTimeScale(model.animationSpeed || 1)
153
+ })
154
+ }, [model.animationSpeed])
155
+
156
+ // ── Tick mixer ───────────────────────────────────────────────────────────
157
+ useFrame((_, delta) => {
158
+ mixerRef.current?.update(delta)
159
+ })
160
+
161
+ // ── Sync position from store (and keyframe interpolation) ─────────────────
162
  useEffect(() => {
163
  if (!groupRef.current) return
164
+ const store = useStore.getState()
165
+ const interpolated = store.interpolateAtFrame(model.id, currentFrame)
166
+ const src = interpolated || model
167
+ groupRef.current.position.set(...src.position)
168
+ groupRef.current.rotation.set(...src.rotation)
169
+ groupRef.current.scale.set(...src.scale)
170
+ }, [currentFrame, keyframes, model.position, model.rotation, model.scale])
171
 
172
+ // ── Handle TransformControls drag ─────────────────────────────────────────
173
+ const onTransformChange = () => {
174
  if (!groupRef.current) return
175
+ const p = groupRef.current.position
176
+ const r = groupRef.current.rotation
177
  const sc = groupRef.current.scale
178
+ updateModelTransform(model.id, 'position', [p.x, p.y, p.z])
179
+ updateModelTransform(model.id, 'rotation', [r.x, r.y, r.z])
180
+ updateModelTransform(model.id, 'scale', [sc.x, sc.y, sc.z])
181
  }
182
 
183
  if (!model.visible) return null
 
186
  <>
187
  <group
188
  ref={groupRef}
189
+ onClick={e => { e.stopPropagation(); selectModel(model.id) }}
190
  >
191
  <primitive object={clonedScene} />
192
+
193
+ {/* Selection indicator */}
194
  {isSelected && (
195
+ <mesh rotation={[-Math.PI/2, 0, 0]}>
196
+ <ringGeometry args={[0.85, 1.0, 48]} />
197
+ <meshBasicMaterial
198
+ color="#4f8eff"
199
+ transparent opacity={0.7}
200
+ side={THREE.DoubleSide}
201
+ depthWrite={false}
202
+ />
203
  </mesh>
204
  )}
205
  </group>
206
 
207
  {isSelected && (
208
  <TransformControls
 
209
  object={groupRef}
210
  mode={transformMode}
211
+ onObjectChange={onTransformChange}
212
+ size={0.75}
213
  />
214
  )}
215
  </>
 
221
  return (
222
  <>
223
  {models.map(model => (
224
+ <ModelMesh key={`${model.id}-${model.url}`} model={model} />
225
  ))}
226
  </>
227
  )
src/store/useStore.js CHANGED
@@ -54,6 +54,9 @@ const useStore = create(
54
  if (m) m.activeAnimation = animName
55
  }),
56
 
 
 
 
57
  setModelAnimSpeed: (id, speed) => set(state => {
58
  const m = state.models.find(m => m.id === id)
59
  if (m) m.animationSpeed = speed
 
54
  if (m) m.activeAnimation = animName
55
  }),
56
 
57
+ setModelAnimPlaying: (id, playing) => set(state => ({
58
+ models: state.models.map(m => m.id === id ? { ...m, animationPlaying: playing } : m)
59
+ })),
60
  setModelAnimSpeed: (id, speed) => set(state => {
61
  const m = state.models.find(m => m.id === id)
62
  if (m) m.animationSpeed = speed