BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / fbx_scene.rs

fbx_scene.rs

fbx_scene.rs 36.9 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 });
685 }
686 }
687
688 if positions.is_empty() || indices.len() < 3 {
689 return Err("FBX-mesh heeft geen tekenbare driehoeken na triangulatie".into());
690 }
691 Ok((
692 positions,
693 normals,
694 source_normals_complete,
695 joints,
696 weights,
697 indices,
698 colors,
699 uvs,
700 surfaces,
701 ))
702}
703
704/// FBX UV (origin bottom-left) to the glTF-style `PreviewCharacter` UV
705/// convention (origin top-left) used by the production body path.
706pub(crate) fn preview_uv(u: f64, v: f64) -> [f32; 2] {
707 [u as f32, (1.0 - v) as f32]
708}
709
710fn skin_influences(
711 skin: &ufbx::SkinDeformer,
712 vertex: usize,
713) -> Result<([u16; 4], [f32; 4]), String> {
714 if vertex >= skin.vertices.len() {
715 return Err(format!("skin vertex-index {vertex} buiten bereik"));
716 }
717 let sv = &skin.vertices[vertex];
718 let mut pairs: Vec<(u16, f32)> = Vec::new();
719 for i in 0..sv.num_weights as usize {
720 let w = &skin.weights[sv.weight_begin as usize + i];
721 if w.cluster_index as usize >= skin.clusters.len() {
722 return Err(format!(
723 "skin weight verwijst naar onbekende cluster {}",
724 w.cluster_index
725 ));
726 }
727 let weight = w.weight as f32;
728 if !weight.is_finite() || weight < 0.0 {
729 return Err("skin weight is niet eindig of negatief".into());
730 }
731 if weight > 0.0 {
732 pairs.push((w.cluster_index as u16, weight));
733 }
734 }
735 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
736 pairs.truncate(4);
737 if pairs.is_empty() {
738 pairs.push((0, 1.0));
739 }
740 let sum: f32 = pairs.iter().map(|(_, w)| *w).sum();
741 if sum <= 1e-8 {
742 return Err("skin weights sommeren tot nul".into());
743 }
744 let mut joints = [0u16; 4];
745 let mut weights = [0.0f32; 4];
746 for (i, (j, w)) in pairs.into_iter().enumerate() {
747 joints[i] = j;
748 weights[i] = w / sum;
749 }
750 Ok((joints, weights))
751}
752
753fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewClip>, String> {
754 let mut clips = Vec::new();
755 for stack in &scene.anim_stacks {
756 let bake = ufbx::bake_anim(
757 scene,
758 &stack.anim,
759 ufbx::BakeOpts {
760 resample_rate: 30.0,
761 minimum_sample_rate: 1.0,
762 maximum_sample_rate: 120.0,
763 trim_start_time: true,
764 key_reduction_enabled: true,
765 key_reduction_rotation: true,
766 key_reduction_threshold: 1e-4,
767 ..Default::default()
768 },
769 )
770 .map_err(|err| {
771 format!(
772 "animatiestack '{}' bakken mislukt: {}",
773 stack.element.name.trim(),
774 err.description
775 )
776 })?;
777
778 let mut channels = Vec::new();
779 for baked in &bake.nodes {
780 let node = baked.typed_id as usize;
781 if node >= nodes.len() {
782 return Err(format!(
783 "gebakken node typed_id {node} buiten bereik ({} nodes)",
784 nodes.len()
785 ));
786 }
787 push_vec3_channel(
788 &mut channels,
789 node,
790 ChannelProperty::Translation,
791 &baked.translation_keys,
792 )?;
793 push_quat_channel(&mut channels, node, &baked.rotation_keys)?;
794 push_vec3_channel(
795 &mut channels,
796 node,
797 ChannelProperty::Scale,
798 &baked.scale_keys,
799 )?;
800 }
801 if channels.is_empty() {
802 continue;
803 }
804 let duration = if bake.playback_duration > 0.0 {
805 bake.playback_duration as f32
806 } else {
807 (stack.time_end - stack.time_begin).max(0.0) as f32
808 };
809 let name = {
810 let raw = stack.element.name.trim();
811 if raw.is_empty() {
812 "clip".to_string()
813 } else {
814 raw.to_string()
815 }
816 };
817 clips.push(PreviewClip {
818 name,
819 duration,
820 channels,
821 });
822 }
823 Ok(clips)
824}
825
826fn push_vec3_channel(
827 channels: &mut Vec<AnimChannel>,
828 node: usize,
829 property: ChannelProperty,
830 keys: &[ufbx::BakedVec3],
831) -> Result<(), String> {
832 if keys.is_empty() {
833 return Ok(());
834 }
835 let mut times = Vec::with_capacity(keys.len());
836 let mut values = Vec::with_capacity(keys.len() * 3);
837 for key in keys {
838 let t = key.time as f32;
839 let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32];
840 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
841 return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into());
842 }
843 times.push(t);
844 values.extend_from_slice(&v);
845 }
846 channels.push(AnimChannel {
847 node,
848 property,
849 times,
850 values,
851 });
852 Ok(())
853}
854
855fn push_quat_channel(
856 channels: &mut Vec<AnimChannel>,
857 node: usize,
858 keys: &[ufbx::BakedQuat],
859) -> Result<(), String> {
860 if keys.is_empty() {
861 return Ok(());
862 }
863 let mut times = Vec::with_capacity(keys.len());
864 let mut values = Vec::with_capacity(keys.len() * 4);
865 for key in keys {
866 let t = key.time as f32;
867 let v = [
868 key.value.x as f32,
869 key.value.y as f32,
870 key.value.z as f32,
871 key.value.w as f32,
872 ];
873 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
874 return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into());
875 }
876 times.push(t);
877 values.extend_from_slice(&v);
878 }
879 channels.push(AnimChannel {
880 node,
881 property: ChannelProperty::Rotation,
882 times,
883 values,
884 });
885 Ok(())
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use crate::preview::{
892 animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world,
893 sample_skin_matrices,
894 };
895
896 fn fixture_fbx() -> std::path::PathBuf {
897 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx")
898 }
899
900 fn fixture_glb() -> std::path::PathBuf {
901 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
902 }
903
904 fn translation_of(m: Mat4) -> [f32; 3] {
905 [m[12], m[13], m[14]]
906 }
907
908 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
909 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
910 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
911 }
912
913 fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 {
914 let Some(parent_node) = character.nodes[node].parent else {
915 return 0.0;
916 };
917 let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node)
918 else {
919 return 0.0;
920 };
921 let child = rest_joint_world(character, joint).unwrap();
922 let parent = rest_joint_world(character, parent_joint).unwrap();
923 distance(translation_of(child), translation_of(parent))
924 }
925
926 #[test]
927 fn fbx_drawable_loads_normalized_skinned_scene() {
928 let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx");
929 assert_eq!(scene.provenance.target_axes, "right-handed Y-up");
930 assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9);
931 assert!(scene.provenance.space_conversion.contains("ModifyGeometry"));
932 assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx")));
933 assert_eq!(scene.character.joint_names.len(), 19);
934 assert!(scene.character.joint_names.iter().any(|n| n == "Hips"));
935 assert!(scene
936 .character
937 .joint_names
938 .iter()
939 .any(|n| n == "LeftLowerLeg"));
940 assert!(scene.character.positions.len() >= 19 * 8);
941 assert!(scene.character.indices.len() >= 3);
942 assert!(scene.character.clips.len() >= 2);
943 assert!(scene
944 .character
945 .clips
946 .iter()
947 .any(|c| c.name.to_lowercase().contains("walk")));
948 assert!(scene
949 .character
950 .clips
951 .iter()
952 .any(|c| c.name.to_lowercase().contains("attack")));
953 assert!(!scene.materials.is_empty());
954 let matrices = sample_skin_matrices(&scene.character, "walk", 0.2);
955 assert_eq!(matrices.len(), 19);
956 assert!(matrices.iter().flatten().all(|v| v.is_finite()));
957 }
958
959 #[test]
960 fn fbx_bind_pose_matches_glb_fixture() {
961 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
962 let glb = load_gltf_file(&fixture_glb()).unwrap();
963 assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len());
964 for name in &glb.joint_names {
965 assert!(
966 fbx.character.joint_names.iter().any(|n| n == name),
967 "FBX mist joint {name}"
968 );
969 }
970
971 // Hips rest translation matches (shared rig origin); Blender bone-axis
972 // rotations make intermediate rest XYZ diverge while animation matches.
973 let f_hips = fbx
974 .character
975 .joint_names
976 .iter()
977 .position(|n| n == "Hips")
978 .unwrap();
979 let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap();
980 let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap();
981 let g_rest = rest_joint_world(&glb, g_hips).unwrap();
982 assert!(
983 distance(translation_of(f_rest), translation_of(g_rest)) < 0.02,
984 "Hips bind {:?} vs {:?}",
985 translation_of(f_rest),
986 translation_of(g_rest)
987 );
988
989 for name in &glb.joint_names {
990 let fi = fbx
991 .character
992 .joint_names
993 .iter()
994 .position(|n| n == name)
995 .unwrap();
996 let gi = glb.joint_names.iter().position(|n| n == name).unwrap();
997 let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]);
998 let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]);
999 if f_len > 1e-4 && g_len > 1e-4 {
1000 assert!(
1001 (f_len - g_len).abs() < 0.02,
1002 "bone length {name}: fbx={f_len} glb={g_len}"
1003 );
1004 }
1005 }
1006
1007 // Bind-equivalent pose is reached inside the walk clip (fixture mid sample).
1008 let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1009 let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1010 assert!(
1011 distance(f_leg, g_leg) < 0.02,
1012 "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}"
1013 );
1014 let g_bind = translation_of(
1015 rest_joint_world(
1016 &glb,
1017 glb.joint_names
1018 .iter()
1019 .position(|n| n == "LeftLowerLeg")
1020 .unwrap(),
1021 )
1022 .unwrap(),
1023 );
1024 assert!(
1025 distance(f_leg, g_bind) < 0.02,
1026 "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}"
1027 );
1028 }
1029
1030 #[test]
1031 fn fbx_animation_samples_match_glb_fixture() {
1032 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
1033 let glb = load_gltf_file(&fixture_glb()).unwrap();
1034
1035 let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap();
1036 let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1037 assert!(
1038 distance(leg_a, leg_b) > 0.05,
1039 "FBX walk must move LeftLowerLeg, delta {}",
1040 distance(leg_a, leg_b)
1041 );
1042
1043 let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap();
1044 let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1045 assert!(
1046 distance(leg_a, g_a) < 0.05,
1047 "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}"
1048 );
1049 assert!(
1050 distance(leg_b, g_b) < 0.08,
1051 "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}"
1052 );
1053
1054 let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap();
1055 let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap();
1056 assert!(distance(hand_a, hand_b) > 0.05);
1057 let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap();
1058 let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap();
1059 assert!(distance(hand_a, gh_a) < 0.05);
1060 assert!(
1061 distance(hand_b, gh_b) < 0.1,
1062 "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}"
1063 );
1064
1065 // Skin matrices stay finite and joint count matches.
1066 let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5);
1067 let g_skin = sample_skin_matrices(&glb, "walk", 0.5);
1068 assert_eq!(f_skin.len(), g_skin.len());
1069 for (name, f_m, g_m) in glb
1070 .joint_names
1071 .iter()
1072 .zip(f_skin.iter())
1073 .zip(g_skin.iter())
1074 .map(|((n, f), g)| (n, f, g))
1075 {
1076 let fi = fbx
1077 .character
1078 .joint_names
1079 .iter()
1080 .position(|n| n == name)
1081 .unwrap();
1082 let _ = fi;
1083 let d = distance(translation_of(*f_m), translation_of(*g_m));
1084 assert!(d < 0.15, "skin matrix translation {name} delta {d}");
1085 }
1086
1087 let fi = fbx
1088 .character
1089 .joint_names
1090 .iter()
1091 .position(|n| n == "Hips")
1092 .unwrap();
1093 assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1094 }
1095
1096 #[test]
1097 fn fbx_uv_converts_to_preview_top_left_convention() {
1098 assert_eq!(preview_uv(0.25, 0.0), [0.25, 1.0]);
1099 assert_eq!(preview_uv(0.5, 1.0), [0.5, 0.0]);
1100 assert_eq!(preview_uv(1.0, 0.75), [1.0, 0.25]);
1101 }
1102
1103 #[test]
1104 fn garbage_fbx_fails_explicitly() {
1105 let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
1106 let _ = std::fs::remove_dir_all(&root);
1107 std::fs::create_dir_all(&root).unwrap();
1108 let path = root.join("bogus.fbx");
1109 std::fs::write(&path, b"not really fbx").unwrap();
1110 let err = load_fbx_drawable(&path).unwrap_err();
1111 assert!(
1112 err.contains("mislukt") || err.to_lowercase().contains("fbx"),
1113 "{err}"
1114 );
1115 let _ = std::fs::remove_dir_all(&root);
1116 }
1117}