File size: 10,799 Bytes
a0e74d4
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
 
afd2779
a0e74d4
afd2779
 
a0e74d4
afd2779
 
 
 
 
a0e74d4
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
 
afd2779
 
 
 
 
 
a0e74d4
 
afd2779
 
a0e74d4
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
 
 
afd2779
a0e74d4
 
 
afd2779
a0e74d4
afd2779
 
 
 
 
a0e74d4
 
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458a46f
afd2779
 
458a46f
afd2779
 
 
a0e74d4
afd2779
a0e74d4
 
 
afd2779
a0e74d4
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
 
 
 
afd2779
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
afd2779
 
 
 
 
a0e74d4
afd2779
 
 
 
 
a0e74d4
afd2779
 
 
 
a0e74d4
 
afd2779
 
 
a0e74d4
 
afd2779
 
 
a0e74d4
 
afd2779
 
 
a0e74d4
 
afd2779
 
 
 
 
 
 
 
 
a0e74d4
 
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
afd2779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458a46f
afd2779
 
 
 
 
 
 
 
 
 
 
 
a0e74d4
afd2779
a0e74d4
 
 
afd2779
 
a0e74d4
afd2779
a0e74d4
afd2779
a0e74d4
 
afd2779
 
 
 
 
 
 
a0e74d4
afd2779
a0e74d4
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
/**
 * PhysicsEngine.jsx β€” Complete Cannon-es physics integration
 *
 * Fixes:
 * - Bodies now automatically registered when models load
 * - Proper sync: physics β†’ Three.js (not overridden by store transforms)
 * - Fixed accumulator so simulation runs at stable 120Hz substeps
 *
 * New physics properties:
 * - velocity, acceleration (applied as forces every frame)
 * - linearDamping (air resistance / viscosity simulation)
 * - angularDamping (rotational resistance)
 * - static friction + dynamic friction (per contact material)
 * - restitution (bounciness)
 * - centerOfMass offset
 * - continuousCollisionDetection for fast-moving objects
 * - Wind force (constant world-space force on all dynamic bodies)
 * - Per-body constant force (engine force for vehicles)
 *
 * Exported API:
 *   registerPhysicsObject(id, mesh, props)
 *   unregisterPhysicsObject(id)
 *   applyImpulse(id, {x,y,z}, point?)
 *   applyForce(id, {x,y,z}, point?)
 *   setBodyVelocity(id, {x,y,z})
 *   setAngularVelocity(id, {x,y,z})
 *   getBodyState(id) β†’ {position, velocity, angularVelocity, sleeping}
 *   getPhysicsWorld()
 */
