| 1 | //! Tim t1027u: skinned equipment parts with per-bone links to the character. |
| 2 | use paperdoll_renderer::equipment::{self, Preset}; |
| 3 | use paperdoll_renderer::equipment_rig; |
| 4 | use paperdoll_renderer::preview::{self, PreviewControls}; |
| 5 | use std::path::PathBuf; |
| 6 | |
| 7 | fn body_path() -> PathBuf { |
| 8 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb") |
| 9 | } |
| 10 | |
| 11 | fn apply(m: &[f32; 16], p: [f32; 3]) -> [f32; 3] { |
| 12 | [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i]) |
| 13 | } |
| 14 | |
| 15 | /// The body skinned on the CPU (linear blend) at a clip time. |
| 16 | fn skinned_body(body: &preview::PreviewCharacter, clip: &str, t: f32) -> Vec<[f32; 3]> { |
| 17 | let skins: Vec<[f32; 16]> = (0..body.joint_nodes.len()) |
| 18 | .map(|j| { |
| 19 | preview::mul( |
| 20 | preview::animated_joint_world(body, clip, t, j).unwrap(), |
| 21 | body.inverse_binds[j], |
| 22 | ) |
| 23 | }) |
| 24 | .collect(); |
| 25 | (0..body.positions.len()) |
| 26 | .map(|v| { |
| 27 | let mut out = [0f32; 3]; |
| 28 | for (j, w) in body.joints[v].iter().zip(body.weights[v]) { |
| 29 | let p = apply(&skins[*j as usize], body.positions[v]); |
| 30 | for k in 0..3 { |
| 31 | out[k] += p[k] * w; |
| 32 | } |
| 33 | } |
| 34 | out |
| 35 | }) |
| 36 | .collect() |
| 37 | } |
| 38 | |
| 39 | fn equipment_points( |
| 40 | preset: &Preset, |
| 41 | body: &preview::PreviewCharacter, |
| 42 | clip: &str, |
| 43 | t: f32, |
| 44 | ) -> Vec<[f32; 3]> { |
| 45 | let controls = PreviewControls { |
| 46 | clip_name: clip.into(), |
| 47 | time: t, |
| 48 | ..Default::default() |
| 49 | }; |
| 50 | equipment::layers(preset, 0, body, &controls) |
| 51 | .unwrap() |
| 52 | .iter() |
| 53 | .flat_map(|l| { |
| 54 | l.mesh |
| 55 | .positions |
| 56 | .iter() |
| 57 | .map(|p| apply(&l.model, *p)) |
| 58 | .collect::<Vec<_>>() |
| 59 | }) |
| 60 | .collect() |
| 61 | } |
| 62 | |
| 63 | fn max_nearest(points: &[[f32; 3]], cloud: &[[f32; 3]]) -> f32 { |
| 64 | points |
| 65 | .iter() |
| 66 | .map(|p| { |
| 67 | cloud |
| 68 | .iter() |
| 69 | .map(|q| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f32>()) |
| 70 | .fold(f32::MAX, f32::min) |
| 71 | .sqrt() |
| 72 | }) |
| 73 | .fold(0., f32::max) |
| 74 | } |
| 75 | |
| 76 | fn mapped_preset(body: &preview::PreviewCharacter) -> Preset { |
| 77 | let mut preset = Preset::new("suit".into()); |
| 78 | preset.applies = [true, false]; |
| 79 | let part = equipment::import_model(&mut preset, &body_path()).unwrap(); |
| 80 | assert!(preset.attachments[part].skeleton.is_some()); |
| 81 | let added = equipment_rig::auto_link(&mut preset, part, 0, body).unwrap(); |
| 82 | assert_eq!(added, body.joint_nodes.len(), "every bone name matches"); |
| 83 | preset |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn skinned_part_follows_the_character_skeleton_in_rest_and_animation() { |
| 88 | let body = preview::load_gltf_file(&body_path()).unwrap(); |
| 89 | let preset = mapped_preset(&body); |
| 90 | // Same rig: every auto link keeps the rest placement with a zero offset. |
| 91 | for link in &preset.attachments[0].bone_links[0] { |
| 92 | assert!(link.translation.iter().all(|x| x.abs() < 1e-3), "{link:?}"); |
| 93 | assert!(link.rotation_deg.iter().all(|x| x.abs() < 0.05), "{link:?}"); |
| 94 | } |
| 95 | let rest = equipment_points(&preset, &body, "", 0.0); |
| 96 | assert!(max_nearest(&rest, &skinned_body(&body, "", 0.0)) < 1e-3); |
| 97 | let clip = &body.clips[0]; |
| 98 | let t = clip.duration * 0.5; |
| 99 | let moving = equipment_points(&preset, &body, &clip.name, t); |
| 100 | let reference = skinned_body(&body, &clip.name, t); |
| 101 | // Rigid per dominant bone: close to linear blend skinning, never far off. |
| 102 | assert!( |
| 103 | max_nearest(&moving, &reference) < 0.03, |
| 104 | "{}", |
| 105 | max_nearest(&moving, &reference) |
| 106 | ); |
| 107 | // Moving differs from rest, so the links really animate. |
| 108 | assert!(max_nearest(&moving, &rest) > 0.01); |
| 109 | } |
| 110 | |
| 111 | #[test] |
| 112 | fn per_link_offset_moves_only_that_bone_and_its_unlinked_children() { |
| 113 | let body = preview::load_gltf_file(&body_path()).unwrap(); |
| 114 | let mut preset = mapped_preset(&body); |
| 115 | let skeleton = preset.attachments[0].skeleton.clone().unwrap(); |
| 116 | let head = skeleton |
| 117 | .bones |
| 118 | .iter() |
| 119 | .position(|b| b.name == "Head") |
| 120 | .unwrap(); |
| 121 | // Lift only the Head link by 10 cm. |
| 122 | let links = &mut preset.attachments[0].bone_links[0]; |
| 123 | let link = links.iter_mut().find(|l| l.bone == head).unwrap(); |
| 124 | link.translation[1] += 0.1; |
| 125 | let controls = PreviewControls::default(); |
| 126 | let layers = equipment::layers(&preset, 0, &body, &controls).unwrap(); |
| 127 | let rest = equipment_rig::bone_models(&skeleton, &[], &[], |_| None); |
| 128 | assert!(rest.is_err(), "no links is an error"); |
| 129 | for layer in &layers { |
| 130 | let bone = layer.mesh.bone.unwrap(); |
| 131 | let expected = skeleton.bones[bone].rest_world; |
| 132 | let lifted = bone == head; |
| 133 | let dy = layer.model[13] - expected[13]; |
| 134 | if lifted { |
| 135 | assert!(dy > 0.05, "head lifted"); |
| 136 | } else { |
| 137 | assert!(dy.abs() < 1e-3, "bone {} moved", skeleton.bones[bone].name); |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() { |
| 144 | let old = std::fs::read( |
| 145 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v1.ppe"), |
| 146 | ) |
| 147 | .unwrap(); |
| 148 | assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1); |
| 149 | let migrated = Preset::decode(&old).unwrap(); |
| 150 | assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none())); |
| 151 | assert!(!migrated.semi_transparency); |
| 152 | // A version-2 file (0.1.3) loads with semi-transparency off (t1031u). |
| 153 | let v2 = std::fs::read( |
| 154 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v2.ppe"), |
| 155 | ) |
| 156 | .unwrap(); |
| 157 | assert_eq!(u32::from_le_bytes(v2[8..12].try_into().unwrap()), 2); |
| 158 | assert!(!Preset::decode(&v2).unwrap().semi_transparency); |
| 159 | let bytes = migrated.encode().unwrap(); |
| 160 | assert_eq!( |
| 161 | u32::from_le_bytes(bytes[8..12].try_into().unwrap()), |
| 162 | equipment::VERSION |
| 163 | ); |
| 164 | assert_eq!(Preset::decode(&bytes).unwrap(), migrated); |
| 165 | |
| 166 | let body = preview::load_gltf_file(&body_path()).unwrap(); |
| 167 | let mut preset = mapped_preset(&body); |
| 168 | preset.attachments[0].bone_links[0][0].rotation_deg = [5., 10., 15.]; |
| 169 | let again = Preset::decode(&preset.encode().unwrap()).unwrap(); |
| 170 | assert_eq!(again, preset); |
| 171 | // Links to an unknown bone are rejected. |
| 172 | let mut bad = preset.clone(); |
| 173 | bad.attachments[0].bone_links[0][0].target = "nope".into(); |
| 174 | assert!(bad.encode().is_err()); |
| 175 | } |