Skip to content

Motion.kt

src/jvmMain/kotlin/afield/animation/mocap/Motion.kt · 238 lines

@file:afield.runtime.completion.CompletionCategory("Animation")

package afield.animation.mocap

import afield.geometry.path.FLine
import afield.graphics.scene.GltfSkinning
import afield.math.linalg.Matrix3f
import afield.math.linalg.Matrix4f
import afield.math.linalg.Quat
import afield.math.linalg.Vec3
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
import kotlin.math.abs
import kotlin.math.atan2

class MotionCoordinates

Motion uses metres, seconds, right handed Y-up coordinates and xyzw quaternions. This matches Field's glTF scene coordinates. Cameras and projection remain the caller's. sourceToField is a row-major orthogonal basis, also used for forces; moments are axial vectors.

class MotionCoordinates(
    val sourceUnits: String = "m",
    val sourceHandedness: String = "right",
    val sourceUp: String = "+Y",
    val sourceForward: String = "+Z",
    sourceToField: List<Double> = listOf(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0),
    val metresPerUnit: Double = 1.0,
) {

val sourceToField = sourceToField.toList()

    private val basis: Matrix3f

val determinant: Float

init

    init {
        require(sourceToField.size == 9 && sourceToField.all(Double::isFinite))
        require(metresPerUnit.isFinite() && metresPerUnit > 0)
        val b = this.sourceToField
        basis = Matrix3f().set(b[0].toFloat(), b[3].toFloat(), b[6].toFloat(),
            b[1].toFloat(), b[4].toFloat(), b[7].toFloat(), b[2].toFloat(), b[5].toFloat(), b[8].toFloat())
        determinant = basis.determinant()
        for (i in 0..2) for (j in 0..2) {
            val dot = (0..2).sumOf { b[it * 3 + i] * b[it * 3 + j] }
            require(abs(dot - if (i == j) 1.0 else 0.0) < 1e-5) { "Coordinate basis must be orthogonal" }
        }
    }

fun position(p: Vec3): Vec3 = basis.transform(Vec3(p)).mul(metresPerUnit.toFloat())

fun direction(p: Vec3): Vec3 = basis.transform(Vec3(p))

fun moment(p: Vec3): Vec3 = direction(p).mul(determinant)

fun rotation(q: Quat): Quat

    fun rotation(q: Quat): Quat {
        val r = Matrix3f().rotation(q)
        val transformed = Matrix3f(basis).mul(r).mul(Matrix3f(basis).transpose())
        return Quat().setFromNormalized(transformed).normalize()
    }

fun json(): JSONObject

    fun json(): JSONObject = JSONObject().put("sourceUnits", sourceUnits).put("sourceHandedness", sourceHandedness)
        .put("sourceUp", sourceUp).put("sourceForward", sourceForward).put("sourceToField", sourceToField)
        .put("metresPerUnit", metresPerUnit).put("common", "RH_Y_UP_METRES_XYZW")

companion object

fun fromJson(j: JSONObject): MotionCoordinates

        fun fromJson(j: JSONObject): MotionCoordinates = MotionCoordinates(j.getString("sourceUnits"),
            j.getString("sourceHandedness"), j.getString("sourceUp"), j.getString("sourceForward"),
            j.getJSONArray("sourceToField").doubles(), j.getDouble("metresPerUnit")).also {
                require(j.getString("common") == "RH_Y_UP_METRES_XYZW")
            }

fun isaacGym()

Isaac Gym's Z-up world mapped by a proper rotation: (x,y,z) -> (x,z,-y).

        fun isaacGym() = MotionCoordinates(sourceUp = "+Z", sourceForward = "+X",
            sourceToField = listOf(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -1.0, 0.0))
    }
}

data class MotionBody(val name: String, val joint: String, val massKg: Double? = null)

A rigid body is explicitly named and mapped; a joint index is never a force-application body.

init

    init { require(name.isNotBlank() && joint.isNotBlank()); require(massKg == null || massKg.isFinite() && massKg > 0) }

fun json()

    fun json() = JSONObject().put("name", name).put("joint", joint).put("massKg", massKg ?: JSONObject.NULL)
}

class MotionSkeleton

Runtime joint order and rest transforms. Reuses Field's scene hierarchy rather than a new FK engine.

