BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / fbx_scene.rs

fbx_scene.rs

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