Spaces:
Sleeping
Sleeping
File size: 6,758 Bytes
a365c65 123a431 43024e4 a365c65 43024e4 a365c65 43024e4 a365c65 123a431 43024e4 6747639 a365c65 6747639 43024e4 123a431 6747639 43024e4 6747639 123a431 43024e4 6747639 43024e4 123a431 43024e4 6747639 a365c65 123a431 43024e4 123a431 6747639 43024e4 123a431 43024e4 123a431 43024e4 6747639 43024e4 123a431 6747639 123a431 6747639 123a431 43024e4 6747639 43024e4 6747639 123a431 43024e4 123a431 6747639 123a431 6747639 43024e4 6747639 123a431 6747639 a365c65 43024e4 6747639 123a431 43024e4 123a431 43024e4 6747639 a365c65 43024e4 a365c65 6747639 a365c65 43024e4 6747639 123a431 43024e4 123a431 a365c65 43024e4 a365c65 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import { useFrame } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import * as THREE from 'three';
import { SceneObject, useStudioStore } from '../store/useStudioStore';
import { getFramesAtTime, interpolateKF } from '../utils/timeline';
interface ModelProps {
obj: SceneObject;
isSelected: boolean;
onClick: () => void;
}
export interface ModelHandle {
group: THREE.Group | null;
}
export const Model = forwardRef<ModelHandle, ModelProps>(({ obj, isSelected, onClick }, ref) => {
const groupRef = useRef<THREE.Group>(null!);
const mixerRef = useRef<THREE.AnimationMixer | null>(null);
const actionsRef = useRef<THREE.AnimationAction[]>([]);
const { updateObject, addError, tracks, playhead } = useStudioStore();
const [scene, setScene] = useState<THREE.Group | null>(null);
const [clips, setClips] = useState<THREE.AnimationClip[]>([]);
const [error, setError] = useState<string | null>(null);
// โโ Expose group ref to parent (TransformControls needs it) โโ
useImperativeHandle(ref, () => ({ group: groupRef.current }), [scene]);
// โโ 1. Load GLB โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
useEffect(() => {
let cancelled = false;
const loader = new GLTFLoader();
loader.load(
obj.url,
(loaded) => {
if (cancelled) return;
setScene(loaded.scene);
setClips(loaded.animations || []);
if (loaded.animations?.length > 0) {
updateObject(obj.id, {
animations: loaded.animations.map((a: THREE.AnimationClip, i: number) => ({
name: a.name || `Anim ${i + 1}`,
duration: a.duration,
index: i,
})),
});
}
},
undefined,
(err: any) => {
if (cancelled) return;
const msg = `Failed to load: ${err?.message || String(err)}`;
setError(msg);
addError(msg);
}
);
return () => { cancelled = true; };
}, [obj.url]); // eslint-disable-line
// โโ 2. Apply materials โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
useEffect(() => {
if (!scene) return;
scene.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
const mats = Array.isArray(mesh.material)
? mesh.material as THREE.MeshStandardMaterial[]
: [mesh.material as THREE.MeshStandardMaterial];
mats.forEach((m) => {
if (!m) return;
m.color = new THREE.Color(obj.color);
m.metalness = obj.metalness;
m.roughness = obj.roughness;
m.envMapIntensity = obj.envMapIntensity;
if (obj.textureUrl) {
new THREE.TextureLoader().load(obj.textureUrl, (tex) => {
m.map = tex;
m.needsUpdate = true;
});
}
m.needsUpdate = true;
});
}
});
}, [scene, obj.color, obj.metalness, obj.roughness, obj.envMapIntensity, obj.textureUrl]);
// โโ 3. Setup mixer โ bound to SAME scene used by primitive โโโ
useEffect(() => {
if (!scene || !clips.length) return;
// Dispose old mixer first
mixerRef.current?.stopAllAction();
mixerRef.current?.uncacheRoot(scene);
const mixer = new THREE.AnimationMixer(scene);
mixerRef.current = mixer;
actionsRef.current = clips.map((clip) => mixer.clipAction(clip));
return () => {
mixer.stopAllAction();
mixer.uncacheRoot(scene);
};
}, [scene, clips]);
// โโ 4. Play / pause / switch GLB animation clip โโโโโโโโโโโโโโ
useEffect(() => {
if (!actionsRef.current.length) return;
// Stop all first
actionsRef.current.forEach((a) => {
a.stop();
a.reset();
});
if (!obj.animPlaying) return;
const action = actionsRef.current[obj.selectedAnimIndex];
if (!action) return;
action.setLoop(
obj.animLoop ? THREE.LoopRepeat : THREE.LoopOnce,
Infinity
);
action.clampWhenFinished = !obj.animLoop;
action.timeScale = obj.animSpeed;
action.reset();
action.play();
}, [obj.animPlaying, obj.selectedAnimIndex, obj.animLoop, obj.animSpeed]);
// โโ 5. Per-frame: mixer tick + timeline interpolation โโโโโโโโ
useFrame((_state, delta) => {
// Tick GLB animation mixer using R3F's delta (no separate clock)
if (mixerRef.current && obj.animPlaying) {
mixerRef.current.update(delta);
}
// Apply keyframe timeline interpolation
if (!groupRef.current) return;
const track = tracks.find(t => t.objectId === obj.id);
if (track && track.keyframes.length >= 1) {
const result = getFramesAtTime(track.keyframes, playhead);
if (result) {
const interp = interpolateKF(result.a, result.b, result.t);
groupRef.current.position.set(...interp.position);
groupRef.current.rotation.set(
THREE.MathUtils.degToRad(interp.rotation[0]),
THREE.MathUtils.degToRad(interp.rotation[1]),
THREE.MathUtils.degToRad(interp.rotation[2]),
);
groupRef.current.scale.set(...interp.scale);
}
}
});
// โโ Fallback box while loading / on error โโโโโโโโโโโโโโโโโโโโ
if (error || !scene) {
return (
<mesh position={obj.position} onClick={onClick}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={error ? '#ff4444' : '#444466'} wireframe={!!error} />
</mesh>
);
}
// If this object has timeline keyframes, position is driven by
// useFrame interpolation above โ don't set it as a prop too
const hasTimeline = tracks.find(t => t.objectId === obj.id)?.keyframes.length;
return (
<group
ref={groupRef}
position={hasTimeline ? undefined : obj.position}
rotation={
hasTimeline
? undefined
: obj.rotation.map(THREE.MathUtils.degToRad) as [number, number, number]
}
scale={hasTimeline ? undefined : obj.scale}
onClick={(e) => { e.stopPropagation(); onClick(); }}
>
{/* Original scene โ NOT cloned, so mixer bindings stay valid */}
<primitive object={scene} />
{isSelected && (
<mesh>
<boxGeometry args={[2, 2, 2]} />
<meshBasicMaterial color="#00d4ff" wireframe transparent opacity={0.15} />
</mesh>
)}
</group>
);
});
Model.displayName = 'Model'; |