hunt
WO-4: KinematicTransformFrame, DataChannelHub, Axum telemetry, StatsSource wiring
e4d5e6c
Raw History Blame Contribute Delete
32.7 kB
//! miranda-core — shared types and constants used across every Miranda-Engine
//! crate (blendshape counts, spherical-harmonic coefficient counts, common
//! error types). This crate has no dependencies on any other workspace
//! crate — it is the foundation everything else builds on (see WO-1 design.md).
/// Sample rate for all audio on the Miranda IPC bus. Fixed at 16 kHz mono —
/// the target format for Parakeet/ASR-class models. Do not make this
/// runtime-configurable in v1; it is a pipeline-science constant.
pub const AUDIO_SAMPLE_RATE_HZ: u32 = 16_000;
/// Number of audio frames (samples) per `AudioChunk`. 160 frames at 16 kHz is
/// exactly 10 ms — the standard VAD/ASR streaming chunk size (Parakeet,
/// Whisper, and Amazon Transcribe Streaming all use 10 ms frames).
pub const AUDIO_CHUNK_FRAMES: usize = 160;
/// Number of ARKit standard face blend shape channels. This is the full
/// facial animation rig (eyes, brows, mouth, jaw, cheeks) that drives EVE's
/// live expression. Fixed by the pipeline science — do not parameterize.
pub const BLENDSHAPE_COUNT: usize = 52;
/// Number of L2 spherical harmonic lighting coefficients. Encodes the
/// low-frequency ambient lighting environment used by the Gaussian-splat
/// renderer (WO-5) to relight EVE dynamically.
pub const SH_COEFF_COUNT: usize = 9;
/// Canonical ARKit-52 blend shape channel indices — **the single source of
/// truth for this project.**
///
/// # Why this module exists
///
/// Before it, three mutually incompatible index schemes were circulating in
/// this repo's own documentation:
///
/// - Apple's canonical `ARFaceAnchor.BlendShapeLocation` serialization
/// order (`jawOpen` = 17, `mouthClose` = 18, `eyeBlinkRight` = 7)
/// - `.kiro/steering/pipeline-1-aws-native.md`'s viseme table
/// (`jawOpen` = #0, `mouthClose` = #19, `mouthFunnel` = #22,
/// `tongueOut` = #51)
/// - WO-3's own task directive (`eyeBlinkLeft` = 0, `eyeBlinkRight` = 1,
/// `eyeSquintLeft` = 18)
///
/// Those cannot all be correct, and a wrong blend shape index is the
/// nastiest class of bug in this subsystem: it compiles, it passes any
/// test that only checks value ranges, and it fails *only* as a wrong
/// muscle moving on EVE's face. Exactly the silent-failure profile the
/// project's CAT-5 tier exists to guard against.
///
/// This module adopts **Apple's canonical ordering** (the order
/// `ARFaceAnchor.blendShapes` is conventionally serialized in, and the
/// order every ARKit-compatible rig and exporter expects). The other two
/// schemes are superseded. Every table, oscillator, and solver in this
/// project must reference channels through these constants **by name** —
/// never by a bare integer literal — so that a mapping mistake becomes a
/// compile error or an obviously-wrong name at the call site rather than
/// an invisible off-by-N.
pub mod arkit {
// ---- Left eye (0-6) ----
pub const EYE_BLINK_LEFT: usize = 0;
pub const EYE_LOOK_DOWN_LEFT: usize = 1;
pub const EYE_LOOK_IN_LEFT: usize = 2;
pub const EYE_LOOK_OUT_LEFT: usize = 3;
pub const EYE_LOOK_UP_LEFT: usize = 4;
pub const EYE_SQUINT_LEFT: usize = 5;
pub const EYE_WIDE_LEFT: usize = 6;
// ---- Right eye (7-13) ----
pub const EYE_BLINK_RIGHT: usize = 7;
pub const EYE_LOOK_DOWN_RIGHT: usize = 8;
pub const EYE_LOOK_IN_RIGHT: usize = 9;
pub const EYE_LOOK_OUT_RIGHT: usize = 10;
pub const EYE_LOOK_UP_RIGHT: usize = 11;
pub const EYE_SQUINT_RIGHT: usize = 12;
pub const EYE_WIDE_RIGHT: usize = 13;
// ---- Jaw (14-17) ----
pub const JAW_FORWARD: usize = 14;
pub const JAW_LEFT: usize = 15;
pub const JAW_RIGHT: usize = 16;
pub const JAW_OPEN: usize = 17;
// ---- Mouth (18-40) ----
pub const MOUTH_CLOSE: usize = 18;
pub const MOUTH_FUNNEL: usize = 19;
pub const MOUTH_PUCKER: usize = 20;
pub const MOUTH_LEFT: usize = 21;
pub const MOUTH_RIGHT: usize = 22;
pub const MOUTH_SMILE_LEFT: usize = 23;
pub const MOUTH_SMILE_RIGHT: usize = 24;
pub const MOUTH_FROWN_LEFT: usize = 25;
pub const MOUTH_FROWN_RIGHT: usize = 26;
pub const MOUTH_DIMPLE_LEFT: usize = 27;
pub const MOUTH_DIMPLE_RIGHT: usize = 28;
pub const MOUTH_STRETCH_LEFT: usize = 29;
pub const MOUTH_STRETCH_RIGHT: usize = 30;
pub const MOUTH_ROLL_LOWER: usize = 31;
pub const MOUTH_ROLL_UPPER: usize = 32;
pub const MOUTH_SHRUG_LOWER: usize = 33;
pub const MOUTH_SHRUG_UPPER: usize = 34;
pub const MOUTH_PRESS_LEFT: usize = 35;
pub const MOUTH_PRESS_RIGHT: usize = 36;
pub const MOUTH_LOWER_DOWN_LEFT: usize = 37;
pub const MOUTH_LOWER_DOWN_RIGHT: usize = 38;
pub const MOUTH_UPPER_UP_LEFT: usize = 39;
pub const MOUTH_UPPER_UP_RIGHT: usize = 40;
// ---- Brows (41-45) ----
pub const BROW_DOWN_LEFT: usize = 41;
pub const BROW_DOWN_RIGHT: usize = 42;
pub const BROW_INNER_UP: usize = 43;
pub const BROW_OUTER_UP_LEFT: usize = 44;
pub const BROW_OUTER_UP_RIGHT: usize = 45;
// ---- Cheeks (46-48) ----
pub const CHEEK_PUFF: usize = 46;
pub const CHEEK_SQUINT_LEFT: usize = 47;
pub const CHEEK_SQUINT_RIGHT: usize = 48;
// ---- Nose (49-50) ----
pub const NOSE_SNEER_LEFT: usize = 49;
pub const NOSE_SNEER_RIGHT: usize = 50;
// ---- Tongue (51) ----
pub const TONGUE_OUT: usize = 51;
/// All 52 channel names in canonical index order. Index `i` of this
/// array is the name of channel `i` — used for diagnostics, telemetry
/// labels, and the tests that prove this module is internally
/// consistent.
pub const CHANNEL_NAMES: [&str; super::BLENDSHAPE_COUNT] = [
"eyeBlinkLeft",
"eyeLookDownLeft",
"eyeLookInLeft",
"eyeLookOutLeft",
"eyeLookUpLeft",
"eyeSquintLeft",
"eyeWideLeft",
"eyeBlinkRight",
"eyeLookDownRight",
"eyeLookInRight",
"eyeLookOutRight",
"eyeLookUpRight",
"eyeSquintRight",
"eyeWideRight",
"jawForward",
"jawLeft",
"jawRight",
"jawOpen",
"mouthClose",
"mouthFunnel",
"mouthPucker",
"mouthLeft",
"mouthRight",
"mouthSmileLeft",
"mouthSmileRight",
"mouthFrownLeft",
"mouthFrownRight",
"mouthDimpleLeft",
"mouthDimpleRight",
"mouthStretchLeft",
"mouthStretchRight",
"mouthRollLower",
"mouthRollUpper",
"mouthShrugLower",
"mouthShrugUpper",
"mouthPressLeft",
"mouthPressRight",
"mouthLowerDownLeft",
"mouthLowerDownRight",
"mouthUpperUpLeft",
"mouthUpperUpRight",
"browDownLeft",
"browDownRight",
"browInnerUp",
"browOuterUpLeft",
"browOuterUpRight",
"cheekPuff",
"cheekSquintLeft",
"cheekSquintRight",
"noseSneerLeft",
"noseSneerRight",
"tongueOut",
];
/// Looks up a channel index by its ARKit name. Returns `None` for an
/// unknown name rather than panicking, so a caller parsing an external
/// rig manifest can report a clear error instead of aborting.
pub fn index_of(name: &str) -> Option<usize> {
CHANNEL_NAMES.iter().position(|&n| n == name)
}
}
/// A raw audio chunk from the microphone or a VAD pre-buffer.
///
/// `#[repr(C)]` fixes the memory layout to match the C ABI so this struct is
/// identical whether read from Rust, from a future C FFI binding (e.g.
/// parakeet.cpp in WO-2), or from any other language sharing this bus.
///
/// `bytemuck::Pod` and `bytemuck::Zeroable` are compile-time proofs that this
/// struct has no padding, no uninitialized bytes, and no invalid bit
/// patterns — required for safe casting to/from `&[u8]` when writing to and
/// reading from the shared-memory mmap in `miranda-ipc`.
///
/// Total size: 8 (timestamp_us) + 4 (sample_rate) + 4 (frame_count)
/// + 640 (samples: 160 × 4 bytes) = 656 bytes.
#[repr(C)]
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct AudioChunk {
/// Microseconds since harness start.
pub timestamp_us: u64,
/// Always `AUDIO_SAMPLE_RATE_HZ` in v1.
pub sample_rate: u32,
/// Number of valid samples in `samples` (may be less than
/// `AUDIO_CHUNK_FRAMES` for a partial final chunk).
pub frame_count: u32,
/// Raw f32 PCM samples, mono.
pub samples: [f32; AUDIO_CHUNK_FRAMES],
}
/// The 52 ARKit blend shape weights that drive facial animation on EVE.
/// These are normalized weights in `[0.0, 1.0]`. WO-3 (ARKit-52 SIMD
/// kinematics) is the primary writer under Pipeline 2; the Polly viseme
/// adapter is the primary writer under Pipeline 1. The renderer (WO-5) is
/// the primary reader in both.
///
/// Total size: 8 (timestamp_us) + 208 (weights: 52 × 4 bytes) = 216 bytes.
#[repr(C)]
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct BlendshapeFrame {
/// Microseconds since harness start.
pub timestamp_us: u64,
/// ARKit-52 standard blend shape weights.
pub weights: [f32; BLENDSHAPE_COUNT],
}
/// L2 spherical harmonic lighting coefficients — 9 floats encoding the
/// low-frequency ambient lighting environment. Used by the Gaussian-splat
/// renderer (WO-5) to relight EVE's render dynamically.
///
/// Total size: 8 (timestamp_us) + 36 (coefficients: 9 × 4 bytes) + 4
/// (explicit tail padding) = 48 bytes.
///
/// Note on the padding field: `design.md` states this struct's size as 44
/// bytes (8 + 36), but that arithmetic does not account for `#[repr(C)]`
/// alignment rules. Because `timestamp_us: u64` forces 8-byte alignment on
/// the whole struct, and a C-ABI struct's total size must be a multiple of
/// its alignment, the compiler would otherwise insert 4 bytes of *hidden*
/// trailing padding to round 44 up to 48 — and `bytemuck::Pod` correctly
/// refuses to derive over hidden padding, since those bytes would be
/// uninitialized and byte-casting over them would be unsound. `_padding` is
/// an explicit, zero-initialized field that accounts for those same 4
/// bytes, making the layout Pod-safe. The real, correct size is 48 bytes.
#[repr(C)]
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SphericalHarmonics {
/// Microseconds since harness start.
pub timestamp_us: u64,
/// L2 spherical harmonic coefficients.
pub coefficients: [f32; SH_COEFF_COUNT],
/// Explicit tail padding to satisfy `#[repr(C)]`'s size-is-multiple-of-
/// alignment rule without leaving hidden, uninitialized padding bytes.
/// Always zero; not semantically meaningful.
pub _padding: [u8; 4],
}
/// Returned when a ring buffer on the Miranda IPC bus is full and a producer
/// attempts to push.
///
/// Per WO-1 REQ-6, the bus must never silently drop a payload and never
/// corrupt existing buffer contents when full — it returns this typed error
/// so the caller decides the policy (drop, retry, or escalate as a real
/// telemetry event that THE VANITY can surface as backpressure on that node).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackpressureError {
/// Which ring rejected the push.
pub ring: RingId,
/// Capacity of that ring in slots, for telemetry/diagnostics.
pub capacity: usize,
}
/// Identifies which of the four rings on the bus a `BackpressureError`
/// came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RingId {
/// The `AudioChunk` ring.
Audio,
/// The `BlendshapeFrame` ring.
Blendshape,
/// The `SphericalHarmonics` ring.
SphericalHarmonics,
/// The `KinematicTransformFrame` ring (added WO-4).
Kinematic,
}
impl core::fmt::Display for BackpressureError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{:?} ring is full ({} slots); consumer is not draining fast enough",
self.ring, self.capacity
)
}
}
impl std::error::Error for BackpressureError {}
#[cfg(test)]
mod tests {
use super::*;
/// Compile-time-adjacent size check: confirms the struct layouts match
/// the byte sizes fixed by the pipeline science in design.md. If this
/// test fails, either a field was added/removed or padding crept in
/// (which would also fail the `Pod` derive at compile time first).
///
/// `SphericalHarmonics` is 48 bytes, not the 44 stated in design.md's
/// informal arithmetic (8 + 36) — see the doc comment on that struct
/// for why `#[repr(C)]` alignment requires 4 explicit padding bytes.
#[test]
fn struct_sizes_match_design_spec() {
assert_eq!(std::mem::size_of::<AudioChunk>(), 656);
assert_eq!(std::mem::size_of::<BlendshapeFrame>(), 216);
assert_eq!(std::mem::size_of::<SphericalHarmonics>(), 48);
}
/// The `arkit` constants and `CHANNEL_NAMES` are two parallel
/// representations of the same mapping, so they can drift apart
/// silently if edited independently. This test makes that drift a
/// build failure: every named constant must point at the array slot
/// bearing its own camelCase name.
#[test]
fn arkit_constants_agree_with_channel_names() {
use arkit::*;
let pairs: &[(usize, &str)] = &[
(EYE_BLINK_LEFT, "eyeBlinkLeft"),
(EYE_LOOK_DOWN_LEFT, "eyeLookDownLeft"),
(EYE_LOOK_IN_LEFT, "eyeLookInLeft"),
(EYE_LOOK_OUT_LEFT, "eyeLookOutLeft"),
(EYE_LOOK_UP_LEFT, "eyeLookUpLeft"),
(EYE_SQUINT_LEFT, "eyeSquintLeft"),
(EYE_WIDE_LEFT, "eyeWideLeft"),
(EYE_BLINK_RIGHT, "eyeBlinkRight"),
(EYE_LOOK_DOWN_RIGHT, "eyeLookDownRight"),
(EYE_LOOK_IN_RIGHT, "eyeLookInRight"),
(EYE_LOOK_OUT_RIGHT, "eyeLookOutRight"),
(EYE_LOOK_UP_RIGHT, "eyeLookUpRight"),
(EYE_SQUINT_RIGHT, "eyeSquintRight"),
(EYE_WIDE_RIGHT, "eyeWideRight"),
(JAW_FORWARD, "jawForward"),
(JAW_LEFT, "jawLeft"),
(JAW_RIGHT, "jawRight"),
(JAW_OPEN, "jawOpen"),
(MOUTH_CLOSE, "mouthClose"),
(MOUTH_FUNNEL, "mouthFunnel"),
(MOUTH_PUCKER, "mouthPucker"),
(MOUTH_LEFT, "mouthLeft"),
(MOUTH_RIGHT, "mouthRight"),
(MOUTH_SMILE_LEFT, "mouthSmileLeft"),
(MOUTH_SMILE_RIGHT, "mouthSmileRight"),
(MOUTH_FROWN_LEFT, "mouthFrownLeft"),
(MOUTH_FROWN_RIGHT, "mouthFrownRight"),
(MOUTH_DIMPLE_LEFT, "mouthDimpleLeft"),
(MOUTH_DIMPLE_RIGHT, "mouthDimpleRight"),
(MOUTH_STRETCH_LEFT, "mouthStretchLeft"),
(MOUTH_STRETCH_RIGHT, "mouthStretchRight"),
(MOUTH_ROLL_LOWER, "mouthRollLower"),
(MOUTH_ROLL_UPPER, "mouthRollUpper"),
(MOUTH_SHRUG_LOWER, "mouthShrugLower"),
(MOUTH_SHRUG_UPPER, "mouthShrugUpper"),
(MOUTH_PRESS_LEFT, "mouthPressLeft"),
(MOUTH_PRESS_RIGHT, "mouthPressRight"),
(MOUTH_LOWER_DOWN_LEFT, "mouthLowerDownLeft"),
(MOUTH_LOWER_DOWN_RIGHT, "mouthLowerDownRight"),
(MOUTH_UPPER_UP_LEFT, "mouthUpperUpLeft"),
(MOUTH_UPPER_UP_RIGHT, "mouthUpperUpRight"),
(BROW_DOWN_LEFT, "browDownLeft"),
(BROW_DOWN_RIGHT, "browDownRight"),
(BROW_INNER_UP, "browInnerUp"),
(BROW_OUTER_UP_LEFT, "browOuterUpLeft"),
(BROW_OUTER_UP_RIGHT, "browOuterUpRight"),
(CHEEK_PUFF, "cheekPuff"),
(CHEEK_SQUINT_LEFT, "cheekSquintLeft"),
(CHEEK_SQUINT_RIGHT, "cheekSquintRight"),
(NOSE_SNEER_LEFT, "noseSneerLeft"),
(NOSE_SNEER_RIGHT, "noseSneerRight"),
(TONGUE_OUT, "tongueOut"),
];
assert_eq!(
pairs.len(),
BLENDSHAPE_COUNT,
"every one of the 52 channels must be covered by this test"
);
for &(idx, name) in pairs {
assert_eq!(
arkit::CHANNEL_NAMES[idx], name,
"constant for {name} points at index {idx}, which is named \
'{}' — the constants and CHANNEL_NAMES have drifted apart",
arkit::CHANNEL_NAMES[idx]
);
assert_eq!(
arkit::index_of(name),
Some(idx),
"index_of({name}) disagrees with its named constant"
);
}
}
/// No duplicate indices and full coverage of 0..52 — a copy-paste slip
/// that gave two channels the same index would otherwise silently mean
/// one muscle is unreachable and another is double-driven.
#[test]
fn arkit_indices_are_a_complete_bijection() {
let mut seen = [false; BLENDSHAPE_COUNT];
for (i, name) in arkit::CHANNEL_NAMES.iter().enumerate() {
assert!(!seen[i], "index {i} listed twice");
seen[i] = true;
assert!(!name.is_empty(), "channel {i} has an empty name");
// Names must be unique too, else index_of() would be ambiguous.
let first = arkit::CHANNEL_NAMES.iter().position(|n| n == name).unwrap();
assert_eq!(first, i, "duplicate channel name {name:?}");
}
assert!(seen.iter().all(|&s| s), "not all 52 indices are covered");
}
#[test]
fn arkit_index_of_rejects_unknown_names() {
assert_eq!(arkit::index_of("notARealChannel"), None);
assert_eq!(arkit::index_of(""), None);
// Case matters — ARKit names are camelCase and a case-folded match
// would let a subtly-wrong manifest through.
assert_eq!(arkit::index_of("jawopen"), None);
}
}
// ── KinematicTransformFrame ──────────────────────────────────────────────────
/// Number of skeletal joints carried in [`KinematicTransformFrame`].
///
/// HEAD, NECK, SHOULDER_LEFT, SHOULDER_RIGHT — the four joints the
/// respiratory oscillator (WO-3 T5) and future full-body kinematics need
/// to route. ARKit-52 is face-only and structurally cannot carry these, so
/// they travel on a parallel frame type rather than being smuggled into an
/// unrelated face channel.
pub const KINEMATIC_JOINT_COUNT: usize = 4;
/// Index constants for the joints in [`KinematicTransformFrame`].
///
/// Matches the order the renderer (WO-5) expects: head before neck so the
/// dependency chain is top-down.
pub mod kinematic_joints {
pub const HEAD: usize = 0;
pub const NECK: usize = 1;
pub const SHOULDER_LEFT: usize = 2;
pub const SHOULDER_RIGHT: usize = 3;
/// Human-readable names in index order, for telemetry and debug.
pub const JOINT_NAMES: [&str; super::KINEMATIC_JOINT_COUNT] =
["head", "neck", "shoulder_left", "shoulder_right"];
}
/// A unit quaternion representing a joint's local rotation, stored as
/// `[x, y, z, w]` in the standard conventions used by every game-engine
/// and animation tool this project targets (GLM, Three.js, Unity, Omniverse).
///
/// - Identity: `[0.0, 0.0, 0.0, 1.0]`.
/// - `w = 1.0` → no rotation. `w = 0.0, xyz = axis` → 180° rotation.
/// - **Must be unit-length.** Consumers normalize defensively, but a
/// non-unit quaternion is a producer bug — it will produce scale artefacts
/// on the rendered mesh.
///
/// Size: 4 × f32 = 16 bytes.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Quaternion {
pub x: f32,
pub y: f32,
pub z: f32,
pub w: f32,
}
impl Quaternion {
/// The identity rotation — no change from the bind pose.
pub const IDENTITY: Self = Self { x: 0.0, y: 0.0, z: 0.0, w: 1.0 };
/// Builds a quaternion from a rotation around the X axis (pitch), in
/// radians. Positive angle tilts the joint forward (head nods down).
#[inline]
pub fn from_angle_x(radians: f32) -> Self {
let (s, c) = (radians * 0.5).sin_cos();
Self { x: s, y: 0.0, z: 0.0, w: c }
}
/// Builds a quaternion from a rotation around the Y axis (yaw), in
/// radians.
#[inline]
pub fn from_angle_y(radians: f32) -> Self {
let (s, c) = (radians * 0.5).sin_cos();
Self { x: 0.0, y: s, z: 0.0, w: c }
}
/// Builds a quaternion from a rotation around the Z axis (roll), in
/// radians.
#[inline]
pub fn from_angle_z(radians: f32) -> Self {
let (s, c) = (radians * 0.5).sin_cos();
Self { x: 0.0, y: 0.0, z: s, w: c }
}
/// Squared magnitude — should be 1.0 for a valid rotation quaternion.
#[inline]
pub fn norm_sq(self) -> f32 {
self.x * self.x + self.y * self.y + self.z * self.z + self.w * self.w
}
/// Normalises to unit length. Returns `IDENTITY` if the quaternion is
/// degenerate (zero or near-zero magnitude) rather than propagating NaN.
#[inline]
pub fn normalised(self) -> Self {
let n = self.norm_sq().sqrt();
if n < f32::EPSILON {
return Self::IDENTITY;
}
let inv = 1.0 / n;
Self {
x: self.x * inv,
y: self.y * inv,
z: self.z * inv,
w: self.w * inv,
}
}
/// Returns `true` if this quaternion is within `tolerance` of unit
/// length. Useful in tests and assertions.
#[inline]
pub fn is_unit(&self, tolerance: f32) -> bool {
(self.norm_sq() - 1.0).abs() < tolerance
}
}
impl Default for Quaternion {
fn default() -> Self {
Self::IDENTITY
}
}
/// Body-rig kinematic transform frame — joint quaternions for the skeletal
/// joints that ARKit-52's face-only blendshape frame cannot carry.
///
/// This is the companion to [`BlendshapeFrame`], travelling in lockstep
/// through the transport layer. Together they carry the complete per-frame
/// face + body state that EVE's renderer needs.
///
/// # Layout (WO-4 defined — do not add fields without updating the IPC ring)
///
/// ```text
/// offset 0 : timestamp_us : u64 → 8 bytes
/// offset 8 : joints : [Quaternion; 4] → 64 bytes (4 × 16)
/// offset 72 : head_pitch_deg : f32 → 4 bytes
/// offset 76 : clavicle_rise : f32 → 4 bytes
/// offset 80 : _reserved : [u8; 8] → 8 bytes (explicit pad)
/// ─────────────────────────────────────────────────────
/// total 88 bytes
/// ```
///
/// The `_reserved` field is zero on write and must be ignored on read.
/// It provides expansion room (e.g. for a spine joint, shoulder elevation
/// scalar, or protocol version byte) without breaking the mmap layout or
/// existing consumers.
///
/// # The WO-3 gap this closes
///
/// `miranda-nodes/src/breath.rs` computes `head_pitch_deg` (head nodding
/// with the respiratory cycle) and `clavicle_rise` (chest rising on
/// inhalation). Both are physiologically correct values, but `BlendshapeFrame`
/// — ARKit-52 face weights only — has nowhere to put them. Rather than
/// silently smuggling them into an unrelated face channel, WO-3 documented
/// the gap and wrote a test asserting no smuggling occurred. This struct is
/// the promised fix: `BreathGenerator::build_transform_frame()` (WO-3
/// `breath.rs`) can now populate the right fields.
#[repr(C)]
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct KinematicTransformFrame {
/// Microseconds since harness start — matches [`BlendshapeFrame::timestamp_us`]
/// so the renderer can pair the two frames by timestamp.
pub timestamp_us: u64,
/// Local-space rotation quaternions for each joint, in
/// [`kinematic_joints`] index order. Identity quaternion (`w=1`) means
/// no deviation from the bind pose.
pub joints: [Quaternion; KINEMATIC_JOINT_COUNT],
/// Head nodding amplitude from the respiratory cycle, in degrees.
/// Positive = forward nod (chin toward chest). Signed — the head nods
/// slightly backward on the exhale.
///
/// This is a convenience scalar: the full rotation is also encoded in
/// `joints[HEAD]`, but consumers that only need the scalar can read
/// this field without decomposing a quaternion.
pub head_pitch_deg: f32,
/// Fractional clavicle rise from inhalation, in `[0, 1]`. Zero is the
/// resting position; 1.0 would be full elevation (never reached in
/// practice — normal tidal breathing drives this to roughly 0.3).
///
/// Also a convenience scalar mirroring `joints[SHOULDER_LEFT/RIGHT]`'s
/// vertical component.
pub clavicle_rise: f32,
/// Reserved for future fields. Always zero on write.
pub _reserved: [u8; 8],
}
impl KinematicTransformFrame {
/// Rest-pose frame — all joints at identity, scalar outputs zero.
pub const REST: Self = Self {
timestamp_us: 0,
joints: [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT],
head_pitch_deg: 0.0,
clavicle_rise: 0.0,
_reserved: [0; 8],
};
/// Builds a frame from the outputs of `BreathGenerator::tick()`.
///
/// Converts `head_pitch_deg` into a proper X-axis rotation quaternion
/// for the head joint and a matching smaller rotation for the neck
/// joint (neck typically participates at ≈40% of head travel). Shoulders
/// rise symmetrically by `clavicle_rise`.
pub fn from_breath(
timestamp_us: u64,
head_pitch_deg: f32,
clavicle_rise: f32,
) -> Self {
let head_rad = head_pitch_deg.to_radians();
// Neck participates at 40% of head pitch — anatomically, head motion
// distributes across cervical vertebrae, not just the atlanto-occipital
// joint. A 60/40 split between head and neck joints is the standard
// authoring convention used by major rigs (Metahuman, UE5 mannequin).
let neck_rad = head_rad * 0.4;
// Shoulder elevation: clavicle_rise in [0,1] maps to a Z-axis
// rotation of up to 4° per shoulder (typical quiet breathing range).
let shoulder_elevation_rad = clavicle_rise * 4.0f32.to_radians();
let mut joints = [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT];
joints[kinematic_joints::HEAD] = Quaternion::from_angle_x(head_rad).normalised();
joints[kinematic_joints::NECK] = Quaternion::from_angle_x(neck_rad).normalised();
// Shoulders: left rotates positive Z, right negative Z (symmetric
// elevation). The sign convention matches the Y-up, right-hand
// coordinate system used by Three.js / GLB rigs.
joints[kinematic_joints::SHOULDER_LEFT] =
Quaternion::from_angle_z(shoulder_elevation_rad).normalised();
joints[kinematic_joints::SHOULDER_RIGHT] =
Quaternion::from_angle_z(-shoulder_elevation_rad).normalised();
Self {
timestamp_us,
joints,
head_pitch_deg,
clavicle_rise,
_reserved: [0; 8],
}
}
}
impl Default for KinematicTransformFrame {
fn default() -> Self {
Self::REST
}
}
#[cfg(test)]
mod kinematic_tests {
use super::*;
#[test]
fn struct_size_is_exactly_88_bytes() {
assert_eq!(std::mem::size_of::<Quaternion>(), 16);
assert_eq!(
std::mem::size_of::<KinematicTransformFrame>(),
88,
"KinematicTransformFrame layout changed — update the IPC ring \
capacity and the transport framing"
);
}
#[test]
fn rest_frame_is_all_identity_and_zero() {
let f = KinematicTransformFrame::REST;
assert_eq!(f.timestamp_us, 0);
assert_eq!(f.head_pitch_deg, 0.0);
assert_eq!(f.clavicle_rise, 0.0);
assert_eq!(f._reserved, [0u8; 8]);
for j in f.joints.iter() {
assert_eq!(*j, Quaternion::IDENTITY);
}
}
#[test]
fn identity_quaternion_is_unit_length() {
assert!(Quaternion::IDENTITY.is_unit(1e-6));
}
#[test]
fn quaternion_from_angle_x_is_unit() {
for deg in [-90.0f32, -45.0, -1.0, 0.0, 1.0, 15.0, 45.0, 90.0] {
let q = Quaternion::from_angle_x(deg.to_radians());
assert!(
q.is_unit(1e-5),
"from_angle_x({deg}) norm² = {} (not 1)",
q.norm_sq()
);
}
}
#[test]
fn from_breath_produces_valid_quaternions() {
let f = KinematicTransformFrame::from_breath(1_000_000, 0.6, 0.3);
for (i, j) in f.joints.iter().enumerate() {
assert!(
j.is_unit(1e-5),
"joint {} quaternion is not unit: {:?}", i, j
);
}
assert_eq!(f.head_pitch_deg, 0.6);
assert_eq!(f.clavicle_rise, 0.3);
assert_eq!(f._reserved, [0u8; 8]);
}
#[test]
fn head_leads_neck() {
// Head joint must rotate more than neck — neck participates at 40%.
let f = KinematicTransformFrame::from_breath(0, 5.0, 0.0);
// The X component of a quaternion is sin(θ/2); larger pitch ⇒ larger |x|.
let head_x = f.joints[kinematic_joints::HEAD].x.abs();
let neck_x = f.joints[kinematic_joints::NECK].x.abs();
assert!(
head_x > neck_x,
"head x={head_x} should lead neck x={neck_x}"
);
assert!(
(head_x / neck_x - 1.0 / 0.4).abs() < 0.01,
"neck should be ~40% of head pitch, got ratio {}",
head_x / neck_x
);
}
#[test]
fn shoulders_are_symmetric_and_scale_with_clavicle_rise() {
let f_half = KinematicTransformFrame::from_breath(0, 0.0, 0.5);
let f_full = KinematicTransformFrame::from_breath(0, 0.0, 1.0);
let sl = f_half.joints[kinematic_joints::SHOULDER_LEFT];
let sr = f_half.joints[kinematic_joints::SHOULDER_RIGHT];
// Symmetric: Z components should be equal in magnitude, opposite in sign.
assert!(
(sl.z + sr.z).abs() < 1e-6,
"shoulders are not symmetric: L.z={} R.z={}", sl.z, sr.z
);
// Scale: full rise should rotate more than half rise.
let sl_full = f_full.joints[kinematic_joints::SHOULDER_LEFT];
assert!(
sl_full.z.abs() > sl.z.abs(),
"shoulder at rise=1.0 should exceed rise=0.5"
);
}
#[test]
fn zero_breath_produces_rest_pose_joints() {
let f = KinematicTransformFrame::from_breath(0, 0.0, 0.0);
for (i, j) in f.joints.iter().enumerate() {
assert!(
j.is_unit(1e-6) && (j.x.abs() < 1e-7) && (j.y.abs() < 1e-7) && (j.z.abs() < 1e-7),
"joint {i} should be identity at zero breath: {j:?}"
);
}
}
#[test]
fn bytemuck_pod_round_trip() {
// Proves the Pod derive is sound: casting to bytes and back must
// give an identical value, with no padding bytes that could be
// non-deterministic.
let original = KinematicTransformFrame::from_breath(42_000, 0.8, 0.25);
let bytes: &[u8] = bytemuck::bytes_of(&original);
assert_eq!(bytes.len(), 88);
let recovered: KinematicTransformFrame = *bytemuck::from_bytes(bytes);
// Compare field-by-field since f32 doesn't impl Eq.
assert_eq!(recovered.timestamp_us, original.timestamp_us);
assert_eq!(recovered.head_pitch_deg, original.head_pitch_deg);
assert_eq!(recovered.clavicle_rise, original.clavicle_rise);
for i in 0..KINEMATIC_JOINT_COUNT {
assert_eq!(recovered.joints[i], original.joints[i]);
}
}
/// Joint index names must agree with `JOINT_NAMES`.
#[test]
fn joint_constants_match_names_array() {
use kinematic_joints::*;
assert_eq!(JOINT_NAMES[HEAD], "head");
assert_eq!(JOINT_NAMES[NECK], "neck");
assert_eq!(JOINT_NAMES[SHOULDER_LEFT], "shoulder_left");
assert_eq!(JOINT_NAMES[SHOULDER_RIGHT], "shoulder_right");
}
/// Normalise must not panic or produce NaN on a zero quaternion.
#[test]
fn normalise_handles_degenerate_input() {
let q = Quaternion { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
let n = q.normalised();
assert_eq!(n, Quaternion::IDENTITY, "degenerate quaternion must return IDENTITY");
}
}