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}
369
370impl FbxScaleSource {
371 pub fn document(&self) -> &Document {
373 self.source.document()
374 }
375
376 pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
378 self.source.source_facts()
379 }
380
381 pub fn dependency_closure(&self) -> &DependencyClosureV1 {
383 self.source.dependency_closure()
384 }
385
386 pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
388 &self.inventory
389 }
390
391 pub fn into_document(self) -> Document {
393 self.source.into_document()
394 }
395
396 pub(crate) fn into_source(self) -> LoadedSource {
397 self.source
398 }
399}
400
401pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
408 let mut facts = ScaleCapabilityFacts::default();
409 facts.coverage = ScaleCapabilityCoverage::Complete;
410 let morph_source_present = inventory.blend_deformer_count > 0
411 || inventory.blend_channel_count > 0
412 || inventory.blend_shape_count > 0;
413 facts.morphs_present = morph_source_present;
414 facts.morph_weights_present = morph_source_present;
415 facts.cameras_present = inventory.camera_count > 0;
416 facts.lights_present = inventory.light_count > 0;
417 facts.instancing_present = inventory.shared_mesh_definition_count > 0;
418 facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
419 facts.extras_present = inventory.user_defined_property_count > 0;
420 facts.unknown_source_members_present = true;
424 facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
425 facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
426 > 0
427 || inventory.uninstanced_mesh_definition_count > 0
428 || inventory.empty_mesh_definition_count > 0
429 || inventory.multiple_skin_deformer_mesh_count > 0
430 || inventory.dual_quaternion_skin_count > 0
431 || inventory.cache_deformer_count > 0
432 || inventory.missing_skin_influence_corner_count > 0
433 || inventory.rejected_influence_count > 0
434 || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
435 facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
436 facts.inverse_bind_issues_present =
437 inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
438 facts.unsafe_accessor_layout_present = true;
442 facts.external_resources_present = inventory.external_resource_count > 0;
443 facts
444}
445
446pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
452 join_source_facts(source, capability_facts(source.inventory()))
453}
454
455fn join_source_facts(
456 source: &FbxScaleSource,
457 mut facts: ScaleCapabilityFacts,
458) -> ScaleCapabilityFacts {
459 let source_facts = source.source_facts();
460 if [
461 source_facts.constructs().coverage().state(),
462 source_facts.resources().coverage().state(),
463 ]
464 .into_iter()
465 .any(|state| state != SourceSetCoverageStateV1::Complete)
466 {
467 facts.coverage = ScaleCapabilityCoverage::Unavailable;
468 }
469 for row in source_facts.constructs().rows() {
470 match row.kind() {
471 SourceConstructKindV1::CustomProperty => facts.extras_present = true,
472 SourceConstructKindV1::UnknownElement => {
473 facts.unknown_source_members_present = true;
474 }
475 SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
476 }
477 }
478 if source_facts.resources().rows().iter().any(|row| {
479 !matches!(
480 row.locator(),
481 SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
482 )
483 }) {
484 facts.external_resources_present = true;
485 }
486 facts
487}
488
489pub fn rest_bind_capability_facts(
505 inventory: &FbxScaleCapabilityInventory,
506) -> Result<ScaleCapabilityFacts, String> {
507 if inventory.external_resource_count != 0 {
508 return Err(format_rest_bind_violations(
509 "capability inventory",
510 &[format!(
511 "external_resource_count={}",
512 inventory.external_resource_count
513 )],
514 ));
515 }
516 rest_bind_capability_facts_with_construct_counts(inventory, None)
517}
518
519fn rest_bind_capability_facts_with_construct_counts(
520 inventory: &FbxScaleCapabilityInventory,
521 source_counts: Option<crate::source_facts::RestBindSourceConstructCounts>,
522) -> Result<ScaleCapabilityFacts, String> {
523 let mut violations = Vec::new();
524 for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
525 if matches!(
526 status,
527 FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
528 ) {
529 violations.push(format!(
530 "domain.{domain}={}",
531 fbx_scale_domain_status_name(status)
532 ));
533 }
534 }
535 for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
536 if status != FbxScaleDomainStatus::Unverifiable {
537 violations.push(format!(
538 "domain.{domain}={} (expected unverifiable)",
539 fbx_scale_domain_status_name(status)
540 ));
541 }
542 }
543
544 let expected_custom_status = if inventory.unsupported_source_element_count == 0
545 && inventory.user_defined_property_count == 0
546 {
547 FbxScaleDomainStatus::Absent
548 } else {
549 FbxScaleDomainStatus::Unsupported
550 };
551 if inventory.domains.collision_and_custom_data != expected_custom_status {
552 violations.push(format!(
553 "domain.collision_and_custom_data={} (expected {})",
554 fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
555 fbx_scale_domain_status_name(expected_custom_status)
556 ));
557 }
558 if let Some(source_counts) = source_counts {
559 if inventory.user_defined_property_count != source_counts.user_defined_property_count {
560 violations.push(format!(
561 "user_defined_property_count={}!=source:{}",
562 inventory.user_defined_property_count, source_counts.user_defined_property_count
563 ));
564 }
565 let source_unmodeled_count = source_counts.total_unmodeled_element_count();
566 if inventory.unsupported_source_element_count != source_unmodeled_count {
567 violations.push(format!(
568 "unsupported_source_element_count={}!=source:{}",
569 inventory.unsupported_source_element_count, source_unmodeled_count
570 ));
571 }
572 push_unmodeled_element_violation(&mut violations, source_counts);
573 } else {
574 push_nonzero_violation(
575 &mut violations,
576 "unsupported_source_element_count",
577 inventory.unsupported_source_element_count,
578 );
579 }
580
581 if !inventory.coordinate_normalization.target_right_handed_y_up {
582 violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
583 }
584 if inventory.coordinate_normalization.target_unit_meters != 1.0 {
585 violations.push(format!(
586 "coordinate_normalization.target_unit_meters={}",
587 inventory.coordinate_normalization.target_unit_meters
588 ));
589 }
590 if !inventory.coordinate_normalization.adjust_transforms {
591 violations.push("coordinate_normalization.adjust_transforms=false".into());
592 }
593 if !inventory.animation_takes_baked {
594 violations.push("animation_takes_baked=false".into());
595 }
596 if inventory.authored_curve_keys_preserved {
597 violations.push("authored_curve_keys_preserved=true".into());
598 }
599 if !inventory.inherit_modes_compensated {
600 violations.push("inherit_modes_compensated=false".into());
601 }
602 if inventory.identity_bind_defaults_invented {
603 violations.push("identity_bind_defaults_invented=true".into());
604 }
605 for (name, value) in [
606 ("blend_deformer_count", inventory.blend_deformer_count),
607 ("blend_channel_count", inventory.blend_channel_count),
608 ("blend_shape_count", inventory.blend_shape_count),
609 ("camera_count", inventory.camera_count),
610 ("light_count", inventory.light_count),
611 (
612 "shared_mesh_definition_count",
613 inventory.shared_mesh_definition_count,
614 ),
615 (
616 "uninstanced_mesh_definition_count",
617 inventory.uninstanced_mesh_definition_count,
618 ),
619 (
620 "empty_mesh_definition_count",
621 inventory.empty_mesh_definition_count,
622 ),
623 (
624 "multiple_skin_deformer_mesh_count",
625 inventory.multiple_skin_deformer_mesh_count,
626 ),
627 (
628 "dual_quaternion_skin_count",
629 inventory.dual_quaternion_skin_count,
630 ),
631 ("cache_deformer_count", inventory.cache_deformer_count),
632 (
633 "unsupported_vertex_payload_mesh_count",
634 inventory.unsupported_vertex_payload_mesh_count,
635 ),
636 (
637 "incomplete_bind_cluster_count",
638 inventory.incomplete_bind_cluster_count,
639 ),
640 (
641 "empty_skin_deformer_count",
642 inventory.empty_skin_deformer_count,
643 ),
644 (
645 "missing_normal_mesh_count",
646 inventory.missing_normal_mesh_count,
647 ),
648 (
649 "bone_convenience_bind_overwrite_count",
650 inventory.bone_convenience_bind_overwrite_count,
651 ),
652 (
653 "truncated_influence_vertex_count",
654 inventory.truncated_influence_vertex_count,
655 ),
656 (
657 "discarded_influence_count",
658 inventory.discarded_influence_count,
659 ),
660 (
661 "renormalized_influence_vertex_count",
662 inventory.renormalized_influence_vertex_count,
663 ),
664 (
665 "rejected_influence_count",
666 inventory.rejected_influence_count,
667 ),
668 (
669 "missing_skin_influence_corner_count",
670 inventory.missing_skin_influence_corner_count,
671 ),
672 ("non_triangle_face_count", inventory.non_triangle_face_count),
673 ("triangulated_face_count", inventory.triangulated_face_count),
674 (
675 "omitted_non_polygon_face_count",
676 inventory.omitted_non_polygon_face_count,
677 ),
678 ] {
679 push_nonzero_violation(&mut violations, name, value);
680 }
681 if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
682 violations.push(format!(
683 "weld_vertex_count={}!=post:{}",
684 inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
685 ));
686 }
687 if !violations.is_empty() {
688 return Err(format_rest_bind_violations(
689 "capability inventory",
690 &violations,
691 ));
692 }
693
694 let mut facts = capability_facts(inventory);
701 facts.unknown_source_members_present = false;
702 facts.unregistered_extensions_present = false;
703 facts.unsafe_accessor_layout_present = false;
704 facts.extras_present = false;
705 facts.external_resources_present = false;
706 if !facts.is_supported_for(
707 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
708 source_skin_index: 0,
709 source_root_node_index: 0,
710 expected_factor: 1.0,
711 },
712 ) {
713 return Err(format_rest_bind_violations(
714 "projected capability",
715 &scale_capability_violations(&facts),
716 ));
717 }
718 Ok(facts)
719}
720
721pub fn rest_bind_capability_facts_for_source(
733 source: &FbxScaleSource,
734) -> Result<ScaleCapabilityFacts, String> {
735 let source_facts = source.source_facts();
736 let source_counts = source.rest_bind_construct_counts;
737 let mut violations = Vec::new();
738 for (domain, state) in [
739 ("constructs", source_facts.constructs().coverage().state()),
740 ("resources", source_facts.resources().coverage().state()),
741 ] {
742 if state != SourceSetCoverageStateV1::Complete {
743 violations.push(format!(
744 "raw_source.{domain}.coverage={}",
745 source_set_coverage_state_name(state)
746 ));
747 }
748 }
749 let mut saw_custom_properties = false;
750 let mut saw_unmodeled_elements = false;
751 for row in source_facts.constructs().rows() {
752 match row.kind() {
753 SourceConstructKindV1::CustomProperty
754 if row.name().as_str() == "fbx:user-defined-properties" =>
755 {
756 saw_custom_properties = true;
757 if row.count()
758 != u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
759 {
760 violations.push(format!(
761 "raw_source.construct=custom_property({}; count={})!=source:{}",
762 row.name().as_str(),
763 row.count(),
764 source_counts.user_defined_property_count
765 ));
766 }
767 }
768 SourceConstructKindV1::CustomProperty => violations.push(format!(
769 "raw_source.construct=custom_property({}; count={})",
770 row.name().as_str(),
771 row.count()
772 )),
773 SourceConstructKindV1::UnknownElement
774 if row.name().as_str() == "fbx:unmodeled-elements" =>
775 {
776 saw_unmodeled_elements = true;
777 let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
778 .unwrap_or(u64::MAX);
779 if row.count() != total_count {
780 violations.push(format!(
781 "raw_source.construct=unknown_element({}; count={})!=source:{}",
782 row.name().as_str(),
783 row.count(),
784 total_count
785 ));
786 } else if source_counts.unsupported_unmodeled_element_count() > 0 {
787 violations.push(format!(
788 "raw_source.construct=unknown_element({}; {})",
789 row.name().as_str(),
790 unmodeled_element_details(source_counts)
791 ));
792 }
793 }
794 SourceConstructKindV1::UnknownElement => violations.push(format!(
795 "raw_source.construct=unknown_element({}; count={})",
796 row.name().as_str(),
797 row.count()
798 )),
799 SourceConstructKindV1::Extension => violations.push(format!(
800 "raw_source.construct=extension({}; count={})",
801 row.name().as_str(),
802 row.count()
803 )),
804 }
805 }
806 if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
807 violations.push(format!(
808 "raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
809 source_counts.user_defined_property_count
810 ));
811 }
812 if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
813 violations.push(format!(
814 "raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
815 source_counts.total_unmodeled_element_count()
816 ));
817 }
818 if !violations.is_empty() {
819 return Err(format_rest_bind_violations("raw-source facts", &violations));
820 }
821
822 let mut facts = join_source_facts(
823 source,
824 rest_bind_capability_facts_with_construct_counts(source.inventory(), Some(source_counts))?,
825 );
826 facts.unknown_source_members_present = false;
827 facts.unregistered_extensions_present = false;
828 facts.extras_present = false;
829 facts.external_resources_present = false;
830 if facts.is_supported_for(
831 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
832 source_skin_index: 0,
833 source_root_node_index: 0,
834 expected_factor: 1.0,
835 },
836 ) {
837 Ok(facts)
838 } else {
839 Err(format_rest_bind_violations(
840 "joined capability",
841 &scale_capability_violations(&facts),
842 ))
843 }
844}
845
846fn push_unmodeled_element_violation(
847 violations: &mut Vec<String>,
848 counts: crate::source_facts::RestBindSourceConstructCounts,
849) {
850 if counts.unsupported_unmodeled_element_count() > 0 {
851 violations.push(format!(
852 "unsupported_source_element_count; {}",
853 unmodeled_element_details(counts)
854 ));
855 }
856}
857
858fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
859 let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
860 for (kind, count) in counts.unsupported_kind_counts() {
861 details.push_str("; ");
862 details.push_str(kind);
863 details.push('=');
864 details.push_str(&count.to_string());
865 }
866 details
867}
868
869fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
870 match status {
871 FbxScaleDomainStatus::Absent => "absent",
872 FbxScaleDomainStatus::Normalized => "normalized",
873 FbxScaleDomainStatus::Baked => "baked",
874 FbxScaleDomainStatus::Derived => "derived",
875 FbxScaleDomainStatus::Rebuilt => "rebuilt",
876 FbxScaleDomainStatus::Unsupported => "unsupported",
877 FbxScaleDomainStatus::Unverifiable => "unverifiable",
878 }
879}
880
881fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
882 match state {
883 SourceSetCoverageStateV1::Complete => "complete",
884 SourceSetCoverageStateV1::Partial => "partial",
885 SourceSetCoverageStateV1::Unavailable => "unavailable",
886 }
887}
888
889fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
890 if value > 0 {
891 violations.push(format!("{name}={value}"));
892 }
893}
894
895fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
896 format!(
897 "FBX rest/bind {authority} rejected: {}",
898 violations.join("; ")
899 )
900}
901
902fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
903 let mut violations = Vec::new();
904 if facts.coverage != ScaleCapabilityCoverage::Complete {
905 violations.push("coverage=unavailable".into());
906 }
907 for (name, present) in [
908 ("morphs_present", facts.morphs_present),
909 ("morph_weights_present", facts.morph_weights_present),
910 ("cameras_present", facts.cameras_present),
911 ("lights_present", facts.lights_present),
912 ("instancing_present", facts.instancing_present),
913 (
914 "unregistered_extensions_present",
915 facts.unregistered_extensions_present,
916 ),
917 ("extras_present", facts.extras_present),
918 (
919 "unknown_source_members_present",
920 facts.unknown_source_members_present,
921 ),
922 (
923 "non_triangle_primitives_present",
924 facts.non_triangle_primitives_present,
925 ),
926 (
927 "unsupported_vertex_attributes_present",
928 facts.unsupported_vertex_attributes_present,
929 ),
930 (
931 "secondary_skin_influences_present",
932 facts.secondary_skin_influences_present,
933 ),
934 (
935 "inverse_bind_issues_present",
936 facts.inverse_bind_issues_present,
937 ),
938 (
939 "unsafe_accessor_layout_present",
940 facts.unsafe_accessor_layout_present,
941 ),
942 (
943 "external_resources_present",
944 facts.external_resources_present,
945 ),
946 ] {
947 if present {
948 violations.push(format!("{name}=true"));
949 }
950 }
951 violations
952}
953
954#[derive(Debug, Default)]
955pub(crate) struct AssetConversionFacts {
956 pub(crate) truncated_influence_vertex_count: usize,
957 pub(crate) discarded_influence_count: usize,
958 pub(crate) renormalized_influence_vertex_count: usize,
959 pub(crate) rejected_influence_count: usize,
960 pub(crate) missing_skin_influence_corner_count: usize,
961 pub(crate) pre_weld_vertex_count: usize,
962 pub(crate) post_weld_vertex_count: usize,
963}
964
965fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
966 FbxSourceIdentity {
967 source_index: index,
968 ufbx_typed_id: element.typed_id,
969 ufbx_element_id: element.element_id,
970 }
971}
972
973pub(crate) fn inventory(
974 scene: &ufbx::Scene,
975 conversion: &AssetConversionFacts,
976 construct_counts: crate::source_facts::SourceConstructCounts,
977) -> FbxScaleCapabilityInventory {
978 let non_triangle_face_count = scene
979 .meshes
980 .iter()
981 .flat_map(|mesh| mesh.faces.iter())
982 .filter(|face| face.num_indices != 3)
983 .count();
984 let triangulated_face_count = scene
985 .meshes
986 .iter()
987 .flat_map(|mesh| mesh.faces.iter())
988 .filter(|face| face.num_indices > 3)
989 .count();
990 let omitted_non_polygon_face_count = scene
991 .meshes
992 .iter()
993 .flat_map(|mesh| mesh.faces.iter())
994 .filter(|face| face.num_indices < 3)
995 .count();
996 let empty_source_meshes = scene
997 .meshes
998 .iter()
999 .enumerate()
1000 .filter(|(_, mesh)| mesh.faces.is_empty())
1001 .map(|(index, mesh)| identity(index, &mesh.element))
1002 .collect::<Vec<_>>();
1003 let empty_mesh_definition_count = empty_source_meshes.len();
1004 let generated_normal_mesh_count = scene
1005 .meshes
1006 .iter()
1007 .filter(|mesh| mesh.generated_normals)
1008 .count();
1009 let missing_normal_mesh_count = scene
1010 .meshes
1011 .iter()
1012 .filter(|mesh| !mesh.vertex_normal.exists)
1013 .count();
1014 let skin_cluster_count = scene
1015 .skin_deformers
1016 .iter()
1017 .map(|skin| skin.clusters.len())
1018 .sum();
1019 let empty_skin_deformer_count = scene
1020 .skin_deformers
1021 .iter()
1022 .filter(|skin| skin.clusters.is_empty())
1023 .count();
1024 let incomplete_bind_cluster_count = scene
1025 .skin_clusters
1026 .iter()
1027 .filter(|cluster| super::project_cluster_bind(cluster).is_none())
1028 .count();
1029 let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
1030 for cluster in &scene.skin_clusters {
1031 if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
1032 *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
1033 }
1034 }
1035 let bone_convenience_bind_overwrite_count = clusters_per_bone
1036 .values()
1037 .map(|count| count.saturating_sub(1))
1038 .sum();
1039 let multiple_skin_deformer_mesh_count = scene
1040 .meshes
1041 .iter()
1042 .filter(|mesh| mesh.skin_deformers.len() > 1)
1043 .count();
1044 let dual_quaternion_skin_count = scene
1045 .skin_deformers
1046 .iter()
1047 .filter(|skin| {
1048 skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
1049 })
1050 .count();
1051 let unsupported_vertex_payload_mesh_count = scene
1052 .meshes
1053 .iter()
1054 .filter(|mesh| mesh_has_unsupported_source_payload(mesh))
1055 .count();
1056 let shared_mesh_definition_count = scene
1057 .meshes
1058 .iter()
1059 .filter(|mesh| mesh.element.instances.len() > 1)
1060 .count();
1061 let uninstanced_source_meshes = scene
1062 .meshes
1063 .iter()
1064 .enumerate()
1065 .filter(|(_, mesh)| mesh.element.instances.is_empty())
1066 .map(|(index, mesh)| identity(index, &mesh.element))
1067 .collect::<Vec<_>>();
1068 let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
1069 let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
1070 let unsupported_source_element_count =
1071 construct_counts.rest_bind.total_unmodeled_element_count();
1072 let external_resource_count = scene
1073 .textures
1074 .iter()
1075 .filter(|texture| texture.content.is_empty() && texture.has_file)
1076 .count()
1077 + scene
1078 .videos
1079 .iter()
1080 .filter(|video| {
1081 video.content.is_empty()
1082 && (!video.filename.is_empty()
1083 || !video.relative_filename.is_empty()
1084 || !video.absolute_filename.is_empty())
1085 })
1086 .count();
1087 let compensated_inherit_node_count = scene
1088 .nodes
1089 .iter()
1090 .filter(|node| {
1091 node.original_inherit_mode != node.inherit_mode
1092 || node.is_scale_helper
1093 || node.is_scale_compensate_parent
1094 })
1095 .count();
1096
1097 let stackless_animation_present = scene.anim_stacks.is_empty()
1098 && (!scene.anim_layers.is_empty()
1099 || !scene.anim_values.is_empty()
1100 || !scene.anim_curves.is_empty());
1101 let animation = if !scene.anim_stacks.is_empty() {
1102 FbxScaleDomainStatus::Baked
1103 } else if stackless_animation_present {
1104 FbxScaleDomainStatus::Unsupported
1107 } else {
1108 FbxScaleDomainStatus::Absent
1109 };
1110 let domains = FbxScaleDomainInventory {
1111 rest_hierarchy: FbxScaleDomainStatus::Normalized,
1112 translation_animation: animation,
1113 rotation_and_scale_animation: animation,
1114 root_motion_and_velocity: match animation {
1115 FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
1116 status => status,
1117 },
1118 base_mesh_geometry: if scene.meshes.is_empty() {
1119 FbxScaleDomainStatus::Absent
1120 } else if uninstanced_mesh_definition_count > 0
1121 || omitted_non_polygon_face_count > 0
1122 || empty_mesh_definition_count > 0
1123 {
1124 FbxScaleDomainStatus::Unsupported
1125 } else {
1126 FbxScaleDomainStatus::Rebuilt
1127 },
1128 morphs: if scene.blend_deformers.is_empty()
1129 && scene.blend_channels.is_empty()
1130 && scene.blend_shapes.is_empty()
1131 {
1132 FbxScaleDomainStatus::Absent
1133 } else {
1134 FbxScaleDomainStatus::Unsupported
1135 },
1136 skin_binds: if scene.skin_deformers.is_empty() {
1137 FbxScaleDomainStatus::Absent
1138 } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
1139 FbxScaleDomainStatus::Unsupported
1140 } else {
1141 FbxScaleDomainStatus::Derived
1142 },
1143 cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
1144 FbxScaleDomainStatus::Absent
1145 } else {
1146 FbxScaleDomainStatus::Unsupported
1147 },
1148 collision_and_custom_data: if unsupported_source_element_count == 0
1149 && user_defined_property_count == 0
1150 {
1151 FbxScaleDomainStatus::Absent
1152 } else {
1153 FbxScaleDomainStatus::Unsupported
1154 },
1155 other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
1156 || uninstanced_mesh_definition_count > 0
1157 || omitted_non_polygon_face_count > 0
1158 || empty_mesh_definition_count > 0
1159 || multiple_skin_deformer_mesh_count > 0
1160 || dual_quaternion_skin_count > 0
1161 || conversion.truncated_influence_vertex_count > 0
1162 || conversion.missing_skin_influence_corner_count > 0
1163 || conversion.rejected_influence_count > 0
1164 || !scene.blend_deformers.is_empty()
1165 || !scene.blend_channels.is_empty()
1166 || !scene.blend_shapes.is_empty()
1167 || !scene.cache_deformers.is_empty()
1168 || !scene.cache_files.is_empty()
1169 {
1170 FbxScaleDomainStatus::Unsupported
1171 } else if !scene.meshes.is_empty() {
1172 FbxScaleDomainStatus::Rebuilt
1173 } else {
1174 FbxScaleDomainStatus::Absent
1175 },
1176 out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
1177 animation_targeting_matrix_nodes: animation,
1178 shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1179 unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1180 image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
1181 };
1182
1183 FbxScaleCapabilityInventory {
1184 domains,
1185 coordinate_normalization: FbxCoordinateNormalization {
1186 original_up_axis: scene.settings.original_axis_up.into(),
1187 original_unit_meters: scene.settings.original_unit_meters,
1188 target_right_handed_y_up: true,
1189 target_unit_meters: 1.0,
1190 adjust_transforms: matches!(
1191 scene.metadata.space_conversion,
1192 ufbx::SpaceConversion::AdjustTransforms
1193 ),
1194 },
1195 animation_takes_baked: true,
1196 authored_curve_keys_preserved: false,
1197 animation_take_count: scene.anim_stacks.len(),
1198 source_animation_curve_count: scene.anim_curves.len(),
1199 generated_geometry_helper_node_count: scene
1200 .nodes
1201 .iter()
1202 .filter(|node| node.is_geometry_transform_helper)
1203 .count(),
1204 generated_scale_helper_node_count: scene
1205 .nodes
1206 .iter()
1207 .filter(|node| node.is_scale_helper)
1208 .count(),
1209 inherit_modes_compensated: matches!(
1210 scene.metadata.inherit_mode_handling,
1211 ufbx::InheritModeHandling::Compensate
1212 ),
1213 compensated_inherit_node_count,
1214 generated_normal_mesh_count,
1215 missing_normal_mesh_count,
1216 skin_deformer_count: scene.skin_deformers.len(),
1217 skin_cluster_count,
1218 empty_skin_deformer_count,
1219 bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
1220 incomplete_bind_cluster_count,
1221 bone_convenience_bind_overwrite_count,
1222 identity_bind_defaults_invented: false,
1223 truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
1224 discarded_influence_count: conversion.discarded_influence_count,
1225 renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
1226 rejected_influence_count: conversion.rejected_influence_count,
1227 missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
1228 non_triangle_face_count,
1229 triangulated_face_count,
1230 omitted_non_polygon_face_count,
1231 empty_mesh_definition_count,
1232 empty_source_meshes,
1233 pre_weld_vertex_count: conversion.pre_weld_vertex_count,
1234 post_weld_vertex_count: conversion.post_weld_vertex_count,
1235 multiple_skin_deformer_mesh_count,
1236 dual_quaternion_skin_count,
1237 blend_deformer_count: scene.blend_deformers.len(),
1238 blend_channel_count: scene.blend_channels.len(),
1239 blend_shape_count: scene.blend_shapes.len(),
1240 cache_deformer_count: scene.cache_deformers.len(),
1241 unsupported_vertex_payload_mesh_count,
1242 camera_count: scene.cameras.len(),
1243 light_count: scene.lights.len(),
1244 shared_mesh_definition_count,
1245 uninstanced_mesh_definition_count,
1246 uninstanced_source_meshes,
1247 user_defined_property_count,
1248 unsupported_source_element_count,
1249 external_resource_count,
1250 source_nodes: scene
1251 .nodes
1252 .iter()
1253 .enumerate()
1254 .map(|(index, node)| identity(index, &node.element))
1255 .collect(),
1256 source_meshes: scene
1257 .meshes
1258 .iter()
1259 .enumerate()
1260 .map(|(index, mesh)| identity(index, &mesh.element))
1261 .collect(),
1262 source_skins: scene
1263 .skin_deformers
1264 .iter()
1265 .enumerate()
1266 .map(|(index, skin)| identity(index, &skin.element))
1267 .collect(),
1268 }
1269}
1270
1271fn mesh_has_unsupported_source_payload(mesh: &ufbx::Mesh) -> bool {
1275 let ufbx::Mesh {
1276 element: _,
1277 num_vertices: _,
1278 num_indices: _,
1279 num_faces: _,
1280 num_triangles: _,
1281 num_edges: _,
1282 max_face_triangles: _,
1283 num_empty_faces: _,
1284 num_point_faces: _,
1285 num_line_faces: _,
1286 faces: _,
1287 face_smoothing,
1289 face_material: _,
1290 face_group,
1291 face_hole,
1292 edges,
1293 edge_smoothing,
1294 edge_crease,
1295 edge_visibility,
1296 vertex_indices: _,
1297 vertices: _,
1298 vertex_first_index: _,
1299 vertex_position: _,
1300 vertex_normal: _,
1301 vertex_uv: _,
1302 vertex_tangent,
1303 vertex_bitangent,
1304 vertex_color,
1305 vertex_crease,
1306 uv_sets,
1307 color_sets,
1308 materials: _,
1309 face_groups,
1310 material_parts: _,
1312 face_group_parts: _,
1313 material_part_usage_order: _,
1314 skinned_is_local: _,
1315 skinned_position: _,
1316 skinned_normal: _,
1317 skin_deformers: _,
1319 blend_deformers: _,
1320 cache_deformers: _,
1321 all_deformers: _,
1322 subdivision_preview_levels,
1323 subdivision_render_levels,
1324 subdivision_display_mode,
1325 subdivision_boundary,
1326 subdivision_uv_boundary,
1327 reversed_winding: _,
1329 generated_normals: _,
1330 subdivision_evaluated,
1331 subdivision_result,
1332 from_tessellated_nurbs,
1333 } = mesh;
1334
1335 !face_smoothing.is_empty()
1336 || !face_group.is_empty()
1337 || !face_hole.is_empty()
1338 || !edges.is_empty()
1339 || !edge_smoothing.is_empty()
1340 || !edge_crease.is_empty()
1341 || !edge_visibility.is_empty()
1342 || vertex_tangent.exists
1343 || vertex_bitangent.exists
1344 || vertex_color.exists
1345 || vertex_crease.exists
1346 || uv_sets.len() > 1
1347 || !color_sets.is_empty()
1348 || !face_groups.is_empty()
1349 || *subdivision_preview_levels > 0
1350 || *subdivision_render_levels > 0
1351 || !matches!(
1352 subdivision_display_mode,
1353 ufbx::SubdivisionDisplayMode::Disabled
1354 )
1355 || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default)
1356 || !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
1357 || *subdivision_evaluated
1358 || subdivision_result.is_some()
1359 || *from_tessellated_nurbs
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365 use animsmith_core::{
1366 InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
1367 SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
1368 SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
1369 };
1370 use std::path::PathBuf;
1371
1372 fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
1373 captured_with_counts(configure, |_| {})
1374 }
1375
1376 fn captured_with_counts(
1377 configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
1378 configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
1379 ) -> FbxScaleSource {
1380 let fixture =
1381 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
1382 let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
1383 let document = baseline.document().clone();
1384 let mut inventory = baseline.inventory().clone();
1385 let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
1386 configure_counts(&mut rest_bind_construct_counts);
1387 inventory.user_defined_property_count =
1388 rest_bind_construct_counts.user_defined_property_count;
1389 inventory.unsupported_source_element_count =
1390 rest_bind_construct_counts.total_unmodeled_element_count();
1391 inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
1392 && inventory.unsupported_source_element_count == 0
1393 {
1394 FbxScaleDomainStatus::Absent
1395 } else {
1396 FbxScaleDomainStatus::Unsupported
1397 };
1398 let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
1399 let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
1400 configure(&mut builder);
1401 let source = builder.finish(document).expect("synthetic raw facts bind");
1402 FbxScaleSource {
1403 source,
1404 inventory,
1405 rest_bind_construct_counts,
1406 }
1407 }
1408
1409 fn parser_provenance(path: &str) -> SourceProvenanceV1 {
1410 SourceProvenanceV1::parser_projected(
1411 SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
1412 )
1413 }
1414
1415 #[test]
1416 fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
1417 for (source, expected) in [
1418 (
1419 captured_with(|builder| {
1420 builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1421 builder.mark_complete(SourceFactDomainV1::Resources);
1422 }),
1423 "FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
1424 ),
1425 (
1426 captured_with(|builder| {
1427 builder.mark_complete(SourceFactDomainV1::Constructs);
1428 builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
1429 }),
1430 "FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
1431 ),
1432 ] {
1433 assert_eq!(
1434 rest_bind_capability_facts_for_source(&source).unwrap_err(),
1435 expected,
1436 "partial construct/resource coverage must name the exact raw authority"
1437 );
1438 }
1439 }
1440
1441 #[test]
1442 fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
1443 for kind in [
1444 SourceConstructKindV1::UnknownElement,
1445 SourceConstructKindV1::Extension,
1446 ] {
1447 let source = captured_with(|builder| {
1448 builder.push_construct(
1449 SourceConstructFactV1::new(
1450 0,
1451 kind,
1452 SourceTextV1::new("synthetic").expect("bounded test name"),
1453 false,
1454 1,
1455 SourceLoaderDispositionV1::Unsupported,
1456 parser_provenance("fbx:synthetic/construct"),
1457 )
1458 .expect("positive test construct"),
1459 );
1460 builder.mark_complete(SourceFactDomainV1::Constructs);
1461 builder.mark_complete(SourceFactDomainV1::Resources);
1462 });
1463 let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
1464 let expected = match kind {
1465 SourceConstructKindV1::UnknownElement => "unknown_element",
1466 SourceConstructKindV1::Extension => "extension",
1467 SourceConstructKindV1::CustomProperty => unreachable!(),
1468 };
1469 assert_eq!(
1470 error,
1471 format!(
1472 "FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
1473 )
1474 );
1475 }
1476
1477 let custom_and_external = captured_with_counts(
1478 |builder| {
1479 builder.push_construct(
1480 SourceConstructFactV1::new(
1481 0,
1482 SourceConstructKindV1::CustomProperty,
1483 SourceTextV1::new("fbx:user-defined-properties")
1484 .expect("bounded test name"),
1485 false,
1486 1,
1487 SourceLoaderDispositionV1::Unsupported,
1488 parser_provenance("fbx:synthetic/property"),
1489 )
1490 .expect("positive custom-property row"),
1491 );
1492 builder.mark_complete(SourceFactDomainV1::Constructs);
1493 builder.push_resource(SourceResourceReferenceV1::new(
1494 0,
1495 SourceResourceKindV1::Texture,
1496 0,
1497 SourceResourceLocatorV1::classify("texture.png"),
1498 SourceLoaderDispositionV1::Unknown,
1499 parser_provenance("fbx:textures/0/filename"),
1500 ));
1501 builder.mark_complete(SourceFactDomainV1::Resources);
1502 },
1503 |counts| counts.user_defined_property_count = 1,
1504 );
1505 let facts = rest_bind_capability_facts_for_source(&custom_and_external)
1506 .expect("custom properties and external images are not scale-bearing");
1507 assert!(!facts.extras_present);
1508 assert!(!facts.external_resources_present);
1509 assert!(facts.is_supported_for(
1510 animsmith_core::scale::ScaleOperation::RestBindUniformScale {
1511 source_skin_index: 0,
1512 source_root_node_index: 1,
1513 expected_factor: 0.01,
1514 }
1515 ));
1516 }
1517}