Skip to content

JoltPhysicsExample.kt

src/jvmMain/kotlin/afield/examples/JoltPhysicsExample.kt · 374 lines

package afield.examples

import afield.simulation.physics.JoltBodyPick
import afield.simulation.physics.JoltPhysicsWorld
import afield.geometry.path.FLine
import afield.math.linalg.Quat
import afield.math.linalg.Vec3
import afield.math.linalg.vec
import kotlin.math.abs
import kotlin.math.sqrt
import kotlin.random.Random

class JoltPickupLoopRuntime internal constructor

class JoltPickupLoopRuntime internal constructor(
    val frames: Sequence<List<FLine>>,
    private val grabFn: (queryPoint: Vec3, controllerPosition: Vec3, controllerRotation: Quat, maxOutsideDistance: Double) -> JoltBodyPick?,
    private val continueFn: (controllerPosition: Vec3, controllerRotation: Quat) -> Boolean,
    private val releaseFn: () -> String?,
    private val grabbedBodyNameFn: () -> String?,
    private val closeFn: () -> Unit
) : AutoCloseable {

fun grab

    fun grab(
        queryPoint: Vec3,
        controllerPosition: Vec3 = Vec3(queryPoint),
        controllerRotation: Quat = Quat(),
        maxOutsideDistance: Double = 0.08
    ): JoltBodyPick? = grabFn(queryPoint, controllerPosition, controllerRotation, maxOutsideDistance)

fun grab

    fun grab(
        controllerPosition: Vec3,
        controllerRotation: Quat,
        maxOutsideDistance: Double = 0.08
    ): JoltBodyPick? = grab(
        queryPoint = controllerPosition,
        controllerPosition = controllerPosition,
        controllerRotation = controllerRotation,
        maxOutsideDistance = maxOutsideDistance
    )

fun continueGrab(controllerPosition: Vec3, controllerRotation: Quat): Boolean

    fun continueGrab(controllerPosition: Vec3, controllerRotation: Quat): Boolean =
        continueFn(controllerPosition, controllerRotation)

fun 'continue'(controllerPosition: Vec3, controllerRotation: Quat): Boolean

    fun `continue`(controllerPosition: Vec3, controllerRotation: Quat): Boolean =
        continueGrab(controllerPosition, controllerRotation)

fun release(): String? = releaseFn()

fun grabbedBodyName(): String? = grabbedBodyNameFn()

override fun close()

    override fun close() {
        closeFn()
    }
}

object JoltPhysicsExample

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        if (args.contains("--off-center")) {
            runOffCenterCollisionDemo()
        } else {
            runStressTestChaos()
        }
    }

fun runStressTestChaos(seed: Int = 1337, bodyCount: Int = 20)

body — 68 lines, from line 64
    fun runStressTestChaos(seed: Int = 1337, bodyCount: Int = 20) = sequence {
        val random = Random(seed)
        val planeA = PlaneDef(nx = 0.24, ny = 0.97, nz = 0.10, constant = -0.90)
        val planeB = PlaneDef(nx = -0.18, ny = 0.98, nz = -0.21, constant = -1.70)

        JoltPhysicsWorld().use { engine ->
            engine.planeComponents("planeA", planeA.nx, planeA.ny, planeA.nz, planeA.constant) {
                friction = 0.5
                restitution = 0.8
            }
            engine.planeComponents("planeB", planeB.nx, planeB.ny, planeB.nz, planeB.constant) {
                friction = 0.0
                restitution = 0.3
            }

            val spawns = generateNonOverlappingCuboidSpawns(
                count = bodyCount,
                random = random,
                planeA = planeA,
                planeB = planeB
            )
            check(!hasAnySpawnOverlap(spawns)) { "Internal error: overlapping spawn set." }
            println("spawned ${spawns.size} cuboids (seed=$seed), initial overlap check: PASS")

            spawns.forEachIndexed { index, spawn ->
                engine.cuboid("box_${index.toString().padStart(2, '0')}", spawn.width, spawn.height, spawn.depth) {
                    position = vec(spawn.x, spawn.y, spawn.z)
                    friction = random.nextDouble(0.05, 0.06)
                    restitution = random.nextDouble(0.02, 0.90)
                    linearDamping = random.nextDouble(0.0, 0.03)
                    angularDamping = random.nextDouble(0.0, 0.03)
                    velocity = vec(random.nextDouble(-0.9, 0.9), 0.0, random.nextDouble(-0.9, 0.9))
                    angularVelocity = vec(
                        random.nextDouble(-0.6, 0.6),
                        random.nextDouble(-0.6, 0.6),
                        random.nextDouble(-0.6, 0.6)
                    )
                }
            }

            val dt = 1.0 / 120.0
            val steps = 120 * 10
            repeat(steps) { i ->
                val states = engine.step(dt)
                yield(engine.debugWireLines3D(axisLength = 0.18f))
                if (i % 12 == 0) {
                    val t = i * dt
                    val boxes = states.filter { it.name.startsWith("box_") }
                    val avgHeight = boxes.map { it.position.y.toDouble() }.average()
                    val avgSpin = boxes.map { sqrt(it.angularVelocity.lengthSquared().toDouble()) }.average()
                    val topSpin = boxes.sortedByDescending { it.angularVelocity.lengthSquared() }.take(8)

                    println("t=${"%.2f".format(t)} avgY=${avgHeight.format3()} avgSpin=${avgSpin.format3()}")
                    for (state in topSpin) {
                        println(
                            "  ${state.name}: " +
                                "p=(${state.position.x.format3()}, ${state.position.y.format3()}, ${state.position.z.format3()}) " +
                                "q=(${state.rotation.x.format3()}, ${state.rotation.y.format3()}, ${state.rotation.z.format3()}, ${state.rotation.w.format3()}) " +
                                "w=(${state.angularVelocity.x.format3()}, ${state.angularVelocity.y.format3()}, ${state.angularVelocity.z.format3()})"
                        )
                    }
                }
            }

            val debugLines = engine.debugWireLines3D(axisLength = 0.18f)
            println("debugWireLines3D produced ${debugLines.size} lines")
        }
    }

