1#![warn(missing_docs)]
73
74mod capability;
75mod source_facts;
76
77pub use capability::{
78 FbxBindMatrixProvenance, FbxCoordinateAxis, FbxCoordinateNormalization,
79 FbxScaleCapabilityInventory, FbxScaleDomainInventory, FbxScaleDomainStatus, FbxScaleSource,
80 FbxSourceIdentity, capability_facts, capability_facts_for_source, rest_bind_capability_facts,
81 rest_bind_capability_facts_for_source,
82};
83
84use animsmith_core::model::{
85 Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, MeshInstance,
86 NormalTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton, SourceInfo,
87 SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
88 SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
89 SourceSkinAttachment, TextureAsset, Track, TrackValues, Transform,
90};
91use animsmith_core::{
92 DependencyClosureBuilderV1, DependencyResourceKeyV1, DependencyResourceRefusalReasonV1,
93 DependencyResourceUnavailableReasonV1, InputIdentity, LoadedSource, RawSourceFactsBuilderV1,
94 ResourceKeySyntaxV1, SourceFactsError, SourceResourceKindV1, SourceResourceLocatorV1,
95 SourceResourceReferenceV1,
96};
97use capability::AssetConversionFacts;
98use glam::{Mat4, Quat, Vec3};
99use std::{
100 collections::BTreeMap,
101 fs::File,
102 io::Read,
103 path::{Path, PathBuf},
104};
105
106const FBX_MAX_ASSET_TEXTURE_BYTES: usize = 256 * 1024 * 1024;
111
112#[derive(Debug, thiserror::Error)]
118#[non_exhaustive]
119pub enum LoadError {
120 #[error("path is not valid UTF-8: {0}")]
122 Path(String),
123 #[error("FBX parse error: {0}")]
125 Fbx(String),
126 #[error("animation bake failed for take {take:?}: {message}")]
128 Bake {
129 take: String,
131 message: String,
133 },
134 #[error("invalid FBX source-facts projection: {0}")]
136 SourceFacts(#[from] SourceFactsError),
137}
138
139fn vec3(v: ufbx::Vec3) -> Vec3 {
140 Vec3::new(v.x as f32, v.y as f32, v.z as f32)
141}
142
143fn quat(q: ufbx::Quat) -> Quat {
144 Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
145}
146
147fn transform(t: &ufbx::Transform) -> Transform {
148 Transform {
149 translation: vec3(t.translation),
150 rotation: quat(t.rotation),
151 scale: vec3(t.scale),
152 }
153}
154
155fn mat4(m: &ufbx::Matrix) -> Mat4 {
157 Mat4::from_cols_array(&[
158 m.m00 as f32,
159 m.m10 as f32,
160 m.m20 as f32,
161 0.0,
162 m.m01 as f32,
163 m.m11 as f32,
164 m.m21 as f32,
165 0.0,
166 m.m02 as f32,
167 m.m12 as f32,
168 m.m22 as f32,
169 0.0,
170 m.m03 as f32,
171 m.m13 as f32,
172 m.m23 as f32,
173 1.0,
174 ])
175}
176
177fn project_cluster_bind(cluster: &ufbx::SkinCluster) -> Option<(Mat4, Mat4)> {
181 cluster.bone_node.as_ref()?;
182 let bind_to_world = mat4(&cluster.bind_to_world);
183 let geometry_to_world = mat4(&cluster.geometry_to_world);
184 if !bind_to_world.is_finite() || !geometry_to_world.is_finite() {
185 return None;
186 }
187 let bone_inverse = bind_to_world.inverse();
188 let instance_inverse = bone_inverse * geometry_to_world;
189 (bone_inverse.is_finite() && instance_inverse.is_finite())
190 .then_some((bone_inverse, instance_inverse))
191}
192
193pub fn load(path: &Path) -> Result<Document, LoadError> {
207 Ok(load_source(path)?.into_document())
208}
209
210pub fn load_source(path: &Path) -> Result<LoadedSource, LoadError> {
224 Ok(load_scale_source(path)?.into_source())
225}
226
227pub fn load_scale_source(path: &Path) -> Result<FbxScaleSource, LoadError> {
241 path.to_str()
242 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
243 let bytes = std::fs::read(path).map_err(|error| LoadError::Fbx(error.to_string()))?;
244 let resource_root = path
245 .parent()
246 .filter(|parent| !parent.as_os_str().is_empty())
247 .unwrap_or_else(|| Path::new("."));
248 load_scale_source_bytes_with_resource_root(path, &bytes, resource_root)
249}
250
251pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
266 Ok(load_source_bytes(path, bytes)?.into_document())
267}
268
269pub fn load_bytes_with_resource_root(
282 path: &Path,
283 bytes: &[u8],
284 resource_root: &Path,
285) -> Result<Document, LoadError> {
286 Ok(load_source_bytes_with_resource_root(path, bytes, resource_root)?.into_document())
287}
288
289pub fn load_source_bytes(path: &Path, bytes: &[u8]) -> Result<LoadedSource, LoadError> {
303 Ok(load_scale_source_bytes_inner(path, bytes, None)?.into_source())
304}
305
306pub fn load_source_bytes_with_resource_root(
317 path: &Path,
318 bytes: &[u8],
319 resource_root: &Path,
320) -> Result<LoadedSource, LoadError> {
321 Ok(load_scale_source_bytes_inner(path, bytes, Some(resource_root))?.into_source())
322}
323
324pub fn load_scale_source_bytes(path: &Path, bytes: &[u8]) -> Result<FbxScaleSource, LoadError> {
339 load_scale_source_bytes_inner(path, bytes, None)
340}
341
342pub fn load_scale_source_bytes_with_resource_root(
354 path: &Path,
355 bytes: &[u8],
356 resource_root: &Path,
357) -> Result<FbxScaleSource, LoadError> {
358 load_scale_source_bytes_inner(path, bytes, Some(resource_root))
359}
360
361fn load_scale_source_bytes_inner(
362 path: &Path,
363 bytes: &[u8],
364 resource_root: Option<&Path>,
365) -> Result<FbxScaleSource, LoadError> {
366 let filename = path
367 .to_str()
368 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
369 let opts = ufbx::LoadOpts {
370 target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
371 target_unit_meters: 1.0,
372 space_conversion: ufbx::SpaceConversion::AdjustTransforms,
373 geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
374 inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
380 generate_missing_normals: true,
381 load_external_files: false,
384 ignore_missing_external_files: false,
385 filename: filename.into(),
386 ..Default::default()
387 };
388 let scene = ufbx::load_memory(bytes, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
389
390 let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
395 for node in &scene.nodes {
396 let name = if node.element.name.is_empty() {
397 if node.is_root {
398 "<fbx-root>".to_string()
399 } else {
400 format!("node{}", node.element.typed_id)
401 }
402 } else {
403 node.element.name.to_string()
404 };
405 bones.push(Bone {
406 name,
407 parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
408 rest: transform(&node.local_transform),
409 inverse_bind: None,
410 });
411 }
412 for cluster in &scene.skin_clusters {
413 if let (Some(bone_node), Some((bone_inverse, _))) =
414 (&cluster.bone_node, project_cluster_bind(cluster))
415 {
416 let id = bone_node.element.typed_id as usize;
417 if id < bones.len() {
418 bones[id].inverse_bind = Some(bone_inverse);
422 }
423 }
424 }
425
426 let mut clips = Vec::new();
427 for (index, stack) in scene.anim_stacks.iter().enumerate() {
428 let take = if stack.element.name.is_empty() {
429 format!("take{index}")
430 } else {
431 stack.element.name.to_string()
432 };
433 let baked = ufbx::bake_anim(
434 &scene,
435 &stack.anim,
436 ufbx::BakeOpts {
437 trim_start_time: true,
438 ..Default::default()
439 },
440 )
441 .map_err(|e| LoadError::Bake {
442 take: take.clone(),
443 message: format!("{e:?}"),
444 })?;
445
446 let mut tracks = Vec::new();
447 let mut duration = 0.0f64;
448 for node in &baked.nodes {
449 let bone = node.typed_id as usize;
450 if !node.translation_keys.is_empty() {
451 let times: Vec<f32> = node
452 .translation_keys
453 .iter()
454 .map(|k| k.time as f32)
455 .collect();
456 let values: Vec<Vec3> = node
457 .translation_keys
458 .iter()
459 .map(|k| vec3(k.value))
460 .collect();
461 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
462 tracks.push(Track {
463 bone,
464 property: Property::Translation,
465 interpolation: Interpolation::Linear,
466 times,
467 values: TrackValues::Vec3s(values),
468 });
469 }
470 if !node.rotation_keys.is_empty() {
471 let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
472 let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
473 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
474 tracks.push(Track {
475 bone,
476 property: Property::Rotation,
477 interpolation: Interpolation::Linear,
478 times,
479 values: TrackValues::Quats(values),
480 });
481 }
482 if !node.scale_keys.is_empty() {
483 let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
484 let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
485 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
486 tracks.push(Track {
487 bone,
488 property: Property::Scale,
489 interpolation: Interpolation::Linear,
490 times,
491 values: TrackValues::Vec3s(values),
492 });
493 }
494 }
495 clips.push(Clip {
496 name: take,
497 duration_s: duration,
498 tracks,
499 });
500 }
501
502 let construct_counts = source_facts::construct_counts(&scene);
503 let raw_facts = source_facts::project(&scene, construct_counts, bytes);
504 let (dependency_closure, resource_capture) =
505 capture_dependency_closure(&scene, &raw_facts, resource_root)?;
506 let (assets, conversion) = extract_assets(&scene, &resource_capture);
507 let inventory = capability::inventory(&scene, &conversion, construct_counts);
508
509 let document = Document {
510 skeleton: Skeleton { bones },
511 clips,
512 assets,
513 source: SourceInfo {
514 path: Some(path.display().to_string()),
515 format: Some("fbx".into()),
516 },
517 };
518 let source = raw_facts.finish_with_dependency_closure(document, dependency_closure)?;
519
520 Ok(FbxScaleSource {
521 source,
522 inventory,
523 rest_bind_construct_counts: construct_counts.rest_bind,
524 })
525}
526
527#[derive(Debug, Clone, Copy)]
529enum ExternalCaptureOutcome {
530 Captured,
531 Refused(DependencyResourceRefusalReasonV1),
532 Unavailable(DependencyResourceUnavailableReasonV1),
533}
534
535#[derive(Debug, Default)]
537struct FbxResourceCapture {
538 outcomes: BTreeMap<DependencyResourceKeyV1, ExternalCaptureOutcome>,
539 bytes_by_key: BTreeMap<DependencyResourceKeyV1, Vec<u8>>,
540 texture_keys: BTreeMap<u64, DependencyResourceKeyV1>,
541}
542
543impl FbxResourceCapture {
544 fn record_resource_key(
545 &mut self,
546 kind: SourceResourceKindV1,
547 source_index: u64,
548 key: &DependencyResourceKeyV1,
549 outcome: ExternalCaptureOutcome,
550 ) {
551 if kind == SourceResourceKindV1::Texture
552 && matches!(outcome, ExternalCaptureOutcome::Captured)
553 {
554 self.texture_keys.insert(source_index, key.clone());
555 }
556 }
557
558 fn texture_bytes(&self, source_index: u64) -> Option<&[u8]> {
559 self.texture_keys
560 .get(&source_index)
561 .and_then(|key| self.bytes_by_key.get(key))
562 .map(Vec::as_slice)
563 }
564}
565
566#[derive(Debug)]
568enum RootedCaptureError {
569 Refused(DependencyResourceRefusalReasonV1),
570 Unavailable(DependencyResourceUnavailableReasonV1),
571}
572
573fn capture_dependency_closure(
575 scene: &ufbx::Scene,
576 facts: &RawSourceFactsBuilderV1,
577 resource_root: Option<&Path>,
578) -> Result<(animsmith_core::DependencyClosureV1, FbxResourceCapture), SourceFactsError> {
579 let mut closure = DependencyClosureBuilderV1::new(
580 facts.primary_identity().clone(),
581 facts.resource_coverage(),
582 facts.resource_rows().len(),
583 );
584 if !scene.audio_clips.is_empty() {
585 closure.mark_unmodeled_resource_domain();
591 }
592
593 let mut capture = FbxResourceCapture::default();
594 for resource in facts.resource_rows() {
595 if !capture_reference(resource, resource_root, &mut closure, &mut capture)? {
596 break;
597 }
598 }
599 Ok((closure.finish()?, capture))
600}
601
602fn capture_reference(
603 resource: &SourceResourceReferenceV1,
604 resource_root: Option<&Path>,
605 closure: &mut DependencyClosureBuilderV1,
606 capture: &mut FbxResourceCapture,
607) -> Result<bool, SourceFactsError> {
608 let order = resource.source_order_index();
609 let kind = resource.kind();
610 let source_index = resource.source_index();
611 match resource.locator() {
612 SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri => {
613 if !closure.begin_reference(0, 0) {
614 return Ok(false);
615 }
616 closure.push_primary(order, kind, source_index)?;
617 }
618 SourceResourceLocatorV1::Relative(locator) => {
619 if !closure.begin_reference(
620 locator.as_str().len(),
621 DependencyResourceKeyV1::source_component_count(locator),
622 ) {
623 return Ok(false);
624 }
625 let key = match DependencyResourceKeyV1::from_relative(
626 locator,
627 ResourceKeySyntaxV1::ParserRelativePath,
628 ) {
629 Ok(key) => key,
630 Err(animsmith_core::DependencyClosureError::ResourceKeyTooLong { .. }) => {
631 closure.push_refused(
632 order,
633 kind,
634 source_index,
635 DependencyResourceRefusalReasonV1::Oversized,
636 )?;
637 return Ok(true);
638 }
639 Err(_) => {
640 closure.push_refused(
641 order,
642 kind,
643 source_index,
644 DependencyResourceRefusalReasonV1::Malformed,
645 )?;
646 return Ok(true);
647 }
648 };
649 match closure.prepare_external_key(&key)? {
650 None => return Ok(false),
651 Some(false) => {
652 let outcome =
653 capture.outcomes.get(&key).copied().ok_or(
654 animsmith_core::DependencyClosureError::ExternalIdentityMissing,
655 )?;
656 match outcome {
657 ExternalCaptureOutcome::Captured => {
658 closure.push_external_alias(order, kind, source_index, key.clone())?;
659 }
660 ExternalCaptureOutcome::Refused(reason) => {
661 closure.push_refused(order, kind, source_index, reason)?;
662 }
663 ExternalCaptureOutcome::Unavailable(reason) => {
664 closure.push_unavailable(
665 order,
666 kind,
667 source_index,
668 Some(key.clone()),
669 reason,
670 )?;
671 }
672 }
673 capture.record_resource_key(kind, source_index, &key, outcome);
674 }
675 Some(true) => {
676 let outcome = match resource_root {
677 None => ExternalCaptureOutcome::Unavailable(
678 DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
679 ),
680 Some(root) => {
681 let byte_limit = closure
682 .max_resource_bytes()
683 .min(closure.remaining_external_bytes());
684 let read = match checked_rooted_resource_path(root, &key) {
689 Ok(path) => {
690 closure.record_external_open_attempt(&key)?;
694 read_file_bounded_path(path, byte_limit)
695 }
696 Err(error) => Err(error),
697 };
698 match read {
699 Ok(bytes) => {
700 let identity = InputIdentity::from_bytes(&bytes);
701 if !closure.push_captured_external(
702 order,
703 kind,
704 source_index,
705 key.clone(),
706 identity,
707 )? {
708 return Ok(false);
709 }
710 capture.bytes_by_key.insert(key.clone(), bytes);
711 ExternalCaptureOutcome::Captured
712 }
713 Err(RootedCaptureError::Refused(reason)) => {
714 closure.push_refused(order, kind, source_index, reason)?;
715 ExternalCaptureOutcome::Refused(reason)
716 }
717 Err(RootedCaptureError::Unavailable(reason)) => {
718 closure.push_unavailable(
719 order,
720 kind,
721 source_index,
722 Some(key.clone()),
723 reason,
724 )?;
725 ExternalCaptureOutcome::Unavailable(reason)
726 }
727 }
728 }
729 };
730 if resource_root.is_none() {
731 closure.push_unavailable(
732 order,
733 kind,
734 source_index,
735 Some(key.clone()),
736 DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
737 )?;
738 }
739 capture.outcomes.insert(key.clone(), outcome);
740 capture.record_resource_key(kind, source_index, &key, outcome);
741 }
742 }
743 }
744 locator => {
745 if !closure.begin_reference(0, 0) {
746 return Ok(false);
747 }
748 let reason = match locator {
749 SourceResourceLocatorV1::Absolute => DependencyResourceRefusalReasonV1::Absolute,
750 SourceResourceLocatorV1::Escaping => DependencyResourceRefusalReasonV1::Escaping,
751 SourceResourceLocatorV1::Remote => DependencyResourceRefusalReasonV1::Remote,
752 SourceResourceLocatorV1::Malformed => DependencyResourceRefusalReasonV1::Malformed,
753 SourceResourceLocatorV1::Oversized => DependencyResourceRefusalReasonV1::Oversized,
756 SourceResourceLocatorV1::Missing => {
757 closure.push_unavailable(
758 order,
759 kind,
760 source_index,
761 None,
762 DependencyResourceUnavailableReasonV1::Missing,
763 )?;
764 return Ok(true);
765 }
766 SourceResourceLocatorV1::Embedded
767 | SourceResourceLocatorV1::DataUri
768 | SourceResourceLocatorV1::Relative(_) => unreachable!(),
769 };
770 closure.push_refused(order, kind, source_index, reason)?;
771 }
772 }
773 Ok(true)
774}
775
776fn checked_rooted_resource_path(
782 root: &Path,
783 key: &DependencyResourceKeyV1,
784) -> Result<PathBuf, RootedCaptureError> {
785 let root_metadata = std::fs::symlink_metadata(root).map_err(root_metadata_error)?;
786 if root_metadata.file_type().is_symlink() {
787 return Err(RootedCaptureError::Refused(
788 DependencyResourceRefusalReasonV1::Symlink,
789 ));
790 }
791 if !root_metadata.is_dir() {
792 return Err(RootedCaptureError::Unavailable(
793 DependencyResourceUnavailableReasonV1::Unreadable,
794 ));
795 }
796
797 let mut path = PathBuf::from(root);
798 let component_count = key.as_str().split('/').count();
799 for (index, component) in key.as_str().split('/').enumerate() {
800 path.push(component);
801 let metadata = std::fs::symlink_metadata(&path).map_err(resource_metadata_error)?;
802 if metadata.file_type().is_symlink() {
803 return Err(RootedCaptureError::Refused(
804 DependencyResourceRefusalReasonV1::Symlink,
805 ));
806 }
807 if index + 1 < component_count && !metadata.is_dir() {
808 return Err(RootedCaptureError::Unavailable(
809 DependencyResourceUnavailableReasonV1::Unreadable,
810 ));
811 }
812 if index + 1 == component_count && !metadata.is_file() {
813 return Err(RootedCaptureError::Unavailable(
817 DependencyResourceUnavailableReasonV1::Unreadable,
818 ));
819 }
820 }
821 Ok(path)
822}
823
824fn root_metadata_error(error: std::io::Error) -> RootedCaptureError {
825 let reason = if error.kind() == std::io::ErrorKind::NotFound {
826 DependencyResourceUnavailableReasonV1::ResourceRootUnavailable
827 } else {
828 DependencyResourceUnavailableReasonV1::Unreadable
829 };
830 RootedCaptureError::Unavailable(reason)
831}
832
833fn resource_metadata_error(error: std::io::Error) -> RootedCaptureError {
834 let reason = if error.kind() == std::io::ErrorKind::NotFound {
835 DependencyResourceUnavailableReasonV1::Missing
836 } else {
837 DependencyResourceUnavailableReasonV1::Unreadable
838 };
839 RootedCaptureError::Unavailable(reason)
840}
841
842fn read_file_bounded_path(path: PathBuf, byte_limit: u64) -> Result<Vec<u8>, RootedCaptureError> {
843 let mut file = File::open(path).map_err(resource_metadata_error)?;
844 let limit = usize::try_from(byte_limit).map_err(|_| {
845 RootedCaptureError::Unavailable(
846 DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
847 )
848 })?;
849 let mut bytes = Vec::new();
850 bytes.try_reserve(limit.min(8 * 1024)).map_err(|_| {
851 RootedCaptureError::Unavailable(DependencyResourceUnavailableReasonV1::Unreadable)
852 })?;
853 file.by_ref()
854 .take(byte_limit.saturating_add(1))
855 .read_to_end(&mut bytes)
856 .map_err(|_| {
857 RootedCaptureError::Unavailable(DependencyResourceUnavailableReasonV1::Unreadable)
858 })?;
859 Ok(bytes)
860}
861
862#[derive(Debug)]
863struct AssetTextureMaterializer {
864 remaining: usize,
865}
866
867impl Default for AssetTextureMaterializer {
868 fn default() -> Self {
869 Self {
870 remaining: FBX_MAX_ASSET_TEXTURE_BYTES,
871 }
872 }
873}
874
875impl AssetTextureMaterializer {
876 fn materialize(&mut self, bytes: &[u8], mime: &'static str) -> Option<TextureAsset> {
877 if bytes.len() > self.remaining {
878 return None;
879 }
880 let mut retained = Vec::new();
881 retained.try_reserve_exact(bytes.len()).ok()?;
882 retained.extend_from_slice(bytes);
883 self.remaining -= bytes.len();
884 Some(TextureAsset {
885 bytes: retained,
886 mime: mime.into(),
887 })
888 }
889}
890
891#[cfg(test)]
892mod asset_materializer_tests {
893 use super::{AssetTextureMaterializer, FBX_MAX_ASSET_TEXTURE_BYTES};
894 use std::io::Write;
895
896 #[test]
897 fn aliases_cannot_multiply_retained_texture_bytes_past_the_cap() {
898 let bytes = [7u8; 4];
899 let mut materializer = AssetTextureMaterializer { remaining: 8 };
900
901 assert!(materializer.materialize(&bytes, "image/png").is_some());
902 assert!(materializer.materialize(&bytes, "image/png").is_some());
903 assert!(materializer.materialize(&bytes, "image/png").is_none());
904 assert_eq!(materializer.remaining, 0);
905 assert_eq!(
906 AssetTextureMaterializer::default().remaining,
907 FBX_MAX_ASSET_TEXTURE_BYTES
908 );
909 }
910
911 #[test]
912 fn bounded_reader_returns_the_cap_plus_one_budget_witness() {
913 let mut file = tempfile::NamedTempFile::new().expect("temporary resource file");
914 file.write_all(&[1, 2, 3]).expect("write resource bytes");
915 file.flush().expect("flush resource bytes");
916
917 let bytes = super::read_file_bounded_path(file.path().to_path_buf(), 2)
918 .expect("bounded read succeeds");
919 assert_eq!(bytes, [1, 2, 3]);
920 }
921}
922
923fn texture_asset(
926 texture: &ufbx::Texture,
927 capture: &FbxResourceCapture,
928 materializer: &mut AssetTextureMaterializer,
929) -> Option<TextureAsset> {
930 let bytes: &[u8] = if !texture.content.is_empty() {
931 texture.content.as_ref()
932 } else {
933 capture.texture_bytes(u64::from(texture.element.typed_id))?
934 };
935 let mime = match bytes.get(..3) {
936 Some([0x89, b'P', b'N']) => "image/png",
937 Some([0xFF, 0xD8, _]) => "image/jpeg",
938 _ => return None,
939 };
940 materializer.materialize(bytes, mime)
941}
942
943fn base_color_texture(
944 material: &ufbx::Material,
945 capture: &FbxResourceCapture,
946 materializer: &mut AssetTextureMaterializer,
947) -> Option<TextureAsset> {
948 let texture = material.pbr.base_color.texture.as_ref().or(material
949 .fbx
950 .diffuse_color
951 .texture
952 .as_ref())?;
953 texture_asset(texture, capture, materializer)
954}
955
956fn normal_texture(
957 material: &ufbx::Material,
958 capture: &FbxResourceCapture,
959 materializer: &mut AssetTextureMaterializer,
960) -> Option<NormalTextureAsset> {
961 let texture = material.pbr.normal_map.texture.as_ref().or(material
962 .fbx
963 .normal_map
964 .texture
965 .as_ref())?;
966 texture_asset(texture, capture, materializer).map(|texture| NormalTextureAsset {
967 texture,
968 scale: 1.0,
972 })
973}
974
975#[derive(Debug, Clone, Copy, PartialEq)]
976enum ProjectedInfluence {
977 Absent,
978 Retained(u16, f32),
979 Rejected,
980}
981
982fn project_skin_influence(
983 source_weight: f64,
984 cluster_index: Option<usize>,
985 cluster_count: usize,
986 cluster_has_bone: bool,
987) -> ProjectedInfluence {
988 let weight = source_weight as f32;
989 if !source_weight.is_finite()
990 || source_weight < 0.0
991 || !weight.is_finite()
992 || (source_weight > 0.0 && weight == 0.0)
993 {
994 return ProjectedInfluence::Rejected;
995 }
996 if weight == 0.0 {
997 return ProjectedInfluence::Absent;
998 }
999 let Some(cluster_index) = cluster_index else {
1000 return ProjectedInfluence::Rejected;
1001 };
1002 if cluster_index >= cluster_count || !cluster_has_bone {
1003 return ProjectedInfluence::Rejected;
1004 }
1005 match u16::try_from(cluster_index) {
1006 Ok(index) => ProjectedInfluence::Retained(index, weight),
1007 Err(_) => ProjectedInfluence::Rejected,
1008 }
1009}
1010
1011fn extract_source_skeleton(scene: &ufbx::Scene) -> SourceSkeletonAssets {
1016 let nodes = scene
1017 .nodes
1018 .iter()
1019 .map(|node| {
1020 let mut source = SourceNodeAsset::new(
1021 node.element.typed_id as usize,
1022 SourceNodeLocalRest::Trs {
1023 translation: vec3(node.local_transform.translation),
1024 rotation: quat(node.local_transform.rotation),
1025 scale: vec3(node.local_transform.scale),
1026 },
1027 );
1028 source.name = (!node.element.name.is_empty()).then(|| node.element.name.to_string());
1029 source.parent_source_node_index = node
1030 .parent
1031 .as_ref()
1032 .map(|parent| parent.element.typed_id as usize);
1033 source.scene_root_indices = if node.is_root { vec![0] } else { Vec::new() };
1034 source.bone = Some(node.element.typed_id as usize);
1035 source
1036 })
1037 .collect();
1038
1039 if scene.skin_clusters.iter().any(|cluster| {
1045 cluster.bone_node.as_ref().is_none_or(|bone| {
1046 usize::try_from(bone.element.typed_id)
1047 .ok()
1048 .is_none_or(|index| index >= scene.nodes.len())
1049 })
1050 }) {
1051 return SourceSkeletonAssets::default();
1052 }
1053
1054 let mut attachments = vec![Vec::new(); scene.skin_deformers.len()];
1055 for node in &scene.nodes {
1056 let Some(mesh) = &node.mesh else { continue };
1057 for skin in &mesh.skin_deformers {
1058 let Some(for_skin) = attachments.get_mut(skin.element.typed_id as usize) else {
1059 return SourceSkeletonAssets::default();
1060 };
1061 for_skin.push(SourceSkinAttachment {
1062 source_node_index: node.element.typed_id as usize,
1063 source_mesh_index: Some(mesh.element.typed_id as usize),
1064 });
1065 }
1066 }
1067
1068 let skins = scene
1069 .skin_deformers
1070 .iter()
1071 .map(|skin| {
1072 let source_skin_index = skin.element.typed_id as usize;
1073 let projected_matrices = skin
1074 .clusters
1075 .iter()
1076 .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
1077 .collect::<Option<Vec<_>>>();
1078 let (status, matrices) = match (skin.clusters.is_empty(), projected_matrices) {
1079 (true, _) => (SourceInverseBindAccessorStatus::Absent, Vec::new()),
1080 (false, Some(matrices)) => (SourceInverseBindAccessorStatus::Available, matrices),
1081 (false, None) => (SourceInverseBindAccessorStatus::Unreadable, Vec::new()),
1084 };
1085 SourceSkinAsset {
1086 source_skin_index,
1087 name: (!skin.element.name.is_empty()).then(|| skin.element.name.to_string()),
1088 skeleton_root_source_node_index: None,
1091 joint_source_node_indices: skin
1092 .clusters
1093 .iter()
1094 .filter_map(|cluster| {
1095 cluster
1096 .bone_node
1097 .as_ref()
1098 .map(|node| node.element.typed_id as usize)
1099 })
1100 .collect(),
1101 inverse_bind_accessor: SourceInverseBindAccessor {
1102 status,
1103 declared_count: (!skin.clusters.is_empty()).then_some(skin.clusters.len()),
1104 matrices,
1105 },
1106 attachments: attachments
1107 .get_mut(source_skin_index)
1108 .map(std::mem::take)
1109 .unwrap_or_default(),
1110 }
1111 })
1112 .collect();
1113
1114 SourceSkeletonAssets {
1115 coverage: SourceSkeletonCoverage::Complete,
1116 nodes,
1117 skins,
1118 }
1119}
1120
1121fn extract_assets(
1126 scene: &ufbx::Scene,
1127 capture: &FbxResourceCapture,
1128) -> (SceneAssets, AssetConversionFacts) {
1129 let mut assets = SceneAssets::default();
1130 let mut conversion = AssetConversionFacts::default();
1131 let mut materializer = AssetTextureMaterializer::default();
1132 let mut material_index: std::collections::BTreeMap<u32, usize> =
1133 std::collections::BTreeMap::new();
1134 let mut normalized_mesh_index_by_source = std::collections::BTreeMap::<u32, usize>::new();
1135
1136 for (source_node_index, node) in scene.nodes.iter().enumerate() {
1137 let Some(mesh) = &node.mesh else { continue };
1138 let node_id = node.element.typed_id as usize;
1139
1140 let local_materials: Vec<usize> = mesh
1142 .materials
1143 .iter()
1144 .map(|m| {
1145 *material_index
1146 .entry(m.element.element_id)
1147 .or_insert_with(|| {
1148 let base = if m.pbr.base_color.has_value {
1149 m.pbr.base_color.value_vec4
1150 } else {
1151 m.fbx.diffuse_color.value_vec4
1152 };
1153 let texture = base_color_texture(m, capture, &mut materializer);
1154 let normal_texture = normal_texture(m, capture, &mut materializer);
1155 assets.materials.push(MaterialAsset {
1156 name: m.element.name.to_string(),
1157 base_color: if texture.is_some() {
1160 [1.0, 1.0, 1.0, 1.0]
1161 } else {
1162 [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
1163 },
1164 metallic: if m.pbr.metalness.has_value {
1165 m.pbr.metalness.value_vec4.x as f32
1166 } else {
1167 0.0
1168 },
1169 roughness: if m.pbr.roughness.has_value {
1170 m.pbr.roughness.value_vec4.x as f32
1171 } else {
1172 1.0
1173 },
1174 base_color_texture: texture,
1175 normal_texture,
1176 metallic_roughness_texture: None,
1177 occlusion_texture: None,
1178 });
1179 assets.materials.len() - 1
1180 })
1181 })
1182 .collect();
1183
1184 let skin = mesh.skin_deformers.first();
1187 let skin_joints: Vec<usize> = skin
1188 .map(|s| {
1189 s.clusters
1190 .iter()
1191 .map(|c| {
1192 c.bone_node
1193 .as_ref()
1194 .map(|b| b.element.typed_id as usize)
1195 .unwrap_or(0)
1196 })
1197 .collect()
1198 })
1199 .unwrap_or_default();
1200 let skin_ibms: Vec<glam::Mat4> = skin
1204 .and_then(|s| {
1205 s.clusters
1206 .iter()
1207 .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
1208 .collect::<Option<Vec<_>>>()
1209 })
1210 .unwrap_or_default();
1211 if let Some(&normalized_mesh_index) =
1212 normalized_mesh_index_by_source.get(&mesh.element.typed_id)
1213 {
1214 assets.instances.push(MeshInstance {
1215 source_node_index,
1216 node: node_id,
1217 mesh: normalized_mesh_index,
1218 skin_joints,
1219 skin_ibms,
1220 });
1221 continue;
1222 }
1223 let vertex_influences: Vec<Option<([u16; 4], [f32; 4])>> = skin
1224 .map(|s| {
1225 (0..mesh.num_vertices)
1226 .map(|v| {
1227 let mut pairs: Vec<(u16, f32)> = Vec::new();
1228 if let Some(sv) = s.vertices.get(v) {
1229 let begin = sv.weight_begin as usize;
1230 let end = begin.saturating_add(sv.num_weights as usize);
1231 for sw in s.weights.get(begin..end).unwrap_or_default() {
1232 let source_weight = sw.weight;
1233 let cluster_index = usize::try_from(sw.cluster_index).ok();
1234 let cluster_has_bone = cluster_index
1235 .and_then(|index| s.clusters.get(index))
1236 .is_some_and(|cluster| cluster.bone_node.is_some());
1237 match project_skin_influence(
1238 source_weight,
1239 cluster_index,
1240 s.clusters.len(),
1241 cluster_has_bone,
1242 ) {
1243 ProjectedInfluence::Absent => {}
1244 ProjectedInfluence::Retained(index, weight) => {
1245 pairs.push((index, weight));
1246 }
1247 ProjectedInfluence::Rejected => {
1248 conversion.rejected_influence_count += 1;
1249 }
1250 }
1251 }
1252 }
1253 pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
1254 if pairs.len() > 4 {
1255 conversion.truncated_influence_vertex_count += 1;
1256 conversion.discarded_influence_count += pairs.len() - 4;
1257 }
1258 pairs.truncate(4);
1259 let total: f32 = pairs.iter().map(|p| p.1).sum();
1260 if pairs.is_empty() || !total.is_finite() || total <= 0.0 {
1261 return None;
1262 }
1263 let mut joints = [0u16; 4];
1264 let mut weights = [0f32; 4];
1265 let mut renormalized = false;
1266 for (slot, (j, w)) in pairs.into_iter().enumerate() {
1267 joints[slot] = j;
1268 let normalized = if total > 0.0 { w / total } else { 0.0 };
1269 renormalized |= normalized.to_bits() != w.to_bits();
1270 weights[slot] = normalized;
1271 }
1272 if renormalized {
1273 conversion.renormalized_influence_vertex_count += 1;
1274 }
1275 Some((joints, weights))
1276 })
1277 .collect()
1278 })
1279 .unwrap_or_default();
1280
1281 let slots = local_materials.len().max(1);
1283 let mut primitives: Vec<Primitive> = (0..slots)
1284 .map(|slot| Primitive {
1285 material: local_materials.get(slot).copied(),
1286 ..Primitive::default()
1287 })
1288 .collect();
1289
1290 let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
1291 for (face_index, &face) in mesh.faces.iter().enumerate() {
1292 let slot = mesh
1293 .face_material
1294 .get(face_index)
1295 .map(|&m| m as usize)
1296 .filter(|&m| m < slots)
1297 .unwrap_or(0);
1298 let prim = &mut primitives[slot];
1299 let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
1300 for &corner in &tri_indices[..tris * 3] {
1301 let corner = corner as usize;
1302 let p = mesh.vertex_position[corner];
1303 prim.positions
1304 .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
1305 if mesh.vertex_normal.exists {
1306 let n = mesh.vertex_normal[corner];
1307 prim.normals
1308 .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
1309 }
1310 if mesh.vertex_uv.exists {
1311 let uv = mesh.vertex_uv[corner];
1312 prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
1315 }
1316 if !vertex_influences.is_empty() {
1317 let vertex = mesh.vertex_indices[corner] as usize;
1318 let (joints, weights) = vertex_influences
1319 .get(vertex)
1320 .copied()
1321 .flatten()
1322 .unwrap_or_else(|| {
1323 conversion.missing_skin_influence_corner_count += 1;
1324 ([0; 4], [0.0; 4])
1325 });
1326 prim.joints.push(joints);
1327 prim.weights.push(weights);
1328 }
1329 }
1330 }
1331 primitives.retain(|p| !p.positions.is_empty());
1332 for prim in &mut primitives {
1333 conversion.pre_weld_vertex_count += prim.positions.len();
1334 prim.weld();
1335 conversion.post_weld_vertex_count += prim.positions.len();
1336 }
1337 if primitives.is_empty() {
1338 continue;
1339 }
1340 let normalized_mesh_index = assets.meshes.len();
1341 let source_mesh_index = mesh.element.typed_id as usize;
1342 normalized_mesh_index_by_source.insert(mesh.element.typed_id, normalized_mesh_index);
1343 assets.meshes.push(MeshAsset {
1344 name: mesh.element.name.to_string(),
1345 source_mesh_index,
1349 primitives,
1350 });
1351 assets.instances.push(MeshInstance {
1352 source_node_index,
1353 node: node_id,
1354 mesh: normalized_mesh_index,
1355 skin_joints,
1356 skin_ibms,
1357 });
1358 }
1359 assets.scenes.push(SceneAsset {
1360 source_scene_index: 0,
1361 name: None,
1362 roots: scene
1363 .nodes
1364 .iter()
1365 .filter(|node| node.is_root)
1366 .map(|node| node.element.typed_id as usize)
1367 .collect(),
1368 });
1369 assets.default_scene = Some(0);
1370 assets.source_skeleton = extract_source_skeleton(scene);
1371 (assets, conversion)
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 use super::{ProjectedInfluence, project_skin_influence};
1377
1378 #[test]
1379 fn influence_projection_checks_sign_range_and_u16_cluster_narrowing() {
1380 assert_eq!(
1381 project_skin_influence(0.0, Some(0), 1, true),
1382 ProjectedInfluence::Absent
1383 );
1384 assert_eq!(
1385 project_skin_influence(-0.25, Some(0), 1, true),
1386 ProjectedInfluence::Rejected
1387 );
1388 assert_eq!(
1389 project_skin_influence(1.0, Some(1), 1, true),
1390 ProjectedInfluence::Rejected,
1391 "a source cluster index outside the declaration must not survive"
1392 );
1393 assert_eq!(
1394 project_skin_influence(
1395 1.0,
1396 Some(usize::from(u16::MAX) + 1),
1397 usize::from(u16::MAX) + 2,
1398 true,
1399 ),
1400 ProjectedInfluence::Rejected,
1401 "u32/usize cluster identity must not wrap while narrowing to u16"
1402 );
1403 assert_eq!(
1404 project_skin_influence(0.5, Some(7), 8, true),
1405 ProjectedInfluence::Retained(7, 0.5)
1406 );
1407 }
1408}