feat(animation): recover Node38 fallback hierarchy
This commit is contained in:
@@ -781,16 +781,29 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
|
||||
}
|
||||
|
||||
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
|
||||
let translated = rotate_point(parent.rotation, child.translation);
|
||||
Ok(Pose {
|
||||
translation: [
|
||||
parent.translation[0] + child.translation[0],
|
||||
parent.translation[1] + child.translation[1],
|
||||
parent.translation[2] + child.translation[2],
|
||||
parent.translation[0] + translated[0],
|
||||
parent.translation[1] + translated[1],
|
||||
parent.translation[2] + translated[2],
|
||||
],
|
||||
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] {
|
||||
let [x, y, z, w] = rotation;
|
||||
let tx = 2.0 * (y * point[2] - z * point[1]);
|
||||
let ty = 2.0 * (z * point[0] - x * point[2]);
|
||||
let tz = 2.0 * (x * point[1] - y * point[0]);
|
||||
[
|
||||
point[0] + w * tx + (y * tz - z * ty),
|
||||
point[1] + w * ty + (z * tx - x * tz),
|
||||
point[2] + w * tz + (x * ty - y * tx),
|
||||
]
|
||||
}
|
||||
|
||||
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
|
||||
let [lx, ly, lz, lw] = left;
|
||||
let [rx, ry, rz, rw] = right;
|
||||
@@ -1174,6 +1187,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_rotates_child_translation_by_parent_orientation() {
|
||||
let half = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let local = [
|
||||
Pose {
|
||||
translation: [1.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, half, half],
|
||||
},
|
||||
Pose {
|
||||
translation: [2.0, 0.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!((buffer.poses[1].translation[0] - 1.0).abs() < 0.0001);
|
||||
assert!((buffer.poses[1].translation[1] - 2.0).abs() < 0.0001);
|
||||
assert!(buffer.poses[1].translation[2].abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_valid_quaternions_remain_finite() {
|
||||
for index in 1..64_u16 {
|
||||
|
||||
@@ -24,7 +24,9 @@ use fparkan_diagnostics::{
|
||||
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
|
||||
};
|
||||
use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
|
||||
use fparkan_msh::{decode_msh, validate_msh, ModelAsset};
|
||||
use fparkan_msh::{
|
||||
decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId,
|
||||
};
|
||||
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||
use fparkan_path::{normalize_relative, PathPolicy};
|
||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||
@@ -85,6 +87,29 @@ pub struct ModelInspection {
|
||||
pub indices: usize,
|
||||
/// Batch count.
|
||||
pub batches: usize,
|
||||
/// Original node record stride.
|
||||
pub node_stride: usize,
|
||||
/// Standard-node metadata in source order, when the model uses `Node38`.
|
||||
pub node38: Vec<Node38Inspection>,
|
||||
/// Number of decoded type-8 animation keys, when available.
|
||||
pub animation_keys: Option<usize>,
|
||||
/// Declared type-19 animation frame count, when available.
|
||||
pub animation_frame_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Inspection view of a standard 38-byte model node.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Node38Inspection {
|
||||
/// Source node index.
|
||||
pub index: usize,
|
||||
/// Opaque source field at byte offset two.
|
||||
pub parent_or_link_raw: u16,
|
||||
/// Type-19 frame-map offset, or `0xFFFF`.
|
||||
pub anim_map_start: u16,
|
||||
/// Type-8 fallback key index.
|
||||
pub fallback_key: u16,
|
||||
/// Whether LOD zero/group zero selects a geometry slot.
|
||||
pub has_lod0_group0: bool,
|
||||
}
|
||||
|
||||
/// Texture inspection payload.
|
||||
@@ -312,6 +337,23 @@ pub fn inspect_model_from_root(
|
||||
})?;
|
||||
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
||||
let node38 = if validated.node_stride == 38 {
|
||||
(0..validated.node_count)
|
||||
.filter_map(|index| {
|
||||
let node = NodeId(u32::try_from(index).ok()?);
|
||||
let metadata = node38_metadata(&validated, node)?;
|
||||
Some(Node38Inspection {
|
||||
index,
|
||||
parent_or_link_raw: metadata.parent_or_link_raw,
|
||||
anim_map_start: metadata.anim_map_start,
|
||||
fallback_key: metadata.fallback_key,
|
||||
has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(ModelInspection {
|
||||
streams: msh.streams().len(),
|
||||
nodes: validated.node_count,
|
||||
@@ -319,6 +361,16 @@ pub fn inspect_model_from_root(
|
||||
positions: validated.positions.len(),
|
||||
indices: validated.indices.len(),
|
||||
batches: validated.batches.len(),
|
||||
node_stride: validated.node_stride,
|
||||
node38,
|
||||
animation_keys: validated
|
||||
.animation
|
||||
.as_ref()
|
||||
.map(|animation| animation.keys.len()),
|
||||
animation_frame_count: validated
|
||||
.animation
|
||||
.as_ref()
|
||||
.map(|animation| animation.frame_count),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! Stage-3 MSH asset contract.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use fparkan_animation::{AnimKey24, Pose};
|
||||
use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose};
|
||||
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
||||
|
||||
/// Node table stream.
|
||||
@@ -450,6 +450,34 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
|
||||
.map(AnimKey24::sampling_pose)
|
||||
}
|
||||
|
||||
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
|
||||
///
|
||||
/// `0xFFFF` denotes a root. Every non-root source link must name an earlier
|
||||
/// node, which preserves the producer's parent-before-child ordering. Models
|
||||
/// that do not meet this established `Node38` contract return `None` so callers
|
||||
/// can retain an explicitly unhierarchical diagnostic path.
|
||||
#[must_use]
|
||||
pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
||||
if model.node_stride != 38 || model.node_count == 0 {
|
||||
return None;
|
||||
}
|
||||
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)))?
|
||||
};
|
||||
parents.push(parent);
|
||||
poses.push(node38_fallback_pose(model, node)?);
|
||||
}
|
||||
evaluate_hierarchy(&parents, &poses).ok()
|
||||
}
|
||||
|
||||
/// Returns draw batches for a validated slot.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -1204,6 +1232,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_nodes_evaluate_fallback_parent_hierarchy() {
|
||||
let mut root = node38([u16::MAX; 15]);
|
||||
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
root[6..8].copy_from_slice(&0_u16.to_le_bytes());
|
||||
let mut child = node38([u16::MAX; 15]);
|
||||
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
|
||||
child[6..8].copy_from_slice(&1_u16.to_le_bytes());
|
||||
let mut nodes = root;
|
||||
nodes.extend(child);
|
||||
let mut keys = Vec::new();
|
||||
for (x, y, z, qz, qw) in [
|
||||
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
|
||||
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
|
||||
] {
|
||||
push_f32(&mut keys, x);
|
||||
push_f32(&mut keys, y);
|
||||
push_f32(&mut keys, z);
|
||||
push_f32(&mut keys, 0.0);
|
||||
push_u16(&mut keys, 0);
|
||||
push_u16(&mut keys, 0);
|
||||
push_u16(&mut keys, qz.cast_unsigned());
|
||||
push_u16(&mut keys, qw.cast_unsigned());
|
||||
}
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
|
||||
]))
|
||||
.expect("nested NRes");
|
||||
let model =
|
||||
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
|
||||
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
|
||||
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
|
||||
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||
let too_small = decode_nested(&build_nres(&[
|
||||
@@ -1496,6 +1565,13 @@ mod tests {
|
||||
let model = validate_msh(&msh).unwrap_or_else(|err| {
|
||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||
});
|
||||
if model.node_stride == 38 {
|
||||
assert!(
|
||||
node38_fallback_hierarchy(&model).is_some(),
|
||||
"{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child",
|
||||
entry.name_bytes()
|
||||
);
|
||||
}
|
||||
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
|
||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user