fun runStressTestPickupLoop

body — 241 lines, from line 133
    fun runStressTestPickupLoop(
        seed: Int = 1337,
        bodyCount: Int = 20,
        dt: Double = 1.0 / 120.0,
        floorPlaneHeight: Float = 0f
    ): JoltPickupLoopRuntime {
        require(bodyCount > 0) { "bodyCount must be > 0, got $bodyCount" }
        require(dt > 0.0) { "dt must be > 0, got $dt" }

        val random = Random(seed)
        val ground = PlaneDef(nx = 0.0, ny = 1.0, nz = 0.0, constant = -floorPlaneHeight.toDouble())
        val engine = JoltPhysicsWorld()
        val simulationLock = Any()
        var closed = false

        synchronized(simulationLock) {
            engine.planeComponents("ground", ground.nx, ground.ny, ground.nz, ground.constant) {
                friction = 0.9
                restitution = 0.0
            }

            val spawns = generateNonOverlappingCuboidSpawns(
                count = bodyCount,
                random = random,
                planeA = ground,
                planeB = ground
            )
            check(!hasAnySpawnOverlap(spawns)) { "Internal error: overlapping spawn set." }

            spawns.forEachIndexed { index, spawn ->
                engine.cuboid("box_${index.toString().padStart(2, '0')}", spawn.width, spawn.height, spawn.depth) {
                    position = vec(spawn.x, spawn.y, spawn.z)
                    friction = random.nextDouble(0.04, 0.10)
                    restitution = random.nextDouble(0.45, 0.90)
                    linearDamping = random.nextDouble(0.0, 0.02)
                    angularDamping = random.nextDouble(0.0, 0.02)
                    velocity = vec(random.nextDouble(-1.0, 1.0), 0.0, random.nextDouble(-1.0, 1.0))
                    angularVelocity = vec(
                        random.nextDouble(-21.7, 21.7),
                        random.nextDouble(-0.7, 0.7),
                        random.nextDouble(-0.7, 0.7)
                    )
                }
            }
        }

        fun closeRuntime() {
            synchronized(simulationLock) {
                if (closed) return
                engine.releaseRigidGrab()
                engine.close()
                closed = true
            }
        }

        val frames = sequence {
            try {
                while (true) {
                    val lines = synchronized(simulationLock) {
                        if (closed) return@synchronized null
                        engine.step(dt)
                        engine.debugWireLines3D(axisLength = 0.16f)
                    } ?: break
                    yield(lines)
                }
            } finally {
                closeRuntime()
            }
        }

        return JoltPickupLoopRuntime(
            frames = frames,
            grabFn = { queryPoint, controllerPosition, controllerRotation, maxOutsideDistance ->
                synchronized(simulationLock) {
                    if (closed) {
                        return@synchronized null
                    }
                    engine.pickAndBeginRigidGrab(
                        queryPoint = queryPoint,
                        controllerPosition = controllerPosition,
                        controllerRotation = controllerRotation,
                        maxOutsideDistance = maxOutsideDistance
                    )
                }
            },
            continueFn = { controllerPosition, controllerRotation ->
                synchronized(simulationLock) {
                    if (closed) {
                        return@synchronized false
                    }
                    engine.updateRigidGrabTransform(controllerPosition, controllerRotation)
                }
            },
            releaseFn = {
                synchronized(simulationLock) {
                    if (closed) {
                        return@synchronized null
                    }
                    engine.releaseRigidGrab()
                }
            },
            grabbedBodyNameFn = {
                synchronized(simulationLock) {
                    if (closed) {
                        return@synchronized null
                    }
                    engine.grabbedBodyName
                }
            },
            closeFn = ::closeRuntime
        )
    }

    private fun runOffCenterCollisionDemo() {
        JoltPhysicsWorld().use { engine ->
            engine.plane("ground") {
                friction = 0.05
                restitution = 0.9
            }

            engine.cuboid("targetBox", 1.0, 1.0, 1.0) {
                position = vec(0.0, 0.55, 0.0)
                friction = 0.01
                restitution = 0.5
            }

            engine.sphere("strikerSphere", 0.35) {
                position = vec(-3.2, 1.05, 0.65)
                velocity = vec(12.0, 0.0, -1.5)
                friction = 0.01
                restitution = 0.8
            }

            val dt = 1.0 / 60.0
            val steps = 60 * 4
            repeat(steps) { i ->
                val states = engine.step(dt)
                if (i % 6 == 0) {
                    val t = i * dt
                    println("t=${"%.2f".format(t)}")
                    for (state in states.filter { it.name != "ground" }) {
                        println(
                            "  ${state.name}: " +
                                "p=(${state.position.x.format3()}, ${state.position.y.format3()}, ${state.position.z.format3()}) " +
                                "v=(${state.linearVelocity.x.format3()}, ${state.linearVelocity.y.format3()}, ${state.linearVelocity.z.format3()}) " +
                                "q=(${state.rotation.x.format3()}, ${state.rotation.y.format3()}, ${state.rotation.z.format3()}, ${state.rotation.w.format3()}) " +
                                "w=(${state.angularVelocity.x.format3()}, ${state.angularVelocity.y.format3()}, ${state.angularVelocity.z.format3()})"
                        )
                    }
                }
            }

            val debugLines = engine.debugWireLines3D(axisLength = 0.4f)
            println("debugWireLines3D produced ${debugLines.size} lines")
        }
    }
}

