From 5c1ccae393379758a35ea54a0b6b9ab3184ad442 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 19:08:49 +0400 Subject: feat(render): preview explicit animation frames --- crates/fparkan-msh/src/lib.rs | 142 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) (limited to 'crates') 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 { 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 { + 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 { + 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 @@ -1273,6 +1358,61 @@ mod tests { assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001); } + #[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(&[ -- cgit v1.2.3