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(({ obj, isSelected, onClick }, ref) => { const groupRef = useRef(null!); const mixerRef = useRef(null); const actionsRef = useRef([]); const { updateObject, addError, tracks, playhead } = useStudioStore(); const [scene, setScene] = useState(null); const [clips, setClips] = useState([]); const [error, setError] = useState(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 ( ); } // 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 ( { e.stopPropagation(); onClick(); }} > {/* Original scene — NOT cloned, so mixer bindings stay valid */} {isSelected && ( )} ); }); Model.displayName = 'Model';