BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / fbx_scene.rs

fbx_scene.rs

fbx_scene.rs 37.2 KiB Download Raw
1//! In-process normalized drawable/animated FBX scene.
2//!
3//! Loads FBX through vendored ufbx once with unit/axis/winding normalization,
4//! builds a `PreviewCharacter` that samples like the glTF path, and retains
5//! conversion provenance. The live Glow viewport and the production four-map
6//! batch draw FBX bodies through `preview::load_body_file`, which uses this
7//! loader; no further axis/unit conversion is applied downstream.
8
9use std::io::Cursor;
10use std::path::Path;
11use std::sync::Arc;
12
13use crate::equipment::Texture;
14use crate::import::FBX_BACKEND;
15use crate::preview::{
16 AnimChannel, BodySurface, ChannelProperty, Mat4, PreviewCharacter, PreviewClip, SceneNode,
17 MAX_JOINTS,
18};
19
20const MAX_BYTES: u64 = 32 * 1024 * 1024;
21const MAX_VERTICES: usize = 1_000_000;
22const MAX_INDICES: usize = 3_000_000;
23const TARGET_UNIT_METERS: f64 = 1.0;
24
25/// Metadata describing the one-shot conversion applied while loading.
26#[derive(Debug, Clone, PartialEq)]
27pub struct ConversionProvenance {
28 pub source_path: String,
29 pub backend: String,
30 pub target_axes: String,
31 pub target_unit_meters: f64,
32 pub source_unit_meters: f64,
33 pub space_conversion: String,
34 pub geometry_transform_handling: String,
35 pub mirror_axis: String,
36 pub winding_reversed: bool,
37 pub geometry_scale: f64,
38 pub notices: Vec<String>,
39}
40
41/// Material as reported by ufbx after normalization.
42#[derive(Debug, Clone, PartialEq)]
43pub struct FbxMaterial {
44 pub name: String,
45 pub base_color: [f32; 4],
46 pub base_color_texture: Option<String>,
47 pub normal_texture: Option<String>,
48}
49
50/// Texture bytes that ufbx reported (file path relative label or embedded).
51#[derive(Debug, Clone, PartialEq)]
52pub struct FbxTextureRef {
53 pub name: String,
54 pub source: String,
55 pub width: u32,
56 pub height: u32,
57 pub embedded: bool,
58}
59
60/// Error returned by [`load_fbx_drawable`] when the file has drawable meshes
61/// but none of them is skinned; `preview::load_body_file` then loads it as a
62/// static body instead of inventing a skeleton.
63pub const NO_SKINNED_MESH: &str = "FBX bevat geen skinned mesh met driehoeken";
64
65/// Normalized drawable FBX scene ready for the shared sampling API.
66#[derive(Debug, Clone)]
67pub struct FbxDrawableScene {
68 pub character: PreviewCharacter,
69 pub provenance: ConversionProvenance,
70 pub materials: Vec<FbxMaterial>,
71 pub textures: Vec<FbxTextureRef>,
72}
73
74/// Load an FBX file into a normalized drawable/animated scene.
75pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
76 let path_str = path
77 .to_str()
78 .ok_or_else(|| "het FBX-pad is geen UTF-8".to_string())?;
79 let meta = std::fs::metadata(path).map_err(|err| format!("FBX openen mislukt: {err}"))?;
80 if !meta.is_file() {
81 return Err(format!("FBX-bestand ontbreekt: {}", path.display()));
82 }
83 if meta.len() > MAX_BYTES {
84 return Err(format!(
85 "FBX-bestand is groter dan {MAX_BYTES} bytes ({})",
86 meta.len()
87 ));
88 }
89
90 let opts = ufbx::LoadOpts {
91 target_axes: ufbx::axes_right_handed_y_up(),
92 target_unit_meters: TARGET_UNIT_METERS,
93 space_conversion: ufbx::SpaceConversion::ModifyGeometry,
94 geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry,
95 generate_missing_normals: true,
96 clean_skin_weights: true,
97 normalize_normals: true,
98 load_external_files: true,
99 ignore_missing_external_files: false,
100 ..Default::default()
101 };
102 let scene = ufbx::load_file(path_str, opts).map_err(|err| {
103 format!(
104 "FBX laden mislukt ({}): {}",
105 path.display(),
106 err.description
107 )
108 })?;
109
110 let mut notices = Vec::new();
111 notices.push(format!(
112 "FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters."
113 ));
114 notices.push(
115 "Dezelfde genormaliseerde scene voedt de live viewport en de productierender.".into(),
116 );
117
118 if scene.metadata.geometry_ignored {
119 return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into());
120 }
121 if scene.metadata.animation_ignored {
122 notices.push("animatiegegevens waren gemarkeerd als genegeerd door de loader".into());
123 }
124
125 let (mesh_node_index, mesh) = find_skinned_mesh(&scene)?;
126 let _ = mesh_node_index;
127 let skin = mesh
128 .skin_deformers
129 .first()
130 .ok_or_else(|| "skinned mesh heeft geen skin deformer".to_string())?;
131
132 match skin.skinning_method {
133 ufbx::SkinningMethod::Linear | ufbx::SkinningMethod::Rigid => {}
134 other => {
135 return Err(format!(
136 "niet-ondersteunde FBX-skinningmethode {:?}; alleen linear/rigid",
137 other
138 ));
139 }
140 }
141 if skin.clusters.is_empty() {
142 return Err("FBX-skin heeft geen clusters".into());
143 }
144 if skin.clusters.len() > MAX_JOINTS {
145 return Err(format!(
146 "FBX-skeleton heeft {} joints; limiet is {MAX_JOINTS}",
147 skin.clusters.len()
148 ));
149 }
150 if !mesh.blend_deformers.is_empty() {
151 notices.push(format!(
152 "{} blend deformers genegeerd (morph targets niet ondersteund)",
153 mesh.blend_deformers.len()
154 ));
155 }
156
157 let nodes = build_nodes(&scene)?;
158 let (joint_nodes, joint_names, inverse_binds) = build_skin(skin)?;
159
160 let (materials, textures, surfaces_base) = collect_materials(path, &scene, mesh, &mut notices)?;
161
162 let (
163 positions,
164 normals,
165 source_normals_complete,
166 joints,
167 weights,
168 indices,
169 colors,
170 uvs,
171 surfaces,
172 ) = extract_mesh(mesh, skin, &surfaces_base)?;
173
174 if positions.len() > MAX_VERTICES || indices.len() > MAX_INDICES {
175 return Err(format!(
176 "FBX-mesh te groot ({} vertices, {} indices)",
177 positions.len(),
178 indices.len()
179 ));
180 }
181 if positions.iter().any(|p| p.iter().any(|v| !v.is_finite()))
182 || normals.iter().any(|n| n.iter().any(|v| !v.is_finite()))
183 {
184 return Err("FBX-mesh bevat niet-eindige posities of normalen".into());
185 }
186
187 let clips = build_clips(&scene, &nodes)?;
188 if clips.is_empty() {
189 notices.push("geen animatiestacks met transformkanalen gevonden".into());
190 }
191
192 let winding_reversed = mesh.reversed_winding;
193 let provenance = ConversionProvenance {
194 source_path: path.display().to_string(),
195 backend: FBX_BACKEND.to_string(),
196 target_axes: "right-handed Y-up".into(),
197 target_unit_meters: TARGET_UNIT_METERS,
198 source_unit_meters: scene.settings.original_unit_meters as f64,
199 space_conversion: format!("{:?}", scene.metadata.space_conversion),
200 geometry_transform_handling: format!("{:?}", scene.metadata.geometry_transform_handling),
201 mirror_axis: format!("{:?}", scene.metadata.mirror_axis),
202 winding_reversed,
203 geometry_scale: scene.metadata.geometry_scale as f64,
204 notices: notices.clone(),
205 };
206
207 let base_color = surfaces
208 .first()
209 .map(|s| s.color)
210 .unwrap_or([0.7, 0.7, 0.75, 1.0]);
211
212 Ok(FbxDrawableScene {
213 character: PreviewCharacter {
214 nodes,
215 joint_nodes,
216 joint_names,
217 inverse_binds,
218 positions,
219 normals,
220 source_normals_complete,
221 joints,
222 weights,
223 indices,
224 base_color,
225 colors,
226 uvs,
227 surfaces,
228 clips,
229 rig: crate::preview::BodyRig::Skinned,
230 },
231 provenance,
232 materials,
233 textures,
234 })
235}
236
237fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String> {
238 for node in &scene.nodes {
239 if let Some(mesh) = node.mesh.as_ref() {
240 if mesh.num_vertices == 0 || mesh.num_triangles == 0 {
241 continue;
242 }
243 if mesh.skin_deformers.is_empty() {
244 continue;
245 }
246 return Ok((node.element.typed_id as usize, mesh));
247 }
248 }
249 Err(NO_SKINNED_MESH.into())
250}
251
252fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
253 let mut nodes = Vec::with_capacity(scene.nodes.len());
254 for node in &scene.nodes {
255 let parent = if node.is_root {
256 None
257 } else {
258 node.parent
259 .as_ref()
260 .map(|parent| parent.element.typed_id as usize)
261 };
262 if node.has_geometry_transform && node.is_geometry_transform_helper {
263 return Err(format!(
264 "node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd",
265 node.element.name.trim()
266 ));
267 }
268 let t = node.local_transform;
269 let translation = [
270 t.translation.x as f32,
271 t.translation.y as f32,
272 t.translation.z as f32,
273 ];
274 let rotation = [
275 t.rotation.x as f32,
276 t.rotation.y as f32,
277 t.rotation.z as f32,
278 t.rotation.w as f32,
279 ];
280 let scale = [t.scale.x as f32, t.scale.y as f32, t.scale.z as f32];
281 if !translation
282 .iter()
283 .chain(rotation.iter())
284 .chain(scale.iter())
285 .all(|v| v.is_finite())
286 {
287 return Err(format!(
288 "node '{}' heeft niet-eindige lokale transform",
289 node.element.name.trim()
290 ));
291 }
292 nodes.push(SceneNode {
293 name: node.element.name.trim().to_string(),
294 parent,
295 translation,
296 rotation,
297 scale,
298 });
299 }
300 for (index, node) in scene.nodes.iter().enumerate() {
301 if node.element.typed_id as usize != index {
302 return Err(format!(
303 "ufbx typed_id {} komt niet overeen met node-index {index}",
304 node.element.typed_id
305 ));
306 }
307 }
308 Ok(nodes)
309}
310
311type SkinBuild = (Vec<usize>, Vec<String>, Vec<Mat4>);
312type MaterialBuild = (Vec<FbxMaterial>, Vec<FbxTextureRef>, Vec<SurfaceSeed>);
313type MeshBuild = (
314 Vec<[f32; 3]>,
315 Vec<[f32; 3]>,
316 bool,
317 Vec<[u16; 4]>,
318 Vec<[f32; 4]>,
319 Vec<u32>,
320 Vec<[f32; 4]>,
321 Vec<[f32; 2]>,
322 Vec<BodySurface>,
323);
324
325fn build_skin(skin: &ufbx::SkinDeformer) -> Result<SkinBuild, String> {
326 let mut joint_nodes = Vec::with_capacity(skin.clusters.len());
327 let mut joint_names = Vec::with_capacity(skin.clusters.len());
328 let mut inverse_binds = Vec::with_capacity(skin.clusters.len());
329 for cluster in &skin.clusters {
330 let bone = cluster
331 .bone_node
332 .as_ref()
333 .ok_or_else(|| "skin cluster zonder bone_node".to_string())?;
334 let name = bone.element.name.trim();
335 if name.is_empty() {
336 return Err("skin cluster bone heeft geen naam".into());
337 }
338 joint_nodes.push(bone.element.typed_id as usize);
339 joint_names.push(name.to_string());
340 let ib = matrix_to_mat4(&cluster.geometry_to_bone);
341 if !ib.iter().all(|v| v.is_finite()) {
342 return Err(format!("inverse-bind van '{name}' is niet eindig"));
343 }
344 inverse_binds.push(ib);
345 }
346 Ok((joint_nodes, joint_names, inverse_binds))
347}
348
349fn matrix_to_mat4(m: &ufbx::Matrix) -> Mat4 {
350 [
351 m.m00 as f32,
352 m.m10 as f32,
353 m.m20 as f32,
354 0.0,
355 m.m01 as f32,
356 m.m11 as f32,
357 m.m21 as f32,
358 0.0,
359 m.m02 as f32,
360 m.m12 as f32,
361 m.m22 as f32,
362 0.0,
363 m.m03 as f32,
364 m.m13 as f32,
365 m.m23 as f32,
366 1.0,
367 ]
368}
369
370struct SurfaceSeed {
371 color: [f32; 4],
372 albedo: Option<Arc<Texture>>,
373 cutoff: f32,
374}
375
376fn collect_materials(
377 path: &Path,
378 scene: &ufbx::Scene,
379 mesh: &ufbx::Mesh,
380 notices: &mut Vec<String>,
381) -> Result<MaterialBuild, String> {
382 let mut materials = Vec::new();
383 let mut textures = Vec::new();
384 let mut seeds = Vec::new();
385
386 let material_list: Vec<&ufbx::Material> = if mesh.materials.is_empty() {
387 (&scene.materials).into_iter().collect()
388 } else {
389 (&mesh.materials).into_iter().collect()
390 };
391
392 if material_list.is_empty() {
393 seeds.push(SurfaceSeed {
394 color: [0.7, 0.7, 0.75, 1.0],
395 albedo: None,
396 cutoff: 0.001,
397 });
398 return Ok((materials, textures, seeds));
399 }
400
401 for material in material_list {
402 let color_map = if material.pbr.base_color.has_value {
403 &material.pbr.base_color
404 } else {
405 &material.fbx.diffuse_color
406 };
407 let base_color = [
408 color_map.value_vec4.x as f32,
409 color_map.value_vec4.y as f32,
410 color_map.value_vec4.z as f32,
411 if color_map.value_vec4.w == 0.0 {
412 1.0
413 } else {
414 color_map.value_vec4.w as f32
415 },
416 ];
417 if !base_color.iter().all(|c| c.is_finite()) {
418 return Err(format!(
419 "materiaal '{}' heeft niet-eindige basiskleur",
420 material.element.name.trim()
421 ));
422 }
423
424 let (base_tex_name, base_albedo) =
425 resolve_texture(path, color_map.texture.as_ref(), &mut textures, notices)?;
426 let (normal_tex_name, _) = resolve_texture(
427 path,
428 material
429 .fbx
430 .normal_map
431 .texture
432 .as_ref()
433 .or(material.pbr.normal_map.texture.as_ref()),
434 &mut textures,
435 notices,
436 )?;
437
438 materials.push(FbxMaterial {
439 name: {
440 let name = material.element.name.trim();
441 if name.is_empty() {
442 "materiaal".into()
443 } else {
444 name.to_string()
445 }
446 },
447 base_color,
448 base_color_texture: base_tex_name.clone(),
449 normal_texture: normal_tex_name,
450 });
451 seeds.push(SurfaceSeed {
452 color: base_color,
453 albedo: base_albedo,
454 cutoff: 0.001,
455 });
456 }
457 Ok((materials, textures, seeds))
458}
459
460fn resolve_texture(
461 fbx_path: &Path,
462 texture: Option<&ufbx::Ref<ufbx::Texture>>,
463 textures: &mut Vec<FbxTextureRef>,
464 notices: &mut Vec<String>,
465) -> Result<(Option<String>, Option<Arc<Texture>>), String> {
466 let Some(texture) = texture else {
467 return Ok((None, None));
468 };
469 if texture.content.size > 0 {
470 let name = {
471 let n = texture.element.name.trim();
472 if n.is_empty() {
473 format!("ingebed:{}", textures.len())
474 } else {
475 format!("ingebed:{n}")
476 }
477 };
478 let decoded = decode_png_bytes(&texture.content)
479 .map_err(|err| format!("ingebedde texture '{name}' kon niet gelezen worden: {err}"))?;
480 if decoded.width > 2048 || decoded.height > 2048 {
481 return Err(format!("texture '{name}' is groter dan 2048×2048"));
482 }
483 let width = decoded.width;
484 let height = decoded.height;
485 let arc = Arc::new(decoded);
486 textures.push(FbxTextureRef {
487 name: name.clone(),
488 source: name.clone(),
489 width,
490 height,
491 embedded: true,
492 });
493 return Ok((Some(name), Some(arc)));
494 }
495
496 let relative = texture.relative_filename.trim().replace('\\', "/");
497 let filename = if relative.is_empty() {
498 texture.filename.trim().replace('\\', "/")
499 } else {
500 relative
501 };
502 if filename.is_empty() {
503 return Ok((None, None));
504 }
505 reject_escape(&filename)?;
506 let base = fbx_path.parent().unwrap_or(fbx_path);
507 let full = base.join(&filename);
508 if full.is_file() {
509 let bytes = std::fs::read(&full)
510 .map_err(|err| format!("texture '{filename}' lezen mislukt: {err}"))?;
511 let decoded = decode_png_bytes(&bytes)
512 .map_err(|err| format!("texture '{filename}' is geen geldige 8-bit PNG: {err}"))?;
513 if decoded.width > 2048 || decoded.height > 2048 {
514 return Err(format!("texture '{filename}' is groter dan 2048×2048"));
515 }
516 let width = decoded.width;
517 let height = decoded.height;
518 let arc = Arc::new(decoded);
519 textures.push(FbxTextureRef {
520 name: filename.clone(),
521 source: filename.clone(),
522 width,
523 height,
524 embedded: false,
525 });
526 Ok((Some(filename), Some(arc)))
527 } else if texture.has_file {
528 Err(format!("FBX-texture ontbreekt: {filename}"))
529 } else {
530 notices.push(format!(
531 "texture-referentie '{filename}' zonder bestand genegeerd"
532 ));
533 Ok((Some(filename), None))
534 }
535}
536
537fn reject_escape(relative: &str) -> Result<(), String> {
538 if relative.starts_with('/')
539 || relative.starts_with('\\')
540 || Path::new(relative).components().any(|c| {
541 matches!(
542 c,
543 std::path::Component::ParentDir | std::path::Component::RootDir
544 )
545 })
546 {
547 return Err(format!("onveilig texturepad geweigerd: {relative}"));
548 }
549 Ok(())
550}
551
552fn decode_png_bytes(bytes: &[u8]) -> Result<Texture, String> {
553 let mut decoder = png::Decoder::new(Cursor::new(bytes));
554 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
555 let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
556 let info = reader.info();
557 if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
558 return Err("textureafmeting ongeldig of >2048".into());
559 }
560 let size = reader
561 .output_buffer_size()
562 .ok_or_else(|| "texturebuffer te groot".to_string())?;
563 let mut buffer = vec![0u8; size];
564 let frame = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
565 let mut rgba = Vec::with_capacity((frame.width * frame.height * 4) as usize);
566 match frame.color_type {
567 png::ColorType::Grayscale => {
568 for &p in &buffer[..frame.buffer_size()] {
569 rgba.extend_from_slice(&[p, p, p, 255]);
570 }
571 }
572 png::ColorType::GrayscaleAlpha => {
573 for chunk in buffer[..frame.buffer_size()].chunks_exact(2) {
574 rgba.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
575 }
576 }
577 png::ColorType::Rgb => {
578 for chunk in buffer[..frame.buffer_size()].chunks_exact(3) {
579 rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
580 }
581 }
582 png::ColorType::Rgba => rgba.extend_from_slice(&buffer[..frame.buffer_size()]),
583 other => return Err(format!("niet-ondersteund PNG-kleurtype {other:?}")),
584 }
585 use std::hash::{Hash, Hasher};
586 let mut hasher = std::collections::hash_map::DefaultHasher::new();
587 rgba.hash(&mut hasher);
588 Ok(Texture {
589 content_hash: hasher.finish(),
590 width: frame.width,
591 height: frame.height,
592 rgba,
593 })
594}
595
596fn extract_mesh(
597 mesh: &ufbx::Mesh,
598 skin: &ufbx::SkinDeformer,
599 seeds: &[SurfaceSeed],
600) -> Result<MeshBuild, String> {
601 let source_normals_complete = mesh.vertex_normal.exists && !mesh.generated_normals;
602 let mut positions = Vec::new();
603 let mut normals = Vec::new();
604 let mut joints = Vec::new();
605 let mut weights = Vec::new();
606 let mut indices = Vec::new();
607 let mut colors = Vec::new();
608 let mut uvs = Vec::new();
609 let mut surfaces = Vec::new();
610
611 let part_count = mesh.material_parts.len().max(1);
612 for part_index in 0..part_count {
613 let face_indices: Vec<u32> = if mesh.material_parts.is_empty() {
614 (0..mesh.faces.len() as u32).collect()
615 } else {
616 mesh.material_parts[part_index]
617 .face_indices
618 .iter()
619 .copied()
620 .collect()
621 };
622 if face_indices.is_empty() {
623 continue;
624 }
625 let seed = seeds
626 .get(part_index.min(seeds.len().saturating_sub(1)))
627 .ok_or_else(|| "materiaalpart zonder seed".to_string())?;
628 let first = indices.len();
629 let mut tri_buf = Vec::new();
630 for face_index in face_indices {
631 let face = mesh.faces[face_index as usize];
632 if face.num_indices < 3 {
633 continue;
634 }
635 let tri_count = ufbx::triangulate_face_vec(&mut tri_buf, mesh, face);
636 for t in 0..tri_count as usize {
637 let corner_indices = [tri_buf[t * 3], tri_buf[t * 3 + 1], tri_buf[t * 3 + 2]];
638 for &corner in &corner_indices {
639 let corner = corner as usize;
640 let pos = &mesh.vertex_position[corner];
641 let position = [pos.x as f32, pos.y as f32, pos.z as f32];
642 let normal = if mesh.vertex_normal.exists {
643 let n = &mesh.vertex_normal[corner];
644 [n.x as f32, n.y as f32, n.z as f32]
645 } else {
646 [0.0, 1.0, 0.0]
647 };
648 // `PreviewCharacter` UVs use the glTF convention (origin top-left,
649 // as sampled by the production body path); FBX stores V upwards.
650 let uv = if mesh.vertex_uv.exists {
651 let u = &mesh.vertex_uv[corner];
652 preview_uv(u.x, u.y)
653 } else {
654 [0.0, 0.0]
655 };
656 let color = if mesh.vertex_color.exists {
657 let c = &mesh.vertex_color[corner];
658 [c.x as f32, c.y as f32, c.z as f32, c.w as f32]
659 } else {
660 [1.0, 1.0, 1.0, 1.0]
661 };
662 let vertex_ix = mesh.vertex_indices[corner] as usize;
663 let (j, w) = skin_influences(skin, vertex_ix)?;
664 let out_index = positions.len() as u32;
665 positions.push(position);
666 normals.push(normal);
667 uvs.push(uv);
668 colors.push(color);
669 joints.push(j);
670 weights.push(w);
671 indices.push(out_index);
672 }
673 }
674 }
675 let count = indices.len() - first;
676 if count > 0 {
677 surfaces.push(BodySurface {
678 first,
679 count,
680 color: seed.color,
681 albedo: seed.albedo.clone(),
682 cutoff: seed.cutoff,
683 flat_normals: !mesh.vertex_normal.exists,
684 specular: 0.,
685 shiny: None,
686 blend: false,
687 });
688 }
689 }
690
691 if positions.is_empty() || indices.len() < 3 {
692 return Err("FBX-mesh heeft geen tekenbare driehoeken na triangulatie".into());
693 }
694 Ok((
695 positions,
696 normals,
697 source_normals_complete,
698 joints,
699 weights,
700 indices,
701 colors,
702 uvs,
703 surfaces,
704 ))
705}
706
707/// FBX UV (origin bottom-left) to the glTF-style `PreviewCharacter` UV
708/// convention (origin top-left) used by the production body path.
709pub(crate) fn preview_uv(u: f64, v: f64) -> [f32; 2] {
710 [u as f32, (1.0 - v) as f32]
711}
712
713fn skin_influences(
714 skin: &ufbx::SkinDeformer,
715 vertex: usize,
716) -> Result<([u16; 4], [f32; 4]), String> {
717 if vertex >= skin.vertices.len() {
718 return Err(format!("skin vertex-index {vertex} buiten bereik"));
719 }
720 let sv = &skin.vertices[vertex];
721 let mut pairs: Vec<(u16, f32)> = Vec::new();
722 for i in 0..sv.num_weights as usize {
723 let w = &skin.weights[sv.weight_begin as usize + i];
724 if w.cluster_index as usize >= skin.clusters.len() {
725 return Err(format!(
726 "skin weight verwijst naar onbekende cluster {}",
727 w.cluster_index
728 ));
729 }
730 let weight = w.weight as f32;
731 if !weight.is_finite() || weight < 0.0 {
732 return Err("skin weight is niet eindig of negatief".into());
733 }
734 if weight > 0.0 {
735 pairs.push((w.cluster_index as u16, weight));
736 }
737 }
738 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
739 pairs.truncate(4);
740 if pairs.is_empty() {
741 pairs.push((0, 1.0));
742 }
743 let sum: f32 = pairs.iter().map(|(_, w)| *w).sum();
744 if sum <= 1e-8 {
745 return Err("skin weights sommeren tot nul".into());
746 }
747 let mut joints = [0u16; 4];
748 let mut weights = [0.0f32; 4];
749 for (i, (j, w)) in pairs.into_iter().enumerate() {
750 joints[i] = j;
751 weights[i] = w / sum;
752 }
753 Ok((joints, weights))
754}
755
756fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewClip>, String> {
757 let mut clips = Vec::new();
758 for stack in &scene.anim_stacks {
759 let bake = ufbx::bake_anim(
760 scene,
761 &stack.anim,
762 ufbx::BakeOpts {
763 resample_rate: 30.0,
764 minimum_sample_rate: 1.0,
765 maximum_sample_rate: 120.0,
766 trim_start_time: true,
767 key_reduction_enabled: true,
768 key_reduction_rotation: true,
769 key_reduction_threshold: 1e-4,
770 ..Default::default()
771 },
772 )
773 .map_err(|err| {
774 format!(
775 "animatiestack '{}' bakken mislukt: {}",
776 stack.element.name.trim(),
777 err.description
778 )
779 })?;
780
781 let mut channels = Vec::new();
782 for baked in &bake.nodes {
783 let node = baked.typed_id as usize;
784 if node >= nodes.len() {
785 return Err(format!(
786 "gebakken node typed_id {node} buiten bereik ({} nodes)",
787 nodes.len()
788 ));
789 }
790 push_vec3_channel(
791 &mut channels,
792 node,
793 ChannelProperty::Translation,
794 &baked.translation_keys,
795 )?;
796 push_quat_channel(&mut channels, node, &baked.rotation_keys)?;
797 push_vec3_channel(
798 &mut channels,
799 node,
800 ChannelProperty::Scale,
801 &baked.scale_keys,
802 )?;
803 }
804 if channels.is_empty() {
805 continue;
806 }
807 let duration = if bake.playback_duration > 0.0 {
808 bake.playback_duration as f32
809 } else {
810 (stack.time_end - stack.time_begin).max(0.0) as f32
811 };
812 let name = {
813 let raw = stack.element.name.trim();
814 if raw.is_empty() {
815 "clip".to_string()
816 } else {
817 raw.to_string()
818 }
819 };
820 clips.push(PreviewClip {
821 name,
822 duration,
823 channels,
824 skipped: Vec::new(),
825 });
826 }
827 Ok(clips)
828}
829
830fn push_vec3_channel(
831 channels: &mut Vec<AnimChannel>,
832 node: usize,
833 property: ChannelProperty,
834 keys: &[ufbx::BakedVec3],
835) -> Result<(), String> {
836 if keys.is_empty() {
837 return Ok(());
838 }
839 let mut times = Vec::with_capacity(keys.len());
840 let mut values = Vec::with_capacity(keys.len() * 3);
841 for key in keys {
842 let t = key.time as f32;
843 let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32];
844 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
845 return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into());
846 }
847 times.push(t);
848 values.extend_from_slice(&v);
849 }
850 channels.push(AnimChannel {
851 node,
852 property,
853 times,
854 values,
855 interpolation: crate::preview::Interpolation::Linear,
856 });
857 Ok(())
858}
859
860fn push_quat_channel(
861 channels: &mut Vec<AnimChannel>,
862 node: usize,
863 keys: &[ufbx::BakedQuat],
864) -> Result<(), String> {
865 if keys.is_empty() {
866 return Ok(());
867 }
868 let mut times = Vec::with_capacity(keys.len());
869 let mut values = Vec::with_capacity(keys.len() * 4);
870 for key in keys {
871 let t = key.time as f32;
872 let v = [
873 key.value.x as f32,
874 key.value.y as f32,
875 key.value.z as f32,
876 key.value.w as f32,
877 ];
878 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
879 return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into());
880 }
881 times.push(t);
882 values.extend_from_slice(&v);
883 }
884 channels.push(AnimChannel {
885 node,
886 property: ChannelProperty::Rotation,
887 times,
888 values,
889 interpolation: crate::preview::Interpolation::Linear,
890 });
891 Ok(())
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use crate::preview::{
898 animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world,
899 sample_skin_matrices,
900 };
901
902 fn fixture_fbx() -> std::path::PathBuf {
903 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx")
904 }
905
906 fn fixture_glb() -> std::path::PathBuf {
907 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
908 }
909
910 fn translation_of(m: Mat4) -> [f32; 3] {
911 [m[12], m[13], m[14]]
912 }
913
914 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
915 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
916 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
917 }
918
919 fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 {
920 let Some(parent_node) = character.nodes[node].parent else {
921 return 0.0;
922 };
923 let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node)
924 else {
925 return 0.0;
926 };
927 let child = rest_joint_world(character, joint).unwrap();
928 let parent = rest_joint_world(character, parent_joint).unwrap();
929 distance(translation_of(child), translation_of(parent))
930 }
931
932 #[test]
933 fn fbx_drawable_loads_normalized_skinned_scene() {
934 let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx");
935 assert_eq!(scene.provenance.target_axes, "right-handed Y-up");
936 assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9);
937 assert!(scene.provenance.space_conversion.contains("ModifyGeometry"));
938 assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx")));
939 assert_eq!(scene.character.joint_names.len(), 19);
940 assert!(scene.character.joint_names.iter().any(|n| n == "Hips"));
941 assert!(scene
942 .character
943 .joint_names
944 .iter()
945 .any(|n| n == "LeftLowerLeg"));
946 assert!(scene.character.positions.len() >= 19 * 8);
947 assert!(scene.character.indices.len() >= 3);
948 assert!(scene.character.clips.len() >= 2);
949 assert!(scene
950 .character
951 .clips
952 .iter()
953 .any(|c| c.name.to_lowercase().contains("walk")));
954 assert!(scene
955 .character
956 .clips
957 .iter()
958 .any(|c| c.name.to_lowercase().contains("attack")));
959 assert!(!scene.materials.is_empty());
960 let matrices = sample_skin_matrices(&scene.character, "walk", 0.2);
961 assert_eq!(matrices.len(), 19);
962 assert!(matrices.iter().flatten().all(|v| v.is_finite()));
963 }
964
965 #[test]
966 fn fbx_bind_pose_matches_glb_fixture() {
967 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
968 let glb = load_gltf_file(&fixture_glb()).unwrap();
969 assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len());
970 for name in &glb.joint_names {
971 assert!(
972 fbx.character.joint_names.iter().any(|n| n == name),
973 "FBX mist joint {name}"
974 );
975 }
976
977 // Hips rest translation matches (shared rig origin); Blender bone-axis
978 // rotations make intermediate rest XYZ diverge while animation matches.
979 let f_hips = fbx
980 .character
981 .joint_names
982 .iter()
983 .position(|n| n == "Hips")
984 .unwrap();
985 let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap();
986 let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap();
987 let g_rest = rest_joint_world(&glb, g_hips).unwrap();
988 assert!(
989 distance(translation_of(f_rest), translation_of(g_rest)) < 0.02,
990 "Hips bind {:?} vs {:?}",
991 translation_of(f_rest),
992 translation_of(g_rest)
993 );
994
995 for name in &glb.joint_names {
996 let fi = fbx
997 .character
998 .joint_names
999 .iter()
1000 .position(|n| n == name)
1001 .unwrap();
1002 let gi = glb.joint_names.iter().position(|n| n == name).unwrap();
1003 let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]);
1004 let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]);
1005 if f_len > 1e-4 && g_len > 1e-4 {
1006 assert!(
1007 (f_len - g_len).abs() < 0.02,
1008 "bone length {name}: fbx={f_len} glb={g_len}"
1009 );
1010 }
1011 }
1012
1013 // Bind-equivalent pose is reached inside the walk clip (fixture mid sample).
1014 let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1015 let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1016 assert!(
1017 distance(f_leg, g_leg) < 0.02,
1018 "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}"
1019 );
1020 let g_bind = translation_of(
1021 rest_joint_world(
1022 &glb,
1023 glb.joint_names
1024 .iter()
1025 .position(|n| n == "LeftLowerLeg")
1026 .unwrap(),
1027 )
1028 .unwrap(),
1029 );
1030 assert!(
1031 distance(f_leg, g_bind) < 0.02,
1032 "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn fbx_animation_samples_match_glb_fixture() {
1038 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
1039 let glb = load_gltf_file(&fixture_glb()).unwrap();
1040
1041 let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap();
1042 let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1043 assert!(
1044 distance(leg_a, leg_b) > 0.05,
1045 "FBX walk must move LeftLowerLeg, delta {}",
1046 distance(leg_a, leg_b)
1047 );
1048
1049 let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap();
1050 let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1051 assert!(
1052 distance(leg_a, g_a) < 0.05,
1053 "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}"
1054 );
1055 assert!(
1056 distance(leg_b, g_b) < 0.08,
1057 "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}"
1058 );
1059
1060 let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap();
1061 let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap();
1062 assert!(distance(hand_a, hand_b) > 0.05);
1063 let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap();
1064 let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap();
1065 assert!(distance(hand_a, gh_a) < 0.05);
1066 assert!(
1067 distance(hand_b, gh_b) < 0.1,
1068 "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}"
1069 );
1070
1071 // Skin matrices stay finite and joint count matches.
1072 let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5);
1073 let g_skin = sample_skin_matrices(&glb, "walk", 0.5);
1074 assert_eq!(f_skin.len(), g_skin.len());
1075 for (name, f_m, g_m) in glb
1076 .joint_names
1077 .iter()
1078 .zip(f_skin.iter())
1079 .zip(g_skin.iter())
1080 .map(|((n, f), g)| (n, f, g))
1081 {
1082 let fi = fbx
1083 .character
1084 .joint_names
1085 .iter()
1086 .position(|n| n == name)
1087 .unwrap();
1088 let _ = fi;
1089 let d = distance(translation_of(*f_m), translation_of(*g_m));
1090 assert!(d < 0.15, "skin matrix translation {name} delta {d}");
1091 }
1092
1093 let fi = fbx
1094 .character
1095 .joint_names
1096 .iter()
1097 .position(|n| n == "Hips")
1098 .unwrap();
1099 assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1100 }
1101
1102 #[test]
1103 fn fbx_uv_converts_to_preview_top_left_convention() {
1104 assert_eq!(preview_uv(0.25, 0.0), [0.25, 1.0]);
1105 assert_eq!(preview_uv(0.5, 1.0), [0.5, 0.0]);
1106 assert_eq!(preview_uv(1.0, 0.75), [1.0, 0.25]);
1107 }
1108
1109 #[test]
1110 fn garbage_fbx_fails_explicitly() {
1111 let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
1112 let _ = std::fs::remove_dir_all(&root);
1113 std::fs::create_dir_all(&root).unwrap();
1114 let path = root.join("bogus.fbx");
1115 std::fs::write(&path, b"not really fbx").unwrap();
1116 let err = load_fbx_drawable(&path).unwrap_err();
1117 assert!(
1118 err.contains("mislukt") || err.to_lowercase().contains("fbx"),
1119 "{err}"
1120 );
1121 let _ = std::fs::remove_dir_all(&root);
1122 }
1123}