1#![warn(missing_docs)]
61
62mod capability;
63
64pub use capability::{
65 FbxBindMatrixProvenance, FbxCoordinateAxis, FbxCoordinateNormalization,
66 FbxScaleCapabilityInventory, FbxScaleDomainInventory, FbxScaleDomainStatus, FbxScaleSource,
67 FbxSourceIdentity, capability_facts,
68};
69
70use animsmith_core::model::{
71 Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, MeshInstance,
72 NormalTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton, SourceInfo,
73 SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
74 SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
75 SourceSkinAttachment, TextureAsset, Track, TrackValues, Transform,
76};
77use capability::AssetConversionFacts;
78use glam::{Mat4, Quat, Vec3};
79use std::path::Path;
80
81#[derive(Debug, thiserror::Error)]
87#[non_exhaustive]
88pub enum LoadError {
89 #[error("path is not valid UTF-8: {0}")]
91 Path(String),
92 #[error("FBX parse error: {0}")]
94 Fbx(String),
95 #[error("animation bake failed for take {take:?}: {message}")]
97 Bake {
98 take: String,
100 message: String,
102 },
103}
104
105fn vec3(v: ufbx::Vec3) -> Vec3 {
106 Vec3::new(v.x as f32, v.y as f32, v.z as f32)
107}
108
109fn quat(q: ufbx::Quat) -> Quat {
110 Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
111}
112
113fn transform(t: &ufbx::Transform) -> Transform {
114 Transform {
115 translation: vec3(t.translation),
116 rotation: quat(t.rotation),
117 scale: vec3(t.scale),
118 }
119}
120
121fn mat4(m: &ufbx::Matrix) -> Mat4 {
123 Mat4::from_cols_array(&[
124 m.m00 as f32,
125 m.m10 as f32,
126 m.m20 as f32,
127 0.0,
128 m.m01 as f32,
129 m.m11 as f32,
130 m.m21 as f32,
131 0.0,
132 m.m02 as f32,
133 m.m12 as f32,
134 m.m22 as f32,
135 0.0,
136 m.m03 as f32,
137 m.m13 as f32,
138 m.m23 as f32,
139 1.0,
140 ])
141}
142
143fn project_cluster_bind(cluster: &ufbx::SkinCluster) -> Option<(Mat4, Mat4)> {
147 cluster.bone_node.as_ref()?;
148 let bind_to_world = mat4(&cluster.bind_to_world);
149 let geometry_to_world = mat4(&cluster.geometry_to_world);
150 if !bind_to_world.is_finite() || !geometry_to_world.is_finite() {
151 return None;
152 }
153 let bone_inverse = bind_to_world.inverse();
154 let instance_inverse = bone_inverse * geometry_to_world;
155 (bone_inverse.is_finite() && instance_inverse.is_finite())
156 .then_some((bone_inverse, instance_inverse))
157}
158
159pub fn load(path: &Path) -> Result<Document, LoadError> {
171 Ok(load_scale_source(path)?.into_document())
172}
173
174pub fn load_scale_source(path: &Path) -> Result<FbxScaleSource, LoadError> {
185 path.to_str()
186 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
187 let bytes = std::fs::read(path).map_err(|error| LoadError::Fbx(error.to_string()))?;
188 load_scale_source_bytes(path, &bytes)
189}
190
191pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
204 Ok(load_scale_source_bytes(path, bytes)?.into_document())
205}
206
207pub fn load_scale_source_bytes(path: &Path, bytes: &[u8]) -> Result<FbxScaleSource, LoadError> {
218 let filename = path
219 .to_str()
220 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
221 let opts = ufbx::LoadOpts {
222 target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
223 target_unit_meters: 1.0,
224 space_conversion: ufbx::SpaceConversion::AdjustTransforms,
225 geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
226 inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
232 generate_missing_normals: true,
233 filename: filename.into(),
234 ..Default::default()
235 };
236 let scene = ufbx::load_memory(bytes, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
237
238 let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
243 for node in &scene.nodes {
244 let name = if node.element.name.is_empty() {
245 if node.is_root {
246 "<fbx-root>".to_string()
247 } else {
248 format!("node{}", node.element.typed_id)
249 }
250 } else {
251 node.element.name.to_string()
252 };
253 bones.push(Bone {
254 name,
255 parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
256 rest: transform(&node.local_transform),
257 inverse_bind: None,
258 });
259 }
260 for cluster in &scene.skin_clusters {
261 if let (Some(bone_node), Some((bone_inverse, _))) =
262 (&cluster.bone_node, project_cluster_bind(cluster))
263 {
264 let id = bone_node.element.typed_id as usize;
265 if id < bones.len() {
266 bones[id].inverse_bind = Some(bone_inverse);
270 }
271 }
272 }
273
274 let mut clips = Vec::new();
275 for (index, stack) in scene.anim_stacks.iter().enumerate() {
276 let take = if stack.element.name.is_empty() {
277 format!("take{index}")
278 } else {
279 stack.element.name.to_string()
280 };
281 let baked = ufbx::bake_anim(
282 &scene,
283 &stack.anim,
284 ufbx::BakeOpts {
285 trim_start_time: true,
286 ..Default::default()
287 },
288 )
289 .map_err(|e| LoadError::Bake {
290 take: take.clone(),
291 message: format!("{e:?}"),
292 })?;
293
294 let mut tracks = Vec::new();
295 let mut duration = 0.0f64;
296 for node in &baked.nodes {
297 let bone = node.typed_id as usize;
298 if !node.translation_keys.is_empty() {
299 let times: Vec<f32> = node
300 .translation_keys
301 .iter()
302 .map(|k| k.time as f32)
303 .collect();
304 let values: Vec<Vec3> = node
305 .translation_keys
306 .iter()
307 .map(|k| vec3(k.value))
308 .collect();
309 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
310 tracks.push(Track {
311 bone,
312 property: Property::Translation,
313 interpolation: Interpolation::Linear,
314 times,
315 values: TrackValues::Vec3s(values),
316 });
317 }
318 if !node.rotation_keys.is_empty() {
319 let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
320 let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
321 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
322 tracks.push(Track {
323 bone,
324 property: Property::Rotation,
325 interpolation: Interpolation::Linear,
326 times,
327 values: TrackValues::Quats(values),
328 });
329 }
330 if !node.scale_keys.is_empty() {
331 let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
332 let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
333 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
334 tracks.push(Track {
335 bone,
336 property: Property::Scale,
337 interpolation: Interpolation::Linear,
338 times,
339 values: TrackValues::Vec3s(values),
340 });
341 }
342 }
343 clips.push(Clip {
344 name: take,
345 duration_s: duration,
346 tracks,
347 });
348 }
349
350 let (assets, conversion) = extract_assets(&scene, path.parent());
351 let inventory = capability::inventory(&scene, &conversion);
352
353 Ok(FbxScaleSource {
354 document: Document {
355 skeleton: Skeleton { bones },
356 clips,
357 assets,
358 source: SourceInfo {
359 path: Some(path.display().to_string()),
360 format: Some("fbx".into()),
361 },
362 },
363 inventory,
364 })
365}
366
367fn texture_asset(texture: &ufbx::Texture, base_dir: Option<&Path>) -> Option<TextureAsset> {
370 let bytes: Vec<u8> = if !texture.content.is_empty() {
371 texture.content.to_vec()
372 } else {
373 let mut found = None;
374 for candidate in [
375 texture.absolute_filename.as_ref(),
376 texture.relative_filename.as_ref(),
377 texture.filename.as_ref(),
378 ] {
379 if candidate.is_empty() {
380 continue;
381 }
382 let direct = Path::new(candidate);
383 let path = if direct.is_absolute() {
384 direct.to_path_buf()
385 } else {
386 base_dir.unwrap_or(Path::new(".")).join(direct)
387 };
388 if let Ok(data) = std::fs::read(&path) {
389 found = Some(data);
390 break;
391 }
392 }
393 found?
394 };
395 let mime = match bytes.get(..3) {
396 Some([0x89, b'P', b'N']) => "image/png",
397 Some([0xFF, 0xD8, _]) => "image/jpeg",
398 _ => return None,
399 };
400 Some(TextureAsset {
401 bytes,
402 mime: mime.into(),
403 })
404}
405
406fn base_color_texture(material: &ufbx::Material, base_dir: Option<&Path>) -> Option<TextureAsset> {
407 let texture = material.pbr.base_color.texture.as_ref().or(material
408 .fbx
409 .diffuse_color
410 .texture
411 .as_ref())?;
412 texture_asset(texture, base_dir)
413}
414
415fn normal_texture(
416 material: &ufbx::Material,
417 base_dir: Option<&Path>,
418) -> Option<NormalTextureAsset> {
419 let texture = material.pbr.normal_map.texture.as_ref().or(material
420 .fbx
421 .normal_map
422 .texture
423 .as_ref())?;
424 texture_asset(texture, base_dir).map(|texture| NormalTextureAsset {
425 texture,
426 scale: 1.0,
430 })
431}
432
433#[derive(Debug, Clone, Copy, PartialEq)]
434enum ProjectedInfluence {
435 Absent,
436 Retained(u16, f32),
437 Rejected,
438}
439
440fn project_skin_influence(
441 source_weight: f64,
442 cluster_index: Option<usize>,
443 cluster_count: usize,
444 cluster_has_bone: bool,
445) -> ProjectedInfluence {
446 let weight = source_weight as f32;
447 if !source_weight.is_finite()
448 || source_weight < 0.0
449 || !weight.is_finite()
450 || (source_weight > 0.0 && weight == 0.0)
451 {
452 return ProjectedInfluence::Rejected;
453 }
454 if weight == 0.0 {
455 return ProjectedInfluence::Absent;
456 }
457 let Some(cluster_index) = cluster_index else {
458 return ProjectedInfluence::Rejected;
459 };
460 if cluster_index >= cluster_count || !cluster_has_bone {
461 return ProjectedInfluence::Rejected;
462 }
463 match u16::try_from(cluster_index) {
464 Ok(index) => ProjectedInfluence::Retained(index, weight),
465 Err(_) => ProjectedInfluence::Rejected,
466 }
467}
468
469fn extract_source_skeleton(scene: &ufbx::Scene) -> SourceSkeletonAssets {
474 let nodes = scene
475 .nodes
476 .iter()
477 .map(|node| {
478 let mut source = SourceNodeAsset::new(
479 node.element.typed_id as usize,
480 SourceNodeLocalRest::Trs {
481 translation: vec3(node.local_transform.translation),
482 rotation: quat(node.local_transform.rotation),
483 scale: vec3(node.local_transform.scale),
484 },
485 );
486 source.name = (!node.element.name.is_empty()).then(|| node.element.name.to_string());
487 source.parent_source_node_index = node
488 .parent
489 .as_ref()
490 .map(|parent| parent.element.typed_id as usize);
491 source.scene_root_indices = if node.is_root { vec![0] } else { Vec::new() };
492 source.bone = Some(node.element.typed_id as usize);
493 source
494 })
495 .collect();
496
497 if scene.skin_clusters.iter().any(|cluster| {
503 cluster.bone_node.as_ref().is_none_or(|bone| {
504 usize::try_from(bone.element.typed_id)
505 .ok()
506 .is_none_or(|index| index >= scene.nodes.len())
507 })
508 }) {
509 return SourceSkeletonAssets::default();
510 }
511
512 let mut attachments = vec![Vec::new(); scene.skin_deformers.len()];
513 for node in &scene.nodes {
514 let Some(mesh) = &node.mesh else { continue };
515 for skin in &mesh.skin_deformers {
516 let Some(for_skin) = attachments.get_mut(skin.element.typed_id as usize) else {
517 return SourceSkeletonAssets::default();
518 };
519 for_skin.push(SourceSkinAttachment {
520 source_node_index: node.element.typed_id as usize,
521 source_mesh_index: Some(mesh.element.typed_id as usize),
522 });
523 }
524 }
525
526 let skins = scene
527 .skin_deformers
528 .iter()
529 .map(|skin| {
530 let source_skin_index = skin.element.typed_id as usize;
531 let projected_matrices = skin
532 .clusters
533 .iter()
534 .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
535 .collect::<Option<Vec<_>>>();
536 let (status, matrices) = match (skin.clusters.is_empty(), projected_matrices) {
537 (true, _) => (SourceInverseBindAccessorStatus::Absent, Vec::new()),
538 (false, Some(matrices)) => (SourceInverseBindAccessorStatus::Available, matrices),
539 (false, None) => (SourceInverseBindAccessorStatus::Unreadable, Vec::new()),
542 };
543 SourceSkinAsset {
544 source_skin_index,
545 name: (!skin.element.name.is_empty()).then(|| skin.element.name.to_string()),
546 skeleton_root_source_node_index: None,
549 joint_source_node_indices: skin
550 .clusters
551 .iter()
552 .filter_map(|cluster| {
553 cluster
554 .bone_node
555 .as_ref()
556 .map(|node| node.element.typed_id as usize)
557 })
558 .collect(),
559 inverse_bind_accessor: SourceInverseBindAccessor {
560 status,
561 declared_count: (!skin.clusters.is_empty()).then_some(skin.clusters.len()),
562 matrices,
563 },
564 attachments: attachments
565 .get_mut(source_skin_index)
566 .map(std::mem::take)
567 .unwrap_or_default(),
568 }
569 })
570 .collect();
571
572 SourceSkeletonAssets {
573 coverage: SourceSkeletonCoverage::Complete,
574 nodes,
575 skins,
576 }
577}
578
579fn extract_assets(
584 scene: &ufbx::Scene,
585 base_dir: Option<&Path>,
586) -> (SceneAssets, AssetConversionFacts) {
587 let mut assets = SceneAssets::default();
588 let mut conversion = AssetConversionFacts::default();
589 let mut material_index: std::collections::BTreeMap<u32, usize> =
590 std::collections::BTreeMap::new();
591 let mut normalized_mesh_index_by_source = std::collections::BTreeMap::<u32, usize>::new();
592
593 for (source_node_index, node) in scene.nodes.iter().enumerate() {
594 let Some(mesh) = &node.mesh else { continue };
595 let node_id = node.element.typed_id as usize;
596
597 let local_materials: Vec<usize> = mesh
599 .materials
600 .iter()
601 .map(|m| {
602 *material_index
603 .entry(m.element.element_id)
604 .or_insert_with(|| {
605 let base = if m.pbr.base_color.has_value {
606 m.pbr.base_color.value_vec4
607 } else {
608 m.fbx.diffuse_color.value_vec4
609 };
610 let texture = base_color_texture(m, base_dir);
611 let normal_texture = normal_texture(m, base_dir);
612 assets.materials.push(MaterialAsset {
613 name: m.element.name.to_string(),
614 base_color: if texture.is_some() {
617 [1.0, 1.0, 1.0, 1.0]
618 } else {
619 [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
620 },
621 metallic: if m.pbr.metalness.has_value {
622 m.pbr.metalness.value_vec4.x as f32
623 } else {
624 0.0
625 },
626 roughness: if m.pbr.roughness.has_value {
627 m.pbr.roughness.value_vec4.x as f32
628 } else {
629 1.0
630 },
631 base_color_texture: texture,
632 normal_texture,
633 metallic_roughness_texture: None,
634 occlusion_texture: None,
635 });
636 assets.materials.len() - 1
637 })
638 })
639 .collect();
640
641 let skin = mesh.skin_deformers.first();
644 let skin_joints: Vec<usize> = skin
645 .map(|s| {
646 s.clusters
647 .iter()
648 .map(|c| {
649 c.bone_node
650 .as_ref()
651 .map(|b| b.element.typed_id as usize)
652 .unwrap_or(0)
653 })
654 .collect()
655 })
656 .unwrap_or_default();
657 let skin_ibms: Vec<glam::Mat4> = skin
661 .and_then(|s| {
662 s.clusters
663 .iter()
664 .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
665 .collect::<Option<Vec<_>>>()
666 })
667 .unwrap_or_default();
668 if let Some(&normalized_mesh_index) =
669 normalized_mesh_index_by_source.get(&mesh.element.typed_id)
670 {
671 assets.instances.push(MeshInstance {
672 source_node_index,
673 node: node_id,
674 mesh: normalized_mesh_index,
675 skin_joints,
676 skin_ibms,
677 });
678 continue;
679 }
680 let vertex_influences: Vec<Option<([u16; 4], [f32; 4])>> = skin
681 .map(|s| {
682 (0..mesh.num_vertices)
683 .map(|v| {
684 let mut pairs: Vec<(u16, f32)> = Vec::new();
685 if let Some(sv) = s.vertices.get(v) {
686 let begin = sv.weight_begin as usize;
687 let end = begin.saturating_add(sv.num_weights as usize);
688 for sw in s.weights.get(begin..end).unwrap_or_default() {
689 let source_weight = sw.weight;
690 let cluster_index = usize::try_from(sw.cluster_index).ok();
691 let cluster_has_bone = cluster_index
692 .and_then(|index| s.clusters.get(index))
693 .is_some_and(|cluster| cluster.bone_node.is_some());
694 match project_skin_influence(
695 source_weight,
696 cluster_index,
697 s.clusters.len(),
698 cluster_has_bone,
699 ) {
700 ProjectedInfluence::Absent => {}
701 ProjectedInfluence::Retained(index, weight) => {
702 pairs.push((index, weight));
703 }
704 ProjectedInfluence::Rejected => {
705 conversion.rejected_influence_count += 1;
706 }
707 }
708 }
709 }
710 pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
711 if pairs.len() > 4 {
712 conversion.truncated_influence_vertex_count += 1;
713 conversion.discarded_influence_count += pairs.len() - 4;
714 }
715 pairs.truncate(4);
716 let total: f32 = pairs.iter().map(|p| p.1).sum();
717 if pairs.is_empty() || !total.is_finite() || total <= 0.0 {
718 return None;
719 }
720 let mut joints = [0u16; 4];
721 let mut weights = [0f32; 4];
722 let mut renormalized = false;
723 for (slot, (j, w)) in pairs.into_iter().enumerate() {
724 joints[slot] = j;
725 let normalized = if total > 0.0 { w / total } else { 0.0 };
726 renormalized |= normalized.to_bits() != w.to_bits();
727 weights[slot] = normalized;
728 }
729 if renormalized {
730 conversion.renormalized_influence_vertex_count += 1;
731 }
732 Some((joints, weights))
733 })
734 .collect()
735 })
736 .unwrap_or_default();
737
738 let slots = local_materials.len().max(1);
740 let mut primitives: Vec<Primitive> = (0..slots)
741 .map(|slot| Primitive {
742 material: local_materials.get(slot).copied(),
743 ..Primitive::default()
744 })
745 .collect();
746
747 let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
748 for (face_index, &face) in mesh.faces.iter().enumerate() {
749 let slot = mesh
750 .face_material
751 .get(face_index)
752 .map(|&m| m as usize)
753 .filter(|&m| m < slots)
754 .unwrap_or(0);
755 let prim = &mut primitives[slot];
756 let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
757 for &corner in &tri_indices[..tris * 3] {
758 let corner = corner as usize;
759 let p = mesh.vertex_position[corner];
760 prim.positions
761 .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
762 if mesh.vertex_normal.exists {
763 let n = mesh.vertex_normal[corner];
764 prim.normals
765 .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
766 }
767 if mesh.vertex_uv.exists {
768 let uv = mesh.vertex_uv[corner];
769 prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
772 }
773 if !vertex_influences.is_empty() {
774 let vertex = mesh.vertex_indices[corner] as usize;
775 let (joints, weights) = vertex_influences
776 .get(vertex)
777 .copied()
778 .flatten()
779 .unwrap_or_else(|| {
780 conversion.missing_skin_influence_corner_count += 1;
781 ([0; 4], [0.0; 4])
782 });
783 prim.joints.push(joints);
784 prim.weights.push(weights);
785 }
786 }
787 }
788 primitives.retain(|p| !p.positions.is_empty());
789 for prim in &mut primitives {
790 conversion.pre_weld_vertex_count += prim.positions.len();
791 prim.weld();
792 conversion.post_weld_vertex_count += prim.positions.len();
793 }
794 if primitives.is_empty() {
795 continue;
796 }
797 let normalized_mesh_index = assets.meshes.len();
798 let source_mesh_index = mesh.element.typed_id as usize;
799 normalized_mesh_index_by_source.insert(mesh.element.typed_id, normalized_mesh_index);
800 assets.meshes.push(MeshAsset {
801 name: mesh.element.name.to_string(),
802 source_mesh_index,
806 primitives,
807 });
808 assets.instances.push(MeshInstance {
809 source_node_index,
810 node: node_id,
811 mesh: normalized_mesh_index,
812 skin_joints,
813 skin_ibms,
814 });
815 }
816 assets.scenes.push(SceneAsset {
817 source_scene_index: 0,
818 name: None,
819 roots: scene
820 .nodes
821 .iter()
822 .filter(|node| node.is_root)
823 .map(|node| node.element.typed_id as usize)
824 .collect(),
825 });
826 assets.default_scene = Some(0);
827 assets.source_skeleton = extract_source_skeleton(scene);
828 (assets, conversion)
829}
830
831#[cfg(test)]
832mod tests {
833 use super::{ProjectedInfluence, project_skin_influence};
834
835 #[test]
836 fn influence_projection_checks_sign_range_and_u16_cluster_narrowing() {
837 assert_eq!(
838 project_skin_influence(0.0, Some(0), 1, true),
839 ProjectedInfluence::Absent
840 );
841 assert_eq!(
842 project_skin_influence(-0.25, Some(0), 1, true),
843 ProjectedInfluence::Rejected
844 );
845 assert_eq!(
846 project_skin_influence(1.0, Some(1), 1, true),
847 ProjectedInfluence::Rejected,
848 "a source cluster index outside the declaration must not survive"
849 );
850 assert_eq!(
851 project_skin_influence(
852 1.0,
853 Some(usize::from(u16::MAX) + 1),
854 usize::from(u16::MAX) + 2,
855 true,
856 ),
857 ProjectedInfluence::Rejected,
858 "u32/usize cluster identity must not wrap while narrowing to u16"
859 );
860 assert_eq!(
861 project_skin_influence(0.5, Some(7), 8, true),
862 ProjectedInfluence::Retained(7, 0.5)
863 );
864 }
865}