Skip to content

GrowingGraphSimdVerletExample.kt

src/jvmMain/kotlin/afield/examples/GrowingGraphSimdVerletExample.kt · 826 lines

package and imports — 319 lines, from line 1
package afield.examples

import afield.simulation.physics.OctreeBarnesHutPool
import afield.simulation.physics.SimdVerlet
import afield.runtime.lifecycle.generator2
import afield.geometry.path.FLine
import afield.ai.learning.*
import afield.ai.retrieval.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.animation.motion.*
import afield.app.commands.*
import afield.app.diagnostics.*
import afield.audio.analysis.*
import afield.audio.synth.*
import afield.data.collections.*
import afield.data.formats.*
import afield.data.graph.*
import afield.data.sorting.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.geometry.mesh.*
import afield.geometry.path.*
import afield.geometry.spatial.*
import afield.graphics.color.*
import afield.graphics.interaction.*
import afield.graphics.render.*
import afield.graphics.shader.*
import afield.graphics.text.*
import afield.graphics.vulkan.buffers.*
import afield.io.osc.*
import afield.io.process.*
import afield.lang.lambda.*
import afield.lang.python.*
import afield.lang.remotejs.*
import afield.lang.stacksl.*
import afield.math.autodiff.*
import afield.math.control.*
import afield.math.estimation.*
import afield.math.functions.*
import afield.math.linalg.*
import afield.math.optimization.*
import afield.math.statistics.*
import afield.math.units.*
import afield.media.image.*
import afield.media.video.*
import afield.runtime.execution.*
import afield.runtime.languages.*
import afield.runtime.lifecycle.*
import afield.simulation.agents.*
import afield.simulation.automata.*
import afield.simulation.particles.*
import afield.study.build.*
import afield.study.evidence.*
import afield.study.search.*
import afield.study.source.*
import afield.study.ui.*
import afield.ui.controls.*
import afield.ui.output.*
import afield.ui.visualization.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
import kotlin.math.max
import kotlin.math.sqrt

private class ConstraintBuffer(initialSize: Int) {
    private var capacity: Int = max(0, initialSize)

    var n0: IntArray = IntArray(capacity)
    var n1: IntArray = IntArray(capacity)
    var n2: IntArray = IntArray(capacity)
    var r0: FloatArray = FloatArray(capacity)
    var r1: FloatArray = FloatArray(capacity)
    var r2: FloatArray = FloatArray(capacity)
    var s0: FloatArray = FloatArray(capacity)
    var s1: FloatArray = FloatArray(capacity)
    var s2: FloatArray = FloatArray(capacity)

    private var constraints: SimdVerlet.DirectedConstraints3 =
        SimdVerlet.DirectedConstraints3(n0, n1, n2, r0, r1, r2, s0, s1, s2)

    private fun grownCapacity(minCapacity: Int): Int {
        var next = max(16, max(1, capacity))
        while (next < minCapacity) {
            next += max(1, next / 2)
        }
        return next
    }

    fun ensureSize(newSize: Int): SimdVerlet.DirectedConstraints3 {
        require(newSize >= 0) { "newSize must be >= 0" }
        if (newSize <= capacity) return constraints

        capacity = grownCapacity(newSize)
        n0 = IntArray(capacity)
        n1 = IntArray(capacity)
        n2 = IntArray(capacity)
        r0 = FloatArray(capacity)
        r1 = FloatArray(capacity)
        r2 = FloatArray(capacity)
        s0 = FloatArray(capacity)
        s1 = FloatArray(capacity)
        s2 = FloatArray(capacity)
        constraints = SimdVerlet.DirectedConstraints3(n0, n1, n2, r0, r1, r2, s0, s1, s2)
        return constraints
    }
}

