BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / static_body.rs

static_body.rs

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