import { useEffect, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as CANNON from 'cannon-es'
import * as THREE  from 'three'
import useStore    from '../store/useStore'

// ── Globals ───────────────────────────────────────────────────────────────────
let world    = null
const bodies = new Map()   // modelId β†’ CANNON.Body
const meshes = new Map()   // modelId β†’ THREE.Object3D
const forces = new Map()   // modelId β†’ {x,y,z}  constant per-body force

// ── World creation ─────────────────────────────────────────────────────────────
function buildWorld(gravity, cfg = {}) {
  const w = new CANNON.World({
    gravity: new CANNON.Vec3(0, gravity, 0),
  })
  w.broadphase  = new CANNON.SAPBroadphase(w)
  w.allowSleep  = true
  w.solver.iterations = 20        // more iterations = more stable stacks

  // Default contact material
  const def = w.defaultContactMaterial
  def.friction          = cfg.globalFriction    ?? 0.4
  def.restitution       = cfg.globalRestitution ?? 0.3
  def.contactEquationStiffness  = 1e8
  def.contactEquationRelaxation = 3

  // Static ground plane
  const ground = new CANNON.Body({ mass:0, type:CANNON.Body.STATIC })
  ground.addShape(new CANNON.Plane())
  ground.quaternion.setFromEuler(-Math.PI/2, 0, 0)
  ground.position.set(0, 0, 0)
  ground.material = new CANNON.Material('ground')
  ground.material.friction    = cfg.globalFriction    ?? 0.6
  ground.material.restitution = cfg.globalRestitution ?? 0.3
  w.addBody(ground)

  return w
}

function teardown() {
  if (world) {
    ;[...world.bodies].forEach(b => world.removeBody(b))
    world = null
  }
  bodies.clear(); meshes.clear(); forces.clear()
}

// ── Body creation ──────────────────────────────────────────────────────────────
function makeBody(mesh, props = {}) {
  const {
    mass              = 1,
    type              = 'dynamic',
    linearDamping     = 0.3,
    angularDamping    = 0.5,
    friction          = 0.4,
    restitution       = 0.2,
    staticFriction    = 0.6,
    ccdRadius         = 0,        // >0 enables CCD for fast objects
    centerOfMassY     = 0,        // COM offset (lower = more stable car)
    collisionShape    = 'box',    // box | sphere | cylinder
  } = props

  // Compute bounding box from mesh
  const bb   = new THREE.Box3().setFromObject(mesh)
  const size = bb.getSize(new THREE.Vector3())
  const cx   = bb.getCenter(new THREE.Vector3())

  const bodyType =
    type === 'static'    ? CANNON.Body.STATIC    :
    type === 'kinematic' ? CANNON.Body.KINEMATIC :
                           CANNON.Body.DYNAMIC

  const body = new CANNON.Body({
    mass:           bodyType === CANNON.Body.STATIC ? 0 : Math.max(0.01, mass),
    type:           bodyType,
    linearDamping:  Math.min(1, Math.max(0, linearDamping)),
    angularDamping: Math.min(1, Math.max(0, angularDamping)),
    allowSleep:     true,
    sleepSpeedLimit: 0.05,
    sleepTimeLimit:  0.5,
  })

  // Choose collision shape
  if (collisionShape === 'sphere') {
    const r = Math.max(size.x, size.y, size.z) / 2
    body.addShape(new CANNON.Sphere(Math.max(r, 0.05)))
  } else if (collisionShape === 'cylinder') {
    const r = Math.max(size.x, size.z) / 2
    body.addShape(new CANNON.Cylinder(Math.max(r,0.05), Math.max(r,0.05), Math.max(size.y,0.1), 12))
  } else {
    body.addShape(new CANNON.Box(new CANNON.Vec3(
      Math.max(size.x/2, 0.05),
      Math.max(size.y/2, 0.05),
      Math.max(size.z/2, 0.05),
    )))
  }

  // Place at mesh world position exactly β€” no offsets that cause drift
  const wp = new THREE.Vector3()
  mesh.getWorldPosition(wp)
  body.position.set(wp.x, wp.y + size.y/2, wp.z)
  const wq = new THREE.Quaternion()
  mesh.getWorldQuaternion(wq)
  body.quaternion.set(wq.x, wq.y, wq.z, wq.w)

  // Per-body contact material (for friction/restitution with ground)
  const mat = new CANNON.Material()
  mat.friction    = friction
  mat.restitution = restitution
  body.material   = mat

  if (world) {
    const groundMat  = world.bodies[0]?.material
    if (groundMat) {
      const contact = new CANNON.ContactMaterial(groundMat, mat, {
        friction,
        restitution,
        contactEquationStiffness:  1e8,
        contactEquationRelaxation: 3,
        frictionEquationStiffness: 1e8,
      })
      world.addContactMaterial(contact)
    }
  }

  // CCD for fast objects (vehicles)
  if (ccdRadius > 0) {
    body.ccdSpeedThreshold = 1
    body.ccdIterations     = 10
  }

  return body
}

// ── Public API ─────────────────────────────────────────────────────────────────
export function registerPhysicsObject(id, mesh, props) {
  if (!world) return
  if (bodies.has(id)) {
    world.removeBody(bodies.get(id))
    bodies.delete(id); meshes.delete(id); forces.delete(id)
  }
  const body = makeBody(mesh, props)
  world.addBody(body)
  bodies.set(id, body)
  meshes.set(id, mesh)
}

export function unregisterPhysicsObject(id) {
  if (!bodies.has(id)) return
  world?.removeBody(bodies.get(id))
  bodies.delete(id); meshes.delete(id); forces.delete(id)
}

export function applyImpulse(id, imp, pt = {x:0,y:0,z:0}) {
  const b = bodies.get(id); if(!b) return
  b.applyImpulse(new CANNON.Vec3(imp.x,imp.y,imp.z), new CANNON.Vec3(pt.x,pt.y,pt.z))
  b.wakeUp()
}

export function applyForce(id, f, pt = {x:0,y:0,z:0}) {
  const b = bodies.get(id); if(!b) return
  b.applyForce(new CANNON.Vec3(f.x,f.y,f.z), new CANNON.Vec3(pt.x,pt.y,pt.z))
  b.wakeUp()
}

export function setBodyVelocity(id, v) {
  const b = bodies.get(id); if(!b) return
  b.velocity.set(v.x||0, v.y||0, v.z||0); b.wakeUp()
}

export function setAngularVelocity(id, v) {
  const b = bodies.get(id); if(!b) return
  b.angularVelocity.set(v.x||0, v.y||0, v.z||0); b.wakeUp()
}

export function setConstantForce(id, f) {
  if (f) forces.set(id, f)
  else   forces.delete(id)
}

export function getBodyState(id) {
  const b = bodies.get(id); if(!b) return null
  return {
    position:        { x:b.position.x,        y:b.position.y,        z:b.position.z },
    velocity:        { x:b.velocity.x,        y:b.velocity.y,        z:b.velocity.z },
    angularVelocity: { x:b.angularVelocity.x, y:b.angularVelocity.y, z:b.angularVelocity.z },
    sleeping:        b.sleepState === CANNON.Body.SLEEPING,
    speed:           b.velocity.length(),
  }
}

export function teleportBody(id, pos, quat) {
  const b = bodies.get(id); if(!b) return
  if (pos)  b.position.set(pos.x, pos.y, pos.z)
  if (quat) b.quaternion.set(quat.x, quat.y, quat.z, quat.w)
  b.velocity.set(0,0,0); b.angularVelocity.set(0,0,0); b.wakeUp()
}

export function getPhysicsWorld() { return world }
export function getBodies()       { return bodies }

// ── Ticker β€” runs inside R3F Canvas ──────────────────────────────────────────
function Ticker() {
  const accum = useRef(0)
  const FIXED = 1/120  // 120Hz substeps

  useFrame((_, delta) => {
    if (!world) return
    const s   = useStore.getState()
    const wind = s.physicsWind || { x:0, y:0, z:0 }
    const windMag = Math.sqrt(wind.x**2 + wind.y**2 + wind.z**2)

    // Apply constant forces before stepping
    bodies.forEach((body, id) => {
      if (body.type !== CANNON.Body.DYNAMIC) return

      // Per-body constant engine force
      const cf = forces.get(id)
      if (cf) body.applyForce(new CANNON.Vec3(cf.x, cf.y, cf.z), body.position)

      // Global wind force (proportional to exposed area, simplified)
      if (windMag > 0) {
        body.applyForce(new CANNON.Vec3(wind.x, wind.y, wind.z), body.position)
      }
    })

    // Fixed timestep accumulator
    accum.current += Math.min(delta, 0.1)
    while (accum.current >= FIXED) {
      world.step(FIXED)
      accum.current -= FIXED
    }

    // Sync physics β†’ Three.js meshes ONLY (no store writeback - causes feedback loop)
    bodies.forEach((body, id) => {
      const mesh = meshes.get(id)
      if (!mesh || body.type === CANNON.Body.STATIC) return
      mesh.position.set(body.position.x, body.position.y, body.position.z)
      mesh.quaternion.set(body.quaternion.x, body.quaternion.y, body.quaternion.z, body.quaternion.w)
    })
  })

  return null
}

// ── Main export ────────────────────────────────────────────────────────────────
export default function PhysicsEngine() {
  const { physicsEnabled, gravity, physicsConfig } = useStore()

  useEffect(() => {
    if (physicsEnabled) {
      if (!world) world = buildWorld(gravity, physicsConfig || {})
      else        world.gravity.set(0, gravity, 0)
    } else {
      teardown()
    }
    return () => {}
  }, [physicsEnabled, gravity])

  // Update global friction/restitution when config changes
  useEffect(() => {
    if (!world || !physicsConfig) return
    world.defaultContactMaterial.friction    = physicsConfig.globalFriction    ?? 0.4
    world.defaultContactMaterial.restitution = physicsConfig.globalRestitution ?? 0.3
  }, [physicsConfig])

  if (!physicsEnabled) return null
  return <Ticker />
}