class MotionSkeleton(
    val id: String,
    val version: String,
    names: List<String>,
    parents: List<Int>,
    restTranslations: List<Double>,
    restRotations: List<Double>,
    val coordinates: MotionCoordinates = MotionCoordinates(),
    bodies: List<MotionBody> = emptyList(),
) {

val names = names.toList()

val parents = parents.toList()

val restTranslations = restTranslations.toList()

val restRotations = restRotations.toList()

val bodies = bodies.toList()

val size get() = names.size

val hash: String

init

    init {
        require(id.isNotBlank() && version.isNotBlank())
        require((listOf(id, version, coordinates.sourceUnits, coordinates.sourceHandedness,
            coordinates.sourceUp, coordinates.sourceForward) + names + bodies.flatMap { listOf(it.name, it.joint) })
            .all { text -> text.none { it.code == 0 } }) { "Skeleton hash strings must not contain NUL" }
        require(size in 1..1024 && names.toSet().size == size && names.all { it.isNotBlank() })
        require(parents.size == size && parents.count { it == -1 } == 1)
        parents.forEachIndexed { i, p -> require(p in -1 until i) { "Parent $p must precede joint $i" } }
        require(restTranslations.size == 3 * size && restTranslations.all(Double::isFinite))
        validateRotations(restRotations, size)
        require(bodies.map { it.name }.toSet().size == bodies.size && bodies.all { it.joint in names })
        // Hash a canonical array, avoiding JSONObject's unspecified key ordering; shared with Python.
        // Numeric spelling differs across JSON implementations; hash IEEE binary values separately.
        val digest = MessageDigest.getInstance("SHA-256")
        fun string(s: String) { digest.update(s.toByteArray(Charsets.UTF_8)); digest.update(0.toByte()) }
        string(id); string(version); names.forEach(::string)
        val bytes = java.nio.ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN)
        fun number(n: Double) { bytes.clear(); bytes.putDouble(n); digest.update(bytes.array()) }
        parents.forEach { number(it.toDouble()) }; restTranslations.forEach(::number); restRotations.forEach(::number)
        coordinates.sourceToField.forEach(::number); number(coordinates.metresPerUnit)
        string(coordinates.sourceUnits); string(coordinates.sourceHandedness); string(coordinates.sourceUp); string(coordinates.sourceForward)
        bodies.forEach { string(it.name); string(it.joint); number(it.massKg ?: -1.0) }
        hash = digest.digest().joinToString("") { "%02x".format(it) }
    }

fun joint(name: String): Int

    fun joint(name: String): Int = names.indexOf(name).also { require(it >= 0) { "Unknown joint '$name'; available: $names" } }

fun body(name: String): MotionBody

    fun body(name: String): MotionBody = bodies.firstOrNull { it.name == name }
        ?: throw IllegalArgumentException("Unknown rigid body '$name'; available: ${bodies.map { it.name }}")

fun sceneSkeleton(): GltfSkinning.Skeleton

    fun sceneSkeleton(): GltfSkinning.Skeleton = GltfSkinning.Skeleton(names.indices.map { i ->
        GltfSkinning.SkeletonNode(names[i], parents[i], Matrix4f().translation(
            restTranslations[i * 3].toFloat(), restTranslations[i * 3 + 1].toFloat(), restTranslations[i * 3 + 2].toFloat()).rotate(Quat(
            restRotations[i * 4].toFloat(), restRotations[i * 4 + 1].toFloat(), restRotations[i * 4 + 2].toFloat(), restRotations[i * 4 + 3].toFloat())))
    }, emptyList(), emptyList())

fun json()

    fun json() = JSONObject().put("id", id).put("version", version).put("names", names).put("parents", parents)
        .put("restTranslations", restTranslations).put("restRotations", restRotations).put("coordinates", coordinates.json())
        .put("bodies", JSONArray(bodies.map { it.json() })).put("hash", hash)

companion object

fun fromJson(j: JSONObject): MotionSkeleton

        fun fromJson(j: JSONObject): MotionSkeleton {
            val bodies = j.getJSONArray("bodies")
            val s = MotionSkeleton(j.getString("id"), j.getString("version"), j.getJSONArray("names").strings(),
                j.getJSONArray("parents").let { a -> List(a.length()) { a.getInt(it) } },
                j.getJSONArray("restTranslations").doubles(), j.getJSONArray("restRotations").doubles(),
                MotionCoordinates.fromJson(j.getJSONObject("coordinates")), List(bodies.length()) {
                    val b = bodies.getJSONObject(it)
                    MotionBody(b.getString("name"), b.getString("joint"), if (b.isNull("massKg")) null else b.getDouble("massKg"))
                })
            require(s.hash == j.getString("hash")) { "Skeleton hash mismatch" }
            return s
        }
    }
}

class MotionPose(root: List<Double>, localRotations: List<Double>, worldPositions: List<Double>)

Lists are copied on construction. Display code may create mutable Field vectors without sharing worker state.

val root = root.toList()

val localRotations = localRotations.toList()

val worldPositions = worldPositions.toList()

val size: Int get() = worldPositions.size / 3

val hasRotations: Boolean get() = localRotations.isNotEmpty()

init

    init {
        require(root.size == 3 && root.all { it.isFinite() && abs(it) <= Float.MAX_VALUE })
        require(worldPositions.isNotEmpty() && worldPositions.size % 3 == 0)
        if (hasRotations) validateRotations(localRotations, size)
        require(worldPositions.size == size * 3 && worldPositions.all { it.isFinite() && abs(it) <= Float.MAX_VALUE })
    }

fun position(index: Int): Vec3

    fun position(index: Int): Vec3 = Vec3(worldPositions[index * 3].toFloat(), worldPositions[index * 3 + 1].toFloat(), worldPositions[index * 3 + 2].toFloat())

