MotionSession.kt
src/jvmMain/kotlin/afield/animation/mocap/MotionSession.kt · 266 lines
@file:afield.runtime.completion.CompletionCategory("Animation")
package afield.animation.mocap
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import org.json.JSONArray
import org.json.JSONObject
import java.nio.file.Path
import java.util.ArrayDeque
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
data class MotionCommand(val revision: Long, val operation: MotionOperation, val requestedTime: Double, val payload: JSONObject)
data class MotionCommand(val revision: Long, val operation: MotionOperation, val requestedTime: Double, val payload: JSONObject)
data class MotionApplied(val appliedTime: Double?, val frameIndex: Long?, val details: JSONObject = JSONObject())
Null time/index means accepted for scheduling; a later update carries the actual applied event.
data class MotionApplied(val appliedTime: Double?, val frameIndex: Long?, val details: JSONObject = JSONObject())
data class MotionUpdate(val frames: List<MotionFrame>, val events: List<JSONObject> = emptyList())
data class MotionUpdate(val frames: List<MotionFrame>, val events: List<JSONObject> = emptyList())
interface MotionBackend : AutoCloseable
Blocking backend boundary, invoked only by MotionSession's owned daemon thread. Implementations must return bounded batches, honor explicit session times, and close promptly. An installed Python package alone must never advertise a working capability.
val skeleton: MotionSkeleton
val capabilities: MotionCapabilities
fun command(command: MotionCommand): MotionApplied
fun advance(seconds: Double): MotionUpdate
class MotionSession(private val backend: MotionBackend, val queueCapacity: Int = 32,
One small optional motion facade. No inference or simulator work runs on Field's frame thread.
Commands are bounded; path/target replacements coalesce only before the next ordered event.
Subscriptions run on the worker thread and must be short; rendering reads buffer snapshots.
Construction in a box registers with its existing RunScope (stop/re-run/document close).
Live ARDY/CLoSD drivers require separately validated model environments; replay needs neither.
class MotionSession(private val backend: MotionBackend, val queueCapacity: Int = 32,
val buffer: MotionBuffer = MotionBuffer()) : AutoCloseable {
val telemetry = MotionTelemetry()
val skeleton get() = backend.skeleton
val capabilities get() = backend.capabilities
@Volatile var latest: MotionFrame? = null; private set
@Volatile var failure: Throwable? = null; private set
@Volatile var closed = false; private set
private var revision = 0L
private data class Pending(val operation: MotionOperation, val command: MotionCommand?, val seconds: Double?,
val future: CompletableFuture<Any?>)
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val lock = Object()
private val queue = ArrayDeque<Pending>()
private val subscribers = CopyOnWriteArrayList<(MotionFrame) -> Unit>()
private var recorder: MotionRecorder? = null
private val edits = LinkedHashMap<Long, Long>()
private val delaySamples = ArrayDeque<Double>()
private val backendClosed = AtomicBoolean()
private var active: CompletableFuture<Any?>? = null
private val worker: Thread
private val ownerScope = RunScope.current()
init
init {
require(queueCapacity in 1..1024)
worker = Thread({ work() }, "field-motion-${capabilities.backend}").apply { isDaemon = true; start() }
ownerScope?.own(this, ReleasePolicy.RUNNING)
}
fun subscribe(listener: (MotionFrame) -> Unit): AutoCloseable = synchronized(lock) {
fun subscribe(listener: (MotionFrame) -> Unit): AutoCloseable = synchronized(lock) {
check(!closed)
subscribers.add(listener)
AutoCloseable { subscribers.remove(listener) }.also { RunScope.current()?.own(it, ReleasePolicy.RUNNING) }
}
fun record(directory: Path, configuration: JSONObject): MotionRecorder
fun record(directory: Path, configuration: JSONObject): MotionRecorder = synchronized(lock) {
check(!closed && recorder == null) { "Session closed or already recording" }
MotionRecorder(directory, skeleton, configuration).also { recorder = it }
}
fun setPrompt(text: String, effectiveTime: Double)
fun setPrompt(text: String, effectiveTime: Double) = submit(MotionOperation.PROMPT, effectiveTime, JSONObject().put("text", text))
fun setRootPlan(points: List<TimedRootPoint>, effectiveTime: Double): CompletableFuture<MotionApplied?>
fun setRootPlan(points: List<TimedRootPoint>, effectiveTime: Double): CompletableFuture<MotionApplied?> {
require(points.size <= 10000)
points.zipWithNext().forEach { (a, b) -> require(b.time > a.time) }
return submit(MotionOperation.ROOT_PLAN, effectiveTime, JSONObject().put("points", JSONArray(points.map { it.json() })))
}
fun setJointTargets(targets: List<JointTarget>, effectiveTime: Double): CompletableFuture<MotionApplied?>
fun setJointTargets(targets: List<JointTarget>, effectiveTime: Double): CompletableFuture<MotionApplied?> {
capabilities.require(MotionOperation.JOINT_TARGETS)
require(targets.size <= 10000)
targets.forEach { skeleton.joint(it.joint) }
return submit(MotionOperation.JOINT_TARGETS, effectiveTime, JSONObject().put("targets", JSONArray(targets.map { it.json() })))
}
fun attach(attachment: MotionAttachment, effectiveTime: Double): CompletableFuture<MotionApplied?>
fun attach(attachment: MotionAttachment, effectiveTime: Double): CompletableFuture<MotionApplied?> {
capabilities.require(MotionOperation.ATTACH)
skeleton.body(attachment.body)
return submit(MotionOperation.ATTACH, effectiveTime, attachment.json())
}
fun updateAttachment(attachment: MotionAttachment, effectiveTime: Double): CompletableFuture<MotionApplied?>
fun updateAttachment(attachment: MotionAttachment, effectiveTime: Double): CompletableFuture<MotionApplied?> {
capabilities.require(MotionOperation.UPDATE_ATTACHMENT)
skeleton.body(attachment.body)
return submit(MotionOperation.UPDATE_ATTACHMENT, effectiveTime, attachment.json())
}
fun detach(id: String, effectiveTime: Double)
fun detach(id: String, effectiveTime: Double) = submit(MotionOperation.DETACH, effectiveTime, JSONObject().put("id", id))
fun applyImpulse(body: String, localPoint: List<Double>, impulse: List<Double>, effectiveTime: Double): CompletableFuture<MotionApplied?>
Point is body-local metres; impulse is a world vector in N s.
fun applyImpulse(body: String, localPoint: List<Double>, impulse: List<Double>, effectiveTime: Double): CompletableFuture<MotionApplied?> {
capabilities.require(MotionOperation.IMPULSE)
skeleton.body(body)
require(localPoint.size == 3 && impulse.size == 3 && (localPoint + impulse).all(Double::isFinite))
return submit(MotionOperation.IMPULSE, effectiveTime, JSONObject().put("body", body).put("localPoint", localPoint).put("impulse", impulse))
}
fun reset(effectiveTime: Double) = submit(MotionOperation.RESET, effectiveTime, JSONObject())
fun advance(seconds: Double): CompletableFuture<MotionUpdate>
fun advance(seconds: Double): CompletableFuture<MotionUpdate> {
capabilities.require(MotionOperation.ADVANCE)
require(seconds.isFinite() && seconds > 0 && seconds <= 10)
val future = CompletableFuture<Any?>()
synchronized(lock) { enqueue(Pending(MotionOperation.ADVANCE, null, seconds, future)) }
return future.thenApply { it as MotionUpdate }
}
fun markVisible(frame: MotionFrame): Double?
Call when a renderer actually displays a revision; timings do not treat receipt as visibility.
fun markVisible(frame: MotionFrame): Double? = synchronized(lock) {
val submitted = edits[frame.revision] ?: return null
val ms = (System.nanoTime() - submitted) / 1e6
edits.keys.filter { it <= frame.revision }.forEach(edits::remove)
delaySamples.addLast(ms)
if (delaySamples.size > 1024) delaySamples.removeFirst()
recorder?.event(JSONObject().put("type", "visible").put("revision", frame.revision).put("frameIndex", frame.index).put("delayMs", ms))
ms
}
fun visibleDelayPercentile(fraction: Double): Double?
fun visibleDelayPercentile(fraction: Double): Double? = synchronized(lock) {
require(fraction in 0.0..1.0)
val sorted = delaySamples.sorted()
if (sorted.isEmpty()) null else sorted[(fraction * sorted.lastIndex).toInt()]
}
fun queued(): Int = synchronized(lock) { queue.size }
source — 94 lines, from line 129
private fun submit(operation: MotionOperation, time: Double, payload: JSONObject): CompletableFuture<MotionApplied?> {
capabilities.require(operation)
require(time.isFinite() && time >= 0)
synchronized(lock) {
check(!closed)
val future = CompletableFuture<Any?>()
val command = MotionCommand(++revision, operation, time, JSONObject(payload.toString()))
val coalescible = setOf(MotionOperation.ROOT_PLAN, MotionOperation.JOINT_TARGETS)
if (operation in coalescible) {
val iterator = queue.descendingIterator()
while (iterator.hasNext()) {
val old = iterator.next()
if (old.operation !in coalescible) break
if (old.operation == operation) {
iterator.remove(); old.future.complete(null)
recorder?.event(commandEvent(old.command!!, "superseded"))
edits.remove(old.command!!.revision)
}
}
}
enqueue(Pending(operation, command, null, future))
edits[command.revision] = System.nanoTime()
// Bounded diagnostics even if a backend never makes a command visible.
if (edits.size > 1024) edits.remove(edits.keys.first())
recorder?.event(commandEvent(command, "requested"))
return future.thenApply { it as MotionApplied? }
}
}
private fun enqueue(pending: Pending) {
check(!closed)
check(queue.size < queueCapacity) { "Motion queue full ($queueCapacity); wait for advance before submitting more work" }
queue.addLast(pending); lock.notifyAll()
}
private fun commandEvent(command: MotionCommand, type: String) = JSONObject().put("type", type)
.put("operation", command.operation.name).put("revision", command.revision).put("requestedTime", command.requestedTime)
.put("payload", command.payload)
private fun work() {
try {
while (true) {
val pending = synchronized(lock) {
while (queue.isEmpty() && !closed) lock.wait()
if (closed) null else queue.removeFirst().also { active = it.future }
} ?: break
try {
val c = pending.command
if (c != null) {
val applied = backend.command(c)
if (applied.appliedTime != null) {
require(applied.appliedTime.isFinite() && applied.appliedTime >= c.requestedTime && applied.frameIndex != null && applied.frameIndex >= 0)
} else require(applied.frameIndex == null)
synchronized(lock) {
recorder?.event(commandEvent(c, if (applied.appliedTime == null) "scheduled" else "applied")
.put("appliedTime", applied.appliedTime ?: JSONObject.NULL)
.put("frameIndex", applied.frameIndex ?: JSONObject.NULL).put("details", applied.details))
}
pending.future.complete(applied)
} else {
val update = backend.advance(pending.seconds!!)
telemetry.accept(update)
require(update.frames.size <= 10000) { "Backend returned an unbounded batch" }
synchronized(lock) { update.events.forEach { recorder?.event(it) } }
for (frame in update.frames) {
check(frame.executed.size == skeleton.size)
synchronized(lock) {
if (closed) break
recorder?.append(frame); buffer.add(frame); latest = frame
}
subscribers.forEach { listener -> runCatching { listener(frame) }.onFailure { failure = it } }
}
pending.future.complete(update)
}
} catch (e: Throwable) {
pending.future.completeExceptionally(e)
throw e
} finally {
synchronized(lock) { active = null }
}
}
} catch (e: InterruptedException) {
if (!closed) failure = e
} catch (e: Throwable) {
failure = e
} finally {
synchronized(lock) {
closed = true
queue.forEach { it.future.completeExceptionally(failure ?: IllegalStateException("Motion session closed")) }
queue.clear(); subscribers.clear(); edits.clear()
failure?.let { error -> runCatching { recorder?.event(JSONObject().put("type", "workerFailure").put("message", error.toString())) } }
runCatching { recorder?.close() }.onFailure { if (failure == null) failure = it }
}
closeBackend()
ownerScope?.disown(this)
}
}
override fun close()
Nonblocking on Field's runloop; the backend must make its close unblock pending I/O.
override fun close() {
ownerScope?.disown(this)
synchronized(lock) {
if (closed) return
closed = true; subscribers.clear(); lock.notifyAll()
active?.completeExceptionally(IllegalStateException("Motion session closed"))
queue.forEach { it.future.completeExceptionally(IllegalStateException("Motion session closed")) }
queue.clear()
}
worker.interrupt()
Thread({ closeBackend() }, "field-motion-close").apply { isDaemon = true; start() }
}
private fun closeBackend() {
if (backendClosed.compareAndSet(false, true)) runCatching { backend.close() }.onFailure { if (failure == null) failure = it }
}
companion object
fun replay(clip: MotionClip) = MotionSession(ReplayBackend(clip))
fun replay(recording: MotionRecording)
Replay poses and actual intervention/reset events from a portable recording.
fun replay(recording: MotionRecording) = MotionSession(ReplayBackend(recording.clip(), recording.events().toList()))
}
}
private class ReplayBackend(private val clip: MotionClip, events: List<JSONObject> = emptyList()) : MotionBackend {
private fun eventTime(e: JSONObject) = e.optDouble("physicsTime", e.optDouble("time", e.optDouble("appliedTime", e.optDouble("requestedTime", clip.startTime))))
private val events = events.sortedBy(::eventTime)
private var nextEvent = 0
override val skeleton = clip.skeleton
override val capabilities = MotionCapabilities("recording.replay", setOf(MotionOperation.ADVANCE),
JSONObject().put("resimulation", false).put("startTime", clip.startTime).put("endTime", clip.endTime))
private var next = 0
private var time = clip.startTime
override fun command(command: MotionCommand): MotionApplied = error("Recording playback does not change intentions or re-simulate")
override fun advance(seconds: Double): MotionUpdate {
time += seconds
val frames = ArrayList<MotionFrame>()
while (next < clip.frames.size && clip.frames[next].time <= time) frames.add(clip.frames[next++])
val emitted = ArrayList<JSONObject>()
while (nextEvent < events.size && eventTime(events[nextEvent]) <= time) emitted.add(events[nextEvent++])
return MotionUpdate(frames, emitted)
}
override fun close() = Unit
}