BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / static_body.rs

static_body.rs

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