//! Tim t1027u: skinned equipment parts with per-bone links to the character. use paperdoll_renderer::equipment::{self, Preset}; use paperdoll_renderer::equipment_rig; use paperdoll_renderer::preview::{self, PreviewControls}; use std::path::PathBuf; fn body_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb") } fn apply(m: &[f32; 16], p: [f32; 3]) -> [f32; 3] { [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i]) } /// The body skinned on the CPU (linear blend) at a clip time. fn skinned_body(body: &preview::PreviewCharacter, clip: &str, t: f32) -> Vec<[f32; 3]> { let skins: Vec<[f32; 16]> = (0..body.joint_nodes.len()) .map(|j| { preview::mul( preview::animated_joint_world(body, clip, t, j).unwrap(), body.inverse_binds[j], ) }) .collect(); (0..body.positions.len()) .map(|v| { let mut out = [0f32; 3]; for (j, w) in body.joints[v].iter().zip(body.weights[v]) { let p = apply(&skins[*j as usize], body.positions[v]); for k in 0..3 { out[k] += p[k] * w; } } out }) .collect() } fn equipment_points( preset: &Preset, body: &preview::PreviewCharacter, clip: &str, t: f32, ) -> Vec<[f32; 3]> { let controls = PreviewControls { clip_name: clip.into(), time: t, ..Default::default() }; equipment::layers(preset, 0, body, &controls) .unwrap() .iter() .flat_map(|l| { l.mesh .positions .iter() .map(|p| apply(&l.model, *p)) .collect::>() }) .collect() } fn max_nearest(points: &[[f32; 3]], cloud: &[[f32; 3]]) -> f32 { points .iter() .map(|p| { cloud .iter() .map(|q| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::()) .fold(f32::MAX, f32::min) .sqrt() }) .fold(0., f32::max) } fn mapped_preset(body: &preview::PreviewCharacter) -> Preset { let mut preset = Preset::new("suit".into()); preset.applies = [true, false]; let part = equipment::import_model(&mut preset, &body_path()).unwrap(); assert!(preset.attachments[part].skeleton.is_some()); let added = equipment_rig::auto_link(&mut preset, part, 0, body).unwrap(); assert_eq!(added, body.joint_nodes.len(), "every bone name matches"); preset } #[test] fn skinned_part_follows_the_character_skeleton_in_rest_and_animation() { let body = preview::load_gltf_file(&body_path()).unwrap(); let preset = mapped_preset(&body); // Same rig: every auto link keeps the rest placement with a zero offset. for link in &preset.attachments[0].bone_links[0] { assert!(link.translation.iter().all(|x| x.abs() < 1e-3), "{link:?}"); assert!(link.rotation_deg.iter().all(|x| x.abs() < 0.05), "{link:?}"); } let rest = equipment_points(&preset, &body, "", 0.0); assert!(max_nearest(&rest, &skinned_body(&body, "", 0.0)) < 1e-3); let clip = &body.clips[0]; let t = clip.duration * 0.5; let moving = equipment_points(&preset, &body, &clip.name, t); let reference = skinned_body(&body, &clip.name, t); // Rigid per dominant bone: close to linear blend skinning, never far off. assert!( max_nearest(&moving, &reference) < 0.03, "{}", max_nearest(&moving, &reference) ); // Moving differs from rest, so the links really animate. assert!(max_nearest(&moving, &rest) > 0.01); } #[test] fn per_link_offset_moves_only_that_bone_and_its_unlinked_children() { let body = preview::load_gltf_file(&body_path()).unwrap(); let mut preset = mapped_preset(&body); let skeleton = preset.attachments[0].skeleton.clone().unwrap(); let head = skeleton .bones .iter() .position(|b| b.name == "Head") .unwrap(); // Lift only the Head link by 10 cm. let links = &mut preset.attachments[0].bone_links[0]; let link = links.iter_mut().find(|l| l.bone == head).unwrap(); link.translation[1] += 0.1; let controls = PreviewControls::default(); let layers = equipment::layers(&preset, 0, &body, &controls).unwrap(); let rest = equipment_rig::bone_models(&skeleton, &[], &[], |_| None); assert!(rest.is_err(), "no links is an error"); for layer in &layers { let bone = layer.mesh.bone.unwrap(); let expected = skeleton.bones[bone].rest_world; let lifted = bone == head; let dy = layer.model[13] - expected[13]; if lifted { assert!(dy > 0.05, "head lifted"); } else { assert!(dy.abs() < 1e-3, "bone {} moved", skeleton.bones[bone].name); } } } #[test] fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() { let old = std::fs::read( PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v1.ppe"), ) .unwrap(); assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1); let migrated = Preset::decode(&old).unwrap(); assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none())); assert!(!migrated.semi_transparency); // A version-2 file (0.1.3) loads with semi-transparency off (t1031u). let v2 = std::fs::read( PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v2.ppe"), ) .unwrap(); assert_eq!(u32::from_le_bytes(v2[8..12].try_into().unwrap()), 2); assert!(!Preset::decode(&v2).unwrap().semi_transparency); let bytes = migrated.encode().unwrap(); assert_eq!( u32::from_le_bytes(bytes[8..12].try_into().unwrap()), equipment::VERSION ); assert_eq!(Preset::decode(&bytes).unwrap(), migrated); let body = preview::load_gltf_file(&body_path()).unwrap(); let mut preset = mapped_preset(&body); preset.attachments[0].bone_links[0][0].rotation_deg = [5., 10., 15.]; let again = Preset::decode(&preset.encode().unwrap()).unwrap(); assert_eq!(again, preset); // Links to an unknown bone are rejected. let mut bad = preset.clone(); bad.attachments[0].bone_links[0][0].target = "nope".into(); assert!(bad.encode().is_err()); } fn female_body() -> preview::PreviewCharacter { preview::load_gltf_file( &PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-female.glb"), ) .unwrap() } fn rename_joint( mut body: preview::PreviewCharacter, from: &str, to: &str, ) -> preview::PreviewCharacter { for name in &mut body.joint_names { if name == from { *name = to.to_string(); } } for node in &mut body.nodes { if node.name == from { node.name = to.to_string(); } } body } #[test] fn copy_skinned_links_matching_partial_and_no_overlap() { let male = preview::load_gltf_file(&body_path()).unwrap(); let female = female_body(); let mut preset = mapped_preset(&male); // Distinct authored offsets on two links so we can see the copy. let head = preset.attachments[0] .skeleton .as_ref() .unwrap() .bones .iter() .position(|b| b.name == "Head") .unwrap(); let hand = preset.attachments[0] .skeleton .as_ref() .unwrap() .bones .iter() .position(|b| b.name == "RightHand") .unwrap(); for link in &mut preset.attachments[0].bone_links[0] { if link.bone == head { link.translation = [0.0, 0.1, 0.0]; } if link.bone == hand { link.rotation_deg = [0.0, 15.0, 0.0]; } } assert!(preset.attachments[0].bone_links[1].is_empty()); // Full name overlap. let copied = preset.copy_gender_mapping(0, 1, &female).unwrap(); assert_eq!(copied.unmatched, 0); assert_eq!(copied.mapped, preset.attachments[0].bone_links[0].len()); assert_eq!( preset.attachments[0].bone_links[0].len(), preset.attachments[0].bone_links[1].len() ); let female_paths = preview::joint_paths(&female); for src in &preset.attachments[0].bone_links[0] { let dest = preset.attachments[0].bone_links[1] .iter() .find(|l| l.bone == src.bone) .expect("copied link"); assert_eq!(dest.translation, src.translation); assert_eq!(dest.rotation_deg, src.rotation_deg); assert!(female_paths.contains(&dest.target)); assert_eq!( equipment::joint_leaf_name(&dest.target), equipment::joint_leaf_name(&src.target) ); } // Independent after the copy: changing male does not change female. preset.attachments[0].bone_links[0] .iter_mut() .find(|l| l.bone == head) .unwrap() .translation = [3.0, 0.0, 0.0]; let female_head = preset.attachments[0].bone_links[1] .iter() .find(|l| l.bone == head) .unwrap(); assert_eq!(female_head.translation, [0.0, 0.1, 0.0]); let restored = Preset::decode(&preset.encode().unwrap()).unwrap(); assert_eq!(restored, preset); // Partial overlap: female is missing Head; other links still copy. let mut partial = mapped_preset(&male); partial.attachments[0].bone_links[0] .iter_mut() .find(|l| l.bone == head) .unwrap() .translation = [0.0, 0.1, 0.0]; let no_head = rename_joint(female.clone(), "Head", "Skull"); let outcome = partial.copy_gender_mapping(0, 1, &no_head).unwrap(); assert_eq!(outcome.unmatched, 1); assert_eq!( outcome.mapped, partial.attachments[0].bone_links[0].len() - 1 ); assert!(partial.attachments[0].bone_links[1] .iter() .all(|l| equipment::joint_leaf_name(&l.target) != "Head")); assert!(partial.attachments[0].bone_links[1] .iter() .any(|l| equipment::joint_leaf_name(&l.target) == "RightHand")); // No overlap at all: rename every female joint. let mut unique = female.clone(); for (i, name) in unique.joint_names.iter_mut().enumerate() { *name = format!("OnlyFemale{i}"); } for (i, node) in unique.nodes.iter_mut().enumerate() { if male.joint_names.iter().any(|n| n == &node.name) { node.name = format!("OnlyFemaleNode{i}"); } } let mut none = mapped_preset(&male); let outcome = none.copy_gender_mapping(0, 1, &unique).unwrap(); assert_eq!(outcome.mapped, 0); assert_eq!(outcome.unmatched, none.attachments[0].bone_links[0].len()); assert!(none.attachments[0].bone_links[1].is_empty()); assert!(none.rigs[1].is_none()); }