//! FBX bodies in the live/production path and rigid FBX equipment import. use paperdoll_renderer::equipment::{self, Preset}; use paperdoll_renderer::preview::{self, Mat4, PreviewCharacter, PreviewControls}; use paperdoll_renderer::production::{self, RenderJob, SourceFingerprint}; use paperdoll_renderer::project::CameraFraming; use std::path::PathBuf; fn repo(path: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) } fn fbx_body() -> PreviewCharacter { preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap() } fn glb_body() -> PreviewCharacter { preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap() } fn scratch(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "paperdoll-fbx-{tag}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); dir } fn job(character: PreviewCharacter, preset: Option) -> RenderJob { RenderJob { depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16, atlas_trim: false, character, sex: "male".into(), preset, target_width: 32, target_height: 48, fps: 4, framing: CameraFraming { height_milli: 2400, center_y_milli: 1000, pivot_y_milli: None, depth_range_milli: None, }, output: std::env::temp_dir(), project_id: "fbx-test".into(), project_name: "FBX".into(), body_model: SourceFingerprint { path: "assets/body/test-body-male.fbx".into(), sha256: "0".repeat(64), }, preset_sha256: None, } } fn bounds(scene: &production::Scene) -> ([f32; 3], [f32; 3]) { let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3]; for draw in &scene.draws { for &i in &draw.indices { let p = draw.positions[i as usize]; for k in 0..3 { lo[k] = lo[k].min(p[k]); hi[k] = hi[k].max(p[k]); } } } (lo, hi) } fn clip_named(body: &PreviewCharacter, prefix: &str) -> String { body.clips .iter() .find(|c| c.name.contains(prefix)) .unwrap_or_else(|| panic!("clip {prefix} ontbreekt")) .name .clone() } fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] { [ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], ] } #[test] fn body_loader_dispatches_by_extension_and_rejects_other_formats() { assert!(preview::is_drawable_body(std::path::Path::new("a/B.FBX"))); assert!(preview::is_drawable_body(std::path::Path::new("b.glb"))); // OBJ is drawable as a static body (no skeleton); STL/PLY are not bodies. assert!(preview::is_drawable_body(std::path::Path::new("c.obj"))); assert!(!preview::is_drawable_body(std::path::Path::new("d.stl"))); let fbx = fbx_body(); let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo( "tests/fixtures/bodies/test-body-male.fbx", )) .unwrap(); assert_eq!(fbx.positions, direct.character.positions); assert_eq!(fbx.clips.len(), 2); let obj = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap(); assert!(obj.is_static() && obj.clips.is_empty()); let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.ply")).unwrap_err(); assert!(err.contains("geen tekenbaar lichaam"), "{err}"); // A file named .glb that actually holds FBX bytes is not silently accepted. let dir = scratch("renamed"); let fake = dir.join("body.glb"); std::fs::copy(repo("tests/fixtures/bodies/test-body-male.fbx"), &fake).unwrap(); assert!(preview::load_body_file(&fake).is_err()); let _ = std::fs::remove_dir_all(dir); } #[test] fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization() { let fbx = fbx_body(); let glb = glb_body(); let fbx_plan = production::plan(&fbx, 12).unwrap(); let glb_plan = production::plan(&glb, 12).unwrap(); assert_eq!(fbx_plan.images, glb_plan.images); assert_eq!(fbx_plan.files, glb_plan.files); let fbx_job = job(fbx, None); let glb_job = job(glb, None); let mut cache = equipment::TextureCache::default(); // Only `walk` is compared as a whole silhouette: Blender's FBX exporter // baked the legs of the fixture's `attack` stack in a leftover walk pose // (all 60 curves present in the file), so the attack clip is compared per // animated arm joint below instead. for (prefix, time) in [("walk", 0.0), ("walk", 0.25), ("walk", 0.6)] { let f = production::scene( &fbx_job, &clip_named(&fbx_job.character, prefix), time, 30., 90., &mut cache, ) .unwrap(); let g = production::scene(&glb_job, prefix, time, 30., 90., &mut cache).unwrap(); f.validate().unwrap(); let (flo, fhi) = bounds(&f); let (glo, ghi) = bounds(&g); for k in 0..3 { assert!( (flo[k] - glo[k]).abs() < 0.03 && (fhi[k] - ghi[k]).abs() < 0.03, "{prefix}@{time} axis {k}: fbx {flo:?}..{fhi:?} vs glb {glo:?}..{ghi:?}" ); } // Body height in metres (no centimetre or axis double-conversion). assert!((fhi[1] - flo[1]) > 1.4 && (fhi[1] - flo[1]) < 2.2); // Same fixed per-body framing: identical orthographic projection and // a rest-pose centre pivot from the FBX close to the GLB one (the // pivot offset is the only projection term that may differ slightly). for (a, b) in f.projection.iter().zip(g.projection) { assert!( (a - b).abs() < 0.01, "{:?} vs {:?}", f.projection, g.projection ); } assert_eq!((f.width, f.height), (g.width, g.height)); } let attack = clip_named(&fbx_job.character, "attack"); for name in [ "RightUpperArm", "RightLowerArm", "RightHand", "Chest", "Spine", ] { for time in [0.0, 0.2, 0.4, 0.7] { let f = preview::joint_world_translation(&fbx_job.character, &attack, time, name).unwrap(); let g = preview::joint_world_translation(&glb_job.character, "attack", time, name).unwrap(); let d = ((f[0] - g[0]).powi(2) + (f[1] - g[1]).powi(2) + (f[2] - g[2]).powi(2)).sqrt(); assert!(d < 0.02, "attack {name}@{time}: {f:?} vs {g:?}"); } } let fp = paperdoll_renderer::camera::resolve(&fbx_job.character, fbx_job.framing); let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing); assert!(matches!( fp.source, paperdoll_renderer::camera::PivotSource::BoundsCenter { .. } )); let (fy, gy) = ( fp.framing.pivot_y_milli.unwrap(), gp.framing.pivot_y_milli.unwrap(), ); assert!((fy - gy).abs() <= 30, "centre pivot fbx {fy} vs glb {gy}"); } #[test] fn rigid_fbx_import_normalizes_axes_units_and_embeds_png() { let mut preset = Preset::new("fbxsword".into()); let part = equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")) .unwrap(); let item = &preset.attachments[part]; assert_eq!(item.label, "sword-fbx.fbx"); assert_eq!(item.meshes.len(), 2); let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3]; for p in item.meshes.iter().flat_map(|m| &m.positions) { for k in 0..3 { lo[k] = lo[k].min(p[k]); hi[k] = hi[k].max(p[k]); } } // Exported Z-up from Blender: blade length must end up along +Y in metres. assert!( lo[1].abs() < 1e-3 && (hi[1] - 0.58).abs() < 1e-3, "{lo:?} {hi:?}" ); assert!((hi[0] - 0.09).abs() < 1e-3 && (lo[0] + 0.09).abs() < 1e-3); assert!((hi[2] - 0.015).abs() < 1e-3 && (lo[2] + 0.015).abs() < 1e-3); for mesh in &item.meshes { assert_eq!(mesh.positions.len(), mesh.normals.len()); assert_eq!(mesh.positions.len(), mesh.uvs.len()); for n in &mesh.normals { let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); assert!((len - 1.).abs() < 1e-3); } } let blade = item.meshes.iter().find(|m| m.albedo.is_some()).unwrap(); let guard = item.meshes.iter().find(|m| m.albedo.is_none()).unwrap(); // A connected texture replaces the FBX diffuse colour; the guard keeps its colour. assert_eq!(blade.color, [1., 1., 1., 1.]); for (got, want) in guard.color.iter().zip([0.75, 0.55, 0.2, 1.]) { assert!((got - want).abs() < 0.01, "{:?}", guard.color); } // UVs keep the OBJ/FBX convention (V up): on the broad blade faces the // tip has V=1 and the base V=0 (the fixture maps each face to the unit square). let broad = |want_y: f32| { blade .positions .iter() .zip(&blade.normals) .zip(&blade.uvs) .filter(|((p, n), _)| n[2].abs() > 0.9 && (p[1] - want_y).abs() < 1e-4) .map(|(_, uv)| uv[1]) .collect::>() }; let (tip, base) = (broad(0.58), broad(0.03)); assert!( !tip.is_empty() && tip.iter().all(|v| (*v - 1.).abs() < 1e-4), "{tip:?}" ); assert!( !base.is_empty() && base.iter().all(|v| v.abs() < 1e-4), "{base:?}" ); let albedo = blade.albedo.unwrap(); assert!(preset.assets[albedo].path.contains("/embedded/")); let embedded = preset.texture(albedo).unwrap(); let mut reference = Preset::new("ref".into()); reference.assets.push(equipment::EmbeddedAsset { path: "checker.png".into(), bytes: std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap(), }); assert_eq!(embedded.rgba, reference.texture(0).unwrap().rgba); // Original FBX payload is kept, and the binary preset round-trips. assert!(item.source_assets.iter().any(|i| preset.assets[*i].bytes == std::fs::read(repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap())); let decoded = Preset::decode(&preset.encode().unwrap()).unwrap(); assert_eq!(decoded, preset); } #[test] fn rigid_fbx_external_png_is_embedded_and_missing_texture_fails() { let mut preset = Preset::new("fbxsword".into()); let part = equipment::import_model( &mut preset, &repo("tests/fixtures/equipment/sword-fbx-external.fbx"), ) .unwrap(); let blade = preset.attachments[part] .meshes .iter() .find(|m| m.albedo.is_some()) .unwrap(); let asset = &preset.assets[blade.albedo.unwrap()]; assert!(asset.path.ends_with("/checker.png"), "{}", asset.path); assert_eq!( asset.bytes, std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap() ); let dir = scratch("missing-tex"); std::fs::copy( repo("tests/fixtures/equipment/sword-fbx-external.fbx"), dir.join("lonely.fbx"), ) .unwrap(); let mut other = Preset::new("lonely".into()); let err = equipment::import_fbx(&mut other, &dir.join("lonely.fbx")).unwrap_err(); assert!(err.contains("FBX-texture"), "{err}"); assert!(other.attachments.is_empty() && other.assets.is_empty()); let _ = std::fs::remove_dir_all(dir); } #[test] fn skinned_garbage_or_unsupported_equipment_is_rejected() { let mut preset = Preset::new("bad".into()); let err = equipment::import_fbx( &mut preset, &repo("tests/fixtures/bodies/test-body-male.fbx"), ) .unwrap_err(); assert!(err.contains("geskinned"), "{err}"); let dir = scratch("garbage"); std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap(); assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err()); // Tim t1027u: a skinned model is now a skinned part with its skeleton. for skinned in [ "tests/fixtures/bodies/test-body-male.glb", "tests/fixtures/bodies/test-body-male.fbx", ] { let mut ok = Preset::new("suit".into()); let part = equipment::import_model(&mut ok, &repo(skinned)).unwrap(); assert!(ok.attachments[part].skeleton.is_some(), "{skinned}"); } assert!(preset.attachments.is_empty() && preset.assets.is_empty()); // OBJ still goes through the same dispatcher. equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap(); assert_eq!(preset.attachments.len(), 1); let _ = std::fs::remove_dir_all(dir); } #[test] fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() { let body = fbx_body(); let joint = body .joint_names .iter() .position(|n| n == "RightHand") .unwrap(); let mut preset = Preset::new("fbxsword".into()); equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap(); preset.bind(0, &body, 0, joint).unwrap(); preset.attachments[0].rotation_deg = [0., 0., -15.]; let attack = clip_named(&body, "attack"); let job = job(body, Some(preset.clone())); let mut cache = equipment::TextureCache::default(); let early = production::scene(&job, &attack, 0.0, 0., 0., &mut cache).unwrap(); let late = production::scene(&job, &attack, 0.4, 0., 0., &mut cache).unwrap(); assert_eq!(early.draws.len(), 2); assert!(early.draws.iter().any(|d| d.albedo.is_some())); assert_eq!(early.view, late.view); assert_ne!(early.draws[0].positions, late.draws[0].positions); // Exact attachment: animated joint world × local transform × source vertex. let world = preview::animated_joint_world(&job.character, &attack, 0.4, joint).unwrap(); let model = preview::mul(world, equipment::local_matrix([0.; 3], [0., 0., -15.])); let layers = equipment::layers( &preset, 0, &job.character, &PreviewControls { clip_name: attack.clone(), time: 0.4, ..Default::default() }, ) .unwrap(); assert_eq!(layers[0].model, model); let expected = apply(&model, preset.attachments[0].meshes[0].positions[0]); let got = late.draws[0].positions[0]; for k in 0..3 { assert!( (expected[k] - got[k]).abs() < 1e-5, "{expected:?} vs {got:?}" ); } // The hand-bound sword sits at the FBX hand, not at the origin. let hand = [world[12], world[13], world[14]]; assert!(hand[1] > 0.5); let d = ((got[0] - hand[0]).powi(2) + (got[1] - hand[1]).powi(2) + (got[2] - hand[2]).powi(2)) .sqrt(); assert!(d < 0.7, "sword vertex {got:?} far from hand {hand:?}"); // A preset bound to the GLB rig is refused on the FBX rig instead of guessing. let mut glb_bound = Preset::new("glbsword".into()); equipment::import_fbx( &mut glb_bound, &repo("tests/fixtures/equipment/sword-fbx.fbx"), ) .unwrap(); let glb = glb_body(); glb_bound .bind( 0, &glb, 0, glb.joint_names .iter() .position(|n| n == "RightHand") .unwrap(), ) .unwrap(); let err = equipment::layers(&glb_bound, 0, &job.character, &PreviewControls::default()).unwrap_err(); assert!(err.contains("rig wijkt af"), "{err}"); }