aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-animation/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-animation/src
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-animation/src')
-rw-r--r--crates/fparkan-animation/src/lib.rs1217
1 files changed, 1217 insertions, 0 deletions
diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs
new file mode 100644
index 0000000..9bf9ef5
--- /dev/null
+++ b/crates/fparkan-animation/src/lib.rs
@@ -0,0 +1,1217 @@
+#![forbid(unsafe_code)]
+#![allow(clippy::cast_precision_loss)]
+//! Deterministic animation sampling contracts.
+
+use std::fmt;
+
+/// Numeric profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum NumericProfile {
+ /// Portable reference.
+ PortableReference,
+ /// X87-compatible compatibility profile for captured parity vectors.
+ X87Compatibility,
+}
+
+/// Animation time in frames.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct AnimationTime(pub f32);
+
+/// Pose.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Pose {
+ /// Translation.
+ pub translation: [f32; 3],
+ /// Quaternion.
+ pub rotation: [f32; 4],
+}
+
+impl Default for Pose {
+ fn default() -> Self {
+ Self {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ }
+ }
+}
+
+/// Scalar animation key.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ScalarKey {
+ /// Frame number.
+ pub frame: u32,
+ /// Scalar value at the frame.
+ pub value: f32,
+}
+
+/// Pose animation key.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PoseKey {
+ /// Frame number.
+ pub frame: u32,
+ /// Pose at the frame.
+ pub pose: Pose,
+}
+
+/// Pose key addressed by a floating-point animation time.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct TimedPoseKey {
+ /// Key time in frames.
+ pub time: AnimationTime,
+ /// Pose at the time.
+ pub pose: Pose,
+}
+
+/// Decoded 24-byte animation key.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct AnimKey24 {
+ /// Key time.
+ pub time: AnimationTime,
+ /// Pose decoded from signed fixed-point channels.
+ pub pose: Pose,
+}
+
+/// Optional frame remapping table.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FrameMap {
+ attr_frame_count: u16,
+ frames: Vec<u16>,
+}
+
+/// Scalar track with a deterministic fallback.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ScalarTrack {
+ fallback: f32,
+ keys: Vec<ScalarKey>,
+}
+
+/// Pose track with a deterministic fallback.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PoseTrack {
+ fallback: Pose,
+ keys: Vec<PoseKey>,
+}
+
+/// Pose track keyed by floating-point animation times.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TimedPoseTrack {
+ fallback: Pose,
+ keys: Vec<TimedPoseKey>,
+}
+
+/// Parent index for a node in an animation hierarchy.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ParentIndex(pub Option<u16>);
+
+/// Node pose after hierarchy evaluation.
+#[derive(Clone, Debug, PartialEq)]
+pub struct NodePoseBuffer {
+ /// Global poses in node order.
+ pub poses: Vec<Pose>,
+}
+
+/// Difference between portable and x87-compatible samples.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct NumericProfileDifference {
+ /// Time that was sampled.
+ pub time: AnimationTime,
+ /// Per-axis translation delta: x87 - portable.
+ pub translation_delta: [f32; 3],
+ /// Per-component quaternion delta: x87 - portable.
+ pub rotation_delta: [f32; 4],
+}
+
+/// Material animation state.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MaterialAnimationState {
+ /// Time used by material phase evaluation.
+ pub time: AnimationTime,
+ /// Named deterministic random stream.
+ pub rng: NamedRngStream,
+}
+
+/// Named deterministic random stream.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct NamedRngStream {
+ state: u64,
+ calls: u64,
+}
+
+/// Animation sampling error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AnimationError {
+ /// Track keys are not sorted by frame or contain duplicate frames.
+ NonMonotonicKeys,
+ /// Time was NaN or infinite.
+ InvalidTime,
+ /// Quaternion could not be normalized.
+ InvalidQuaternion,
+ /// Input buffer size is invalid for the expected record stride.
+ InvalidSize,
+ /// Frame map entry points outside the clip frame count.
+ InvalidFrameMapValue {
+ /// Requested mapped frame.
+ frame: u16,
+ /// Declared frame count.
+ frame_count: u16,
+ },
+ /// Parent index is not before its child.
+ ParentOrder {
+ /// Child node index.
+ child: usize,
+ /// Parent node index.
+ parent: usize,
+ },
+ /// Parent graph contains a cycle.
+ ParentCycle {
+ /// Node where the cycle was detected.
+ node: usize,
+ },
+}
+
+impl fmt::Display for AnimationError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for AnimationError {}
+
+impl ScalarTrack {
+ /// Creates a scalar track.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly
+ /// sorted by frame.
+ pub fn new(fallback: f32, keys: Vec<ScalarKey>) -> Result<Self, AnimationError> {
+ validate_scalar_keys(&keys)?;
+ Ok(Self { fallback, keys })
+ }
+
+ /// Returns the keys in frame order.
+ #[must_use]
+ pub fn keys(&self) -> &[ScalarKey] {
+ &self.keys
+ }
+
+ /// Samples the scalar track with clamp-and-linear semantics.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite.
+ pub fn sample(&self, time: AnimationTime) -> Result<f32, AnimationError> {
+ validate_time(time)?;
+ let Some(first) = self.keys.first() else {
+ return Ok(self.fallback);
+ };
+ if time.0 <= first.frame as f32 {
+ return Ok(first.value);
+ }
+
+ for pair in self.keys.windows(2) {
+ let left = pair[0];
+ let right = pair[1];
+ let left_frame = left.frame as f32;
+ let right_frame = right.frame as f32;
+ if time.0 <= right_frame {
+ let span = right_frame - left_frame;
+ let t = if span == 0.0 {
+ 0.0
+ } else {
+ (time.0 - left_frame) / span
+ };
+ return Ok(lerp(left.value, right.value, t));
+ }
+ }
+
+ Ok(self.keys.last().map_or(self.fallback, |key| key.value))
+ }
+}
+
+impl AnimKey24 {
+ /// Decodes one 24-byte animation key.
+ ///
+ /// Layout: `position:f32x3`, `time:f32`, `rotation:i16x4` scaled by
+ /// `1/32767`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidSize`] when the record is not exactly
+ /// 24 bytes or [`AnimationError::InvalidTime`] when the key time is not
+ /// finite.
+ pub fn decode(bytes: &[u8]) -> Result<Self, AnimationError> {
+ if bytes.len() != 24 {
+ return Err(AnimationError::InvalidSize);
+ }
+ let translation = [
+ read_f32(bytes, 0)?,
+ read_f32(bytes, 4)?,
+ read_f32(bytes, 8)?,
+ ];
+ let time = AnimationTime(read_f32(bytes, 12)?);
+ validate_time(time)?;
+ let raw_rotation = [
+ f32::from(read_i16(bytes, 16)?) / 32767.0,
+ f32::from(read_i16(bytes, 18)?) / 32767.0,
+ f32::from(read_i16(bytes, 20)?) / 32767.0,
+ f32::from(read_i16(bytes, 22)?) / 32767.0,
+ ];
+ Ok(Self {
+ time,
+ pose: Pose {
+ translation,
+ rotation: raw_rotation,
+ },
+ })
+ }
+
+ /// Returns a pose ready for runtime sampling.
+ ///
+ /// Degenerate all-zero quaternions are treated as identity, matching the
+ /// safe static-node fallback used by legacy animation data.
+ #[must_use]
+ pub fn sampling_pose(&self) -> Pose {
+ let rotation = normalize_quat(self.pose.rotation).unwrap_or(Pose::default().rotation);
+ Pose {
+ translation: self.pose.translation,
+ rotation,
+ }
+ }
+}
+
+impl TimedPoseTrack {
+ /// Creates a pose track keyed by floating-point times.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly
+ /// sorted by time, [`AnimationError::InvalidTime`] when a key time is not
+ /// finite, or [`AnimationError::InvalidQuaternion`] when a key rotation
+ /// cannot be normalized.
+ pub fn new(fallback: Pose, keys: Vec<TimedPoseKey>) -> Result<Self, AnimationError> {
+ validate_pose(&fallback)?;
+ validate_timed_pose_keys(&keys)?;
+ Ok(Self { fallback, keys })
+ }
+
+ /// Returns keys in time order.
+ #[must_use]
+ pub fn keys(&self) -> &[TimedPoseKey] {
+ &self.keys
+ }
+
+ /// Samples the pose track with linear translation and normalized
+ /// quaternion interpolation.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite.
+ pub fn sample(&self, time: AnimationTime) -> Result<Pose, AnimationError> {
+ validate_time(time)?;
+ let Some(first) = self.keys.first() else {
+ return Ok(self.fallback);
+ };
+ if time.0 <= first.time.0 {
+ return Ok(first.pose);
+ }
+
+ for pair in self.keys.windows(2) {
+ let left = pair[0];
+ let right = pair[1];
+ if time.0 <= right.time.0 {
+ let span = right.time.0 - left.time.0;
+ let t = if span == 0.0 {
+ 0.0
+ } else {
+ (time.0 - left.time.0) / span
+ };
+ return blend_pose(left.pose, right.pose, t);
+ }
+ }
+
+ Ok(self.keys.last().map_or(self.fallback, |key| key.pose))
+ }
+}
+
+impl FrameMap {
+ /// Decodes a `u16` frame map from type-19 bytes and an attr frame count.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidSize`] when bytes are not u16-aligned.
+ pub fn decode(bytes: &[u8], attr_frame_count: u16) -> Result<Self, AnimationError> {
+ if !bytes.len().is_multiple_of(2) {
+ return Err(AnimationError::InvalidSize);
+ }
+ let mut frames = Vec::with_capacity(bytes.len() / 2);
+ for offset in (0..bytes.len()).step_by(2) {
+ frames.push(read_u16(bytes, offset)?);
+ }
+ Ok(Self {
+ attr_frame_count,
+ frames,
+ })
+ }
+
+ /// Resolves a logical frame through the optional map.
+ ///
+ /// Missing map entries and invalid mapped values fall back to the input
+ /// frame, which is the documented compatibility branch for incomplete
+ /// legacy clips.
+ #[must_use]
+ pub fn resolve_or_fallback(&self, logical_frame: u16) -> u16 {
+ let Some(mapped) = self.frames.get(usize::from(logical_frame)).copied() else {
+ return logical_frame;
+ };
+ if mapped < self.attr_frame_count {
+ mapped
+ } else {
+ logical_frame
+ }
+ }
+
+ /// Resolves a logical frame and reports invalid mapped values explicitly.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidFrameMapValue`] when the mapped frame is
+ /// outside the declared attr frame count.
+ pub fn resolve_strict(&self, logical_frame: u16) -> Result<u16, AnimationError> {
+ let Some(mapped) = self.frames.get(usize::from(logical_frame)).copied() else {
+ return Ok(logical_frame);
+ };
+ if mapped < self.attr_frame_count {
+ Ok(mapped)
+ } else {
+ Err(AnimationError::InvalidFrameMapValue {
+ frame: mapped,
+ frame_count: self.attr_frame_count,
+ })
+ }
+ }
+
+ /// Declared frame count from attributes.
+ #[must_use]
+ pub const fn attr_frame_count(&self) -> u16 {
+ self.attr_frame_count
+ }
+
+ /// Raw frame map values.
+ #[must_use]
+ pub fn frames(&self) -> &[u16] {
+ &self.frames
+ }
+}
+
+impl PoseTrack {
+ /// Creates a pose track.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly
+ /// sorted by frame, or [`AnimationError::InvalidQuaternion`] when a key
+ /// rotation cannot be normalized.
+ pub fn new(fallback: Pose, keys: Vec<PoseKey>) -> Result<Self, AnimationError> {
+ validate_pose(&fallback)?;
+ validate_pose_keys(&keys)?;
+ Ok(Self { fallback, keys })
+ }
+
+ /// Returns the keys in frame order.
+ #[must_use]
+ pub fn keys(&self) -> &[PoseKey] {
+ &self.keys
+ }
+
+ /// Samples the pose track with linear translation and normalized quaternion
+ /// interpolation.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite.
+ pub fn sample(
+ &self,
+ time: AnimationTime,
+ _profile: NumericProfile,
+ ) -> Result<Pose, AnimationError> {
+ validate_time(time)?;
+ let Some(first) = self.keys.first() else {
+ return Ok(self.fallback);
+ };
+ if time.0 <= first.frame as f32 {
+ return Ok(first.pose);
+ }
+
+ for pair in self.keys.windows(2) {
+ let left = pair[0];
+ let right = pair[1];
+ let left_frame = left.frame as f32;
+ let right_frame = right.frame as f32;
+ if time.0 <= right_frame {
+ let span = right_frame - left_frame;
+ let t = if span == 0.0 {
+ 0.0
+ } else {
+ (time.0 - left_frame) / span
+ };
+ return blend_pose(left.pose, right.pose, t);
+ }
+ }
+
+ Ok(self.keys.last().map_or(self.fallback, |key| key.pose))
+ }
+}
+
+impl NamedRngStream {
+ /// Creates a deterministic stream from a global seed and a stable stream
+ /// name.
+ #[must_use]
+ pub fn new(seed: u64, name: &str) -> Self {
+ let mut state = 0x9e37_79b9_7f4a_7c15_u64 ^ seed;
+ for byte in name.as_bytes() {
+ state ^= u64::from(*byte);
+ state = splitmix64(state);
+ }
+ if state == 0 {
+ state = 0x6a09_e667_f3bc_c909;
+ }
+ Self { state, calls: 0 }
+ }
+
+ /// Returns how many values have been generated.
+ #[must_use]
+ pub const fn calls(&self) -> u64 {
+ self.calls
+ }
+
+ /// Returns the next deterministic `u32`.
+ pub fn next_u32(&mut self) -> u32 {
+ self.calls = self.calls.wrapping_add(1);
+ self.state = splitmix64(self.state);
+ (self.state >> 32) as u32
+ }
+
+ /// Returns the next deterministic scalar in `[0, 1]`.
+ pub fn next_unit_f32(&mut self) -> f32 {
+ let value = self.next_u32() >> 8;
+ value as f32 / 0x00ff_ffff_u32 as f32
+ }
+}
+
+impl MaterialAnimationState {
+ /// Advances material time without drawing or emitting side effects.
+ #[must_use]
+ pub fn advanced(self, delta_frames: f32) -> Self {
+ Self {
+ time: AnimationTime(self.time.0 + delta_frames),
+ rng: self.rng,
+ }
+ }
+}
+
+/// Builds a canonical pose capture from a track and frame list.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when pose sampling fails.
+pub fn canonical_pose_capture(
+ track: &PoseTrack,
+ times: &[AnimationTime],
+) -> Result<Vec<u8>, AnimationError> {
+ let mut out = Vec::new();
+ for time in times {
+ let pose = track.sample(*time, NumericProfile::PortableReference)?;
+ out.extend_from_slice(b"P,");
+ write_f32_bits(&mut out, time.0);
+ for value in pose.translation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ for value in pose.rotation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ out.push(b'\n');
+ }
+ Ok(out)
+}
+
+/// Builds a canonical pose capture from a float-time track.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when pose sampling fails.
+pub fn canonical_timed_pose_capture(
+ track: &TimedPoseTrack,
+ times: &[AnimationTime],
+) -> Result<Vec<u8>, AnimationError> {
+ let mut out = Vec::new();
+ for time in times {
+ let pose = track.sample(*time)?;
+ out.extend_from_slice(b"P,");
+ write_f32_bits(&mut out, time.0);
+ for value in pose.translation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ for value in pose.rotation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ out.push(b'\n');
+ }
+ Ok(out)
+}
+
+/// Blends two optional poses.
+///
+/// When only one side is valid, the valid side is returned. When both sides are
+/// absent, [`AnimationError::InvalidQuaternion`] is returned as a deterministic
+/// invalid-pose marker.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when both inputs are invalid or quaternion
+/// interpolation cannot be normalized.
+pub fn blend_optional_pose(
+ left: Option<Pose>,
+ right: Option<Pose>,
+ weight: f32,
+) -> Result<Pose, AnimationError> {
+ match (left, right) {
+ (Some(left), Some(right)) => blend_pose(left, right, weight),
+ (Some(pose), None) | (None, Some(pose)) => Ok(pose),
+ (None, None) => Err(AnimationError::InvalidQuaternion),
+ }
+}
+
+/// Evaluates local poses into global poses with parent-before-child ordering.
+///
+/// # Errors
+///
+/// Returns [`AnimationError::ParentOrder`] when a parent appears after its
+/// child, or [`AnimationError::ParentCycle`] when a node is its own ancestor.
+pub fn evaluate_hierarchy(
+ parents: &[ParentIndex],
+ local_poses: &[Pose],
+) -> Result<NodePoseBuffer, AnimationError> {
+ if parents.len() != local_poses.len() {
+ return Err(AnimationError::InvalidSize);
+ }
+ for (index, parent) in parents.iter().enumerate() {
+ let Some(raw_parent) = parent.0 else {
+ continue;
+ };
+ let parent_index = usize::from(raw_parent);
+ if parent_index == index {
+ return Err(AnimationError::ParentCycle { node: index });
+ }
+ if parent_index > index {
+ return Err(AnimationError::ParentOrder {
+ child: index,
+ parent: parent_index,
+ });
+ }
+ }
+
+ let mut global = Vec::with_capacity(local_poses.len());
+ for (index, pose) in local_poses.iter().copied().enumerate() {
+ let composed = if let Some(parent) = parents[index].0 {
+ compose_pose(global[usize::from(parent)], pose)?
+ } else {
+ pose
+ };
+ global.push(composed);
+ }
+ Ok(NodePoseBuffer { poses: global })
+}
+
+/// Compares portable and x87-compatible profile samples explicitly.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when either profile fails to sample.
+pub fn compare_numeric_profiles(
+ track: &PoseTrack,
+ times: &[AnimationTime],
+) -> Result<Vec<NumericProfileDifference>, AnimationError> {
+ let mut out = Vec::with_capacity(times.len());
+ for time in times {
+ let portable = track.sample(*time, NumericProfile::PortableReference)?;
+ let x87 = track.sample(*time, NumericProfile::X87Compatibility)?;
+ out.push(NumericProfileDifference {
+ time: *time,
+ translation_delta: [
+ x87.translation[0] - portable.translation[0],
+ x87.translation[1] - portable.translation[1],
+ x87.translation[2] - portable.translation[2],
+ ],
+ rotation_delta: [
+ x87.rotation[0] - portable.rotation[0],
+ x87.rotation[1] - portable.rotation[1],
+ x87.rotation[2] - portable.rotation[2],
+ x87.rotation[3] - portable.rotation[3],
+ ],
+ });
+ }
+ Ok(out)
+}
+
+fn validate_scalar_keys(keys: &[ScalarKey]) -> Result<(), AnimationError> {
+ for pair in keys.windows(2) {
+ if pair[0].frame >= pair[1].frame {
+ return Err(AnimationError::NonMonotonicKeys);
+ }
+ }
+ Ok(())
+}
+
+fn validate_pose_keys(keys: &[PoseKey]) -> Result<(), AnimationError> {
+ for key in keys {
+ validate_pose(&key.pose)?;
+ }
+ for pair in keys.windows(2) {
+ if pair[0].frame >= pair[1].frame {
+ return Err(AnimationError::NonMonotonicKeys);
+ }
+ }
+ Ok(())
+}
+
+fn validate_timed_pose_keys(keys: &[TimedPoseKey]) -> Result<(), AnimationError> {
+ for key in keys {
+ validate_time(key.time)?;
+ validate_pose(&key.pose)?;
+ }
+ for pair in keys.windows(2) {
+ if pair[0].time.0 >= pair[1].time.0 {
+ return Err(AnimationError::NonMonotonicKeys);
+ }
+ }
+ Ok(())
+}
+
+fn validate_pose(pose: &Pose) -> Result<(), AnimationError> {
+ normalize_quat(pose.rotation).map(|_| ())
+}
+
+fn validate_time(time: AnimationTime) -> Result<(), AnimationError> {
+ if time.0.is_finite() {
+ Ok(())
+ } else {
+ Err(AnimationError::InvalidTime)
+ }
+}
+
+fn blend_pose(left: Pose, right: Pose, t: f32) -> Result<Pose, AnimationError> {
+ let mut right_rotation = right.rotation;
+ if dot4(left.rotation, right_rotation) < 0.0 {
+ for value in &mut right_rotation {
+ *value = -*value;
+ }
+ }
+
+ Ok(Pose {
+ translation: [
+ lerp(left.translation[0], right.translation[0], t),
+ lerp(left.translation[1], right.translation[1], t),
+ lerp(left.translation[2], right.translation[2], t),
+ ],
+ rotation: normalize_quat([
+ lerp(left.rotation[0], right_rotation[0], t),
+ lerp(left.rotation[1], right_rotation[1], t),
+ lerp(left.rotation[2], right_rotation[2], t),
+ lerp(left.rotation[3], right_rotation[3], t),
+ ])?,
+ })
+}
+
+fn normalize_quat(quat: [f32; 4]) -> Result<[f32; 4], AnimationError> {
+ let len2 = dot4(quat, quat);
+ if !len2.is_finite() || len2 <= f32::EPSILON {
+ return Err(AnimationError::InvalidQuaternion);
+ }
+ let inv = len2.sqrt().recip();
+ Ok([quat[0] * inv, quat[1] * inv, quat[2] * inv, quat[3] * inv])
+}
+
+fn dot4(left: [f32; 4], right: [f32; 4]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + left[3] * right[3]
+}
+
+fn lerp(left: f32, right: f32, t: f32) -> f32 {
+ left + (right - left) * t
+}
+
+fn splitmix64(mut value: u64) -> u64 {
+ value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ let mut mixed = value;
+ mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ mixed ^ (mixed >> 31)
+}
+
+fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
+ out.extend_from_slice(format!("{:08x}", value.to_bits()).as_bytes());
+}
+
+fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
+ Ok(Pose {
+ translation: [
+ parent.translation[0] + child.translation[0],
+ parent.translation[1] + child.translation[1],
+ parent.translation[2] + child.translation[2],
+ ],
+ rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
+ })
+}
+
+fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
+ let [lx, ly, lz, lw] = left;
+ let [rx, ry, rz, rw] = right;
+ [
+ lw * rx + lx * rw + ly * rz - lz * ry,
+ lw * ry - lx * rz + ly * rw + lz * rx,
+ lw * rz + lx * ry - ly * rx + lz * rw,
+ lw * rw - lx * rx - ly * ry - lz * rz,
+ ]
+}
+
+fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, AnimationError> {
+ let raw = bytes
+ .get(offset..offset + 2)
+ .ok_or(AnimationError::InvalidSize)?;
+ Ok(u16::from_le_bytes(
+ raw.try_into().map_err(|_| AnimationError::InvalidSize)?,
+ ))
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, AnimationError> {
+ let raw = bytes
+ .get(offset..offset + 4)
+ .ok_or(AnimationError::InvalidSize)?;
+ Ok(u32::from_le_bytes(
+ raw.try_into().map_err(|_| AnimationError::InvalidSize)?,
+ ))
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Result<f32, AnimationError> {
+ Ok(f32::from_bits(read_u32(bytes, offset)?))
+}
+
+fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, AnimationError> {
+ let raw = bytes
+ .get(offset..offset + 2)
+ .ok_or(AnimationError::InvalidSize)?;
+ Ok(i16::from_le_bytes(
+ raw.try_into().map_err(|_| AnimationError::InvalidSize)?,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn scalar_track_clamps_and_interpolates() {
+ let track = ScalarTrack::new(
+ -1.0,
+ vec![
+ ScalarKey {
+ frame: 10,
+ value: 2.0,
+ },
+ ScalarKey {
+ frame: 20,
+ value: 6.0,
+ },
+ ],
+ )
+ .expect("track");
+
+ assert_eq!(track.sample(AnimationTime(0.0)).expect("sample"), 2.0);
+ assert_eq!(track.sample(AnimationTime(15.0)).expect("sample"), 4.0);
+ assert_eq!(track.sample(AnimationTime(30.0)).expect("sample"), 6.0);
+ }
+
+ #[test]
+ fn anim_key24_decodes_signed_quaternion() {
+ let mut bytes = [0_u8; 24];
+ bytes[0..4].copy_from_slice(&(-1.0_f32).to_bits().to_le_bytes());
+ bytes[4..8].copy_from_slice(&(2.0_f32).to_bits().to_le_bytes());
+ bytes[8..12].copy_from_slice(&(0.0_f32).to_bits().to_le_bytes());
+ bytes[12..16].copy_from_slice(&(12.5_f32).to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[18..20].copy_from_slice(&(-23170_i16).to_le_bytes());
+ bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[22..24].copy_from_slice(&23170_i16.to_le_bytes());
+
+ let key = AnimKey24::decode(&bytes).expect("key");
+
+ assert_eq!(key.time, AnimationTime(12.5));
+ assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
+ assert!(key.pose.rotation[1] < 0.0);
+ assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ }
+
+ #[test]
+ fn frame_map_decodes_u16_and_uses_attr_frame_count() {
+ let map = FrameMap::decode(&[2, 0, 4, 0], 5).expect("map");
+
+ assert_eq!(map.attr_frame_count(), 5);
+ assert_eq!(map.frames(), &[2, 4]);
+ assert_eq!(map.resolve_strict(0).expect("mapped"), 2);
+ assert_eq!(map.resolve_strict(2).expect("fallback missing"), 2);
+ }
+
+ #[test]
+ fn frame_map_falls_back_when_absent_or_invalid() {
+ let empty = FrameMap::decode(&[], 3).expect("empty map");
+ let invalid = FrameMap::decode(&[5, 0], 3).expect("invalid map");
+
+ assert_eq!(empty.resolve_or_fallback(2), 2);
+ assert_eq!(invalid.resolve_or_fallback(0), 0);
+ assert_eq!(
+ invalid.resolve_strict(0).expect_err("invalid mapped value"),
+ AnimationError::InvalidFrameMapValue {
+ frame: 5,
+ frame_count: 3,
+ }
+ );
+ }
+
+ #[test]
+ fn exact_key_time_returns_exact_pose() {
+ let target = Pose {
+ translation: [1.0, 2.0, 3.0],
+ rotation: [0.0, 0.0, 1.0, 0.0],
+ };
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 8,
+ pose: target,
+ },
+ ],
+ )
+ .expect("track");
+
+ assert_eq!(
+ track
+ .sample(AnimationTime(8.0), NumericProfile::PortableReference)
+ .expect("pose"),
+ target
+ );
+ }
+
+ #[test]
+ fn scalar_track_uses_fallback_when_empty() {
+ let track = ScalarTrack::new(3.5, Vec::new()).expect("track");
+
+ assert_eq!(track.sample(AnimationTime(4.0)).expect("sample"), 3.5);
+ }
+
+ #[test]
+ fn rejects_unsorted_keys_and_invalid_time() {
+ let track = ScalarTrack::new(
+ 0.0,
+ vec![
+ ScalarKey {
+ frame: 7,
+ value: 0.0,
+ },
+ ScalarKey {
+ frame: 7,
+ value: 1.0,
+ },
+ ],
+ );
+ assert_eq!(
+ track.expect_err("unsorted"),
+ AnimationError::NonMonotonicKeys
+ );
+
+ let track = ScalarTrack::new(0.0, Vec::new()).expect("track");
+ assert_eq!(
+ track
+ .sample(AnimationTime(f32::NAN))
+ .expect_err("invalid time"),
+ AnimationError::InvalidTime
+ );
+ }
+
+ #[test]
+ fn pose_track_blends_translation_and_rotation() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 10,
+ pose: Pose {
+ translation: [10.0, 20.0, 30.0],
+ rotation: [0.0, 1.0, 0.0, 0.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let pose = track
+ .sample(AnimationTime(5.0), NumericProfile::PortableReference)
+ .expect("pose");
+
+ assert_eq!(pose.translation, [5.0, 10.0, 15.0]);
+ assert!((pose.rotation[1] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ assert!((pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ }
+
+ #[test]
+ fn timed_pose_track_samples_float_key_times() {
+ let track = TimedPoseTrack::new(
+ Pose::default(),
+ vec![
+ TimedPoseKey {
+ time: AnimationTime(1.5),
+ pose: Pose::default(),
+ },
+ TimedPoseKey {
+ time: AnimationTime(3.5),
+ pose: Pose {
+ translation: [4.0, 8.0, 12.0],
+ rotation: [0.0, 0.0, 1.0, 0.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let pose = track.sample(AnimationTime(2.5)).expect("pose");
+
+ assert_eq!(pose.translation, [2.0, 4.0, 6.0]);
+ assert!((pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ assert!((pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ }
+
+ #[test]
+ fn quaternion_shortest_path_sign_flip_is_stable() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ PoseKey {
+ frame: 10,
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, -1.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let pose = track
+ .sample(AnimationTime(5.0), NumericProfile::PortableReference)
+ .expect("pose");
+
+ assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]);
+ }
+
+ #[test]
+ fn zero_or_degenerate_key_interval_is_rejected() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 1,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 1,
+ pose: Pose::default(),
+ },
+ ],
+ );
+
+ assert_eq!(
+ track.expect_err("duplicate key"),
+ AnimationError::NonMonotonicKeys
+ );
+ }
+
+ #[test]
+ fn x87_boundary_golden_vectors_and_profile_difference_report() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 2,
+ pose: Pose {
+ translation: [2.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let portable = track
+ .sample(AnimationTime(1.0), NumericProfile::PortableReference)
+ .expect("portable");
+ let x87 = track
+ .sample(AnimationTime(1.0), NumericProfile::X87Compatibility)
+ .expect("x87");
+
+ assert_eq!(portable, x87);
+ assert_eq!(portable.translation, [1.0, 0.0, 0.0]);
+ assert_eq!(
+ compare_numeric_profiles(&track, &[AnimationTime(1.0)]).expect("diff"),
+ vec![NumericProfileDifference {
+ time: AnimationTime(1.0),
+ translation_delta: [0.0; 3],
+ rotation_delta: [0.0; 4],
+ }]
+ );
+ }
+
+ #[test]
+ fn blend_optional_pose_uses_valid_side() {
+ let valid = Pose {
+ translation: [3.0, 4.0, 5.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ };
+
+ assert_eq!(
+ blend_optional_pose(Some(valid), None, 0.5).expect("left"),
+ valid
+ );
+ assert_eq!(
+ blend_optional_pose(None, Some(valid), 0.5).expect("right"),
+ valid
+ );
+ assert_eq!(
+ blend_optional_pose(None, None, 0.5).expect_err("invalid"),
+ AnimationError::InvalidQuaternion
+ );
+ }
+
+ #[test]
+ fn hierarchy_evaluates_parent_before_child_and_rejects_cycles() {
+ let local = vec![
+ Pose {
+ translation: [1.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ Pose {
+ translation: [0.0, 2.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ ];
+
+ let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local)
+ .expect("hierarchy");
+
+ assert_eq!(buffer.poses[0].translation, [1.0, 0.0, 0.0]);
+ assert_eq!(buffer.poses[1].translation, [1.0, 2.0, 0.0]);
+ assert_eq!(
+ evaluate_hierarchy(&[ParentIndex(Some(0))], &[Pose::default()]).expect_err("cycle"),
+ AnimationError::ParentCycle { node: 0 }
+ );
+ assert_eq!(
+ evaluate_hierarchy(
+ &[ParentIndex(Some(1)), ParentIndex(None)],
+ &[Pose::default(), Pose::default()],
+ )
+ .expect_err("order"),
+ AnimationError::ParentOrder {
+ child: 0,
+ parent: 1,
+ }
+ );
+ }
+
+ #[test]
+ fn generated_valid_quaternions_remain_finite() {
+ for index in 1..64_u16 {
+ let mut bytes = [0_u8; 24];
+ bytes[12..16].copy_from_slice(&f32::from(index).to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&(i16::try_from(index).expect("small")).to_le_bytes());
+ bytes[18..20].copy_from_slice(&123_i16.to_le_bytes());
+ bytes[20..22].copy_from_slice(&(-456_i16).to_le_bytes());
+ bytes[22..24].copy_from_slice(&32767_i16.to_le_bytes());
+
+ let key = AnimKey24::decode(&bytes).expect("key");
+
+ assert!(key.pose.rotation.iter().all(|value| value.is_finite()));
+ }
+ }
+
+ #[test]
+ fn named_rng_stream_is_stable_and_named() {
+ let mut material_a = NamedRngStream::new(42, "material");
+ let mut material_b = NamedRngStream::new(42, "material");
+ let mut fx = NamedRngStream::new(42, "fx");
+
+ assert_eq!(material_a.next_u32(), material_b.next_u32());
+ assert_ne!(material_a.next_u32(), fx.next_u32());
+ assert_eq!(material_a.calls(), 2);
+ }
+
+ #[test]
+ fn pose_capture_uses_float_bits() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ }],
+ )
+ .expect("track");
+
+ let capture = canonical_pose_capture(&track, &[AnimationTime(0.0)]).expect("capture");
+
+ assert_eq!(
+ capture,
+ b"P,00000000,00000000,00000000,00000000,00000000,00000000,00000000,3f800000\n"
+ );
+ }
+
+ #[test]
+ fn timed_pose_capture_uses_float_bits() {
+ let track = TimedPoseTrack::new(
+ Pose::default(),
+ vec![TimedPoseKey {
+ time: AnimationTime(0.5),
+ pose: Pose::default(),
+ }],
+ )
+ .expect("track");
+
+ let capture = canonical_timed_pose_capture(&track, &[AnimationTime(0.5)]).expect("capture");
+
+ assert_eq!(
+ capture,
+ b"P,3f000000,00000000,00000000,00000000,00000000,00000000,00000000,3f800000\n"
+ );
+ }
+}