| 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 | } |
| 176 | |
| 177 | fn female_body() -> preview::PreviewCharacter { |
| 178 | preview::load_gltf_file( |
| 179 | &PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| 180 | .join("tests/fixtures/bodies/test-body-female.glb"), |
| 181 | ) |
| 182 | .unwrap() |
| 183 | } |
| 184 | |
| 185 | fn rename_joint( |
| 186 | mut body: preview::PreviewCharacter, |
| 187 | from: &str, |
| 188 | to: &str, |
| 189 | ) -> preview::PreviewCharacter { |
| 190 | for name in &mut body.joint_names { |
| 191 | if name == from { |
| 192 | *name = to.to_string(); |
| 193 | } |
| 194 | } |
| 195 | for node in &mut body.nodes { |
| 196 | if node.name == from { |
| 197 | node.name = to.to_string(); |
| 198 | } |
| 199 | } |
| 200 | body |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn copy_skinned_links_matching_partial_and_no_overlap() { |
| 205 | let male = preview::load_gltf_file(&body_path()).unwrap(); |
| 206 | let female = female_body(); |
| 207 | let mut preset = mapped_preset(&male); |
| 208 | // Distinct authored offsets on two links so we can see the copy. |
| 209 | let head = preset.attachments[0] |
| 210 | .skeleton |
| 211 | .as_ref() |
| 212 | .unwrap() |
| 213 | .bones |
| 214 | .iter() |
| 215 | .position(|b| b.name == "Head") |
| 216 | .unwrap(); |
| 217 | let hand = preset.attachments[0] |
| 218 | .skeleton |
| 219 | .as_ref() |
| 220 | .unwrap() |
| 221 | .bones |
| 222 | .iter() |
| 223 | .position(|b| b.name == "RightHand") |
| 224 | .unwrap(); |
| 225 | for link in &mut preset.attachments[0].bone_links[0] { |
| 226 | if link.bone == head { |
| 227 | link.translation = [0.0, 0.1, 0.0]; |
| 228 | } |
| 229 | if link.bone == hand { |
| 230 | link.rotation_deg = [0.0, 15.0, 0.0]; |
| 231 | } |
| 232 | } |
| 233 | assert!(preset.attachments[0].bone_links[1].is_empty()); |
| 234 | |
| 235 | // Full name overlap. |
| 236 | let copied = preset.copy_gender_mapping(0, 1, &female).unwrap(); |
| 237 | assert_eq!(copied.unmatched, 0); |
| 238 | assert_eq!(copied.mapped, preset.attachments[0].bone_links[0].len()); |
| 239 | assert_eq!( |
| 240 | preset.attachments[0].bone_links[0].len(), |
| 241 | preset.attachments[0].bone_links[1].len() |
| 242 | ); |
| 243 | let female_paths = preview::joint_paths(&female); |
| 244 | for src in &preset.attachments[0].bone_links[0] { |
| 245 | let dest = preset.attachments[0].bone_links[1] |
| 246 | .iter() |
| 247 | .find(|l| l.bone == src.bone) |
| 248 | .expect("copied link"); |
| 249 | assert_eq!(dest.translation, src.translation); |
| 250 | assert_eq!(dest.rotation_deg, src.rotation_deg); |
| 251 | assert!(female_paths.contains(&dest.target)); |
| 252 | assert_eq!( |
| 253 | equipment::joint_leaf_name(&dest.target), |
| 254 | equipment::joint_leaf_name(&src.target) |
| 255 | ); |
| 256 | } |
| 257 | // Independent after the copy: changing male does not change female. |
| 258 | preset.attachments[0].bone_links[0] |
| 259 | .iter_mut() |
| 260 | .find(|l| l.bone == head) |
| 261 | .unwrap() |
| 262 | .translation = [3.0, 0.0, 0.0]; |
| 263 | let female_head = preset.attachments[0].bone_links[1] |
| 264 | .iter() |
| 265 | .find(|l| l.bone == head) |
| 266 | .unwrap(); |
| 267 | assert_eq!(female_head.translation, [0.0, 0.1, 0.0]); |
| 268 | let restored = Preset::decode(&preset.encode().unwrap()).unwrap(); |
| 269 | assert_eq!(restored, preset); |
| 270 | |
| 271 | // Partial overlap: female is missing Head; other links still copy. |
| 272 | let mut partial = mapped_preset(&male); |
| 273 | partial.attachments[0].bone_links[0] |
| 274 | .iter_mut() |
| 275 | .find(|l| l.bone == head) |
| 276 | .unwrap() |
| 277 | .translation = [0.0, 0.1, 0.0]; |
| 278 | let no_head = rename_joint(female.clone(), "Head", "Skull"); |
| 279 | let outcome = partial.copy_gender_mapping(0, 1, &no_head).unwrap(); |
| 280 | assert_eq!(outcome.unmatched, 1); |
| 281 | assert_eq!( |
| 282 | outcome.mapped, |
| 283 | partial.attachments[0].bone_links[0].len() - 1 |
| 284 | ); |
| 285 | assert!(partial.attachments[0].bone_links[1] |
| 286 | .iter() |
| 287 | .all(|l| equipment::joint_leaf_name(&l.target) != "Head")); |
| 288 | assert!(partial.attachments[0].bone_links[1] |
| 289 | .iter() |
| 290 | .any(|l| equipment::joint_leaf_name(&l.target) == "RightHand")); |
| 291 | |
| 292 | // No overlap at all: rename every female joint. |
| 293 | let mut unique = female.clone(); |
| 294 | for (i, name) in unique.joint_names.iter_mut().enumerate() { |
| 295 | *name = format!("OnlyFemale{i}"); |
| 296 | } |
| 297 | for (i, node) in unique.nodes.iter_mut().enumerate() { |
| 298 | if male.joint_names.iter().any(|n| n == &node.name) { |
| 299 | node.name = format!("OnlyFemaleNode{i}"); |
| 300 | } |
| 301 | } |
| 302 | let mut none = mapped_preset(&male); |
| 303 | let outcome = none.copy_gender_mapping(0, 1, &unique).unwrap(); |
| 304 | assert_eq!(outcome.mapped, 0); |
| 305 | assert_eq!(outcome.unmatched, none.attachments[0].bone_links[0].len()); |
| 306 | assert!(none.attachments[0].bone_links[1].is_empty()); |
| 307 | assert!(none.rigs[1].is_none()); |
| 308 | } |