Skip to content

Cloth.kt

src/jvmMain/kotlin/afield/simulation/physics/Cloth.kt · 583 lines

package afield.simulation.physics

import afield.geometry.path.FLine
import afield.math.linalg.Vec3
import afield.math.linalg.Vector3fc
import afield.math.linalg.vec
import kotlin.math.sqrt

A rectangular cloth patch on a SimdVerlet simulation.

Cloth.addTo (or SimdVerlet.addCloth) appends width * height particles to an existing simulation, wires their edge springs into the constraint solve, and returns a handle that knows the grid. The handle drives the whole simulation through Cloth.step or the infinite-sequence Cloth.frames, and exposes grid coordinates via Cloth.position, Cloth.pin and Cloth.grabNearest.

class Cloth private constructor

A rectangular cloth patch living inside somebody else's SimdVerlet.

Cloth.addTo (or sim.addCloth(...)) appends width * height particles to a simulation that may already contain other particles, wires them into a SimdVerlet.DirectedConstraints4 grid — every interior particle spends all four of its slots on its axis neighbors — and registers the optional curvature force on the simulation. What comes back is a handle that knows the grid: cloth[col, row] is a particle index, lines is the wireframe, pin / teleport / grabNearest address particles by grid coordinate. For the solver's view rather than the sheet's — a segment per constraint, the pins in their own colour — see debugLines.

val sim = SimdVerlet(state) val cloth = sim.addCloth(width = 20, height = 20, spacing = 0.5f) while (true) { cloth.step(gravity = -9.8f) draw(cloth.lines()) }

Constraints. A 4-slot constraint set is indexed by particle index, and the solver walks 0 until state.size, so one set has to cover the whole simulation rather than just this cloth. By default a cloth adopts SimdVerlet.constraints4 if one is already there (so two cloths, or a cloth plus hand-wired constraints, share a single set) and otherwise creates one and publishes it as sim.constraints4. step grows it whenever the simulation has grown. Driving the simulation yourself means passing that set in explicitly: sim.step(dt, damping, gravity, iterations, sim.constraints4) — the no-constraints step(...) overload solves nothing and the cloth simply falls apart.

Curvature. curvatureStrength pulls each interior particle toward the mean of its four axis neighbors, which flattens the sheet and takes the sag out of the distance-only solve. It is one SimdVerlet.bulkForces pass over the whole patch, not a force generator per particle, so the cost is a single scalar sweep rather than width * height coroutine resumes. The field is live: set it to 0f to switch the force off for a frame or forever (the pass stays registered and returns immediately). A cloth built with curvatureStrength = 0f registers nothing at all — enableCurvature adds it later.

