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