Skip to main content

animsmith_fbx/
capability.rs

1//! Conservative ufbx-side scale capability inventory.
2
3use animsmith_core::Document;
4use animsmith_core::scale::{ScaleCapabilityCoverage, ScaleCapabilityFacts};
5
6/// How one Appendix D.4 domain reaches the normalized FBX document.
7///
8/// These values describe semantic ingestion only. None claims raw FBX byte,
9/// object-property, curve-key, or payload-span preservation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum FbxScaleDomainStatus {
13    /// The source inspection proved that the domain is absent.
14    Absent,
15    /// ufbx normalized the source representation before it reached the core model.
16    Normalized,
17    /// ufbx evaluated source animation into resampled linear TRS tracks.
18    Baked,
19    /// The value is derived from another normalized domain.
20    Derived,
21    /// The loader rebuilt the domain into a different normalized representation.
22    Rebuilt,
23    /// The source domain is present but not completely represented.
24    Unsupported,
25    /// ufbx exposes no raw-span relationship with which to prove this domain.
26    Unverifiable,
27}
28
29/// Explicit status for every current domain row in DESIGN.md Appendix D.4.
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct FbxScaleDomainInventory {
33    /// Rest hierarchy and local transforms.
34    pub rest_hierarchy: FbxScaleDomainStatus,
35    /// Translation animation values and tangents.
36    pub translation_animation: FbxScaleDomainStatus,
37    /// Rotation and scale animation values and tangents.
38    pub rotation_and_scale_animation: FbxScaleDomainStatus,
39    /// Root-motion and velocity evidence derived from translation tracks.
40    pub root_motion_and_velocity: FbxScaleDomainStatus,
41    /// Base mesh positions and normals.
42    pub base_mesh_geometry: FbxScaleDomainStatus,
43    /// Morph targets and morph-weight animation.
44    pub morphs: FbxScaleDomainStatus,
45    /// Per-skin inverse-bind matrices.
46    pub skin_binds: FbxScaleDomainStatus,
47    /// Cameras and lights.
48    pub cameras_and_lights: FbxScaleDomainStatus,
49    /// Collision, custom properties, constraints, and unknown elements.
50    pub collision_and_custom_data: FbxScaleDomainStatus,
51    /// Other vertex attributes, deformers, and source geometry kinds.
52    pub other_vertex_and_source_data: FbxScaleDomainStatus,
53    /// Source transform-stack state outside the normalized TRS model.
54    pub out_of_contract_node_transforms: FbxScaleDomainStatus,
55    /// Animation targeting source transform-stack or matrix state.
56    pub animation_targeting_matrix_nodes: FbxScaleDomainStatus,
57    /// Shared raw payload spans corresponding to glTF accessors.
58    pub shared_raw_accessor_payloads: FbxScaleDomainStatus,
59    /// Raw payload spans corresponding to unreferenced glTF accessors.
60    pub unreferenced_accessor_payloads: FbxScaleDomainStatus,
61    /// Image payload spans that could alias scale-bearing source bytes.
62    pub image_payload_aliases: FbxScaleDomainStatus,
63}
64
65impl FbxScaleDomainInventory {
66    /// Every Appendix D.4 row in table order with its current FBX status.
67    ///
68    /// This is the mechanical bridge between the public named fields and the
69    /// design table. Tests compare these names with the table so adding a row
70    /// to either authority cannot silently leave the other incomplete.
71    pub fn named_rows(&self) -> [(&'static str, FbxScaleDomainStatus); 15] {
72        [
73            ("Rest hierarchy", self.rest_hierarchy),
74            ("Translation animation", self.translation_animation),
75            (
76                "Rotation and scale animation",
77                self.rotation_and_scale_animation,
78            ),
79            ("Root motion and velocity", self.root_motion_and_velocity),
80            ("Base mesh geometry", self.base_mesh_geometry),
81            ("Morphs", self.morphs),
82            ("Skin binds", self.skin_binds),
83            ("Cameras/lights", self.cameras_and_lights),
84            ("Collision/custom data", self.collision_and_custom_data),
85            (
86                "Other vertex/source data",
87                self.other_vertex_and_source_data,
88            ),
89            (
90                "Out-of-contract node transforms",
91                self.out_of_contract_node_transforms,
92            ),
93            (
94                "Animation targeting a matrix node",
95                self.animation_targeting_matrix_nodes,
96            ),
97            (
98                "Shared raw accessor payloads",
99                self.shared_raw_accessor_payloads,
100            ),
101            (
102                "Unreferenced accessor payloads",
103                self.unreferenced_accessor_payloads,
104            ),
105            ("Image payload aliases", self.image_payload_aliases),
106        ]
107    }
108}
109
110/// A format-independent spelling of one FBX coordinate axis.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum FbxCoordinateAxis {
114    /// Positive X.
115    PositiveX,
116    /// Negative X.
117    NegativeX,
118    /// Positive Y.
119    PositiveY,
120    /// Negative Y.
121    NegativeY,
122    /// Positive Z.
123    PositiveZ,
124    /// Negative Z.
125    NegativeZ,
126    /// ufbx could not determine the axis.
127    Unknown,
128}
129
130impl From<ufbx::CoordinateAxis> for FbxCoordinateAxis {
131    fn from(value: ufbx::CoordinateAxis) -> Self {
132        match value {
133            ufbx::CoordinateAxis::PositiveX => Self::PositiveX,
134            ufbx::CoordinateAxis::NegativeX => Self::NegativeX,
135            ufbx::CoordinateAxis::PositiveY => Self::PositiveY,
136            ufbx::CoordinateAxis::NegativeY => Self::NegativeY,
137            ufbx::CoordinateAxis::PositiveZ => Self::PositiveZ,
138            ufbx::CoordinateAxis::NegativeZ => Self::NegativeZ,
139            ufbx::CoordinateAxis::Unknown => Self::Unknown,
140        }
141    }
142}
143
144/// Coordinate and unit normalization applied by the loader.
145#[derive(Debug, Clone, PartialEq)]
146#[non_exhaustive]
147pub struct FbxCoordinateNormalization {
148    /// Original source up axis reported by ufbx.
149    pub original_up_axis: FbxCoordinateAxis,
150    /// Original source unit in metres reported by ufbx.
151    pub original_unit_meters: f64,
152    /// Target is right-handed, +Y up, and -Z forward.
153    pub target_right_handed_y_up: bool,
154    /// Target unit in metres.
155    pub target_unit_meters: f64,
156    /// ufbx adjusted transforms rather than preserving raw transform members.
157    pub adjust_transforms: bool,
158}
159
160/// Stable source identity retained beside one normalized ufbx element.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[non_exhaustive]
163pub struct FbxSourceIdentity {
164    /// Stable index in the relevant ufbx typed list.
165    pub source_index: usize,
166    /// ufbx's typed id, which addresses that typed list.
167    pub ufbx_typed_id: u32,
168    /// ufbx's scene-wide element id, or zero for its generated root.
169    ///
170    /// This is deliberately not described as the raw FBX object id: ufbx
171    /// assigns its own stable scene identity after parsing and normalization.
172    pub ufbx_element_id: u32,
173}
174
175/// Provenance of inverse-bind matrices projected into the source sidecar.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum FbxBindMatrixProvenance {
179    /// ufbx converted cluster bind matrices into target coordinates, then the
180    /// loader derived `bind_to_world^-1 * geometry_to_world` per cluster.
181    UfbxConvertedClusterMatrices,
182}
183
184/// Deterministic capability inventory captured from one successfully parsed FBX scene.
185///
186/// Every current Appendix D.4 row has a status, but those statuses deliberately
187/// include unsupported and unverifiable states. Call
188/// [`capability_facts`] to project those states into the format-neutral core
189/// gate; #286-A never turns them into operation support.
190#[derive(Debug, Clone, PartialEq)]
191#[non_exhaustive]
192pub struct FbxScaleCapabilityInventory {
193    /// Every Appendix D.4 domain, in named fields rather than an absence-based map.
194    pub domains: FbxScaleDomainInventory,
195    /// Coordinate and unit normalization applied before model construction.
196    pub coordinate_normalization: FbxCoordinateNormalization,
197    /// Every animation take is evaluated through `ufbx::bake_anim`.
198    pub animation_takes_baked: bool,
199    /// Authored FBX curve keys and interpolation are not retained.
200    pub authored_curve_keys_preserved: bool,
201    /// Number of source animation takes.
202    pub animation_take_count: usize,
203    /// Number of source animation curves discarded after baking.
204    pub source_animation_curve_count: usize,
205    /// Number of ufbx-generated geometry-transform helper nodes.
206    pub generated_geometry_helper_node_count: usize,
207    /// Number of ufbx-generated scale-compensation helper nodes.
208    pub generated_scale_helper_node_count: usize,
209    /// Whether the load boundary asks ufbx to compensate FBX inherit modes.
210    pub inherit_modes_compensated: bool,
211    /// Number of nodes whose original inherit mode or helper state required compensation.
212    pub compensated_inherit_node_count: usize,
213    /// Number of meshes for which ufbx generated missing normals.
214    pub generated_normal_mesh_count: usize,
215    /// Number of meshes still lacking normals after generation was requested.
216    pub missing_normal_mesh_count: usize,
217    /// Number of source skin deformers.
218    pub skin_deformer_count: usize,
219    /// Number of source skin clusters.
220    pub skin_cluster_count: usize,
221    /// Number of source skin deformers that declare no clusters or bind matrices.
222    pub empty_skin_deformer_count: usize,
223    /// Provenance of every available projected inverse-bind matrix.
224    pub bind_matrix_provenance: FbxBindMatrixProvenance,
225    /// Number of clusters missing a bone or a finite converted bind matrix.
226    pub incomplete_bind_cluster_count: usize,
227    /// Number of times multiple successfully projected clusters target one bone and overwrite its
228    /// lossy convenience bind. Unreadable clusters are skipped, not counted as writes.
229    pub bone_convenience_bind_overwrite_count: usize,
230    /// Whether the loader invented identity matrices for missing bind evidence.
231    pub identity_bind_defaults_invented: bool,
232    /// Number of normalized vertices whose source influence list exceeded four entries.
233    pub truncated_influence_vertex_count: usize,
234    /// Number of source influences discarded by the four-slot limit.
235    pub discarded_influence_count: usize,
236    /// Number of normalized vertices whose retained weights changed during renormalization.
237    pub renormalized_influence_vertex_count: usize,
238    /// Number of non-finite, negative, or unrepresentable source influences rejected.
239    pub rejected_influence_count: usize,
240    /// Number of emitted skinned corners whose source vertex had no influence record.
241    pub missing_skin_influence_corner_count: usize,
242    /// Number of source faces that are not triangles.
243    pub non_triangle_face_count: usize,
244    /// Number of polygon faces with more than three corners that were triangulated.
245    pub triangulated_face_count: usize,
246    /// Number of point/line faces omitted from triangle output.
247    pub omitted_non_polygon_face_count: usize,
248    /// Number of source mesh definitions that declare no faces.
249    pub empty_mesh_definition_count: usize,
250    /// Stable identities of the zero-face source mesh definitions counted above.
251    pub empty_source_meshes: Vec<FbxSourceIdentity>,
252    /// Number of unindexed corners submitted to exact-bit welding.
253    pub pre_weld_vertex_count: usize,
254    /// Number of normalized vertices retained after exact-bit welding.
255    pub post_weld_vertex_count: usize,
256    /// Number of source meshes with more than one skin deformer.
257    pub multiple_skin_deformer_mesh_count: usize,
258    /// Number of dual-quaternion skin deformers not represented by the normalized model.
259    pub dual_quaternion_skin_count: usize,
260    /// Number of blend deformers (morph domains) not represented by the normalized model.
261    pub blend_deformer_count: usize,
262    /// Number of blend channels not represented by the normalized model.
263    pub blend_channel_count: usize,
264    /// Number of blend shapes not represented by the normalized model.
265    pub blend_shape_count: usize,
266    /// Number of geometry cache deformers not represented by the normalized model.
267    pub cache_deformer_count: usize,
268    /// Number of meshes carrying unsupported modeled-vertex payloads.
269    pub unsupported_vertex_payload_mesh_count: usize,
270    /// Number of cameras.
271    pub camera_count: usize,
272    /// Number of lights.
273    pub light_count: usize,
274    /// Number of shared mesh definitions with more than one node instance.
275    pub shared_mesh_definition_count: usize,
276    /// Number of source mesh definitions with no node instance and no normalized output mesh.
277    pub uninstanced_mesh_definition_count: usize,
278    /// Stable identities of the uninstanced source mesh definitions counted above.
279    pub uninstanced_source_meshes: Vec<FbxSourceIdentity>,
280    /// Number of user-defined source properties.
281    pub user_defined_property_count: usize,
282    /// Number of unknown or otherwise unmodeled source elements/scene records.
283    pub unsupported_source_element_count: usize,
284    /// Number of referenced external texture/video payloads.
285    pub external_resource_count: usize,
286    /// Node identities in stable ufbx source order.
287    pub source_nodes: Vec<FbxSourceIdentity>,
288    /// Mesh identities in stable ufbx source order.
289    pub source_meshes: Vec<FbxSourceIdentity>,
290    /// Skin-deformer identities in stable ufbx source order.
291    pub source_skins: Vec<FbxSourceIdentity>,
292}
293
294/// One FBX document and the capability inventory captured from the same parse.
295#[derive(Debug, Clone)]
296pub struct FbxScaleSource {
297    pub(crate) document: Document,
298    pub(crate) inventory: FbxScaleCapabilityInventory,
299}
300
301impl FbxScaleSource {
302    /// The normalized document carrying the documented ufbx source projection.
303    pub fn document(&self) -> &Document {
304        &self.document
305    }
306
307    /// The conservative ufbx-side inventory.
308    pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
309        &self.inventory
310    }
311
312    /// Consume the source wrapper and retain its normalized document.
313    pub fn into_document(self) -> Document {
314        self.document
315    }
316}
317
318/// Project an FBX inventory into the format-neutral core capability gate.
319///
320/// `coverage` means every Appendix D.4 domain has an explicit status, not that
321/// any domain is preserved losslessly. Support remains false: normalized transform stacks, baked
322/// curves, rebuilt meshes, and unverifiable raw payload relationships are
323/// recorded as unsupported facts rather than hidden behind absent flags.
324pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
325    let mut facts = ScaleCapabilityFacts::default();
326    facts.coverage = ScaleCapabilityCoverage::Complete;
327    let morph_source_present = inventory.blend_deformer_count > 0
328        || inventory.blend_channel_count > 0
329        || inventory.blend_shape_count > 0;
330    facts.morphs_present = morph_source_present;
331    facts.morph_weights_present = morph_source_present;
332    facts.cameras_present = inventory.camera_count > 0;
333    facts.lights_present = inventory.light_count > 0;
334    facts.instancing_present = inventory.shared_mesh_definition_count > 0;
335    facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
336    facts.extras_present = inventory.user_defined_property_count > 0;
337    // FBX transform stacks and authored animation curves are normalized or
338    // baked before Document construction, so their raw members are not in
339    // the model even for the smallest accepted scene.
340    facts.unknown_source_members_present = true;
341    facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
342    facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
343        > 0
344        || inventory.uninstanced_mesh_definition_count > 0
345        || inventory.empty_mesh_definition_count > 0
346        || inventory.multiple_skin_deformer_mesh_count > 0
347        || inventory.dual_quaternion_skin_count > 0
348        || inventory.cache_deformer_count > 0
349        || inventory.missing_skin_influence_corner_count > 0
350        || inventory.rejected_influence_count > 0
351        || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
352    facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
353    facts.inverse_bind_issues_present =
354        inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
355    // ufbx exposes normalized objects, not accessor/image byte spans. A future
356    // FBX writer must discharge this preservation obligation through the full
357    // inventory route; #286-A cannot declare the source layout rewrite-safe.
358    facts.unsafe_accessor_layout_present = true;
359    facts.external_resources_present = inventory.external_resource_count > 0;
360    facts
361}
362
363#[derive(Debug, Default)]
364pub(crate) struct AssetConversionFacts {
365    pub(crate) truncated_influence_vertex_count: usize,
366    pub(crate) discarded_influence_count: usize,
367    pub(crate) renormalized_influence_vertex_count: usize,
368    pub(crate) rejected_influence_count: usize,
369    pub(crate) missing_skin_influence_corner_count: usize,
370    pub(crate) pre_weld_vertex_count: usize,
371    pub(crate) post_weld_vertex_count: usize,
372}
373
374fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
375    FbxSourceIdentity {
376        source_index: index,
377        ufbx_typed_id: element.typed_id,
378        ufbx_element_id: element.element_id,
379    }
380}
381
382pub(crate) fn inventory(
383    scene: &ufbx::Scene,
384    conversion: &AssetConversionFacts,
385) -> FbxScaleCapabilityInventory {
386    let non_triangle_face_count = scene
387        .meshes
388        .iter()
389        .flat_map(|mesh| mesh.faces.iter())
390        .filter(|face| face.num_indices != 3)
391        .count();
392    let triangulated_face_count = scene
393        .meshes
394        .iter()
395        .flat_map(|mesh| mesh.faces.iter())
396        .filter(|face| face.num_indices > 3)
397        .count();
398    let omitted_non_polygon_face_count = scene
399        .meshes
400        .iter()
401        .flat_map(|mesh| mesh.faces.iter())
402        .filter(|face| face.num_indices < 3)
403        .count();
404    let empty_source_meshes = scene
405        .meshes
406        .iter()
407        .enumerate()
408        .filter(|(_, mesh)| mesh.faces.is_empty())
409        .map(|(index, mesh)| identity(index, &mesh.element))
410        .collect::<Vec<_>>();
411    let empty_mesh_definition_count = empty_source_meshes.len();
412    let generated_normal_mesh_count = scene
413        .meshes
414        .iter()
415        .filter(|mesh| mesh.generated_normals)
416        .count();
417    let missing_normal_mesh_count = scene
418        .meshes
419        .iter()
420        .filter(|mesh| !mesh.vertex_normal.exists)
421        .count();
422    let skin_cluster_count = scene
423        .skin_deformers
424        .iter()
425        .map(|skin| skin.clusters.len())
426        .sum();
427    let empty_skin_deformer_count = scene
428        .skin_deformers
429        .iter()
430        .filter(|skin| skin.clusters.is_empty())
431        .count();
432    let incomplete_bind_cluster_count = scene
433        .skin_clusters
434        .iter()
435        .filter(|cluster| super::project_cluster_bind(cluster).is_none())
436        .count();
437    let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
438    for cluster in &scene.skin_clusters {
439        if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
440            *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
441        }
442    }
443    let bone_convenience_bind_overwrite_count = clusters_per_bone
444        .values()
445        .map(|count| count.saturating_sub(1))
446        .sum();
447    let multiple_skin_deformer_mesh_count = scene
448        .meshes
449        .iter()
450        .filter(|mesh| mesh.skin_deformers.len() > 1)
451        .count();
452    let dual_quaternion_skin_count = scene
453        .skin_deformers
454        .iter()
455        .filter(|skin| {
456            skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
457        })
458        .count();
459    let unsupported_vertex_payload_mesh_count = scene
460        .meshes
461        .iter()
462        .filter(|mesh| mesh_has_unsupported_source_payload(mesh))
463        .count();
464    let shared_mesh_definition_count = scene
465        .meshes
466        .iter()
467        .filter(|mesh| mesh.element.instances.len() > 1)
468        .count();
469    let uninstanced_source_meshes = scene
470        .meshes
471        .iter()
472        .enumerate()
473        .filter(|(_, mesh)| mesh.element.instances.is_empty())
474        .map(|(index, mesh)| identity(index, &mesh.element))
475        .collect::<Vec<_>>();
476    let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
477    let user_defined_property_count = scene
478        .elements
479        .iter()
480        .flat_map(|element| element.props.props.iter())
481        .filter(|prop| prop.flags.has_any(ufbx::PropFlags::USER_DEFINED))
482        .count();
483    let unsupported_source_element_count = unsupported_source_element_count(scene);
484    let external_resource_count = scene
485        .textures
486        .iter()
487        .filter(|texture| texture.content.is_empty() && texture.has_file)
488        .count()
489        + scene
490            .videos
491            .iter()
492            .filter(|video| {
493                video.content.is_empty()
494                    && (!video.filename.is_empty()
495                        || !video.relative_filename.is_empty()
496                        || !video.absolute_filename.is_empty())
497            })
498            .count();
499    let compensated_inherit_node_count = scene
500        .nodes
501        .iter()
502        .filter(|node| {
503            node.original_inherit_mode != node.inherit_mode
504                || node.is_scale_helper
505                || node.is_scale_compensate_parent
506        })
507        .count();
508
509    let stackless_animation_present = scene.anim_stacks.is_empty()
510        && (!scene.anim_layers.is_empty()
511            || !scene.anim_values.is_empty()
512            || !scene.anim_curves.is_empty());
513    let animation = if !scene.anim_stacks.is_empty() {
514        FbxScaleDomainStatus::Baked
515    } else if stackless_animation_present {
516        // No take was available to bake, but authored curve/value/layer rows
517        // were parsed and discarded by normalized clip extraction.
518        FbxScaleDomainStatus::Unsupported
519    } else {
520        FbxScaleDomainStatus::Absent
521    };
522    let domains = FbxScaleDomainInventory {
523        rest_hierarchy: FbxScaleDomainStatus::Normalized,
524        translation_animation: animation,
525        rotation_and_scale_animation: animation,
526        root_motion_and_velocity: match animation {
527            FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
528            status => status,
529        },
530        base_mesh_geometry: if scene.meshes.is_empty() {
531            FbxScaleDomainStatus::Absent
532        } else if uninstanced_mesh_definition_count > 0
533            || omitted_non_polygon_face_count > 0
534            || empty_mesh_definition_count > 0
535        {
536            FbxScaleDomainStatus::Unsupported
537        } else {
538            FbxScaleDomainStatus::Rebuilt
539        },
540        morphs: if scene.blend_deformers.is_empty()
541            && scene.blend_channels.is_empty()
542            && scene.blend_shapes.is_empty()
543        {
544            FbxScaleDomainStatus::Absent
545        } else {
546            FbxScaleDomainStatus::Unsupported
547        },
548        skin_binds: if scene.skin_deformers.is_empty() {
549            FbxScaleDomainStatus::Absent
550        } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
551            FbxScaleDomainStatus::Unsupported
552        } else {
553            FbxScaleDomainStatus::Derived
554        },
555        cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
556            FbxScaleDomainStatus::Absent
557        } else {
558            FbxScaleDomainStatus::Unsupported
559        },
560        collision_and_custom_data: if unsupported_source_element_count == 0
561            && user_defined_property_count == 0
562        {
563            FbxScaleDomainStatus::Absent
564        } else {
565            FbxScaleDomainStatus::Unsupported
566        },
567        other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
568            || uninstanced_mesh_definition_count > 0
569            || omitted_non_polygon_face_count > 0
570            || empty_mesh_definition_count > 0
571            || multiple_skin_deformer_mesh_count > 0
572            || dual_quaternion_skin_count > 0
573            || conversion.truncated_influence_vertex_count > 0
574            || conversion.missing_skin_influence_corner_count > 0
575            || conversion.rejected_influence_count > 0
576            || !scene.blend_deformers.is_empty()
577            || !scene.blend_channels.is_empty()
578            || !scene.blend_shapes.is_empty()
579            || !scene.cache_deformers.is_empty()
580            || !scene.cache_files.is_empty()
581        {
582            FbxScaleDomainStatus::Unsupported
583        } else if !scene.meshes.is_empty() {
584            FbxScaleDomainStatus::Rebuilt
585        } else {
586            FbxScaleDomainStatus::Absent
587        },
588        out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
589        animation_targeting_matrix_nodes: animation,
590        shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
591        unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
592        image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
593    };
594
595    FbxScaleCapabilityInventory {
596        domains,
597        coordinate_normalization: FbxCoordinateNormalization {
598            original_up_axis: scene.settings.original_axis_up.into(),
599            original_unit_meters: scene.settings.original_unit_meters,
600            target_right_handed_y_up: true,
601            target_unit_meters: 1.0,
602            adjust_transforms: matches!(
603                scene.metadata.space_conversion,
604                ufbx::SpaceConversion::AdjustTransforms
605            ),
606        },
607        animation_takes_baked: true,
608        authored_curve_keys_preserved: false,
609        animation_take_count: scene.anim_stacks.len(),
610        source_animation_curve_count: scene.anim_curves.len(),
611        generated_geometry_helper_node_count: scene
612            .nodes
613            .iter()
614            .filter(|node| node.is_geometry_transform_helper)
615            .count(),
616        generated_scale_helper_node_count: scene
617            .nodes
618            .iter()
619            .filter(|node| node.is_scale_helper)
620            .count(),
621        inherit_modes_compensated: matches!(
622            scene.metadata.inherit_mode_handling,
623            ufbx::InheritModeHandling::Compensate
624        ),
625        compensated_inherit_node_count,
626        generated_normal_mesh_count,
627        missing_normal_mesh_count,
628        skin_deformer_count: scene.skin_deformers.len(),
629        skin_cluster_count,
630        empty_skin_deformer_count,
631        bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
632        incomplete_bind_cluster_count,
633        bone_convenience_bind_overwrite_count,
634        identity_bind_defaults_invented: false,
635        truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
636        discarded_influence_count: conversion.discarded_influence_count,
637        renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
638        rejected_influence_count: conversion.rejected_influence_count,
639        missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
640        non_triangle_face_count,
641        triangulated_face_count,
642        omitted_non_polygon_face_count,
643        empty_mesh_definition_count,
644        empty_source_meshes,
645        pre_weld_vertex_count: conversion.pre_weld_vertex_count,
646        post_weld_vertex_count: conversion.post_weld_vertex_count,
647        multiple_skin_deformer_mesh_count,
648        dual_quaternion_skin_count,
649        blend_deformer_count: scene.blend_deformers.len(),
650        blend_channel_count: scene.blend_channels.len(),
651        blend_shape_count: scene.blend_shapes.len(),
652        cache_deformer_count: scene.cache_deformers.len(),
653        unsupported_vertex_payload_mesh_count,
654        camera_count: scene.cameras.len(),
655        light_count: scene.lights.len(),
656        shared_mesh_definition_count,
657        uninstanced_mesh_definition_count,
658        uninstanced_source_meshes,
659        user_defined_property_count,
660        unsupported_source_element_count,
661        external_resource_count,
662        source_nodes: scene
663            .nodes
664            .iter()
665            .enumerate()
666            .map(|(index, node)| identity(index, &node.element))
667            .collect(),
668        source_meshes: scene
669            .meshes
670            .iter()
671            .enumerate()
672            .map(|(index, mesh)| identity(index, &mesh.element))
673            .collect(),
674        source_skins: scene
675            .skin_deformers
676            .iter()
677            .enumerate()
678            .map(|(index, skin)| identity(index, &skin.element))
679            .collect(),
680    }
681}
682
683/// Classify every field in `ufbx::Mesh` at one structural boundary. Omitting
684/// `..` is deliberate: a ufbx upgrade that adds mesh payload must fail to
685/// compile until extraction either models it or this predicate refuses it.
686fn mesh_has_unsupported_source_payload(mesh: &ufbx::Mesh) -> bool {
687    let ufbx::Mesh {
688        element: _,
689        num_vertices: _,
690        num_indices: _,
691        num_faces: _,
692        num_triangles: _,
693        num_edges: _,
694        max_face_triangles: _,
695        num_empty_faces: _,
696        num_point_faces: _,
697        num_line_faces: _,
698        faces: _,
699        // Authored face/edge members are not retained by triangle extraction.
700        face_smoothing,
701        face_material: _,
702        face_group,
703        face_hole,
704        edges,
705        edge_smoothing,
706        edge_crease,
707        edge_visibility,
708        vertex_indices: _,
709        vertices: _,
710        vertex_first_index: _,
711        vertex_position: _,
712        vertex_normal: _,
713        vertex_uv: _,
714        vertex_tangent,
715        vertex_bitangent,
716        vertex_color,
717        vertex_crease,
718        uv_sets,
719        color_sets,
720        materials: _,
721        face_groups,
722        // Mesh parts and skinned views are parser-derived indexes/results.
723        material_parts: _,
724        face_group_parts: _,
725        material_part_usage_order: _,
726        skinned_is_local: _,
727        skinned_position: _,
728        skinned_normal: _,
729        // Deformer kinds have dedicated inventory counters.
730        skin_deformers: _,
731        blend_deformers: _,
732        cache_deformers: _,
733        all_deformers: _,
734        subdivision_preview_levels,
735        subdivision_render_levels,
736        subdivision_display_mode,
737        subdivision_boundary,
738        subdivision_uv_boundary,
739        // Winding conversion and generated-normal state are consumed/counted.
740        reversed_winding: _,
741        generated_normals: _,
742        subdivision_evaluated,
743        subdivision_result,
744        from_tessellated_nurbs,
745    } = mesh;
746
747    !face_smoothing.is_empty()
748        || !face_group.is_empty()
749        || !face_hole.is_empty()
750        || !edges.is_empty()
751        || !edge_smoothing.is_empty()
752        || !edge_crease.is_empty()
753        || !edge_visibility.is_empty()
754        || vertex_tangent.exists
755        || vertex_bitangent.exists
756        || vertex_color.exists
757        || vertex_crease.exists
758        || uv_sets.len() > 1
759        || !color_sets.is_empty()
760        || !face_groups.is_empty()
761        || *subdivision_preview_levels > 0
762        || *subdivision_render_levels > 0
763        || !matches!(
764            subdivision_display_mode,
765            ufbx::SubdivisionDisplayMode::Disabled
766        )
767        || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default)
768        || !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
769        || *subdivision_evaluated
770        || subdivision_result.is_some()
771        || *from_tessellated_nurbs
772}
773
774/// Classify every field in `ufbx::Scene` at one exhaustive structural
775/// boundary. Omitting `..` is deliberate: a ufbx upgrade that adds a typed
776/// list must fail to compile until that list receives a classification.
777fn unsupported_source_element_count(scene: &ufbx::Scene) -> usize {
778    let ufbx::Scene {
779        metadata: _,
780        settings: _,
781        root_node: _,
782        anim: _,
783        // Unknown and source kinds without a normalized core representation.
784        unknowns,
785        nodes: _,
786        meshes: _,
787        // Cameras and lights have dedicated counts/core facts.
788        lights: _,
789        cameras: _,
790        // Bone/empty attributes normalize into the complete node projection.
791        bones: _,
792        empties: _,
793        line_curves,
794        nurbs_curves,
795        nurbs_surfaces,
796        nurbs_trim_surfaces,
797        nurbs_trim_boundaries,
798        procedural_geometries,
799        stereo_cameras,
800        camera_switchers,
801        markers,
802        lod_groups,
803        // Skin rows have dedicated bind/influence counts and sidecars.
804        skin_deformers: _,
805        skin_clusters: _,
806        // Deformers are counted separately; their subordinate payload rows
807        // are still unmodeled source elements.
808        blend_deformers: _,
809        blend_channels,
810        blend_shapes,
811        cache_deformers: _,
812        cache_files,
813        // The loader rebuilds its documented material/texture subset;
814        // external payload absence is counted separately.
815        materials: _,
816        textures: _,
817        videos: _,
818        shaders,
819        shader_bindings,
820        // Animation lists are evaluated through bake_anim.
821        anim_stacks: _,
822        anim_layers: _,
823        anim_values: _,
824        anim_curves: _,
825        display_layers,
826        selection_sets,
827        selection_nodes,
828        characters,
829        constraints,
830        audio_layers,
831        audio_clips,
832        poses,
833        metadata_objects,
834        // Texture-file records carry source linkage beyond the rebuilt
835        // texture subset, so they are conservatively unmodeled.
836        texture_files,
837        // Scene-wide structural indexes are parser-derived views, not source
838        // element domains that need independent semantic counting.
839        elements: _,
840        connections_src: _,
841        connections_dst: _,
842        elements_by_name: _,
843        dom_root: _,
844    } = scene;
845
846    unknowns.len()
847        + line_curves.len()
848        + nurbs_curves.len()
849        + nurbs_surfaces.len()
850        + nurbs_trim_surfaces.len()
851        + nurbs_trim_boundaries.len()
852        + procedural_geometries.len()
853        + stereo_cameras.len()
854        + camera_switchers.len()
855        + markers.len()
856        + lod_groups.len()
857        + blend_channels.len()
858        + blend_shapes.len()
859        + cache_files.len()
860        + shaders.len()
861        + shader_bindings.len()
862        + display_layers.len()
863        + selection_sets.len()
864        + selection_nodes.len()
865        + characters.len()
866        + constraints.len()
867        + audio_layers.len()
868        + audio_clips.len()
869        + poses.len()
870        + metadata_objects.len()
871        + texture_files.len()
872}