aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-msh
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 18:08:49 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 18:08:49 +0300
commit5c1ccae393379758a35ea54a0b6b9ab3184ad442 (patch)
tree4a01e1dac9177cf10661dbdeb51d1b5d70ceabb6 /crates/fparkan-msh
parent3b6dd392a74ebd1096a9bcccf0a02c417370bf82 (diff)
downloadfparkan-5c1ccae393379758a35ea54a0b6b9ab3184ad442.tar.xz
fparkan-5c1ccae393379758a35ea54a0b6b9ab3184ad442.zip
feat(render): preview explicit animation frames
Diffstat (limited to 'crates/fparkan-msh')
-rw-r--r--crates/fparkan-msh/src/lib.rs142
1 files changed, 141 insertions, 1 deletions
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index dfc4c02..fd222c6 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -21,7 +21,10 @@
//! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251;
-use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose};
+use fparkan_animation::{
+ evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
+ TimedPoseTrack,
+};
use fparkan_nres::{EntryMeta, NresDocument, NresError};
/// Node table stream.
@@ -478,6 +481,88 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok()
}
+/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
+/// model at one explicitly supplied logical frame.
+///
+/// A node without a usable type-19 map, or a requested frame outside the
+/// declared map length, retains its exact fallback key. Mapped keys are
+/// sampled against their immediate successor using the decoded key times.
+/// This is an offline asset-sampling contract; it does not claim ownership of
+/// the original runtime's animation clock or x87 numeric profile.
+///
+/// Returns `None` when the model lacks a complete standard-node animation
+/// representation or the map cannot be safely resolved.
+#[must_use]
+pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
+ if model.node_stride != 38 || model.node_count == 0 {
+ return None;
+ }
+ let animation = model.animation.as_ref()?;
+ let mut parents = Vec::with_capacity(model.node_count);
+ let mut poses = Vec::with_capacity(model.node_count);
+ for index in 0..model.node_count {
+ let node = NodeId(u32::try_from(index).ok()?);
+ let metadata = node38_metadata(model, node)?;
+ let parent = if metadata.parent_or_link_raw == u16::MAX {
+ ParentIndex(None)
+ } else {
+ let parent = usize::from(metadata.parent_or_link_raw);
+ (parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
+ };
+ let fallback_index = usize::from(metadata.fallback_key);
+ let _fallback = animation.keys.get(fallback_index)?;
+ let key_index =
+ if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
+ fallback_index
+ } else {
+ let mapped_index =
+ usize::from(*animation.frame_map.get(
+ usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
+ )?);
+ if mapped_index < fallback_index {
+ mapped_index
+ } else {
+ fallback_index
+ }
+ };
+ let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
+ parents.push(parent);
+ poses.push(pose);
+ }
+ evaluate_hierarchy(&parents, &poses).ok()
+}
+
+fn sample_node38_key_pair(
+ keys: &[AnimKey24],
+ key_index: usize,
+ fallback_index: usize,
+ frame: u16,
+) -> Option<Pose> {
+ let key = *keys.get(key_index)?;
+ if key_index == fallback_index {
+ return Some(key.sampling_pose());
+ }
+ let next = *keys.get(key_index.checked_add(1)?)?;
+ if next.time.0 <= key.time.0 {
+ return Some(key.sampling_pose());
+ }
+ let track = TimedPoseTrack::new(
+ key.sampling_pose(),
+ vec![
+ TimedPoseKey {
+ time: key.time,
+ pose: key.sampling_pose(),
+ },
+ TimedPoseKey {
+ time: next.time,
+ pose: next.sampling_pose(),
+ },
+ ],
+ )
+ .ok()?;
+ track.sample(AnimationTime(f32::from(frame))).ok()
+}
+
/// Returns draw batches for a validated slot.
///
/// # Errors
@@ -1274,6 +1359,61 @@ mod tests {
}
#[test]
+ fn standard_nodes_sample_mapped_key_before_fallback_key() {
+ let mut node = node38([u16::MAX; 15]);
+ node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ node[4..6].copy_from_slice(&0_u16.to_le_bytes());
+ node[6..8].copy_from_slice(&2_u16.to_le_bytes());
+ let model = ModelAsset {
+ node_stride: 38,
+ node_count: 1,
+ nodes_raw: node,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ indices: Vec::new(),
+ batches: Vec::new(),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [4.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(2.0),
+ pose: Pose {
+ translation: [8.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(9.0),
+ pose: Pose::default(),
+ },
+ ],
+ frame_map: vec![0, 0, 0],
+ frame_count: 3,
+ }),
+ };
+
+ let pose = node38_sampled_hierarchy(&model, 1)
+ .expect("mapped hierarchy")
+ .poses[0];
+ assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
+ assert_eq!(
+ node38_sampled_hierarchy(&model, 9)
+ .expect("fallback hierarchy")
+ .poses[0],
+ Pose::default()
+ );
+ }
+
+ #[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),