1use animsmith_core::scale::{ScaleCapabilityCoverage, ScaleCapabilityFacts};
4use animsmith_core::{
5 DependencyClosureV1, Document, LoadedSource, SourceConstructKindV1, SourceFactsViewV1,
6 SourceResourceLocatorV1, SourceSetCoverageStateV1,
7};
8use serde::Serialize;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum FbxScaleDomainStatus {
17 Absent,
19 Normalized,
21 Baked,
23 Derived,
25 Rebuilt,
27 Unsupported,
29 Unverifiable,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct FbxScaleDomainInventory {
37 pub rest_hierarchy: FbxScaleDomainStatus,
39 pub translation_animation: FbxScaleDomainStatus,
41 pub rotation_and_scale_animation: FbxScaleDomainStatus,
43 pub root_motion_and_velocity: FbxScaleDomainStatus,
45 pub base_mesh_geometry: FbxScaleDomainStatus,
47 pub morphs: FbxScaleDomainStatus,
49 pub skin_binds: FbxScaleDomainStatus,
51 pub cameras_and_lights: FbxScaleDomainStatus,
53 pub collision_and_custom_data: FbxScaleDomainStatus,
55 pub other_vertex_and_source_data: FbxScaleDomainStatus,
57 pub out_of_contract_node_transforms: FbxScaleDomainStatus,
59 pub animation_targeting_matrix_nodes: FbxScaleDomainStatus,
61 pub shared_raw_accessor_payloads: FbxScaleDomainStatus,
63 pub unreferenced_accessor_payloads: FbxScaleDomainStatus,
65 pub image_payload_aliases: FbxScaleDomainStatus,
67}
68
69impl FbxScaleDomainInventory {
70 pub fn named_rows(&self) -> [(&'static str, FbxScaleDomainStatus); 15] {
76 [
77 ("Rest hierarchy", self.rest_hierarchy),
78 ("Translation animation", self.translation_animation),
79 (
80 "Rotation and scale animation",
81 self.rotation_and_scale_animation,
82 ),
83 ("Root motion and velocity", self.root_motion_and_velocity),
84 ("Base mesh geometry", self.base_mesh_geometry),
85 ("Morphs", self.morphs),
86 ("Skin binds", self.skin_binds),
87 ("Cameras/lights", self.cameras_and_lights),
88 ("Collision/custom data", self.collision_and_custom_data),
89 (
90 "Other vertex/source data",
91 self.other_vertex_and_source_data,
92 ),
93 (
94 "Out-of-contract node transforms",
95 self.out_of_contract_node_transforms,
96 ),
97 (
98 "Animation targeting a matrix node",
99 self.animation_targeting_matrix_nodes,
100 ),
101 (
102 "Shared raw accessor payloads",
103 self.shared_raw_accessor_payloads,
104 ),
105 (
106 "Unreferenced accessor payloads",
107 self.unreferenced_accessor_payloads,
108 ),
109 ("Image payload aliases", self.image_payload_aliases),
110 ]
111 }
112
113 fn rest_bind_semantic_statuses(&self) -> [(&'static str, FbxScaleDomainStatus); 11] {
122 [
123 ("rest_hierarchy", self.rest_hierarchy),
124 ("translation_animation", self.translation_animation),
125 (
126 "rotation_and_scale_animation",
127 self.rotation_and_scale_animation,
128 ),
129 ("root_motion_and_velocity", self.root_motion_and_velocity),
130 ("base_mesh_geometry", self.base_mesh_geometry),
131 ("morphs", self.morphs),
132 ("skin_binds", self.skin_binds),
133 ("cameras_and_lights", self.cameras_and_lights),
134 (
135 "other_vertex_and_source_data",
136 self.other_vertex_and_source_data,
137 ),
138 (
139 "out_of_contract_node_transforms",
140 self.out_of_contract_node_transforms,
141 ),
142 (
143 "animation_targeting_matrix_nodes",
144 self.animation_targeting_matrix_nodes,
145 ),
146 ]
147 }
148
149 fn rest_bind_raw_span_statuses(&self) -> [(&'static str, FbxScaleDomainStatus); 3] {
151 [
152 (
153 "shared_raw_accessor_payloads",
154 self.shared_raw_accessor_payloads,
155 ),
156 (
157 "unreferenced_accessor_payloads",
158 self.unreferenced_accessor_payloads,
159 ),
160 ("image_payload_aliases", self.image_payload_aliases),
161 ]
162 }
163
164 fn clip_track_semantic_statuses(&self) -> [(&'static str, FbxScaleDomainStatus); 6] {
171 [
172 ("rest_hierarchy", self.rest_hierarchy),
173 ("translation_animation", self.translation_animation),
174 (
175 "rotation_and_scale_animation",
176 self.rotation_and_scale_animation,
177 ),
178 ("root_motion_and_velocity", self.root_motion_and_velocity),
179 (
180 "out_of_contract_node_transforms",
181 self.out_of_contract_node_transforms,
182 ),
183 (
184 "animation_targeting_matrix_nodes",
185 self.animation_targeting_matrix_nodes,
186 ),
187 ]
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "kebab-case")]
194pub enum FbxCoordinateAxis {
195 PositiveX,
197 NegativeX,
199 PositiveY,
201 NegativeY,
203 PositiveZ,
205 NegativeZ,
207 Unknown,
209}
210
211impl From<ufbx::CoordinateAxis> for FbxCoordinateAxis {
212 fn from(value: ufbx::CoordinateAxis) -> Self {
213 match value {
214 ufbx::CoordinateAxis::PositiveX => Self::PositiveX,
215 ufbx::CoordinateAxis::NegativeX => Self::NegativeX,
216 ufbx::CoordinateAxis::PositiveY => Self::PositiveY,
217 ufbx::CoordinateAxis::NegativeY => Self::NegativeY,
218 ufbx::CoordinateAxis::PositiveZ => Self::PositiveZ,
219 ufbx::CoordinateAxis::NegativeZ => Self::NegativeZ,
220 ufbx::CoordinateAxis::Unknown => Self::Unknown,
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize)]
227#[serde(deny_unknown_fields)]
228pub struct FbxCoordinateNormalization {
229 pub original_up_axis: FbxCoordinateAxis,
233 pub original_unit_meters: f64,
237 pub target_right_handed_y_up: bool,
239 pub target_unit_meters: f64,
241 pub adjust_transforms: bool,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
247#[serde(deny_unknown_fields)]
248pub struct FbxSourceIdentity {
249 pub source_index: usize,
251 pub ufbx_typed_id: u32,
253 pub ufbx_element_id: u32,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum FbxBindMatrixProvenance {
264 UfbxConvertedClusterMatrices,
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize)]
278#[serde(deny_unknown_fields)]
279pub struct FbxScaleCapabilityInventory {
280 pub domains: FbxScaleDomainInventory,
282 pub coordinate_normalization: FbxCoordinateNormalization,
284 pub animation_takes_baked: bool,
286 pub authored_curve_keys_preserved: bool,
288 pub animation_take_count: usize,
290 pub source_animation_curve_count: usize,
292 pub generated_geometry_helper_node_count: usize,
294 pub generated_scale_helper_node_count: usize,
296 pub inherit_modes_compensated: bool,
298 pub compensated_inherit_node_count: usize,
300 pub generated_normal_mesh_count: usize,
302 pub missing_normal_mesh_count: usize,
304 pub skin_deformer_count: usize,
306 pub skin_cluster_count: usize,
308 pub empty_skin_deformer_count: usize,
310 pub bind_matrix_provenance: FbxBindMatrixProvenance,
312 pub incomplete_bind_cluster_count: usize,
314 pub bone_convenience_bind_overwrite_count: usize,
317 pub identity_bind_defaults_invented: bool,
319 pub truncated_influence_vertex_count: usize,
321 pub discarded_influence_count: usize,
323 pub renormalized_influence_vertex_count: usize,
325 pub rejected_influence_count: usize,
327 pub missing_skin_influence_corner_count: usize,
329 pub non_triangle_face_count: usize,
331 pub triangulated_face_count: usize,
333 pub omitted_non_polygon_face_count: usize,
335 pub empty_mesh_definition_count: usize,
337 pub empty_source_meshes: Vec<FbxSourceIdentity>,
339 pub pre_weld_vertex_count: usize,
341 pub post_weld_vertex_count: usize,
343 pub multiple_skin_deformer_mesh_count: usize,
345 pub dual_quaternion_skin_count: usize,
347 pub blend_deformer_count: usize,
349 pub blend_channel_count: usize,
351 pub blend_shape_count: usize,
353 pub cache_deformer_count: usize,
355 pub unsupported_vertex_payload_mesh_count: usize,
357 pub camera_count: usize,
359 pub light_count: usize,
361 pub shared_mesh_definition_count: usize,
363 pub uninstanced_mesh_definition_count: usize,
365 pub uninstanced_source_meshes: Vec<FbxSourceIdentity>,
367 pub user_defined_property_count: usize,
369 pub unsupported_source_element_count: usize,
371 pub external_resource_count: usize,
373 pub source_nodes: Vec<FbxSourceIdentity>,
375 pub source_meshes: Vec<FbxSourceIdentity>,
377 pub source_skins: Vec<FbxSourceIdentity>,
379}
380
381#[derive(Debug)]
384pub struct FbxScaleSource {
385 pub(crate) source: LoadedSource,
386 pub(crate) inventory: FbxScaleCapabilityInventory,
387 pub(crate) rest_bind_construct_counts: crate::source_facts::RestBindSourceConstructCounts,
394 pub(crate) rest_bind_scale_invariant_payload_mesh_count: usize,
397}
398
399impl FbxScaleSource {
400 pub fn document(&self) -> &Document {
402 self.source.document()
403 }
404
405 pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
407 self.source.source_facts()
408 }
409
410 pub fn dependency_closure(&self) -> &DependencyClosureV1 {
412 self.source.dependency_closure()
413 }
414
415 pub fn raw_transform_path_inventory(
420 &self,
421 ) -> Option<&animsmith_core::RawTransformPathInventoryV1> {
422 self.source.raw_transform_path_inventory()
423 }
424
425 pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
427 &self.inventory
428 }
429
430 pub fn into_document(self) -> Document {
432 self.source.into_document()
433 }
434
435 pub(crate) fn into_source(self) -> LoadedSource {
436 self.source
437 }
438}
439
440pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
447 let mut facts = ScaleCapabilityFacts::default();
448 facts.coverage = ScaleCapabilityCoverage::Complete;
449 let morph_source_present = inventory.blend_deformer_count > 0
450 || inventory.blend_channel_count > 0
451 || inventory.blend_shape_count > 0;
452 facts.morphs_present = morph_source_present;
453 facts.morph_weights_present = morph_source_present;
454 facts.cameras_present = inventory.camera_count > 0;
455 facts.lights_present = inventory.light_count > 0;
456 facts.instancing_present = inventory.shared_mesh_definition_count > 0;
457 facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
458 facts.extras_present = inventory.user_defined_property_count > 0;
459 facts.unknown_source_members_present = true;
463 facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
464 facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
465 > 0
466 || inventory.uninstanced_mesh_definition_count > 0
467 || inventory.empty_mesh_definition_count > 0
468 || inventory.multiple_skin_deformer_mesh_count > 0
469 || inventory.dual_quaternion_skin_count > 0
470 || inventory.cache_deformer_count > 0
471 || inventory.missing_skin_influence_corner_count > 0
472 || inventory.rejected_influence_count > 0
473 || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
474 facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
475 facts.inverse_bind_issues_present =
476 inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
477 facts.unsafe_accessor_layout_present = true;
481 facts.external_resources_present = inventory.external_resource_count > 0;
482 facts
483}
484
485pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
491 join_source_facts(source, capability_facts(source.inventory()))
492}
493
494fn join_source_facts(
495 source: &FbxScaleSource,
496 mut facts: ScaleCapabilityFacts,
497) -> ScaleCapabilityFacts {
498 let source_facts = source.source_facts();
499 if [
500 source_facts.constructs().coverage().state(),
501 source_facts.resources().coverage().state(),
502 ]
503 .into_iter()
504 .any(|state| state != SourceSetCoverageStateV1::Complete)
505 {
506 facts.coverage = ScaleCapabilityCoverage::Unavailable;
507 }
508 for row in source_facts.constructs().rows() {
509 match row.kind() {
510 SourceConstructKindV1::CustomProperty => facts.extras_present = true,
511 SourceConstructKindV1::UnknownElement => {
512 facts.unknown_source_members_present = true;
513 }
514 SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
515 }
516 }
517 if source_facts.resources().rows().iter().any(|row| {
518 !matches!(
519 row.locator(),
520 SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
521 )
522 }) {
523 facts.external_resources_present = true;
524 }
525 facts
526}
527
528pub fn rest_bind_capability_facts(
545 inventory: &FbxScaleCapabilityInventory,
546) -> Result<ScaleCapabilityFacts, String> {
547 if inventory.external_resource_count != 0 {
548 return Err(format_rest_bind_violations(
549 "capability inventory",
550 &[format!(
551 "external_resource_count={}",
552 inventory.external_resource_count
553 )],
554 ));
555 }
556 rest_bind_capability_facts_with_context(inventory, RestBindCapabilityContext::InventoryOnly)
557}
558
559#[derive(Debug, Clone, Copy)]
560enum RestBindCapabilityContext<'a> {
561 InventoryOnly,
562 CapturedSource {
563 counts: &'a crate::source_facts::RestBindSourceConstructCounts,
564 scale_invariant_payload_mesh_count: usize,
565 },
566}
567
568fn rest_bind_capability_facts_with_context(
569 inventory: &FbxScaleCapabilityInventory,
570 context: RestBindCapabilityContext<'_>,
571) -> Result<ScaleCapabilityFacts, String> {
572 let captured_source = match context {
573 RestBindCapabilityContext::InventoryOnly => None,
574 RestBindCapabilityContext::CapturedSource {
575 counts,
576 scale_invariant_payload_mesh_count,
577 } => Some((counts, scale_invariant_payload_mesh_count)),
578 };
579 let mut violations = Vec::new();
580 for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
581 if domain == "other_vertex_and_source_data" && captured_source.is_some() {
582 continue;
583 }
584 if matches!(
585 status,
586 FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
587 ) {
588 violations.push(format!(
589 "domain.{domain}={}",
590 fbx_scale_domain_status_name(status)
591 ));
592 }
593 }
594 for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
595 if status != FbxScaleDomainStatus::Unverifiable {
596 violations.push(format!(
597 "domain.{domain}={} (expected unverifiable)",
598 fbx_scale_domain_status_name(status)
599 ));
600 }
601 }
602
603 let expected_custom_status = if inventory.unsupported_source_element_count == 0
604 && inventory.user_defined_property_count == 0
605 {
606 FbxScaleDomainStatus::Absent
607 } else {
608 FbxScaleDomainStatus::Unsupported
609 };
610 if inventory.domains.collision_and_custom_data != expected_custom_status {
611 violations.push(format!(
612 "domain.collision_and_custom_data={} (expected {})",
613 fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
614 fbx_scale_domain_status_name(expected_custom_status)
615 ));
616 }
617 if let Some((source_counts, _)) = captured_source {
618 if inventory.user_defined_property_count != source_counts.user_defined_property_count {
619 violations.push(format!(
620 "user_defined_property_count={}!=source:{}",
621 inventory.user_defined_property_count, source_counts.user_defined_property_count
622 ));
623 }
624 let source_unmodeled_count = source_counts.total_unmodeled_element_count();
625 if inventory.unsupported_source_element_count != source_unmodeled_count {
626 violations.push(format!(
627 "unsupported_source_element_count={}!=source:{}",
628 inventory.unsupported_source_element_count, source_unmodeled_count
629 ));
630 }
631 push_unmodeled_element_violation(&mut violations, *source_counts);
632 } else {
633 push_nonzero_violation(
634 &mut violations,
635 "unsupported_source_element_count",
636 inventory.unsupported_source_element_count,
637 );
638 }
639
640 push_normalized_baked_animation_violations(&mut violations, inventory);
641 if inventory.identity_bind_defaults_invented {
642 violations.push("identity_bind_defaults_invented=true".into());
643 }
644 for (name, value) in [
645 ("blend_deformer_count", inventory.blend_deformer_count),
646 ("blend_channel_count", inventory.blend_channel_count),
647 ("blend_shape_count", inventory.blend_shape_count),
648 ("camera_count", inventory.camera_count),
649 ("light_count", inventory.light_count),
650 (
651 "shared_mesh_definition_count",
652 inventory.shared_mesh_definition_count,
653 ),
654 (
655 "uninstanced_mesh_definition_count",
656 inventory.uninstanced_mesh_definition_count,
657 ),
658 (
659 "empty_mesh_definition_count",
660 inventory.empty_mesh_definition_count,
661 ),
662 (
663 "multiple_skin_deformer_mesh_count",
664 inventory.multiple_skin_deformer_mesh_count,
665 ),
666 (
667 "dual_quaternion_skin_count",
668 inventory.dual_quaternion_skin_count,
669 ),
670 ("cache_deformer_count", inventory.cache_deformer_count),
671 (
672 "incomplete_bind_cluster_count",
673 inventory.incomplete_bind_cluster_count,
674 ),
675 (
676 "empty_skin_deformer_count",
677 inventory.empty_skin_deformer_count,
678 ),
679 (
680 "missing_normal_mesh_count",
681 inventory.missing_normal_mesh_count,
682 ),
683 (
684 "bone_convenience_bind_overwrite_count",
685 inventory.bone_convenience_bind_overwrite_count,
686 ),
687 (
688 "missing_skin_influence_corner_count",
689 inventory.missing_skin_influence_corner_count,
690 ),
691 (
692 "omitted_non_polygon_face_count",
693 inventory.omitted_non_polygon_face_count,
694 ),
695 ] {
696 push_nonzero_violation(&mut violations, name, value);
697 }
698 if let Some((_, scale_invariant_payload_mesh_count)) = captured_source {
699 if inventory.unsupported_vertex_payload_mesh_count != scale_invariant_payload_mesh_count {
700 violations.push(format!(
701 "unsupported_vertex_payload_mesh_count={}!=scale_invariant_source:{}",
702 inventory.unsupported_vertex_payload_mesh_count, scale_invariant_payload_mesh_count
703 ));
704 }
705 } else {
706 for (name, value) in [
707 (
708 "unsupported_vertex_payload_mesh_count",
709 inventory.unsupported_vertex_payload_mesh_count,
710 ),
711 (
712 "truncated_influence_vertex_count",
713 inventory.truncated_influence_vertex_count,
714 ),
715 (
716 "discarded_influence_count",
717 inventory.discarded_influence_count,
718 ),
719 (
720 "renormalized_influence_vertex_count",
721 inventory.renormalized_influence_vertex_count,
722 ),
723 (
724 "rejected_influence_count",
725 inventory.rejected_influence_count,
726 ),
727 ("non_triangle_face_count", inventory.non_triangle_face_count),
728 ("triangulated_face_count", inventory.triangulated_face_count),
729 ] {
730 push_nonzero_violation(&mut violations, name, value);
731 }
732 if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
733 violations.push(format!(
734 "weld_vertex_count={}!=post:{}",
735 inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
736 ));
737 }
738 }
739 if !violations.is_empty() {
740 return Err(format_rest_bind_violations(
741 "capability inventory",
742 &violations,
743 ));
744 }
745
746 let mut facts = capability_facts(inventory);
753 facts.unknown_source_members_present = false;
754 facts.unregistered_extensions_present = false;
755 facts.unsafe_accessor_layout_present = false;
756 facts.extras_present = false;
757 facts.external_resources_present = false;
758 if captured_source.is_some() {
759 facts.non_triangle_primitives_present = false;
760 facts.unsupported_vertex_attributes_present = false;
761 facts.secondary_skin_influences_present = false;
762 }
763 if !facts.is_supported_for(
764 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
765 source_skin_index: 0,
766 source_root_node_index: 0,
767 expected_factor: 1.0,
768 },
769 ) {
770 return Err(format_rest_bind_violations(
771 "projected capability",
772 &scale_capability_violations(&facts),
773 ));
774 }
775 Ok(facts)
776}
777
778fn require_supported_raw_source_facts(source: &FbxScaleSource) -> Result<(), String> {
785 let source_facts = source.source_facts();
786 let source_counts = source.rest_bind_construct_counts;
787 let mut violations = Vec::new();
788 for (domain, state) in [
789 ("constructs", source_facts.constructs().coverage().state()),
790 ("resources", source_facts.resources().coverage().state()),
791 ] {
792 if state != SourceSetCoverageStateV1::Complete {
793 violations.push(format!(
794 "raw_source.{domain}.coverage={}",
795 source_set_coverage_state_name(state)
796 ));
797 }
798 }
799 let mut saw_custom_properties = false;
800 let mut saw_unmodeled_elements = false;
801 for row in source_facts.constructs().rows() {
802 match row.kind() {
803 SourceConstructKindV1::CustomProperty
804 if row.name().as_str() == "fbx:user-defined-properties" =>
805 {
806 saw_custom_properties = true;
807 if row.count()
808 != u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
809 {
810 violations.push(format!(
811 "raw_source.construct=custom_property({}; count={})!=source:{}",
812 row.name().as_str(),
813 row.count(),
814 source_counts.user_defined_property_count
815 ));
816 }
817 }
818 SourceConstructKindV1::CustomProperty => violations.push(format!(
819 "raw_source.construct=custom_property({}; count={})",
820 row.name().as_str(),
821 row.count()
822 )),
823 SourceConstructKindV1::UnknownElement
824 if row.name().as_str() == "fbx:unmodeled-elements" =>
825 {
826 saw_unmodeled_elements = true;
827 let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
828 .unwrap_or(u64::MAX);
829 if row.count() != total_count {
830 violations.push(format!(
831 "raw_source.construct=unknown_element({}; count={})!=source:{}",
832 row.name().as_str(),
833 row.count(),
834 total_count
835 ));
836 } else if source_counts.unsupported_unmodeled_element_count() > 0 {
837 violations.push(format!(
838 "raw_source.construct=unknown_element({}; {})",
839 row.name().as_str(),
840 unmodeled_element_details(source_counts)
841 ));
842 }
843 }
844 SourceConstructKindV1::UnknownElement => violations.push(format!(
845 "raw_source.construct=unknown_element({}; count={})",
846 row.name().as_str(),
847 row.count()
848 )),
849 SourceConstructKindV1::Extension => violations.push(format!(
850 "raw_source.construct=extension({}; count={})",
851 row.name().as_str(),
852 row.count()
853 )),
854 }
855 }
856 if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
857 violations.push(format!(
858 "raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
859 source_counts.user_defined_property_count
860 ));
861 }
862 if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
863 violations.push(format!(
864 "raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
865 source_counts.total_unmodeled_element_count()
866 ));
867 }
868 if !violations.is_empty() {
869 return Err(format_rest_bind_violations("raw-source facts", &violations));
870 }
871 Ok(())
872}
873
874pub fn rest_bind_capability_facts_for_source(
886 source: &FbxScaleSource,
887) -> Result<ScaleCapabilityFacts, String> {
888 require_supported_raw_source_facts(source)?;
889 let source_counts = source.rest_bind_construct_counts;
890
891 let mut facts = join_source_facts(
892 source,
893 rest_bind_capability_facts_with_context(
894 source.inventory(),
895 RestBindCapabilityContext::CapturedSource {
896 counts: &source_counts,
897 scale_invariant_payload_mesh_count: source
898 .rest_bind_scale_invariant_payload_mesh_count,
899 },
900 )?,
901 );
902 facts.unknown_source_members_present = false;
903 facts.unregistered_extensions_present = false;
904 facts.extras_present = false;
905 facts.external_resources_present = false;
906 if facts.is_supported_for(
907 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
908 source_skin_index: 0,
909 source_root_node_index: 0,
910 expected_factor: 1.0,
911 },
912 ) {
913 Ok(facts)
914 } else {
915 Err(format_rest_bind_violations(
916 "joined capability",
917 &scale_capability_violations(&facts),
918 ))
919 }
920}
921
922pub fn require_clip_track_capability_for_source(source: &FbxScaleSource) -> Result<(), String> {
935 require_supported_raw_source_facts(source)?;
936 let inventory = source.inventory();
937 let mut violations = Vec::new();
938 for (domain, status) in inventory.domains.clip_track_semantic_statuses() {
939 if matches!(
940 status,
941 FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
942 ) {
943 violations.push(format!(
944 "domain.{domain}={}",
945 fbx_scale_domain_status_name(status)
946 ));
947 }
948 }
949 push_normalized_baked_animation_violations(&mut violations, inventory);
950 if !violations.is_empty() {
951 return Err(format_rest_bind_violations(
952 "clip-track capability inventory",
953 &violations,
954 ));
955 }
956
957 Ok(())
958}
959
960fn push_normalized_baked_animation_violations(
961 violations: &mut Vec<String>,
962 inventory: &FbxScaleCapabilityInventory,
963) {
964 if !inventory.coordinate_normalization.target_right_handed_y_up {
965 violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
966 }
967 if inventory.coordinate_normalization.target_unit_meters != 1.0 {
968 violations.push(format!(
969 "coordinate_normalization.target_unit_meters={}",
970 inventory.coordinate_normalization.target_unit_meters
971 ));
972 }
973 if !inventory.coordinate_normalization.adjust_transforms {
974 violations.push("coordinate_normalization.adjust_transforms=false".into());
975 }
976 if !inventory.animation_takes_baked {
977 violations.push("animation_takes_baked=false".into());
978 }
979 if inventory.authored_curve_keys_preserved {
980 violations.push("authored_curve_keys_preserved=true".into());
981 }
982 if !inventory.inherit_modes_compensated {
983 violations.push("inherit_modes_compensated=false".into());
984 }
985}
986
987fn push_unmodeled_element_violation(
988 violations: &mut Vec<String>,
989 counts: crate::source_facts::RestBindSourceConstructCounts,
990) {
991 if counts.unsupported_unmodeled_element_count() > 0 {
992 violations.push(format!(
993 "unsupported_source_element_count; {}",
994 unmodeled_element_details(counts)
995 ));
996 }
997}
998
999fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
1000 let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
1001 for (kind, count) in counts.unsupported_kind_counts() {
1002 details.push_str("; ");
1003 details.push_str(kind);
1004 details.push('=');
1005 details.push_str(&count.to_string());
1006 }
1007 details
1008}
1009
1010fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
1011 match status {
1012 FbxScaleDomainStatus::Absent => "absent",
1013 FbxScaleDomainStatus::Normalized => "normalized",
1014 FbxScaleDomainStatus::Baked => "baked",
1015 FbxScaleDomainStatus::Derived => "derived",
1016 FbxScaleDomainStatus::Rebuilt => "rebuilt",
1017 FbxScaleDomainStatus::Unsupported => "unsupported",
1018 FbxScaleDomainStatus::Unverifiable => "unverifiable",
1019 }
1020}
1021
1022fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
1023 match state {
1024 SourceSetCoverageStateV1::Complete => "complete",
1025 SourceSetCoverageStateV1::Partial => "partial",
1026 SourceSetCoverageStateV1::Unavailable => "unavailable",
1027 }
1028}
1029
1030fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
1031 if value > 0 {
1032 violations.push(format!("{name}={value}"));
1033 }
1034}
1035
1036fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
1037 format!(
1038 "FBX rest/bind {authority} rejected: {}",
1039 violations.join("; ")
1040 )
1041}
1042
1043fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
1044 let mut violations = Vec::new();
1045 if facts.coverage != ScaleCapabilityCoverage::Complete {
1046 violations.push("coverage=unavailable".into());
1047 }
1048 for (name, present) in [
1049 ("morphs_present", facts.morphs_present),
1050 ("morph_weights_present", facts.morph_weights_present),
1051 ("cameras_present", facts.cameras_present),
1052 ("lights_present", facts.lights_present),
1053 ("instancing_present", facts.instancing_present),
1054 (
1055 "unregistered_extensions_present",
1056 facts.unregistered_extensions_present,
1057 ),
1058 ("extras_present", facts.extras_present),
1059 (
1060 "unknown_source_members_present",
1061 facts.unknown_source_members_present,
1062 ),
1063 (
1064 "non_triangle_primitives_present",
1065 facts.non_triangle_primitives_present,
1066 ),
1067 (
1068 "unsupported_vertex_attributes_present",
1069 facts.unsupported_vertex_attributes_present,
1070 ),
1071 (
1072 "secondary_skin_influences_present",
1073 facts.secondary_skin_influences_present,
1074 ),
1075 (
1076 "inverse_bind_issues_present",
1077 facts.inverse_bind_issues_present,
1078 ),
1079 (
1080 "unsafe_accessor_layout_present",
1081 facts.unsafe_accessor_layout_present,
1082 ),
1083 (
1084 "external_resources_present",
1085 facts.external_resources_present,
1086 ),
1087 ] {
1088 if present {
1089 violations.push(format!("{name}=true"));
1090 }
1091 }
1092 violations
1093}
1094
1095#[derive(Debug, Default)]
1096pub(crate) struct AssetConversionFacts {
1097 pub(crate) truncated_influence_vertex_count: usize,
1098 pub(crate) discarded_influence_count: usize,
1099 pub(crate) renormalized_influence_vertex_count: usize,
1100 pub(crate) rejected_influence_count: usize,
1101 pub(crate) missing_skin_influence_corner_count: usize,
1102 pub(crate) pre_weld_vertex_count: usize,
1103 pub(crate) post_weld_vertex_count: usize,
1104}
1105
1106#[derive(Debug, Clone, Copy, Default)]
1107pub(crate) struct RestBindMeshPayloadCounts {
1108 pub(crate) unsupported_mesh_count: usize,
1109 pub(crate) scale_invariant_mesh_count: usize,
1110}
1111
1112fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
1113 FbxSourceIdentity {
1114 source_index: index,
1115 ufbx_typed_id: element.typed_id,
1116 ufbx_element_id: element.element_id,
1117 }
1118}
1119
1120pub(crate) fn inventory(
1121 scene: &ufbx::Scene,
1122 conversion: &AssetConversionFacts,
1123 construct_counts: crate::source_facts::SourceConstructCounts,
1124) -> (FbxScaleCapabilityInventory, RestBindMeshPayloadCounts) {
1125 let non_triangle_face_count = scene
1126 .meshes
1127 .iter()
1128 .flat_map(|mesh| mesh.faces.iter())
1129 .filter(|face| face.num_indices != 3)
1130 .count();
1131 let triangulated_face_count = scene
1132 .meshes
1133 .iter()
1134 .flat_map(|mesh| mesh.faces.iter())
1135 .filter(|face| face.num_indices > 3)
1136 .count();
1137 let omitted_non_polygon_face_count = scene
1138 .meshes
1139 .iter()
1140 .flat_map(|mesh| mesh.faces.iter())
1141 .filter(|face| face.num_indices < 3)
1142 .count();
1143 let empty_source_meshes = scene
1144 .meshes
1145 .iter()
1146 .enumerate()
1147 .filter(|(_, mesh)| mesh.faces.is_empty())
1148 .map(|(index, mesh)| identity(index, &mesh.element))
1149 .collect::<Vec<_>>();
1150 let empty_mesh_definition_count = empty_source_meshes.len();
1151 let generated_normal_mesh_count = scene
1152 .meshes
1153 .iter()
1154 .filter(|mesh| mesh.generated_normals)
1155 .count();
1156 let missing_normal_mesh_count = scene
1157 .meshes
1158 .iter()
1159 .filter(|mesh| !mesh.vertex_normal.exists)
1160 .count();
1161 let skin_cluster_count = scene
1162 .skin_deformers
1163 .iter()
1164 .map(|skin| skin.clusters.len())
1165 .sum();
1166 let empty_skin_deformer_count = scene
1167 .skin_deformers
1168 .iter()
1169 .filter(|skin| skin.clusters.is_empty())
1170 .count();
1171 let incomplete_bind_cluster_count = scene
1172 .skin_clusters
1173 .iter()
1174 .filter(|cluster| super::project_cluster_bind(cluster).is_none())
1175 .count();
1176 let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
1177 for cluster in &scene.skin_clusters {
1178 if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
1179 *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
1180 }
1181 }
1182 let bone_convenience_bind_overwrite_count = clusters_per_bone
1183 .values()
1184 .map(|count| count.saturating_sub(1))
1185 .sum();
1186 let multiple_skin_deformer_mesh_count = scene
1187 .meshes
1188 .iter()
1189 .filter(|mesh| mesh.skin_deformers.len() > 1)
1190 .count();
1191 let dual_quaternion_skin_count = scene
1192 .skin_deformers
1193 .iter()
1194 .filter(|skin| {
1195 skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
1196 })
1197 .count();
1198 let mesh_payload_counts = scene
1199 .meshes
1200 .iter()
1201 .map(|mesh| classify_mesh_source_payload(mesh))
1202 .fold(
1203 RestBindMeshPayloadCounts::default(),
1204 |mut counts, classification| {
1205 match classification {
1206 MeshSourcePayloadClassification::Absent => {}
1207 MeshSourcePayloadClassification::ScaleInvariantConversion => {
1208 counts.unsupported_mesh_count += 1;
1209 counts.scale_invariant_mesh_count += 1;
1210 }
1211 MeshSourcePayloadClassification::Unsupported => {
1212 counts.unsupported_mesh_count += 1;
1213 }
1214 }
1215 counts
1216 },
1217 );
1218 let unsupported_vertex_payload_mesh_count = mesh_payload_counts.unsupported_mesh_count;
1219 let shared_mesh_definition_count = scene
1220 .meshes
1221 .iter()
1222 .filter(|mesh| mesh.element.instances.len() > 1)
1223 .count();
1224 let uninstanced_source_meshes = scene
1225 .meshes
1226 .iter()
1227 .enumerate()
1228 .filter(|(_, mesh)| mesh.element.instances.is_empty())
1229 .map(|(index, mesh)| identity(index, &mesh.element))
1230 .collect::<Vec<_>>();
1231 let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
1232 let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
1233 let unsupported_source_element_count =
1234 construct_counts.rest_bind.total_unmodeled_element_count();
1235 let external_resource_count = scene
1236 .textures
1237 .iter()
1238 .filter(|texture| texture.content.is_empty() && texture.has_file)
1239 .count()
1240 + scene
1241 .videos
1242 .iter()
1243 .filter(|video| {
1244 video.content.is_empty()
1245 && (!video.filename.is_empty()
1246 || !video.relative_filename.is_empty()
1247 || !video.absolute_filename.is_empty())
1248 })
1249 .count();
1250 let compensated_inherit_node_count = scene
1251 .nodes
1252 .iter()
1253 .filter(|node| {
1254 node.original_inherit_mode != node.inherit_mode
1255 || node.is_scale_helper
1256 || node.is_scale_compensate_parent
1257 })
1258 .count();
1259
1260 let stackless_animation_present = scene.anim_stacks.is_empty()
1261 && (!scene.anim_layers.is_empty()
1262 || !scene.anim_values.is_empty()
1263 || !scene.anim_curves.is_empty());
1264 let animation = if !scene.anim_stacks.is_empty() {
1265 FbxScaleDomainStatus::Baked
1266 } else if stackless_animation_present {
1267 FbxScaleDomainStatus::Unsupported
1270 } else {
1271 FbxScaleDomainStatus::Absent
1272 };
1273 let domains = FbxScaleDomainInventory {
1274 rest_hierarchy: FbxScaleDomainStatus::Normalized,
1275 translation_animation: animation,
1276 rotation_and_scale_animation: animation,
1277 root_motion_and_velocity: match animation {
1278 FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
1279 status => status,
1280 },
1281 base_mesh_geometry: if scene.meshes.is_empty() {
1282 FbxScaleDomainStatus::Absent
1283 } else if uninstanced_mesh_definition_count > 0
1284 || omitted_non_polygon_face_count > 0
1285 || empty_mesh_definition_count > 0
1286 {
1287 FbxScaleDomainStatus::Unsupported
1288 } else {
1289 FbxScaleDomainStatus::Rebuilt
1290 },
1291 morphs: if scene.blend_deformers.is_empty()
1292 && scene.blend_channels.is_empty()
1293 && scene.blend_shapes.is_empty()
1294 {
1295 FbxScaleDomainStatus::Absent
1296 } else {
1297 FbxScaleDomainStatus::Unsupported
1298 },
1299 skin_binds: if scene.skin_deformers.is_empty() {
1300 FbxScaleDomainStatus::Absent
1301 } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
1302 FbxScaleDomainStatus::Unsupported
1303 } else {
1304 FbxScaleDomainStatus::Derived
1305 },
1306 cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
1307 FbxScaleDomainStatus::Absent
1308 } else {
1309 FbxScaleDomainStatus::Unsupported
1310 },
1311 collision_and_custom_data: if unsupported_source_element_count == 0
1312 && user_defined_property_count == 0
1313 {
1314 FbxScaleDomainStatus::Absent
1315 } else {
1316 FbxScaleDomainStatus::Unsupported
1317 },
1318 other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
1319 || uninstanced_mesh_definition_count > 0
1320 || omitted_non_polygon_face_count > 0
1321 || empty_mesh_definition_count > 0
1322 || multiple_skin_deformer_mesh_count > 0
1323 || dual_quaternion_skin_count > 0
1324 || conversion.truncated_influence_vertex_count > 0
1325 || conversion.missing_skin_influence_corner_count > 0
1326 || conversion.rejected_influence_count > 0
1327 || !scene.blend_deformers.is_empty()
1328 || !scene.blend_channels.is_empty()
1329 || !scene.blend_shapes.is_empty()
1330 || !scene.cache_deformers.is_empty()
1331 || !scene.cache_files.is_empty()
1332 {
1333 FbxScaleDomainStatus::Unsupported
1334 } else if !scene.meshes.is_empty() {
1335 FbxScaleDomainStatus::Rebuilt
1336 } else {
1337 FbxScaleDomainStatus::Absent
1338 },
1339 out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
1340 animation_targeting_matrix_nodes: animation,
1341 shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1342 unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1343 image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
1344 };
1345
1346 let inventory = FbxScaleCapabilityInventory {
1347 domains,
1348 coordinate_normalization: FbxCoordinateNormalization {
1349 original_up_axis: scene.settings.original_axis_up.into(),
1350 original_unit_meters: scene.settings.original_unit_meters,
1351 target_right_handed_y_up: true,
1352 target_unit_meters: 1.0,
1353 adjust_transforms: matches!(
1354 scene.metadata.space_conversion,
1355 ufbx::SpaceConversion::AdjustTransforms
1356 ),
1357 },
1358 animation_takes_baked: true,
1359 authored_curve_keys_preserved: false,
1360 animation_take_count: scene.anim_stacks.len(),
1361 source_animation_curve_count: scene.anim_curves.len(),
1362 generated_geometry_helper_node_count: scene
1363 .nodes
1364 .iter()
1365 .filter(|node| node.is_geometry_transform_helper)
1366 .count(),
1367 generated_scale_helper_node_count: scene
1368 .nodes
1369 .iter()
1370 .filter(|node| node.is_scale_helper)
1371 .count(),
1372 inherit_modes_compensated: matches!(
1373 scene.metadata.inherit_mode_handling,
1374 ufbx::InheritModeHandling::Compensate
1375 ),
1376 compensated_inherit_node_count,
1377 generated_normal_mesh_count,
1378 missing_normal_mesh_count,
1379 skin_deformer_count: scene.skin_deformers.len(),
1380 skin_cluster_count,
1381 empty_skin_deformer_count,
1382 bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
1383 incomplete_bind_cluster_count,
1384 bone_convenience_bind_overwrite_count,
1385 identity_bind_defaults_invented: false,
1386 truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
1387 discarded_influence_count: conversion.discarded_influence_count,
1388 renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
1389 rejected_influence_count: conversion.rejected_influence_count,
1390 missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
1391 non_triangle_face_count,
1392 triangulated_face_count,
1393 omitted_non_polygon_face_count,
1394 empty_mesh_definition_count,
1395 empty_source_meshes,
1396 pre_weld_vertex_count: conversion.pre_weld_vertex_count,
1397 post_weld_vertex_count: conversion.post_weld_vertex_count,
1398 multiple_skin_deformer_mesh_count,
1399 dual_quaternion_skin_count,
1400 blend_deformer_count: scene.blend_deformers.len(),
1401 blend_channel_count: scene.blend_channels.len(),
1402 blend_shape_count: scene.blend_shapes.len(),
1403 cache_deformer_count: scene.cache_deformers.len(),
1404 unsupported_vertex_payload_mesh_count,
1405 camera_count: scene.cameras.len(),
1406 light_count: scene.lights.len(),
1407 shared_mesh_definition_count,
1408 uninstanced_mesh_definition_count,
1409 uninstanced_source_meshes,
1410 user_defined_property_count,
1411 unsupported_source_element_count,
1412 external_resource_count,
1413 source_nodes: scene
1414 .nodes
1415 .iter()
1416 .enumerate()
1417 .map(|(index, node)| identity(index, &node.element))
1418 .collect(),
1419 source_meshes: scene
1420 .meshes
1421 .iter()
1422 .enumerate()
1423 .map(|(index, mesh)| identity(index, &mesh.element))
1424 .collect(),
1425 source_skins: scene
1426 .skin_deformers
1427 .iter()
1428 .enumerate()
1429 .map(|(index, skin)| identity(index, &skin.element))
1430 .collect(),
1431 };
1432 (inventory, mesh_payload_counts)
1433}
1434
1435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439enum MeshSourcePayloadClassification {
1440 Absent,
1441 ScaleInvariantConversion,
1442 Unsupported,
1443}
1444
1445fn classify_mesh_source_payload(mesh: &ufbx::Mesh) -> MeshSourcePayloadClassification {
1446 let ufbx::Mesh {
1447 element: _,
1448 num_vertices: _,
1449 num_indices: _,
1450 num_faces: _,
1451 num_triangles: _,
1452 num_edges: _,
1453 max_face_triangles: _,
1454 num_empty_faces: _,
1455 num_point_faces: _,
1456 num_line_faces: _,
1457 faces: _,
1458 face_smoothing,
1460 face_material: _,
1461 face_group,
1462 face_hole,
1463 edges,
1464 edge_smoothing,
1465 edge_crease,
1466 edge_visibility,
1467 vertex_indices: _,
1468 vertices: _,
1469 vertex_first_index: _,
1470 vertex_position: _,
1471 vertex_normal: _,
1472 vertex_uv: _,
1473 vertex_tangent,
1474 vertex_bitangent,
1475 vertex_color,
1476 vertex_crease,
1477 uv_sets,
1478 color_sets,
1479 materials: _,
1480 face_groups,
1481 material_parts: _,
1483 face_group_parts: _,
1484 material_part_usage_order: _,
1485 skinned_is_local: _,
1486 skinned_position: _,
1487 skinned_normal: _,
1488 skin_deformers: _,
1490 blend_deformers: _,
1491 cache_deformers: _,
1492 all_deformers: _,
1493 subdivision_preview_levels,
1494 subdivision_render_levels,
1495 subdivision_display_mode,
1496 subdivision_boundary,
1497 subdivision_uv_boundary,
1498 reversed_winding: _,
1500 generated_normals: _,
1501 subdivision_evaluated,
1502 subdivision_result,
1503 from_tessellated_nurbs,
1504 } = mesh;
1505
1506 let unsupported_generated_payload_present =
1511 !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
1512 || *subdivision_evaluated
1513 || subdivision_result.is_some()
1514 || *from_tessellated_nurbs;
1515 let scale_invariant_conversion_facts_present = !face_smoothing.is_empty()
1516 || !face_group.is_empty()
1517 || !face_hole.is_empty()
1518 || !edges.is_empty()
1519 || !edge_smoothing.is_empty()
1520 || !edge_crease.is_empty()
1521 || !edge_visibility.is_empty()
1522 || vertex_tangent.exists
1523 || vertex_bitangent.exists
1524 || vertex_color.exists
1525 || vertex_crease.exists
1526 || uv_sets.len() > 1
1527 || !color_sets.is_empty()
1528 || !face_groups.is_empty()
1529 || *subdivision_preview_levels > 0
1530 || *subdivision_render_levels > 0
1531 || !matches!(
1532 subdivision_display_mode,
1533 ufbx::SubdivisionDisplayMode::Disabled
1534 )
1535 || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default);
1536
1537 if unsupported_generated_payload_present {
1538 MeshSourcePayloadClassification::Unsupported
1539 } else if scale_invariant_conversion_facts_present {
1540 MeshSourcePayloadClassification::ScaleInvariantConversion
1541 } else {
1542 MeshSourcePayloadClassification::Absent
1543 }
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548 use super::*;
1549 use animsmith_core::{
1550 InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
1551 SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
1552 SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
1553 };
1554 use std::path::PathBuf;
1555
1556 fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
1557 captured_with_counts(configure, |_| {})
1558 }
1559
1560 fn captured_with_counts(
1561 configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
1562 configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
1563 ) -> FbxScaleSource {
1564 let fixture =
1565 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
1566 let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
1567 let document = baseline.document().clone();
1568 let mut inventory = baseline.inventory().clone();
1569 let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
1570 let rest_bind_scale_invariant_payload_mesh_count =
1571 baseline.rest_bind_scale_invariant_payload_mesh_count;
1572 configure_counts(&mut rest_bind_construct_counts);
1573 inventory.user_defined_property_count =
1574 rest_bind_construct_counts.user_defined_property_count;
1575 inventory.unsupported_source_element_count =
1576 rest_bind_construct_counts.total_unmodeled_element_count();
1577 inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
1578 && inventory.unsupported_source_element_count == 0
1579 {
1580 FbxScaleDomainStatus::Absent
1581 } else {
1582 FbxScaleDomainStatus::Unsupported
1583 };
1584 let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
1585 let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
1586 configure(&mut builder);
1587 let source = builder.finish(document).expect("synthetic raw facts bind");
1588 FbxScaleSource {
1589 source,
1590 inventory,
1591 rest_bind_construct_counts,
1592 rest_bind_scale_invariant_payload_mesh_count,
1593 }
1594 }
1595
1596 fn parser_provenance(path: &str) -> SourceProvenanceV1 {
1597 SourceProvenanceV1::parser_projected(
1598 SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
1599 )
1600 }
1601
1602 fn complete_captured_source() -> FbxScaleSource {
1603 captured_with(|builder| {
1604 builder.mark_complete(SourceFactDomainV1::Constructs);
1605 builder.mark_complete(SourceFactDomainV1::Resources);
1606 })
1607 }
1608
1609 #[test]
1610 fn source_aware_rest_bind_admits_only_reconciled_scale_invariant_conversion_facts() {
1611 let mut source = complete_captured_source();
1612 source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1613 source.inventory.unsupported_vertex_payload_mesh_count = 1;
1614 source.rest_bind_scale_invariant_payload_mesh_count = 1;
1615 source.inventory.truncated_influence_vertex_count = 1;
1616 source.inventory.discarded_influence_count = 2;
1617 source.inventory.renormalized_influence_vertex_count = 1;
1618 source.inventory.rejected_influence_count = 1;
1619 source.inventory.non_triangle_face_count = 1;
1620 source.inventory.triangulated_face_count = 1;
1621 source.inventory.post_weld_vertex_count = source.inventory.pre_weld_vertex_count - 1;
1622
1623 assert!(
1624 rest_bind_capability_facts(source.inventory()).is_err(),
1625 "a detached public inventory cannot prove these conversions are scale-invariant"
1626 );
1627 let facts = rest_bind_capability_facts_for_source(&source)
1628 .expect("same-parse enumerated conversion facts are admissible");
1629 assert!(!facts.non_triangle_primitives_present);
1630 assert!(!facts.unsupported_vertex_attributes_present);
1631 assert!(!facts.secondary_skin_influences_present);
1632
1633 source.rest_bind_scale_invariant_payload_mesh_count = 0;
1634 assert_eq!(
1635 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1636 concat!(
1637 "FBX rest/bind capability inventory rejected: ",
1638 "unsupported_vertex_payload_mesh_count=1!=scale_invariant_source:0"
1639 ),
1640 "an unclassified payload cannot hide inside the public aggregate"
1641 );
1642
1643 source.rest_bind_scale_invariant_payload_mesh_count = 1;
1644 source.inventory.missing_skin_influence_corner_count = 1;
1645 assert_eq!(
1646 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1647 concat!(
1648 "FBX rest/bind capability inventory rejected: ",
1649 "missing_skin_influence_corner_count=1"
1650 ),
1651 "conversion evidence never overrides missing effective skin coverage"
1652 );
1653 }
1654
1655 #[test]
1656 fn clip_track_projection_excludes_deformation_but_keeps_animation_gates() {
1657 let mut source = complete_captured_source();
1658 source.inventory.domains.base_mesh_geometry = FbxScaleDomainStatus::Unsupported;
1659 source.inventory.domains.skin_binds = FbxScaleDomainStatus::Unsupported;
1660 source.inventory.domains.morphs = FbxScaleDomainStatus::Unsupported;
1661 source.inventory.domains.cameras_and_lights = FbxScaleDomainStatus::Unsupported;
1662 source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1663 source.inventory.dual_quaternion_skin_count = 1;
1664 source.inventory.incomplete_bind_cluster_count = 1;
1665 source.inventory.blend_deformer_count = 1;
1666 source.inventory.cache_deformer_count = 1;
1667 assert!(rest_bind_capability_facts_for_source(&source).is_err());
1668 require_clip_track_capability_for_source(&source)
1669 .expect("clip-only projection excludes deformation and bind domains");
1670
1671 source.inventory.animation_takes_baked = false;
1672 assert_eq!(
1673 require_clip_track_capability_for_source(&source).unwrap_err(),
1674 "FBX rest/bind clip-track capability inventory rejected: animation_takes_baked=false"
1675 );
1676 source.inventory.animation_takes_baked = true;
1677 source.inventory.domains.out_of_contract_node_transforms =
1678 FbxScaleDomainStatus::Unsupported;
1679 assert_eq!(
1680 require_clip_track_capability_for_source(&source).unwrap_err(),
1681 "FBX rest/bind clip-track capability inventory rejected: domain.out_of_contract_node_transforms=unsupported"
1682 );
1683
1684 macro_rules! required_domain_refuses {
1685 ($field:ident) => {{
1686 let mut source = complete_captured_source();
1687 source.inventory.domains.$field = FbxScaleDomainStatus::Unsupported;
1688 let error = require_clip_track_capability_for_source(&source).unwrap_err();
1689 assert!(
1690 error.contains(concat!("domain.", stringify!($field), "=unsupported")),
1691 "{error}"
1692 );
1693 }};
1694 }
1695 required_domain_refuses!(rest_hierarchy);
1696 required_domain_refuses!(translation_animation);
1697 required_domain_refuses!(rotation_and_scale_animation);
1698 required_domain_refuses!(root_motion_and_velocity);
1699 required_domain_refuses!(out_of_contract_node_transforms);
1700 required_domain_refuses!(animation_targeting_matrix_nodes);
1701
1702 let mut source = complete_captured_source();
1703 source.inventory.domains.rest_hierarchy = FbxScaleDomainStatus::Unverifiable;
1704 assert!(
1705 require_clip_track_capability_for_source(&source)
1706 .unwrap_err()
1707 .contains("domain.rest_hierarchy=unverifiable")
1708 );
1709 }
1710
1711 #[test]
1712 fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
1713 for (source, expected) in [
1714 (
1715 captured_with(|builder| {
1716 builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1717 builder.mark_complete(SourceFactDomainV1::Resources);
1718 }),
1719 "FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
1720 ),
1721 (
1722 captured_with(|builder| {
1723 builder.mark_complete(SourceFactDomainV1::Constructs);
1724 builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
1725 }),
1726 "FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
1727 ),
1728 ] {
1729 assert_eq!(
1730 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1731 expected,
1732 "partial construct/resource coverage must name the exact raw authority"
1733 );
1734 }
1735 }
1736
1737 #[test]
1738 fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
1739 for kind in [
1740 SourceConstructKindV1::UnknownElement,
1741 SourceConstructKindV1::Extension,
1742 ] {
1743 let source = captured_with(|builder| {
1744 builder.push_construct(
1745 SourceConstructFactV1::new(
1746 0,
1747 kind,
1748 SourceTextV1::new("synthetic").expect("bounded test name"),
1749 false,
1750 1,
1751 SourceLoaderDispositionV1::Unsupported,
1752 parser_provenance("fbx:synthetic/construct"),
1753 )
1754 .expect("positive test construct"),
1755 );
1756 builder.mark_complete(SourceFactDomainV1::Constructs);
1757 builder.mark_complete(SourceFactDomainV1::Resources);
1758 });
1759 let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
1760 let expected = match kind {
1761 SourceConstructKindV1::UnknownElement => "unknown_element",
1762 SourceConstructKindV1::Extension => "extension",
1763 SourceConstructKindV1::CustomProperty => unreachable!(),
1764 };
1765 assert_eq!(
1766 error,
1767 format!(
1768 "FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
1769 )
1770 );
1771 }
1772
1773 let custom_and_external = captured_with_counts(
1774 |builder| {
1775 builder.push_construct(
1776 SourceConstructFactV1::new(
1777 0,
1778 SourceConstructKindV1::CustomProperty,
1779 SourceTextV1::new("fbx:user-defined-properties")
1780 .expect("bounded test name"),
1781 false,
1782 1,
1783 SourceLoaderDispositionV1::Unsupported,
1784 parser_provenance("fbx:synthetic/property"),
1785 )
1786 .expect("positive custom-property row"),
1787 );
1788 builder.mark_complete(SourceFactDomainV1::Constructs);
1789 builder.push_resource(SourceResourceReferenceV1::new(
1790 0,
1791 SourceResourceKindV1::Texture,
1792 0,
1793 SourceResourceLocatorV1::classify("texture.png"),
1794 SourceLoaderDispositionV1::Unknown,
1795 parser_provenance("fbx:textures/0/filename"),
1796 ));
1797 builder.mark_complete(SourceFactDomainV1::Resources);
1798 },
1799 |counts| counts.user_defined_property_count = 1,
1800 );
1801 let facts = rest_bind_capability_facts_for_source(&custom_and_external)
1802 .expect("custom properties and external images are not scale-bearing");
1803 assert!(!facts.extras_present);
1804 assert!(!facts.external_resources_present);
1805 assert!(facts.is_supported_for(
1806 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
1807 source_skin_index: 0,
1808 source_root_node_index: 1,
1809 expected_factor: 0.01,
1810 }
1811 ));
1812 }
1813}