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 inventory(&self) -> &FbxScaleCapabilityInventory {
417 &self.inventory
418 }
419
420 pub fn into_document(self) -> Document {
422 self.source.into_document()
423 }
424
425 pub(crate) fn into_source(self) -> LoadedSource {
426 self.source
427 }
428}
429
430pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
437 let mut facts = ScaleCapabilityFacts::default();
438 facts.coverage = ScaleCapabilityCoverage::Complete;
439 let morph_source_present = inventory.blend_deformer_count > 0
440 || inventory.blend_channel_count > 0
441 || inventory.blend_shape_count > 0;
442 facts.morphs_present = morph_source_present;
443 facts.morph_weights_present = morph_source_present;
444 facts.cameras_present = inventory.camera_count > 0;
445 facts.lights_present = inventory.light_count > 0;
446 facts.instancing_present = inventory.shared_mesh_definition_count > 0;
447 facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
448 facts.extras_present = inventory.user_defined_property_count > 0;
449 facts.unknown_source_members_present = true;
453 facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
454 facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
455 > 0
456 || inventory.uninstanced_mesh_definition_count > 0
457 || inventory.empty_mesh_definition_count > 0
458 || inventory.multiple_skin_deformer_mesh_count > 0
459 || inventory.dual_quaternion_skin_count > 0
460 || inventory.cache_deformer_count > 0
461 || inventory.missing_skin_influence_corner_count > 0
462 || inventory.rejected_influence_count > 0
463 || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
464 facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
465 facts.inverse_bind_issues_present =
466 inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
467 facts.unsafe_accessor_layout_present = true;
471 facts.external_resources_present = inventory.external_resource_count > 0;
472 facts
473}
474
475pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
481 join_source_facts(source, capability_facts(source.inventory()))
482}
483
484fn join_source_facts(
485 source: &FbxScaleSource,
486 mut facts: ScaleCapabilityFacts,
487) -> ScaleCapabilityFacts {
488 let source_facts = source.source_facts();
489 if [
490 source_facts.constructs().coverage().state(),
491 source_facts.resources().coverage().state(),
492 ]
493 .into_iter()
494 .any(|state| state != SourceSetCoverageStateV1::Complete)
495 {
496 facts.coverage = ScaleCapabilityCoverage::Unavailable;
497 }
498 for row in source_facts.constructs().rows() {
499 match row.kind() {
500 SourceConstructKindV1::CustomProperty => facts.extras_present = true,
501 SourceConstructKindV1::UnknownElement => {
502 facts.unknown_source_members_present = true;
503 }
504 SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
505 }
506 }
507 if source_facts.resources().rows().iter().any(|row| {
508 !matches!(
509 row.locator(),
510 SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
511 )
512 }) {
513 facts.external_resources_present = true;
514 }
515 facts
516}
517
518pub fn rest_bind_capability_facts(
535 inventory: &FbxScaleCapabilityInventory,
536) -> Result<ScaleCapabilityFacts, String> {
537 if inventory.external_resource_count != 0 {
538 return Err(format_rest_bind_violations(
539 "capability inventory",
540 &[format!(
541 "external_resource_count={}",
542 inventory.external_resource_count
543 )],
544 ));
545 }
546 rest_bind_capability_facts_with_context(inventory, RestBindCapabilityContext::InventoryOnly)
547}
548
549#[derive(Debug, Clone, Copy)]
550enum RestBindCapabilityContext<'a> {
551 InventoryOnly,
552 CapturedSource {
553 counts: &'a crate::source_facts::RestBindSourceConstructCounts,
554 scale_invariant_payload_mesh_count: usize,
555 },
556}
557
558fn rest_bind_capability_facts_with_context(
559 inventory: &FbxScaleCapabilityInventory,
560 context: RestBindCapabilityContext<'_>,
561) -> Result<ScaleCapabilityFacts, String> {
562 let captured_source = match context {
563 RestBindCapabilityContext::InventoryOnly => None,
564 RestBindCapabilityContext::CapturedSource {
565 counts,
566 scale_invariant_payload_mesh_count,
567 } => Some((counts, scale_invariant_payload_mesh_count)),
568 };
569 let mut violations = Vec::new();
570 for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
571 if domain == "other_vertex_and_source_data" && captured_source.is_some() {
572 continue;
573 }
574 if matches!(
575 status,
576 FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
577 ) {
578 violations.push(format!(
579 "domain.{domain}={}",
580 fbx_scale_domain_status_name(status)
581 ));
582 }
583 }
584 for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
585 if status != FbxScaleDomainStatus::Unverifiable {
586 violations.push(format!(
587 "domain.{domain}={} (expected unverifiable)",
588 fbx_scale_domain_status_name(status)
589 ));
590 }
591 }
592
593 let expected_custom_status = if inventory.unsupported_source_element_count == 0
594 && inventory.user_defined_property_count == 0
595 {
596 FbxScaleDomainStatus::Absent
597 } else {
598 FbxScaleDomainStatus::Unsupported
599 };
600 if inventory.domains.collision_and_custom_data != expected_custom_status {
601 violations.push(format!(
602 "domain.collision_and_custom_data={} (expected {})",
603 fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
604 fbx_scale_domain_status_name(expected_custom_status)
605 ));
606 }
607 if let Some((source_counts, _)) = captured_source {
608 if inventory.user_defined_property_count != source_counts.user_defined_property_count {
609 violations.push(format!(
610 "user_defined_property_count={}!=source:{}",
611 inventory.user_defined_property_count, source_counts.user_defined_property_count
612 ));
613 }
614 let source_unmodeled_count = source_counts.total_unmodeled_element_count();
615 if inventory.unsupported_source_element_count != source_unmodeled_count {
616 violations.push(format!(
617 "unsupported_source_element_count={}!=source:{}",
618 inventory.unsupported_source_element_count, source_unmodeled_count
619 ));
620 }
621 push_unmodeled_element_violation(&mut violations, *source_counts);
622 } else {
623 push_nonzero_violation(
624 &mut violations,
625 "unsupported_source_element_count",
626 inventory.unsupported_source_element_count,
627 );
628 }
629
630 push_normalized_baked_animation_violations(&mut violations, inventory);
631 if inventory.identity_bind_defaults_invented {
632 violations.push("identity_bind_defaults_invented=true".into());
633 }
634 for (name, value) in [
635 ("blend_deformer_count", inventory.blend_deformer_count),
636 ("blend_channel_count", inventory.blend_channel_count),
637 ("blend_shape_count", inventory.blend_shape_count),
638 ("camera_count", inventory.camera_count),
639 ("light_count", inventory.light_count),
640 (
641 "shared_mesh_definition_count",
642 inventory.shared_mesh_definition_count,
643 ),
644 (
645 "uninstanced_mesh_definition_count",
646 inventory.uninstanced_mesh_definition_count,
647 ),
648 (
649 "empty_mesh_definition_count",
650 inventory.empty_mesh_definition_count,
651 ),
652 (
653 "multiple_skin_deformer_mesh_count",
654 inventory.multiple_skin_deformer_mesh_count,
655 ),
656 (
657 "dual_quaternion_skin_count",
658 inventory.dual_quaternion_skin_count,
659 ),
660 ("cache_deformer_count", inventory.cache_deformer_count),
661 (
662 "incomplete_bind_cluster_count",
663 inventory.incomplete_bind_cluster_count,
664 ),
665 (
666 "empty_skin_deformer_count",
667 inventory.empty_skin_deformer_count,
668 ),
669 (
670 "missing_normal_mesh_count",
671 inventory.missing_normal_mesh_count,
672 ),
673 (
674 "bone_convenience_bind_overwrite_count",
675 inventory.bone_convenience_bind_overwrite_count,
676 ),
677 (
678 "missing_skin_influence_corner_count",
679 inventory.missing_skin_influence_corner_count,
680 ),
681 (
682 "omitted_non_polygon_face_count",
683 inventory.omitted_non_polygon_face_count,
684 ),
685 ] {
686 push_nonzero_violation(&mut violations, name, value);
687 }
688 if let Some((_, scale_invariant_payload_mesh_count)) = captured_source {
689 if inventory.unsupported_vertex_payload_mesh_count != scale_invariant_payload_mesh_count {
690 violations.push(format!(
691 "unsupported_vertex_payload_mesh_count={}!=scale_invariant_source:{}",
692 inventory.unsupported_vertex_payload_mesh_count, scale_invariant_payload_mesh_count
693 ));
694 }
695 } else {
696 for (name, value) in [
697 (
698 "unsupported_vertex_payload_mesh_count",
699 inventory.unsupported_vertex_payload_mesh_count,
700 ),
701 (
702 "truncated_influence_vertex_count",
703 inventory.truncated_influence_vertex_count,
704 ),
705 (
706 "discarded_influence_count",
707 inventory.discarded_influence_count,
708 ),
709 (
710 "renormalized_influence_vertex_count",
711 inventory.renormalized_influence_vertex_count,
712 ),
713 (
714 "rejected_influence_count",
715 inventory.rejected_influence_count,
716 ),
717 ("non_triangle_face_count", inventory.non_triangle_face_count),
718 ("triangulated_face_count", inventory.triangulated_face_count),
719 ] {
720 push_nonzero_violation(&mut violations, name, value);
721 }
722 if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
723 violations.push(format!(
724 "weld_vertex_count={}!=post:{}",
725 inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
726 ));
727 }
728 }
729 if !violations.is_empty() {
730 return Err(format_rest_bind_violations(
731 "capability inventory",
732 &violations,
733 ));
734 }
735
736 let mut facts = capability_facts(inventory);
743 facts.unknown_source_members_present = false;
744 facts.unregistered_extensions_present = false;
745 facts.unsafe_accessor_layout_present = false;
746 facts.extras_present = false;
747 facts.external_resources_present = false;
748 if captured_source.is_some() {
749 facts.non_triangle_primitives_present = false;
750 facts.unsupported_vertex_attributes_present = false;
751 facts.secondary_skin_influences_present = false;
752 }
753 if !facts.is_supported_for(
754 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
755 source_skin_index: 0,
756 source_root_node_index: 0,
757 expected_factor: 1.0,
758 },
759 ) {
760 return Err(format_rest_bind_violations(
761 "projected capability",
762 &scale_capability_violations(&facts),
763 ));
764 }
765 Ok(facts)
766}
767
768fn require_supported_raw_source_facts(source: &FbxScaleSource) -> Result<(), String> {
775 let source_facts = source.source_facts();
776 let source_counts = source.rest_bind_construct_counts;
777 let mut violations = Vec::new();
778 for (domain, state) in [
779 ("constructs", source_facts.constructs().coverage().state()),
780 ("resources", source_facts.resources().coverage().state()),
781 ] {
782 if state != SourceSetCoverageStateV1::Complete {
783 violations.push(format!(
784 "raw_source.{domain}.coverage={}",
785 source_set_coverage_state_name(state)
786 ));
787 }
788 }
789 let mut saw_custom_properties = false;
790 let mut saw_unmodeled_elements = false;
791 for row in source_facts.constructs().rows() {
792 match row.kind() {
793 SourceConstructKindV1::CustomProperty
794 if row.name().as_str() == "fbx:user-defined-properties" =>
795 {
796 saw_custom_properties = true;
797 if row.count()
798 != u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
799 {
800 violations.push(format!(
801 "raw_source.construct=custom_property({}; count={})!=source:{}",
802 row.name().as_str(),
803 row.count(),
804 source_counts.user_defined_property_count
805 ));
806 }
807 }
808 SourceConstructKindV1::CustomProperty => violations.push(format!(
809 "raw_source.construct=custom_property({}; count={})",
810 row.name().as_str(),
811 row.count()
812 )),
813 SourceConstructKindV1::UnknownElement
814 if row.name().as_str() == "fbx:unmodeled-elements" =>
815 {
816 saw_unmodeled_elements = true;
817 let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
818 .unwrap_or(u64::MAX);
819 if row.count() != total_count {
820 violations.push(format!(
821 "raw_source.construct=unknown_element({}; count={})!=source:{}",
822 row.name().as_str(),
823 row.count(),
824 total_count
825 ));
826 } else if source_counts.unsupported_unmodeled_element_count() > 0 {
827 violations.push(format!(
828 "raw_source.construct=unknown_element({}; {})",
829 row.name().as_str(),
830 unmodeled_element_details(source_counts)
831 ));
832 }
833 }
834 SourceConstructKindV1::UnknownElement => violations.push(format!(
835 "raw_source.construct=unknown_element({}; count={})",
836 row.name().as_str(),
837 row.count()
838 )),
839 SourceConstructKindV1::Extension => violations.push(format!(
840 "raw_source.construct=extension({}; count={})",
841 row.name().as_str(),
842 row.count()
843 )),
844 }
845 }
846 if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
847 violations.push(format!(
848 "raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
849 source_counts.user_defined_property_count
850 ));
851 }
852 if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
853 violations.push(format!(
854 "raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
855 source_counts.total_unmodeled_element_count()
856 ));
857 }
858 if !violations.is_empty() {
859 return Err(format_rest_bind_violations("raw-source facts", &violations));
860 }
861 Ok(())
862}
863
864pub fn rest_bind_capability_facts_for_source(
876 source: &FbxScaleSource,
877) -> Result<ScaleCapabilityFacts, String> {
878 require_supported_raw_source_facts(source)?;
879 let source_counts = source.rest_bind_construct_counts;
880
881 let mut facts = join_source_facts(
882 source,
883 rest_bind_capability_facts_with_context(
884 source.inventory(),
885 RestBindCapabilityContext::CapturedSource {
886 counts: &source_counts,
887 scale_invariant_payload_mesh_count: source
888 .rest_bind_scale_invariant_payload_mesh_count,
889 },
890 )?,
891 );
892 facts.unknown_source_members_present = false;
893 facts.unregistered_extensions_present = false;
894 facts.extras_present = false;
895 facts.external_resources_present = false;
896 if facts.is_supported_for(
897 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
898 source_skin_index: 0,
899 source_root_node_index: 0,
900 expected_factor: 1.0,
901 },
902 ) {
903 Ok(facts)
904 } else {
905 Err(format_rest_bind_violations(
906 "joined capability",
907 &scale_capability_violations(&facts),
908 ))
909 }
910}
911
912pub fn require_clip_track_capability_for_source(source: &FbxScaleSource) -> Result<(), String> {
925 require_supported_raw_source_facts(source)?;
926 let inventory = source.inventory();
927 let mut violations = Vec::new();
928 for (domain, status) in inventory.domains.clip_track_semantic_statuses() {
929 if matches!(
930 status,
931 FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
932 ) {
933 violations.push(format!(
934 "domain.{domain}={}",
935 fbx_scale_domain_status_name(status)
936 ));
937 }
938 }
939 push_normalized_baked_animation_violations(&mut violations, inventory);
940 if !violations.is_empty() {
941 return Err(format_rest_bind_violations(
942 "clip-track capability inventory",
943 &violations,
944 ));
945 }
946
947 Ok(())
948}
949
950fn push_normalized_baked_animation_violations(
951 violations: &mut Vec<String>,
952 inventory: &FbxScaleCapabilityInventory,
953) {
954 if !inventory.coordinate_normalization.target_right_handed_y_up {
955 violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
956 }
957 if inventory.coordinate_normalization.target_unit_meters != 1.0 {
958 violations.push(format!(
959 "coordinate_normalization.target_unit_meters={}",
960 inventory.coordinate_normalization.target_unit_meters
961 ));
962 }
963 if !inventory.coordinate_normalization.adjust_transforms {
964 violations.push("coordinate_normalization.adjust_transforms=false".into());
965 }
966 if !inventory.animation_takes_baked {
967 violations.push("animation_takes_baked=false".into());
968 }
969 if inventory.authored_curve_keys_preserved {
970 violations.push("authored_curve_keys_preserved=true".into());
971 }
972 if !inventory.inherit_modes_compensated {
973 violations.push("inherit_modes_compensated=false".into());
974 }
975}
976
977fn push_unmodeled_element_violation(
978 violations: &mut Vec<String>,
979 counts: crate::source_facts::RestBindSourceConstructCounts,
980) {
981 if counts.unsupported_unmodeled_element_count() > 0 {
982 violations.push(format!(
983 "unsupported_source_element_count; {}",
984 unmodeled_element_details(counts)
985 ));
986 }
987}
988
989fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
990 let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
991 for (kind, count) in counts.unsupported_kind_counts() {
992 details.push_str("; ");
993 details.push_str(kind);
994 details.push('=');
995 details.push_str(&count.to_string());
996 }
997 details
998}
999
1000fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
1001 match status {
1002 FbxScaleDomainStatus::Absent => "absent",
1003 FbxScaleDomainStatus::Normalized => "normalized",
1004 FbxScaleDomainStatus::Baked => "baked",
1005 FbxScaleDomainStatus::Derived => "derived",
1006 FbxScaleDomainStatus::Rebuilt => "rebuilt",
1007 FbxScaleDomainStatus::Unsupported => "unsupported",
1008 FbxScaleDomainStatus::Unverifiable => "unverifiable",
1009 }
1010}
1011
1012fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
1013 match state {
1014 SourceSetCoverageStateV1::Complete => "complete",
1015 SourceSetCoverageStateV1::Partial => "partial",
1016 SourceSetCoverageStateV1::Unavailable => "unavailable",
1017 }
1018}
1019
1020fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
1021 if value > 0 {
1022 violations.push(format!("{name}={value}"));
1023 }
1024}
1025
1026fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
1027 format!(
1028 "FBX rest/bind {authority} rejected: {}",
1029 violations.join("; ")
1030 )
1031}
1032
1033fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
1034 let mut violations = Vec::new();
1035 if facts.coverage != ScaleCapabilityCoverage::Complete {
1036 violations.push("coverage=unavailable".into());
1037 }
1038 for (name, present) in [
1039 ("morphs_present", facts.morphs_present),
1040 ("morph_weights_present", facts.morph_weights_present),
1041 ("cameras_present", facts.cameras_present),
1042 ("lights_present", facts.lights_present),
1043 ("instancing_present", facts.instancing_present),
1044 (
1045 "unregistered_extensions_present",
1046 facts.unregistered_extensions_present,
1047 ),
1048 ("extras_present", facts.extras_present),
1049 (
1050 "unknown_source_members_present",
1051 facts.unknown_source_members_present,
1052 ),
1053 (
1054 "non_triangle_primitives_present",
1055 facts.non_triangle_primitives_present,
1056 ),
1057 (
1058 "unsupported_vertex_attributes_present",
1059 facts.unsupported_vertex_attributes_present,
1060 ),
1061 (
1062 "secondary_skin_influences_present",
1063 facts.secondary_skin_influences_present,
1064 ),
1065 (
1066 "inverse_bind_issues_present",
1067 facts.inverse_bind_issues_present,
1068 ),
1069 (
1070 "unsafe_accessor_layout_present",
1071 facts.unsafe_accessor_layout_present,
1072 ),
1073 (
1074 "external_resources_present",
1075 facts.external_resources_present,
1076 ),
1077 ] {
1078 if present {
1079 violations.push(format!("{name}=true"));
1080 }
1081 }
1082 violations
1083}
1084
1085#[derive(Debug, Default)]
1086pub(crate) struct AssetConversionFacts {
1087 pub(crate) truncated_influence_vertex_count: usize,
1088 pub(crate) discarded_influence_count: usize,
1089 pub(crate) renormalized_influence_vertex_count: usize,
1090 pub(crate) rejected_influence_count: usize,
1091 pub(crate) missing_skin_influence_corner_count: usize,
1092 pub(crate) pre_weld_vertex_count: usize,
1093 pub(crate) post_weld_vertex_count: usize,
1094}
1095
1096#[derive(Debug, Clone, Copy, Default)]
1097pub(crate) struct RestBindMeshPayloadCounts {
1098 pub(crate) unsupported_mesh_count: usize,
1099 pub(crate) scale_invariant_mesh_count: usize,
1100}
1101
1102fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
1103 FbxSourceIdentity {
1104 source_index: index,
1105 ufbx_typed_id: element.typed_id,
1106 ufbx_element_id: element.element_id,
1107 }
1108}
1109
1110pub(crate) fn inventory(
1111 scene: &ufbx::Scene,
1112 conversion: &AssetConversionFacts,
1113 construct_counts: crate::source_facts::SourceConstructCounts,
1114) -> (FbxScaleCapabilityInventory, RestBindMeshPayloadCounts) {
1115 let non_triangle_face_count = scene
1116 .meshes
1117 .iter()
1118 .flat_map(|mesh| mesh.faces.iter())
1119 .filter(|face| face.num_indices != 3)
1120 .count();
1121 let triangulated_face_count = scene
1122 .meshes
1123 .iter()
1124 .flat_map(|mesh| mesh.faces.iter())
1125 .filter(|face| face.num_indices > 3)
1126 .count();
1127 let omitted_non_polygon_face_count = scene
1128 .meshes
1129 .iter()
1130 .flat_map(|mesh| mesh.faces.iter())
1131 .filter(|face| face.num_indices < 3)
1132 .count();
1133 let empty_source_meshes = scene
1134 .meshes
1135 .iter()
1136 .enumerate()
1137 .filter(|(_, mesh)| mesh.faces.is_empty())
1138 .map(|(index, mesh)| identity(index, &mesh.element))
1139 .collect::<Vec<_>>();
1140 let empty_mesh_definition_count = empty_source_meshes.len();
1141 let generated_normal_mesh_count = scene
1142 .meshes
1143 .iter()
1144 .filter(|mesh| mesh.generated_normals)
1145 .count();
1146 let missing_normal_mesh_count = scene
1147 .meshes
1148 .iter()
1149 .filter(|mesh| !mesh.vertex_normal.exists)
1150 .count();
1151 let skin_cluster_count = scene
1152 .skin_deformers
1153 .iter()
1154 .map(|skin| skin.clusters.len())
1155 .sum();
1156 let empty_skin_deformer_count = scene
1157 .skin_deformers
1158 .iter()
1159 .filter(|skin| skin.clusters.is_empty())
1160 .count();
1161 let incomplete_bind_cluster_count = scene
1162 .skin_clusters
1163 .iter()
1164 .filter(|cluster| super::project_cluster_bind(cluster).is_none())
1165 .count();
1166 let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
1167 for cluster in &scene.skin_clusters {
1168 if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
1169 *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
1170 }
1171 }
1172 let bone_convenience_bind_overwrite_count = clusters_per_bone
1173 .values()
1174 .map(|count| count.saturating_sub(1))
1175 .sum();
1176 let multiple_skin_deformer_mesh_count = scene
1177 .meshes
1178 .iter()
1179 .filter(|mesh| mesh.skin_deformers.len() > 1)
1180 .count();
1181 let dual_quaternion_skin_count = scene
1182 .skin_deformers
1183 .iter()
1184 .filter(|skin| {
1185 skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
1186 })
1187 .count();
1188 let mesh_payload_counts = scene
1189 .meshes
1190 .iter()
1191 .map(|mesh| classify_mesh_source_payload(mesh))
1192 .fold(
1193 RestBindMeshPayloadCounts::default(),
1194 |mut counts, classification| {
1195 match classification {
1196 MeshSourcePayloadClassification::Absent => {}
1197 MeshSourcePayloadClassification::ScaleInvariantConversion => {
1198 counts.unsupported_mesh_count += 1;
1199 counts.scale_invariant_mesh_count += 1;
1200 }
1201 MeshSourcePayloadClassification::Unsupported => {
1202 counts.unsupported_mesh_count += 1;
1203 }
1204 }
1205 counts
1206 },
1207 );
1208 let unsupported_vertex_payload_mesh_count = mesh_payload_counts.unsupported_mesh_count;
1209 let shared_mesh_definition_count = scene
1210 .meshes
1211 .iter()
1212 .filter(|mesh| mesh.element.instances.len() > 1)
1213 .count();
1214 let uninstanced_source_meshes = scene
1215 .meshes
1216 .iter()
1217 .enumerate()
1218 .filter(|(_, mesh)| mesh.element.instances.is_empty())
1219 .map(|(index, mesh)| identity(index, &mesh.element))
1220 .collect::<Vec<_>>();
1221 let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
1222 let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
1223 let unsupported_source_element_count =
1224 construct_counts.rest_bind.total_unmodeled_element_count();
1225 let external_resource_count = scene
1226 .textures
1227 .iter()
1228 .filter(|texture| texture.content.is_empty() && texture.has_file)
1229 .count()
1230 + scene
1231 .videos
1232 .iter()
1233 .filter(|video| {
1234 video.content.is_empty()
1235 && (!video.filename.is_empty()
1236 || !video.relative_filename.is_empty()
1237 || !video.absolute_filename.is_empty())
1238 })
1239 .count();
1240 let compensated_inherit_node_count = scene
1241 .nodes
1242 .iter()
1243 .filter(|node| {
1244 node.original_inherit_mode != node.inherit_mode
1245 || node.is_scale_helper
1246 || node.is_scale_compensate_parent
1247 })
1248 .count();
1249
1250 let stackless_animation_present = scene.anim_stacks.is_empty()
1251 && (!scene.anim_layers.is_empty()
1252 || !scene.anim_values.is_empty()
1253 || !scene.anim_curves.is_empty());
1254 let animation = if !scene.anim_stacks.is_empty() {
1255 FbxScaleDomainStatus::Baked
1256 } else if stackless_animation_present {
1257 FbxScaleDomainStatus::Unsupported
1260 } else {
1261 FbxScaleDomainStatus::Absent
1262 };
1263 let domains = FbxScaleDomainInventory {
1264 rest_hierarchy: FbxScaleDomainStatus::Normalized,
1265 translation_animation: animation,
1266 rotation_and_scale_animation: animation,
1267 root_motion_and_velocity: match animation {
1268 FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
1269 status => status,
1270 },
1271 base_mesh_geometry: if scene.meshes.is_empty() {
1272 FbxScaleDomainStatus::Absent
1273 } else if uninstanced_mesh_definition_count > 0
1274 || omitted_non_polygon_face_count > 0
1275 || empty_mesh_definition_count > 0
1276 {
1277 FbxScaleDomainStatus::Unsupported
1278 } else {
1279 FbxScaleDomainStatus::Rebuilt
1280 },
1281 morphs: if scene.blend_deformers.is_empty()
1282 && scene.blend_channels.is_empty()
1283 && scene.blend_shapes.is_empty()
1284 {
1285 FbxScaleDomainStatus::Absent
1286 } else {
1287 FbxScaleDomainStatus::Unsupported
1288 },
1289 skin_binds: if scene.skin_deformers.is_empty() {
1290 FbxScaleDomainStatus::Absent
1291 } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
1292 FbxScaleDomainStatus::Unsupported
1293 } else {
1294 FbxScaleDomainStatus::Derived
1295 },
1296 cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
1297 FbxScaleDomainStatus::Absent
1298 } else {
1299 FbxScaleDomainStatus::Unsupported
1300 },
1301 collision_and_custom_data: if unsupported_source_element_count == 0
1302 && user_defined_property_count == 0
1303 {
1304 FbxScaleDomainStatus::Absent
1305 } else {
1306 FbxScaleDomainStatus::Unsupported
1307 },
1308 other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
1309 || uninstanced_mesh_definition_count > 0
1310 || omitted_non_polygon_face_count > 0
1311 || empty_mesh_definition_count > 0
1312 || multiple_skin_deformer_mesh_count > 0
1313 || dual_quaternion_skin_count > 0
1314 || conversion.truncated_influence_vertex_count > 0
1315 || conversion.missing_skin_influence_corner_count > 0
1316 || conversion.rejected_influence_count > 0
1317 || !scene.blend_deformers.is_empty()
1318 || !scene.blend_channels.is_empty()
1319 || !scene.blend_shapes.is_empty()
1320 || !scene.cache_deformers.is_empty()
1321 || !scene.cache_files.is_empty()
1322 {
1323 FbxScaleDomainStatus::Unsupported
1324 } else if !scene.meshes.is_empty() {
1325 FbxScaleDomainStatus::Rebuilt
1326 } else {
1327 FbxScaleDomainStatus::Absent
1328 },
1329 out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
1330 animation_targeting_matrix_nodes: animation,
1331 shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1332 unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1333 image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
1334 };
1335
1336 let inventory = FbxScaleCapabilityInventory {
1337 domains,
1338 coordinate_normalization: FbxCoordinateNormalization {
1339 original_up_axis: scene.settings.original_axis_up.into(),
1340 original_unit_meters: scene.settings.original_unit_meters,
1341 target_right_handed_y_up: true,
1342 target_unit_meters: 1.0,
1343 adjust_transforms: matches!(
1344 scene.metadata.space_conversion,
1345 ufbx::SpaceConversion::AdjustTransforms
1346 ),
1347 },
1348 animation_takes_baked: true,
1349 authored_curve_keys_preserved: false,
1350 animation_take_count: scene.anim_stacks.len(),
1351 source_animation_curve_count: scene.anim_curves.len(),
1352 generated_geometry_helper_node_count: scene
1353 .nodes
1354 .iter()
1355 .filter(|node| node.is_geometry_transform_helper)
1356 .count(),
1357 generated_scale_helper_node_count: scene
1358 .nodes
1359 .iter()
1360 .filter(|node| node.is_scale_helper)
1361 .count(),
1362 inherit_modes_compensated: matches!(
1363 scene.metadata.inherit_mode_handling,
1364 ufbx::InheritModeHandling::Compensate
1365 ),
1366 compensated_inherit_node_count,
1367 generated_normal_mesh_count,
1368 missing_normal_mesh_count,
1369 skin_deformer_count: scene.skin_deformers.len(),
1370 skin_cluster_count,
1371 empty_skin_deformer_count,
1372 bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
1373 incomplete_bind_cluster_count,
1374 bone_convenience_bind_overwrite_count,
1375 identity_bind_defaults_invented: false,
1376 truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
1377 discarded_influence_count: conversion.discarded_influence_count,
1378 renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
1379 rejected_influence_count: conversion.rejected_influence_count,
1380 missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
1381 non_triangle_face_count,
1382 triangulated_face_count,
1383 omitted_non_polygon_face_count,
1384 empty_mesh_definition_count,
1385 empty_source_meshes,
1386 pre_weld_vertex_count: conversion.pre_weld_vertex_count,
1387 post_weld_vertex_count: conversion.post_weld_vertex_count,
1388 multiple_skin_deformer_mesh_count,
1389 dual_quaternion_skin_count,
1390 blend_deformer_count: scene.blend_deformers.len(),
1391 blend_channel_count: scene.blend_channels.len(),
1392 blend_shape_count: scene.blend_shapes.len(),
1393 cache_deformer_count: scene.cache_deformers.len(),
1394 unsupported_vertex_payload_mesh_count,
1395 camera_count: scene.cameras.len(),
1396 light_count: scene.lights.len(),
1397 shared_mesh_definition_count,
1398 uninstanced_mesh_definition_count,
1399 uninstanced_source_meshes,
1400 user_defined_property_count,
1401 unsupported_source_element_count,
1402 external_resource_count,
1403 source_nodes: scene
1404 .nodes
1405 .iter()
1406 .enumerate()
1407 .map(|(index, node)| identity(index, &node.element))
1408 .collect(),
1409 source_meshes: scene
1410 .meshes
1411 .iter()
1412 .enumerate()
1413 .map(|(index, mesh)| identity(index, &mesh.element))
1414 .collect(),
1415 source_skins: scene
1416 .skin_deformers
1417 .iter()
1418 .enumerate()
1419 .map(|(index, skin)| identity(index, &skin.element))
1420 .collect(),
1421 };
1422 (inventory, mesh_payload_counts)
1423}
1424
1425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1429enum MeshSourcePayloadClassification {
1430 Absent,
1431 ScaleInvariantConversion,
1432 Unsupported,
1433}
1434
1435fn classify_mesh_source_payload(mesh: &ufbx::Mesh) -> MeshSourcePayloadClassification {
1436 let ufbx::Mesh {
1437 element: _,
1438 num_vertices: _,
1439 num_indices: _,
1440 num_faces: _,
1441 num_triangles: _,
1442 num_edges: _,
1443 max_face_triangles: _,
1444 num_empty_faces: _,
1445 num_point_faces: _,
1446 num_line_faces: _,
1447 faces: _,
1448 face_smoothing,
1450 face_material: _,
1451 face_group,
1452 face_hole,
1453 edges,
1454 edge_smoothing,
1455 edge_crease,
1456 edge_visibility,
1457 vertex_indices: _,
1458 vertices: _,
1459 vertex_first_index: _,
1460 vertex_position: _,
1461 vertex_normal: _,
1462 vertex_uv: _,
1463 vertex_tangent,
1464 vertex_bitangent,
1465 vertex_color,
1466 vertex_crease,
1467 uv_sets,
1468 color_sets,
1469 materials: _,
1470 face_groups,
1471 material_parts: _,
1473 face_group_parts: _,
1474 material_part_usage_order: _,
1475 skinned_is_local: _,
1476 skinned_position: _,
1477 skinned_normal: _,
1478 skin_deformers: _,
1480 blend_deformers: _,
1481 cache_deformers: _,
1482 all_deformers: _,
1483 subdivision_preview_levels,
1484 subdivision_render_levels,
1485 subdivision_display_mode,
1486 subdivision_boundary,
1487 subdivision_uv_boundary,
1488 reversed_winding: _,
1490 generated_normals: _,
1491 subdivision_evaluated,
1492 subdivision_result,
1493 from_tessellated_nurbs,
1494 } = mesh;
1495
1496 let unsupported_generated_payload_present =
1501 !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
1502 || *subdivision_evaluated
1503 || subdivision_result.is_some()
1504 || *from_tessellated_nurbs;
1505 let scale_invariant_conversion_facts_present = !face_smoothing.is_empty()
1506 || !face_group.is_empty()
1507 || !face_hole.is_empty()
1508 || !edges.is_empty()
1509 || !edge_smoothing.is_empty()
1510 || !edge_crease.is_empty()
1511 || !edge_visibility.is_empty()
1512 || vertex_tangent.exists
1513 || vertex_bitangent.exists
1514 || vertex_color.exists
1515 || vertex_crease.exists
1516 || uv_sets.len() > 1
1517 || !color_sets.is_empty()
1518 || !face_groups.is_empty()
1519 || *subdivision_preview_levels > 0
1520 || *subdivision_render_levels > 0
1521 || !matches!(
1522 subdivision_display_mode,
1523 ufbx::SubdivisionDisplayMode::Disabled
1524 )
1525 || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default);
1526
1527 if unsupported_generated_payload_present {
1528 MeshSourcePayloadClassification::Unsupported
1529 } else if scale_invariant_conversion_facts_present {
1530 MeshSourcePayloadClassification::ScaleInvariantConversion
1531 } else {
1532 MeshSourcePayloadClassification::Absent
1533 }
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538 use super::*;
1539 use animsmith_core::{
1540 InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
1541 SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
1542 SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
1543 };
1544 use std::path::PathBuf;
1545
1546 fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
1547 captured_with_counts(configure, |_| {})
1548 }
1549
1550 fn captured_with_counts(
1551 configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
1552 configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
1553 ) -> FbxScaleSource {
1554 let fixture =
1555 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
1556 let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
1557 let document = baseline.document().clone();
1558 let mut inventory = baseline.inventory().clone();
1559 let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
1560 let rest_bind_scale_invariant_payload_mesh_count =
1561 baseline.rest_bind_scale_invariant_payload_mesh_count;
1562 configure_counts(&mut rest_bind_construct_counts);
1563 inventory.user_defined_property_count =
1564 rest_bind_construct_counts.user_defined_property_count;
1565 inventory.unsupported_source_element_count =
1566 rest_bind_construct_counts.total_unmodeled_element_count();
1567 inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
1568 && inventory.unsupported_source_element_count == 0
1569 {
1570 FbxScaleDomainStatus::Absent
1571 } else {
1572 FbxScaleDomainStatus::Unsupported
1573 };
1574 let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
1575 let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
1576 configure(&mut builder);
1577 let source = builder.finish(document).expect("synthetic raw facts bind");
1578 FbxScaleSource {
1579 source,
1580 inventory,
1581 rest_bind_construct_counts,
1582 rest_bind_scale_invariant_payload_mesh_count,
1583 }
1584 }
1585
1586 fn parser_provenance(path: &str) -> SourceProvenanceV1 {
1587 SourceProvenanceV1::parser_projected(
1588 SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
1589 )
1590 }
1591
1592 fn complete_captured_source() -> FbxScaleSource {
1593 captured_with(|builder| {
1594 builder.mark_complete(SourceFactDomainV1::Constructs);
1595 builder.mark_complete(SourceFactDomainV1::Resources);
1596 })
1597 }
1598
1599 #[test]
1600 fn source_aware_rest_bind_admits_only_reconciled_scale_invariant_conversion_facts() {
1601 let mut source = complete_captured_source();
1602 source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1603 source.inventory.unsupported_vertex_payload_mesh_count = 1;
1604 source.rest_bind_scale_invariant_payload_mesh_count = 1;
1605 source.inventory.truncated_influence_vertex_count = 1;
1606 source.inventory.discarded_influence_count = 2;
1607 source.inventory.renormalized_influence_vertex_count = 1;
1608 source.inventory.rejected_influence_count = 1;
1609 source.inventory.non_triangle_face_count = 1;
1610 source.inventory.triangulated_face_count = 1;
1611 source.inventory.post_weld_vertex_count = source.inventory.pre_weld_vertex_count - 1;
1612
1613 assert!(
1614 rest_bind_capability_facts(source.inventory()).is_err(),
1615 "a detached public inventory cannot prove these conversions are scale-invariant"
1616 );
1617 let facts = rest_bind_capability_facts_for_source(&source)
1618 .expect("same-parse enumerated conversion facts are admissible");
1619 assert!(!facts.non_triangle_primitives_present);
1620 assert!(!facts.unsupported_vertex_attributes_present);
1621 assert!(!facts.secondary_skin_influences_present);
1622
1623 source.rest_bind_scale_invariant_payload_mesh_count = 0;
1624 assert_eq!(
1625 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1626 concat!(
1627 "FBX rest/bind capability inventory rejected: ",
1628 "unsupported_vertex_payload_mesh_count=1!=scale_invariant_source:0"
1629 ),
1630 "an unclassified payload cannot hide inside the public aggregate"
1631 );
1632
1633 source.rest_bind_scale_invariant_payload_mesh_count = 1;
1634 source.inventory.missing_skin_influence_corner_count = 1;
1635 assert_eq!(
1636 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1637 concat!(
1638 "FBX rest/bind capability inventory rejected: ",
1639 "missing_skin_influence_corner_count=1"
1640 ),
1641 "conversion evidence never overrides missing effective skin coverage"
1642 );
1643 }
1644
1645 #[test]
1646 fn clip_track_projection_excludes_deformation_but_keeps_animation_gates() {
1647 let mut source = complete_captured_source();
1648 source.inventory.domains.base_mesh_geometry = FbxScaleDomainStatus::Unsupported;
1649 source.inventory.domains.skin_binds = FbxScaleDomainStatus::Unsupported;
1650 source.inventory.domains.morphs = FbxScaleDomainStatus::Unsupported;
1651 source.inventory.domains.cameras_and_lights = FbxScaleDomainStatus::Unsupported;
1652 source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1653 source.inventory.dual_quaternion_skin_count = 1;
1654 source.inventory.incomplete_bind_cluster_count = 1;
1655 source.inventory.blend_deformer_count = 1;
1656 source.inventory.cache_deformer_count = 1;
1657 assert!(rest_bind_capability_facts_for_source(&source).is_err());
1658 require_clip_track_capability_for_source(&source)
1659 .expect("clip-only projection excludes deformation and bind domains");
1660
1661 source.inventory.animation_takes_baked = false;
1662 assert_eq!(
1663 require_clip_track_capability_for_source(&source).unwrap_err(),
1664 "FBX rest/bind clip-track capability inventory rejected: animation_takes_baked=false"
1665 );
1666 source.inventory.animation_takes_baked = true;
1667 source.inventory.domains.out_of_contract_node_transforms =
1668 FbxScaleDomainStatus::Unsupported;
1669 assert_eq!(
1670 require_clip_track_capability_for_source(&source).unwrap_err(),
1671 "FBX rest/bind clip-track capability inventory rejected: domain.out_of_contract_node_transforms=unsupported"
1672 );
1673
1674 macro_rules! required_domain_refuses {
1675 ($field:ident) => {{
1676 let mut source = complete_captured_source();
1677 source.inventory.domains.$field = FbxScaleDomainStatus::Unsupported;
1678 let error = require_clip_track_capability_for_source(&source).unwrap_err();
1679 assert!(
1680 error.contains(concat!("domain.", stringify!($field), "=unsupported")),
1681 "{error}"
1682 );
1683 }};
1684 }
1685 required_domain_refuses!(rest_hierarchy);
1686 required_domain_refuses!(translation_animation);
1687 required_domain_refuses!(rotation_and_scale_animation);
1688 required_domain_refuses!(root_motion_and_velocity);
1689 required_domain_refuses!(out_of_contract_node_transforms);
1690 required_domain_refuses!(animation_targeting_matrix_nodes);
1691
1692 let mut source = complete_captured_source();
1693 source.inventory.domains.rest_hierarchy = FbxScaleDomainStatus::Unverifiable;
1694 assert!(
1695 require_clip_track_capability_for_source(&source)
1696 .unwrap_err()
1697 .contains("domain.rest_hierarchy=unverifiable")
1698 );
1699 }
1700
1701 #[test]
1702 fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
1703 for (source, expected) in [
1704 (
1705 captured_with(|builder| {
1706 builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1707 builder.mark_complete(SourceFactDomainV1::Resources);
1708 }),
1709 "FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
1710 ),
1711 (
1712 captured_with(|builder| {
1713 builder.mark_complete(SourceFactDomainV1::Constructs);
1714 builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
1715 }),
1716 "FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
1717 ),
1718 ] {
1719 assert_eq!(
1720 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1721 expected,
1722 "partial construct/resource coverage must name the exact raw authority"
1723 );
1724 }
1725 }
1726
1727 #[test]
1728 fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
1729 for kind in [
1730 SourceConstructKindV1::UnknownElement,
1731 SourceConstructKindV1::Extension,
1732 ] {
1733 let source = captured_with(|builder| {
1734 builder.push_construct(
1735 SourceConstructFactV1::new(
1736 0,
1737 kind,
1738 SourceTextV1::new("synthetic").expect("bounded test name"),
1739 false,
1740 1,
1741 SourceLoaderDispositionV1::Unsupported,
1742 parser_provenance("fbx:synthetic/construct"),
1743 )
1744 .expect("positive test construct"),
1745 );
1746 builder.mark_complete(SourceFactDomainV1::Constructs);
1747 builder.mark_complete(SourceFactDomainV1::Resources);
1748 });
1749 let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
1750 let expected = match kind {
1751 SourceConstructKindV1::UnknownElement => "unknown_element",
1752 SourceConstructKindV1::Extension => "extension",
1753 SourceConstructKindV1::CustomProperty => unreachable!(),
1754 };
1755 assert_eq!(
1756 error,
1757 format!(
1758 "FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
1759 )
1760 );
1761 }
1762
1763 let custom_and_external = captured_with_counts(
1764 |builder| {
1765 builder.push_construct(
1766 SourceConstructFactV1::new(
1767 0,
1768 SourceConstructKindV1::CustomProperty,
1769 SourceTextV1::new("fbx:user-defined-properties")
1770 .expect("bounded test name"),
1771 false,
1772 1,
1773 SourceLoaderDispositionV1::Unsupported,
1774 parser_provenance("fbx:synthetic/property"),
1775 )
1776 .expect("positive custom-property row"),
1777 );
1778 builder.mark_complete(SourceFactDomainV1::Constructs);
1779 builder.push_resource(SourceResourceReferenceV1::new(
1780 0,
1781 SourceResourceKindV1::Texture,
1782 0,
1783 SourceResourceLocatorV1::classify("texture.png"),
1784 SourceLoaderDispositionV1::Unknown,
1785 parser_provenance("fbx:textures/0/filename"),
1786 ));
1787 builder.mark_complete(SourceFactDomainV1::Resources);
1788 },
1789 |counts| counts.user_defined_property_count = 1,
1790 );
1791 let facts = rest_bind_capability_facts_for_source(&custom_and_external)
1792 .expect("custom properties and external images are not scale-bearing");
1793 assert!(!facts.extras_present);
1794 assert!(!facts.external_resources_present);
1795 assert!(facts.is_supported_for(
1796 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
1797 source_skin_index: 0,
1798 source_root_node_index: 1,
1799 expected_factor: 0.01,
1800 }
1801 ));
1802 }
1803}