CSVReader.kt
src/jvmMain/kotlin/afield/simulation/physics/CSVReader.kt · 192 lines
package afield.simulation.physics
import afield.math.linalg.Vec3
import afield.math.linalg.Vector3f
import java.io.File
import java.util.TreeMap
import kotlin.math.max
import kotlin.math.min
A reader for motion-capture CSV, and a clip that samples it at an arbitrary time.
The format is one row per captured frame: a time in the first column, then three columns per
joint. Joint names come out of the header, which is expected to read
time,Hip.x,Hip.y,Hip.z,Knee.x,…: every third cell, cut at its first .. MotionClip.load does
that parsing and is the only way to build a clip from a file. It sits on the companion object, so
it has no row of its own on this page.
MotionClip sorts its frames by time on construction, and sample(t) clamps t into the clip's
range, binary-searches for the bracketing pair of frames and interpolates every joint linearly
between them. The vectors that come back are fresh copies, so a caller can move them around
without disturbing the clip.
A clip holds no reference to a solver and nothing else in afield.simulation.physics reads one, so
which particle a joint drives is a decision for the calling code. Two kinds of malformed input
load without an exception. A data row with fewer fields than the header promises is skipped, so a
truncated file becomes a shorter clip; and a file whose every data row is short becomes a clip of
no frames, which answers sample with an empty map and throws from start, end and
duration.
data class Frame
One captured frame: a timestamp, and a position per joint name.
The names are whatever the CSV header spelled, and MotionClip.load gives every frame it builds
the same set of them. A hand-assembled list whose names vary between frames will fail in
MotionClip.sample, which looks the earlier frame's names up in the later one.
data class Frame(
val time: Float,
val joints: Map<String, Vector3f>
)
class MotionClip(frames: List<Frame>)
A sequence of Frames, sampled by time.
Frames are sorted by time on construction, so a list in any order is accepted. start, end and
duration read off the first and last of the sorted frames, and so throw on a clip of none.
class MotionClip(frames: List<Frame>) {
private val frames: List<Frame> = frames.sortedBy { it.time }
private val times: List<Float> = this.frames.map { it.time }
val start : Float
val start : Float
get() = times.first()
val end : Float
val end : Float
get() = times.last()
val duration : Float
val duration : Float
get() = end-start
val bounds : Pair<Vec3, Vec3>
The axis-aligned extent of every joint of every frame, as (minimum, maximum).
Recomputed on each read, walking the whole clip. A clip of no frames answers with the inverted box it starts from, positive infinity paired with negative infinity, rather than throwing.
val bounds : Pair<Vec3, Vec3>
get() {
val m = Vec3(Float.POSITIVE_INFINITY,Float.POSITIVE_INFINITY,Float.POSITIVE_INFINITY)
val x = Vec3(Float.NEGATIVE_INFINITY,Float.NEGATIVE_INFINITY,Float.NEGATIVE_INFINITY)
frames.forEach {
it.joints.forEach { string, at ->
m.min(at)
x.max(at)
}
}
return m to x
}
fun sample(time: Float): Map<String, Vector3f>
Every joint's position at time, interpolated linearly between the two frames bracketing it.
time is clamped into the clip's range, so a value before start or after end gives the
pose of the nearer end frame rather than an error, and a clip of no frames gives an empty
map. The vectors returned are new on every call and are never the ones the clip holds.
fun sample(time: Float): Map<String, Vector3f> {
if (frames.isEmpty()) return emptyMap()
// Clamp time to valid range
val clampedTime = time.coerceIn(times.first(), times.last())
// Binary search
val index = times.binarySearch(clampedTime)
if (index >= 0) {
// Exact frame hit
return frames[index].joints.mapValues { Vector3f(it.value) }
}
val insertIndex = -index - 1
val i0 = max(0, insertIndex - 1)
val i1 = min(frames.lastIndex, insertIndex)
if (i0 == i1) {
return frames[i0].joints.mapValues { Vector3f(it.value) }
}
val f0 = frames[i0]
val f1 = frames[i1]
val t = (clampedTime - f0.time) / (f1.time - f0.time)
val result = mutableMapOf<String, Vector3f>()
for (joint in f0.joints.keys) {
val v0 = f0.joints[joint]!!
val v1 = f1.joints[joint]!!
result[joint] = Vector3f(v0).lerp(v1, t)
}
return result
}
companion object
companion object {
fun load(file: String): MotionClip
Loads a CSV file of the format time,Joint.x,Joint.y,Joint.z,….
The joint names, and how many of them there are, come from the header alone: every third
cell after the first, cut at its first .. A blank line anywhere is dropped; a data row
carrying fewer than 1 + 3 * joints fields is skipped rather than reported. An empty
file throws.
fun load(file: String): MotionClip {
val lines = File(file).readLines().filter { it.isNotBlank() }
require(lines.isNotEmpty()) { "Empty file" }
val header = lines.first().split(",").map { it.trim() }
// Extract joint names from header
val jointOrder = mutableListOf<String>()
for (i in 1 until header.size step 3) {
val name = header[i].substringBefore(".")
jointOrder.add(name)
}
val frames = mutableListOf<Frame>()
for (line in lines.drop(1)) {
val tokens = line.split(",").map { it.trim() }
if (tokens.size < 1 + jointOrder.size * 3) continue
val time = tokens[0].toFloat()
val joints = mutableMapOf<String, Vector3f>()
var idx = 1
for (joint in jointOrder) {
val x = tokens[idx++].toFloat()
val y = tokens[idx++].toFloat()
val z = tokens[idx++].toFloat()
joints[joint] = Vector3f(x, y, z)
}
frames.add(Frame(time, joints))
}
return MotionClip(frames)
}
}
}
//////////////////// Example Usage ////////////////////
fun main()
Example usage: load motion.csv, sample it at 0.05 and print the pose a joint at a time.
fun main() {
val clip = MotionClip.load("motion.csv")
val t = 0.05f
val pose = clip.sample(t)
println("Pose at $t:")
pose.forEach { (joint, position) ->
println("$joint -> $position")
}
}