studio3d / src /components /Model.tsx
varunm2004's picture
Update src/components/Model.tsx
6747639 verified
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';