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 },
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 skipped: Vec::new(),
822 });
823 }
824 Ok(clips)
825}
826
827fn push_vec3_channel(
828 channels: &mut Vec<AnimChannel>,
829 node: usize,
830 property: ChannelProperty,
831 keys: &[ufbx::BakedVec3],
832) -> Result<(), String> {
833 if keys.is_empty() {
834 return Ok(());
835 }
836 let mut times = Vec::with_capacity(keys.len());
837 let mut values = Vec::with_capacity(keys.len() * 3);
838 for key in keys {
839 let t = key.time as f32;
840 let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32];
841 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
842 return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into());
843 }
844 times.push(t);
845 values.extend_from_slice(&v);
846 }
847 channels.push(AnimChannel {
848 node,
849 property,
850 times,
851 values,
852 interpolation: crate::preview::Interpolation::Linear,
853 });
854 Ok(())
855}
856
857fn push_quat_channel(
858 channels: &mut Vec<AnimChannel>,
859 node: usize,
860 keys: &[ufbx::BakedQuat],
861) -> Result<(), String> {
862 if keys.is_empty() {
863 return Ok(());
864 }
865 let mut times = Vec::with_capacity(keys.len());
866 let mut values = Vec::with_capacity(keys.len() * 4);
867 for key in keys {
868 let t = key.time as f32;
869 let v = [
870 key.value.x as f32,
871 key.value.y as f32,
872 key.value.z as f32,
873 key.value.w as f32,
874 ];
875 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
876 return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into());
877 }
878 times.push(t);
879 values.extend_from_slice(&v);
880 }
881 channels.push(AnimChannel {
882 node,
883 property: ChannelProperty::Rotation,
884 times,
885 values,
886 interpolation: crate::preview::Interpolation::Linear,
887 });
888 Ok(())
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use crate::preview::{
895 animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world,
896 sample_skin_matrices,
897 };
898
899 fn fixture_fbx() -> std::path::PathBuf {
900 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx")
901 }
902
903 fn fixture_glb() -> std::path::PathBuf {
904 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
905 }
906
907 fn translation_of(m: Mat4) -> [f32; 3] {
908 [m[12], m[13], m[14]]
909 }
910
911 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
912 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
913 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
914 }
915
916 fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 {
917 let Some(parent_node) = character.nodes[node].parent else {
918 return 0.0;
919 };
920 let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node)
921 else {
922 return 0.0;
923 };
924 let child = rest_joint_world(character, joint).unwrap();
925 let parent = rest_joint_world(character, parent_joint).unwrap();
926 distance(translation_of(child), translation_of(parent))
927 }
928
929 #[test]
930 fn fbx_drawable_loads_normalized_skinned_scene() {
931 let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx");
932 assert_eq!(scene.provenance.target_axes, "right-handed Y-up");
933 assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9);
934 assert!(scene.provenance.space_conversion.contains("ModifyGeometry"));
935 assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx")));
936 assert_eq!(scene.character.joint_names.len(), 19);
937 assert!(scene.character.joint_names.iter().any(|n| n == "Hips"));
938 assert!(scene
939 .character
940 .joint_names
941 .iter()
942 .any(|n| n == "LeftLowerLeg"));
943 assert!(scene.character.positions.len() >= 19 * 8);
944 assert!(scene.character.indices.len() >= 3);
945 assert!(scene.character.clips.len() >= 2);
946 assert!(scene
947 .character
948 .clips
949 .iter()
950 .any(|c| c.name.to_lowercase().contains("walk")));
951 assert!(scene
952 .character
953 .clips
954 .iter()
955 .any(|c| c.name.to_lowercase().contains("attack")));
956 assert!(!scene.materials.is_empty());
957 let matrices = sample_skin_matrices(&scene.character, "walk", 0.2);
958 assert_eq!(matrices.len(), 19);
959 assert!(matrices.iter().flatten().all(|v| v.is_finite()));
960 }
961
962 #[test]
963 fn fbx_bind_pose_matches_glb_fixture() {
964 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
965 let glb = load_gltf_file(&fixture_glb()).unwrap();
966 assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len());
967 for name in &glb.joint_names {
968 assert!(
969 fbx.character.joint_names.iter().any(|n| n == name),
970 "FBX mist joint {name}"
971 );
972 }
973
974 // Hips rest translation matches (shared rig origin); Blender bone-axis
975 // rotations make intermediate rest XYZ diverge while animation matches.
976 let f_hips = fbx
977 .character
978 .joint_names
979 .iter()
980 .position(|n| n == "Hips")
981 .unwrap();
982 let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap();
983 let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap();
984 let g_rest = rest_joint_world(&glb, g_hips).unwrap();
985 assert!(
986 distance(translation_of(f_rest), translation_of(g_rest)) < 0.02,
987 "Hips bind {:?} vs {:?}",
988 translation_of(f_rest),
989 translation_of(g_rest)
990 );
991
992 for name in &glb.joint_names {
993 let fi = fbx
994 .character
995 .joint_names
996 .iter()
997 .position(|n| n == name)
998 .unwrap();
999 let gi = glb.joint_names.iter().position(|n| n == name).unwrap();
1000 let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]);
1001 let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]);
1002 if f_len > 1e-4 && g_len > 1e-4 {
1003 assert!(
1004 (f_len - g_len).abs() < 0.02,
1005 "bone length {name}: fbx={f_len} glb={g_len}"
1006 );
1007 }
1008 }
1009
1010 // Bind-equivalent pose is reached inside the walk clip (fixture mid sample).
1011 let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1012 let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1013 assert!(
1014 distance(f_leg, g_leg) < 0.02,
1015 "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}"
1016 );
1017 let g_bind = translation_of(
1018 rest_joint_world(
1019 &glb,
1020 glb.joint_names
1021 .iter()
1022 .position(|n| n == "LeftLowerLeg")
1023 .unwrap(),
1024 )
1025 .unwrap(),
1026 );
1027 assert!(
1028 distance(f_leg, g_bind) < 0.02,
1029 "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}"
1030 );
1031 }
1032
1033 #[test]
1034 fn fbx_animation_samples_match_glb_fixture() {
1035 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
1036 let glb = load_gltf_file(&fixture_glb()).unwrap();
1037
1038 let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap();
1039 let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1040 assert!(
1041 distance(leg_a, leg_b) > 0.05,
1042 "FBX walk must move LeftLowerLeg, delta {}",
1043 distance(leg_a, leg_b)
1044 );
1045
1046 let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap();
1047 let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1048 assert!(
1049 distance(leg_a, g_a) < 0.05,
1050 "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}"
1051 );
1052 assert!(
1053 distance(leg_b, g_b) < 0.08,
1054 "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}"
1055 );
1056
1057 let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap();
1058 let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap();
1059 assert!(distance(hand_a, hand_b) > 0.05);
1060 let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap();
1061 let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap();
1062 assert!(distance(hand_a, gh_a) < 0.05);
1063 assert!(
1064 distance(hand_b, gh_b) < 0.1,
1065 "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}"
1066 );
1067
1068 // Skin matrices stay finite and joint count matches.
1069 let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5);
1070 let g_skin = sample_skin_matrices(&glb, "walk", 0.5);
1071 assert_eq!(f_skin.len(), g_skin.len());
1072 for (name, f_m, g_m) in glb
1073 .joint_names
1074 .iter()
1075 .zip(f_skin.iter())
1076 .zip(g_skin.iter())
1077 .map(|((n, f), g)| (n, f, g))
1078 {
1079 let fi = fbx
1080 .character
1081 .joint_names
1082 .iter()
1083 .position(|n| n == name)
1084 .unwrap();
1085 let _ = fi;
1086 let d = distance(translation_of(*f_m), translation_of(*g_m));
1087 assert!(d < 0.15, "skin matrix translation {name} delta {d}");
1088 }
1089
1090 let fi = fbx
1091 .character
1092 .joint_names
1093 .iter()
1094 .position(|n| n == "Hips")
1095 .unwrap();
1096 assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1097 }
1098
1099 #[test]
1100 fn fbx_uv_converts_to_preview_top_left_convention() {
1101 assert_eq!(preview_uv(0.25, 0.0), [0.25, 1.0]);
1102 assert_eq!(preview_uv(0.5, 1.0), [0.5, 0.0]);
1103 assert_eq!(preview_uv(1.0, 0.75), [1.0, 0.25]);
1104 }
1105
1106 #[test]
1107 fn garbage_fbx_fails_explicitly() {
1108 let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
1109 let _ = std::fs::remove_dir_all(&root);
1110 std::fs::create_dir_all(&root).unwrap();
1111 let path = root.join("bogus.fbx");
1112 std::fs::write(&path, b"not really fbx").unwrap();
1113 let err = load_fbx_drawable(&path).unwrap_err();
1114 assert!(
1115 err.contains("mislukt") || err.to_lowercase().contains("fbx"),
1116 "{err}"
1117 );
1118 let _ = std::fs::remove_dir_all(&root);
1119 }
1120}