//! 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()); }