Spaces:
Sleeping
fix: animation detection + AI 404 errors
Browse filesModelManager.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 +21 -8
- src/components/AnimationPlayer.jsx +147 -55
- src/components/CommandInterpreter.js +6 -0
- src/components/ModelManager.jsx +159 -59
- src/store/useStore.js +3 -0
|
@@ -17,12 +17,19 @@ import {
|
|
| 17 |
} from './CommandInterpreter'
|
| 18 |
|
| 19 |
const BASE_URL = 'https://openrouter.ai/api/v1'
|
|
|
|
|
|
|
| 20 |
const FREE_MODELS = [
|
| 21 |
-
'
|
| 22 |
-
'google/gemma-3-4b-it:free',
|
| 23 |
'mistralai/mistral-7b-instruct:free',
|
| 24 |
-
'
|
| 25 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 158 |
-
|
|
|
|
| 159 |
return callAI(messages, apiKey, modelIdx+1)
|
| 160 |
}
|
| 161 |
if (!res.ok) {
|
| 162 |
const txt = await res.text()
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return callAI(messages, apiKey, modelIdx+1)
|
| 165 |
-
|
|
|
|
| 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 }
|
|
@@ -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
|
| 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={{
|
| 16 |
-
{/*
|
| 17 |
-
<div style={{
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
|
| 26 |
-
{/*
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</div>
|
| 40 |
|
| 41 |
-
{/* Controls */}
|
| 42 |
-
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
}}>{playing ? 'βΈ' : 'βΆ'}</button>
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
|
|
|
|
| 57 |
<div style={{ display:'flex', alignItems:'center', gap:6, flex:1 }}>
|
| 58 |
-
<
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
{model.
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 74 |
-
|
| 75 |
-
<div style={{
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
-
|
| 81 |
-
|
| 82 |
|
| 83 |
return (
|
| 84 |
<div>
|
| 85 |
-
<div style={{
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
</div>
|
| 89 |
-
|
|
|
|
| 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>
|
|
@@ -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
|
|
@@ -1,88 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useEffect, useRef, useMemo } from 'react'
|
| 2 |
-
import { useGLTF, useAnimations } from '@react-three/drei'
|
| 3 |
-
import { useFrame }
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
import useStore from '../store/useStore'
|
| 7 |
|
| 8 |
function ModelMesh({ model }) {
|
| 9 |
-
const groupRef
|
| 10 |
-
const
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 18 |
-
interpolateAtFrame, keyframes
|
| 19 |
} = useStore()
|
| 20 |
|
| 21 |
const isSelected = selectedModelId === model.id
|
| 22 |
|
| 23 |
-
// Clone scene
|
| 24 |
const clonedScene = useMemo(() => {
|
|
|
|
| 25 |
const clone = scene.clone(true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
clone.traverse(child => {
|
| 27 |
if (child.isMesh) {
|
| 28 |
-
child.castShadow
|
| 29 |
child.receiveShadow = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
})
|
| 32 |
return clone
|
| 33 |
}, [scene])
|
| 34 |
|
| 35 |
-
//
|
| 36 |
useEffect(() => {
|
| 37 |
-
if (
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
-
}, [animations, model.id])
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
useEffect(() => {
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
-
}, [model.activeAnimation, model.animationPlaying, model.animationSpeed
|
| 54 |
|
| 55 |
-
//
|
| 56 |
useEffect(() => {
|
| 57 |
-
const
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
useEffect(() => {
|
| 71 |
if (!groupRef.current) return
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
//
|
| 78 |
-
const
|
| 79 |
if (!groupRef.current) return
|
| 80 |
-
const
|
| 81 |
-
const
|
| 82 |
const sc = groupRef.current.scale
|
| 83 |
-
updateModelTransform(model.id, 'position', [
|
| 84 |
-
updateModelTransform(model.id, 'rotation', [
|
| 85 |
-
updateModelTransform(model.id, 'scale',
|
| 86 |
}
|
| 87 |
|
| 88 |
if (!model.visible) return null
|
|
@@ -91,25 +186,30 @@ function ModelMesh({ model }) {
|
|
| 91 |
<>
|
| 92 |
<group
|
| 93 |
ref={groupRef}
|
| 94 |
-
onClick={
|
| 95 |
>
|
| 96 |
<primitive object={clonedScene} />
|
| 97 |
-
|
|
|
|
| 98 |
{isSelected && (
|
| 99 |
-
<mesh>
|
| 100 |
-
<ringGeometry args={[0.
|
| 101 |
-
<meshBasicMaterial
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</mesh>
|
| 103 |
)}
|
| 104 |
</group>
|
| 105 |
|
| 106 |
{isSelected && (
|
| 107 |
<TransformControls
|
| 108 |
-
ref={transformRef}
|
| 109 |
object={groupRef}
|
| 110 |
mode={transformMode}
|
| 111 |
-
onObjectChange={
|
| 112 |
-
size={0.
|
| 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 |
)
|
|
@@ -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
|