private fun updateConstraintsAll(
    graph: GrowingGraph,
    sim: SimdVerlet,
    buffers: ConstraintBuffer,
    stiffness: Float
) {
    val state = sim.state
    val count = state.size
    val n0 = buffers.n0
    val n1 = buffers.n1
    val n2 = buffers.n2
    val r0 = buffers.r0
    val r1 = buffers.r1
    val r2 = buffers.r2
    val s0 = buffers.s0
    val s1 = buffers.s1
    val s2 = buffers.s2

    for (i in 0 until count) {
        val a = graph.neighborAt(i, 0)
        val b = graph.neighborAt(i, 1)
        val c = graph.neighborAt(i, 2)

        n0[i] = a
        n1[i] = b
        n2[i] = c

        val dx0 = state.x[a] - state.x[i]
        val dy0 = state.y[a] - state.y[i]
        val dz0 = state.z[a] - state.z[i]
        val dx1 = state.x[b] - state.x[i]
        val dy1 = state.y[b] - state.y[i]
        val dz1 = state.z[b] - state.z[i]
        val dx2 = state.x[c] - state.x[i]
        val dy2 = state.y[c] - state.y[i]
        val dz2 = state.z[c] - state.z[i]

        r0[i] = max(4.5f, 0.9f*sqrt(dx0 * dx0 + dy0 * dy0 + dz0 * dz0))
        r1[i] = max(4.5f, 0.9f*sqrt(dx1 * dx1 + dy1 * dy1 + dz1 * dz1))
        r2[i] = max(4.5f, 0.9f*sqrt(dx2 * dx2 + dy2 * dy2 + dz2 * dz2))
//        r0[i] = 5f
//        r1[i] = 5f
//        r2[i] = 5f

        s0[i] = stiffness
        s1[i] = stiffness
        s2[i] = stiffness
    }
}

private fun addNewNodes(
    graph: GrowingGraph,
    sim: SimdVerlet,
    newNodeStart: Int,
    newNodeCount: Int
) {
    if (newNodeCount <= 0) return
    val state = sim.state
    val startIndex = sim.addZeroed(newNodeCount)
    require(startIndex == newNodeStart) { "sim index drifted: $startIndex != $newNodeStart" }

    val end = newNodeStart + newNodeCount
    for (i in newNodeStart until end) {
        var cx = 0f
        var cy = 0f
        var cz = 0f
        var c = 0
        for (slot in 0 until 3) {
            val n = graph.neighborAt(i, slot)
            if (n < newNodeStart) {
                cx += state.x[n]
                cy += state.y[n]
                cz += state.z[n]
                c++
            }
        }
        if (c > 0) {
            val inv = 1f / c.toFloat()
            cx *= inv
            cy *= inv
            cz *= inv
        }

        val jitter = 0.05f * ((i * 97) % 3 - 1)
        val x = cx + jitter
        val y = cy - jitter
        val z = cz + jitter * 0.5f

        state.x[i] = x
        state.y[i] = y
        state.z[i] = z
        state.px[i] = x
        state.py[i] = y
        state.pz[i] = z
    }
}

private fun buildEdgeFLine(graph: GrowingGraph, sim: SimdVerlet): FLine {
    val line = FLine()
    val state = sim.state
    val count = state.size
    for (i in 0 until count) {
        for (slot in 0 until 3) {
            val j = graph.neighborAt(i, slot)
            if (j <= i) continue
            line.moveTo(vec(state.x[i], state.y[i], state.z[i]))
            line.lineTo(vec(state.x[j], state.y[j], state.z[j]))
        }
    }
    return line
}

private fun buildEdgeFLine(constraints: SimdVerlet.DirectedConstraints3, sim: SimdVerlet): FLine {
    val line = FLine()
    val state = sim.state
    val count = state.size
    val n0 = constraints.n0
    val n1 = constraints.n1
    val n2 = constraints.n2

    fun emitEdge(i: Int, j: Int) {
        if (j <= i || j >= count) return
        line.moveTo(vec(state.x[i], state.y[i], state.z[i]))
        line.lineTo(vec(state.x[j], state.y[j], state.z[j]))
    }

    for (i in 0 until count) {
        emitEdge(i, n0[i])
        emitEdge(i, n1[i])
        emitEdge(i, n2[i])
    }
    return line
}

private fun buildPointFLine(sim: SimdVerlet, size: Double = 5.0): FLine {
    val line = FLine()
    line.pointed = true
    line.pointSize = size
    val state = sim.state
    val count = state.size
    for (i in 0 until count) {
        line.moveTo(vec(state.x[i], state.y[i], state.z[i]))
    }
    return line
}

private fun scheduleGrow(
    executor: ExecutorService,
    graph: GrowingGraph
): Future<GrowingGraph.GrowDelta> = executor.submit<GrowingGraph.GrowDelta> { graph.grow() }

