/**
* ModelManager.jsx
*
* Root cause of all issues was a feedback loop:
* TransformControls drag → onChange → updateModelTransform (store) →
* model.position changes → useEffect fires → position.set() on group →
* fights the drag in progress → TC drops selection → mesh snaps back
*
* Fix: use a isDragging ref. When TC is dragging, the store→mesh sync
* useEffect is COMPLETELY skipped. TC owns the mesh during drag.
* Only on drag END (onMouseUp) do we write to the store.
*
* Also fixes keyframe workflow:
* 1. Click model → selected (TC appears)
* 2. Drag model to new position (TC owns mesh, no store updates mid-drag)
* 3. Release → position written to store ONCE
* 4. Click "Add Keyframe" → stores current model.position correctly
* 5. Scrub timeline → interpolation works
*/
import { useEffect, useRef, useMemo, useCallback } from 'react'
import { useGLTF } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
import * as THREE from 'three'
import useStore from '../store/useStore'
import { orbitControlsRef } from './Scene'
/* ── lazy physics import ───────────────────────────────────────────────────── */
let _reg=null, _unreg=null
async function physFns() {
if (!_reg) { const m=await import('./PhysicsEngine'); _reg=m.registerPhysicsObject; _unreg=m.unregisterPhysicsObject }
return { reg:_reg, unreg:_unreg }
}
/* ── TransformControls wrapper using vanilla Three.js (not @react-three/drei)
Drei's TransformControls fires onChange every frame which is the root
cause of the drag feedback loop. The vanilla TC fires events correctly. ── */
function useTCGizmo(groupRef, mode, onChange, snap) {
const { camera, gl, scene } = useThree()
const tcRef = useRef(null)
useEffect(() => {
if (!groupRef.current) return
const tc = new TransformControls(camera, gl.domElement)
tc.attach(groupRef.current)
tc.setMode(mode || 'translate')
tc.size = 0.85
// Snap
if (snap?.translate) tc.translationSnap = snap.translate
if (snap?.rotate) tc.rotationSnap = snap.rotate * (Math.PI / 180)
if (snap?.scale) tc.scaleSnap = snap.scale
scene.add(tc)
tcRef.current = tc
// Drag end → write to store once
const onEnd = () => {
if (!groupRef.current) return
onChange()
}
tc.addEventListener('mouseUp', onEnd)
tc.addEventListener('touchEnd', onEnd)
return () => {
tc.removeEventListener('mouseUp', onEnd)
tc.removeEventListener('touchEnd', onEnd)
tc.detach()
scene.remove(tc)
tc.dispose()
tcRef.current = null
}
}, [groupRef.current, mode, camera, gl, scene]) // remount when mode or object changes
// Disable OrbitControls while dragging
useEffect(() => {
const tc = tcRef.current
if (!tc) return
const onDragStart = () => {
// Find and disable orbit controls
scene.traverse(obj => { if (obj.isOrbitControls) obj.enabled = false })
}
const onDragEnd = () => {
scene.traverse(obj => { if (obj.isOrbitControls) obj.enabled = true })
}
tc.addEventListener('dragging-changed', e => {
if (e.value) onDragStart(); else onDragEnd()
})
}, [tcRef.current, scene])
useFrame(() => { tcRef.current?.update?.() })
return tcRef
}
function ModelMesh({ model }) {
const groupRef = useRef()
const mixerRef = useRef(null)
const actionsRef = useRef({})
const curAnimRef = useRef(null)
const physActiveRef = useRef(false)
const tcRef = useRef(null) // vanilla TC instance
const { camera, gl, scene } = useThree()
const gltf = useGLTF(model.url)
const sceneGltf = gltf?.scene
const animations = Array.isArray(gltf?.animations) ? gltf.animations : []
const {
selectedModelId, transformMode,
updateModelTransform, setModelAnimations, setModelAnimPlaying,
selectModel, currentFrame, keyframes,
snapEnabled, snapTranslate, snapRotate, snapScale,
physicsEnabled, physicsConnected, modelPhysics,
} = useStore()
const isSelected = selectedModelId === model.id
const isRenderMode = useStore(s => !!(s.isRenderMode || s.isExporting))
/* ── Clone scene ─────────────────────────────────────────────────────────── */
const clonedScene = useMemo(() => {
if (!sceneGltf) return null
const clone = sceneGltf.clone(true)
const srcB=[], dstB=[]
sceneGltf.traverse(n => { if (n.isBone) srcB.push(n) })
clone.traverse(n => { if (n.isBone) dstB.push(n) })
clone.traverse(child => {
if (child.isSkinnedMesh && child.skeleton) {
const bones = child.skeleton.bones.map(b => { const i=srcB.indexOf(b); return i!==-1?dstB[i]:b })
child.skeleton = new THREE.Skeleton(bones, child.skeleton.boneInverses)
child.bind(child.skeleton, child.bindMatrix)
}
if (child.isMesh) {
child.castShadow = child.receiveShadow = true
if (child.material)
child.material = Array.isArray(child.material) ? child.material.map(m=>m.clone()) : child.material.clone()
}
})
return clone
}, [sceneGltf])
/* ── AnimationMixer ──────────────────────────────────────────────────────── */
useEffect(() => {
if (!clonedScene || animations.length === 0) return
const mixer = new THREE.AnimationMixer(clonedScene)
mixerRef.current = mixer
const map = {}
animations.forEach(clip => {
if (!clip?.name) return
const a = mixer.clipAction(clip, clonedScene)
a.setLoop(THREE.LoopRepeat, Infinity)
a.clampWhenFinished = false
map[clip.name] = a
})
actionsRef.current = map
const names = Object.keys(map)
if (names.length > 0) {
setModelAnimations(model.id, names)
const cur = useStore.getState().models.find(m=>m.id===model.id)?.activeAnimation
const first = cur || names[0]
if (map[first]) { map[first].play(); curAnimRef.current = first }
setModelAnimPlaying(model.id, true)
}
return () => {
mixer.stopAllAction(); mixer.uncacheRoot(clonedScene)
mixerRef.current=null; actionsRef.current={}; curAnimRef.current=null
}
}, [clonedScene, animations.length, model.id])
/* ── Anim clip switch ────────────────────────────────────────────────────── */
useEffect(() => {
const mixer=mixerRef.current, acts=actionsRef.current
if (!mixer || !model.activeAnimation) return
const target = model.activeAnimation
if (curAnimRef.current === target) {
const cur = acts[target]
if (cur) { cur.paused=!model.animationPlaying; cur.setEffectiveTimeScale(model.animationSpeed??1) }
return
}
const prev=acts[curAnimRef.current], next=acts[target]
if (!next) return
if (prev && prev!==next) { prev.fadeOut(0.25); next.reset().fadeIn(0.25) } else next.reset()
if (model.animationPlaying) next.play(); else { next.play(); next.paused=true }
next.setEffectiveTimeScale(model.animationSpeed ?? 1)
curAnimRef.current = target
}, [model.activeAnimation, model.animationPlaying, model.animationSpeed])
useFrame((_, dt) => { mixerRef.current?.update(dt) })
/* ── Vanilla TransformControls — created/destroyed when selection changes ── */
useEffect(() => {
// Clean up any existing TC
if (tcRef.current) {
tcRef.current.detach()
scene.remove(tcRef.current)
tcRef.current.dispose()
tcRef.current = null
}
if (!isSelected || isRenderMode || !groupRef.current) return
const tc = new TransformControls(camera, gl.domElement)
tc.attach(groupRef.current)
tc.setMode(transformMode || 'translate')
tc.size = 0.85
// Snap settings
if (snapEnabled) {
tc.translationSnap = snapTranslate || null
tc.rotationSnap = snapRotate ? (snapRotate * Math.PI / 180) : null
tc.scaleSnap = snapScale || null
}
scene.add(tc)
tcRef.current = tc
// Write to store ONCE when drag ends — not during drag
const onMouseUp = () => {
if (!groupRef.current) return
const p = groupRef.current.position
const r = groupRef.current.rotation
const sc = groupRef.current.scale
updateModelTransform(model.id, 'position', [p.x, p.y, p.z])
updateModelTransform(model.id, 'rotation', [r.x, r.y, r.z])
updateModelTransform(model.id, 'scale', [sc.x, sc.y, sc.z])
}
// Disable OrbitControls during drag so camera doesn't rotate while moving model
const onDragging = (e) => {
if (orbitControlsRef.current) orbitControlsRef.current.enabled = !e.value
}
tc.addEventListener('mouseUp', onMouseUp)
tc.addEventListener('touchEnd', onMouseUp)
tc.addEventListener('dragging-changed', onDragging)
return () => {
tc.removeEventListener('mouseUp', onMouseUp)
tc.removeEventListener('touchEnd', onMouseUp)
tc.removeEventListener('dragging-changed', onDragging)
tc.detach()
scene.remove(tc)
tc.dispose()
tcRef.current = null
// Re-enable orbit on cleanup
if (orbitControlsRef.current) orbitControlsRef.current.enabled = true
}
}, [isSelected, isRenderMode, transformMode, snapEnabled, snapTranslate, snapRotate, snapScale,
camera, gl, scene, model.id, updateModelTransform])
// Update TC mode when toolbar mode changes without remounting
useEffect(() => {
tcRef.current?.setMode(transformMode || 'translate')
}, [transformMode])
// Update snap when snap settings change
useEffect(() => {
const tc = tcRef.current
if (!tc) return
tc.translationSnap = snapEnabled ? (snapTranslate||null) : null
tc.rotationSnap = snapEnabled ? (snapRotate?(snapRotate*Math.PI/180):null) : null
tc.scaleSnap = snapEnabled ? (snapScale||null) : null
}, [snapEnabled, snapTranslate, snapRotate, snapScale])
useFrame(() => { tcRef.current?.update?.() })
/* ── Physics ─────────────────────────────────────────────────────────────── */
useEffect(() => {
if (!physicsEnabled||!physicsConnected||!groupRef.current) { physActiveRef.current=false; return }
const props = modelPhysics?.[model.id] ?? {}
physFns().then(({reg}) => {
if (!groupRef.current) return
reg(model.id, groupRef.current, {
mass:props.mass??1, type:props.type??'static',
linearDamping:props.damping??0.3, angularDamping:props.angularDamping??0.5,
friction:props.friction??0.4, restitution:props.restitution??0.2,
centerOfMassY:props.centerOfMassY??0, collisionShape:props.collisionShape??'box', ccdRadius:props.ccdRadius??0,
})
physActiveRef.current = true
})
return () => { physFns().then(({unreg})=>{ unreg(model.id); physActiveRef.current=false }) }
}, [physicsEnabled, physicsConnected, model.id, JSON.stringify(modelPhysics?.[model.id])])
/* ── Material overrides ──────────────────────────────────────────────────── */
useEffect(() => {
if (!clonedScene) return
const mat = model.materialOverride
clonedScene.traverse(child => {
if (!child.isMesh || !child.material) return
const mats = Array.isArray(child.material) ? child.material : [child.material]
mats.forEach(m => {
if (mat?.color !== undefined) m.color?.set(mat.color)
if (mat?.roughness !== undefined) m.roughness = mat.roughness
if (mat?.metalness !== undefined) m.metalness = mat.metalness
if (mat?.opacity !== undefined) { m.opacity=mat.opacity; m.transparent=mat.opacity<1 }
if (mat?.wireframe !== undefined) m.wireframe = !!mat.wireframe
if (!mat) { m.wireframe=false; m.opacity=1; m.transparent=false }
m.needsUpdate = true
})
})
}, [model.materialOverride, clonedScene])
/* ── Shadows ─────────────────────────────────────────────────────────────── */
useEffect(() => {
if (!clonedScene) return
clonedScene.traverse(child => {
if (child.isMesh) { child.castShadow=model.castShadow??true; child.receiveShadow=model.receiveShadow??true }
})
}, [model.castShadow, model.receiveShadow, clonedScene])
/* ── Store → mesh sync
Only runs when NOT being dragged by TC.
TC writing to store on mouseUp means model.position changes AFTER drag ends.
Then this effect runs and confirms the position — no conflict. ── */
useEffect(() => {
if (!groupRef.current || physActiveRef.current) return
// Don't reset while TC is actively dragging
if (tcRef.current?.dragging) return
const s = useStore.getState()
const src = s.interpolateAtFrame(model.id, currentFrame) || model
groupRef.current.position.set(...(src.position || [0,0,0]))
groupRef.current.rotation.set(...(src.rotation || [0,0,0]))
groupRef.current.scale.set( ...(src.scale || [1,1,1]))
}, [currentFrame, keyframes, model.position, model.rotation, model.scale])
if (!model.visible || !clonedScene) return null
return (
{ e.stopPropagation(); selectModel(model.id) }}>
{/* Selection ring */}
{isSelected && !isRenderMode && (
)}
)
}
export default function ModelManager() {
const models = useStore(s => s.models)
if (!models || models.length === 0) return null
return <>{models.map(m => )}>
}