BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / fbx_scene.rs

fbx_scene.rs

fbx_scene.rs 37.1 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 fixups: Default::default(),
231 },
232 provenance,
233 materials,
234 textures,
235 })
236}
237
238fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String> {
239 for node in &scene.nodes {
240 if let Some(mesh) = node.mesh.as_ref() {
241 if mesh.num_vertices == 0 || mesh.num_triangles == 0 {
242 continue;
243 }
244 if mesh.skin_deformers.is_empty() {
245 continue;
246 }
247 return Ok((node.element.typed_id as usize, mesh));
248 }
249 }
250 Err(NO_SKINNED_MESH.into())
251}
252
253fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
254 let mut nodes = Vec::with_capacity(scene.nodes.len());
255 for node in &scene.nodes {
256 let parent = if node.is_root {
257 None
258 } else {
259 node.parent
260 .as_ref()
261 .map(|parent| parent.element.typed_id as usize)
262 };
263 if node.has_geometry_transform && node.is_geometry_transform_helper {
264 return Err(format!(
265 "node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd",
266 node.element.name.trim()
267 ));
268 }
269 let t = node.local_transform;
270 let translation = [
271 t.translation.x as f32,
272 t.translation.y as f32,
273 t.translation.z as f32,
274 ];
275 let rotation = [
276 t.rotation.x as f32,
277 t.rotation.y as f32,
278 t.rotation.z as f32,
279 t.rotation.w as f32,
280 ];
281 let scale = [t.scale.x as f32, t.scale.y as f32, t.scale.z as f32];
282 if !translation
283 .iter()
284 .chain(rotation.iter())
285 .chain(scale.iter())
286 .all(|v| v.is_finite())
287 {
288 return Err(format!(
289 "node '{}' heeft niet-eindige lokale transform",
290 node.element.name.trim()
291 ));
292 }
293 nodes.push(SceneNode {
294 name: node.element.name.trim().to_string(),
295 parent,
296 translation,
297 rotation,
298 scale,
299 });
300 }
301 for (index, node) in scene.nodes.iter().enumerate() {
302 if node.element.typed_id as usize != index {
303 return Err(format!(
304 "ufbx typed_id {} komt niet overeen met node-index {index}",
305 node.element.typed_id
306 ));
307 }
308 }
309 Ok(nodes)
310}
311
312type SkinBuild = (Vec<usize>, Vec<String>, Vec<Mat4>);
313type MaterialBuild = (Vec<FbxMaterial>, Vec<FbxTextureRef>, Vec<SurfaceSeed>);
314type MeshBuild = (
315 Vec<[f32; 3]>,
316 Vec<[f32; 3]>,
317 bool,
318 Vec<[u16; 4]>,
319 Vec<[f32; 4]>,
320 Vec<u32>,
321 Vec<[f32; 4]>,
322 Vec<[f32; 2]>,
323 Vec<BodySurface>,
324);
325
326fn build_skin(skin: &ufbx::SkinDeformer) -> Result<SkinBuild, String> {
327 let mut joint_nodes = Vec::with_capacity(skin.clusters.len());
328 let mut joint_names = Vec::with_capacity(skin.clusters.len());
329 let mut inverse_binds = Vec::with_capacity(skin.clusters.len());
330 for cluster in &skin.clusters {
331 let bone = cluster
332 .bone_node
333 .as_ref()
334 .ok_or_else(|| "skin cluster zonder bone_node".to_string())?;
335 let name = bone.element.name.trim();
336 if name.is_empty() {
337 return Err("skin cluster bone heeft geen naam".into());
338 }
339 joint_nodes.push(bone.element.typed_id as usize);
340 joint_names.push(name.to_string());
341 let ib = matrix_to_mat4(&cluster.geometry_to_bone);
342 if !ib.iter().all(|v| v.is_finite()) {
343 return Err(format!("inverse-bind van '{name}' is niet eindig"));
344 }
345 inverse_binds.push(ib);
346 }
347 Ok((joint_nodes, joint_names, inverse_binds))
348}
349
350fn matrix_to_mat4(m: &ufbx::Matrix) -> Mat4 {
351 [
352 m.m00 as f32,
353 m.m10 as f32,
354 m.m20 as f32,
355 0.0,
356 m.m01 as f32,
357 m.m11 as f32,
358 m.m21 as f32,
359 0.0,
360 m.m02 as f32,
361 m.m12 as f32,
362 m.m22 as f32,
363 0.0,
364 m.m03 as f32,
365 m.m13 as f32,
366 m.m23 as f32,
367 1.0,
368 ]
369}
370
371struct SurfaceSeed {
372 color: [f32; 4],
373 albedo: Option<Arc<Texture>>,
374 cutoff: f32,
375}
376
377fn collect_materials(
378 path: &Path,
379 scene: &ufbx::Scene,
380 mesh: &ufbx::Mesh,
381 notices: &mut Vec<String>,
382) -> Result<MaterialBuild, String> {
383 let mut materials = Vec::new();
384 let mut textures = Vec::new();
385 let mut seeds = Vec::new();
386
387 let material_list: Vec<&ufbx::Material> = if mesh.materials.is_empty() {
388 (&scene.materials).into_iter().collect()
389 } else {
390 (&mesh.materials).into_iter().collect()
391 };
392
393 if material_list.is_empty() {
394 seeds.push(SurfaceSeed {
395 color: [0.7, 0.7, 0.75, 1.0],
396 albedo: None,
397 cutoff: 0.001,
398 });
399 return Ok((materials, textures, seeds));
400 }
401
402 for material in material_list {
403 let color_map = if material.pbr.base_color.has_value {
404 &material.pbr.base_color
405 } else {
406 &material.fbx.diffuse_color
407 };
408 let base_color = [
409 color_map.value_vec4.x as f32,
410 color_map.value_vec4.y as f32,
411 color_map.value_vec4.z as f32,
412 if color_map.value_vec4.w == 0.0 {
413 1.0
414 } else {
415 color_map.value_vec4.w as f32
416 },
417 ];
418 if !base_color.iter().all(|c| c.is_finite()) {
419 return Err(format!(
420 "materiaal '{}' heeft niet-eindige basiskleur",
421 material.element.name.trim()
422 ));
423 }
424
425 let (base_tex_name, base_albedo) =
426 resolve_texture(path, color_map.texture.as_ref(), &mut textures, notices)?;
427 let (normal_tex_name, _) = resolve_texture(
428 path,
429 material
430 .fbx
431 .normal_map
432 .texture
433 .as_ref()
434 .or(material.pbr.normal_map.texture.as_ref()),
435 &mut textures,
436 notices,
437 )?;
438
439 materials.push(FbxMaterial {
440 name: {
441 let name = material.element.name.trim();
442 if name.is_empty() {
443 "materiaal".into()
444 } else {
445 name.to_string()
446 }
447 },
448 base_color,
449 base_color_texture: base_tex_name.clone(),
450 normal_texture: normal_tex_name,
451 });
452 seeds.push(SurfaceSeed {
453 color: base_color,
454 albedo: base_albedo,
455 cutoff: 0.001,
456 });
457 }
458 Ok((materials, textures, seeds))
459}
460
461fn resolve_texture(
462 fbx_path: &Path,
463 texture: Option<&ufbx::Ref<ufbx::Texture>>,
464 textures: &mut Vec<FbxTextureRef>,
465 notices: &mut Vec<String>,
466) -> Result<(Option<String>, Option<Arc<Texture>>), String> {
467 let Some(texture) = texture else {
468 return Ok((None, None));
469 };
470 if texture.content.size > 0 {
471 let name = {
472 let n = texture.element.name.trim();
473 if n.is_empty() {
474 format!("ingebed:{}", textures.len())
475 } else {
476 format!("ingebed:{n}")
477 }
478 };
479 let decoded = decode_png_bytes(&texture.content)
480 .map_err(|err| format!("ingebedde texture '{name}' kon niet gelezen worden: {err}"))?;
481 if decoded.width > 2048 || decoded.height > 2048 {
482 return Err(format!("texture '{name}' is groter dan 2048×2048"));
483 }
484 let width = decoded.width;
485 let height = decoded.height;
486 let arc = Arc::new(decoded);
487 textures.push(FbxTextureRef {
488 name: name.clone(),
489 source: name.clone(),
490 width,
491 height,
492 embedded: true,
493 });
494 return Ok((Some(name), Some(arc)));
495 }
496
497 let relative = texture.relative_filename.trim().replace('\\', "/");
498 let filename = if relative.is_empty() {
499 texture.filename.trim().replace('\\', "/")
500 } else {
501 relative
502 };
503 if filename.is_empty() {
504 return Ok((None, None));
505 }
506 reject_escape(&filename)?;
507 let base = fbx_path.parent().unwrap_or(fbx_path);
508 let full = base.join(&filename);
509 if full.is_file() {
510 let bytes = std::fs::read(&full)
511 .map_err(|err| format!("texture '{filename}' lezen mislukt: {err}"))?;
512 let decoded = decode_png_bytes(&bytes)
513 .map_err(|err| format!("texture '{filename}' is geen geldige 8-bit PNG: {err}"))?;
514 if decoded.width > 2048 || decoded.height > 2048 {
515 return Err(format!("texture '{filename}' is groter dan 2048×2048"));
516 }
517 let width = decoded.width;
518 let height = decoded.height;
519 let arc = Arc::new(decoded);
520 textures.push(FbxTextureRef {
521 name: filename.clone(),
522 source: filename.clone(),
523 width,
524 height,
525 embedded: false,
526 });
527 Ok((Some(filename), Some(arc)))
528 } else if texture.has_file {
529 Err(format!("FBX-texture ontbreekt: {filename}"))
530 } else {
531 notices.push(format!(
532 "texture-referentie '{filename}' zonder bestand genegeerd"
533 ));
534 Ok((Some(filename), None))
535 }
536}
537
538fn reject_escape(relative: &str) -> Result<(), String> {
539 if relative.starts_with('/')
540 || relative.starts_with('\\')
541 || Path::new(relative).components().any(|c| {
542 matches!(
543 c,
544 std::path::Component::ParentDir | std::path::Component::RootDir
545 )
546 })
547 {
548 return Err(format!("onveilig texturepad geweigerd: {relative}"));
549 }
550 Ok(())
551}
552
553fn decode_png_bytes(bytes: &[u8]) -> Result<Texture, String> {
554 let mut decoder = png::Decoder::new(Cursor::new(bytes));
555 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
556 let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
557 let info = reader.info();
558 if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
559 return Err("textureafmeting ongeldig of >2048".into());
560 }
561 let size = reader
562 .output_buffer_size()
563 .ok_or_else(|| "texturebuffer te groot".to_string())?;
564 let mut buffer = vec![0u8; size];
565 let frame = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
566 let mut rgba = Vec::with_capacity((frame.width * frame.height * 4) as usize);
567 match frame.color_type {
568 png::ColorType::Grayscale => {
569 for &p in &buffer[..frame.buffer_size()] {
570 rgba.extend_from_slice(&[p, p, p, 255]);
571 }
572 }
573 png::ColorType::GrayscaleAlpha => {
574 for chunk in buffer[..frame.buffer_size()].chunks_exact(2) {
575 rgba.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
576 }
577 }
578 png::ColorType::Rgb => {
579 for chunk in buffer[..frame.buffer_size()].chunks_exact(3) {
580 rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
581 }
582 }
583 png::ColorType::Rgba => rgba.extend_from_slice(&buffer[..frame.buffer_size()]),
584 other => return Err(format!("niet-ondersteund PNG-kleurtype {other:?}")),
585 }
586 use std::hash::{Hash, Hasher};
587 let mut hasher = std::collections::hash_map::DefaultHasher::new();
588 rgba.hash(&mut hasher);
589 Ok(Texture {
590 content_hash: hasher.finish(),
591 width: frame.width,
592 height: frame.height,
593 rgba,
594 })
595}
596
597fn extract_mesh(
598 mesh: &ufbx::Mesh,
599 skin: &ufbx::SkinDeformer,
600 seeds: &[SurfaceSeed],
601) -> Result<MeshBuild, String> {
602 let source_normals_complete = mesh.vertex_normal.exists && !mesh.generated_normals;
603 let mut positions = Vec::new();
604 let mut normals = Vec::new();
605 let mut joints = Vec::new();
606 let mut weights = Vec::new();
607 let mut indices = Vec::new();
608 let mut colors = Vec::new();
609 let mut uvs = Vec::new();
610 let mut surfaces = Vec::new();
611
612 let part_count = mesh.material_parts.len().max(1);
613 for part_index in 0..part_count {
614 let face_indices: Vec<u32> = if mesh.material_parts.is_empty() {
615 (0..mesh.faces.len() as u32).collect()
616 } else {
617 mesh.material_parts[part_index]
618 .face_indices
619 .iter()
620 .copied()
621 .collect()
622 };
623 if face_indices.is_empty() {
624 continue;
625 }
626 let seed = seeds
627 .get(part_index.min(seeds.len().saturating_sub(1)))
628 .ok_or_else(|| "materiaalpart zonder seed".to_string())?;
629 let first = indices.len();
630 let mut tri_buf = Vec::new();
631 for face_index in face_indices {
632 let face = mesh.faces[face_index as usize];
633 if face.num_indices < 3 {
634 continue;
635 }
636 let tri_count = ufbx::triangulate_face_vec(&mut tri_buf, mesh, face);
637 for t in 0..tri_count as usize {
638 let corner_indices = [tri_buf[t * 3], tri_buf[t * 3 + 1], tri_buf[t * 3 + 2]];
639 for &corner in &corner_indices {
640 let corner = corner as usize;
641 let pos = &mesh.vertex_position[corner];
642 let position = [pos.x as f32, pos.y as f32, pos.z as f32];
643 let normal = if mesh.vertex_normal.exists {
644 let n = &mesh.vertex_normal[corner];
645 [n.x as f32, n.y as f32, n.z as f32]
646 } else {
647 [0.0, 1.0, 0.0]
648 };
649 // `PreviewCharacter` UVs use the glTF convention (origin top-left,
650 // as sampled by the production body path); FBX stores V upwards.
651 let uv = if mesh.vertex_uv.exists {
652 let u = &mesh.vertex_uv[corner];
653 preview_uv(u.x, u.y)
654 } else {
655 [0.0, 0.0]
656 };
657 let color = if mesh.vertex_color.exists {
658 let c = &mesh.vertex_color[corner];
659 [c.x as f32, c.y as f32, c.z as f32, c.w as f32]
660 } else {
661 [1.0, 1.0, 1.0, 1.0]
662 };
663 let vertex_ix = mesh.vertex_indices[corner] as usize;
664 let (j, w) = skin_influences(skin, vertex_ix)?;
665 let out_index = positions.len() as u32;
666 positions.push(position);
667 normals.push(normal);
668 uvs.push(uv);
669 colors.push(color);
670 joints.push(j);
671 weights.push(w);
672 indices.push(out_index);
673 }
674 }
675 }
676 let count = indices.len() - first;
677 if count > 0 {
678 surfaces.push(BodySurface {
679 first,
680 count,
681 color: seed.color,
682 albedo: seed.albedo.clone(),
683 cutoff: seed.cutoff,
684 flat_normals: !mesh.vertex_normal.exists,
685 });
686 }
687 }
688
689 if positions.is_empty() || indices.len() < 3 {
690 return Err("FBX-mesh heeft geen tekenbare driehoeken na triangulatie".into());
691 }
692 Ok((
693 positions,
694 normals,
695 source_normals_complete,
696 joints,
697 weights,
698 indices,
699 colors,
700 uvs,
701 surfaces,
702 ))
703}
704
705/// FBX UV (origin bottom-left) to the glTF-style `PreviewCharacter` UV
706/// convention (origin top-left) used by the production body path.
707pub(crate) fn preview_uv(u: f64, v: f64) -> [f32; 2] {
708 [u as f32, (1.0 - v) as f32]
709}
710
711fn skin_influences(
712 skin: &ufbx::SkinDeformer,
713 vertex: usize,
714) -> Result<([u16; 4], [f32; 4]), String> {
715 if vertex >= skin.vertices.len() {
716 return Err(format!("skin vertex-index {vertex} buiten bereik"));
717 }
718 let sv = &skin.vertices[vertex];
719 let mut pairs: Vec<(u16, f32)> = Vec::new();
720 for i in 0..sv.num_weights as usize {
721 let w = &skin.weights[sv.weight_begin as usize + i];
722 if w.cluster_index as usize >= skin.clusters.len() {
723 return Err(format!(
724 "skin weight verwijst naar onbekende cluster {}",
725 w.cluster_index
726 ));
727 }
728 let weight = w.weight as f32;
729 if !weight.is_finite() || weight < 0.0 {
730 return Err("skin weight is niet eindig of negatief".into());
731 }
732 if weight > 0.0 {
733 pairs.push((w.cluster_index as u16, weight));
734 }
735 }
736 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
737 pairs.truncate(4);
738 if pairs.is_empty() {
739 pairs.push((0, 1.0));
740 }
741 let sum: f32 = pairs.iter().map(|(_, w)| *w).sum();
742 if sum <= 1e-8 {
743 return Err("skin weights sommeren tot nul".into());
744 }
745 let mut joints = [0u16; 4];
746 let mut weights = [0.0f32; 4];
747 for (i, (j, w)) in pairs.into_iter().enumerate() {
748 joints[i] = j;
749 weights[i] = w / sum;
750 }
751 Ok((joints, weights))
752}
753
754fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewClip>, String> {
755 let mut clips = Vec::new();
756 for stack in &scene.anim_stacks {
757 let bake = ufbx::bake_anim(
758 scene,
759 &stack.anim,
760 ufbx::BakeOpts {
761 resample_rate: 30.0,
762 minimum_sample_rate: 1.0,
763 maximum_sample_rate: 120.0,
764 trim_start_time: true,
765 key_reduction_enabled: true,
766 key_reduction_rotation: true,
767 key_reduction_threshold: 1e-4,
768 ..Default::default()
769 },
770 )
771 .map_err(|err| {
772 format!(
773 "animatiestack '{}' bakken mislukt: {}",
774 stack.element.name.trim(),
775 err.description
776 )
777 })?;
778
779 let mut channels = Vec::new();
780 for baked in &bake.nodes {
781 let node = baked.typed_id as usize;
782 if node >= nodes.len() {
783 return Err(format!(
784 "gebakken node typed_id {node} buiten bereik ({} nodes)",
785 nodes.len()
786 ));
787 }
788 push_vec3_channel(
789 &mut channels,
790 node,
791 ChannelProperty::Translation,
792 &baked.translation_keys,
793 )?;
794 push_quat_channel(&mut channels, node, &baked.rotation_keys)?;
795 push_vec3_channel(
796 &mut channels,
797 node,
798 ChannelProperty::Scale,
799 &baked.scale_keys,
800 )?;
801 }
802 if channels.is_empty() {
803 continue;
804 }
805 let duration = if bake.playback_duration > 0.0 {
806 bake.playback_duration as f32
807 } else {
808 (stack.time_end - stack.time_begin).max(0.0) as f32
809 };
810 let name = {
811 let raw = stack.element.name.trim();
812 if raw.is_empty() {
813 "clip".to_string()
814 } else {
815 raw.to_string()
816 }
817 };
818 clips.push(PreviewClip {
819 name,
820 duration,
821 channels,
822 skipped: Vec::new(),
823 });
824 }
825 Ok(clips)
826}
827
828fn push_vec3_channel(
829 channels: &mut Vec<AnimChannel>,
830 node: usize,
831 property: ChannelProperty,
832 keys: &[ufbx::BakedVec3],
833) -> Result<(), String> {
834 if keys.is_empty() {
835 return Ok(());
836 }
837 let mut times = Vec::with_capacity(keys.len());
838 let mut values = Vec::with_capacity(keys.len() * 3);
839 for key in keys {
840 let t = key.time as f32;
841 let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32];
842 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
843 return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into());
844 }
845 times.push(t);
846 values.extend_from_slice(&v);
847 }
848 channels.push(AnimChannel {
849 node,
850 property,
851 times,
852 values,
853 interpolation: crate::preview::Interpolation::Linear,
854 });
855 Ok(())
856}
857
858fn push_quat_channel(
859 channels: &mut Vec<AnimChannel>,
860 node: usize,
861 keys: &[ufbx::BakedQuat],
862) -> Result<(), String> {
863 if keys.is_empty() {
864 return Ok(());
865 }
866 let mut times = Vec::with_capacity(keys.len());
867 let mut values = Vec::with_capacity(keys.len() * 4);
868 for key in keys {
869 let t = key.time as f32;
870 let v = [
871 key.value.x as f32,
872 key.value.y as f32,
873 key.value.z as f32,
874 key.value.w as f32,
875 ];
876 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
877 return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into());
878 }
879 times.push(t);
880 values.extend_from_slice(&v);
881 }
882 channels.push(AnimChannel {
883 node,
884 property: ChannelProperty::Rotation,
885 times,
886 values,
887 interpolation: crate::preview::Interpolation::Linear,
888 });
889 Ok(())
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use crate::preview::{
896 animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world,
897 sample_skin_matrices,
898 };
899
900 fn fixture_fbx() -> std::path::PathBuf {
901 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx")
902 }
903
904 fn fixture_glb() -> std::path::PathBuf {
905 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
906 }
907
908 fn translation_of(m: Mat4) -> [f32; 3] {
909 [m[12], m[13], m[14]]
910 }
911
912 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
913 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
914 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
915 }
916
917 fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 {
918 let Some(parent_node) = character.nodes[node].parent else {
919 return 0.0;
920 };
921 let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node)
922 else {
923 return 0.0;
924 };
925 let child = rest_joint_world(character, joint).unwrap();
926 let parent = rest_joint_world(character, parent_joint).unwrap();
927 distance(translation_of(child), translation_of(parent))
928 }
929
930 #[test]
931 fn fbx_drawable_loads_normalized_skinned_scene() {
932 let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx");
933 assert_eq!(scene.provenance.target_axes, "right-handed Y-up");
934 assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9);
935 assert!(scene.provenance.space_conversion.contains("ModifyGeometry"));
936 assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx")));
937 assert_eq!(scene.character.joint_names.len(), 19);
938 assert!(scene.character.joint_names.iter().any(|n| n == "Hips"));
939 assert!(scene
940 .character
941 .joint_names
942 .iter()
943 .any(|n| n == "LeftLowerLeg"));
944 assert!(scene.character.positions.len() >= 19 * 8);
945 assert!(scene.character.indices.len() >= 3);
946 assert!(scene.character.clips.len() >= 2);
947 assert!(scene
948 .character
949 .clips
950 .iter()
951 .any(|c| c.name.to_lowercase().contains("walk")));
952 assert!(scene
953 .character
954 .clips
955 .iter()
956 .any(|c| c.name.to_lowercase().contains("attack")));
957 assert!(!scene.materials.is_empty());
958 let matrices = sample_skin_matrices(&scene.character, "walk", 0.2);
959 assert_eq!(matrices.len(), 19);
960 assert!(matrices.iter().flatten().all(|v| v.is_finite()));
961 }
962
963 #[test]
964 fn fbx_bind_pose_matches_glb_fixture() {
965 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
966 let glb = load_gltf_file(&fixture_glb()).unwrap();
967 assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len());
968 for name in &glb.joint_names {
969 assert!(
970 fbx.character.joint_names.iter().any(|n| n == name),
971 "FBX mist joint {name}"
972 );
973 }
974
975 // Hips rest translation matches (shared rig origin); Blender bone-axis
976 // rotations make intermediate rest XYZ diverge while animation matches.
977 let f_hips = fbx
978 .character
979 .joint_names
980 .iter()
981 .position(|n| n == "Hips")
982 .unwrap();
983 let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap();
984 let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap();
985 let g_rest = rest_joint_world(&glb, g_hips).unwrap();
986 assert!(
987 distance(translation_of(f_rest), translation_of(g_rest)) < 0.02,
988 "Hips bind {:?} vs {:?}",
989 translation_of(f_rest),
990 translation_of(g_rest)
991 );
992
993 for name in &glb.joint_names {
994 let fi = fbx
995 .character
996 .joint_names
997 .iter()
998 .position(|n| n == name)
999 .unwrap();
1000 let gi = glb.joint_names.iter().position(|n| n == name).unwrap();
1001 let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]);
1002 let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]);
1003 if f_len > 1e-4 && g_len > 1e-4 {
1004 assert!(
1005 (f_len - g_len).abs() < 0.02,
1006 "bone length {name}: fbx={f_len} glb={g_len}"
1007 );
1008 }
1009 }
1010
1011 // Bind-equivalent pose is reached inside the walk clip (fixture mid sample).
1012 let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1013 let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1014 assert!(
1015 distance(f_leg, g_leg) < 0.02,
1016 "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}"
1017 );
1018 let g_bind = translation_of(
1019 rest_joint_world(
1020 &glb,
1021 glb.joint_names
1022 .iter()
1023 .position(|n| n == "LeftLowerLeg")
1024 .unwrap(),
1025 )
1026 .unwrap(),
1027 );
1028 assert!(
1029 distance(f_leg, g_bind) < 0.02,
1030 "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}"
1031 );
1032 }
1033
1034 #[test]
1035 fn fbx_animation_samples_match_glb_fixture() {
1036 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
1037 let glb = load_gltf_file(&fixture_glb()).unwrap();
1038
1039 let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap();
1040 let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1041 assert!(
1042 distance(leg_a, leg_b) > 0.05,
1043 "FBX walk must move LeftLowerLeg, delta {}",
1044 distance(leg_a, leg_b)
1045 );
1046
1047 let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap();
1048 let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1049 assert!(
1050 distance(leg_a, g_a) < 0.05,
1051 "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}"
1052 );
1053 assert!(
1054 distance(leg_b, g_b) < 0.08,
1055 "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}"
1056 );
1057
1058 let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap();
1059 let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap();
1060 assert!(distance(hand_a, hand_b) > 0.05);
1061 let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap();
1062 let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap();
1063 assert!(distance(hand_a, gh_a) < 0.05);
1064 assert!(
1065 distance(hand_b, gh_b) < 0.1,
1066 "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}"
1067 );
1068
1069 // Skin matrices stay finite and joint count matches.
1070 let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5);
1071 let g_skin = sample_skin_matrices(&glb, "walk", 0.5);
1072 assert_eq!(f_skin.len(), g_skin.len());
1073 for (name, f_m, g_m) in glb
1074 .joint_names
1075 .iter()
1076 .zip(f_skin.iter())
1077 .zip(g_skin.iter())
1078 .map(|((n, f), g)| (n, f, g))
1079 {
1080 let fi = fbx
1081 .character
1082 .joint_names
1083 .iter()
1084 .position(|n| n == name)
1085 .unwrap();
1086 let _ = fi;
1087 let d = distance(translation_of(*f_m), translation_of(*g_m));
1088 assert!(d < 0.15, "skin matrix translation {name} delta {d}");
1089 }
1090
1091 let fi = fbx
1092 .character
1093 .joint_names
1094 .iter()
1095 .position(|n| n == "Hips")
1096 .unwrap();
1097 assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1098 }
1099
1100 #[test]
1101 fn fbx_uv_converts_to_preview_top_left_convention() {
1102 assert_eq!(preview_uv(0.25, 0.0), [0.25, 1.0]);
1103 assert_eq!(preview_uv(0.5, 1.0), [0.5, 0.0]);
1104 assert_eq!(preview_uv(1.0, 0.75), [1.0, 0.25]);
1105 }
1106
1107 #[test]
1108 fn garbage_fbx_fails_explicitly() {
1109 let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
1110 let _ = std::fs::remove_dir_all(&root);
1111 std::fs::create_dir_all(&root).unwrap();
1112 let path = root.join("bogus.fbx");
1113 std::fs::write(&path, b"not really fbx").unwrap();
1114 let err = load_fbx_drawable(&path).unwrap_err();
1115 assert!(
1116 err.contains("mislukt") || err.to_lowercase().contains("fbx"),
1117 "{err}"
1118 );
1119 let _ = std::fs::remove_dir_all(&root);
1120 }
1121}