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
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum FbxCoordinateAxis {
169 PositiveX,
171 NegativeX,
173 PositiveY,
175 NegativeY,
177 PositiveZ,
179 NegativeZ,
181 Unknown,
183}
184
185impl From<ufbx::CoordinateAxis> for FbxCoordinateAxis {
186 fn from(value: ufbx::CoordinateAxis) -> Self {
187 match value {
188 ufbx::CoordinateAxis::PositiveX => Self::PositiveX,
189 ufbx::CoordinateAxis::NegativeX => Self::NegativeX,
190 ufbx::CoordinateAxis::PositiveY => Self::PositiveY,
191 ufbx::CoordinateAxis::NegativeY => Self::NegativeY,
192 ufbx::CoordinateAxis::PositiveZ => Self::PositiveZ,
193 ufbx::CoordinateAxis::NegativeZ => Self::NegativeZ,
194 ufbx::CoordinateAxis::Unknown => Self::Unknown,
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize)]
201#[serde(deny_unknown_fields)]
202pub struct FbxCoordinateNormalization {
203 pub original_up_axis: FbxCoordinateAxis,
207 pub original_unit_meters: f64,
211 pub target_right_handed_y_up: bool,
213 pub target_unit_meters: f64,
215 pub adjust_transforms: bool,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(deny_unknown_fields)]
222pub struct FbxSourceIdentity {
223 pub source_index: usize,
225 pub ufbx_typed_id: u32,
227 pub ufbx_element_id: u32,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
236#[serde(rename_all = "kebab-case")]
237pub enum FbxBindMatrixProvenance {
238 UfbxConvertedClusterMatrices,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize)]
252#[serde(deny_unknown_fields)]
253pub struct FbxScaleCapabilityInventory {
254 pub domains: FbxScaleDomainInventory,
256 pub coordinate_normalization: FbxCoordinateNormalization,
258 pub animation_takes_baked: bool,
260 pub authored_curve_keys_preserved: bool,
262 pub animation_take_count: usize,
264 pub source_animation_curve_count: usize,
266 pub generated_geometry_helper_node_count: usize,
268 pub generated_scale_helper_node_count: usize,
270 pub inherit_modes_compensated: bool,
272 pub compensated_inherit_node_count: usize,
274 pub generated_normal_mesh_count: usize,
276 pub missing_normal_mesh_count: usize,
278 pub skin_deformer_count: usize,
280 pub skin_cluster_count: usize,
282 pub empty_skin_deformer_count: usize,
284 pub bind_matrix_provenance: FbxBindMatrixProvenance,
286 pub incomplete_bind_cluster_count: usize,
288 pub bone_convenience_bind_overwrite_count: usize,
291 pub identity_bind_defaults_invented: bool,
293 pub truncated_influence_vertex_count: usize,
295 pub discarded_influence_count: usize,
297 pub renormalized_influence_vertex_count: usize,
299 pub rejected_influence_count: usize,
301 pub missing_skin_influence_corner_count: usize,
303 pub non_triangle_face_count: usize,
305 pub triangulated_face_count: usize,
307 pub omitted_non_polygon_face_count: usize,
309 pub empty_mesh_definition_count: usize,
311 pub empty_source_meshes: Vec<FbxSourceIdentity>,
313 pub pre_weld_vertex_count: usize,
315 pub post_weld_vertex_count: usize,
317 pub multiple_skin_deformer_mesh_count: usize,
319 pub dual_quaternion_skin_count: usize,
321 pub blend_deformer_count: usize,
323 pub blend_channel_count: usize,
325 pub blend_shape_count: usize,
327 pub cache_deformer_count: usize,
329 pub unsupported_vertex_payload_mesh_count: usize,
331 pub camera_count: usize,
333 pub light_count: usize,
335 pub shared_mesh_definition_count: usize,
337 pub uninstanced_mesh_definition_count: usize,
339 pub uninstanced_source_meshes: Vec<FbxSourceIdentity>,
341 pub user_defined_property_count: usize,
343 pub unsupported_source_element_count: usize,
345 pub external_resource_count: usize,
347 pub source_nodes: Vec<FbxSourceIdentity>,
349 pub source_meshes: Vec<FbxSourceIdentity>,
351 pub source_skins: Vec<FbxSourceIdentity>,
353}
354
355#[derive(Debug)]
358pub struct FbxScaleSource {
359 pub(crate) source: LoadedSource,
360 pub(crate) inventory: FbxScaleCapabilityInventory,
361 pub(crate) rest_bind_construct_counts: crate::source_facts::RestBindSourceConstructCounts,
368 pub(crate) rest_bind_scale_invariant_payload_mesh_count: usize,
371}
372
373impl FbxScaleSource {
374 pub fn document(&self) -> &Document {
376 self.source.document()
377 }
378
379 pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
381 self.source.source_facts()
382 }
383
384 pub fn dependency_closure(&self) -> &DependencyClosureV1 {
386 self.source.dependency_closure()
387 }
388
389 pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
391 &self.inventory
392 }
393
394 pub fn into_document(self) -> Document {
396 self.source.into_document()
397 }
398
399 pub(crate) fn into_source(self) -> LoadedSource {
400 self.source
401 }
402}
403
404pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
411 let mut facts = ScaleCapabilityFacts::default();
412 facts.coverage = ScaleCapabilityCoverage::Complete;
413 let morph_source_present = inventory.blend_deformer_count > 0
414 || inventory.blend_channel_count > 0
415 || inventory.blend_shape_count > 0;
416 facts.morphs_present = morph_source_present;
417 facts.morph_weights_present = morph_source_present;
418 facts.cameras_present = inventory.camera_count > 0;
419 facts.lights_present = inventory.light_count > 0;
420 facts.instancing_present = inventory.shared_mesh_definition_count > 0;
421 facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
422 facts.extras_present = inventory.user_defined_property_count > 0;
423 facts.unknown_source_members_present = true;
427 facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
428 facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
429 > 0
430 || inventory.uninstanced_mesh_definition_count > 0
431 || inventory.empty_mesh_definition_count > 0
432 || inventory.multiple_skin_deformer_mesh_count > 0
433 || inventory.dual_quaternion_skin_count > 0
434 || inventory.cache_deformer_count > 0
435 || inventory.missing_skin_influence_corner_count > 0
436 || inventory.rejected_influence_count > 0
437 || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
438 facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
439 facts.inverse_bind_issues_present =
440 inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
441 facts.unsafe_accessor_layout_present = true;
445 facts.external_resources_present = inventory.external_resource_count > 0;
446 facts
447}
448
449pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
455 join_source_facts(source, capability_facts(source.inventory()))
456}
457
458fn join_source_facts(
459 source: &FbxScaleSource,
460 mut facts: ScaleCapabilityFacts,
461) -> ScaleCapabilityFacts {
462 let source_facts = source.source_facts();
463 if [
464 source_facts.constructs().coverage().state(),
465 source_facts.resources().coverage().state(),
466 ]
467 .into_iter()
468 .any(|state| state != SourceSetCoverageStateV1::Complete)
469 {
470 facts.coverage = ScaleCapabilityCoverage::Unavailable;
471 }
472 for row in source_facts.constructs().rows() {
473 match row.kind() {
474 SourceConstructKindV1::CustomProperty => facts.extras_present = true,
475 SourceConstructKindV1::UnknownElement => {
476 facts.unknown_source_members_present = true;
477 }
478 SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
479 }
480 }
481 if source_facts.resources().rows().iter().any(|row| {
482 !matches!(
483 row.locator(),
484 SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
485 )
486 }) {
487 facts.external_resources_present = true;
488 }
489 facts
490}
491
492pub fn rest_bind_capability_facts(
509 inventory: &FbxScaleCapabilityInventory,
510) -> Result<ScaleCapabilityFacts, String> {
511 if inventory.external_resource_count != 0 {
512 return Err(format_rest_bind_violations(
513 "capability inventory",
514 &[format!(
515 "external_resource_count={}",
516 inventory.external_resource_count
517 )],
518 ));
519 }
520 rest_bind_capability_facts_with_context(inventory, RestBindCapabilityContext::InventoryOnly)
521}
522
523#[derive(Debug, Clone, Copy)]
524enum RestBindCapabilityContext<'a> {
525 InventoryOnly,
526 CapturedSource {
527 counts: &'a crate::source_facts::RestBindSourceConstructCounts,
528 scale_invariant_payload_mesh_count: usize,
529 },
530}
531
532fn rest_bind_capability_facts_with_context(
533 inventory: &FbxScaleCapabilityInventory,
534 context: RestBindCapabilityContext<'_>,
535) -> Result<ScaleCapabilityFacts, String> {
536 let captured_source = match context {
537 RestBindCapabilityContext::InventoryOnly => None,
538 RestBindCapabilityContext::CapturedSource {
539 counts,
540 scale_invariant_payload_mesh_count,
541 } => Some((counts, scale_invariant_payload_mesh_count)),
542 };
543 let mut violations = Vec::new();
544 for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
545 if domain == "other_vertex_and_source_data" && captured_source.is_some() {
546 continue;
547 }
548 if matches!(
549 status,
550 FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
551 ) {
552 violations.push(format!(
553 "domain.{domain}={}",
554 fbx_scale_domain_status_name(status)
555 ));
556 }
557 }
558 for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
559 if status != FbxScaleDomainStatus::Unverifiable {
560 violations.push(format!(
561 "domain.{domain}={} (expected unverifiable)",
562 fbx_scale_domain_status_name(status)
563 ));
564 }
565 }
566
567 let expected_custom_status = if inventory.unsupported_source_element_count == 0
568 && inventory.user_defined_property_count == 0
569 {
570 FbxScaleDomainStatus::Absent
571 } else {
572 FbxScaleDomainStatus::Unsupported
573 };
574 if inventory.domains.collision_and_custom_data != expected_custom_status {
575 violations.push(format!(
576 "domain.collision_and_custom_data={} (expected {})",
577 fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
578 fbx_scale_domain_status_name(expected_custom_status)
579 ));
580 }
581 if let Some((source_counts, _)) = captured_source {
582 if inventory.user_defined_property_count != source_counts.user_defined_property_count {
583 violations.push(format!(
584 "user_defined_property_count={}!=source:{}",
585 inventory.user_defined_property_count, source_counts.user_defined_property_count
586 ));
587 }
588 let source_unmodeled_count = source_counts.total_unmodeled_element_count();
589 if inventory.unsupported_source_element_count != source_unmodeled_count {
590 violations.push(format!(
591 "unsupported_source_element_count={}!=source:{}",
592 inventory.unsupported_source_element_count, source_unmodeled_count
593 ));
594 }
595 push_unmodeled_element_violation(&mut violations, *source_counts);
596 } else {
597 push_nonzero_violation(
598 &mut violations,
599 "unsupported_source_element_count",
600 inventory.unsupported_source_element_count,
601 );
602 }
603
604 if !inventory.coordinate_normalization.target_right_handed_y_up {
605 violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
606 }
607 if inventory.coordinate_normalization.target_unit_meters != 1.0 {
608 violations.push(format!(
609 "coordinate_normalization.target_unit_meters={}",
610 inventory.coordinate_normalization.target_unit_meters
611 ));
612 }
613 if !inventory.coordinate_normalization.adjust_transforms {
614 violations.push("coordinate_normalization.adjust_transforms=false".into());
615 }
616 if !inventory.animation_takes_baked {
617 violations.push("animation_takes_baked=false".into());
618 }
619 if inventory.authored_curve_keys_preserved {
620 violations.push("authored_curve_keys_preserved=true".into());
621 }
622 if !inventory.inherit_modes_compensated {
623 violations.push("inherit_modes_compensated=false".into());
624 }
625 if inventory.identity_bind_defaults_invented {
626 violations.push("identity_bind_defaults_invented=true".into());
627 }
628 for (name, value) in [
629 ("blend_deformer_count", inventory.blend_deformer_count),
630 ("blend_channel_count", inventory.blend_channel_count),
631 ("blend_shape_count", inventory.blend_shape_count),
632 ("camera_count", inventory.camera_count),
633 ("light_count", inventory.light_count),
634 (
635 "shared_mesh_definition_count",
636 inventory.shared_mesh_definition_count,
637 ),
638 (
639 "uninstanced_mesh_definition_count",
640 inventory.uninstanced_mesh_definition_count,
641 ),
642 (
643 "empty_mesh_definition_count",
644 inventory.empty_mesh_definition_count,
645 ),
646 (
647 "multiple_skin_deformer_mesh_count",
648 inventory.multiple_skin_deformer_mesh_count,
649 ),
650 (
651 "dual_quaternion_skin_count",
652 inventory.dual_quaternion_skin_count,
653 ),
654 ("cache_deformer_count", inventory.cache_deformer_count),
655 (
656 "incomplete_bind_cluster_count",
657 inventory.incomplete_bind_cluster_count,
658 ),
659 (
660 "empty_skin_deformer_count",
661 inventory.empty_skin_deformer_count,
662 ),
663 (
664 "missing_normal_mesh_count",
665 inventory.missing_normal_mesh_count,
666 ),
667 (
668 "bone_convenience_bind_overwrite_count",
669 inventory.bone_convenience_bind_overwrite_count,
670 ),
671 (
672 "missing_skin_influence_corner_count",
673 inventory.missing_skin_influence_corner_count,
674 ),
675 (
676 "omitted_non_polygon_face_count",
677 inventory.omitted_non_polygon_face_count,
678 ),
679 ] {
680 push_nonzero_violation(&mut violations, name, value);
681 }
682 if let Some((_, scale_invariant_payload_mesh_count)) = captured_source {
683 if inventory.unsupported_vertex_payload_mesh_count != scale_invariant_payload_mesh_count {
684 violations.push(format!(
685 "unsupported_vertex_payload_mesh_count={}!=scale_invariant_source:{}",
686 inventory.unsupported_vertex_payload_mesh_count, scale_invariant_payload_mesh_count
687 ));
688 }
689 } else {
690 for (name, value) in [
691 (
692 "unsupported_vertex_payload_mesh_count",
693 inventory.unsupported_vertex_payload_mesh_count,
694 ),
695 (
696 "truncated_influence_vertex_count",
697 inventory.truncated_influence_vertex_count,
698 ),
699 (
700 "discarded_influence_count",
701 inventory.discarded_influence_count,
702 ),
703 (
704 "renormalized_influence_vertex_count",
705 inventory.renormalized_influence_vertex_count,
706 ),
707 (
708 "rejected_influence_count",
709 inventory.rejected_influence_count,
710 ),
711 ("non_triangle_face_count", inventory.non_triangle_face_count),
712 ("triangulated_face_count", inventory.triangulated_face_count),
713 ] {
714 push_nonzero_violation(&mut violations, name, value);
715 }
716 if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
717 violations.push(format!(
718 "weld_vertex_count={}!=post:{}",
719 inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
720 ));
721 }
722 }
723 if !violations.is_empty() {
724 return Err(format_rest_bind_violations(
725 "capability inventory",
726 &violations,
727 ));
728 }
729
730 let mut facts = capability_facts(inventory);
737 facts.unknown_source_members_present = false;
738 facts.unregistered_extensions_present = false;
739 facts.unsafe_accessor_layout_present = false;
740 facts.extras_present = false;
741 facts.external_resources_present = false;
742 if captured_source.is_some() {
743 facts.non_triangle_primitives_present = false;
744 facts.unsupported_vertex_attributes_present = false;
745 facts.secondary_skin_influences_present = false;
746 }
747 if !facts.is_supported_for(
748 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
749 source_skin_index: 0,
750 source_root_node_index: 0,
751 expected_factor: 1.0,
752 },
753 ) {
754 return Err(format_rest_bind_violations(
755 "projected capability",
756 &scale_capability_violations(&facts),
757 ));
758 }
759 Ok(facts)
760}
761
762pub fn rest_bind_capability_facts_for_source(
774 source: &FbxScaleSource,
775) -> Result<ScaleCapabilityFacts, String> {
776 let source_facts = source.source_facts();
777 let source_counts = source.rest_bind_construct_counts;
778 let mut violations = Vec::new();
779 for (domain, state) in [
780 ("constructs", source_facts.constructs().coverage().state()),
781 ("resources", source_facts.resources().coverage().state()),
782 ] {
783 if state != SourceSetCoverageStateV1::Complete {
784 violations.push(format!(
785 "raw_source.{domain}.coverage={}",
786 source_set_coverage_state_name(state)
787 ));
788 }
789 }
790 let mut saw_custom_properties = false;
791 let mut saw_unmodeled_elements = false;
792 for row in source_facts.constructs().rows() {
793 match row.kind() {
794 SourceConstructKindV1::CustomProperty
795 if row.name().as_str() == "fbx:user-defined-properties" =>
796 {
797 saw_custom_properties = true;
798 if row.count()
799 != u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
800 {
801 violations.push(format!(
802 "raw_source.construct=custom_property({}; count={})!=source:{}",
803 row.name().as_str(),
804 row.count(),
805 source_counts.user_defined_property_count
806 ));
807 }
808 }
809 SourceConstructKindV1::CustomProperty => violations.push(format!(
810 "raw_source.construct=custom_property({}; count={})",
811 row.name().as_str(),
812 row.count()
813 )),
814 SourceConstructKindV1::UnknownElement
815 if row.name().as_str() == "fbx:unmodeled-elements" =>
816 {
817 saw_unmodeled_elements = true;
818 let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
819 .unwrap_or(u64::MAX);
820 if row.count() != total_count {
821 violations.push(format!(
822 "raw_source.construct=unknown_element({}; count={})!=source:{}",
823 row.name().as_str(),
824 row.count(),
825 total_count
826 ));
827 } else if source_counts.unsupported_unmodeled_element_count() > 0 {
828 violations.push(format!(
829 "raw_source.construct=unknown_element({}; {})",
830 row.name().as_str(),
831 unmodeled_element_details(source_counts)
832 ));
833 }
834 }
835 SourceConstructKindV1::UnknownElement => violations.push(format!(
836 "raw_source.construct=unknown_element({}; count={})",
837 row.name().as_str(),
838 row.count()
839 )),
840 SourceConstructKindV1::Extension => violations.push(format!(
841 "raw_source.construct=extension({}; count={})",
842 row.name().as_str(),
843 row.count()
844 )),
845 }
846 }
847 if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
848 violations.push(format!(
849 "raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
850 source_counts.user_defined_property_count
851 ));
852 }
853 if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
854 violations.push(format!(
855 "raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
856 source_counts.total_unmodeled_element_count()
857 ));
858 }
859 if !violations.is_empty() {
860 return Err(format_rest_bind_violations("raw-source facts", &violations));
861 }
862
863 let mut facts = join_source_facts(
864 source,
865 rest_bind_capability_facts_with_context(
866 source.inventory(),
867 RestBindCapabilityContext::CapturedSource {
868 counts: &source_counts,
869 scale_invariant_payload_mesh_count: source
870 .rest_bind_scale_invariant_payload_mesh_count,
871 },
872 )?,
873 );
874 facts.unknown_source_members_present = false;
875 facts.unregistered_extensions_present = false;
876 facts.extras_present = false;
877 facts.external_resources_present = false;
878 if facts.is_supported_for(
879 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
880 source_skin_index: 0,
881 source_root_node_index: 0,
882 expected_factor: 1.0,
883 },
884 ) {
885 Ok(facts)
886 } else {
887 Err(format_rest_bind_violations(
888 "joined capability",
889 &scale_capability_violations(&facts),
890 ))
891 }
892}
893
894fn push_unmodeled_element_violation(
895 violations: &mut Vec<String>,
896 counts: crate::source_facts::RestBindSourceConstructCounts,
897) {
898 if counts.unsupported_unmodeled_element_count() > 0 {
899 violations.push(format!(
900 "unsupported_source_element_count; {}",
901 unmodeled_element_details(counts)
902 ));
903 }
904}
905
906fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
907 let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
908 for (kind, count) in counts.unsupported_kind_counts() {
909 details.push_str("; ");
910 details.push_str(kind);
911 details.push('=');
912 details.push_str(&count.to_string());
913 }
914 details
915}
916
917fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
918 match status {
919 FbxScaleDomainStatus::Absent => "absent",
920 FbxScaleDomainStatus::Normalized => "normalized",
921 FbxScaleDomainStatus::Baked => "baked",
922 FbxScaleDomainStatus::Derived => "derived",
923 FbxScaleDomainStatus::Rebuilt => "rebuilt",
924 FbxScaleDomainStatus::Unsupported => "unsupported",
925 FbxScaleDomainStatus::Unverifiable => "unverifiable",
926 }
927}
928
929fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
930 match state {
931 SourceSetCoverageStateV1::Complete => "complete",
932 SourceSetCoverageStateV1::Partial => "partial",
933 SourceSetCoverageStateV1::Unavailable => "unavailable",
934 }
935}
936
937fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
938 if value > 0 {
939 violations.push(format!("{name}={value}"));
940 }
941}
942
943fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
944 format!(
945 "FBX rest/bind {authority} rejected: {}",
946 violations.join("; ")
947 )
948}
949
950fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
951 let mut violations = Vec::new();
952 if facts.coverage != ScaleCapabilityCoverage::Complete {
953 violations.push("coverage=unavailable".into());
954 }
955 for (name, present) in [
956 ("morphs_present", facts.morphs_present),
957 ("morph_weights_present", facts.morph_weights_present),
958 ("cameras_present", facts.cameras_present),
959 ("lights_present", facts.lights_present),
960 ("instancing_present", facts.instancing_present),
961 (
962 "unregistered_extensions_present",
963 facts.unregistered_extensions_present,
964 ),
965 ("extras_present", facts.extras_present),
966 (
967 "unknown_source_members_present",
968 facts.unknown_source_members_present,
969 ),
970 (
971 "non_triangle_primitives_present",
972 facts.non_triangle_primitives_present,
973 ),
974 (
975 "unsupported_vertex_attributes_present",
976 facts.unsupported_vertex_attributes_present,
977 ),
978 (
979 "secondary_skin_influences_present",
980 facts.secondary_skin_influences_present,
981 ),
982 (
983 "inverse_bind_issues_present",
984 facts.inverse_bind_issues_present,
985 ),
986 (
987 "unsafe_accessor_layout_present",
988 facts.unsafe_accessor_layout_present,
989 ),
990 (
991 "external_resources_present",
992 facts.external_resources_present,
993 ),
994 ] {
995 if present {
996 violations.push(format!("{name}=true"));
997 }
998 }
999 violations
1000}
1001
1002#[derive(Debug, Default)]
1003pub(crate) struct AssetConversionFacts {
1004 pub(crate) truncated_influence_vertex_count: usize,
1005 pub(crate) discarded_influence_count: usize,
1006 pub(crate) renormalized_influence_vertex_count: usize,
1007 pub(crate) rejected_influence_count: usize,
1008 pub(crate) missing_skin_influence_corner_count: usize,
1009 pub(crate) pre_weld_vertex_count: usize,
1010 pub(crate) post_weld_vertex_count: usize,
1011}
1012
1013#[derive(Debug, Clone, Copy, Default)]
1014pub(crate) struct RestBindMeshPayloadCounts {
1015 pub(crate) unsupported_mesh_count: usize,
1016 pub(crate) scale_invariant_mesh_count: usize,
1017}
1018
1019fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
1020 FbxSourceIdentity {
1021 source_index: index,
1022 ufbx_typed_id: element.typed_id,
1023 ufbx_element_id: element.element_id,
1024 }
1025}
1026
1027pub(crate) fn inventory(
1028 scene: &ufbx::Scene,
1029 conversion: &AssetConversionFacts,
1030 construct_counts: crate::source_facts::SourceConstructCounts,
1031) -> (FbxScaleCapabilityInventory, RestBindMeshPayloadCounts) {
1032 let non_triangle_face_count = scene
1033 .meshes
1034 .iter()
1035 .flat_map(|mesh| mesh.faces.iter())
1036 .filter(|face| face.num_indices != 3)
1037 .count();
1038 let triangulated_face_count = scene
1039 .meshes
1040 .iter()
1041 .flat_map(|mesh| mesh.faces.iter())
1042 .filter(|face| face.num_indices > 3)
1043 .count();
1044 let omitted_non_polygon_face_count = scene
1045 .meshes
1046 .iter()
1047 .flat_map(|mesh| mesh.faces.iter())
1048 .filter(|face| face.num_indices < 3)
1049 .count();
1050 let empty_source_meshes = scene
1051 .meshes
1052 .iter()
1053 .enumerate()
1054 .filter(|(_, mesh)| mesh.faces.is_empty())
1055 .map(|(index, mesh)| identity(index, &mesh.element))
1056 .collect::<Vec<_>>();
1057 let empty_mesh_definition_count = empty_source_meshes.len();
1058 let generated_normal_mesh_count = scene
1059 .meshes
1060 .iter()
1061 .filter(|mesh| mesh.generated_normals)
1062 .count();
1063 let missing_normal_mesh_count = scene
1064 .meshes
1065 .iter()
1066 .filter(|mesh| !mesh.vertex_normal.exists)
1067 .count();
1068 let skin_cluster_count = scene
1069 .skin_deformers
1070 .iter()
1071 .map(|skin| skin.clusters.len())
1072 .sum();
1073 let empty_skin_deformer_count = scene
1074 .skin_deformers
1075 .iter()
1076 .filter(|skin| skin.clusters.is_empty())
1077 .count();
1078 let incomplete_bind_cluster_count = scene
1079 .skin_clusters
1080 .iter()
1081 .filter(|cluster| super::project_cluster_bind(cluster).is_none())
1082 .count();
1083 let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
1084 for cluster in &scene.skin_clusters {
1085 if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
1086 *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
1087 }
1088 }
1089 let bone_convenience_bind_overwrite_count = clusters_per_bone
1090 .values()
1091 .map(|count| count.saturating_sub(1))
1092 .sum();
1093 let multiple_skin_deformer_mesh_count = scene
1094 .meshes
1095 .iter()
1096 .filter(|mesh| mesh.skin_deformers.len() > 1)
1097 .count();
1098 let dual_quaternion_skin_count = scene
1099 .skin_deformers
1100 .iter()
1101 .filter(|skin| {
1102 skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
1103 })
1104 .count();
1105 let mesh_payload_counts = scene
1106 .meshes
1107 .iter()
1108 .map(|mesh| classify_mesh_source_payload(mesh))
1109 .fold(
1110 RestBindMeshPayloadCounts::default(),
1111 |mut counts, classification| {
1112 match classification {
1113 MeshSourcePayloadClassification::Absent => {}
1114 MeshSourcePayloadClassification::ScaleInvariantConversion => {
1115 counts.unsupported_mesh_count += 1;
1116 counts.scale_invariant_mesh_count += 1;
1117 }
1118 MeshSourcePayloadClassification::Unsupported => {
1119 counts.unsupported_mesh_count += 1;
1120 }
1121 }
1122 counts
1123 },
1124 );
1125 let unsupported_vertex_payload_mesh_count = mesh_payload_counts.unsupported_mesh_count;
1126 let shared_mesh_definition_count = scene
1127 .meshes
1128 .iter()
1129 .filter(|mesh| mesh.element.instances.len() > 1)
1130 .count();
1131 let uninstanced_source_meshes = scene
1132 .meshes
1133 .iter()
1134 .enumerate()
1135 .filter(|(_, mesh)| mesh.element.instances.is_empty())
1136 .map(|(index, mesh)| identity(index, &mesh.element))
1137 .collect::<Vec<_>>();
1138 let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
1139 let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
1140 let unsupported_source_element_count =
1141 construct_counts.rest_bind.total_unmodeled_element_count();
1142 let external_resource_count = scene
1143 .textures
1144 .iter()
1145 .filter(|texture| texture.content.is_empty() && texture.has_file)
1146 .count()
1147 + scene
1148 .videos
1149 .iter()
1150 .filter(|video| {
1151 video.content.is_empty()
1152 && (!video.filename.is_empty()
1153 || !video.relative_filename.is_empty()
1154 || !video.absolute_filename.is_empty())
1155 })
1156 .count();
1157 let compensated_inherit_node_count = scene
1158 .nodes
1159 .iter()
1160 .filter(|node| {
1161 node.original_inherit_mode != node.inherit_mode
1162 || node.is_scale_helper
1163 || node.is_scale_compensate_parent
1164 })
1165 .count();
1166
1167 let stackless_animation_present = scene.anim_stacks.is_empty()
1168 && (!scene.anim_layers.is_empty()
1169 || !scene.anim_values.is_empty()
1170 || !scene.anim_curves.is_empty());
1171 let animation = if !scene.anim_stacks.is_empty() {
1172 FbxScaleDomainStatus::Baked
1173 } else if stackless_animation_present {
1174 FbxScaleDomainStatus::Unsupported
1177 } else {
1178 FbxScaleDomainStatus::Absent
1179 };
1180 let domains = FbxScaleDomainInventory {
1181 rest_hierarchy: FbxScaleDomainStatus::Normalized,
1182 translation_animation: animation,
1183 rotation_and_scale_animation: animation,
1184 root_motion_and_velocity: match animation {
1185 FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
1186 status => status,
1187 },
1188 base_mesh_geometry: if scene.meshes.is_empty() {
1189 FbxScaleDomainStatus::Absent
1190 } else if uninstanced_mesh_definition_count > 0
1191 || omitted_non_polygon_face_count > 0
1192 || empty_mesh_definition_count > 0
1193 {
1194 FbxScaleDomainStatus::Unsupported
1195 } else {
1196 FbxScaleDomainStatus::Rebuilt
1197 },
1198 morphs: if scene.blend_deformers.is_empty()
1199 && scene.blend_channels.is_empty()
1200 && scene.blend_shapes.is_empty()
1201 {
1202 FbxScaleDomainStatus::Absent
1203 } else {
1204 FbxScaleDomainStatus::Unsupported
1205 },
1206 skin_binds: if scene.skin_deformers.is_empty() {
1207 FbxScaleDomainStatus::Absent
1208 } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
1209 FbxScaleDomainStatus::Unsupported
1210 } else {
1211 FbxScaleDomainStatus::Derived
1212 },
1213 cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
1214 FbxScaleDomainStatus::Absent
1215 } else {
1216 FbxScaleDomainStatus::Unsupported
1217 },
1218 collision_and_custom_data: if unsupported_source_element_count == 0
1219 && user_defined_property_count == 0
1220 {
1221 FbxScaleDomainStatus::Absent
1222 } else {
1223 FbxScaleDomainStatus::Unsupported
1224 },
1225 other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
1226 || uninstanced_mesh_definition_count > 0
1227 || omitted_non_polygon_face_count > 0
1228 || empty_mesh_definition_count > 0
1229 || multiple_skin_deformer_mesh_count > 0
1230 || dual_quaternion_skin_count > 0
1231 || conversion.truncated_influence_vertex_count > 0
1232 || conversion.missing_skin_influence_corner_count > 0
1233 || conversion.rejected_influence_count > 0
1234 || !scene.blend_deformers.is_empty()
1235 || !scene.blend_channels.is_empty()
1236 || !scene.blend_shapes.is_empty()
1237 || !scene.cache_deformers.is_empty()
1238 || !scene.cache_files.is_empty()
1239 {
1240 FbxScaleDomainStatus::Unsupported
1241 } else if !scene.meshes.is_empty() {
1242 FbxScaleDomainStatus::Rebuilt
1243 } else {
1244 FbxScaleDomainStatus::Absent
1245 },
1246 out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
1247 animation_targeting_matrix_nodes: animation,
1248 shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1249 unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1250 image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
1251 };
1252
1253 let inventory = FbxScaleCapabilityInventory {
1254 domains,
1255 coordinate_normalization: FbxCoordinateNormalization {
1256 original_up_axis: scene.settings.original_axis_up.into(),
1257 original_unit_meters: scene.settings.original_unit_meters,
1258 target_right_handed_y_up: true,
1259 target_unit_meters: 1.0,
1260 adjust_transforms: matches!(
1261 scene.metadata.space_conversion,
1262 ufbx::SpaceConversion::AdjustTransforms
1263 ),
1264 },
1265 animation_takes_baked: true,
1266 authored_curve_keys_preserved: false,
1267 animation_take_count: scene.anim_stacks.len(),
1268 source_animation_curve_count: scene.anim_curves.len(),
1269 generated_geometry_helper_node_count: scene
1270 .nodes
1271 .iter()
1272 .filter(|node| node.is_geometry_transform_helper)
1273 .count(),
1274 generated_scale_helper_node_count: scene
1275 .nodes
1276 .iter()
1277 .filter(|node| node.is_scale_helper)
1278 .count(),
1279 inherit_modes_compensated: matches!(
1280 scene.metadata.inherit_mode_handling,
1281 ufbx::InheritModeHandling::Compensate
1282 ),
1283 compensated_inherit_node_count,
1284 generated_normal_mesh_count,
1285 missing_normal_mesh_count,
1286 skin_deformer_count: scene.skin_deformers.len(),
1287 skin_cluster_count,
1288 empty_skin_deformer_count,
1289 bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
1290 incomplete_bind_cluster_count,
1291 bone_convenience_bind_overwrite_count,
1292 identity_bind_defaults_invented: false,
1293 truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
1294 discarded_influence_count: conversion.discarded_influence_count,
1295 renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
1296 rejected_influence_count: conversion.rejected_influence_count,
1297 missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
1298 non_triangle_face_count,
1299 triangulated_face_count,
1300 omitted_non_polygon_face_count,
1301 empty_mesh_definition_count,
1302 empty_source_meshes,
1303 pre_weld_vertex_count: conversion.pre_weld_vertex_count,
1304 post_weld_vertex_count: conversion.post_weld_vertex_count,
1305 multiple_skin_deformer_mesh_count,
1306 dual_quaternion_skin_count,
1307 blend_deformer_count: scene.blend_deformers.len(),
1308 blend_channel_count: scene.blend_channels.len(),
1309 blend_shape_count: scene.blend_shapes.len(),
1310 cache_deformer_count: scene.cache_deformers.len(),
1311 unsupported_vertex_payload_mesh_count,
1312 camera_count: scene.cameras.len(),
1313 light_count: scene.lights.len(),
1314 shared_mesh_definition_count,
1315 uninstanced_mesh_definition_count,
1316 uninstanced_source_meshes,
1317 user_defined_property_count,
1318 unsupported_source_element_count,
1319 external_resource_count,
1320 source_nodes: scene
1321 .nodes
1322 .iter()
1323 .enumerate()
1324 .map(|(index, node)| identity(index, &node.element))
1325 .collect(),
1326 source_meshes: scene
1327 .meshes
1328 .iter()
1329 .enumerate()
1330 .map(|(index, mesh)| identity(index, &mesh.element))
1331 .collect(),
1332 source_skins: scene
1333 .skin_deformers
1334 .iter()
1335 .enumerate()
1336 .map(|(index, skin)| identity(index, &skin.element))
1337 .collect(),
1338 };
1339 (inventory, mesh_payload_counts)
1340}
1341
1342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1346enum MeshSourcePayloadClassification {
1347 Absent,
1348 ScaleInvariantConversion,
1349 Unsupported,
1350}
1351
1352fn classify_mesh_source_payload(mesh: &ufbx::Mesh) -> MeshSourcePayloadClassification {
1353 let ufbx::Mesh {
1354 element: _,
1355 num_vertices: _,
1356 num_indices: _,
1357 num_faces: _,
1358 num_triangles: _,
1359 num_edges: _,
1360 max_face_triangles: _,
1361 num_empty_faces: _,
1362 num_point_faces: _,
1363 num_line_faces: _,
1364 faces: _,
1365 face_smoothing,
1367 face_material: _,
1368 face_group,
1369 face_hole,
1370 edges,
1371 edge_smoothing,
1372 edge_crease,
1373 edge_visibility,
1374 vertex_indices: _,
1375 vertices: _,
1376 vertex_first_index: _,
1377 vertex_position: _,
1378 vertex_normal: _,
1379 vertex_uv: _,
1380 vertex_tangent,
1381 vertex_bitangent,
1382 vertex_color,
1383 vertex_crease,
1384 uv_sets,
1385 color_sets,
1386 materials: _,
1387 face_groups,
1388 material_parts: _,
1390 face_group_parts: _,
1391 material_part_usage_order: _,
1392 skinned_is_local: _,
1393 skinned_position: _,
1394 skinned_normal: _,
1395 skin_deformers: _,
1397 blend_deformers: _,
1398 cache_deformers: _,
1399 all_deformers: _,
1400 subdivision_preview_levels,
1401 subdivision_render_levels,
1402 subdivision_display_mode,
1403 subdivision_boundary,
1404 subdivision_uv_boundary,
1405 reversed_winding: _,
1407 generated_normals: _,
1408 subdivision_evaluated,
1409 subdivision_result,
1410 from_tessellated_nurbs,
1411 } = mesh;
1412
1413 let unsupported_generated_payload_present =
1418 !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
1419 || *subdivision_evaluated
1420 || subdivision_result.is_some()
1421 || *from_tessellated_nurbs;
1422 let scale_invariant_conversion_facts_present = !face_smoothing.is_empty()
1423 || !face_group.is_empty()
1424 || !face_hole.is_empty()
1425 || !edges.is_empty()
1426 || !edge_smoothing.is_empty()
1427 || !edge_crease.is_empty()
1428 || !edge_visibility.is_empty()
1429 || vertex_tangent.exists
1430 || vertex_bitangent.exists
1431 || vertex_color.exists
1432 || vertex_crease.exists
1433 || uv_sets.len() > 1
1434 || !color_sets.is_empty()
1435 || !face_groups.is_empty()
1436 || *subdivision_preview_levels > 0
1437 || *subdivision_render_levels > 0
1438 || !matches!(
1439 subdivision_display_mode,
1440 ufbx::SubdivisionDisplayMode::Disabled
1441 )
1442 || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default);
1443
1444 if unsupported_generated_payload_present {
1445 MeshSourcePayloadClassification::Unsupported
1446 } else if scale_invariant_conversion_facts_present {
1447 MeshSourcePayloadClassification::ScaleInvariantConversion
1448 } else {
1449 MeshSourcePayloadClassification::Absent
1450 }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456 use animsmith_core::{
1457 InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
1458 SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
1459 SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
1460 };
1461 use std::path::PathBuf;
1462
1463 fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
1464 captured_with_counts(configure, |_| {})
1465 }
1466
1467 fn captured_with_counts(
1468 configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
1469 configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
1470 ) -> FbxScaleSource {
1471 let fixture =
1472 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
1473 let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
1474 let document = baseline.document().clone();
1475 let mut inventory = baseline.inventory().clone();
1476 let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
1477 let rest_bind_scale_invariant_payload_mesh_count =
1478 baseline.rest_bind_scale_invariant_payload_mesh_count;
1479 configure_counts(&mut rest_bind_construct_counts);
1480 inventory.user_defined_property_count =
1481 rest_bind_construct_counts.user_defined_property_count;
1482 inventory.unsupported_source_element_count =
1483 rest_bind_construct_counts.total_unmodeled_element_count();
1484 inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
1485 && inventory.unsupported_source_element_count == 0
1486 {
1487 FbxScaleDomainStatus::Absent
1488 } else {
1489 FbxScaleDomainStatus::Unsupported
1490 };
1491 let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
1492 let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
1493 configure(&mut builder);
1494 let source = builder.finish(document).expect("synthetic raw facts bind");
1495 FbxScaleSource {
1496 source,
1497 inventory,
1498 rest_bind_construct_counts,
1499 rest_bind_scale_invariant_payload_mesh_count,
1500 }
1501 }
1502
1503 fn parser_provenance(path: &str) -> SourceProvenanceV1 {
1504 SourceProvenanceV1::parser_projected(
1505 SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
1506 )
1507 }
1508
1509 fn complete_captured_source() -> FbxScaleSource {
1510 captured_with(|builder| {
1511 builder.mark_complete(SourceFactDomainV1::Constructs);
1512 builder.mark_complete(SourceFactDomainV1::Resources);
1513 })
1514 }
1515
1516 #[test]
1517 fn source_aware_rest_bind_admits_only_reconciled_scale_invariant_conversion_facts() {
1518 let mut source = complete_captured_source();
1519 source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1520 source.inventory.unsupported_vertex_payload_mesh_count = 1;
1521 source.rest_bind_scale_invariant_payload_mesh_count = 1;
1522 source.inventory.truncated_influence_vertex_count = 1;
1523 source.inventory.discarded_influence_count = 2;
1524 source.inventory.renormalized_influence_vertex_count = 1;
1525 source.inventory.rejected_influence_count = 1;
1526 source.inventory.non_triangle_face_count = 1;
1527 source.inventory.triangulated_face_count = 1;
1528 source.inventory.post_weld_vertex_count = source.inventory.pre_weld_vertex_count - 1;
1529
1530 assert!(
1531 rest_bind_capability_facts(source.inventory()).is_err(),
1532 "a detached public inventory cannot prove these conversions are scale-invariant"
1533 );
1534 let facts = rest_bind_capability_facts_for_source(&source)
1535 .expect("same-parse enumerated conversion facts are admissible");
1536 assert!(!facts.non_triangle_primitives_present);
1537 assert!(!facts.unsupported_vertex_attributes_present);
1538 assert!(!facts.secondary_skin_influences_present);
1539
1540 source.rest_bind_scale_invariant_payload_mesh_count = 0;
1541 assert_eq!(
1542 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1543 concat!(
1544 "FBX rest/bind capability inventory rejected: ",
1545 "unsupported_vertex_payload_mesh_count=1!=scale_invariant_source:0"
1546 ),
1547 "an unclassified payload cannot hide inside the public aggregate"
1548 );
1549
1550 source.rest_bind_scale_invariant_payload_mesh_count = 1;
1551 source.inventory.missing_skin_influence_corner_count = 1;
1552 assert_eq!(
1553 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1554 concat!(
1555 "FBX rest/bind capability inventory rejected: ",
1556 "missing_skin_influence_corner_count=1"
1557 ),
1558 "conversion evidence never overrides missing effective skin coverage"
1559 );
1560 }
1561
1562 #[test]
1563 fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
1564 for (source, expected) in [
1565 (
1566 captured_with(|builder| {
1567 builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1568 builder.mark_complete(SourceFactDomainV1::Resources);
1569 }),
1570 "FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
1571 ),
1572 (
1573 captured_with(|builder| {
1574 builder.mark_complete(SourceFactDomainV1::Constructs);
1575 builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
1576 }),
1577 "FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
1578 ),
1579 ] {
1580 assert_eq!(
1581 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1582 expected,
1583 "partial construct/resource coverage must name the exact raw authority"
1584 );
1585 }
1586 }
1587
1588 #[test]
1589 fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
1590 for kind in [
1591 SourceConstructKindV1::UnknownElement,
1592 SourceConstructKindV1::Extension,
1593 ] {
1594 let source = captured_with(|builder| {
1595 builder.push_construct(
1596 SourceConstructFactV1::new(
1597 0,
1598 kind,
1599 SourceTextV1::new("synthetic").expect("bounded test name"),
1600 false,
1601 1,
1602 SourceLoaderDispositionV1::Unsupported,
1603 parser_provenance("fbx:synthetic/construct"),
1604 )
1605 .expect("positive test construct"),
1606 );
1607 builder.mark_complete(SourceFactDomainV1::Constructs);
1608 builder.mark_complete(SourceFactDomainV1::Resources);
1609 });
1610 let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
1611 let expected = match kind {
1612 SourceConstructKindV1::UnknownElement => "unknown_element",
1613 SourceConstructKindV1::Extension => "extension",
1614 SourceConstructKindV1::CustomProperty => unreachable!(),
1615 };
1616 assert_eq!(
1617 error,
1618 format!(
1619 "FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
1620 )
1621 );
1622 }
1623
1624 let custom_and_external = captured_with_counts(
1625 |builder| {
1626 builder.push_construct(
1627 SourceConstructFactV1::new(
1628 0,
1629 SourceConstructKindV1::CustomProperty,
1630 SourceTextV1::new("fbx:user-defined-properties")
1631 .expect("bounded test name"),
1632 false,
1633 1,
1634 SourceLoaderDispositionV1::Unsupported,
1635 parser_provenance("fbx:synthetic/property"),
1636 )
1637 .expect("positive custom-property row"),
1638 );
1639 builder.mark_complete(SourceFactDomainV1::Constructs);
1640 builder.push_resource(SourceResourceReferenceV1::new(
1641 0,
1642 SourceResourceKindV1::Texture,
1643 0,
1644 SourceResourceLocatorV1::classify("texture.png"),
1645 SourceLoaderDispositionV1::Unknown,
1646 parser_provenance("fbx:textures/0/filename"),
1647 ));
1648 builder.mark_complete(SourceFactDomainV1::Resources);
1649 },
1650 |counts| counts.user_defined_property_count = 1,
1651 );
1652 let facts = rest_bind_capability_facts_for_source(&custom_and_external)
1653 .expect("custom properties and external images are not scale-bearing");
1654 assert!(!facts.extras_present);
1655 assert!(!facts.external_resources_present);
1656 assert!(facts.is_supported_for(
1657 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
1658 source_skin_index: 0,
1659 source_root_node_index: 1,
1660 expected_factor: 0.01,
1661 }
1662 ));
1663 }
1664}