private class SequencePerf(private val reportEveryFrames: Int) {
    private var frames = 0
    private var growthNs = 0L
    private var simNs = 0L
    private var edgeBuildNs = 0L
    private var pointBuildNs = 0L
    private var totalNs = 0L
    private var growthAppliedFrames = 0
    private var maxNodes = 0

    private fun ms(ns: Long): Double = ns.toDouble() / 1_000_000.0

    fun record(
        nodeCount: Int,
        growthApplied: Boolean,
        growthNs: Long,
        simNs: Long,
        edgeBuildNs: Long,
        pointBuildNs: Long,
        totalNs: Long
    ) {
        frames++
        if (growthApplied) growthAppliedFrames++
        if (nodeCount > maxNodes) maxNodes = nodeCount
        this.growthNs += growthNs
        this.simNs += simNs
        this.edgeBuildNs += edgeBuildNs
        this.pointBuildNs += pointBuildNs
        this.totalNs += totalNs

        if (frames % reportEveryFrames == 0) {
            val f = reportEveryFrames.toDouble()
            val avgGrowth = ms(this.growthNs) / f
            val avgSim = ms(this.simNs) / f
            val avgEdges = ms(this.edgeBuildNs) / f
            val avgPoints = ms(this.pointBuildNs) / f
            val avgTotal = ms(this.totalNs) / f
            val growthPct = if (avgTotal > 0.0) avgGrowth * 100.0 / avgTotal else 0.0
            val simPct = if (avgTotal > 0.0) avgSim * 100.0 / avgTotal else 0.0
            val edgePct = if (avgTotal > 0.0) avgEdges * 100.0 / avgTotal else 0.0
            val pointPct = if (avgTotal > 0.0) avgPoints * 100.0 / avgTotal else 0.0
            println(
                "perf/$reportEveryFrames frames: avgMs total=$avgTotal sim=$avgSim " +
                    "growth=$avgGrowth edges=$avgEdges points=$avgPoints " +
                    "share[%] sim=$simPct growth=$growthPct edges=$edgePct points=$pointPct " +
                    "growthAppliedFrames=$growthAppliedFrames maxNodes=$maxNodes"
            )
            this.growthNs = 0L
            this.simNs = 0L
            this.edgeBuildNs = 0L
            this.pointBuildNs = 0L
            this.totalNs = 0L
            this.growthAppliedFrames = 0
            this.maxNodes = 0
        }
    }
}

fun main1(): Pair<FLine, FLine>

