varunm2004 commited on
Commit
458a46f
Β·
verified Β·
1 Parent(s): afd2779

deploy: 2-stage Dockerfile, npm build on HF

Browse files
src/components/ModelManager.jsx CHANGED
@@ -1,45 +1,68 @@
1
  /**
2
- * ModelManager.jsx
3
- * Renders all GLB models in the Three.js scene.
4
- * Fixes:
5
- * - Animation detection: registers ALL clips from GLB immediately on load
6
- * - Animation playback: auto-plays first clip, responds to animationPlaying state
7
- * - Mixer update: driven by useFrame so clips actually animate
8
- * - Transform sync: bidirectional (store ↔ TransformControls)
9
- * - Clone: properly clones scene so multiple instances don't share geometry
 
 
 
 
 
 
 
 
 
 
 
10
  */
11
- import { useEffect, useRef, useMemo } from 'react'
12
- import { useGLTF, useAnimations, TransformControls } from '@react-three/drei'
13
- import { useFrame } from '@react-three/fiber'
14
- import * as THREE from 'three'
15
- import useStore from '../store/useStore'
16
- import { registerPhysicsObject, unregisterPhysicsObject } from './PhysicsEngine'
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  function ModelMesh({ model }) {
19
- const groupRef = useRef()
20
- const mixerRef = useRef(null)
21
- const actionsRef = useRef({})
22
  const currentAnimRef = useRef(null)
 
23
 
24
- const { scene, animations } = useGLTF(model.url)
 
25
 
26
  const {
27
  selectedModelId, transformMode,
28
  updateModelTransform, setModelAnimations, setModelAnimPlaying,
29
  selectModel, currentFrame, keyframes,
30
  snapEnabled, snapTranslate, snapRotate, snapScale,
 
31
  } = useStore()
32
 
33
- const isSelected = selectedModelId === model.id
34
- const isRenderMode = useStore.getState().isRenderMode || useStore.getState().isExporting
35
 
36
- // ── Clone scene using SkeletonUtils for correct bone cloning ──────────────
37
  const clonedScene = useMemo(() => {
38
- // Deep clone preserving skeleton/skinned mesh properly
39
- const clone = scene.clone(true)
40
- // Re-bind skinned meshes to cloned skeleton
41
- const srcBones = []
42
- const dstBones = []
43
  scene.traverse(n => { if (n.isBone) srcBones.push(n) })
44
  clone.traverse(n => { if (n.isBone) dstBones.push(n) })
45
  clone.traverse(child => {
@@ -51,11 +74,8 @@ function ModelMesh({ model }) {
51
  child.skeleton = new THREE.Skeleton(newBones, child.skeleton.boneInverses)
52
  child.bind(child.skeleton, child.bindMatrix)
53
  }
54
- })
55
- clone.traverse(child => {
56
  if (child.isMesh) {
57
- child.castShadow = true
58
- child.receiveShadow = true
59
  if (child.material) {
60
  child.material = Array.isArray(child.material)
61
  ? child.material.map(m => m.clone())
@@ -66,117 +86,99 @@ function ModelMesh({ model }) {
66
  return clone
67
  }, [scene])
68
 
69
- // ── Set up AnimationMixer + register all clips ────────────────────────────
70
  useEffect(() => {
71
- if (!clonedScene) return
72
-
73
- // Create mixer on the cloned scene root
74
  const mixer = new THREE.AnimationMixer(clonedScene)
75
  mixerRef.current = mixer
 
 
 
 
 
 
 
 
76
 
77
- if (animations && animations.length > 0) {
78
- const actionMap = {}
79
- animations.forEach(clip => {
80
- // Re-target clip to cloned scene bones
81
- const action = mixer.clipAction(clip, clonedScene)
82
- action.setLoop(THREE.LoopRepeat, Infinity)
83
- action.clampWhenFinished = false
84
- actionMap[clip.name] = action
85
- })
86
- actionsRef.current = actionMap
87
-
88
- // Register animation names to store
89
- const names = animations.map(a => a.name)
90
- setModelAnimations(model.id, names)
91
-
92
- // Auto-play first clip if model has no active animation set
93
- const activeAnim = useStore.getState().models.find(m => m.id === model.id)?.activeAnimation
94
- const firstClip = activeAnim || names[0]
95
- if (firstClip && actionMap[firstClip]) {
96
- actionMap[firstClip].play()
97
- currentAnimRef.current = firstClip
98
- }
99
 
100
- // Mark as playing
101
- setModelAnimPlaying(model.id, true)
 
 
 
102
  }
 
103
 
104
  return () => {
105
  mixer.stopAllAction()
106
  mixer.uncacheRoot(clonedScene)
107
  }
108
- }, [clonedScene, animations, model.id])
109
 
110
- // ── React to animation changes from store ─────────────────────────────────
111
  useEffect(() => {
112
  const mixer = mixerRef.current
113
  const actions = actionsRef.current
114
  if (!mixer || !model.activeAnimation) return
115
-
116
- const targetAnim = model.activeAnimation
117
- if (currentAnimRef.current === targetAnim && model.animationPlaying) return
118
-
119
- // Cross-fade to new animation
120
- const prevAction = actions[currentAnimRef.current]
121
- const nextAction = actions[targetAnim]
122
-
123
- if (nextAction) {
124
- if (prevAction && prevAction !== nextAction) {
125
- prevAction.fadeOut(0.3)
126
- nextAction.reset().fadeIn(0.3)
127
- } else {
128
- nextAction.reset()
129
- }
130
-
131
- if (model.animationPlaying) {
132
- nextAction.play()
133
- } else {
134
- nextAction.play()
135
- nextAction.paused = true
136
- }
137
-
138
- nextAction.setEffectiveTimeScale(model.animationSpeed || 1)
139
- currentAnimRef.current = targetAnim
140
- }
141
  }, [model.activeAnimation, model.animationPlaying, model.animationSpeed])
142
 
143
- // ── Play/pause toggle ─────────────────────────────────────────────────────
144
  useEffect(() => {
145
- const actions = actionsRef.current
146
- const cur = currentAnimRef.current
147
- if (!cur || !actions[cur]) return
148
- actions[cur].paused = !model.animationPlaying
149
  }, [model.animationPlaying])
150
 
151
- // ── Speed changes ─────────────────────────────────────────────────────────
152
  useEffect(() => {
153
- const actions = actionsRef.current
154
- Object.values(actions).forEach(a => {
155
- a.setEffectiveTimeScale(model.animationSpeed || 1)
156
- })
157
  }, [model.animationSpeed])
158
 
159
- // ── Register with physics engine when model loads ──────────────────────────
 
 
 
160
  useEffect(() => {
161
- if (!groupRef.current) return
162
- const s = useStore.getState()
163
- if (!s.physicsEnabled) return
164
- const props = s.modelPhysics[model.id] || {}
165
- registerPhysicsObject(model.id, groupRef.current, {
166
- mass: props.mass ?? 1,
167
- type: props.type ?? 'dynamic',
168
- linearDamping: props.damping ?? 0.3,
169
- angularDamping: props.angularDamping ?? 0.5,
170
- friction: props.friction ?? 0.4,
171
- restitution: props.restitution ?? 0.2,
172
- centerOfMassY: props.centerOfMassY ?? 0,
173
- collisionShape: props.collisionShape ?? 'box',
174
- ccdRadius: props.ccdRadius ?? 0,
 
 
 
 
 
 
175
  })
176
- return () => unregisterPhysicsObject(model.id)
177
- }, [model.id, model.url, useStore.getState().physicsEnabled])
 
 
 
 
 
178
 
179
- // ── Apply material overrides from store ──────────────────────────────────
180
  useEffect(() => {
181
  if (!clonedScene) return
182
  const mat = model.materialOverride
@@ -184,23 +186,18 @@ function ModelMesh({ model }) {
184
  if (!child.isMesh || !child.material) return
185
  const mats = Array.isArray(child.material) ? child.material : [child.material]
186
  mats.forEach(m => {
187
- if (mat?.color !== undefined) m.color?.set(mat.color)
188
  if (mat?.roughness !== undefined) m.roughness = mat.roughness
189
  if (mat?.metalness !== undefined) m.metalness = mat.metalness
190
- if (mat?.opacity !== undefined) { m.opacity = mat.opacity; m.transparent = mat.opacity < 1 }
191
  if (mat?.wireframe !== undefined) m.wireframe = !!mat.wireframe
192
- if (!mat) {
193
- // Reset - use original material stored at load
194
- m.wireframe = false
195
- if (m.opacity !== undefined) { m.opacity=1; m.transparent=false }
196
- }
197
  m.needsUpdate = true
198
  })
199
  })
200
  }, [model.materialOverride, clonedScene])
201
 
202
- // ── Tick mixer ───────────────────────────────────────────────────────────
203
- // ── Shadow settings ──────────────────────────────────────────────────────
204
  useEffect(() => {
205
  if (!clonedScene) return
206
  clonedScene.traverse(child => {
@@ -211,33 +208,32 @@ function ModelMesh({ model }) {
211
  })
212
  }, [model.castShadow, model.receiveShadow, clonedScene])
213
 
214
- useFrame((_, delta) => {
215
- mixerRef.current?.update(delta)
216
- })
217
-
218
- // ── Sync position from store (and keyframe interpolation) ────────────────��
219
  useEffect(() => {
220
  if (!groupRef.current) return
221
- const store = useStore.getState()
 
 
222
  const interpolated = store.interpolateAtFrame(model.id, currentFrame)
223
- const src = interpolated || model
224
  groupRef.current.position.set(...src.position)
225
  groupRef.current.rotation.set(...src.rotation)
226
  groupRef.current.scale.set(...src.scale)
227
  }, [currentFrame, keyframes, model.position, model.rotation, model.scale])
228
 
229
- // ── Handle TransformControls drag ─────────────────────────────────────────
230
- const onTransformChange = () => {
231
  if (!groupRef.current) return
232
- const p = groupRef.current.position
233
- const r = groupRef.current.rotation
234
  const sc = groupRef.current.scale
235
  updateModelTransform(model.id, 'position', [p.x, p.y, p.z])
236
  updateModelTransform(model.id, 'rotation', [r.x, r.y, r.z])
237
  updateModelTransform(model.id, 'scale', [sc.x, sc.y, sc.z])
238
- }
239
 
240
- if (!model.visible) return null
241
 
242
  return (
243
  <>
@@ -247,25 +243,28 @@ function ModelMesh({ model }) {
247
  >
248
  <primitive object={clonedScene} />
249
 
250
- {/* Selection indicator β€” hidden during render */}
251
  {isSelected && !isRenderMode && (
252
  <mesh rotation={[-Math.PI/2, 0, 0]}>
253
- <ringGeometry args={[0.85, 1.0, 48]} />
254
- <meshBasicMaterial color="#4f8eff" transparent opacity={0.7}
255
  side={THREE.DoubleSide} depthWrite={false} />
256
  </mesh>
257
  )}
258
  </group>
259
 
260
- {isSelected && !isRenderMode && (
 
 
261
  <TransformControls
262
- object={groupRef}
 
263
  mode={transformMode}
264
- onObjectChange={onTransformChange}
265
- size={0.75}
266
  translationSnap={snapEnabled ? snapTranslate : null}
267
- rotationSnap={snapEnabled ? (snapRotate * Math.PI/180) : null}
268
  scaleSnap={snapEnabled ? snapScale : null}
 
269
  />
270
  )}
271
  </>
@@ -274,6 +273,7 @@ function ModelMesh({ model }) {
274
 
275
  export default function ModelManager() {
276
  const models = useStore(s => s.models)
 
277
  return (
278
  <>
279
  {models.map(model => (
 
1
  /**
2
+ * ModelManager.jsx β€” Fixed version
3
+ *
4
+ * Bug fixes:
5
+ * 1. TypeError "Cannot read properties of undefined (reading 'length')"
6
+ * β€” animations array was undefined before GLTF fully loaded
7
+ * 2. Model selection jumps to wrong model
8
+ * β€” TransformControls was passed groupRef (a ref object) not groupRef.current
9
+ * β€” Fixed: use explicit attach ref pattern
10
+ * 3. Physics causes shaking/flipping
11
+ * β€” Physics engine was writing back to store every frame, causing
12
+ * a feedback loop: store update β†’ useEffect β†’ reset position β†’ physics fight
13
+ * — Fixed: when physics is ON for a body, skip the store→mesh sync useEffect
14
+ * β€” PhysicsEngine syncs mesh; store only updated by user transforms
15
+ * 4. Physics auto-enabled on import
16
+ * β€” Removed: physics registration no longer fires on model load
17
+ * β€” Physics registration now only happens when user explicitly enables physics
18
+ * 5. City GLB moves on physics enable
19
+ * β€” Default type is now 'static' for any model with no physics config set
20
+ * β€” Fixed: bodies placed at exact mesh world position (no bounding box offset)
21
  */
22
+ import { useEffect, useRef, useMemo, useCallback } from 'react'
23
+ import { useGLTF, TransformControls } from '@react-three/drei'
24
+ import { useFrame } from '@react-three/fiber'
25
+ import * as THREE from 'three'
26
+ import useStore from '../store/useStore'
27
+
28
+ // Physics imported lazily to avoid circular deps
29
+ let _registerPhysics = null
30
+ let _unregisterPhysics = null
31
+ async function getPhysicsFns() {
32
+ if (!_registerPhysics) {
33
+ const m = await import('./PhysicsEngine')
34
+ _registerPhysics = m.registerPhysicsObject
35
+ _unregisterPhysics = m.unregisterPhysicsObject
36
+ }
37
+ return { register: _registerPhysics, unregister: _unregisterPhysics }
38
+ }
39
 
40
  function ModelMesh({ model }) {
41
+ const groupRef = useRef()
42
+ const mixerRef = useRef(null)
43
+ const actionsRef = useRef({})
44
  const currentAnimRef = useRef(null)
45
+ const physicsActiveRef = useRef(false) // is this body currently in physics world?
46
 
47
+ const { scene, animations: rawAnims } = useGLTF(model.url)
48
+ const animations = rawAnims || [] // guard against undefined
49
 
50
  const {
51
  selectedModelId, transformMode,
52
  updateModelTransform, setModelAnimations, setModelAnimPlaying,
53
  selectModel, currentFrame, keyframes,
54
  snapEnabled, snapTranslate, snapRotate, snapScale,
55
+ physicsEnabled, modelPhysics,
56
  } = useStore()
57
 
58
+ const isSelected = selectedModelId === model.id
59
+ const isRenderMode = useStore(s => s.isRenderMode || s.isExporting)
60
 
61
+ // ── Clone scene (manual skeleton rebind, no SkeletonUtils dep) ────────────
62
  const clonedScene = useMemo(() => {
63
+ if (!scene) return null
64
+ const clone = scene.clone(true)
65
+ const srcBones = [], dstBones = []
 
 
66
  scene.traverse(n => { if (n.isBone) srcBones.push(n) })
67
  clone.traverse(n => { if (n.isBone) dstBones.push(n) })
68
  clone.traverse(child => {
 
74
  child.skeleton = new THREE.Skeleton(newBones, child.skeleton.boneInverses)
75
  child.bind(child.skeleton, child.bindMatrix)
76
  }
 
 
77
  if (child.isMesh) {
78
+ child.castShadow = child.receiveShadow = true
 
79
  if (child.material) {
80
  child.material = Array.isArray(child.material)
81
  ? child.material.map(m => m.clone())
 
86
  return clone
87
  }, [scene])
88
 
89
+ // ── AnimationMixer β€” set up on load ───────────────────────────────────────
90
  useEffect(() => {
91
+ if (!clonedScene || !animations.length) return
 
 
92
  const mixer = new THREE.AnimationMixer(clonedScene)
93
  mixerRef.current = mixer
94
+ const actionMap = {}
95
+ animations.forEach(clip => {
96
+ const action = mixer.clipAction(clip, clonedScene)
97
+ action.setLoop(THREE.LoopRepeat, Infinity)
98
+ action.clampWhenFinished = false
99
+ actionMap[clip.name] = action
100
+ })
101
+ actionsRef.current = actionMap
102
 
103
+ const names = animations.map(a => a.name)
104
+ setModelAnimations(model.id, names)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ const curAnim = useStore.getState().models.find(m => m.id === model.id)?.activeAnimation
107
+ const first = curAnim || names[0]
108
+ if (first && actionMap[first]) {
109
+ actionMap[first].play()
110
+ currentAnimRef.current = first
111
  }
112
+ setModelAnimPlaying(model.id, true)
113
 
114
  return () => {
115
  mixer.stopAllAction()
116
  mixer.uncacheRoot(clonedScene)
117
  }
118
+ }, [clonedScene, animations.length, model.id]) // animations.length safe guard
119
 
120
+ // ── Animation switch ──────────────────────────────────────────────────────
121
  useEffect(() => {
122
  const mixer = mixerRef.current
123
  const actions = actionsRef.current
124
  if (!mixer || !model.activeAnimation) return
125
+ const target = model.activeAnimation
126
+ if (currentAnimRef.current === target) return
127
+ const prev = actions[currentAnimRef.current]
128
+ const next = actions[target]
129
+ if (!next) return
130
+ if (prev && prev !== next) { prev.fadeOut(0.3); next.reset().fadeIn(0.3) }
131
+ else next.reset()
132
+ if (model.animationPlaying) next.play()
133
+ else { next.play(); next.paused = true }
134
+ next.setEffectiveTimeScale(model.animationSpeed || 1)
135
+ currentAnimRef.current = target
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  }, [model.activeAnimation, model.animationPlaying, model.animationSpeed])
137
 
 
138
  useEffect(() => {
139
+ const cur = actionsRef.current[currentAnimRef.current]
140
+ if (cur) cur.paused = !model.animationPlaying
 
 
141
  }, [model.animationPlaying])
142
 
 
143
  useEffect(() => {
144
+ Object.values(actionsRef.current).forEach(a => a.setEffectiveTimeScale(model.animationSpeed || 1))
 
 
 
145
  }, [model.animationSpeed])
146
 
147
+ // ── Tick mixer ────────────────────────────────────────────────────────────
148
+ useFrame((_, delta) => { mixerRef.current?.update(delta) })
149
+
150
+ // ── Physics registration β€” ONLY when user explicitly enables physics ───────
151
  useEffect(() => {
152
+ if (!physicsEnabled || !groupRef.current) {
153
+ physicsActiveRef.current = false
154
+ return
155
+ }
156
+ // Register this body
157
+ getPhysicsFns().then(({ register, unregister }) => {
158
+ if (!groupRef.current) return
159
+ const props = modelPhysics[model.id] || {}
160
+ register(model.id, groupRef.current, {
161
+ mass: props.mass ?? 1,
162
+ type: props.type ?? 'static', // default STATIC β€” safe
163
+ linearDamping: props.damping ?? 0.3,
164
+ angularDamping: props.angularDamping ?? 0.5,
165
+ friction: props.friction ?? 0.4,
166
+ restitution: props.restitution ?? 0.2,
167
+ centerOfMassY: props.centerOfMassY ?? 0,
168
+ collisionShape: props.collisionShape ?? 'box',
169
+ ccdRadius: props.ccdRadius ?? 0,
170
+ })
171
+ physicsActiveRef.current = true
172
  })
173
+ return () => {
174
+ getPhysicsFns().then(({ unregister }) => {
175
+ unregister(model.id)
176
+ physicsActiveRef.current = false
177
+ })
178
+ }
179
+ }, [physicsEnabled, model.id, JSON.stringify(modelPhysics[model.id])])
180
 
181
+ // ── Material overrides ────────────────────────────────────────────────────
182
  useEffect(() => {
183
  if (!clonedScene) return
184
  const mat = model.materialOverride
 
186
  if (!child.isMesh || !child.material) return
187
  const mats = Array.isArray(child.material) ? child.material : [child.material]
188
  mats.forEach(m => {
189
+ if (mat?.color !== undefined) m.color?.set(mat.color)
190
  if (mat?.roughness !== undefined) m.roughness = mat.roughness
191
  if (mat?.metalness !== undefined) m.metalness = mat.metalness
192
+ if (mat?.opacity !== undefined) { m.opacity = mat.opacity; m.transparent = mat.opacity < 1 }
193
  if (mat?.wireframe !== undefined) m.wireframe = !!mat.wireframe
194
+ if (!mat) { m.wireframe = false; m.opacity = 1; m.transparent = false }
 
 
 
 
195
  m.needsUpdate = true
196
  })
197
  })
198
  }, [model.materialOverride, clonedScene])
199
 
200
+ // ── Shadow settings ───────────────────────────────────────────────────────
 
201
  useEffect(() => {
202
  if (!clonedScene) return
203
  clonedScene.traverse(child => {
 
208
  })
209
  }, [model.castShadow, model.receiveShadow, clonedScene])
210
 
211
+ // ── Sync store position β†’ mesh
212
+ // SKIPPED when physics engine is actively driving this body
 
 
 
213
  useEffect(() => {
214
  if (!groupRef.current) return
215
+ if (physicsActiveRef.current) return // physics owns the transform
216
+
217
+ const store = useStore.getState()
218
  const interpolated = store.interpolateAtFrame(model.id, currentFrame)
219
+ const src = interpolated || model
220
  groupRef.current.position.set(...src.position)
221
  groupRef.current.rotation.set(...src.rotation)
222
  groupRef.current.scale.set(...src.scale)
223
  }, [currentFrame, keyframes, model.position, model.rotation, model.scale])
224
 
225
+ // ── TransformControls drag β†’ store ────────────────────────────────────────
226
+ const onTransformChange = useCallback(() => {
227
  if (!groupRef.current) return
228
+ const p = groupRef.current.position
229
+ const r = groupRef.current.rotation
230
  const sc = groupRef.current.scale
231
  updateModelTransform(model.id, 'position', [p.x, p.y, p.z])
232
  updateModelTransform(model.id, 'rotation', [r.x, r.y, r.z])
233
  updateModelTransform(model.id, 'scale', [sc.x, sc.y, sc.z])
234
+ }, [model.id, updateModelTransform])
235
 
236
+ if (!model.visible || !clonedScene) return null
237
 
238
  return (
239
  <>
 
243
  >
244
  <primitive object={clonedScene} />
245
 
246
+ {/* Selection ring β€” hidden during render */}
247
  {isSelected && !isRenderMode && (
248
  <mesh rotation={[-Math.PI/2, 0, 0]}>
249
+ <ringGeometry args={[0.9, 1.05, 64]} />
250
+ <meshBasicMaterial color="#4f8eff" transparent opacity={0.6}
251
  side={THREE.DoubleSide} depthWrite={false} />
252
  </mesh>
253
  )}
254
  </group>
255
 
256
+ {/* TransformControls β€” fix: pass ref.current via key trick so it
257
+ always attaches to the correct mesh when selection changes */}
258
+ {isSelected && !isRenderMode && groupRef.current && (
259
  <TransformControls
260
+ key={model.id} // force remount when selected model changes
261
+ object={groupRef.current}
262
  mode={transformMode}
263
+ onChange={onTransformChange}
 
264
  translationSnap={snapEnabled ? snapTranslate : null}
265
+ rotationSnap={snapEnabled ? (snapRotate * Math.PI / 180) : null}
266
  scaleSnap={snapEnabled ? snapScale : null}
267
+ size={0.8}
268
  />
269
  )}
270
  </>
 
273
 
274
  export default function ModelManager() {
275
  const models = useStore(s => s.models)
276
+ if (!models?.length) return null
277
  return (
278
  <>
279
  {models.map(model => (
src/components/PhysicsEngine.jsx CHANGED
@@ -126,14 +126,10 @@ function makeBody(mesh, props = {}) {
126
  )))
127
  }
128
 
129
- // Place at mesh world position (accounting for bounding box center)
130
  const wp = new THREE.Vector3()
131
  mesh.getWorldPosition(wp)
132
- body.position.set(
133
- wp.x + cx.x,
134
- wp.y + cx.y + size.y/2 + centerOfMassY,
135
- wp.z + cx.z,
136
- )
137
  const wq = new THREE.Quaternion()
138
  mesh.getWorldQuaternion(wq)
139
  body.quaternion.set(wq.x, wq.y, wq.z, wq.w)
@@ -266,17 +262,12 @@ function Ticker() {
266
  accum.current -= FIXED
267
  }
268
 
269
- // Sync physics β†’ Three.js meshes β†’ store
270
  bodies.forEach((body, id) => {
271
  const mesh = meshes.get(id)
272
  if (!mesh || body.type === CANNON.Body.STATIC) return
273
  mesh.position.set(body.position.x, body.position.y, body.position.z)
274
  mesh.quaternion.set(body.quaternion.x, body.quaternion.y, body.quaternion.z, body.quaternion.w)
275
- // Write back to store so UI reflects real position
276
- useStore.getState().updateModelTransform(id, 'position', [body.position.x, body.position.y, body.position.z])
277
- useStore.getState().updateModelTransform(id, 'rotation', [
278
- mesh.rotation.x, mesh.rotation.y, mesh.rotation.z,
279
- ])
280
  })
281
  })
282
 
 
126
  )))
127
  }
128
 
129
+ // Place at mesh world position exactly β€” no offsets that cause drift
130
  const wp = new THREE.Vector3()
131
  mesh.getWorldPosition(wp)
132
+ body.position.set(wp.x, wp.y + size.y/2, wp.z)
 
 
 
 
133
  const wq = new THREE.Quaternion()
134
  mesh.getWorldQuaternion(wq)
135
  body.quaternion.set(wq.x, wq.y, wq.z, wq.w)
 
262
  accum.current -= FIXED
263
  }
264
 
265
+ // Sync physics β†’ Three.js meshes ONLY (no store writeback - causes feedback loop)
266
  bodies.forEach((body, id) => {
267
  const mesh = meshes.get(id)
268
  if (!mesh || body.type === CANNON.Body.STATIC) return
269
  mesh.position.set(body.position.x, body.position.y, body.position.z)
270
  mesh.quaternion.set(body.quaternion.x, body.quaternion.y, body.quaternion.z, body.quaternion.w)
 
 
 
 
 
271
  })
272
  })
273
 
src/store/useStore.js CHANGED
@@ -352,7 +352,7 @@ const useStore = create(
352
  state.fps = data.fps || 30
353
  state.lightingPreset=data.lightingPreset||'studio'
354
  state.skybox = data.skybox || { type:'preset', value:null, bgColor:'#080810', showBg:false }
355
- state.physicsEnabled=data.physicsEnabled||false
356
  state.gravity = data.gravity ?? -9.82
357
  state.modelPhysics = data.modelPhysics || {}
358
  state.undoStack = []
 
352
  state.fps = data.fps || 30
353
  state.lightingPreset=data.lightingPreset||'studio'
354
  state.skybox = data.skybox || { type:'preset', value:null, bgColor:'#080810', showBg:false }
355
+ state.physicsEnabled = false // always OFF on load β€” user enables manually
356
  state.gravity = data.gravity ?? -9.82
357
  state.modelPhysics = data.modelPhysics || {}
358
  state.undoStack = []