BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / fbx_scene.rs

fbx_scene.rs

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