use animsmith_core::scale::{ScaleCapabilityCoverage, ScaleCapabilityFacts};
use animsmith_core::{
DependencyClosureV1, Document, LoadedSource, SourceConstructKindV1, SourceFactsViewV1,
SourceResourceLocatorV1, SourceSetCoverageStateV1,
};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FbxScaleDomainStatus {
Absent,
Normalized,
Baked,
Derived,
Rebuilt,
Unsupported,
Unverifiable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FbxScaleDomainInventory {
pub rest_hierarchy: FbxScaleDomainStatus,
pub translation_animation: FbxScaleDomainStatus,
pub rotation_and_scale_animation: FbxScaleDomainStatus,
pub root_motion_and_velocity: FbxScaleDomainStatus,
pub base_mesh_geometry: FbxScaleDomainStatus,
pub morphs: FbxScaleDomainStatus,
pub skin_binds: FbxScaleDomainStatus,
pub cameras_and_lights: FbxScaleDomainStatus,
pub collision_and_custom_data: FbxScaleDomainStatus,
pub other_vertex_and_source_data: FbxScaleDomainStatus,
pub out_of_contract_node_transforms: FbxScaleDomainStatus,
pub animation_targeting_matrix_nodes: FbxScaleDomainStatus,
pub shared_raw_accessor_payloads: FbxScaleDomainStatus,
pub unreferenced_accessor_payloads: FbxScaleDomainStatus,
pub image_payload_aliases: FbxScaleDomainStatus,
}
impl FbxScaleDomainInventory {
pub fn named_rows(&self) -> [(&'static str, FbxScaleDomainStatus); 15] {
[
("Rest hierarchy", self.rest_hierarchy),
("Translation animation", self.translation_animation),
(
"Rotation and scale animation",
self.rotation_and_scale_animation,
),
("Root motion and velocity", self.root_motion_and_velocity),
("Base mesh geometry", self.base_mesh_geometry),
("Morphs", self.morphs),
("Skin binds", self.skin_binds),
("Cameras/lights", self.cameras_and_lights),
("Collision/custom data", self.collision_and_custom_data),
(
"Other vertex/source data",
self.other_vertex_and_source_data,
),
(
"Out-of-contract node transforms",
self.out_of_contract_node_transforms,
),
(
"Animation targeting a matrix node",
self.animation_targeting_matrix_nodes,
),
(
"Shared raw accessor payloads",
self.shared_raw_accessor_payloads,
),
(
"Unreferenced accessor payloads",
self.unreferenced_accessor_payloads,
),
("Image payload aliases", self.image_payload_aliases),
]
}
fn rest_bind_semantic_statuses(&self) -> [(&'static str, FbxScaleDomainStatus); 11] {
[
("rest_hierarchy", self.rest_hierarchy),
("translation_animation", self.translation_animation),
(
"rotation_and_scale_animation",
self.rotation_and_scale_animation,
),
("root_motion_and_velocity", self.root_motion_and_velocity),
("base_mesh_geometry", self.base_mesh_geometry),
("morphs", self.morphs),
("skin_binds", self.skin_binds),
("cameras_and_lights", self.cameras_and_lights),
(
"other_vertex_and_source_data",
self.other_vertex_and_source_data,
),
(
"out_of_contract_node_transforms",
self.out_of_contract_node_transforms,
),
(
"animation_targeting_matrix_nodes",
self.animation_targeting_matrix_nodes,
),
]
}
fn rest_bind_raw_span_statuses(&self) -> [(&'static str, FbxScaleDomainStatus); 3] {
[
(
"shared_raw_accessor_payloads",
self.shared_raw_accessor_payloads,
),
(
"unreferenced_accessor_payloads",
self.unreferenced_accessor_payloads,
),
("image_payload_aliases", self.image_payload_aliases),
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FbxCoordinateAxis {
PositiveX,
NegativeX,
PositiveY,
NegativeY,
PositiveZ,
NegativeZ,
Unknown,
}
impl From<ufbx::CoordinateAxis> for FbxCoordinateAxis {
fn from(value: ufbx::CoordinateAxis) -> Self {
match value {
ufbx::CoordinateAxis::PositiveX => Self::PositiveX,
ufbx::CoordinateAxis::NegativeX => Self::NegativeX,
ufbx::CoordinateAxis::PositiveY => Self::PositiveY,
ufbx::CoordinateAxis::NegativeY => Self::NegativeY,
ufbx::CoordinateAxis::PositiveZ => Self::PositiveZ,
ufbx::CoordinateAxis::NegativeZ => Self::NegativeZ,
ufbx::CoordinateAxis::Unknown => Self::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FbxCoordinateNormalization {
pub original_up_axis: FbxCoordinateAxis,
pub original_unit_meters: f64,
pub target_right_handed_y_up: bool,
pub target_unit_meters: f64,
pub adjust_transforms: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FbxSourceIdentity {
pub source_index: usize,
pub ufbx_typed_id: u32,
pub ufbx_element_id: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FbxBindMatrixProvenance {
UfbxConvertedClusterMatrices,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FbxScaleCapabilityInventory {
pub domains: FbxScaleDomainInventory,
pub coordinate_normalization: FbxCoordinateNormalization,
pub animation_takes_baked: bool,
pub authored_curve_keys_preserved: bool,
pub animation_take_count: usize,
pub source_animation_curve_count: usize,
pub generated_geometry_helper_node_count: usize,
pub generated_scale_helper_node_count: usize,
pub inherit_modes_compensated: bool,
pub compensated_inherit_node_count: usize,
pub generated_normal_mesh_count: usize,
pub missing_normal_mesh_count: usize,
pub skin_deformer_count: usize,
pub skin_cluster_count: usize,
pub empty_skin_deformer_count: usize,
pub bind_matrix_provenance: FbxBindMatrixProvenance,
pub incomplete_bind_cluster_count: usize,
pub bone_convenience_bind_overwrite_count: usize,
pub identity_bind_defaults_invented: bool,
pub truncated_influence_vertex_count: usize,
pub discarded_influence_count: usize,
pub renormalized_influence_vertex_count: usize,
pub rejected_influence_count: usize,
pub missing_skin_influence_corner_count: usize,
pub non_triangle_face_count: usize,
pub triangulated_face_count: usize,
pub omitted_non_polygon_face_count: usize,
pub empty_mesh_definition_count: usize,
pub empty_source_meshes: Vec<FbxSourceIdentity>,
pub pre_weld_vertex_count: usize,
pub post_weld_vertex_count: usize,
pub multiple_skin_deformer_mesh_count: usize,
pub dual_quaternion_skin_count: usize,
pub blend_deformer_count: usize,
pub blend_channel_count: usize,
pub blend_shape_count: usize,
pub cache_deformer_count: usize,
pub unsupported_vertex_payload_mesh_count: usize,
pub camera_count: usize,
pub light_count: usize,
pub shared_mesh_definition_count: usize,
pub uninstanced_mesh_definition_count: usize,
pub uninstanced_source_meshes: Vec<FbxSourceIdentity>,
pub user_defined_property_count: usize,
pub unsupported_source_element_count: usize,
pub external_resource_count: usize,
pub source_nodes: Vec<FbxSourceIdentity>,
pub source_meshes: Vec<FbxSourceIdentity>,
pub source_skins: Vec<FbxSourceIdentity>,
}
#[derive(Debug)]
pub struct FbxScaleSource {
pub(crate) source: LoadedSource,
pub(crate) inventory: FbxScaleCapabilityInventory,
pub(crate) rest_bind_construct_counts: crate::source_facts::RestBindSourceConstructCounts,
pub(crate) rest_bind_scale_invariant_payload_mesh_count: usize,
}
impl FbxScaleSource {
pub fn document(&self) -> &Document {
self.source.document()
}
pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
self.source.source_facts()
}
pub fn dependency_closure(&self) -> &DependencyClosureV1 {
self.source.dependency_closure()
}
pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
&self.inventory
}
pub fn into_document(self) -> Document {
self.source.into_document()
}
pub(crate) fn into_source(self) -> LoadedSource {
self.source
}
}
pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
let mut facts = ScaleCapabilityFacts::default();
facts.coverage = ScaleCapabilityCoverage::Complete;
let morph_source_present = inventory.blend_deformer_count > 0
|| inventory.blend_channel_count > 0
|| inventory.blend_shape_count > 0;
facts.morphs_present = morph_source_present;
facts.morph_weights_present = morph_source_present;
facts.cameras_present = inventory.camera_count > 0;
facts.lights_present = inventory.light_count > 0;
facts.instancing_present = inventory.shared_mesh_definition_count > 0;
facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
facts.extras_present = inventory.user_defined_property_count > 0;
facts.unknown_source_members_present = true;
facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
> 0
|| inventory.uninstanced_mesh_definition_count > 0
|| inventory.empty_mesh_definition_count > 0
|| inventory.multiple_skin_deformer_mesh_count > 0
|| inventory.dual_quaternion_skin_count > 0
|| inventory.cache_deformer_count > 0
|| inventory.missing_skin_influence_corner_count > 0
|| inventory.rejected_influence_count > 0
|| inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
facts.inverse_bind_issues_present =
inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
facts.unsafe_accessor_layout_present = true;
facts.external_resources_present = inventory.external_resource_count > 0;
facts
}
pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
join_source_facts(source, capability_facts(source.inventory()))
}
fn join_source_facts(
source: &FbxScaleSource,
mut facts: ScaleCapabilityFacts,
) -> ScaleCapabilityFacts {
let source_facts = source.source_facts();
if [
source_facts.constructs().coverage().state(),
source_facts.resources().coverage().state(),
]
.into_iter()
.any(|state| state != SourceSetCoverageStateV1::Complete)
{
facts.coverage = ScaleCapabilityCoverage::Unavailable;
}
for row in source_facts.constructs().rows() {
match row.kind() {
SourceConstructKindV1::CustomProperty => facts.extras_present = true,
SourceConstructKindV1::UnknownElement => {
facts.unknown_source_members_present = true;
}
SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
}
}
if source_facts.resources().rows().iter().any(|row| {
!matches!(
row.locator(),
SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
)
}) {
facts.external_resources_present = true;
}
facts
}
pub fn rest_bind_capability_facts(
inventory: &FbxScaleCapabilityInventory,
) -> Result<ScaleCapabilityFacts, String> {
if inventory.external_resource_count != 0 {
return Err(format_rest_bind_violations(
"capability inventory",
&[format!(
"external_resource_count={}",
inventory.external_resource_count
)],
));
}
rest_bind_capability_facts_with_context(inventory, RestBindCapabilityContext::InventoryOnly)
}
#[derive(Debug, Clone, Copy)]
enum RestBindCapabilityContext<'a> {
InventoryOnly,
CapturedSource {
counts: &'a crate::source_facts::RestBindSourceConstructCounts,
scale_invariant_payload_mesh_count: usize,
},
}
fn rest_bind_capability_facts_with_context(
inventory: &FbxScaleCapabilityInventory,
context: RestBindCapabilityContext<'_>,
) -> Result<ScaleCapabilityFacts, String> {
let captured_source = match context {
RestBindCapabilityContext::InventoryOnly => None,
RestBindCapabilityContext::CapturedSource {
counts,
scale_invariant_payload_mesh_count,
} => Some((counts, scale_invariant_payload_mesh_count)),
};
let mut violations = Vec::new();
for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
if domain == "other_vertex_and_source_data" && captured_source.is_some() {
continue;
}
if matches!(
status,
FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
) {
violations.push(format!(
"domain.{domain}={}",
fbx_scale_domain_status_name(status)
));
}
}
for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
if status != FbxScaleDomainStatus::Unverifiable {
violations.push(format!(
"domain.{domain}={} (expected unverifiable)",
fbx_scale_domain_status_name(status)
));
}
}
let expected_custom_status = if inventory.unsupported_source_element_count == 0
&& inventory.user_defined_property_count == 0
{
FbxScaleDomainStatus::Absent
} else {
FbxScaleDomainStatus::Unsupported
};
if inventory.domains.collision_and_custom_data != expected_custom_status {
violations.push(format!(
"domain.collision_and_custom_data={} (expected {})",
fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
fbx_scale_domain_status_name(expected_custom_status)
));
}
if let Some((source_counts, _)) = captured_source {
if inventory.user_defined_property_count != source_counts.user_defined_property_count {
violations.push(format!(
"user_defined_property_count={}!=source:{}",
inventory.user_defined_property_count, source_counts.user_defined_property_count
));
}
let source_unmodeled_count = source_counts.total_unmodeled_element_count();
if inventory.unsupported_source_element_count != source_unmodeled_count {
violations.push(format!(
"unsupported_source_element_count={}!=source:{}",
inventory.unsupported_source_element_count, source_unmodeled_count
));
}
push_unmodeled_element_violation(&mut violations, *source_counts);
} else {
push_nonzero_violation(
&mut violations,
"unsupported_source_element_count",
inventory.unsupported_source_element_count,
);
}
if !inventory.coordinate_normalization.target_right_handed_y_up {
violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
}
if inventory.coordinate_normalization.target_unit_meters != 1.0 {
violations.push(format!(
"coordinate_normalization.target_unit_meters={}",
inventory.coordinate_normalization.target_unit_meters
));
}
if !inventory.coordinate_normalization.adjust_transforms {
violations.push("coordinate_normalization.adjust_transforms=false".into());
}
if !inventory.animation_takes_baked {
violations.push("animation_takes_baked=false".into());
}
if inventory.authored_curve_keys_preserved {
violations.push("authored_curve_keys_preserved=true".into());
}
if !inventory.inherit_modes_compensated {
violations.push("inherit_modes_compensated=false".into());
}
if inventory.identity_bind_defaults_invented {
violations.push("identity_bind_defaults_invented=true".into());
}
for (name, value) in [
("blend_deformer_count", inventory.blend_deformer_count),
("blend_channel_count", inventory.blend_channel_count),
("blend_shape_count", inventory.blend_shape_count),
("camera_count", inventory.camera_count),
("light_count", inventory.light_count),
(
"shared_mesh_definition_count",
inventory.shared_mesh_definition_count,
),
(
"uninstanced_mesh_definition_count",
inventory.uninstanced_mesh_definition_count,
),
(
"empty_mesh_definition_count",
inventory.empty_mesh_definition_count,
),
(
"multiple_skin_deformer_mesh_count",
inventory.multiple_skin_deformer_mesh_count,
),
(
"dual_quaternion_skin_count",
inventory.dual_quaternion_skin_count,
),
("cache_deformer_count", inventory.cache_deformer_count),
(
"incomplete_bind_cluster_count",
inventory.incomplete_bind_cluster_count,
),
(
"empty_skin_deformer_count",
inventory.empty_skin_deformer_count,
),
(
"missing_normal_mesh_count",
inventory.missing_normal_mesh_count,
),
(
"bone_convenience_bind_overwrite_count",
inventory.bone_convenience_bind_overwrite_count,
),
(
"missing_skin_influence_corner_count",
inventory.missing_skin_influence_corner_count,
),
(
"omitted_non_polygon_face_count",
inventory.omitted_non_polygon_face_count,
),
] {
push_nonzero_violation(&mut violations, name, value);
}
if let Some((_, scale_invariant_payload_mesh_count)) = captured_source {
if inventory.unsupported_vertex_payload_mesh_count != scale_invariant_payload_mesh_count {
violations.push(format!(
"unsupported_vertex_payload_mesh_count={}!=scale_invariant_source:{}",
inventory.unsupported_vertex_payload_mesh_count, scale_invariant_payload_mesh_count
));
}
} else {
for (name, value) in [
(
"unsupported_vertex_payload_mesh_count",
inventory.unsupported_vertex_payload_mesh_count,
),
(
"truncated_influence_vertex_count",
inventory.truncated_influence_vertex_count,
),
(
"discarded_influence_count",
inventory.discarded_influence_count,
),
(
"renormalized_influence_vertex_count",
inventory.renormalized_influence_vertex_count,
),
(
"rejected_influence_count",
inventory.rejected_influence_count,
),
("non_triangle_face_count", inventory.non_triangle_face_count),
("triangulated_face_count", inventory.triangulated_face_count),
] {
push_nonzero_violation(&mut violations, name, value);
}
if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
violations.push(format!(
"weld_vertex_count={}!=post:{}",
inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
));
}
}
if !violations.is_empty() {
return Err(format_rest_bind_violations(
"capability inventory",
&violations,
));
}
let mut facts = capability_facts(inventory);
facts.unknown_source_members_present = false;
facts.unregistered_extensions_present = false;
facts.unsafe_accessor_layout_present = false;
facts.extras_present = false;
facts.external_resources_present = false;
if captured_source.is_some() {
facts.non_triangle_primitives_present = false;
facts.unsupported_vertex_attributes_present = false;
facts.secondary_skin_influences_present = false;
}
if !facts.is_supported_for(
animsmith_core::scale::ScaleOperation::RestBindUniformScale {
source_skin_index: 0,
source_root_node_index: 0,
expected_factor: 1.0,
},
) {
return Err(format_rest_bind_violations(
"projected capability",
&scale_capability_violations(&facts),
));
}
Ok(facts)
}
pub fn rest_bind_capability_facts_for_source(
source: &FbxScaleSource,
) -> Result<ScaleCapabilityFacts, String> {
let source_facts = source.source_facts();
let source_counts = source.rest_bind_construct_counts;
let mut violations = Vec::new();
for (domain, state) in [
("constructs", source_facts.constructs().coverage().state()),
("resources", source_facts.resources().coverage().state()),
] {
if state != SourceSetCoverageStateV1::Complete {
violations.push(format!(
"raw_source.{domain}.coverage={}",
source_set_coverage_state_name(state)
));
}
}
let mut saw_custom_properties = false;
let mut saw_unmodeled_elements = false;
for row in source_facts.constructs().rows() {
match row.kind() {
SourceConstructKindV1::CustomProperty
if row.name().as_str() == "fbx:user-defined-properties" =>
{
saw_custom_properties = true;
if row.count()
!= u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
{
violations.push(format!(
"raw_source.construct=custom_property({}; count={})!=source:{}",
row.name().as_str(),
row.count(),
source_counts.user_defined_property_count
));
}
}
SourceConstructKindV1::CustomProperty => violations.push(format!(
"raw_source.construct=custom_property({}; count={})",
row.name().as_str(),
row.count()
)),
SourceConstructKindV1::UnknownElement
if row.name().as_str() == "fbx:unmodeled-elements" =>
{
saw_unmodeled_elements = true;
let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
.unwrap_or(u64::MAX);
if row.count() != total_count {
violations.push(format!(
"raw_source.construct=unknown_element({}; count={})!=source:{}",
row.name().as_str(),
row.count(),
total_count
));
} else if source_counts.unsupported_unmodeled_element_count() > 0 {
violations.push(format!(
"raw_source.construct=unknown_element({}; {})",
row.name().as_str(),
unmodeled_element_details(source_counts)
));
}
}
SourceConstructKindV1::UnknownElement => violations.push(format!(
"raw_source.construct=unknown_element({}; count={})",
row.name().as_str(),
row.count()
)),
SourceConstructKindV1::Extension => violations.push(format!(
"raw_source.construct=extension({}; count={})",
row.name().as_str(),
row.count()
)),
}
}
if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
violations.push(format!(
"raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
source_counts.user_defined_property_count
));
}
if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
violations.push(format!(
"raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
source_counts.total_unmodeled_element_count()
));
}
if !violations.is_empty() {
return Err(format_rest_bind_violations("raw-source facts", &violations));
}
let mut facts = join_source_facts(
source,
rest_bind_capability_facts_with_context(
source.inventory(),
RestBindCapabilityContext::CapturedSource {
counts: &source_counts,
scale_invariant_payload_mesh_count: source
.rest_bind_scale_invariant_payload_mesh_count,
},
)?,
);
facts.unknown_source_members_present = false;
facts.unregistered_extensions_present = false;
facts.extras_present = false;
facts.external_resources_present = false;
if facts.is_supported_for(
animsmith_core::scale::ScaleOperation::RestBindUniformScale {
source_skin_index: 0,
source_root_node_index: 0,
expected_factor: 1.0,
},
) {
Ok(facts)
} else {
Err(format_rest_bind_violations(
"joined capability",
&scale_capability_violations(&facts),
))
}
}
fn push_unmodeled_element_violation(
violations: &mut Vec<String>,
counts: crate::source_facts::RestBindSourceConstructCounts,
) {
if counts.unsupported_unmodeled_element_count() > 0 {
violations.push(format!(
"unsupported_source_element_count; {}",
unmodeled_element_details(counts)
));
}
}
fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
for (kind, count) in counts.unsupported_kind_counts() {
details.push_str("; ");
details.push_str(kind);
details.push('=');
details.push_str(&count.to_string());
}
details
}
fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
match status {
FbxScaleDomainStatus::Absent => "absent",
FbxScaleDomainStatus::Normalized => "normalized",
FbxScaleDomainStatus::Baked => "baked",
FbxScaleDomainStatus::Derived => "derived",
FbxScaleDomainStatus::Rebuilt => "rebuilt",
FbxScaleDomainStatus::Unsupported => "unsupported",
FbxScaleDomainStatus::Unverifiable => "unverifiable",
}
}
fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
match state {
SourceSetCoverageStateV1::Complete => "complete",
SourceSetCoverageStateV1::Partial => "partial",
SourceSetCoverageStateV1::Unavailable => "unavailable",
}
}
fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
if value > 0 {
violations.push(format!("{name}={value}"));
}
}
fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
format!(
"FBX rest/bind {authority} rejected: {}",
violations.join("; ")
)
}
fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
let mut violations = Vec::new();
if facts.coverage != ScaleCapabilityCoverage::Complete {
violations.push("coverage=unavailable".into());
}
for (name, present) in [
("morphs_present", facts.morphs_present),
("morph_weights_present", facts.morph_weights_present),
("cameras_present", facts.cameras_present),
("lights_present", facts.lights_present),
("instancing_present", facts.instancing_present),
(
"unregistered_extensions_present",
facts.unregistered_extensions_present,
),
("extras_present", facts.extras_present),
(
"unknown_source_members_present",
facts.unknown_source_members_present,
),
(
"non_triangle_primitives_present",
facts.non_triangle_primitives_present,
),
(
"unsupported_vertex_attributes_present",
facts.unsupported_vertex_attributes_present,
),
(
"secondary_skin_influences_present",
facts.secondary_skin_influences_present,
),
(
"inverse_bind_issues_present",
facts.inverse_bind_issues_present,
),
(
"unsafe_accessor_layout_present",
facts.unsafe_accessor_layout_present,
),
(
"external_resources_present",
facts.external_resources_present,
),
] {
if present {
violations.push(format!("{name}=true"));
}
}
violations
}
#[derive(Debug, Default)]
pub(crate) struct AssetConversionFacts {
pub(crate) truncated_influence_vertex_count: usize,
pub(crate) discarded_influence_count: usize,
pub(crate) renormalized_influence_vertex_count: usize,
pub(crate) rejected_influence_count: usize,
pub(crate) missing_skin_influence_corner_count: usize,
pub(crate) pre_weld_vertex_count: usize,
pub(crate) post_weld_vertex_count: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct RestBindMeshPayloadCounts {
pub(crate) unsupported_mesh_count: usize,
pub(crate) scale_invariant_mesh_count: usize,
}
fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
FbxSourceIdentity {
source_index: index,
ufbx_typed_id: element.typed_id,
ufbx_element_id: element.element_id,
}
}
pub(crate) fn inventory(
scene: &ufbx::Scene,
conversion: &AssetConversionFacts,
construct_counts: crate::source_facts::SourceConstructCounts,
) -> (FbxScaleCapabilityInventory, RestBindMeshPayloadCounts) {
let non_triangle_face_count = scene
.meshes
.iter()
.flat_map(|mesh| mesh.faces.iter())
.filter(|face| face.num_indices != 3)
.count();
let triangulated_face_count = scene
.meshes
.iter()
.flat_map(|mesh| mesh.faces.iter())
.filter(|face| face.num_indices > 3)
.count();
let omitted_non_polygon_face_count = scene
.meshes
.iter()
.flat_map(|mesh| mesh.faces.iter())
.filter(|face| face.num_indices < 3)
.count();
let empty_source_meshes = scene
.meshes
.iter()
.enumerate()
.filter(|(_, mesh)| mesh.faces.is_empty())
.map(|(index, mesh)| identity(index, &mesh.element))
.collect::<Vec<_>>();
let empty_mesh_definition_count = empty_source_meshes.len();
let generated_normal_mesh_count = scene
.meshes
.iter()
.filter(|mesh| mesh.generated_normals)
.count();
let missing_normal_mesh_count = scene
.meshes
.iter()
.filter(|mesh| !mesh.vertex_normal.exists)
.count();
let skin_cluster_count = scene
.skin_deformers
.iter()
.map(|skin| skin.clusters.len())
.sum();
let empty_skin_deformer_count = scene
.skin_deformers
.iter()
.filter(|skin| skin.clusters.is_empty())
.count();
let incomplete_bind_cluster_count = scene
.skin_clusters
.iter()
.filter(|cluster| super::project_cluster_bind(cluster).is_none())
.count();
let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
for cluster in &scene.skin_clusters {
if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
*clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
}
}
let bone_convenience_bind_overwrite_count = clusters_per_bone
.values()
.map(|count| count.saturating_sub(1))
.sum();
let multiple_skin_deformer_mesh_count = scene
.meshes
.iter()
.filter(|mesh| mesh.skin_deformers.len() > 1)
.count();
let dual_quaternion_skin_count = scene
.skin_deformers
.iter()
.filter(|skin| {
skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
})
.count();
let mesh_payload_counts = scene
.meshes
.iter()
.map(|mesh| classify_mesh_source_payload(mesh))
.fold(
RestBindMeshPayloadCounts::default(),
|mut counts, classification| {
match classification {
MeshSourcePayloadClassification::Absent => {}
MeshSourcePayloadClassification::ScaleInvariantConversion => {
counts.unsupported_mesh_count += 1;
counts.scale_invariant_mesh_count += 1;
}
MeshSourcePayloadClassification::Unsupported => {
counts.unsupported_mesh_count += 1;
}
}
counts
},
);
let unsupported_vertex_payload_mesh_count = mesh_payload_counts.unsupported_mesh_count;
let shared_mesh_definition_count = scene
.meshes
.iter()
.filter(|mesh| mesh.element.instances.len() > 1)
.count();
let uninstanced_source_meshes = scene
.meshes
.iter()
.enumerate()
.filter(|(_, mesh)| mesh.element.instances.is_empty())
.map(|(index, mesh)| identity(index, &mesh.element))
.collect::<Vec<_>>();
let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
let unsupported_source_element_count =
construct_counts.rest_bind.total_unmodeled_element_count();
let external_resource_count = scene
.textures
.iter()
.filter(|texture| texture.content.is_empty() && texture.has_file)
.count()
+ scene
.videos
.iter()
.filter(|video| {
video.content.is_empty()
&& (!video.filename.is_empty()
|| !video.relative_filename.is_empty()
|| !video.absolute_filename.is_empty())
})
.count();
let compensated_inherit_node_count = scene
.nodes
.iter()
.filter(|node| {
node.original_inherit_mode != node.inherit_mode
|| node.is_scale_helper
|| node.is_scale_compensate_parent
})
.count();
let stackless_animation_present = scene.anim_stacks.is_empty()
&& (!scene.anim_layers.is_empty()
|| !scene.anim_values.is_empty()
|| !scene.anim_curves.is_empty());
let animation = if !scene.anim_stacks.is_empty() {
FbxScaleDomainStatus::Baked
} else if stackless_animation_present {
FbxScaleDomainStatus::Unsupported
} else {
FbxScaleDomainStatus::Absent
};
let domains = FbxScaleDomainInventory {
rest_hierarchy: FbxScaleDomainStatus::Normalized,
translation_animation: animation,
rotation_and_scale_animation: animation,
root_motion_and_velocity: match animation {
FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
status => status,
},
base_mesh_geometry: if scene.meshes.is_empty() {
FbxScaleDomainStatus::Absent
} else if uninstanced_mesh_definition_count > 0
|| omitted_non_polygon_face_count > 0
|| empty_mesh_definition_count > 0
{
FbxScaleDomainStatus::Unsupported
} else {
FbxScaleDomainStatus::Rebuilt
},
morphs: if scene.blend_deformers.is_empty()
&& scene.blend_channels.is_empty()
&& scene.blend_shapes.is_empty()
{
FbxScaleDomainStatus::Absent
} else {
FbxScaleDomainStatus::Unsupported
},
skin_binds: if scene.skin_deformers.is_empty() {
FbxScaleDomainStatus::Absent
} else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
FbxScaleDomainStatus::Unsupported
} else {
FbxScaleDomainStatus::Derived
},
cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
FbxScaleDomainStatus::Absent
} else {
FbxScaleDomainStatus::Unsupported
},
collision_and_custom_data: if unsupported_source_element_count == 0
&& user_defined_property_count == 0
{
FbxScaleDomainStatus::Absent
} else {
FbxScaleDomainStatus::Unsupported
},
other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
|| uninstanced_mesh_definition_count > 0
|| omitted_non_polygon_face_count > 0
|| empty_mesh_definition_count > 0
|| multiple_skin_deformer_mesh_count > 0
|| dual_quaternion_skin_count > 0
|| conversion.truncated_influence_vertex_count > 0
|| conversion.missing_skin_influence_corner_count > 0
|| conversion.rejected_influence_count > 0
|| !scene.blend_deformers.is_empty()
|| !scene.blend_channels.is_empty()
|| !scene.blend_shapes.is_empty()
|| !scene.cache_deformers.is_empty()
|| !scene.cache_files.is_empty()
{
FbxScaleDomainStatus::Unsupported
} else if !scene.meshes.is_empty() {
FbxScaleDomainStatus::Rebuilt
} else {
FbxScaleDomainStatus::Absent
},
out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
animation_targeting_matrix_nodes: animation,
shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
};
let inventory = FbxScaleCapabilityInventory {
domains,
coordinate_normalization: FbxCoordinateNormalization {
original_up_axis: scene.settings.original_axis_up.into(),
original_unit_meters: scene.settings.original_unit_meters,
target_right_handed_y_up: true,
target_unit_meters: 1.0,
adjust_transforms: matches!(
scene.metadata.space_conversion,
ufbx::SpaceConversion::AdjustTransforms
),
},
animation_takes_baked: true,
authored_curve_keys_preserved: false,
animation_take_count: scene.anim_stacks.len(),
source_animation_curve_count: scene.anim_curves.len(),
generated_geometry_helper_node_count: scene
.nodes
.iter()
.filter(|node| node.is_geometry_transform_helper)
.count(),
generated_scale_helper_node_count: scene
.nodes
.iter()
.filter(|node| node.is_scale_helper)
.count(),
inherit_modes_compensated: matches!(
scene.metadata.inherit_mode_handling,
ufbx::InheritModeHandling::Compensate
),
compensated_inherit_node_count,
generated_normal_mesh_count,
missing_normal_mesh_count,
skin_deformer_count: scene.skin_deformers.len(),
skin_cluster_count,
empty_skin_deformer_count,
bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
incomplete_bind_cluster_count,
bone_convenience_bind_overwrite_count,
identity_bind_defaults_invented: false,
truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
discarded_influence_count: conversion.discarded_influence_count,
renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
rejected_influence_count: conversion.rejected_influence_count,
missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
non_triangle_face_count,
triangulated_face_count,
omitted_non_polygon_face_count,
empty_mesh_definition_count,
empty_source_meshes,
pre_weld_vertex_count: conversion.pre_weld_vertex_count,
post_weld_vertex_count: conversion.post_weld_vertex_count,
multiple_skin_deformer_mesh_count,
dual_quaternion_skin_count,
blend_deformer_count: scene.blend_deformers.len(),
blend_channel_count: scene.blend_channels.len(),
blend_shape_count: scene.blend_shapes.len(),
cache_deformer_count: scene.cache_deformers.len(),
unsupported_vertex_payload_mesh_count,
camera_count: scene.cameras.len(),
light_count: scene.lights.len(),
shared_mesh_definition_count,
uninstanced_mesh_definition_count,
uninstanced_source_meshes,
user_defined_property_count,
unsupported_source_element_count,
external_resource_count,
source_nodes: scene
.nodes
.iter()
.enumerate()
.map(|(index, node)| identity(index, &node.element))
.collect(),
source_meshes: scene
.meshes
.iter()
.enumerate()
.map(|(index, mesh)| identity(index, &mesh.element))
.collect(),
source_skins: scene
.skin_deformers
.iter()
.enumerate()
.map(|(index, skin)| identity(index, &skin.element))
.collect(),
};
(inventory, mesh_payload_counts)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MeshSourcePayloadClassification {
Absent,
ScaleInvariantConversion,
Unsupported,
}
fn classify_mesh_source_payload(mesh: &ufbx::Mesh) -> MeshSourcePayloadClassification {
let ufbx::Mesh {
element: _,
num_vertices: _,
num_indices: _,
num_faces: _,
num_triangles: _,
num_edges: _,
max_face_triangles: _,
num_empty_faces: _,
num_point_faces: _,
num_line_faces: _,
faces: _,
face_smoothing,
face_material: _,
face_group,
face_hole,
edges,
edge_smoothing,
edge_crease,
edge_visibility,
vertex_indices: _,
vertices: _,
vertex_first_index: _,
vertex_position: _,
vertex_normal: _,
vertex_uv: _,
vertex_tangent,
vertex_bitangent,
vertex_color,
vertex_crease,
uv_sets,
color_sets,
materials: _,
face_groups,
material_parts: _,
face_group_parts: _,
material_part_usage_order: _,
skinned_is_local: _,
skinned_position: _,
skinned_normal: _,
skin_deformers: _,
blend_deformers: _,
cache_deformers: _,
all_deformers: _,
subdivision_preview_levels,
subdivision_render_levels,
subdivision_display_mode,
subdivision_boundary,
subdivision_uv_boundary,
reversed_winding: _,
generated_normals: _,
subdivision_evaluated,
subdivision_result,
from_tessellated_nurbs,
} = mesh;
let unsupported_generated_payload_present =
!matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
|| *subdivision_evaluated
|| subdivision_result.is_some()
|| *from_tessellated_nurbs;
let scale_invariant_conversion_facts_present = !face_smoothing.is_empty()
|| !face_group.is_empty()
|| !face_hole.is_empty()
|| !edges.is_empty()
|| !edge_smoothing.is_empty()
|| !edge_crease.is_empty()
|| !edge_visibility.is_empty()
|| vertex_tangent.exists
|| vertex_bitangent.exists
|| vertex_color.exists
|| vertex_crease.exists
|| uv_sets.len() > 1
|| !color_sets.is_empty()
|| !face_groups.is_empty()
|| *subdivision_preview_levels > 0
|| *subdivision_render_levels > 0
|| !matches!(
subdivision_display_mode,
ufbx::SubdivisionDisplayMode::Disabled
)
|| !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default);
if unsupported_generated_payload_present {
MeshSourcePayloadClassification::Unsupported
} else if scale_invariant_conversion_facts_present {
MeshSourcePayloadClassification::ScaleInvariantConversion
} else {
MeshSourcePayloadClassification::Absent
}
}
#[cfg(test)]
mod tests {
use super::*;
use animsmith_core::{
InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
};
use std::path::PathBuf;
fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
captured_with_counts(configure, |_| {})
}
fn captured_with_counts(
configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
) -> FbxScaleSource {
let fixture =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
let document = baseline.document().clone();
let mut inventory = baseline.inventory().clone();
let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
let rest_bind_scale_invariant_payload_mesh_count =
baseline.rest_bind_scale_invariant_payload_mesh_count;
configure_counts(&mut rest_bind_construct_counts);
inventory.user_defined_property_count =
rest_bind_construct_counts.user_defined_property_count;
inventory.unsupported_source_element_count =
rest_bind_construct_counts.total_unmodeled_element_count();
inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
&& inventory.unsupported_source_element_count == 0
{
FbxScaleDomainStatus::Absent
} else {
FbxScaleDomainStatus::Unsupported
};
let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
configure(&mut builder);
let source = builder.finish(document).expect("synthetic raw facts bind");
FbxScaleSource {
source,
inventory,
rest_bind_construct_counts,
rest_bind_scale_invariant_payload_mesh_count,
}
}
fn parser_provenance(path: &str) -> SourceProvenanceV1 {
SourceProvenanceV1::parser_projected(
SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
)
}
fn complete_captured_source() -> FbxScaleSource {
captured_with(|builder| {
builder.mark_complete(SourceFactDomainV1::Constructs);
builder.mark_complete(SourceFactDomainV1::Resources);
})
}
#[test]
fn source_aware_rest_bind_admits_only_reconciled_scale_invariant_conversion_facts() {
let mut source = complete_captured_source();
source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
source.inventory.unsupported_vertex_payload_mesh_count = 1;
source.rest_bind_scale_invariant_payload_mesh_count = 1;
source.inventory.truncated_influence_vertex_count = 1;
source.inventory.discarded_influence_count = 2;
source.inventory.renormalized_influence_vertex_count = 1;
source.inventory.rejected_influence_count = 1;
source.inventory.non_triangle_face_count = 1;
source.inventory.triangulated_face_count = 1;
source.inventory.post_weld_vertex_count = source.inventory.pre_weld_vertex_count - 1;
assert!(
rest_bind_capability_facts(source.inventory()).is_err(),
"a detached public inventory cannot prove these conversions are scale-invariant"
);
let facts = rest_bind_capability_facts_for_source(&source)
.expect("same-parse enumerated conversion facts are admissible");
assert!(!facts.non_triangle_primitives_present);
assert!(!facts.unsupported_vertex_attributes_present);
assert!(!facts.secondary_skin_influences_present);
source.rest_bind_scale_invariant_payload_mesh_count = 0;
assert_eq!(
rest_bind_capability_facts_for_source(&source).unwrap_err(),
concat!(
"FBX rest/bind capability inventory rejected: ",
"unsupported_vertex_payload_mesh_count=1!=scale_invariant_source:0"
),
"an unclassified payload cannot hide inside the public aggregate"
);
source.rest_bind_scale_invariant_payload_mesh_count = 1;
source.inventory.missing_skin_influence_corner_count = 1;
assert_eq!(
rest_bind_capability_facts_for_source(&source).unwrap_err(),
concat!(
"FBX rest/bind capability inventory rejected: ",
"missing_skin_influence_corner_count=1"
),
"conversion evidence never overrides missing effective skin coverage"
);
}
#[test]
fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
for (source, expected) in [
(
captured_with(|builder| {
builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
builder.mark_complete(SourceFactDomainV1::Resources);
}),
"FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
),
(
captured_with(|builder| {
builder.mark_complete(SourceFactDomainV1::Constructs);
builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
}),
"FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
),
] {
assert_eq!(
rest_bind_capability_facts_for_source(&source).unwrap_err(),
expected,
"partial construct/resource coverage must name the exact raw authority"
);
}
}
#[test]
fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
for kind in [
SourceConstructKindV1::UnknownElement,
SourceConstructKindV1::Extension,
] {
let source = captured_with(|builder| {
builder.push_construct(
SourceConstructFactV1::new(
0,
kind,
SourceTextV1::new("synthetic").expect("bounded test name"),
false,
1,
SourceLoaderDispositionV1::Unsupported,
parser_provenance("fbx:synthetic/construct"),
)
.expect("positive test construct"),
);
builder.mark_complete(SourceFactDomainV1::Constructs);
builder.mark_complete(SourceFactDomainV1::Resources);
});
let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
let expected = match kind {
SourceConstructKindV1::UnknownElement => "unknown_element",
SourceConstructKindV1::Extension => "extension",
SourceConstructKindV1::CustomProperty => unreachable!(),
};
assert_eq!(
error,
format!(
"FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
)
);
}
let custom_and_external = captured_with_counts(
|builder| {
builder.push_construct(
SourceConstructFactV1::new(
0,
SourceConstructKindV1::CustomProperty,
SourceTextV1::new("fbx:user-defined-properties")
.expect("bounded test name"),
false,
1,
SourceLoaderDispositionV1::Unsupported,
parser_provenance("fbx:synthetic/property"),
)
.expect("positive custom-property row"),
);
builder.mark_complete(SourceFactDomainV1::Constructs);
builder.push_resource(SourceResourceReferenceV1::new(
0,
SourceResourceKindV1::Texture,
0,
SourceResourceLocatorV1::classify("texture.png"),
SourceLoaderDispositionV1::Unknown,
parser_provenance("fbx:textures/0/filename"),
));
builder.mark_complete(SourceFactDomainV1::Resources);
},
|counts| counts.user_defined_property_count = 1,
);
let facts = rest_bind_capability_facts_for_source(&custom_and_external)
.expect("custom properties and external images are not scale-bearing");
assert!(!facts.extras_present);
assert!(!facts.external_resources_present);
assert!(facts.is_supported_for(
animsmith_core::scale::ScaleOperation::RestBindUniformScale {
source_skin_index: 0,
source_root_node_index: 1,
expected_factor: 0.01,
}
));
}
}