Its useful scale is set by the springs it competes with, and that is easy to get wrong: the constraint solve is force-based (each of step's iterations adds its correction to the same accumulator without moving anything in between), so the effective spring constant is stiffness * iterations, and the curvature force has to be within an order of magnitude or two of that to bend anything. Measured on a 20 x 10 sheet at stiffness = 60, iterations = 16: 0.05 is indistinguishable from off, 20 (the default, a third of the stiffness) is visible, and 100 takes about 40 % of the kink out. The same product decides stability — 60 x 16 settles, 150 x 16 diverges to NaN at dt = 1/60.

Stability. The cloth's particles are the contiguous range [base, base + count), and both the constraint slots and the curvature sweep are written in terms of those indices. Queued removals compact the state (see SimdVerlet.applyQueuedRemovalsSwap) and would renumber them, so a cloth does not survive particle removal anywhere below its own top index. Adding particles after the cloth is fine.

step, lines and the grab controls serialize on this cloth's own monitor, so a render thread pulling frames and an input thread calling moveGrabbed don't have to coordinate.

class Cloth private constructor(
    /** The simulation these particles live in. */
    val sim: SimdVerlet,
    /** Columns; the `x`/`col` axis of the grid. */
    val width: Int,
    /** Rows; the `y`/`row` axis of the grid. Row `height - 1` is the one pinned by default. */
    val height: Int,
    /** Index of this cloth's first particle. Its particles are `base until base + count`. */
    val base: Int,
    /** Nominal edge length: the default layout's grid step and the default rest length. */
    val spacing: Float,
    /** Distance-constraint stiffness this cloth wired into its slots. */
    val stiffness: Float,
    /** Particle mass used by [pin] when it un-pins a particle. */
    val mass: Float,
    /** The constraint set carrying this cloth's grid — shared with the rest of [sim]. */
    val constraints: SimdVerlet.DirectedConstraints4,
    /** Key prefix for everything this cloth registered on [sim] (`"$name.curvature"`, ...). */
    val name: String,
    curvatureStrength: Float
) {

var curvatureStrength: Float

Strength of the flattening force toward the mean of each interior particle's four axis neighbors, applied every step by the registered bulk pass. 0f disables it live.

    @Volatile
    var curvatureStrength: Float = curvatureStrength

    private val lock = Any()

Nodes whose curvature stencil has been switched off, indexed particle - base. Allocated by the first suppressCurvatureAt and null until then, so a cloth that never tears pays nothing for it and accumulateCurvature keeps its unconditional inner loop.

    @Volatile
    private var curvatureSuppressed: BooleanArray? = null

    private var grabbed = -1
    private var targetX = 0f
    private var targetY = 0f
    private var targetZ = 0f

val count: Int get() = width * height

Number of particles in the patch.

val end: Int get() = base + count

One past this cloth's last particle index.

    private val state: SimdVerlet.State3 get() = sim.state

fun index(col: Int, row: Int): Int

Particle index of the grid node at (col, row).

    fun index(col: Int, row: Int): Int {
        require(col in 0 until width && row in 0 until height) { "($col, $row) outside ${width}x$height cloth" }
        return base + row * width + col
    }

operator fun get(col: Int, row: Int): Int = index(col, row)

Particle index of the grid node at (col, row).

operator fun contains(particle: Int): Boolean = particle in base until end

True if particle is one of this cloth's.

fun position(col: Int, row: Int): Vec3

Current position of the grid node at (col, row).

    fun position(col: Int, row: Int): Vec3 {
        val i = index(col, row)
        return vec(state.x[i], state.y[i], state.z[i])
    }

fun teleport(col: Int, row: Int, to: Vector3fc, keepVelocity: Boolean = false)

Moves one node to to. By default the previous position moves with it, so the Verlet integrator sees a teleport rather than a one-frame velocity spike; pass keepVelocity = true to throw the node instead.

    fun teleport(col: Int, row: Int, to: Vector3fc, keepVelocity: Boolean = false) = synchronized(lock) {
        val i = index(col, row)
        val dx = to.x() - state.x[i]
        val dy = to.y() - state.y[i]
        val dz = to.z() - state.z[i]
        state.x[i] = to.x()
        state.y[i] = to.y()
        state.z[i] = to.z()
        if (!keepVelocity) {
            state.px[i] += dx
            state.py[i] += dy
            state.pz[i] += dz
        }
    }

fun pin(col: Int, row: Int, pinned: Boolean = true)

Pins (pinned = true, inverse mass 0 — immovable) or releases (inverse mass 1 / mass) one node. Requires the state to carry an invMass array; without one every particle has unit mass and nothing can be pinned.

    fun pin(col: Int, row: Int, pinned: Boolean = true) {
        val invMass = requireNotNull(state.invMass) { "cannot pin: this State3 has no invMass array" }
        invMass[index(col, row)] = if (pinned) 0f else 1f / mass
    }

fun isPinned(col: Int, row: Int): Boolean

True if the node at (col, row) has zero inverse mass.

    fun isPinned(col: Int, row: Int): Boolean {
        val invMass = state.invMass ?: return false
        return invMass[index(col, row)] == 0f
    }

fun accumulateCurvature(state: SimdVerlet.State3, strength: Float = curvatureStrength)

The curvature pass, exposed for callers who would rather run it through step's accumulateForces hook than leave it registered in SimdVerlet.bulkForces. Accumulates into state.ax/ay/az and touches only interior nodes, so a 1- or 2-wide cloth gets nothing from it.

    fun accumulateCurvature(state: SimdVerlet.State3, strength: Float = curvatureStrength) {
        if (strength == 0f || width < 3 || height < 3) return
        require(state.size >= end) { "state has ${state.size} particles, cloth needs $end" }

        val x = state.x
        val y = state.y
        val z = state.z
        val ax = state.ax
        val ay = state.ay
        val az = state.az
        val suppressed = curvatureSuppressed

        for (row in 1 until height - 1) {
            var i = base + row * width + 1
            for (col in 1 until width - 1) {
                if (suppressed != null && suppressed[i - base]) {
                    i++
                    continue
                }
                val left = i - 1
                val right = i + 1
                val below = i - width
                val above = i + width
                ax[i] += ((x[left] + x[right] + x[below] + x[above]) * 0.25f - x[i]) * strength
                ay[i] += ((y[left] + y[right] + y[below] + y[above]) * 0.25f - y[i]) * strength
                az[i] += ((z[left] + z[right] + z[below] + z[above]) * 0.25f - z[i]) * strength
                i++
            }
        }
    }

fun suppressCurvatureAt(particle: Int)

Switches the curvature stencil off at one particle, permanently.

This exists for tearing (see enableTearing) and is the hook that keeps a rip open. The curvature force is written from the grid, not from the constraint set, so retiring the distance constraint across an edge leaves the flattening force still pulling the two lips of the tear toward each other with no spring behind it — the sheet heals in the picture while the solver says it is torn. Suppressing both endpoints of a broken edge removes exactly the two stencils that spanned it.

A no-op for a particle outside this cloth, and for the boundary nodes the curvature pass never touched anyway. There is no un-suppress: an intact stencil is the invariant, and a cloth that has been cut has lost it.

    fun suppressCurvatureAt(particle: Int) {
        if (particle !in this) return
        val mask = curvatureSuppressed ?: BooleanArray(count).also { curvatureSuppressed = it }
        mask[particle - base] = true
    }

fun isCurvatureSuppressed(particle: Int): Boolean

Whether suppressCurvatureAt has switched the curvature stencil off at particle.

    fun isCurvatureSuppressed(particle: Int): Boolean {
        if (particle !in this) return false
        return curvatureSuppressed?.get(particle - base) == true
    }

fun enableCurvature(strength: Float = curvatureStrength)

Registers the curvature pass on sim at strength (default: whatever curvatureStrength is now), replacing it if it is already registered. Only needed for a cloth that was built with curvatureStrength = 0f; otherwise just assign the field.

    fun enableCurvature(strength: Float = curvatureStrength) {
        curvatureStrength = strength
        sim.addBulkForce("$name.curvature") { state, _ -> accumulateCurvature(state) }
    }

fun disableCurvature()

Drops the curvature pass from sim altogether. accumulateCurvature still works if you would rather run it from step's accumulateForces hook.

    fun disableCurvature() {
        sim.removeBulkForce("$name.curvature")
    }

fun step

Steps the whole simulation once with this cloth's constraint set, growing that set first if particles were added since the last step. accumulateForces is forwarded to SimdVerlet.step and runs alongside the registered bulk forces.

This steps everything in sim, not just the cloth — it is a convenience for the common case where the cloth is the simulation. With other systems in the same simulation, drive it yourself and pass sim.constraints4.

    fun step(
        dt: Float = 1f / 60f,
        damping: Float = 0.99f,
        gravity: Float = 0f,
        iterations: Int = 8,
        accumulateForces: ((state: SimdVerlet.State3, dt: Float) -> Unit)? = null
    ) = synchronized(lock) {
        constraints.ensureCapacity(state.size)
        sim.step(
            dt = dt,
            damping = damping,
            gravity = gravity,
            iterations = iterations,
            constraints = constraints,
            accumulateForces = accumulateForces
        )
    }

fun lines(): List<FLine>

The cloth as a wireframe: one FLine per row followed by one per column, so width + height lines in all.

    fun lines(): List<FLine> = synchronized(lock) {
        val x = state.x
        val y = state.y
        val z = state.z
        val out = ArrayList<FLine>(width + height)

        for (row in 0 until height) {
            val line = FLine()
            var i = base + row * width
            for (col in 0 until width) {
                if (col == 0) line.moveTo(x[i], y[i], z[i]) else line.lineTo(x[i], y[i], z[i])
                i++
            }
            out.add(line)
        }

        for (col in 0 until width) {
            val line = FLine()
            var i = base + col
            for (row in 0 until height) {
                if (row == 0) line.moveTo(x[i], y[i], z[i]) else line.lineTo(x[i], y[i], z[i])
                i += width
            }
            out.add(line)
        }

        out
    }

fun frames

step then lines, forever — the shape a Field box's animation loop consumes.

    fun frames(
        dt: Float = 1f / 60f,
        damping: Float = 0.99f,
        gravity: Float = 0f,
        iterations: Int = 8,
        accumulateForces: ((state: SimdVerlet.State3, dt: Float) -> Unit)? = null
    ): Sequence<List<FLine>> = sequence {
        while (true) {
            step(dt, damping, gravity, iterations, accumulateForces)
            yield(lines())
        }
    }

val grabbedIndex: Int get() = synchronized(lock) { grabbed }

The particle currently held by grabNearest, or -1.

fun grabNearest

Grabs this cloth's particle nearest to the given world point, provided it is within radius, and starts driving it with a PID position controller — so the grab pulls through the constraint solve instead of teleporting the particle out of the sheet. Returns the grabbed particle index, or -1 if nothing was near enough (in which case any previous grab is left alone). A successful grab replaces a previous one.

body — 42 lines, from line 342
    fun grabNearest(
        worldX: Float,
        worldY: Float,
        worldZ: Float = 0f,
        radius: Float = spacing * 2f,
        kp: Float = 100f,
        ki: Float = 0f,
        kd: Float = 10f,
        maxForce: Float = 1000f
    ): Int = synchronized(lock) {
        var nearest = -1
        var nearestDist = Float.POSITIVE_INFINITY
        for (i in base until minOf(end, state.size)) {
            val dx = state.x[i] - worldX
            val dy = state.y[i] - worldY
            val dz = state.z[i] - worldZ
            val dist = sqrt(dx * dx + dy * dy + dz * dz)
            if (dist < nearestDist) {
                nearestDist = dist
                nearest = i
            }
        }
        if (nearest < 0 || nearestDist >= radius) return@synchronized -1

        releaseGrabbedLocked()
        grabbed = nearest
        targetX = state.x[nearest]
        targetY = state.y[nearest]
        targetZ = state.z[nearest]
        sim.addPidPositionController(
            index = nearest,
            name = "$name.grab",
            targetX = { targetX },
            targetY = { targetY },
            targetZ = { targetZ },
            kp = kp,
            ki = ki,
            kd = kd,
            maxForce = maxForce
        )
        nearest
    }

fun moveGrabbed(worldX: Float, worldY: Float, worldZ: Float = 0f): Boolean

Moves the grab target. Returns false if nothing is grabbed.

    fun moveGrabbed(worldX: Float, worldY: Float, worldZ: Float = 0f): Boolean = synchronized(lock) {
        if (grabbed < 0) return@synchronized false
        targetX = worldX
        targetY = worldY
        targetZ = worldZ
        true
    }

fun releaseGrabbed(): Boolean = synchronized(lock) { releaseGrabbedLocked() }

Releases the grabbed particle. Returns false if nothing was grabbed.

    private fun releaseGrabbedLocked(): Boolean {
        val i = grabbed
        if (i < 0) return false
        sim.removeForceGenerator(i, "$name.grab")
        grabbed = -1
        return true
    }

fun remove()

Detaches this cloth from sim: releases any grab, drops the curvature pass and clears the cloth's constraint slots. The particles stay (removing them would renumber the rest of the simulation); pass them to SimdVerlet.queueRemove yourself if you want them gone.

    fun remove() = synchronized(lock) {
        releaseGrabbedLocked()
        sim.removeBulkForce("$name.curvature")
        for (i in base until minOf(end, constraints.size)) {
            constraints.clearSlots(i)
        }
    }

companion object

    companion object {

fun addTo

Appends a width * height cloth to sim and returns the handle. See Cloth for the constraint-set, curvature and index-stability contracts.

@param spacing grid step of the default layout, and the rest length of every edge unless restLengthFromLayout measures them from the initial positions instead. @param stiffness per-slot distance-constraint stiffness. @param mass particle mass; unpinned particles get inverse mass 1 / mass. @param curvatureStrength flattening force toward the mean of the four axis neighbors; 0f registers nothing. Defaults to a third of stiffness, which is on the scale the constraint solve works at, so the default visibly flattens the sheet. @param position initial position of node (col, row); defaults to a flat grid in z = 0 with x along the columns and y along the rows. @param restLengthFromLayout measure each edge's rest length from position rather than using spacing — what you want when position lays the cloth out non-uniformly. @param pinned which nodes get inverse mass 0; defaults to the whole top row (row == height - 1), i.e. a hanging sheet. @param constraints constraint set to wire into; defaults to SimdVerlet.constraints4, creating and publishing one if the simulation hasn't got one yet. @param name key prefix for what this cloth registers on sim; defaults to "cloth@<first particle index>", which is unique per cloth.

body — 114 lines, from line 441
        fun addTo(
            sim: SimdVerlet,
            width: Int,
            height: Int,
            spacing: Float = 0.5f,
            stiffness: Float = 0.8f,
            mass: Float = 1f,
            curvatureStrength: Float = stiffness / 3f,
            position: (col: Int, row: Int) -> Vector3fc = { col, row -> vec(col * spacing, row * spacing, 0f) },
            restLengthFromLayout: Boolean = false,
            pinned: (col: Int, row: Int) -> Boolean = { _, row -> row == height - 1 },
            constraints: SimdVerlet.DirectedConstraints4? = sim.constraints4,
            name: String? = null
        ): Cloth {
            require(width >= 2 && height >= 2) { "a cloth needs at least 2x2 particles, got ${width}x$height" }
            require(mass > 0f) { "mass must be > 0" }

            val count = width * height
            val base = sim.size

            // Lay the grid out first: rest lengths and pinning both need the positions, and the
            // position lambda is not required to be cheap or pure.
            val px = FloatArray(count)
            val py = FloatArray(count)
            val pz = FloatArray(count)
            var anyPinned = false
            for (row in 0 until height) {
                for (col in 0 until width) {
                    val p = position(col, row)
                    val at = row * width + col
                    px[at] = p.x()
                    py[at] = p.y()
                    pz[at] = p.z()
                    if (pinned(col, row)) anyPinned = true
                }
            }
            require(!(anyPinned && sim.state.invMass == null)) {
                "cannot pin particles: this State3 has no invMass array"
            }
            require(mass == 1f || sim.state.invMass != null) {
                "mass != 1 needs a State3 with an invMass array"
            }

            sim.state.ensureCapacity(base + count)
            for (row in 0 until height) {
                for (col in 0 until width) {
                    val at = row * width + col
                    val index = sim.addParticle(
                        x0 = px[at], y0 = py[at], z0 = pz[at],
                        invMass0 = if (pinned(col, row)) 0f else 1f / mass
                    )
                    check(index == base + at) { "cloth particles must be contiguous: got $index, expected ${base + at}" }
                }
            }

            val set = (constraints ?: SimdVerlet.DirectedConstraints4.ofCapacity(base + count))
                .also { it.ensureCapacity(sim.size) }
            sim.constraints4 = set

            fun rest(fromAt: Int, toAt: Int): Float {
                if (!restLengthFromLayout) return spacing
                val dx = px[toAt] - px[fromAt]
                val dy = py[toAt] - py[fromAt]
                val dz = pz[toAt] - pz[fromAt]
                return sqrt(dx * dx + dy * dy + dz * dz)
            }

            // Four outgoing slots per particle: right, below, left, above. An interior particle
            // fills all four; edges and corners leave the rest unused.
            for (row in 0 until height) {
                for (col in 0 until width) {
                    val at = row * width + col
                    val index = base + at
                    var slot = 0
                    if (col < width - 1) {
                        set.setSlot(index, slot++, index + 1, rest(at, at + 1), stiffness)
                    }
                    if (row > 0) {
                        set.setSlot(index, slot++, index - width, rest(at, at - width), stiffness)
                    }
                    if (col > 0) {
                        set.setSlot(index, slot++, index - 1, rest(at, at - 1), stiffness)
                    }
                    if (row < height - 1) {
                        set.setSlot(index, slot++, index + width, rest(at, at + width), stiffness)
                    }
                    while (slot < 4) {
                        set.setSlot(index, slot++, -1, 0f, 0f)
                    }
                }
            }

            val cloth = Cloth(
                sim = sim,
                width = width,
                height = height,
                base = base,
                spacing = spacing,
                stiffness = stiffness,
                mass = mass,
                constraints = set,
                name = name ?: "cloth@$base",
                curvatureStrength = curvatureStrength
            )
            sim.clothRanges += cloth.name to (base until base + width * height)

            if (curvatureStrength != 0f && width >= 3 && height >= 3) {
                cloth.enableCurvature()
            }

            return cloth
        }
    }
}

fun SimdVerlet.addCloth

Appends a cloth patch to this simulation; see Cloth.addTo.

fun SimdVerlet.addCloth(
    width: Int,
    height: Int,
    spacing: Float = 0.5f,
    stiffness: Float = 0.8f,
    mass: Float = 1f,
    curvatureStrength: Float = stiffness / 3f,
    position: (col: Int, row: Int) -> Vector3fc = { col, row -> vec(col * spacing, row * spacing, 0f) },
    restLengthFromLayout: Boolean = false,
    pinned: (col: Int, row: Int) -> Boolean = { _, row -> row == height - 1 },
    constraints: SimdVerlet.DirectedConstraints4? = this.constraints4,
    name: String? = null
): Cloth = Cloth.addTo(
    sim = this,
    width = width,
    height = height,
    spacing = spacing,
    stiffness = stiffness,
    mass = mass,
    curvatureStrength = curvatureStrength,
    position = position,
    restLengthFromLayout = restLengthFromLayout,
    pinned = pinned,
    constraints = constraints,
    name = name
)