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