| 1 | //! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin. |
| 2 | //! They load in their stored pose with no skeleton and no clips, use the |
| 3 | //! documented center-Y pivot fallback, render one bind sample × 60 views and |
| 4 | //! refuse bone-dependent equipment instead of inventing a rig. |
| 5 | use paperdoll_renderer::{ |
| 6 | camera, equipment, preview, |
| 7 | preview::BodyRig, |
| 8 | production::{self, RenderJob, SourceFingerprint}, |
| 9 | project::CameraFraming, |
| 10 | }; |
| 11 | use std::path::{Path, PathBuf}; |
| 12 | |
| 13 | fn repo(p: &str) -> PathBuf { |
| 14 | Path::new(env!("CARGO_MANIFEST_DIR")).join(p) |
| 15 | } |
| 16 | fn obj() -> preview::PreviewCharacter { |
| 17 | preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.obj")).unwrap() |
| 18 | } |
| 19 | fn static_glb() -> preview::PreviewCharacter { |
| 20 | preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.glb")).unwrap() |
| 21 | } |
| 22 | fn skinned_glb() -> preview::PreviewCharacter { |
| 23 | preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap() |
| 24 | } |
| 25 | fn bbox(points: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) { |
| 26 | let mut lo = [f32::MAX; 3]; |
| 27 | let mut hi = [f32::MIN; 3]; |
| 28 | for p in points { |
| 29 | for i in 0..3 { |
| 30 | lo[i] = lo[i].min(p[i]); |
| 31 | hi[i] = hi[i].max(p[i]); |
| 32 | } |
| 33 | } |
| 34 | (lo, hi) |
| 35 | } |
| 36 | fn close(a: [f32; 3], b: [f32; 3], eps: f32) -> bool { |
| 37 | (0..3).all(|i| (a[i] - b[i]).abs() <= eps) |
| 38 | } |
| 39 | /// Every static vertex lies on a vertex of the skinned body's bind pose. |
| 40 | fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) { |
| 41 | for p in static_points { |
| 42 | assert!( |
| 43 | bind.iter().any(|q| close(*p, *q, eps)), |
| 44 | "static vertex {p:?} not in skinned bind pose" |
| 45 | ); |
| 46 | } |
| 47 | for q in bind { |
| 48 | assert!( |
| 49 | static_points.iter().any(|p| close(*p, *q, eps)), |
| 50 | "bind vertex {q:?} missing from static body" |
| 51 | ); |
| 52 | } |
| 53 | } |
| 54 | fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob { |
| 55 | RenderJob { |
| 56 | depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16, |
| 57 | character, |
| 58 | sex: "male".into(), |
| 59 | preset, |
| 60 | target_width: 32, |
| 61 | target_height: 48, |
| 62 | fps: 4, |
| 63 | framing: CameraFraming::default(), |
| 64 | output: std::env::temp_dir().join("paperdoll-static-body-test-unused"), |
| 65 | project_id: "test".into(), |
| 66 | project_name: "test".into(), |
| 67 | body_model: SourceFingerprint { |
| 68 | path: "assets/body/static.obj".into(), |
| 69 | sha256: "0".repeat(64), |
| 70 | }, |
| 71 | preset_sha256: None, |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | #[test] |
| 76 | fn obj_body_is_static_bind_geometry_without_skeleton_or_clips() { |
| 77 | let body = obj(); |
| 78 | assert!(body.is_static()); |
| 79 | assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "OBJ")); |
| 80 | assert!(body.static_reason().unwrap().contains("geen skeleton")); |
| 81 | assert!(body.joint_nodes.is_empty() && body.joint_names.is_empty()); |
| 82 | assert!(body.inverse_binds.is_empty() && body.nodes.is_empty()); |
| 83 | assert!(body.clips.is_empty()); |
| 84 | assert!(preview::sample_skin_matrices(&body, "", 0.).is_empty()); |
| 85 | assert_eq!(body.weights.len(), body.positions.len()); |
| 86 | assert!(body.source_normals_complete); |
| 87 | // OBJ is used as stored: identical to the skinned GLB in bind pose. |
| 88 | same_point_set(&body.positions, &skinned_glb().positions, 1e-5); |
| 89 | assert_eq!(body.surfaces.len(), 1); |
| 90 | assert!(body.surfaces[0].albedo.is_some()); |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn obj_uv_origin_is_converted_once_to_top_left() { |
| 95 | let body = obj(); |
| 96 | let tex = body.surfaces[0].albedo.clone().unwrap(); |
| 97 | assert_eq!((tex.width, tex.height), (4, 4)); |
| 98 | let texel = |uv: [f32; 2]| { |
| 99 | let x = ((uv[0] * tex.width as f32) as u32).min(tex.width - 1); |
| 100 | let y = ((uv[1] * tex.height as f32) as u32).min(tex.height - 1); |
| 101 | let i = ((y * tex.width + x) * 4) as usize; |
| 102 | [tex.rgba[i], tex.rgba[i + 1], tex.rgba[i + 2]] |
| 103 | }; |
| 104 | let mut head = 0; |
| 105 | let mut feet = 0; |
| 106 | for (p, uv) in body.positions.iter().zip(&body.uvs) { |
| 107 | if p[1] > 1.6 { |
| 108 | assert_eq!( |
| 109 | texel(*uv), |
| 110 | [220, 40, 30], |
| 111 | "head must sample the red top half" |
| 112 | ); |
| 113 | head += 1; |
| 114 | } |
| 115 | if p[1] < 0.2 { |
| 116 | assert_eq!( |
| 117 | texel(*uv), |
| 118 | [30, 60, 220], |
| 119 | "feet must sample the blue bottom half" |
| 120 | ); |
| 121 | feet += 1; |
| 122 | } |
| 123 | } |
| 124 | assert!(head > 0 && feet > 0); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn unskinned_gltf_bakes_node_transforms_once_and_ignores_animation() { |
| 129 | let body = static_glb(); |
| 130 | assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "glTF")); |
| 131 | assert!(body.static_reason().unwrap().contains("1 animatie")); |
| 132 | assert!(body.clips.is_empty() && body.joint_nodes.is_empty()); |
| 133 | // Parent T·S(2) and child R_y(90) baked once == skinned bind pose. |
| 134 | same_point_set(&body.positions, &skinned_glb().positions, 1e-4); |
| 135 | // Normals went through the inverse-transpose: outward unit face normals. |
| 136 | for tri in body.indices.chunks_exact(3) { |
| 137 | let [a, b, c] = [0, 1, 2].map(|k| body.positions[tri[k] as usize]); |
| 138 | let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; |
| 139 | let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; |
| 140 | let n = [ |
| 141 | u[1] * v[2] - u[2] * v[1], |
| 142 | u[2] * v[0] - u[0] * v[2], |
| 143 | u[0] * v[1] - u[1] * v[0], |
| 144 | ]; |
| 145 | let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); |
| 146 | let n = [n[0] / len, n[1] / len, n[2] / len]; |
| 147 | let stored = body.normals[tri[0] as usize]; |
| 148 | assert!(close(stored, n, 1e-4), "normal {stored:?} vs face {n:?}"); |
| 149 | } |
| 150 | let (a, b) = (bbox(&body.positions), bbox(&obj().positions)); |
| 151 | assert!( |
| 152 | close(a.0, b.0, 1e-4) && close(a.1, b.1, 1e-4), |
| 153 | "{a:?} vs {b:?}" |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn unskinned_fbx_loads_as_static_body_with_one_normalization() { |
| 159 | let path = repo("tests/fixtures/equipment/sword-fbx.fbx"); |
| 160 | let body = preview::load_body_file(&path).unwrap(); |
| 161 | assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "FBX")); |
| 162 | assert!(body.clips.is_empty() && body.joint_nodes.is_empty()); |
| 163 | assert!(body.surfaces.iter().any(|s| s.albedo.is_some())); |
| 164 | // Same geometry as the rigid FBX equipment reader; only UV origin flipped. |
| 165 | let mut preset = equipment::Preset::new("probe".into()); |
| 166 | equipment::import_fbx(&mut preset, &path).unwrap(); |
| 167 | let meshes = &preset.attachments[0].meshes; |
| 168 | let positions: Vec<_> = meshes.iter().flat_map(|m| m.positions.clone()).collect(); |
| 169 | let uvs: Vec<_> = meshes.iter().flat_map(|m| m.uvs.clone()).collect(); |
| 170 | assert_eq!(body.positions, positions); |
| 171 | for (a, b) in body.uvs.iter().zip(&uvs) { |
| 172 | assert_eq!(a[0], b[0]); |
| 173 | assert!((a[1] - (1. - b[1])).abs() < 1e-6); |
| 174 | } |
| 175 | // The skinned FBX body still goes through fbx_scene with its skeleton. |
| 176 | let skinned = |
| 177 | preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap(); |
| 178 | assert_eq!(skinned.rig, BodyRig::Skinned); |
| 179 | assert!(!skinned.joint_names.is_empty()); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() { |
| 184 | let body = obj(); |
| 185 | let framing = CameraFraming::default(); |
| 186 | let resolved = camera::resolve(&body, framing); |
| 187 | assert_eq!(resolved.framing.pivot_y_milli, Some(framing.center_y_milli)); |
| 188 | match &resolved.source { |
| 189 | camera::PivotSource::Fallback { reason } => assert!(reason.contains("Statisch")), |
| 190 | other => panic!("expected fallback, got {other:?}"), |
| 191 | } |
| 192 | let manual = camera::resolve( |
| 193 | &body, |
| 194 | CameraFraming { |
| 195 | pivot_y_milli: Some(1520), |
| 196 | ..framing |
| 197 | }, |
| 198 | ); |
| 199 | assert_eq!(manual.framing.pivot_y_milli, Some(1520)); |
| 200 | assert_eq!(manual.source, camera::PivotSource::Manual); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() { |
| 205 | let plan = production::plan(&obj(), 12).unwrap(); |
| 206 | assert_eq!(plan.clips.len(), 1); |
| 207 | assert_eq!(plan.clips[0].token, "clip000-bind"); |
| 208 | assert_eq!(plan.clips[0].frames, 1); |
| 209 | assert_eq!(plan.images, 60); |
| 210 | assert_eq!(plan.files, 240); |
| 211 | let paths = production::planned_paths(&plan, "male", "body").unwrap(); |
| 212 | assert_eq!(paths.len(), 240); |
| 213 | for map in ["diffuse", "normal", "depth", "specular"] { |
| 214 | assert_eq!( |
| 215 | paths |
| 216 | .iter() |
| 217 | .filter(|p| p.ends_with(&format!("_{map}.png"))) |
| 218 | .count(), |
| 219 | 60 |
| 220 | ); |
| 221 | } |
| 222 | let contract = production::contract(job(obj(), None)).unwrap(); |
| 223 | assert_eq!(contract.plan, plan_with_fps(4)); |
| 224 | assert_eq!(contract.render_doc["pivot_source"]["kind"], "fallback"); |
| 225 | assert_eq!(contract.render_doc["clips"][0]["token"], "clip000-bind"); |
| 226 | } |
| 227 | fn plan_with_fps(fps: u32) -> production::Plan { |
| 228 | production::plan(&obj(), fps).unwrap() |
| 229 | } |
| 230 | |
| 231 | #[test] |
| 232 | fn static_production_scene_draws_stored_geometry_with_albedo() { |
| 233 | let body = obj(); |
| 234 | let job = job(body.clone(), None); |
| 235 | let mut cache = equipment::TextureCache::default(); |
| 236 | let scene = production::scene(&job, "", 0., 0., 0., &mut cache).unwrap(); |
| 237 | assert_eq!(scene.draws.len(), 1); |
| 238 | assert_eq!(*scene.draws[0].positions, body.positions); |
| 239 | assert!(scene.draws[0].albedo.is_some()); |
| 240 | assert_eq!(scene.draws[0].specular, 0.); |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn bone_functions_are_refused_on_a_static_body() { |
| 245 | let body = obj(); |
| 246 | let mut preset = equipment::Preset::new("sword".into()); |
| 247 | equipment::import_obj(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap(); |
| 248 | assert!(preset.bind(0, &body, 0, 0).is_err()); |
| 249 | let err = production::contract(job(body.clone(), Some(preset.clone()))) |
| 250 | .err() |
| 251 | .unwrap(); |
| 252 | assert!(err.contains("statisch"), "{err}"); |
| 253 | // Layers cannot be attached without a bone either. |
| 254 | assert!(equipment::layers(&preset, 0, &body, &Default::default()).is_err()); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn obj_without_mtl_dependency_fails_with_its_name() { |
| 259 | let dir = std::env::temp_dir().join(format!("paperdoll-static-missing-{}", std::process::id())); |
| 260 | std::fs::create_dir_all(&dir).unwrap(); |
| 261 | std::fs::copy( |
| 262 | repo("tests/fixtures/bodies/static/static-body-male.obj"), |
| 263 | dir.join("static-body-male.obj"), |
| 264 | ) |
| 265 | .unwrap(); |
| 266 | let err = preview::load_body_file(&dir.join("static-body-male.obj")).unwrap_err(); |
| 267 | std::fs::remove_dir_all(&dir).ok(); |
| 268 | assert!(err.contains("statisch OBJ-lichaam"), "{err}"); |
| 269 | } |