private fun Float.format3(): String = "%.3f".format(this)
private fun Double.format3(): String = "%.3f".format(this)

private data class PlaneDef(val nx: Double, val ny: Double, val nz: Double, val constant: Double)

private data class CuboidSpawn(
    val width: Double,
    val height: Double,
    val depth: Double,
    val x: Double,
    val y: Double,
    val z: Double
)

private fun generateNonOverlappingCuboidSpawns(
    count: Int,
    random: Random,
    planeA: PlaneDef,
    planeB: PlaneDef
): List<CuboidSpawn> {
    val result = ArrayList<CuboidSpawn>(count)
    val spawnPadding = 0.08

    repeat(count) { index ->
        var chosen: CuboidSpawn? = null
        var attempts = 0
        while (chosen == null && attempts < 30_000) {
            attempts += 1

            val width = random.nextDouble(0.35, 0.95)
            val height = random.nextDouble(0.35, 2.60)
            val depth = random.nextDouble(0.35, 0.95)
            val x = random.nextDouble(-4.4, 4.4)
            val z = random.nextDouble(-3.6, 3.6)
            val y = random.nextDouble(4.8, 43.6)

            val candidate = CuboidSpawn(width, height, depth, x, y, z)
            val basePlaneY = maxOf(
                planeHeightAt(planeA, x, z),
                planeHeightAt(planeB, x, z)
            )
            val candidateBottom = candidate.y - candidate.height * 0.5
            if (candidateBottom <= basePlaneY + 0.5) {
                continue
            }
            if (result.any { cuboidsOverlap(candidate, it, spawnPadding) }) {
                continue
            }
            chosen = candidate
        }

        check(chosen != null) {
            "Unable to place cuboid $index without overlap after $attempts attempts."
        }
        result += chosen
    }

    return result
}

private fun hasAnySpawnOverlap(spawns: List<CuboidSpawn>): Boolean {
    for (i in 0 until spawns.size) {
        for (j in i + 1 until spawns.size) {
            if (cuboidsOverlap(spawns[i], spawns[j], margin = 0.0)) {
                return true
            }
        }
    }
    return false
}

private fun cuboidsOverlap(a: CuboidSpawn, b: CuboidSpawn, margin: Double): Boolean {
    val overlapX = abs(a.x - b.x) < (a.width + b.width) * 0.5 + margin
    val overlapY = abs(a.y - b.y) < (a.height + b.height) * 0.5 + margin
    val overlapZ = abs(a.z - b.z) < (a.depth + b.depth) * 0.5 + margin
    return overlapX && overlapY && overlapZ
}

private fun planeHeightAt(plane: PlaneDef, x: Double, z: Double): Double {
    val denom = plane.ny
    check(abs(denom) > 1e-6) { "Plane ny must be non-zero, got ${plane.ny}" }
    return -(plane.nx * x + plane.nz * z + plane.constant) / denom
}