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