BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / fbx_integration.rs

fbx_integration.rs

fbx_integration.rs 15.4 KiB Download Raw
1//! FBX bodies in the live/production path and rigid FBX equipment import.
2use paperdoll_renderer::equipment::{self, Preset};
3use paperdoll_renderer::preview::{self, Mat4, PreviewCharacter, PreviewControls};
4use paperdoll_renderer::production::{self, RenderJob, SourceFingerprint};
5use paperdoll_renderer::project::CameraFraming;
6use std::path::PathBuf;
7
8fn repo(path: &str) -> PathBuf {
9 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
10}
11fn fbx_body() -> PreviewCharacter {
12 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap()
13}
14fn glb_body() -> PreviewCharacter {
15 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap()
16}
17fn scratch(tag: &str) -> PathBuf {
18 let dir = std::env::temp_dir().join(format!(
19 "paperdoll-fbx-{tag}-{}",
20 std::time::SystemTime::now()
21 .duration_since(std::time::UNIX_EPOCH)
22 .unwrap()
23 .as_nanos()
24 ));
25 std::fs::create_dir_all(&dir).unwrap();
26 dir
27}
28fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
29 RenderJob {
30 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
31 atlas_trim: false,
32 semi_transparency: true,
33 character,
34 sex: "male".into(),
35 preset,
36 target_width: 32,
37 target_height: 48,
38 fps: 4,
39 framing: CameraFraming {
40 height_milli: 2400,
41 center_y_milli: 1000,
42 pivot_y_milli: None,
43 depth_range_milli: None,
44 },
45 output: std::env::temp_dir(),
46 project_id: "fbx-test".into(),
47 project_name: "FBX".into(),
48 body_model: SourceFingerprint {
49 path: "assets/body/test-body-male.fbx".into(),
50 sha256: "0".repeat(64),
51 },
52 preset_sha256: None,
53 }
54}
55fn bounds(scene: &production::Scene) -> ([f32; 3], [f32; 3]) {
56 let mut lo = [f32::MAX; 3];
57 let mut hi = [f32::MIN; 3];
58 for draw in &scene.draws {
59 for &i in &draw.indices {
60 let p = draw.positions[i as usize];
61 for k in 0..3 {
62 lo[k] = lo[k].min(p[k]);
63 hi[k] = hi[k].max(p[k]);
64 }
65 }
66 }
67 (lo, hi)
68}
69fn clip_named(body: &PreviewCharacter, prefix: &str) -> String {
70 body.clips
71 .iter()
72 .find(|c| c.name.contains(prefix))
73 .unwrap_or_else(|| panic!("clip {prefix} ontbreekt"))
74 .name
75 .clone()
76}
77fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
78 [
79 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
80 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
81 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
82 ]
83}
84
85#[test]
86fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
87 assert!(preview::is_drawable_body(std::path::Path::new("a/B.FBX")));
88 assert!(preview::is_drawable_body(std::path::Path::new("b.glb")));
89 // OBJ is drawable as a static body (no skeleton); STL/PLY are not bodies.
90 assert!(preview::is_drawable_body(std::path::Path::new("c.obj")));
91 assert!(!preview::is_drawable_body(std::path::Path::new("d.stl")));
92 let fbx = fbx_body();
93 let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo(
94 "tests/fixtures/bodies/test-body-male.fbx",
95 ))
96 .unwrap();
97 assert_eq!(fbx.positions, direct.character.positions);
98 assert_eq!(fbx.clips.len(), 2);
99 let obj = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap();
100 assert!(obj.is_static() && obj.clips.is_empty());
101 let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.ply")).unwrap_err();
102 assert!(err.contains("geen tekenbaar lichaam"), "{err}");
103 // A file named .glb that actually holds FBX bytes is not silently accepted.
104 let dir = scratch("renamed");
105 let fake = dir.join("body.glb");
106 std::fs::copy(repo("tests/fixtures/bodies/test-body-male.fbx"), &fake).unwrap();
107 assert!(preview::load_body_file(&fake).is_err());
108 let _ = std::fs::remove_dir_all(dir);
109}
110
111#[test]
112fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization() {
113 let fbx = fbx_body();
114 let glb = glb_body();
115 let fbx_plan = production::plan(&fbx, 12).unwrap();
116 let glb_plan = production::plan(&glb, 12).unwrap();
117 assert_eq!(fbx_plan.images, glb_plan.images);
118 assert_eq!(fbx_plan.files, glb_plan.files);
119 let fbx_job = job(fbx, None);
120 let glb_job = job(glb, None);
121 let mut cache = equipment::TextureCache::default();
122 // Only `walk` is compared as a whole silhouette: Blender's FBX exporter
123 // baked the legs of the fixture's `attack` stack in a leftover walk pose
124 // (all 60 curves present in the file), so the attack clip is compared per
125 // animated arm joint below instead.
126 for (prefix, time) in [("walk", 0.0), ("walk", 0.25), ("walk", 0.6)] {
127 let f = production::scene(
128 &fbx_job,
129 &clip_named(&fbx_job.character, prefix),
130 time,
131 30.,
132 90.,
133 &mut cache,
134 )
135 .unwrap();
136 let g = production::scene(&glb_job, prefix, time, 30., 90., &mut cache).unwrap();
137 f.validate().unwrap();
138 let (flo, fhi) = bounds(&f);
139 let (glo, ghi) = bounds(&g);
140 for k in 0..3 {
141 assert!(
142 (flo[k] - glo[k]).abs() < 0.03 && (fhi[k] - ghi[k]).abs() < 0.03,
143 "{prefix}@{time} axis {k}: fbx {flo:?}..{fhi:?} vs glb {glo:?}..{ghi:?}"
144 );
145 }
146 // Body height in metres (no centimetre or axis double-conversion).
147 assert!((fhi[1] - flo[1]) > 1.4 && (fhi[1] - flo[1]) < 2.2);
148 // Same fixed per-body framing: identical orthographic projection and
149 // a rest-pose centre pivot from the FBX close to the GLB one (the
150 // pivot offset is the only projection term that may differ slightly).
151 for (a, b) in f.projection.iter().zip(g.projection) {
152 assert!(
153 (a - b).abs() < 0.01,
154 "{:?} vs {:?}",
155 f.projection,
156 g.projection
157 );
158 }
159 assert_eq!((f.width, f.height), (g.width, g.height));
160 }
161 let attack = clip_named(&fbx_job.character, "attack");
162 for name in [
163 "RightUpperArm",
164 "RightLowerArm",
165 "RightHand",
166 "Chest",
167 "Spine",
168 ] {
169 for time in [0.0, 0.2, 0.4, 0.7] {
170 let f =
171 preview::joint_world_translation(&fbx_job.character, &attack, time, name).unwrap();
172 let g =
173 preview::joint_world_translation(&glb_job.character, "attack", time, name).unwrap();
174 let d = ((f[0] - g[0]).powi(2) + (f[1] - g[1]).powi(2) + (f[2] - g[2]).powi(2)).sqrt();
175 assert!(d < 0.02, "attack {name}@{time}: {f:?} vs {g:?}");
176 }
177 }
178 let fp = paperdoll_renderer::camera::resolve(&fbx_job.character, fbx_job.framing);
179 let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing);
180 assert!(matches!(
181 fp.source,
182 paperdoll_renderer::camera::PivotSource::BoundsCenter { .. }
183 ));
184 let (fy, gy) = (
185 fp.framing.pivot_y_milli.unwrap(),
186 gp.framing.pivot_y_milli.unwrap(),
187 );
188 assert!((fy - gy).abs() <= 30, "centre pivot fbx {fy} vs glb {gy}");
189}
190
191#[test]
192fn rigid_fbx_import_normalizes_axes_units_and_embeds_png() {
193 let mut preset = Preset::new("fbxsword".into());
194 let part = equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx"))
195 .unwrap();
196 let item = &preset.attachments[part];
197 assert_eq!(item.label, "sword-fbx.fbx");
198 assert_eq!(item.meshes.len(), 2);
199 let mut lo = [f32::MAX; 3];
200 let mut hi = [f32::MIN; 3];
201 for p in item.meshes.iter().flat_map(|m| &m.positions) {
202 for k in 0..3 {
203 lo[k] = lo[k].min(p[k]);
204 hi[k] = hi[k].max(p[k]);
205 }
206 }
207 // Exported Z-up from Blender: blade length must end up along +Y in metres.
208 assert!(
209 lo[1].abs() < 1e-3 && (hi[1] - 0.58).abs() < 1e-3,
210 "{lo:?} {hi:?}"
211 );
212 assert!((hi[0] - 0.09).abs() < 1e-3 && (lo[0] + 0.09).abs() < 1e-3);
213 assert!((hi[2] - 0.015).abs() < 1e-3 && (lo[2] + 0.015).abs() < 1e-3);
214 for mesh in &item.meshes {
215 assert_eq!(mesh.positions.len(), mesh.normals.len());
216 assert_eq!(mesh.positions.len(), mesh.uvs.len());
217 for n in &mesh.normals {
218 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
219 assert!((len - 1.).abs() < 1e-3);
220 }
221 }
222 let blade = item.meshes.iter().find(|m| m.albedo.is_some()).unwrap();
223 let guard = item.meshes.iter().find(|m| m.albedo.is_none()).unwrap();
224 // A connected texture replaces the FBX diffuse colour; the guard keeps its colour.
225 assert_eq!(blade.color, [1., 1., 1., 1.]);
226 for (got, want) in guard.color.iter().zip([0.75, 0.55, 0.2, 1.]) {
227 assert!((got - want).abs() < 0.01, "{:?}", guard.color);
228 }
229 // UVs keep the OBJ/FBX convention (V up): on the broad blade faces the
230 // tip has V=1 and the base V=0 (the fixture maps each face to the unit square).
231 let broad = |want_y: f32| {
232 blade
233 .positions
234 .iter()
235 .zip(&blade.normals)
236 .zip(&blade.uvs)
237 .filter(|((p, n), _)| n[2].abs() > 0.9 && (p[1] - want_y).abs() < 1e-4)
238 .map(|(_, uv)| uv[1])
239 .collect::<Vec<_>>()
240 };
241 let (tip, base) = (broad(0.58), broad(0.03));
242 assert!(
243 !tip.is_empty() && tip.iter().all(|v| (*v - 1.).abs() < 1e-4),
244 "{tip:?}"
245 );
246 assert!(
247 !base.is_empty() && base.iter().all(|v| v.abs() < 1e-4),
248 "{base:?}"
249 );
250 let albedo = blade.albedo.unwrap();
251 assert!(preset.assets[albedo].path.contains("/embedded/"));
252 let embedded = preset.texture(albedo).unwrap();
253 let mut reference = Preset::new("ref".into());
254 reference.assets.push(equipment::EmbeddedAsset {
255 path: "checker.png".into(),
256 bytes: std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap(),
257 });
258 assert_eq!(embedded.rgba, reference.texture(0).unwrap().rgba);
259 // Original FBX payload is kept, and the binary preset round-trips.
260 assert!(item.source_assets.iter().any(|i| preset.assets[*i].bytes
261 == std::fs::read(repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap()));
262 let decoded = Preset::decode(&preset.encode().unwrap()).unwrap();
263 assert_eq!(decoded, preset);
264}
265
266#[test]
267fn rigid_fbx_external_png_is_embedded_and_missing_texture_fails() {
268 let mut preset = Preset::new("fbxsword".into());
269 let part = equipment::import_model(
270 &mut preset,
271 &repo("tests/fixtures/equipment/sword-fbx-external.fbx"),
272 )
273 .unwrap();
274 let blade = preset.attachments[part]
275 .meshes
276 .iter()
277 .find(|m| m.albedo.is_some())
278 .unwrap();
279 let asset = &preset.assets[blade.albedo.unwrap()];
280 assert!(asset.path.ends_with("/checker.png"), "{}", asset.path);
281 assert_eq!(
282 asset.bytes,
283 std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap()
284 );
285 let dir = scratch("missing-tex");
286 std::fs::copy(
287 repo("tests/fixtures/equipment/sword-fbx-external.fbx"),
288 dir.join("lonely.fbx"),
289 )
290 .unwrap();
291 let mut other = Preset::new("lonely".into());
292 let err = equipment::import_fbx(&mut other, &dir.join("lonely.fbx")).unwrap_err();
293 assert!(err.contains("FBX-texture"), "{err}");
294 assert!(other.attachments.is_empty() && other.assets.is_empty());
295 let _ = std::fs::remove_dir_all(dir);
296}
297
298#[test]
299fn skinned_garbage_or_unsupported_equipment_is_rejected() {
300 let mut preset = Preset::new("bad".into());
301 let err = equipment::import_fbx(
302 &mut preset,
303 &repo("tests/fixtures/bodies/test-body-male.fbx"),
304 )
305 .unwrap_err();
306 assert!(err.contains("geskinned"), "{err}");
307 let dir = scratch("garbage");
308 std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap();
309 assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err());
310 // Tim t1027u: a skinned model is now a skinned part with its skeleton.
311 for skinned in [
312 "tests/fixtures/bodies/test-body-male.glb",
313 "tests/fixtures/bodies/test-body-male.fbx",
314 ] {
315 let mut ok = Preset::new("suit".into());
316 let part = equipment::import_model(&mut ok, &repo(skinned)).unwrap();
317 assert!(ok.attachments[part].skeleton.is_some(), "{skinned}");
318 }
319 assert!(preset.attachments.is_empty() && preset.assets.is_empty());
320 // OBJ still goes through the same dispatcher.
321 equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
322 assert_eq!(preset.attachments.len(), 1);
323 let _ = std::fs::remove_dir_all(dir);
324}
325
326#[test]
327fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() {
328 let body = fbx_body();
329 let joint = body
330 .joint_names
331 .iter()
332 .position(|n| n == "RightHand")
333 .unwrap();
334 let mut preset = Preset::new("fbxsword".into());
335 equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap();
336 preset.bind(0, &body, 0, joint).unwrap();
337 preset.attachments[0].rotation_deg = [0., 0., -15.];
338 let attack = clip_named(&body, "attack");
339 let job = job(body, Some(preset.clone()));
340 let mut cache = equipment::TextureCache::default();
341 let early = production::scene(&job, &attack, 0.0, 0., 0., &mut cache).unwrap();
342 let late = production::scene(&job, &attack, 0.4, 0., 0., &mut cache).unwrap();
343 assert_eq!(early.draws.len(), 2);
344 assert!(early.draws.iter().any(|d| d.albedo.is_some()));
345 assert_eq!(early.view, late.view);
346 assert_ne!(early.draws[0].positions, late.draws[0].positions);
347 // Exact attachment: animated joint world × local transform × source vertex.
348 let world = preview::animated_joint_world(&job.character, &attack, 0.4, joint).unwrap();
349 let model = preview::mul(world, equipment::local_matrix([0.; 3], [0., 0., -15.]));
350 let layers = equipment::layers(
351 &preset,
352 0,
353 &job.character,
354 &PreviewControls {
355 clip_name: attack.clone(),
356 time: 0.4,
357 ..Default::default()
358 },
359 )
360 .unwrap();
361 assert_eq!(layers[0].model, model);
362 let expected = apply(&model, preset.attachments[0].meshes[0].positions[0]);
363 let got = late.draws[0].positions[0];
364 for k in 0..3 {
365 assert!(
366 (expected[k] - got[k]).abs() < 1e-5,
367 "{expected:?} vs {got:?}"
368 );
369 }
370 // The hand-bound sword sits at the FBX hand, not at the origin.
371 let hand = [world[12], world[13], world[14]];
372 assert!(hand[1] > 0.5);
373 let d = ((got[0] - hand[0]).powi(2) + (got[1] - hand[1]).powi(2) + (got[2] - hand[2]).powi(2))
374 .sqrt();
375 assert!(d < 0.7, "sword vertex {got:?} far from hand {hand:?}");
376 // A preset bound to the GLB rig is refused on the FBX rig instead of guessing.
377 let mut glb_bound = Preset::new("glbsword".into());
378 equipment::import_fbx(
379 &mut glb_bound,
380 &repo("tests/fixtures/equipment/sword-fbx.fbx"),
381 )
382 .unwrap();
383 let glb = glb_body();
384 glb_bound
385 .bind(
386 0,
387 &glb,
388 0,
389 glb.joint_names
390 .iter()
391 .position(|n| n == "RightHand")
392 .unwrap(),
393 )
394 .unwrap();
395 let err =
396 equipment::layers(&glb_bound, 0, &job.character, &PreviewControls::default()).unwrap_err();
397 assert!(err.contains("rig wijkt af"), "{err}");
398}