//! In-process normalized drawable/animated FBX scene. //! //! Loads FBX through vendored ufbx once with unit/axis/winding normalization, //! builds a `PreviewCharacter` that samples like the glTF path, and retains //! conversion provenance. Live Glow viewport and production four-map batch //! still use glTF/GLB; this module is the CPU/API surface for that later wire-up. use std::io::Cursor; use std::path::Path; use std::sync::Arc; use crate::equipment::Texture; use crate::import::FBX_BACKEND; use crate::preview::{ AnimChannel, BodySurface, ChannelProperty, Mat4, PreviewCharacter, PreviewClip, SceneNode, MAX_JOINTS, }; const MAX_BYTES: u64 = 32 * 1024 * 1024; const MAX_VERTICES: usize = 1_000_000; const MAX_INDICES: usize = 3_000_000; const TARGET_UNIT_METERS: f64 = 1.0; /// Metadata describing the one-shot conversion applied while loading. #[derive(Debug, Clone, PartialEq)] pub struct ConversionProvenance { pub source_path: String, pub backend: String, pub target_axes: String, pub target_unit_meters: f64, pub source_unit_meters: f64, pub space_conversion: String, pub geometry_transform_handling: String, pub mirror_axis: String, pub winding_reversed: bool, pub geometry_scale: f64, pub notices: Vec, } /// Material as reported by ufbx after normalization. #[derive(Debug, Clone, PartialEq)] pub struct FbxMaterial { pub name: String, pub base_color: [f32; 4], pub base_color_texture: Option, pub normal_texture: Option, } /// Texture bytes that ufbx reported (file path relative label or embedded). #[derive(Debug, Clone, PartialEq)] pub struct FbxTextureRef { pub name: String, pub source: String, pub width: u32, pub height: u32, pub embedded: bool, } /// Normalized drawable FBX scene ready for the shared sampling API. #[derive(Debug, Clone)] pub struct FbxDrawableScene { pub character: PreviewCharacter, pub provenance: ConversionProvenance, pub materials: Vec, pub textures: Vec, } /// Load an FBX file into a normalized drawable/animated scene. pub fn load_fbx_drawable(path: &Path) -> Result { let path_str = path .to_str() .ok_or_else(|| "het FBX-pad is geen UTF-8".to_string())?; let meta = std::fs::metadata(path).map_err(|err| format!("FBX openen mislukt: {err}"))?; if !meta.is_file() { return Err(format!("FBX-bestand ontbreekt: {}", path.display())); } if meta.len() > MAX_BYTES { return Err(format!( "FBX-bestand is groter dan {MAX_BYTES} bytes ({})", meta.len() )); } let opts = ufbx::LoadOpts { target_axes: ufbx::axes_right_handed_y_up(), target_unit_meters: TARGET_UNIT_METERS, space_conversion: ufbx::SpaceConversion::ModifyGeometry, geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry, generate_missing_normals: true, clean_skin_weights: true, normalize_normals: true, load_external_files: true, ignore_missing_external_files: false, ..Default::default() }; let scene = ufbx::load_file(path_str, opts).map_err(|err| { format!( "FBX laden mislukt ({}): {}", path.display(), err.description ) })?; let mut notices = Vec::new(); notices.push(format!( "FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters." )); notices.push( "Drawable/animated scene is beschikbaar voor sampling; live viewport en productie gebruiken nog glTF/GLB." .into(), ); if scene.metadata.geometry_ignored { return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into()); } if scene.metadata.animation_ignored { notices.push("animatiegegevens waren gemarkeerd als genegeerd door de loader".into()); } let (mesh_node_index, mesh) = find_skinned_mesh(&scene)?; let _ = mesh_node_index; let skin = mesh .skin_deformers .first() .ok_or_else(|| "skinned mesh heeft geen skin deformer".to_string())?; match skin.skinning_method { ufbx::SkinningMethod::Linear | ufbx::SkinningMethod::Rigid => {} other => { return Err(format!( "niet-ondersteunde FBX-skinningmethode {:?}; alleen linear/rigid", other )); } } if skin.clusters.is_empty() { return Err("FBX-skin heeft geen clusters".into()); } if skin.clusters.len() > MAX_JOINTS { return Err(format!( "FBX-skeleton heeft {} joints; limiet is {MAX_JOINTS}", skin.clusters.len() )); } if !mesh.blend_deformers.is_empty() { notices.push(format!( "{} blend deformers genegeerd (morph targets niet ondersteund)", mesh.blend_deformers.len() )); } let nodes = build_nodes(&scene)?; let (joint_nodes, joint_names, inverse_binds) = build_skin(skin)?; let (materials, textures, surfaces_base) = collect_materials(path, &scene, mesh, &mut notices)?; let ( positions, normals, source_normals_complete, joints, weights, indices, colors, uvs, surfaces, ) = extract_mesh(mesh, skin, &surfaces_base)?; if positions.len() > MAX_VERTICES || indices.len() > MAX_INDICES { return Err(format!( "FBX-mesh te groot ({} vertices, {} indices)", positions.len(), indices.len() )); } if positions.iter().any(|p| p.iter().any(|v| !v.is_finite())) || normals.iter().any(|n| n.iter().any(|v| !v.is_finite())) { return Err("FBX-mesh bevat niet-eindige posities of normalen".into()); } let clips = build_clips(&scene, &nodes)?; if clips.is_empty() { notices.push("geen animatiestacks met transformkanalen gevonden".into()); } let winding_reversed = mesh.reversed_winding; let provenance = ConversionProvenance { source_path: path.display().to_string(), backend: FBX_BACKEND.to_string(), target_axes: "right-handed Y-up".into(), target_unit_meters: TARGET_UNIT_METERS, source_unit_meters: scene.settings.original_unit_meters as f64, space_conversion: format!("{:?}", scene.metadata.space_conversion), geometry_transform_handling: format!("{:?}", scene.metadata.geometry_transform_handling), mirror_axis: format!("{:?}", scene.metadata.mirror_axis), winding_reversed, geometry_scale: scene.metadata.geometry_scale as f64, notices: notices.clone(), }; let base_color = surfaces .first() .map(|s| s.color) .unwrap_or([0.7, 0.7, 0.75, 1.0]); Ok(FbxDrawableScene { character: PreviewCharacter { nodes, joint_nodes, joint_names, inverse_binds, positions, normals, source_normals_complete, joints, weights, indices, base_color, colors, uvs, surfaces, clips, }, provenance, materials, textures, }) } fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String> { for node in &scene.nodes { if let Some(mesh) = node.mesh.as_ref() { if mesh.num_vertices == 0 || mesh.num_triangles == 0 { continue; } if mesh.skin_deformers.is_empty() { continue; } return Ok((node.element.typed_id as usize, mesh)); } } Err("FBX bevat geen skinned mesh met driehoeken".into()) } fn build_nodes(scene: &ufbx::Scene) -> Result, String> { let mut nodes = Vec::with_capacity(scene.nodes.len()); for node in &scene.nodes { let parent = if node.is_root { None } else { node.parent .as_ref() .map(|parent| parent.element.typed_id as usize) }; if node.has_geometry_transform && node.is_geometry_transform_helper { return Err(format!( "node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd", node.element.name.trim() )); } let t = node.local_transform; let translation = [ t.translation.x as f32, t.translation.y as f32, t.translation.z as f32, ]; let rotation = [ t.rotation.x as f32, t.rotation.y as f32, t.rotation.z as f32, t.rotation.w as f32, ]; let scale = [t.scale.x as f32, t.scale.y as f32, t.scale.z as f32]; if !translation .iter() .chain(rotation.iter()) .chain(scale.iter()) .all(|v| v.is_finite()) { return Err(format!( "node '{}' heeft niet-eindige lokale transform", node.element.name.trim() )); } nodes.push(SceneNode { name: node.element.name.trim().to_string(), parent, translation, rotation, scale, }); } for (index, node) in scene.nodes.iter().enumerate() { if node.element.typed_id as usize != index { return Err(format!( "ufbx typed_id {} komt niet overeen met node-index {index}", node.element.typed_id )); } } Ok(nodes) } type SkinBuild = (Vec, Vec, Vec); type MaterialBuild = (Vec, Vec, Vec); type MeshBuild = ( Vec<[f32; 3]>, Vec<[f32; 3]>, bool, Vec<[u16; 4]>, Vec<[f32; 4]>, Vec, Vec<[f32; 4]>, Vec<[f32; 2]>, Vec, ); fn build_skin(skin: &ufbx::SkinDeformer) -> Result { let mut joint_nodes = Vec::with_capacity(skin.clusters.len()); let mut joint_names = Vec::with_capacity(skin.clusters.len()); let mut inverse_binds = Vec::with_capacity(skin.clusters.len()); for cluster in &skin.clusters { let bone = cluster .bone_node .as_ref() .ok_or_else(|| "skin cluster zonder bone_node".to_string())?; let name = bone.element.name.trim(); if name.is_empty() { return Err("skin cluster bone heeft geen naam".into()); } joint_nodes.push(bone.element.typed_id as usize); joint_names.push(name.to_string()); let ib = matrix_to_mat4(&cluster.geometry_to_bone); if !ib.iter().all(|v| v.is_finite()) { return Err(format!("inverse-bind van '{name}' is niet eindig")); } inverse_binds.push(ib); } Ok((joint_nodes, joint_names, inverse_binds)) } fn matrix_to_mat4(m: &ufbx::Matrix) -> Mat4 { [ m.m00 as f32, m.m10 as f32, m.m20 as f32, 0.0, m.m01 as f32, m.m11 as f32, m.m21 as f32, 0.0, m.m02 as f32, m.m12 as f32, m.m22 as f32, 0.0, m.m03 as f32, m.m13 as f32, m.m23 as f32, 1.0, ] } struct SurfaceSeed { color: [f32; 4], albedo: Option>, cutoff: f32, } fn collect_materials( path: &Path, scene: &ufbx::Scene, mesh: &ufbx::Mesh, notices: &mut Vec, ) -> Result { let mut materials = Vec::new(); let mut textures = Vec::new(); let mut seeds = Vec::new(); let material_list: Vec<&ufbx::Material> = if mesh.materials.is_empty() { (&scene.materials).into_iter().collect() } else { (&mesh.materials).into_iter().collect() }; if material_list.is_empty() { seeds.push(SurfaceSeed { color: [0.7, 0.7, 0.75, 1.0], albedo: None, cutoff: 0.001, }); return Ok((materials, textures, seeds)); } for material in material_list { let color_map = if material.pbr.base_color.has_value { &material.pbr.base_color } else { &material.fbx.diffuse_color }; let base_color = [ color_map.value_vec4.x as f32, color_map.value_vec4.y as f32, color_map.value_vec4.z as f32, if color_map.value_vec4.w == 0.0 { 1.0 } else { color_map.value_vec4.w as f32 }, ]; if !base_color.iter().all(|c| c.is_finite()) { return Err(format!( "materiaal '{}' heeft niet-eindige basiskleur", material.element.name.trim() )); } let (base_tex_name, base_albedo) = resolve_texture(path, color_map.texture.as_ref(), &mut textures, notices)?; let (normal_tex_name, _) = resolve_texture( path, material .fbx .normal_map .texture .as_ref() .or(material.pbr.normal_map.texture.as_ref()), &mut textures, notices, )?; materials.push(FbxMaterial { name: { let name = material.element.name.trim(); if name.is_empty() { "materiaal".into() } else { name.to_string() } }, base_color, base_color_texture: base_tex_name.clone(), normal_texture: normal_tex_name, }); seeds.push(SurfaceSeed { color: base_color, albedo: base_albedo, cutoff: 0.001, }); } Ok((materials, textures, seeds)) } fn resolve_texture( fbx_path: &Path, texture: Option<&ufbx::Ref>, textures: &mut Vec, notices: &mut Vec, ) -> Result<(Option, Option>), String> { let Some(texture) = texture else { return Ok((None, None)); }; if texture.content.size > 0 { let name = { let n = texture.element.name.trim(); if n.is_empty() { format!("ingebed:{}", textures.len()) } else { format!("ingebed:{n}") } }; let decoded = decode_png_bytes(&texture.content) .map_err(|err| format!("ingebedde texture '{name}' kon niet gelezen worden: {err}"))?; if decoded.width > 2048 || decoded.height > 2048 { return Err(format!("texture '{name}' is groter dan 2048×2048")); } let width = decoded.width; let height = decoded.height; let arc = Arc::new(decoded); textures.push(FbxTextureRef { name: name.clone(), source: name.clone(), width, height, embedded: true, }); return Ok((Some(name), Some(arc))); } let relative = texture.relative_filename.trim().replace('\\', "/"); let filename = if relative.is_empty() { texture.filename.trim().replace('\\', "/") } else { relative }; if filename.is_empty() { return Ok((None, None)); } reject_escape(&filename)?; let base = fbx_path.parent().unwrap_or(fbx_path); let full = base.join(&filename); if full.is_file() { let bytes = std::fs::read(&full) .map_err(|err| format!("texture '{filename}' lezen mislukt: {err}"))?; let decoded = decode_png_bytes(&bytes) .map_err(|err| format!("texture '{filename}' is geen geldige 8-bit PNG: {err}"))?; if decoded.width > 2048 || decoded.height > 2048 { return Err(format!("texture '{filename}' is groter dan 2048×2048")); } let width = decoded.width; let height = decoded.height; let arc = Arc::new(decoded); textures.push(FbxTextureRef { name: filename.clone(), source: filename.clone(), width, height, embedded: false, }); Ok((Some(filename), Some(arc))) } else if texture.has_file { Err(format!("FBX-texture ontbreekt: {filename}")) } else { notices.push(format!( "texture-referentie '{filename}' zonder bestand genegeerd" )); Ok((Some(filename), None)) } } fn reject_escape(relative: &str) -> Result<(), String> { if relative.starts_with('/') || relative.starts_with('\\') || Path::new(relative).components().any(|c| { matches!( c, std::path::Component::ParentDir | std::path::Component::RootDir ) }) { return Err(format!("onveilig texturepad geweigerd: {relative}")); } Ok(()) } fn decode_png_bytes(bytes: &[u8]) -> Result { let mut decoder = png::Decoder::new(Cursor::new(bytes)); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); let mut reader = decoder.read_info().map_err(|e| e.to_string())?; let info = reader.info(); if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 { return Err("textureafmeting ongeldig of >2048".into()); } let size = reader .output_buffer_size() .ok_or_else(|| "texturebuffer te groot".to_string())?; let mut buffer = vec![0u8; size]; let frame = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?; let mut rgba = Vec::with_capacity((frame.width * frame.height * 4) as usize); match frame.color_type { png::ColorType::Grayscale => { for &p in &buffer[..frame.buffer_size()] { rgba.extend_from_slice(&[p, p, p, 255]); } } png::ColorType::GrayscaleAlpha => { for chunk in buffer[..frame.buffer_size()].chunks_exact(2) { rgba.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]); } } png::ColorType::Rgb => { for chunk in buffer[..frame.buffer_size()].chunks_exact(3) { rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); } } png::ColorType::Rgba => rgba.extend_from_slice(&buffer[..frame.buffer_size()]), other => return Err(format!("niet-ondersteund PNG-kleurtype {other:?}")), } use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); rgba.hash(&mut hasher); Ok(Texture { content_hash: hasher.finish(), width: frame.width, height: frame.height, rgba, }) } fn extract_mesh( mesh: &ufbx::Mesh, skin: &ufbx::SkinDeformer, seeds: &[SurfaceSeed], ) -> Result { let source_normals_complete = mesh.vertex_normal.exists && !mesh.generated_normals; let mut positions = Vec::new(); let mut normals = Vec::new(); let mut joints = Vec::new(); let mut weights = Vec::new(); let mut indices = Vec::new(); let mut colors = Vec::new(); let mut uvs = Vec::new(); let mut surfaces = Vec::new(); let part_count = mesh.material_parts.len().max(1); for part_index in 0..part_count { let face_indices: Vec = if mesh.material_parts.is_empty() { (0..mesh.faces.len() as u32).collect() } else { mesh.material_parts[part_index] .face_indices .iter() .copied() .collect() }; if face_indices.is_empty() { continue; } let seed = seeds .get(part_index.min(seeds.len().saturating_sub(1))) .ok_or_else(|| "materiaalpart zonder seed".to_string())?; let first = indices.len(); let mut tri_buf = Vec::new(); for face_index in face_indices { let face = mesh.faces[face_index as usize]; if face.num_indices < 3 { continue; } let tri_count = ufbx::triangulate_face_vec(&mut tri_buf, mesh, face); for t in 0..tri_count as usize { let corner_indices = [tri_buf[t * 3], tri_buf[t * 3 + 1], tri_buf[t * 3 + 2]]; for &corner in &corner_indices { let corner = corner as usize; let pos = &mesh.vertex_position[corner]; let position = [pos.x as f32, pos.y as f32, pos.z as f32]; let normal = if mesh.vertex_normal.exists { let n = &mesh.vertex_normal[corner]; [n.x as f32, n.y as f32, n.z as f32] } else { [0.0, 1.0, 0.0] }; let uv = if mesh.vertex_uv.exists { let u = &mesh.vertex_uv[corner]; [u.x as f32, u.y as f32] } else { [0.0, 0.0] }; let color = if mesh.vertex_color.exists { let c = &mesh.vertex_color[corner]; [c.x as f32, c.y as f32, c.z as f32, c.w as f32] } else { [1.0, 1.0, 1.0, 1.0] }; let vertex_ix = mesh.vertex_indices[corner] as usize; let (j, w) = skin_influences(skin, vertex_ix)?; let out_index = positions.len() as u32; positions.push(position); normals.push(normal); uvs.push(uv); colors.push(color); joints.push(j); weights.push(w); indices.push(out_index); } } } let count = indices.len() - first; if count > 0 { surfaces.push(BodySurface { first, count, color: seed.color, albedo: seed.albedo.clone(), cutoff: seed.cutoff, flat_normals: !mesh.vertex_normal.exists, }); } } if positions.is_empty() || indices.len() < 3 { return Err("FBX-mesh heeft geen tekenbare driehoeken na triangulatie".into()); } Ok(( positions, normals, source_normals_complete, joints, weights, indices, colors, uvs, surfaces, )) } fn skin_influences( skin: &ufbx::SkinDeformer, vertex: usize, ) -> Result<([u16; 4], [f32; 4]), String> { if vertex >= skin.vertices.len() { return Err(format!("skin vertex-index {vertex} buiten bereik")); } let sv = &skin.vertices[vertex]; let mut pairs: Vec<(u16, f32)> = Vec::new(); for i in 0..sv.num_weights as usize { let w = &skin.weights[sv.weight_begin as usize + i]; if w.cluster_index as usize >= skin.clusters.len() { return Err(format!( "skin weight verwijst naar onbekende cluster {}", w.cluster_index )); } let weight = w.weight as f32; if !weight.is_finite() || weight < 0.0 { return Err("skin weight is niet eindig of negatief".into()); } if weight > 0.0 { pairs.push((w.cluster_index as u16, weight)); } } pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); pairs.truncate(4); if pairs.is_empty() { pairs.push((0, 1.0)); } let sum: f32 = pairs.iter().map(|(_, w)| *w).sum(); if sum <= 1e-8 { return Err("skin weights sommeren tot nul".into()); } let mut joints = [0u16; 4]; let mut weights = [0.0f32; 4]; for (i, (j, w)) in pairs.into_iter().enumerate() { joints[i] = j; weights[i] = w / sum; } Ok((joints, weights)) } fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result, String> { let mut clips = Vec::new(); for stack in &scene.anim_stacks { let bake = ufbx::bake_anim( scene, &stack.anim, ufbx::BakeOpts { resample_rate: 30.0, minimum_sample_rate: 1.0, maximum_sample_rate: 120.0, trim_start_time: true, key_reduction_enabled: true, key_reduction_rotation: true, key_reduction_threshold: 1e-4, ..Default::default() }, ) .map_err(|err| { format!( "animatiestack '{}' bakken mislukt: {}", stack.element.name.trim(), err.description ) })?; let mut channels = Vec::new(); for baked in &bake.nodes { let node = baked.typed_id as usize; if node >= nodes.len() { return Err(format!( "gebakken node typed_id {node} buiten bereik ({} nodes)", nodes.len() )); } push_vec3_channel( &mut channels, node, ChannelProperty::Translation, &baked.translation_keys, )?; push_quat_channel(&mut channels, node, &baked.rotation_keys)?; push_vec3_channel( &mut channels, node, ChannelProperty::Scale, &baked.scale_keys, )?; } if channels.is_empty() { continue; } let duration = if bake.playback_duration > 0.0 { bake.playback_duration as f32 } else { (stack.time_end - stack.time_begin).max(0.0) as f32 }; let name = { let raw = stack.element.name.trim(); if raw.is_empty() { "clip".to_string() } else { raw.to_string() } }; clips.push(PreviewClip { name, duration, channels, }); } Ok(clips) } fn push_vec3_channel( channels: &mut Vec, node: usize, property: ChannelProperty, keys: &[ufbx::BakedVec3], ) -> Result<(), String> { if keys.is_empty() { return Ok(()); } let mut times = Vec::with_capacity(keys.len()); let mut values = Vec::with_capacity(keys.len() * 3); for key in keys { let t = key.time as f32; let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32]; if !t.is_finite() || !v.iter().all(|n| n.is_finite()) { return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into()); } times.push(t); values.extend_from_slice(&v); } channels.push(AnimChannel { node, property, times, values, }); Ok(()) } fn push_quat_channel( channels: &mut Vec, node: usize, keys: &[ufbx::BakedQuat], ) -> Result<(), String> { if keys.is_empty() { return Ok(()); } let mut times = Vec::with_capacity(keys.len()); let mut values = Vec::with_capacity(keys.len() * 4); for key in keys { let t = key.time as f32; let v = [ key.value.x as f32, key.value.y as f32, key.value.z as f32, key.value.w as f32, ]; if !t.is_finite() || !v.iter().all(|n| n.is_finite()) { return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into()); } times.push(t); values.extend_from_slice(&v); } channels.push(AnimChannel { node, property: ChannelProperty::Rotation, times, values, }); Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::preview::{ animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world, sample_skin_matrices, }; fn fixture_fbx() -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx") } fn fixture_glb() -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb") } fn translation_of(m: Mat4) -> [f32; 3] { [m[12], m[13], m[14]] } fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() } fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 { let Some(parent_node) = character.nodes[node].parent else { return 0.0; }; let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node) else { return 0.0; }; let child = rest_joint_world(character, joint).unwrap(); let parent = rest_joint_world(character, parent_joint).unwrap(); distance(translation_of(child), translation_of(parent)) } #[test] fn fbx_drawable_loads_normalized_skinned_scene() { let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx"); assert_eq!(scene.provenance.target_axes, "right-handed Y-up"); assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9); assert!(scene.provenance.space_conversion.contains("ModifyGeometry")); assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx"))); assert_eq!(scene.character.joint_names.len(), 19); assert!(scene.character.joint_names.iter().any(|n| n == "Hips")); assert!(scene .character .joint_names .iter() .any(|n| n == "LeftLowerLeg")); assert!(scene.character.positions.len() >= 19 * 8); assert!(scene.character.indices.len() >= 3); assert!(scene.character.clips.len() >= 2); assert!(scene .character .clips .iter() .any(|c| c.name.to_lowercase().contains("walk"))); assert!(scene .character .clips .iter() .any(|c| c.name.to_lowercase().contains("attack"))); assert!(!scene.materials.is_empty()); let matrices = sample_skin_matrices(&scene.character, "walk", 0.2); assert_eq!(matrices.len(), 19); assert!(matrices.iter().flatten().all(|v| v.is_finite())); } #[test] fn fbx_bind_pose_matches_glb_fixture() { let fbx = load_fbx_drawable(&fixture_fbx()).unwrap(); let glb = load_gltf_file(&fixture_glb()).unwrap(); assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len()); for name in &glb.joint_names { assert!( fbx.character.joint_names.iter().any(|n| n == name), "FBX mist joint {name}" ); } // Hips rest translation matches (shared rig origin); Blender bone-axis // rotations make intermediate rest XYZ diverge while animation matches. let f_hips = fbx .character .joint_names .iter() .position(|n| n == "Hips") .unwrap(); let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap(); let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap(); let g_rest = rest_joint_world(&glb, g_hips).unwrap(); assert!( distance(translation_of(f_rest), translation_of(g_rest)) < 0.02, "Hips bind {:?} vs {:?}", translation_of(f_rest), translation_of(g_rest) ); for name in &glb.joint_names { let fi = fbx .character .joint_names .iter() .position(|n| n == name) .unwrap(); let gi = glb.joint_names.iter().position(|n| n == name).unwrap(); let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]); let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]); if f_len > 1e-4 && g_len > 1e-4 { assert!( (f_len - g_len).abs() < 0.02, "bone length {name}: fbx={f_len} glb={g_len}" ); } } // Bind-equivalent pose is reached inside the walk clip (fixture mid sample). let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap(); let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap(); assert!( distance(f_leg, g_leg) < 0.02, "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}" ); let g_bind = translation_of( rest_joint_world( &glb, glb.joint_names .iter() .position(|n| n == "LeftLowerLeg") .unwrap(), ) .unwrap(), ); assert!( distance(f_leg, g_bind) < 0.02, "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}" ); } #[test] fn fbx_animation_samples_match_glb_fixture() { let fbx = load_fbx_drawable(&fixture_fbx()).unwrap(); let glb = load_gltf_file(&fixture_glb()).unwrap(); let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap(); let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap(); assert!( distance(leg_a, leg_b) > 0.05, "FBX walk must move LeftLowerLeg, delta {}", distance(leg_a, leg_b) ); let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap(); let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap(); assert!( distance(leg_a, g_a) < 0.05, "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}" ); assert!( distance(leg_b, g_b) < 0.08, "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}" ); let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap(); let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap(); assert!(distance(hand_a, hand_b) > 0.05); let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap(); let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap(); assert!(distance(hand_a, gh_a) < 0.05); assert!( distance(hand_b, gh_b) < 0.1, "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}" ); // Skin matrices stay finite and joint count matches. let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5); let g_skin = sample_skin_matrices(&glb, "walk", 0.5); assert_eq!(f_skin.len(), g_skin.len()); for (name, f_m, g_m) in glb .joint_names .iter() .zip(f_skin.iter()) .zip(g_skin.iter()) .map(|((n, f), g)| (n, f, g)) { let fi = fbx .character .joint_names .iter() .position(|n| n == name) .unwrap(); let _ = fi; let d = distance(translation_of(*f_m), translation_of(*g_m)); assert!(d < 0.15, "skin matrix translation {name} delta {d}"); } let fi = fbx .character .joint_names .iter() .position(|n| n == "Hips") .unwrap(); assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some()); } #[test] fn garbage_fbx_fails_explicitly() { let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).unwrap(); let path = root.join("bogus.fbx"); std::fs::write(&path, b"not really fbx").unwrap(); let err = load_fbx_drawable(&path).unwrap_err(); assert!( err.contains("mislukt") || err.to_lowercase().contains("fbx"), "{err}" ); let _ = std::fs::remove_dir_all(&root); } }