| 1 | //! FBX bodies in the live/production path and rigid FBX equipment import. |
| 2 | use paperdoll_renderer::equipment::{self, Preset}; |
| 3 | use paperdoll_renderer::preview::{self, Mat4, PreviewCharacter, PreviewControls}; |
| 4 | use paperdoll_renderer::production::{self, RenderJob, SourceFingerprint}; |
| 5 | use paperdoll_renderer::project::CameraFraming; |
| 6 | use std::path::PathBuf; |
| 7 | |
| 8 | fn repo(path: &str) -> PathBuf { |
| 9 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) |
| 10 | } |
| 11 | fn fbx_body() -> PreviewCharacter { |
| 12 | preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap() |
| 13 | } |
| 14 | fn glb_body() -> PreviewCharacter { |
| 15 | preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap() |
| 16 | } |
| 17 | fn scratch(tag: &str) -> PathBuf { |
| 18 | let dir = std::env::temp_dir().join(format!( |
| 19 | "paperdoll-fbx-{tag}-{}", |
| 20 | std::time::SystemTime::now() |
| 21 | .duration_since(std::time::UNIX_EPOCH) |
| 22 | .unwrap() |
| 23 | .as_nanos() |
| 24 | )); |
| 25 | std::fs::create_dir_all(&dir).unwrap(); |
| 26 | dir |
| 27 | } |
| 28 | fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob { |
| 29 | RenderJob { |
| 30 | character, |
| 31 | sex: "male".into(), |
| 32 | preset, |
| 33 | target_width: 32, |
| 34 | target_height: 48, |
| 35 | fps: 4, |
| 36 | framing: CameraFraming { |
| 37 | height_milli: 2400, |
| 38 | center_y_milli: 1000, |
| 39 | pivot_y_milli: None, |
| 40 | }, |
| 41 | output: std::env::temp_dir(), |
| 42 | project_id: "fbx-test".into(), |
| 43 | project_name: "FBX".into(), |
| 44 | body_model: SourceFingerprint { |
| 45 | path: "assets/body/test-body-male.fbx".into(), |
| 46 | sha256: "0".repeat(64), |
| 47 | }, |
| 48 | preset_sha256: None, |
| 49 | } |
| 50 | } |
| 51 | fn bounds(scene: &production::Scene) -> ([f32; 3], [f32; 3]) { |
| 52 | let mut lo = [f32::MAX; 3]; |
| 53 | let mut hi = [f32::MIN; 3]; |
| 54 | for draw in &scene.draws { |
| 55 | for &i in &draw.indices { |
| 56 | let p = draw.positions[i as usize]; |
| 57 | for k in 0..3 { |
| 58 | lo[k] = lo[k].min(p[k]); |
| 59 | hi[k] = hi[k].max(p[k]); |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | (lo, hi) |
| 64 | } |
| 65 | fn clip_named(body: &PreviewCharacter, prefix: &str) -> String { |
| 66 | body.clips |
| 67 | .iter() |
| 68 | .find(|c| c.name.contains(prefix)) |
| 69 | .unwrap_or_else(|| panic!("clip {prefix} ontbreekt")) |
| 70 | .name |
| 71 | .clone() |
| 72 | } |
| 73 | fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] { |
| 74 | [ |
| 75 | m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], |
| 76 | m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], |
| 77 | m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], |
| 78 | ] |
| 79 | } |
| 80 | |
| 81 | #[test] |
| 82 | fn body_loader_dispatches_by_extension_and_rejects_other_formats() { |
| 83 | assert!(preview::is_drawable_body(std::path::Path::new("a/B.FBX"))); |
| 84 | assert!(preview::is_drawable_body(std::path::Path::new("b.glb"))); |
| 85 | assert!(!preview::is_drawable_body(std::path::Path::new("c.obj"))); |
| 86 | let fbx = fbx_body(); |
| 87 | let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo( |
| 88 | "tests/fixtures/bodies/test-body-male.fbx", |
| 89 | )) |
| 90 | .unwrap(); |
| 91 | assert_eq!(fbx.positions, direct.character.positions); |
| 92 | assert_eq!(fbx.clips.len(), 2); |
| 93 | let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap_err(); |
| 94 | assert!(err.contains("geen tekenbaar lichaam"), "{err}"); |
| 95 | // A file named .glb that actually holds FBX bytes is not silently accepted. |
| 96 | let dir = scratch("renamed"); |
| 97 | let fake = dir.join("body.glb"); |
| 98 | std::fs::copy(repo("tests/fixtures/bodies/test-body-male.fbx"), &fake).unwrap(); |
| 99 | assert!(preview::load_body_file(&fake).is_err()); |
| 100 | let _ = std::fs::remove_dir_all(dir); |
| 101 | } |
| 102 | |
| 103 | #[test] |
| 104 | fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization() { |
| 105 | let fbx = fbx_body(); |
| 106 | let glb = glb_body(); |
| 107 | let fbx_plan = production::plan(&fbx, 12).unwrap(); |
| 108 | let glb_plan = production::plan(&glb, 12).unwrap(); |
| 109 | assert_eq!(fbx_plan.images, glb_plan.images); |
| 110 | assert_eq!(fbx_plan.files, glb_plan.files); |
| 111 | let fbx_job = job(fbx, None); |
| 112 | let glb_job = job(glb, None); |
| 113 | let mut cache = equipment::TextureCache::default(); |
| 114 | // Only `walk` is compared as a whole silhouette: Blender's FBX exporter |
| 115 | // baked the legs of the fixture's `attack` stack in a leftover walk pose |
| 116 | // (all 60 curves present in the file), so the attack clip is compared per |
| 117 | // animated arm joint below instead. |
| 118 | for (prefix, time) in [("walk", 0.0), ("walk", 0.25), ("walk", 0.6)] { |
| 119 | let f = production::scene( |
| 120 | &fbx_job, |
| 121 | &clip_named(&fbx_job.character, prefix), |
| 122 | time, |
| 123 | 30., |
| 124 | 90., |
| 125 | &mut cache, |
| 126 | ) |
| 127 | .unwrap(); |
| 128 | let g = production::scene(&glb_job, prefix, time, 30., 90., &mut cache).unwrap(); |
| 129 | f.validate().unwrap(); |
| 130 | let (flo, fhi) = bounds(&f); |
| 131 | let (glo, ghi) = bounds(&g); |
| 132 | for k in 0..3 { |
| 133 | assert!( |
| 134 | (flo[k] - glo[k]).abs() < 0.03 && (fhi[k] - ghi[k]).abs() < 0.03, |
| 135 | "{prefix}@{time} axis {k}: fbx {flo:?}..{fhi:?} vs glb {glo:?}..{ghi:?}" |
| 136 | ); |
| 137 | } |
| 138 | // Body height in metres (no centimetre or axis double-conversion). |
| 139 | assert!((fhi[1] - flo[1]) > 1.4 && (fhi[1] - flo[1]) < 2.2); |
| 140 | // Same fixed per-body framing: identical orthographic projection and |
| 141 | // a neck pivot resolved from the FBX rig close to the GLB one. |
| 142 | assert_eq!(f.projection, g.projection); |
| 143 | assert_eq!((f.width, f.height), (g.width, g.height)); |
| 144 | } |
| 145 | let attack = clip_named(&fbx_job.character, "attack"); |
| 146 | for name in [ |
| 147 | "RightUpperArm", |
| 148 | "RightLowerArm", |
| 149 | "RightHand", |
| 150 | "Chest", |
| 151 | "Spine", |
| 152 | ] { |
| 153 | for time in [0.0, 0.2, 0.4, 0.7] { |
| 154 | let f = |
| 155 | preview::joint_world_translation(&fbx_job.character, &attack, time, name).unwrap(); |
| 156 | let g = |
| 157 | preview::joint_world_translation(&glb_job.character, "attack", time, name).unwrap(); |
| 158 | let d = ((f[0] - g[0]).powi(2) + (f[1] - g[1]).powi(2) + (f[2] - g[2]).powi(2)).sqrt(); |
| 159 | assert!(d < 0.02, "attack {name}@{time}: {f:?} vs {g:?}"); |
| 160 | } |
| 161 | } |
| 162 | let fp = paperdoll_renderer::camera::resolve(&fbx_job.character, fbx_job.framing); |
| 163 | let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing); |
| 164 | assert!(matches!( |
| 165 | fp.source, |
| 166 | paperdoll_renderer::camera::PivotSource::Neck { .. } |
| 167 | )); |
| 168 | let (fy, gy) = ( |
| 169 | fp.framing.pivot_y_milli.unwrap(), |
| 170 | gp.framing.pivot_y_milli.unwrap(), |
| 171 | ); |
| 172 | assert!((fy - gy).abs() <= 30, "neck pivot fbx {fy} vs glb {gy}"); |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn rigid_fbx_import_normalizes_axes_units_and_embeds_png() { |
| 177 | let mut preset = Preset::new("fbxsword".into()); |
| 178 | let part = equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")) |
| 179 | .unwrap(); |
| 180 | let item = &preset.attachments[part]; |
| 181 | assert_eq!(item.label, "sword-fbx.fbx"); |
| 182 | assert_eq!(item.meshes.len(), 2); |
| 183 | let mut lo = [f32::MAX; 3]; |
| 184 | let mut hi = [f32::MIN; 3]; |
| 185 | for p in item.meshes.iter().flat_map(|m| &m.positions) { |
| 186 | for k in 0..3 { |
| 187 | lo[k] = lo[k].min(p[k]); |
| 188 | hi[k] = hi[k].max(p[k]); |
| 189 | } |
| 190 | } |
| 191 | // Exported Z-up from Blender: blade length must end up along +Y in metres. |
| 192 | assert!( |
| 193 | lo[1].abs() < 1e-3 && (hi[1] - 0.58).abs() < 1e-3, |
| 194 | "{lo:?} {hi:?}" |
| 195 | ); |
| 196 | assert!((hi[0] - 0.09).abs() < 1e-3 && (lo[0] + 0.09).abs() < 1e-3); |
| 197 | assert!((hi[2] - 0.015).abs() < 1e-3 && (lo[2] + 0.015).abs() < 1e-3); |
| 198 | for mesh in &item.meshes { |
| 199 | assert_eq!(mesh.positions.len(), mesh.normals.len()); |
| 200 | assert_eq!(mesh.positions.len(), mesh.uvs.len()); |
| 201 | for n in &mesh.normals { |
| 202 | let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); |
| 203 | assert!((len - 1.).abs() < 1e-3); |
| 204 | } |
| 205 | } |
| 206 | let blade = item.meshes.iter().find(|m| m.albedo.is_some()).unwrap(); |
| 207 | let guard = item.meshes.iter().find(|m| m.albedo.is_none()).unwrap(); |
| 208 | // A connected texture replaces the FBX diffuse colour; the guard keeps its colour. |
| 209 | assert_eq!(blade.color, [1., 1., 1., 1.]); |
| 210 | for (got, want) in guard.color.iter().zip([0.75, 0.55, 0.2, 1.]) { |
| 211 | assert!((got - want).abs() < 0.01, "{:?}", guard.color); |
| 212 | } |
| 213 | // UVs keep the OBJ/FBX convention (V up): on the broad blade faces the |
| 214 | // tip has V=1 and the base V=0 (the fixture maps each face to the unit square). |
| 215 | let broad = |want_y: f32| { |
| 216 | blade |
| 217 | .positions |
| 218 | .iter() |
| 219 | .zip(&blade.normals) |
| 220 | .zip(&blade.uvs) |
| 221 | .filter(|((p, n), _)| n[2].abs() > 0.9 && (p[1] - want_y).abs() < 1e-4) |
| 222 | .map(|(_, uv)| uv[1]) |
| 223 | .collect::<Vec<_>>() |
| 224 | }; |
| 225 | let (tip, base) = (broad(0.58), broad(0.03)); |
| 226 | assert!( |
| 227 | !tip.is_empty() && tip.iter().all(|v| (*v - 1.).abs() < 1e-4), |
| 228 | "{tip:?}" |
| 229 | ); |
| 230 | assert!( |
| 231 | !base.is_empty() && base.iter().all(|v| v.abs() < 1e-4), |
| 232 | "{base:?}" |
| 233 | ); |
| 234 | let albedo = blade.albedo.unwrap(); |
| 235 | assert!(preset.assets[albedo].path.contains("/embedded/")); |
| 236 | let embedded = preset.texture(albedo).unwrap(); |
| 237 | let mut reference = Preset::new("ref".into()); |
| 238 | reference.assets.push(equipment::EmbeddedAsset { |
| 239 | path: "checker.png".into(), |
| 240 | bytes: std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap(), |
| 241 | }); |
| 242 | assert_eq!(embedded.rgba, reference.texture(0).unwrap().rgba); |
| 243 | // Original FBX payload is kept, and the binary preset round-trips. |
| 244 | assert!(item.source_assets.iter().any(|i| preset.assets[*i].bytes |
| 245 | == std::fs::read(repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap())); |
| 246 | let decoded = Preset::decode(&preset.encode().unwrap()).unwrap(); |
| 247 | assert_eq!(decoded, preset); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn rigid_fbx_external_png_is_embedded_and_missing_texture_fails() { |
| 252 | let mut preset = Preset::new("fbxsword".into()); |
| 253 | let part = equipment::import_model( |
| 254 | &mut preset, |
| 255 | &repo("tests/fixtures/equipment/sword-fbx-external.fbx"), |
| 256 | ) |
| 257 | .unwrap(); |
| 258 | let blade = preset.attachments[part] |
| 259 | .meshes |
| 260 | .iter() |
| 261 | .find(|m| m.albedo.is_some()) |
| 262 | .unwrap(); |
| 263 | let asset = &preset.assets[blade.albedo.unwrap()]; |
| 264 | assert!(asset.path.ends_with("/checker.png"), "{}", asset.path); |
| 265 | assert_eq!( |
| 266 | asset.bytes, |
| 267 | std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap() |
| 268 | ); |
| 269 | let dir = scratch("missing-tex"); |
| 270 | std::fs::copy( |
| 271 | repo("tests/fixtures/equipment/sword-fbx-external.fbx"), |
| 272 | dir.join("lonely.fbx"), |
| 273 | ) |
| 274 | .unwrap(); |
| 275 | let mut other = Preset::new("lonely".into()); |
| 276 | let err = equipment::import_fbx(&mut other, &dir.join("lonely.fbx")).unwrap_err(); |
| 277 | assert!(err.contains("FBX-texture"), "{err}"); |
| 278 | assert!(other.attachments.is_empty() && other.assets.is_empty()); |
| 279 | let _ = std::fs::remove_dir_all(dir); |
| 280 | } |
| 281 | |
| 282 | #[test] |
| 283 | fn skinned_garbage_or_unsupported_equipment_is_rejected() { |
| 284 | let mut preset = Preset::new("bad".into()); |
| 285 | let err = equipment::import_fbx( |
| 286 | &mut preset, |
| 287 | &repo("tests/fixtures/bodies/test-body-male.fbx"), |
| 288 | ) |
| 289 | .unwrap_err(); |
| 290 | assert!(err.contains("geskinned"), "{err}"); |
| 291 | let dir = scratch("garbage"); |
| 292 | std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap(); |
| 293 | assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err()); |
| 294 | assert!(equipment::import_model( |
| 295 | &mut preset, |
| 296 | &repo("tests/fixtures/bodies/test-body-male.glb") |
| 297 | ) |
| 298 | .is_err()); |
| 299 | assert!(preset.attachments.is_empty() && preset.assets.is_empty()); |
| 300 | // OBJ still goes through the same dispatcher. |
| 301 | equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap(); |
| 302 | assert_eq!(preset.attachments.len(), 1); |
| 303 | let _ = std::fs::remove_dir_all(dir); |
| 304 | } |
| 305 | |
| 306 | #[test] |
| 307 | fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() { |
| 308 | let body = fbx_body(); |
| 309 | let joint = body |
| 310 | .joint_names |
| 311 | .iter() |
| 312 | .position(|n| n == "RightHand") |
| 313 | .unwrap(); |
| 314 | let mut preset = Preset::new("fbxsword".into()); |
| 315 | equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap(); |
| 316 | preset.bind(0, &body, 0, joint).unwrap(); |
| 317 | preset.attachments[0].rotation_deg = [0., 0., -15.]; |
| 318 | let attack = clip_named(&body, "attack"); |
| 319 | let job = job(body, Some(preset.clone())); |
| 320 | let mut cache = equipment::TextureCache::default(); |
| 321 | let early = production::scene(&job, &attack, 0.0, 0., 0., &mut cache).unwrap(); |
| 322 | let late = production::scene(&job, &attack, 0.4, 0., 0., &mut cache).unwrap(); |
| 323 | assert_eq!(early.draws.len(), 2); |
| 324 | assert!(early.draws.iter().any(|d| d.albedo.is_some())); |
| 325 | assert_eq!(early.view, late.view); |
| 326 | assert_ne!(early.draws[0].positions, late.draws[0].positions); |
| 327 | // Exact attachment: animated joint world × local transform × source vertex. |
| 328 | let world = preview::animated_joint_world(&job.character, &attack, 0.4, joint).unwrap(); |
| 329 | let model = preview::mul(world, equipment::local_matrix([0.; 3], [0., 0., -15.])); |
| 330 | let layers = equipment::layers( |
| 331 | &preset, |
| 332 | 0, |
| 333 | &job.character, |
| 334 | &PreviewControls { |
| 335 | clip_name: attack.clone(), |
| 336 | time: 0.4, |
| 337 | ..Default::default() |
| 338 | }, |
| 339 | ) |
| 340 | .unwrap(); |
| 341 | assert_eq!(layers[0].model, model); |
| 342 | let expected = apply(&model, preset.attachments[0].meshes[0].positions[0]); |
| 343 | let got = late.draws[0].positions[0]; |
| 344 | for k in 0..3 { |
| 345 | assert!( |
| 346 | (expected[k] - got[k]).abs() < 1e-5, |
| 347 | "{expected:?} vs {got:?}" |
| 348 | ); |
| 349 | } |
| 350 | // The hand-bound sword sits at the FBX hand, not at the origin. |
| 351 | let hand = [world[12], world[13], world[14]]; |
| 352 | assert!(hand[1] > 0.5); |
| 353 | let d = ((got[0] - hand[0]).powi(2) + (got[1] - hand[1]).powi(2) + (got[2] - hand[2]).powi(2)) |
| 354 | .sqrt(); |
| 355 | assert!(d < 0.7, "sword vertex {got:?} far from hand {hand:?}"); |
| 356 | // A preset bound to the GLB rig is refused on the FBX rig instead of guessing. |
| 357 | let mut glb_bound = Preset::new("glbsword".into()); |
| 358 | equipment::import_fbx( |
| 359 | &mut glb_bound, |
| 360 | &repo("tests/fixtures/equipment/sword-fbx.fbx"), |
| 361 | ) |
| 362 | .unwrap(); |
| 363 | let glb = glb_body(); |
| 364 | glb_bound |
| 365 | .bind( |
| 366 | 0, |
| 367 | &glb, |
| 368 | 0, |
| 369 | glb.joint_names |
| 370 | .iter() |
| 371 | .position(|n| n == "RightHand") |
| 372 | .unwrap(), |
| 373 | ) |
| 374 | .unwrap(); |
| 375 | let err = |
| 376 | equipment::layers(&glb_bound, 0, &job.character, &PreviewControls::default()).unwrap_err(); |
| 377 | assert!(err.contains("rig wijkt af"), "{err}"); |
| 378 | } |