2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
2026-06-23 22:32:50 +04:00
|
|
|
#![cfg_attr(
|
|
|
|
|
test,
|
|
|
|
|
allow(
|
|
|
|
|
clippy::cast_possible_truncation,
|
|
|
|
|
clippy::cast_possible_wrap,
|
|
|
|
|
clippy::cast_precision_loss,
|
|
|
|
|
clippy::expect_used,
|
|
|
|
|
clippy::float_cmp,
|
|
|
|
|
clippy::identity_op,
|
|
|
|
|
clippy::too_many_lines,
|
|
|
|
|
clippy::uninlined_format_args,
|
|
|
|
|
clippy::map_unwrap_or,
|
|
|
|
|
clippy::needless_raw_string_hashes,
|
|
|
|
|
clippy::semicolon_if_nothing_returned,
|
|
|
|
|
clippy::type_complexity,
|
|
|
|
|
clippy::panic,
|
|
|
|
|
clippy::unwrap_used
|
|
|
|
|
)
|
|
|
|
|
)]
|
2026-06-22 13:12:27 +04:00
|
|
|
#![allow(clippy::cast_precision_loss)]
|
|
|
|
|
//! Deterministic animation sampling contracts.
|
2026-06-22 16:04:35 +04:00
|
|
|
//!
|
|
|
|
|
//! The current sampler is a portable reference path. Compatibility profiles
|
|
|
|
|
//! that require runtime-captured x87 parity remain explicit evidence gaps.
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
use std::fmt;
|
|
|
|
|
|
|
|
|
|
/// Numeric profile.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum NumericProfile {
|
2026-06-22 16:04:35 +04:00
|
|
|
/// Portable reference sampler implemented by this crate.
|
2026-06-22 13:12:27 +04:00
|
|
|
PortableReference,
|
2026-06-22 16:04:35 +04:00
|
|
|
/// Reserved profile for future runtime-captured x87 parity vectors.
|
2026-06-22 13:12:27 +04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:04:35 +04:00
|
|
|
/// Samples the pose track with the portable reference path.
|
|
|
|
|
///
|
|
|
|
|
/// `NumericProfile::X87Compatibility` is accepted so callers can keep the
|
|
|
|
|
/// compatibility contract explicit, but it does not yet select an
|
|
|
|
|
/// independently captured x87 runtime path.
|
2026-06-22 13:12:27 +04:00
|
|
|
///
|
|
|
|
|
/// # 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|