//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin. //! They load in their stored pose with no skeleton and no clips, use the //! documented center-Y pivot fallback, render one bind sample × 60 views and //! refuse bone-dependent equipment instead of inventing a rig. use paperdoll_renderer::{ camera, equipment, preview, preview::BodyRig, production::{self, RenderJob, SourceFingerprint}, project::CameraFraming, }; use std::path::{Path, PathBuf}; fn repo(p: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join(p) } fn obj() -> preview::PreviewCharacter { preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.obj")).unwrap() } fn static_glb() -> preview::PreviewCharacter { preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.glb")).unwrap() } fn skinned_glb() -> preview::PreviewCharacter { preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap() } fn bbox(points: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) { let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3]; for p in points { for i in 0..3 { lo[i] = lo[i].min(p[i]); hi[i] = hi[i].max(p[i]); } } (lo, hi) } fn close(a: [f32; 3], b: [f32; 3], eps: f32) -> bool { (0..3).all(|i| (a[i] - b[i]).abs() <= eps) } /// Every static vertex lies on a vertex of the skinned body's bind pose. fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) { for p in static_points { assert!( bind.iter().any(|q| close(*p, *q, eps)), "static vertex {p:?} not in skinned bind pose" ); } for q in bind { assert!( static_points.iter().any(|p| close(*p, *q, eps)), "bind vertex {q:?} missing from static body" ); } } fn job(character: preview::PreviewCharacter, preset: Option) -> RenderJob { RenderJob { character, sex: "male".into(), preset, target_width: 32, target_height: 48, fps: 4, framing: CameraFraming::default(), output: std::env::temp_dir().join("paperdoll-static-body-test-unused"), project_id: "test".into(), project_name: "test".into(), body_model: SourceFingerprint { path: "assets/body/static.obj".into(), sha256: "0".repeat(64), }, preset_sha256: None, } } #[test] fn obj_body_is_static_bind_geometry_without_skeleton_or_clips() { let body = obj(); assert!(body.is_static()); assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "OBJ")); assert!(body.static_reason().unwrap().contains("geen skeleton")); assert!(body.joint_nodes.is_empty() && body.joint_names.is_empty()); assert!(body.inverse_binds.is_empty() && body.nodes.is_empty()); assert!(body.clips.is_empty()); assert!(preview::sample_skin_matrices(&body, "", 0.).is_empty()); assert_eq!(body.weights.len(), body.positions.len()); assert!(body.source_normals_complete); // OBJ is used as stored: identical to the skinned GLB in bind pose. same_point_set(&body.positions, &skinned_glb().positions, 1e-5); assert_eq!(body.surfaces.len(), 1); assert!(body.surfaces[0].albedo.is_some()); } #[test] fn obj_uv_origin_is_converted_once_to_top_left() { let body = obj(); let tex = body.surfaces[0].albedo.clone().unwrap(); assert_eq!((tex.width, tex.height), (4, 4)); let texel = |uv: [f32; 2]| { let x = ((uv[0] * tex.width as f32) as u32).min(tex.width - 1); let y = ((uv[1] * tex.height as f32) as u32).min(tex.height - 1); let i = ((y * tex.width + x) * 4) as usize; [tex.rgba[i], tex.rgba[i + 1], tex.rgba[i + 2]] }; let mut head = 0; let mut feet = 0; for (p, uv) in body.positions.iter().zip(&body.uvs) { if p[1] > 1.6 { assert_eq!( texel(*uv), [220, 40, 30], "head must sample the red top half" ); head += 1; } if p[1] < 0.2 { assert_eq!( texel(*uv), [30, 60, 220], "feet must sample the blue bottom half" ); feet += 1; } } assert!(head > 0 && feet > 0); } #[test] fn unskinned_gltf_bakes_node_transforms_once_and_ignores_animation() { let body = static_glb(); assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "glTF")); assert!(body.static_reason().unwrap().contains("1 animatie")); assert!(body.clips.is_empty() && body.joint_nodes.is_empty()); // Parent T·S(2) and child R_y(90) baked once == skinned bind pose. same_point_set(&body.positions, &skinned_glb().positions, 1e-4); // Normals went through the inverse-transpose: outward unit face normals. for tri in body.indices.chunks_exact(3) { let [a, b, c] = [0, 1, 2].map(|k| body.positions[tri[k] as usize]); let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; let n = [ u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0], ]; let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); let n = [n[0] / len, n[1] / len, n[2] / len]; let stored = body.normals[tri[0] as usize]; assert!(close(stored, n, 1e-4), "normal {stored:?} vs face {n:?}"); } let (a, b) = (bbox(&body.positions), bbox(&obj().positions)); assert!( close(a.0, b.0, 1e-4) && close(a.1, b.1, 1e-4), "{a:?} vs {b:?}" ); } #[test] fn unskinned_fbx_loads_as_static_body_with_one_normalization() { let path = repo("tests/fixtures/equipment/sword-fbx.fbx"); let body = preview::load_body_file(&path).unwrap(); assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "FBX")); assert!(body.clips.is_empty() && body.joint_nodes.is_empty()); assert!(body.surfaces.iter().any(|s| s.albedo.is_some())); // Same geometry as the rigid FBX equipment reader; only UV origin flipped. let mut preset = equipment::Preset::new("probe".into()); equipment::import_fbx(&mut preset, &path).unwrap(); let meshes = &preset.attachments[0].meshes; let positions: Vec<_> = meshes.iter().flat_map(|m| m.positions.clone()).collect(); let uvs: Vec<_> = meshes.iter().flat_map(|m| m.uvs.clone()).collect(); assert_eq!(body.positions, positions); for (a, b) in body.uvs.iter().zip(&uvs) { assert_eq!(a[0], b[0]); assert!((a[1] - (1. - b[1])).abs() < 1e-6); } // The skinned FBX body still goes through fbx_scene with its skeleton. let skinned = preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap(); assert_eq!(skinned.rig, BodyRig::Skinned); assert!(!skinned.joint_names.is_empty()); } #[test] fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() { let body = obj(); let framing = CameraFraming::default(); let resolved = camera::resolve(&body, framing); assert_eq!(resolved.framing.pivot_y_milli, Some(framing.center_y_milli)); match &resolved.source { camera::PivotSource::Fallback { reason } => assert!(reason.contains("Statisch")), other => panic!("expected fallback, got {other:?}"), } let manual = camera::resolve( &body, CameraFraming { pivot_y_milli: Some(1520), ..framing }, ); assert_eq!(manual.framing.pivot_y_milli, Some(1520)); assert_eq!(manual.source, camera::PivotSource::Manual); } #[test] fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() { let plan = production::plan(&obj(), 12).unwrap(); assert_eq!(plan.clips.len(), 1); assert_eq!(plan.clips[0].token, "clip000-bind"); assert_eq!(plan.clips[0].frames, 1); assert_eq!(plan.images, 60); assert_eq!(plan.files, 240); let paths = production::planned_paths(&plan, "male", "body").unwrap(); assert_eq!(paths.len(), 240); for map in ["diffuse", "normal", "depth", "specular"] { assert_eq!( paths .iter() .filter(|p| p.ends_with(&format!("_{map}.png"))) .count(), 60 ); } let contract = production::contract(job(obj(), None)).unwrap(); assert_eq!(contract.plan, plan_with_fps(4)); assert_eq!(contract.render_doc["pivot_source"]["kind"], "fallback"); assert_eq!(contract.render_doc["clips"][0]["token"], "clip000-bind"); } fn plan_with_fps(fps: u32) -> production::Plan { production::plan(&obj(), fps).unwrap() } #[test] fn static_production_scene_draws_stored_geometry_with_albedo() { let body = obj(); let job = job(body.clone(), None); let mut cache = equipment::TextureCache::default(); let scene = production::scene(&job, "", 0., 0., 0., &mut cache).unwrap(); assert_eq!(scene.draws.len(), 1); assert_eq!(*scene.draws[0].positions, body.positions); assert!(scene.draws[0].albedo.is_some()); assert_eq!(scene.draws[0].specular, 0.); } #[test] fn bone_functions_are_refused_on_a_static_body() { let body = obj(); let mut preset = equipment::Preset::new("sword".into()); equipment::import_obj(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap(); assert!(preset.bind(0, &body, 0, 0).is_err()); let err = production::contract(job(body.clone(), Some(preset.clone()))) .err() .unwrap(); assert!(err.contains("statisch"), "{err}"); // Layers cannot be attached without a bone either. assert!(equipment::layers(&preset, 0, &body, &Default::default()).is_err()); } #[test] fn obj_without_mtl_dependency_fails_with_its_name() { let dir = std::env::temp_dir().join(format!("paperdoll-static-missing-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::copy( repo("tests/fixtures/bodies/static/static-body-male.obj"), dir.join("static-body-male.obj"), ) .unwrap(); let err = preview::load_body_file(&dir.join("static-body-male.obj")).unwrap_err(); std::fs::remove_dir_all(&dir).ok(); assert!(err.contains("statisch OBJ-lichaam"), "{err}"); }