body — 108 lines, from line 322
fun main1(): Pair<FLine, FLine> {
    val graph = GrowingGraph(initialNodeCount = 10, rule = 2182, flipProb = 0.0, seed = 1234L)
    graph.setNeighbors(0, 9, 1, 2)
    graph.setNeighbors(1, 0, 2, 4)
    graph.setNeighbors(2, 1, 3, 0)
    graph.setNeighbors(3, 2, 4, 6)
    graph.setNeighbors(4, 3, 5, 1)
    graph.setNeighbors(5, 4, 6, 8)
    graph.setNeighbors(6, 5, 7, 3)
    graph.setNeighbors(7, 6, 8, 9)
    graph.setNeighbors(8, 7, 9, 5)
    graph.setNeighbors(9, 8, 0, 7)

    val initStates = intArrayOf(0, 0, 0, 1, 0, 1, 0, 1, 1, 1)
    for (i in 0 until 10) graph.setState(i, initStates[i])

    val state = SimdVerlet.State3(
        x = FloatArray(0),
        y = FloatArray(0),
        z = FloatArray(0),
        px = FloatArray(0),
        py = FloatArray(0),
        pz = FloatArray(0),
        ax = FloatArray(0),
        ay = FloatArray(0),
        az = FloatArray(0),
        invMass = FloatArray(0)
    )

    var constraintBuffer = ConstraintBuffer(graph.nodeCount)
    var constraints = constraintBuffer.ensureSize(graph.nodeCount)

    val sim = SimdVerlet(state, constraints)

    val radius = 0.7f
    for (i in 0 until graph.nodeCount) {
        val angle = (i.toFloat() / graph.nodeCount.toFloat()) * (Math.PI * 2.0).toFloat()
        val x = radius * kotlin.math.cos(angle)
        val y = radius * kotlin.math.sin(angle)
        val z = 0.15f * ((i % 3) - 1)
        sim.addParticle(x, y, z)
    }

    constraints = constraintBuffer.ensureSize(graph.nodeCount)
    sim.constraints = constraints
    updateConstraintsAll(graph, sim, constraintBuffer, stiffness = 0.4f)

    val pool = OctreeBarnesHutPool(maxTrees = 1, initialCapacity = 256)
    sim.addForceGenerator(0, "bh-repel", generator2 {
        while (true) {
            val ctx = yield()
            val s = ctx.state
            val n = s.size
            if (n == 0) continue

            val tree = pool.acquire()
            tree.build(s.x, s.y, s.z, massIn = null, invMass = s.invMass, count = n)
            tree.applyForces(
                x = s.x,
                y = s.y,
                z = s.z,
                ax = s.ax,
                ay = s.ay,
                az = s.az,
                massIn = null,
                invMass = s.invMass,
                count = n,
                theta = 0.6f,
                softening = 0.06f,
                g = -10000.2f
            )
            pool.release(tree)
        }
    })

    val dt = 1f / 60f
    repeat(240) { step ->
        val delta = graph.grow()
        if (delta.newNodeCount > 0) {
            addNewNodes(graph, sim, delta.newNodeStart, delta.newNodeCount)
            constraints = constraintBuffer.ensureSize(graph.nodeCount)
            sim.constraints = constraints
            updateConstraintsAll(graph, sim, constraintBuffer, stiffness = 0.4f)
        }

        10 {
            sim.step(dt = dt, damping = 0.995f, gravity = 0f, iterations = 4)
        }

        if (step % 20 == 0) {
            val count = sim.state.size
            println("step=$step nodes=$count")
            sim.state.x.take(count).forEachIndexed { i, x -> println("$i $x ${sim.state.y[i]} ${sim.state.z[i]}") }
            val edges = buildEdgeFLine(graph, sim)
            val points = buildPointFLine(sim)
            println("debug edges=${edges.nodes.size} points=${points.nodes.size}")
        }
    }

    5000 {
        sim.step(dt = dt, damping = 0.995f, gravity = 0f, iterations = 4)
    }

    val edges = buildEdgeFLine(graph, sim)
    val points = buildPointFLine(sim)
    println("debug edges=${edges.nodes.size} points=${points.nodes.size}")
    return edges to points
}

fun main()

fun main() {
    main1()
}

var lastBarnesHutFLine = FLine()

var lastBarnesHutFLine_depth = 5

data class SimdNodePick3D

data class SimdNodePick3D(
    val nodeIndex: Int,
    val queryPoint: afield.math.linalg.Vec3,
    val nodePosition: afield.math.linalg.Vec3,
    val distanceToNode: Float
)

class GrowingSequenceRuntime internal constructor

class GrowingSequenceRuntime internal constructor(
    val frames: Sequence<Pair<FLine, FLine>>,
    private val grabFn: (
        queryPoint: afield.math.linalg.Vec3,
        controllerPosition: afield.math.linalg.Vec3,
        controllerRotation: Quat,
        maxOutsideDistance: Double
    ) -> SimdNodePick3D?,
    private val continueFn: (controllerPosition: afield.math.linalg.Vec3, controllerRotation: Quat) -> Boolean,
    private val releaseFn: () -> Int?,
    private val grabbedNodeIndexFn: () -> Int?,
    private val closeFn: () -> Unit
) : AutoCloseable {

fun grab

    fun grab(
        queryPoint: afield.math.linalg.Vec3,
        controllerPosition: afield.math.linalg.Vec3 = vec(queryPoint.x, queryPoint.y, queryPoint.z),
        controllerRotation: Quat = Quat(),
        maxOutsideDistance: Double = Double.POSITIVE_INFINITY
    ): SimdNodePick3D? = grabFn(queryPoint, controllerPosition, controllerRotation, maxOutsideDistance)

fun grab

    fun grab(
        controllerPosition: afield.math.linalg.Vec3,
        controllerRotation: Quat,
        maxOutsideDistance: Double = Double.POSITIVE_INFINITY
    ): SimdNodePick3D? = grab(
        queryPoint = controllerPosition,
        controllerPosition = controllerPosition,
        controllerRotation = controllerRotation,
        maxOutsideDistance = maxOutsideDistance
    )

fun continueGrab(controllerPosition: afield.math.linalg.Vec3, controllerRotation: Quat): Boolean

    fun continueGrab(controllerPosition: afield.math.linalg.Vec3, controllerRotation: Quat): Boolean =
        continueFn(controllerPosition, controllerRotation)

fun 'continue'(controllerPosition: afield.math.linalg.Vec3, controllerRotation: Quat): Boolean

    fun `continue`(controllerPosition: afield.math.linalg.Vec3, controllerRotation: Quat): Boolean =
        continueGrab(controllerPosition, controllerRotation)

fun release(): Int? = releaseFn()

fun grabbedNodeIndex(): Int? = grabbedNodeIndexFn()

override fun close()

    override fun close() {
        closeFn()
    }
}

