SimdVerletDebugLines.kt
src/jvmMain/kotlin/afield/simulation/physics/SimdVerletDebugLines.kt · 191 lines
package afield.simulation.physics
import afield.geometry.path.FLine
import afield.math.linalg.Vec4
import afield.graphics.render.color
import afield.graphics.render.pointSize
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.math.linalg.vec
A SimdVerlet's particles and constraints as FLines, for seeing what the solver holds rather
than what the scene means.
The file is one extension function, debugLines, and the list it returns is grouped by
appearance rather than by particle: at most four lines, in the order constraint edges, velocity
whiskers, unpinned particles, pinned particles, each already carrying its own colour and
thickness, and each left out when it would have been empty. So a caller restyles a whole
category by editing one element of that list, and the list's own length says only which
categories drew something.
fun SimdVerlet.debugLines
The solver's own state as FLines: one segment per distance constraint, a dot per particle,
pinned particles in their own colour, and — when asked — a velocity whisker per particle.
This is the debug view, not a renderer. It draws what the solver holds rather than what the
scene means, which is the point: a constraint that was never wired, a neighbour index pointing at
the wrong particle, a pin that isn't pinned and a sheet that is quietly exploding all look
obviously wrong here and look like nothing at all in a shaded mesh. Pair it with Box.showPlane
for a GPU-free live view:
loop {
cloth.step(gravity = 4f)
showPlane(sim.debugLines(), window = Rect(-1.0, -1.0, 12.0, 9.0))
}
Edges come from whichever constraint set the simulation is actually solving —
SimdVerlet.constraints4 if it has one, else SimdVerlet.constraints. A symmetric pair (i -> j
and j -> i, as a Cloth grid wires it) is drawn once; a one-way constraint is drawn by its
owner, so an accidentally asymmetric wiring stays visible rather than being deduplicated away.
Unused slots (neighbour -1 or zero stiffness) draw nothing.
thickness and pointSize are in the units the output uses: pixels of the mapped frame for the
2D SVG paths (showPlane / showPlaneGrid), world units for Box.lines and the Field 4
renderers, where they want to be much smaller. The dot radius convention is pointSize * 3.
@param particles which particle indices to draw, clamped to the live range. Pass
cloth.base until cloth.end to draw one cloth out of a busier simulation.
@param edges draw the distance constraints.
@param points draw a dot per particle.
@param velocityScale length of the velocity whiskers in world units per unit of Verlet
displacement (x - px, i.e. one frame's travel). 0f — the default — draws none.
body — 87 lines, from line 56
fun SimdVerlet.debugLines(
particles: IntRange = 0 until size,
edges: Boolean = true,
points: Boolean = true,
velocityScale: Float = 0f,
edgeColor: Vec4 = vec(0.36, 0.78, 1.0, 0.45),
pointColor: Vec4 = vec(0.74, 0.82, 0.94, 0.9),
pinnedColor: Vec4 = vec(1.0, 0.42, 0.32, 1.0),
velocityColor: Vec4 = vec(0.55, 0.95, 0.6, 0.75),
thickness: Double = 1.4,
pointSize: Double = 0.7
): List<FLine> {
val range = particles.first.coerceAtLeast(0)..particles.last.coerceAtMost(size - 1)
if (range.isEmpty()) return emptyList()
val x = state.x
val y = state.y
val z = state.z
val out = ArrayList<FLine>(4)
val c4 = constraints4
val c3 = if (c4 == null) constraints else null
if (edges && (c4 != null || c3 != null)) {
val line = FLine()
val slots = IntArray(4)
for (i in range) {
val used = neighborSlots(c4, c3, i, slots)
for (s in 0 until used) {
val j = slots[s]
if (j !in 0 until size) continue
// A symmetric pair is one edge. Let the lower index draw it, but only when the
// higher one really does own the reverse constraint and is itself being drawn.
if (j < i && j in range && hasConstraint(c4, c3, j, i)) continue
line.moveTo(x[i], y[i], z[i])
line.lineTo(x[j], y[j], z[j])
}
}
if (line.nodes.isNotEmpty()) {
line.color = edgeColor
line.thickness = thickness
out.add(line)
}
}
if (velocityScale != 0f) {
val px = state.px
val py = state.py
val pz = state.pz
val line = FLine()
for (i in range) {
val vx = (x[i] - px[i]) * velocityScale
val vy = (y[i] - py[i]) * velocityScale
val vz = (z[i] - pz[i]) * velocityScale
if (vx == 0f && vy == 0f && vz == 0f) continue
line.moveTo(x[i], y[i], z[i])
line.lineTo(x[i] + vx, y[i] + vy, z[i] + vz)
}
if (line.nodes.isNotEmpty()) {
line.color = velocityColor
line.thickness = thickness
out.add(line)
}
}
if (points) {
val invMass = state.invMass
val free = FLine()
val pinned = FLine()
for (i in range) {
val target = if (invMass != null && invMass[i] == 0f) pinned else free
target.moveTo(x[i], y[i], z[i])
}
for ((line, color) in listOf(free to pointColor, pinned to pinnedColor)) {
if (line.nodes.isEmpty()) continue
line.color = color
line.pointed = true
line.pointSize = pointSize
// Nothing to stroke — every node is a moveTo — but say so, so a renderer that would
// otherwise set up a stroke pass for this line doesn't bother.
line.stroked = false
out.add(line)
}
}
return out
}
Neighbour indices of the slots particle i actually uses, written into into; returns how many.
private fun neighborSlots(
c4: SimdVerlet.DirectedConstraints4?,
c3: SimdVerlet.DirectedConstraints3?,
i: Int,
into: IntArray
): Int {
var n = 0
if (c4 != null) {
if (i >= c4.size) return 0
if (c4.n0[i] >= 0 && c4.s0[i] != 0f) into[n++] = c4.n0[i]
if (c4.n1[i] >= 0 && c4.s1[i] != 0f) into[n++] = c4.n1[i]
if (c4.n2[i] >= 0 && c4.s2[i] != 0f) into[n++] = c4.n2[i]
if (c4.n3[i] >= 0 && c4.s3[i] != 0f) into[n++] = c4.n3[i]
return n
}
if (c3 != null) {
if (i >= c3.size) return 0
if (c3.n0[i] >= 0 && c3.s0[i] != 0f) into[n++] = c3.n0[i]
if (c3.n1[i] >= 0 && c3.s1[i] != 0f) into[n++] = c3.n1[i]
if (c3.n2[i] >= 0 && c3.s2[i] != 0f) into[n++] = c3.n2[i]
}
return n
}
Whether from owns a live constraint to to.
private fun hasConstraint(
c4: SimdVerlet.DirectedConstraints4?,
c3: SimdVerlet.DirectedConstraints3?,
from: Int,
to: Int
): Boolean {
if (c4 != null) {
if (from >= c4.size) return false
return (c4.n0[from] == to && c4.s0[from] != 0f) ||
(c4.n1[from] == to && c4.s1[from] != 0f) ||
(c4.n2[from] == to && c4.s2[from] != 0f) ||
(c4.n3[from] == to && c4.s3[from] != 0f)
}
if (c3 != null) {
if (from >= c3.size) return false
return (c3.n0[from] == to && c3.s0[from] != 0f) ||
(c3.n1[from] == to && c3.s1[from] != 0f) ||
(c3.n2[from] == to && c3.s2[from] != 0f)
}
return false
}