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 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]
76fn 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]
94fn 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]
128fn 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]
158fn 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]
183fn static_body_pivot_is_geometry_centre_and_manual_pivot_still_wins() {
184 let body = obj();
185 let framing = CameraFraming::default();
186 let resolved = camera::resolve(&body, framing);
187 // Static geometry has a centre too (Tim t1008u): no fallback needed.
188 match resolved.source {
189 camera::PivotSource::BoundsCenter {
190 min_y_milli,
191 max_y_milli,
192 } => assert_eq!(
193 resolved.framing.pivot_y_milli,
194 Some((min_y_milli + max_y_milli) / 2)
195 ),
196 ref other => panic!("expected bounds centre, got {other:?}"),
197 }
198 let manual = camera::resolve(
199 &body,
200 CameraFraming {
201 pivot_y_milli: Some(1520),
202 ..framing
203 },
204 );
205 assert_eq!(manual.framing.pivot_y_milli, Some(1520));
206 assert_eq!(manual.source, camera::PivotSource::Manual);
207}
208
209#[test]
210fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() {
211 let plan = production::plan(&obj(), 12).unwrap();
212 assert_eq!(plan.clips.len(), 1);
213 assert_eq!(plan.clips[0].token, "clip000-bind");
214 assert_eq!(plan.clips[0].frames, 1);
215 assert_eq!(plan.images, 60);
216 assert_eq!(plan.files, 240);
217 let paths = production::planned_paths(&plan, "male", "body").unwrap();
218 assert_eq!(paths.len(), 240);
219 for map in ["diffuse", "normal", "depth", "specular"] {
220 assert_eq!(
221 paths
222 .iter()
223 .filter(|p| p.ends_with(&format!("_{map}.png")))
224 .count(),
225 60
226 );
227 }
228 let contract = production::contract(job(obj(), None)).unwrap();
229 assert_eq!(contract.plan, plan_with_fps(4));
230 assert_eq!(contract.render_doc["pivot_source"]["kind"], "bounds_center");
231 assert_eq!(contract.render_doc["clips"][0]["token"], "clip000-bind");
232}
233fn plan_with_fps(fps: u32) -> production::Plan {
234 production::plan(&obj(), fps).unwrap()
235}
236
237#[test]
238fn static_production_scene_draws_stored_geometry_with_albedo() {
239 let body = obj();
240 let job = job(body.clone(), None);
241 let mut cache = equipment::TextureCache::default();
242 let scene = production::scene(&job, "", 0., 0., 0., &mut cache).unwrap();
243 assert_eq!(scene.draws.len(), 1);
244 assert_eq!(*scene.draws[0].positions, body.positions);
245 assert!(scene.draws[0].albedo.is_some());
246 assert_eq!(scene.draws[0].specular, 0.);
247}
248
249#[test]
250fn bone_functions_are_refused_on_a_static_body() {
251 let body = obj();
252 let mut preset = equipment::Preset::new("sword".into());
253 equipment::import_obj(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
254 assert!(preset.bind(0, &body, 0, 0).is_err());
255 let err = production::contract(job(body.clone(), Some(preset.clone())))
256 .err()
257 .unwrap();
258 assert!(err.contains("statisch"), "{err}");
259 // Layers cannot be attached without a bone either.
260 assert!(equipment::layers(&preset, 0, &body, &Default::default()).is_err());
261}
262
263#[test]
264fn obj_without_mtl_dependency_fails_with_its_name() {
265 let dir = std::env::temp_dir().join(format!("paperdoll-static-missing-{}", std::process::id()));
266 std::fs::create_dir_all(&dir).unwrap();
267 std::fs::copy(
268 repo("tests/fixtures/bodies/static/static-body-male.obj"),
269 dir.join("static-body-male.obj"),
270 )
271 .unwrap();
272 let err = preview::load_body_file(&dir.join("static-body-male.obj")).unwrap_err();
273 std::fs::remove_dir_all(&dir).ok();
274 assert!(err.contains("statisch OBJ-lichaam"), "{err}");
275}