fun growingSequence(): Sequence<Pair<FLine, FLine>> = growingSequenceInteractive().frames

fun growingSequenceInteractive(): GrowingSequenceRuntime

body — 334 lines, from line 492
fun growingSequenceInteractive(): GrowingSequenceRuntime {
    val graph = GrowingGraph(initialNodeCount = 10, rule = 2182, flipProb = 0.0, seed = 1234L)
    graph.setNeighbors(0, 9, 1, 2)
    graph.setNeighbors(1, 0, 2, 4)
    graph.setNeighbors(2, 1, 3, 0)
    graph.setNeighbors(3, 2, 4, 6)
    graph.setNeighbors(4, 3, 5, 1)
    graph.setNeighbors(5, 4, 6, 8)
    graph.setNeighbors(6, 5, 7, 3)
    graph.setNeighbors(7, 6, 8, 9)
    graph.setNeighbors(8, 7, 9, 5)
    graph.setNeighbors(9, 8, 0, 7)

    val initStates = intArrayOf(0, 0, 0, 1, 0, 1, 0, 1, 1, 1)
    for (i in 0 until 10) graph.setState(i, initStates[i])

    val state = SimdVerlet.State3(
        x = FloatArray(0),
        y = FloatArray(0),
        z = FloatArray(0),
        px = FloatArray(0),
        py = FloatArray(0),
        pz = FloatArray(0),
        ax = FloatArray(0),
        ay = FloatArray(0),
        az = FloatArray(0),
        invMass = FloatArray(0)
    )

    var constraintBuffer = ConstraintBuffer(graph.nodeCount)
    var constraints = constraintBuffer.ensureSize(graph.nodeCount)

    val sim = SimdVerlet(state, constraints)

    val radius = 0.7f
    for (i in 0 until graph.nodeCount) {
        val angle = (i.toFloat() / graph.nodeCount.toFloat()) * (Math.PI * 2.0).toFloat()
        val x = radius * kotlin.math.cos(angle)
        val y = radius * kotlin.math.sin(angle)
        val z = 0.15f * ((i % 3) - 1)
        sim.addParticle(x, y, z)
    }

    constraints = constraintBuffer.ensureSize(graph.nodeCount)
    sim.constraints = constraints
    updateConstraintsAll(graph, sim, constraintBuffer, stiffness = 0.4f)

    val pool = OctreeBarnesHutPool(maxTrees = 1, initialCapacity = 256)
    val bhForceThreads = max(1, Runtime.getRuntime().availableProcessors() - 1)
    val bhForceExecutor = SimdVerlet.ForceExecutor(bhForceThreads)
    val simulationLock = Any()
    var closed = false
    var grabbedNodeIndex: Int? = null
    var targetX = 0f
    var targetY = 0f
    var targetZ = 0f
    val grabControllerName = "vr-grabbed"

    fun releaseGrabLocked(): Int? {
        val idx = grabbedNodeIndex ?: return null
        if (idx in 0 until sim.state.forceGenerators.size) {
            sim.state.forceGenerators[idx].remove(grabControllerName)
        }
        grabbedNodeIndex = null
        return idx
    }

    sim.addForceGenerator(0, "bh-repel", generator2 {
        var t = 0
        while (t++<10000) {
            val ctx = yield()
            lastBarnesHutFLine.clear()
            val s = ctx.state
            val n = s.size
            if (n == 0) continue

            val tree = pool.acquire()
            tree.build(
                x = s.x,
                y = s.y,
                z = s.z,
                massIn = null,
                invMass = s.invMass,
                count = n,
                buildExecutor = bhForceExecutor,
                minParallelCount = 4096
            )
            tree.applyForces(
                x = s.x,
                y = s.y,
                z = s.z,
                ax = s.ax,
                ay = s.ay,
                az = s.az,
                massIn = null,
                invMass = s.invMass,
                count = n,
                theta = 0.6f,
                softening = 0.06f,
                g = -1000.2f,
                forceExecutor = bhForceExecutor,
                minChunk = 1024
            )
            tree.appendCuboidsTo(lastBarnesHutFLine, lastBarnesHutFLine_depth)
            pool.release(tree)
        }
    })

    val growthExecutor = Executors.newSingleThreadExecutor { runnable ->
        Thread(runnable, "growing-graph-worker").apply { isDaemon = true }
    }
    var growthFuture: Future<GrowingGraph.GrowDelta>? = null
    fun closeRuntime() {
        synchronized(simulationLock) {
            if (closed) return
            releaseGrabLocked()
            growthFuture?.cancel(true)
            growthFuture = null
            growthExecutor.shutdownNow()
            bhForceExecutor.close()
            closed = true
        }
    }

    val frames = kotlin.sequences.sequence {
        try {
            synchronized(simulationLock) {
                if (!closed && growthFuture == null) {
                    growthFuture = scheduleGrow(growthExecutor, graph)
                }
            }

            val dt = 1f / 20f
            val growthSteps = 1700
            val dumpCoordinatesEvery = 0
            val perf = SequencePerf(reportEveryFrames = 12)
            var completedGrowthSteps = 0
            var frame = 0
            while (completedGrowthSteps < growthSteps) {
                val frameResult = synchronized(simulationLock) {
                    if (closed) {
                        return@synchronized null
                    }

                    val frameStartNs = System.nanoTime()

                    val growthStartNs = System.nanoTime()
                    val pendingGrowth = growthFuture ?: error("growth future missing")
                    var growthAppliedThisFrame = false
                    if (pendingGrowth.isDone) {
                        val delta = pendingGrowth.get()
                        growthFuture = null
                        if (delta.newNodeCount > 0) {
                            addNewNodes(graph, sim, delta.newNodeStart, delta.newNodeCount)
                            constraints = constraintBuffer.ensureSize(graph.nodeCount)
                            sim.constraints = constraints
                            updateConstraintsAll(graph, sim, constraintBuffer, stiffness = 0.4f)
                        }

                        val flip = (Math.random() * graph.nodeCount).i
                        graph.setState(flip, 1 - graph.stateOf(flip))

                        completedGrowthSteps++
                        growthAppliedThisFrame = true
                        if (completedGrowthSteps < growthSteps) {
                            growthFuture = scheduleGrow(growthExecutor, graph)
                        }
                    }
                    val growthNs = System.nanoTime() - growthStartNs

                    val simStartNs = System.nanoTime()
                    1 {
                        sim.step(dt = dt, damping = 0.995f, gravity = 0f, iterations = 10)
                    }
                    val simNs = System.nanoTime() - simStartNs

                    if (dumpCoordinatesEvery > 0 && frame % dumpCoordinatesEvery == 0) {
                        val count = sim.state.size
                        println("step=$frame growth=$completedGrowthSteps nodes=$count")
                        sim.state.x.take(count).forEachIndexed { i, x -> println("$i $x ${sim.state.y[i]} ${sim.state.z[i]}") }
                    }

                    val edgeStartNs = System.nanoTime()
                    val edges = buildEdgeFLine(constraints, sim)
                    val edgeBuildNs = System.nanoTime() - edgeStartNs
                    val pointStartNs = System.nanoTime()
                    val points = buildPointFLine(sim)
                    val pointBuildNs = System.nanoTime() - pointStartNs
                    val totalNs = System.nanoTime() - frameStartNs
                    perf.record(
                        nodeCount = sim.state.size,
                        growthApplied = growthAppliedThisFrame,
                        growthNs = growthNs,
                        simNs = simNs,
                        edgeBuildNs = edgeBuildNs,
                        pointBuildNs = pointBuildNs,
                        totalNs = totalNs
                    )

                    if (sim.state.size > 26000) {
                        return@synchronized null
                    }

                    frame++
                    edges to points
                } ?: break

                yield(frameResult)
            }

            500 { settleFrame ->
                val frameResult = synchronized(simulationLock) {
                    if (closed) {
                        return@synchronized null
                    }

                    val frameStartNs = System.nanoTime()
                    val simStartNs = System.nanoTime()
                    sim.step(dt = dt * 2, damping = 0.995f, gravity = 0f, iterations = 10)
                    val simNs = System.nanoTime() - simStartNs
                    val edgeStartNs = System.nanoTime()
                    val edges = buildEdgeFLine(constraints, sim)
                    val edgeBuildNs = System.nanoTime() - edgeStartNs
                    val pointStartNs = System.nanoTime()
                    val points = buildPointFLine(sim)
                    val pointBuildNs = System.nanoTime() - pointStartNs
                    val totalNs = System.nanoTime() - frameStartNs
                    perf.record(
                        nodeCount = sim.state.size,
                        growthApplied = false,
                        growthNs = 0L,
                        simNs = simNs,
                        edgeBuildNs = edgeBuildNs,
                        pointBuildNs = pointBuildNs,
                        totalNs = totalNs
                    )
                    if (dumpCoordinatesEvery > 0 && settleFrame % dumpCoordinatesEvery == 0) {
                        println("settleFrame=$settleFrame nodes=${sim.state.size}")
                    }
                    edges to points
                } ?: return@sequence

                yield(frameResult)
            }
        } finally {
            closeRuntime()
        }
    }

    return GrowingSequenceRuntime(
        frames = frames,
        grabFn = { queryPoint, controllerPosition, _controllerRotation, maxOutsideDistance ->
            synchronized(simulationLock) {
                if (closed || sim.state.size == 0) {
                    return@synchronized null
                }

                var nearestIdx = -1
                var nearestDist = Float.POSITIVE_INFINITY
                for (i in 0 until sim.state.size) {
                    val dx = sim.state.x[i] - queryPoint.x
                    val dy = sim.state.y[i] - queryPoint.y
                    val dz = sim.state.z[i] - queryPoint.z
                    val dist = sqrt(dx * dx + dy * dy + dz * dz)
                    if (dist < nearestDist) {
                        nearestDist = dist
                        nearestIdx = i
                    }
                }

                if (nearestIdx < 0) {
                    return@synchronized null
                }

                if (nearestDist.toDouble() > maxOutsideDistance) {
                    return@synchronized null
                }

                releaseGrabLocked()
                grabbedNodeIndex = nearestIdx
                targetX = controllerPosition.x
                targetY = controllerPosition.y
                targetZ = controllerPosition.z

                sim.addPidPositionController(
                    nearestIdx,
                    grabControllerName,
                    targetX = { targetX },
                    targetY = { targetY },
                    targetZ = { targetZ },
                    kp = 160f,
                    ki = 0f,
                    kd = 15f,
                    maxForce = 2500f
                )

                SimdNodePick3D(
                    nodeIndex = nearestIdx,
                    queryPoint = vec(queryPoint.x, queryPoint.y, queryPoint.z),
                    nodePosition = vec(sim.state.x[nearestIdx], sim.state.y[nearestIdx], sim.state.z[nearestIdx]),
                    distanceToNode = nearestDist
                )
            }
        },
        continueFn = { controllerPosition, _controllerRotation ->
            synchronized(simulationLock) {
                if (closed || grabbedNodeIndex == null) {
                    return@synchronized false
                }
                targetX = controllerPosition.x
                targetY = controllerPosition.y
                targetZ = controllerPosition.z
                true
            }
        },
        releaseFn = {
            synchronized(simulationLock) {
                if (closed) {
                    return@synchronized null
                }
                releaseGrabLocked()
            }
        },
        grabbedNodeIndexFn = {
            synchronized(simulationLock) {
                if (closed) {
                    return@synchronized null
                }
                grabbedNodeIndex
            }
        },
        closeFn = ::closeRuntime
    )
}