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