Skip to main content

animsmith_fbx/
capability.rs

1//! Conservative ufbx-side scale capability inventory.
2
3use animsmith_core::scale::{ScaleCapabilityCoverage, ScaleCapabilityFacts};
4use animsmith_core::{
5    DependencyClosureV1, Document, LoadedSource, SourceConstructKindV1, SourceFactsViewV1,
6    SourceResourceLocatorV1, SourceSetCoverageStateV1,
7};
8use serde::Serialize;
9
10/// How one Appendix D.4 domain reaches the normalized FBX document.
11///
12/// These values describe semantic ingestion only. None claims raw FBX byte,
13/// object-property, curve-key, or payload-span preservation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum FbxScaleDomainStatus {
17    /// The source inspection proved that the domain is absent.
18    Absent,
19    /// ufbx normalized the source representation before it reached the core model.
20    Normalized,
21    /// ufbx evaluated source animation into resampled linear TRS tracks.
22    Baked,
23    /// The value is derived from another normalized domain.
24    Derived,
25    /// The loader rebuilt the domain into a different normalized representation.
26    Rebuilt,
27    /// The source domain is present but not completely represented.
28    Unsupported,
29    /// ufbx exposes no raw-span relationship with which to prove this domain.
30    Unverifiable,
31}
32
33/// Explicit status for every current domain row in DESIGN.md Appendix D.4.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct FbxScaleDomainInventory {
37    /// Rest hierarchy and local transforms.
38    pub rest_hierarchy: FbxScaleDomainStatus,
39    /// Translation animation values and tangents.
40    pub translation_animation: FbxScaleDomainStatus,
41    /// Rotation and scale animation values and tangents.
42    pub rotation_and_scale_animation: FbxScaleDomainStatus,
43    /// Root-motion and velocity evidence derived from translation tracks.
44    pub root_motion_and_velocity: FbxScaleDomainStatus,
45    /// Base mesh positions and normals.
46    pub base_mesh_geometry: FbxScaleDomainStatus,
47    /// Morph targets and morph-weight animation.
48    pub morphs: FbxScaleDomainStatus,
49    /// Per-skin inverse-bind matrices.
50    pub skin_binds: FbxScaleDomainStatus,
51    /// Cameras and lights.
52    pub cameras_and_lights: FbxScaleDomainStatus,
53    /// Collision, custom properties, constraints, and unknown elements.
54    pub collision_and_custom_data: FbxScaleDomainStatus,
55    /// Other vertex attributes, deformers, and source geometry kinds.
56    pub other_vertex_and_source_data: FbxScaleDomainStatus,
57    /// Source transform-stack state outside the normalized TRS model.
58    pub out_of_contract_node_transforms: FbxScaleDomainStatus,
59    /// Animation targeting source transform-stack or matrix state.
60    pub animation_targeting_matrix_nodes: FbxScaleDomainStatus,
61    /// Shared raw payload spans corresponding to glTF accessors.
62    pub shared_raw_accessor_payloads: FbxScaleDomainStatus,
63    /// Raw payload spans corresponding to unreferenced glTF accessors.
64    pub unreferenced_accessor_payloads: FbxScaleDomainStatus,
65    /// Image payload spans that could alias scale-bearing source bytes.
66    pub image_payload_aliases: FbxScaleDomainStatus,
67}
68
69impl FbxScaleDomainInventory {
70    /// Every Appendix D.4 row in table order with its current FBX status.
71    ///
72    /// This is the mechanical bridge between the public named fields and the
73    /// design table. Tests compare these names with the table so adding a row
74    /// to either authority cannot silently leave the other incomplete.
75    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    /// Semantic domains whose normalized representation must be complete
114    /// before the narrow FBX rest/bind bridge may stage a GLB.
115    ///
116    /// The three raw-span rows are deliberately excluded: the bridge never
117    /// rewrites FBX bytes, so it serializes a private GLB and proves that
118    /// GLB's raw spans instead. Keeping this as typed fields rather than
119    /// display labels makes a newly added semantic domain fail closed until
120    /// this policy is deliberately updated.
121    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    /// The raw-span rows whose FBX status is expected to be unverifiable.
150    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/// A format-independent spelling of one FBX coordinate axis.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum FbxCoordinateAxis {
169    /// Positive X.
170    PositiveX,
171    /// Negative X.
172    NegativeX,
173    /// Positive Y.
174    PositiveY,
175    /// Negative Y.
176    NegativeY,
177    /// Positive Z.
178    PositiveZ,
179    /// Negative Z.
180    NegativeZ,
181    /// ufbx could not determine the axis.
182    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/// Coordinate and unit normalization applied by the loader.
200#[derive(Debug, Clone, PartialEq, Serialize)]
201#[serde(deny_unknown_fields)]
202pub struct FbxCoordinateNormalization {
203    /// Advisory `OriginalUpAxis` value reported by ufbx.
204    ///
205    /// This is not the effective `UpAxis`/`FrontAxis`/`CoordAxis` basis.
206    pub original_up_axis: FbxCoordinateAxis,
207    /// Advisory `OriginalUnitScaleFactor` value in metres reported by ufbx.
208    ///
209    /// This is not the effective `UnitScaleFactor` source unit.
210    pub original_unit_meters: f64,
211    /// Target is right-handed, +Y up, and -Z forward.
212    pub target_right_handed_y_up: bool,
213    /// Target unit in metres.
214    pub target_unit_meters: f64,
215    /// ufbx adjusted transforms rather than preserving raw transform members.
216    pub adjust_transforms: bool,
217}
218
219/// Stable source identity retained beside one normalized ufbx element.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(deny_unknown_fields)]
222pub struct FbxSourceIdentity {
223    /// Stable index in the relevant ufbx typed list.
224    pub source_index: usize,
225    /// ufbx's typed id, which addresses that typed list.
226    pub ufbx_typed_id: u32,
227    /// ufbx's scene-wide element id, or zero for its generated root.
228    ///
229    /// This is deliberately not described as the raw FBX object id: ufbx
230    /// assigns its own stable scene identity after parsing and normalization.
231    pub ufbx_element_id: u32,
232}
233
234/// Provenance of inverse-bind matrices projected into the source sidecar.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
236#[serde(rename_all = "kebab-case")]
237pub enum FbxBindMatrixProvenance {
238    /// ufbx converted cluster bind matrices into target coordinates, then the
239    /// loader derived `bind_to_world^-1 * geometry_to_world` per cluster.
240    UfbxConvertedClusterMatrices,
241}
242
243/// Deterministic capability inventory captured from one successfully parsed FBX scene.
244///
245/// Every current Appendix D.4 row has a status, but those statuses deliberately
246/// include unsupported and unverifiable states. Call
247/// [`capability_facts`] to project those states into the format-neutral core
248/// gate; #286-A never turns them into operation support. This is also the
249/// frozen source projection serialized by scale-evidence v5: a new inventory
250/// fact requires a new evidence version rather than silently changing v5.
251#[derive(Debug, Clone, PartialEq, Serialize)]
252#[serde(deny_unknown_fields)]
253pub struct FbxScaleCapabilityInventory {
254    /// Every Appendix D.4 domain, in named fields rather than an absence-based map.
255    pub domains: FbxScaleDomainInventory,
256    /// Coordinate and unit normalization applied before model construction.
257    pub coordinate_normalization: FbxCoordinateNormalization,
258    /// Every animation take is evaluated through `ufbx::bake_anim`.
259    pub animation_takes_baked: bool,
260    /// Authored FBX curve keys and interpolation are not retained.
261    pub authored_curve_keys_preserved: bool,
262    /// Number of source animation takes.
263    pub animation_take_count: usize,
264    /// Number of source animation curves discarded after baking.
265    pub source_animation_curve_count: usize,
266    /// Number of ufbx-generated geometry-transform helper nodes.
267    pub generated_geometry_helper_node_count: usize,
268    /// Number of ufbx-generated scale-compensation helper nodes.
269    pub generated_scale_helper_node_count: usize,
270    /// Whether the load boundary asks ufbx to compensate FBX inherit modes.
271    pub inherit_modes_compensated: bool,
272    /// Number of nodes whose original inherit mode or helper state required compensation.
273    pub compensated_inherit_node_count: usize,
274    /// Number of meshes for which ufbx generated missing normals.
275    pub generated_normal_mesh_count: usize,
276    /// Number of meshes still lacking normals after generation was requested.
277    pub missing_normal_mesh_count: usize,
278    /// Number of source skin deformers.
279    pub skin_deformer_count: usize,
280    /// Number of source skin clusters.
281    pub skin_cluster_count: usize,
282    /// Number of source skin deformers that declare no clusters or bind matrices.
283    pub empty_skin_deformer_count: usize,
284    /// Provenance of every available projected inverse-bind matrix.
285    pub bind_matrix_provenance: FbxBindMatrixProvenance,
286    /// Number of clusters missing a bone or a finite converted bind matrix.
287    pub incomplete_bind_cluster_count: usize,
288    /// Number of times multiple successfully projected clusters target one bone and overwrite its
289    /// lossy convenience bind. Unreadable clusters are skipped, not counted as writes.
290    pub bone_convenience_bind_overwrite_count: usize,
291    /// Whether the loader invented identity matrices for missing bind evidence.
292    pub identity_bind_defaults_invented: bool,
293    /// Number of normalized vertices whose source influence list exceeded four entries.
294    pub truncated_influence_vertex_count: usize,
295    /// Number of source influences discarded by the four-slot limit.
296    pub discarded_influence_count: usize,
297    /// Number of normalized vertices whose retained weights changed during renormalization.
298    pub renormalized_influence_vertex_count: usize,
299    /// Number of non-finite, negative, or unrepresentable source influences rejected.
300    pub rejected_influence_count: usize,
301    /// Number of emitted skinned corners whose source vertex had no influence record.
302    pub missing_skin_influence_corner_count: usize,
303    /// Number of source faces that are not triangles.
304    pub non_triangle_face_count: usize,
305    /// Number of polygon faces with more than three corners that were triangulated.
306    pub triangulated_face_count: usize,
307    /// Number of point/line faces omitted from triangle output.
308    pub omitted_non_polygon_face_count: usize,
309    /// Number of source mesh definitions that declare no faces.
310    pub empty_mesh_definition_count: usize,
311    /// Stable identities of the zero-face source mesh definitions counted above.
312    pub empty_source_meshes: Vec<FbxSourceIdentity>,
313    /// Number of unindexed corners submitted to exact-bit welding.
314    pub pre_weld_vertex_count: usize,
315    /// Number of normalized vertices retained after exact-bit welding.
316    pub post_weld_vertex_count: usize,
317    /// Number of source meshes with more than one skin deformer.
318    pub multiple_skin_deformer_mesh_count: usize,
319    /// Number of dual-quaternion skin deformers not represented by the normalized model.
320    pub dual_quaternion_skin_count: usize,
321    /// Number of blend deformers (morph domains) not represented by the normalized model.
322    pub blend_deformer_count: usize,
323    /// Number of blend channels not represented by the normalized model.
324    pub blend_channel_count: usize,
325    /// Number of blend shapes not represented by the normalized model.
326    pub blend_shape_count: usize,
327    /// Number of geometry cache deformers not represented by the normalized model.
328    pub cache_deformer_count: usize,
329    /// Number of meshes carrying unsupported modeled-vertex payloads.
330    pub unsupported_vertex_payload_mesh_count: usize,
331    /// Number of cameras.
332    pub camera_count: usize,
333    /// Number of lights.
334    pub light_count: usize,
335    /// Number of shared mesh definitions with more than one node instance.
336    pub shared_mesh_definition_count: usize,
337    /// Number of source mesh definitions with no node instance and no normalized output mesh.
338    pub uninstanced_mesh_definition_count: usize,
339    /// Stable identities of the uninstanced source mesh definitions counted above.
340    pub uninstanced_source_meshes: Vec<FbxSourceIdentity>,
341    /// Number of user-defined source properties.
342    pub user_defined_property_count: usize,
343    /// Number of unknown or otherwise unmodeled source elements/scene records.
344    pub unsupported_source_element_count: usize,
345    /// Number of referenced external texture/video payloads.
346    pub external_resource_count: usize,
347    /// Node identities in stable ufbx source order.
348    pub source_nodes: Vec<FbxSourceIdentity>,
349    /// Mesh identities in stable ufbx source order.
350    pub source_meshes: Vec<FbxSourceIdentity>,
351    /// Skin-deformer identities in stable ufbx source order.
352    pub source_skins: Vec<FbxSourceIdentity>,
353}
354
355/// One immutable FBX source/document owner and scale capability inventory
356/// captured from the same parse.
357#[derive(Debug)]
358pub struct FbxScaleSource {
359    pub(crate) source: LoadedSource,
360    pub(crate) inventory: FbxScaleCapabilityInventory,
361    /// Same-parse breakdown behind the aggregate construct inventory.
362    ///
363    /// This parser-side distinction is intentionally not part of the frozen
364    /// scale-evidence v5 inventory. It is same-load admission evidence for the
365    /// normalized GLB bridge, where the texture/video declarations have their
366    /// own bounded resource facts and cannot affect rest/bind transforms.
367    pub(crate) rest_bind_construct_counts: crate::source_facts::RestBindSourceConstructCounts,
368}
369
370impl FbxScaleSource {
371    /// The normalized document carrying the documented ufbx source projection.
372    pub fn document(&self) -> &Document {
373        self.source.document()
374    }
375
376    /// The bounded importer-sensitive facts retained from the same ufbx parse.
377    pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
378        self.source.source_facts()
379    }
380
381    /// The bounded dependency closure captured with the same ufbx parse.
382    pub fn dependency_closure(&self) -> &DependencyClosureV1 {
383        self.source.dependency_closure()
384    }
385
386    /// The conservative ufbx-side inventory.
387    pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
388        &self.inventory
389    }
390
391    /// Consume the source wrapper and retain its normalized document.
392    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
401/// Project an FBX inventory into the format-neutral core capability gate.
402///
403/// `coverage` means every Appendix D.4 domain has an explicit status, not that
404/// any domain is preserved losslessly. Support remains false: normalized transform stacks, baked
405/// curves, rebuilt meshes, and unverifiable raw payload relationships are
406/// recorded as unsupported facts rather than hidden behind absent flags.
407pub 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    // FBX transform stacks and authored animation curves are normalized or
421    // baked before Document construction, so their raw members are not in
422    // the model even for the smallest accepted scene.
423    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    // ufbx exposes normalized objects, not accessor/image byte spans. A future
439    // FBX writer must discharge this preservation obligation through the full
440    // inventory route; #286-A cannot declare the source layout rewrite-safe.
441    facts.unsafe_accessor_layout_present = true;
442    facts.external_resources_present = inventory.external_resource_count > 0;
443    facts
444}
445
446/// Project scale capabilities from one immutable captured FBX source.
447///
448/// The operation inventory keeps its detailed normalization/bake ledger. The
449/// shared raw-source facts independently supply custom/unknown construct and
450/// resource presence, and partial shared coverage always fails closed.
451pub 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
489/// Project the narrow FBX subset that can enter rest/bind scaling.
490///
491/// The accepted operation rewrites a freshly serialized GLB rather than the
492/// FBX container, so the three raw-span rows are intentionally
493/// [`FbxScaleDomainStatus::Unverifiable`]. Every semantic domain must still
494/// be complete when it can affect the normalized document. User-defined FBX
495/// properties are already explicitly discarded by the loader, and external
496/// image declarations require the source-aware companion so their same-load
497/// resource classification and capture can be validated before staging. The
498/// frozen inventory alone remains conservative for external references because
499/// it cannot prove that boundary. Neither known class is a scale-bearing
500/// ambiguity. Unknown source elements,
501/// incomplete bind evidence, altered skin influences, or an incomplete
502/// coordinate projection remain stable refusals before the producer can stage
503/// any output.
504pub 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    // Reuse the complete conservative projection for every counter and
695    // source-domain fact. These flags are discharged by the private GLB
696    // staging/proof boundary: raw FBX members are not preserved, while the
697    // already-validated texture-linkage aggregate, custom properties, and
698    // external locator spellings cannot carry scale-bearing state into the
699    // normalized document. Every other fact continues to gate the operation.
700    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
721/// Project the narrow FBX rest/bind subset from one captured source.
722///
723/// Shared construct/resource coverage is checked before the older
724/// operation-specific inventory. This prevents a truncated positive-only raw
725/// projection from being treated as proof of absence.
726///
727/// # Errors
728///
729/// Returns a stable refusal naming each incomplete shared-raw coverage domain,
730/// unsupported construct row, semantic status, or inventory counter that
731/// prevents proof of the selected domain.
732pub 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        // No take was available to bake, but authored curve/value/layer rows
1105        // were parsed and discarded by normalized clip extraction.
1106        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
1271/// Classify every field in `ufbx::Mesh` at one structural boundary. Omitting
1272/// `..` is deliberate: a ufbx upgrade that adds mesh payload must fail to
1273/// compile until extraction either models it or this predicate refuses it.
1274fn 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        // Authored face/edge members are not retained by triangle extraction.
1288        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        // Mesh parts and skinned views are parser-derived indexes/results.
1311        material_parts: _,
1312        face_group_parts: _,
1313        material_part_usage_order: _,
1314        skinned_is_local: _,
1315        skinned_position: _,
1316        skinned_normal: _,
1317        // Deformer kinds have dedicated inventory counters.
1318        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        // Winding conversion and generated-normal state are consumed/counted.
1328        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}