fun rotation(index: Int): Quat

    fun rotation(index: Int): Quat {
        check(hasRotations) { "This reference pose has positions only; upstream does not supply orientations" }
        return Quat(localRotations[index * 4].toFloat(), localRotations[index * 4 + 1].toFloat(), localRotations[index * 4 + 2].toFloat(), localRotations[index * 4 + 3].toFloat())
    }

fun json()

    fun json() = JSONObject().put("root", root).put("localRotations", localRotations).put("worldPositions", worldPositions)

companion object

fun fromJson(j: JSONObject)

        fun fromJson(j: JSONObject) = MotionPose(j.getJSONArray("root").doubles(), j.getJSONArray("localRotations").doubles(), j.getJSONArray("worldPositions").doubles())
    }
}

class MotionFrame

class MotionFrame(
    val time: Double, val index: Long, val episode: Long, val revision: Long,
    val executed: MotionPose, val intended: MotionPose? = null,
    contacts: List<Double> = emptyList(), val contactProvenance: String = "unavailable",
    diagnostics: Map<String, Double> = emptyMap(),
) {

val contacts = contacts.toList()

val diagnostics = diagnostics.toMap()

init

    init {
        require(time.isFinite() && time >= 0 && index >= 0 && episode >= 0 && revision >= 0)
        require(executed.hasRotations) { "Executed pose requires orientations" }
        require(intended == null || intended.size == executed.size)
        require(contacts.all { it.isFinite() && it in 0.0..1.0 })
        require(contactProvenance in setOf("unavailable", "ardy.predicted", "simulator.measured", "fixture"))
        require(diagnostics.values.all(Double::isFinite))
    }

fun json()

    fun json() = JSONObject().put("time", time).put("index", index).put("episode", episode).put("revision", revision)
        .put("executed", executed.json()).put("intended", intended?.json() ?: JSONObject.NULL).put("contacts", contacts)
        .put("contactProvenance", contactProvenance).put("diagnostics", JSONObject(diagnostics))

companion object

fun fromJson(j: JSONObject): MotionFrame

        fun fromJson(j: JSONObject): MotionFrame {
            val d = j.getJSONObject("diagnostics")
            return MotionFrame(j.getDouble("time"), j.getLong("index"), j.getLong("episode"), j.getLong("revision"),
                MotionPose.fromJson(j.getJSONObject("executed")), if (j.isNull("intended")) null else MotionPose.fromJson(j.getJSONObject("intended")),
                j.getJSONArray("contacts").doubles(), j.getString("contactProvenance"), d.keySet().associateWith { d.getDouble(it) })
        }
    }
}

fun MotionPose.bones(skeleton: MotionSkeleton): FLine

Parent-child segments in 3D; pass these to the existing Field line renderer and camera.

fun MotionPose.bones(skeleton: MotionSkeleton): FLine {
    require(size == skeleton.size)
    return FLine().also { line -> skeleton.parents.forEachIndexed { i, parent ->
        if (parent >= 0) line.moveTo(position(parent)).lineTo(position(i))
    } }
}

data class TimedRootPoint(val time: Double, val x: Double, val z: Double, val heading: Double? = null)

init

    init { require(listOf(time, x, z).all(Double::isFinite) && time >= 0 && (heading == null || heading.isFinite())) }

fun json()

    fun json() = JSONObject().put("time", time).put("x", x).put("z", z).put("heading", heading ?: JSONObject.NULL)
}

fun FLine.motionRoute(startTime: Double, duration: Double, count: Int = 41,

Existing FLine arc-length cursor; distanceFraction supplies timing separately from geometry.

fun FLine.motionRoute(startTime: Double, duration: Double, count: Int = 41,
    headings: Boolean = false, distanceFraction: (Double) -> Double = { it }): List<TimedRootPoint> {
    require(startTime.isFinite() && startTime >= 0 && duration.isFinite() && duration > 0 && count in 2..10000)
    val cursor = cursor()
    val length = cursor.lengthD()
    var previous = -1.0
    val points = List(count) { i ->
        val t = i.toDouble() / (count - 1)
        val distance = distanceFraction(t)
        require(distance.isFinite() && distance in 0.0..1.0 && distance >= previous) { "Distance envelope must be monotone in [0,1]" }
        previous = distance
        cursor.setD((length * distance).toFloat())
        cursor.position()
    }
    return points.mapIndexed { i, p ->
        val before = points[(i - 1).coerceAtLeast(0)]
        val after = points[(i + 1).coerceAtMost(points.lastIndex)]
        TimedRootPoint(startTime + duration * i / (count - 1), p.x.toDouble(), p.z.toDouble(),
            if (headings) atan2((after.x - before.x).toDouble(), (after.z - before.z).toDouble()) else null)
    }
}

internal fun JSONArray.doubles() = List(length()) { getDouble(it) }

internal fun JSONArray.strings() = List(length()) { getString(it) }

private fun validateRotations(values: List<Double>, size: Int) {
    require(values.size == size * 4 && values.all(Double::isFinite))
    repeat(size) { i -> require(abs((0..3).sumOf { values[4 * i + it] * values[4 * i + it] } - 1.0) < .002) { "Quaternion $i must be normalized xyzw" } }
}