File size: 14,584 Bytes
b6aaffe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0fffd55
b6aaffe
 
 
0fffd55
 
b6aaffe
458a46f
b6aaffe
0fffd55
39353da
0fffd55
 
458a46f
cad548d
b6aaffe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cad548d
0fffd55
 
 
 
 
b6aaffe
 
 
d9dc1a9
39353da
b6aaffe
0fffd55
cad548d
 
 
d9dc1a9
 
72b08e2
5a33ffc
0fffd55
cad548d
b6aaffe
 
cad548d
b6aaffe
cad548d
b6aaffe
 
 
 
0fffd55
d9dc1a9
 
b6aaffe
 
 
d9dc1a9
cad548d
b6aaffe
 
 
cad548d
 
 
b6aaffe
cad548d
b6aaffe
cad548d
b6aaffe
 
 
 
458a46f
39353da
b6aaffe
 
 
 
458a46f
b6aaffe
 
 
 
 
 
 
 
 
 
 
 
d9dc1a9
0fffd55
d9dc1a9
b6aaffe
cad548d
0fffd55
b6aaffe
 
 
 
0fffd55
39353da
 
0fffd55
458a46f
b6aaffe
0fffd55
b6aaffe
 
 
cad548d
b6aaffe
cad548d
b6aaffe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afd2779
0fffd55
b6aaffe
 
458a46f
b6aaffe
0fffd55
 
 
 
458a46f
b6aaffe
afd2779
0fffd55
b6aaffe
afd2779
b6aaffe
72b08e2
 
b6aaffe
72b08e2
b6aaffe
 
72b08e2
b6aaffe
 
 
 
 
 
 
72b08e2
 
b6aaffe
72b08e2
b6aaffe
72b08e2
 
b6aaffe
 
 
 
72b08e2
b6aaffe
 
 
 
cad548d
b6aaffe
 
 
 
 
 
 
 
 
 
cad548d
b6aaffe
cad548d
 
b6aaffe
 
 
 
 
 
 
 
cad548d
b6aaffe
cad548d
 
 
 
 
b6aaffe
0fffd55
cad548d
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
/**
 * 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 (
    <group ref={groupRef} onClick={e => { e.stopPropagation(); selectModel(model.id) }}>
      <primitive object={clonedScene} />
      {/* Selection ring */}
      {isSelected && !isRenderMode && (
        <mesh rotation={[-Math.PI/2, 0, 0]}>
          <ringGeometry args={[0.9, 1.05, 64]} />
          <meshBasicMaterial color="#4f8eff" transparent opacity={0.5} side={THREE.DoubleSide} depthWrite={false} />
        </mesh>
      )}
    </group>
  )
}

export default function ModelManager() {
  const models = useStore(s => s.models)
  if (!models || models.length === 0) return null
  return <>{models.map(m => <ModelMesh key={`${m.id}__${m.url}`} model={m} />)}</>
}