Spaces:
Sleeping
Sleeping
| 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'; |