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    /// Semantic domains consumed by a clip-only assembly projection.
165    ///
166    /// This positive list is deliberately separate from the full rest/bind
167    /// inventory: geometry, deformation, materials, cameras/lights, and bind
168    /// state cannot enter a clip-only projection. Adding another admitted
169    /// source domain therefore requires an explicit policy decision here.
170    fn clip_track_semantic_statuses(&self) -> [(&'static str, FbxScaleDomainStatus); 6] {
171        [
172            ("rest_hierarchy", self.rest_hierarchy),
173            ("translation_animation", self.translation_animation),
174            (
175                "rotation_and_scale_animation",
176                self.rotation_and_scale_animation,
177            ),
178            ("root_motion_and_velocity", self.root_motion_and_velocity),
179            (
180                "out_of_contract_node_transforms",
181                self.out_of_contract_node_transforms,
182            ),
183            (
184                "animation_targeting_matrix_nodes",
185                self.animation_targeting_matrix_nodes,
186            ),
187        ]
188    }
189}
190
191/// A format-independent spelling of one FBX coordinate axis.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "kebab-case")]
194pub enum FbxCoordinateAxis {
195    /// Positive X.
196    PositiveX,
197    /// Negative X.
198    NegativeX,
199    /// Positive Y.
200    PositiveY,
201    /// Negative Y.
202    NegativeY,
203    /// Positive Z.
204    PositiveZ,
205    /// Negative Z.
206    NegativeZ,
207    /// ufbx could not determine the axis.
208    Unknown,
209}
210
211impl From<ufbx::CoordinateAxis> for FbxCoordinateAxis {
212    fn from(value: ufbx::CoordinateAxis) -> Self {
213        match value {
214            ufbx::CoordinateAxis::PositiveX => Self::PositiveX,
215            ufbx::CoordinateAxis::NegativeX => Self::NegativeX,
216            ufbx::CoordinateAxis::PositiveY => Self::PositiveY,
217            ufbx::CoordinateAxis::NegativeY => Self::NegativeY,
218            ufbx::CoordinateAxis::PositiveZ => Self::PositiveZ,
219            ufbx::CoordinateAxis::NegativeZ => Self::NegativeZ,
220            ufbx::CoordinateAxis::Unknown => Self::Unknown,
221        }
222    }
223}
224
225/// Coordinate and unit normalization applied by the loader.
226#[derive(Debug, Clone, PartialEq, Serialize)]
227#[serde(deny_unknown_fields)]
228pub struct FbxCoordinateNormalization {
229    /// Advisory `OriginalUpAxis` value reported by ufbx.
230    ///
231    /// This is not the effective `UpAxis`/`FrontAxis`/`CoordAxis` basis.
232    pub original_up_axis: FbxCoordinateAxis,
233    /// Advisory `OriginalUnitScaleFactor` value in metres reported by ufbx.
234    ///
235    /// This is not the effective `UnitScaleFactor` source unit.
236    pub original_unit_meters: f64,
237    /// Target is right-handed, +Y up, and -Z forward.
238    pub target_right_handed_y_up: bool,
239    /// Target unit in metres.
240    pub target_unit_meters: f64,
241    /// ufbx adjusted transforms rather than preserving raw transform members.
242    pub adjust_transforms: bool,
243}
244
245/// Stable source identity retained beside one normalized ufbx element.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
247#[serde(deny_unknown_fields)]
248pub struct FbxSourceIdentity {
249    /// Stable index in the relevant ufbx typed list.
250    pub source_index: usize,
251    /// ufbx's typed id, which addresses that typed list.
252    pub ufbx_typed_id: u32,
253    /// ufbx's scene-wide element id, or zero for its generated root.
254    ///
255    /// This is deliberately not described as the raw FBX object id: ufbx
256    /// assigns its own stable scene identity after parsing and normalization.
257    pub ufbx_element_id: u32,
258}
259
260/// Provenance of inverse-bind matrices projected into the source sidecar.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum FbxBindMatrixProvenance {
264    /// ufbx converted cluster bind matrices into target coordinates, then the
265    /// loader derived `bind_to_world^-1 * geometry_to_world` per cluster.
266    UfbxConvertedClusterMatrices,
267}
268
269/// Deterministic capability inventory captured from one successfully parsed FBX scene.
270///
271/// Every current Appendix D.4 row has a status, but those statuses deliberately
272/// include unsupported and unverifiable states. Call
273/// [`capability_facts`] to project those states into the format-neutral core
274/// gate; #286-A never turns them into operation support. This is also the
275/// frozen source projection serialized by scale-evidence v5: a new inventory
276/// fact requires a new evidence version rather than silently changing v5.
277#[derive(Debug, Clone, PartialEq, Serialize)]
278#[serde(deny_unknown_fields)]
279pub struct FbxScaleCapabilityInventory {
280    /// Every Appendix D.4 domain, in named fields rather than an absence-based map.
281    pub domains: FbxScaleDomainInventory,
282    /// Coordinate and unit normalization applied before model construction.
283    pub coordinate_normalization: FbxCoordinateNormalization,
284    /// Every animation take is evaluated through `ufbx::bake_anim`.
285    pub animation_takes_baked: bool,
286    /// Authored FBX curve keys and interpolation are not retained.
287    pub authored_curve_keys_preserved: bool,
288    /// Number of source animation takes.
289    pub animation_take_count: usize,
290    /// Number of source animation curves discarded after baking.
291    pub source_animation_curve_count: usize,
292    /// Number of ufbx-generated geometry-transform helper nodes.
293    pub generated_geometry_helper_node_count: usize,
294    /// Number of ufbx-generated scale-compensation helper nodes.
295    pub generated_scale_helper_node_count: usize,
296    /// Whether the load boundary asks ufbx to compensate FBX inherit modes.
297    pub inherit_modes_compensated: bool,
298    /// Number of nodes whose original inherit mode or helper state required compensation.
299    pub compensated_inherit_node_count: usize,
300    /// Number of meshes for which ufbx generated missing normals.
301    pub generated_normal_mesh_count: usize,
302    /// Number of meshes still lacking normals after generation was requested.
303    pub missing_normal_mesh_count: usize,
304    /// Number of source skin deformers.
305    pub skin_deformer_count: usize,
306    /// Number of source skin clusters.
307    pub skin_cluster_count: usize,
308    /// Number of source skin deformers that declare no clusters or bind matrices.
309    pub empty_skin_deformer_count: usize,
310    /// Provenance of every available projected inverse-bind matrix.
311    pub bind_matrix_provenance: FbxBindMatrixProvenance,
312    /// Number of clusters missing a bone or a finite converted bind matrix.
313    pub incomplete_bind_cluster_count: usize,
314    /// Number of times multiple successfully projected clusters target one bone and overwrite its
315    /// lossy convenience bind. Unreadable clusters are skipped, not counted as writes.
316    pub bone_convenience_bind_overwrite_count: usize,
317    /// Whether the loader invented identity matrices for missing bind evidence.
318    pub identity_bind_defaults_invented: bool,
319    /// Number of normalized vertices whose source influence list exceeded four entries.
320    pub truncated_influence_vertex_count: usize,
321    /// Number of source influences discarded by the four-slot limit.
322    pub discarded_influence_count: usize,
323    /// Number of normalized vertices whose retained weights changed during renormalization.
324    pub renormalized_influence_vertex_count: usize,
325    /// Number of non-finite, negative, or unrepresentable source influences rejected.
326    pub rejected_influence_count: usize,
327    /// Number of emitted skinned corners whose source vertex had no influence record.
328    pub missing_skin_influence_corner_count: usize,
329    /// Number of source faces that are not triangles.
330    pub non_triangle_face_count: usize,
331    /// Number of polygon faces with more than three corners that were triangulated.
332    pub triangulated_face_count: usize,
333    /// Number of point/line faces omitted from triangle output.
334    pub omitted_non_polygon_face_count: usize,
335    /// Number of source mesh definitions that declare no faces.
336    pub empty_mesh_definition_count: usize,
337    /// Stable identities of the zero-face source mesh definitions counted above.
338    pub empty_source_meshes: Vec<FbxSourceIdentity>,
339    /// Number of unindexed corners submitted to exact-bit welding.
340    pub pre_weld_vertex_count: usize,
341    /// Number of normalized vertices retained after exact-bit welding.
342    pub post_weld_vertex_count: usize,
343    /// Number of source meshes with more than one skin deformer.
344    pub multiple_skin_deformer_mesh_count: usize,
345    /// Number of dual-quaternion skin deformers not represented by the normalized model.
346    pub dual_quaternion_skin_count: usize,
347    /// Number of blend deformers (morph domains) not represented by the normalized model.
348    pub blend_deformer_count: usize,
349    /// Number of blend channels not represented by the normalized model.
350    pub blend_channel_count: usize,
351    /// Number of blend shapes not represented by the normalized model.
352    pub blend_shape_count: usize,
353    /// Number of geometry cache deformers not represented by the normalized model.
354    pub cache_deformer_count: usize,
355    /// Number of meshes carrying unsupported modeled-vertex payloads.
356    pub unsupported_vertex_payload_mesh_count: usize,
357    /// Number of cameras.
358    pub camera_count: usize,
359    /// Number of lights.
360    pub light_count: usize,
361    /// Number of shared mesh definitions with more than one node instance.
362    pub shared_mesh_definition_count: usize,
363    /// Number of source mesh definitions with no node instance and no normalized output mesh.
364    pub uninstanced_mesh_definition_count: usize,
365    /// Stable identities of the uninstanced source mesh definitions counted above.
366    pub uninstanced_source_meshes: Vec<FbxSourceIdentity>,
367    /// Number of user-defined source properties.
368    pub user_defined_property_count: usize,
369    /// Number of unknown or otherwise unmodeled source elements/scene records.
370    pub unsupported_source_element_count: usize,
371    /// Number of referenced external texture/video payloads.
372    pub external_resource_count: usize,
373    /// Node identities in stable ufbx source order.
374    pub source_nodes: Vec<FbxSourceIdentity>,
375    /// Mesh identities in stable ufbx source order.
376    pub source_meshes: Vec<FbxSourceIdentity>,
377    /// Skin-deformer identities in stable ufbx source order.
378    pub source_skins: Vec<FbxSourceIdentity>,
379}
380
381/// One immutable FBX source/document owner and scale capability inventory
382/// captured from the same parse.
383#[derive(Debug)]
384pub struct FbxScaleSource {
385    pub(crate) source: LoadedSource,
386    pub(crate) inventory: FbxScaleCapabilityInventory,
387    /// Same-parse breakdown behind the aggregate construct inventory.
388    ///
389    /// This parser-side distinction is intentionally not part of the frozen
390    /// scale-evidence v5 inventory. It is same-load admission evidence for the
391    /// normalized GLB bridge, where the texture/video declarations have their
392    /// own bounded resource facts and cannot affect rest/bind transforms.
393    pub(crate) rest_bind_construct_counts: crate::source_facts::RestBindSourceConstructCounts,
394    /// Same-parse meshes whose unsupported public payload aggregate consists
395    /// entirely of enumerated scale-invariant conversion-fidelity facts.
396    pub(crate) rest_bind_scale_invariant_payload_mesh_count: usize,
397}
398
399impl FbxScaleSource {
400    /// The normalized document carrying the documented ufbx source projection.
401    pub fn document(&self) -> &Document {
402        self.source.document()
403    }
404
405    /// The bounded importer-sensitive facts retained from the same ufbx parse.
406    pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
407        self.source.source_facts()
408    }
409
410    /// The bounded dependency closure captured with the same ufbx parse.
411    pub fn dependency_closure(&self) -> &DependencyClosureV1 {
412        self.source.dependency_closure()
413    }
414
415    /// Bounded original-FBX transform paths projected from the exact same bytes.
416    ///
417    /// The projection uses a raw-preserving ufbx pass and correlates original
418    /// nodes to normalized document bones by ufbx's same-file `element_id`.
419    pub fn raw_transform_path_inventory(
420        &self,
421    ) -> Option<&animsmith_core::RawTransformPathInventoryV1> {
422        self.source.raw_transform_path_inventory()
423    }
424
425    /// The conservative ufbx-side inventory.
426    pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
427        &self.inventory
428    }
429
430    /// Consume the source wrapper and retain its normalized document.
431    pub fn into_document(self) -> Document {
432        self.source.into_document()
433    }
434
435    pub(crate) fn into_source(self) -> LoadedSource {
436        self.source
437    }
438}
439
440/// Project an FBX inventory into the format-neutral core capability gate.
441///
442/// `coverage` means every Appendix D.4 domain has an explicit status, not that
443/// any domain is preserved losslessly. Support remains false: normalized transform stacks, baked
444/// curves, rebuilt meshes, and unverifiable raw payload relationships are
445/// recorded as unsupported facts rather than hidden behind absent flags.
446pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
447    let mut facts = ScaleCapabilityFacts::default();
448    facts.coverage = ScaleCapabilityCoverage::Complete;
449    let morph_source_present = inventory.blend_deformer_count > 0
450        || inventory.blend_channel_count > 0
451        || inventory.blend_shape_count > 0;
452    facts.morphs_present = morph_source_present;
453    facts.morph_weights_present = morph_source_present;
454    facts.cameras_present = inventory.camera_count > 0;
455    facts.lights_present = inventory.light_count > 0;
456    facts.instancing_present = inventory.shared_mesh_definition_count > 0;
457    facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
458    facts.extras_present = inventory.user_defined_property_count > 0;
459    // FBX transform stacks and authored animation curves are normalized or
460    // baked before Document construction, so their raw members are not in
461    // the model even for the smallest accepted scene.
462    facts.unknown_source_members_present = true;
463    facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
464    facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
465        > 0
466        || inventory.uninstanced_mesh_definition_count > 0
467        || inventory.empty_mesh_definition_count > 0
468        || inventory.multiple_skin_deformer_mesh_count > 0
469        || inventory.dual_quaternion_skin_count > 0
470        || inventory.cache_deformer_count > 0
471        || inventory.missing_skin_influence_corner_count > 0
472        || inventory.rejected_influence_count > 0
473        || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
474    facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
475    facts.inverse_bind_issues_present =
476        inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
477    // ufbx exposes normalized objects, not accessor/image byte spans. A future
478    // FBX writer must discharge this preservation obligation through the full
479    // inventory route; #286-A cannot declare the source layout rewrite-safe.
480    facts.unsafe_accessor_layout_present = true;
481    facts.external_resources_present = inventory.external_resource_count > 0;
482    facts
483}
484
485/// Project scale capabilities from one immutable captured FBX source.
486///
487/// The operation inventory keeps its detailed normalization/bake ledger. The
488/// shared raw-source facts independently supply custom/unknown construct and
489/// resource presence, and partial shared coverage always fails closed.
490pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
491    join_source_facts(source, capability_facts(source.inventory()))
492}
493
494fn join_source_facts(
495    source: &FbxScaleSource,
496    mut facts: ScaleCapabilityFacts,
497) -> ScaleCapabilityFacts {
498    let source_facts = source.source_facts();
499    if [
500        source_facts.constructs().coverage().state(),
501        source_facts.resources().coverage().state(),
502    ]
503    .into_iter()
504    .any(|state| state != SourceSetCoverageStateV1::Complete)
505    {
506        facts.coverage = ScaleCapabilityCoverage::Unavailable;
507    }
508    for row in source_facts.constructs().rows() {
509        match row.kind() {
510            SourceConstructKindV1::CustomProperty => facts.extras_present = true,
511            SourceConstructKindV1::UnknownElement => {
512                facts.unknown_source_members_present = true;
513            }
514            SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
515        }
516    }
517    if source_facts.resources().rows().iter().any(|row| {
518        !matches!(
519            row.locator(),
520            SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
521        )
522    }) {
523        facts.external_resources_present = true;
524    }
525    facts
526}
527
528/// Project the narrow FBX subset that can enter rest/bind scaling.
529///
530/// The accepted operation rewrites a freshly serialized GLB rather than the
531/// FBX container, so the three raw-span rows are intentionally
532/// [`FbxScaleDomainStatus::Unverifiable`]. Every semantic domain must still
533/// be complete when it can affect the normalized document. User-defined FBX
534/// properties are already explicitly discarded by the loader, and external
535/// image declarations require the source-aware companion so their same-load
536/// resource classification and capture can be validated before staging. The
537/// frozen inventory alone remains conservative for external references because
538/// it cannot prove that boundary. Neither known class is a scale-bearing
539/// ambiguity. The source-aware form may also admit enumerated scale-invariant
540/// conversion-fidelity facts while retaining them in the inventory. Unknown
541/// source elements, missing effective influence coverage, incomplete bind
542/// evidence, or an incomplete coordinate projection remain stable refusals
543/// before the producer can stage any output.
544pub fn rest_bind_capability_facts(
545    inventory: &FbxScaleCapabilityInventory,
546) -> Result<ScaleCapabilityFacts, String> {
547    if inventory.external_resource_count != 0 {
548        return Err(format_rest_bind_violations(
549            "capability inventory",
550            &[format!(
551                "external_resource_count={}",
552                inventory.external_resource_count
553            )],
554        ));
555    }
556    rest_bind_capability_facts_with_context(inventory, RestBindCapabilityContext::InventoryOnly)
557}
558
559#[derive(Debug, Clone, Copy)]
560enum RestBindCapabilityContext<'a> {
561    InventoryOnly,
562    CapturedSource {
563        counts: &'a crate::source_facts::RestBindSourceConstructCounts,
564        scale_invariant_payload_mesh_count: usize,
565    },
566}
567
568fn rest_bind_capability_facts_with_context(
569    inventory: &FbxScaleCapabilityInventory,
570    context: RestBindCapabilityContext<'_>,
571) -> Result<ScaleCapabilityFacts, String> {
572    let captured_source = match context {
573        RestBindCapabilityContext::InventoryOnly => None,
574        RestBindCapabilityContext::CapturedSource {
575            counts,
576            scale_invariant_payload_mesh_count,
577        } => Some((counts, scale_invariant_payload_mesh_count)),
578    };
579    let mut violations = Vec::new();
580    for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
581        if domain == "other_vertex_and_source_data" && captured_source.is_some() {
582            continue;
583        }
584        if matches!(
585            status,
586            FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
587        ) {
588            violations.push(format!(
589                "domain.{domain}={}",
590                fbx_scale_domain_status_name(status)
591            ));
592        }
593    }
594    for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
595        if status != FbxScaleDomainStatus::Unverifiable {
596            violations.push(format!(
597                "domain.{domain}={} (expected unverifiable)",
598                fbx_scale_domain_status_name(status)
599            ));
600        }
601    }
602
603    let expected_custom_status = if inventory.unsupported_source_element_count == 0
604        && inventory.user_defined_property_count == 0
605    {
606        FbxScaleDomainStatus::Absent
607    } else {
608        FbxScaleDomainStatus::Unsupported
609    };
610    if inventory.domains.collision_and_custom_data != expected_custom_status {
611        violations.push(format!(
612            "domain.collision_and_custom_data={} (expected {})",
613            fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
614            fbx_scale_domain_status_name(expected_custom_status)
615        ));
616    }
617    if let Some((source_counts, _)) = captured_source {
618        if inventory.user_defined_property_count != source_counts.user_defined_property_count {
619            violations.push(format!(
620                "user_defined_property_count={}!=source:{}",
621                inventory.user_defined_property_count, source_counts.user_defined_property_count
622            ));
623        }
624        let source_unmodeled_count = source_counts.total_unmodeled_element_count();
625        if inventory.unsupported_source_element_count != source_unmodeled_count {
626            violations.push(format!(
627                "unsupported_source_element_count={}!=source:{}",
628                inventory.unsupported_source_element_count, source_unmodeled_count
629            ));
630        }
631        push_unmodeled_element_violation(&mut violations, *source_counts);
632    } else {
633        push_nonzero_violation(
634            &mut violations,
635            "unsupported_source_element_count",
636            inventory.unsupported_source_element_count,
637        );
638    }
639
640    push_normalized_baked_animation_violations(&mut violations, inventory);
641    if inventory.identity_bind_defaults_invented {
642        violations.push("identity_bind_defaults_invented=true".into());
643    }
644    for (name, value) in [
645        ("blend_deformer_count", inventory.blend_deformer_count),
646        ("blend_channel_count", inventory.blend_channel_count),
647        ("blend_shape_count", inventory.blend_shape_count),
648        ("camera_count", inventory.camera_count),
649        ("light_count", inventory.light_count),
650        (
651            "shared_mesh_definition_count",
652            inventory.shared_mesh_definition_count,
653        ),
654        (
655            "uninstanced_mesh_definition_count",
656            inventory.uninstanced_mesh_definition_count,
657        ),
658        (
659            "empty_mesh_definition_count",
660            inventory.empty_mesh_definition_count,
661        ),
662        (
663            "multiple_skin_deformer_mesh_count",
664            inventory.multiple_skin_deformer_mesh_count,
665        ),
666        (
667            "dual_quaternion_skin_count",
668            inventory.dual_quaternion_skin_count,
669        ),
670        ("cache_deformer_count", inventory.cache_deformer_count),
671        (
672            "incomplete_bind_cluster_count",
673            inventory.incomplete_bind_cluster_count,
674        ),
675        (
676            "empty_skin_deformer_count",
677            inventory.empty_skin_deformer_count,
678        ),
679        (
680            "missing_normal_mesh_count",
681            inventory.missing_normal_mesh_count,
682        ),
683        (
684            "bone_convenience_bind_overwrite_count",
685            inventory.bone_convenience_bind_overwrite_count,
686        ),
687        (
688            "missing_skin_influence_corner_count",
689            inventory.missing_skin_influence_corner_count,
690        ),
691        (
692            "omitted_non_polygon_face_count",
693            inventory.omitted_non_polygon_face_count,
694        ),
695    ] {
696        push_nonzero_violation(&mut violations, name, value);
697    }
698    if let Some((_, scale_invariant_payload_mesh_count)) = captured_source {
699        if inventory.unsupported_vertex_payload_mesh_count != scale_invariant_payload_mesh_count {
700            violations.push(format!(
701                "unsupported_vertex_payload_mesh_count={}!=scale_invariant_source:{}",
702                inventory.unsupported_vertex_payload_mesh_count, scale_invariant_payload_mesh_count
703            ));
704        }
705    } else {
706        for (name, value) in [
707            (
708                "unsupported_vertex_payload_mesh_count",
709                inventory.unsupported_vertex_payload_mesh_count,
710            ),
711            (
712                "truncated_influence_vertex_count",
713                inventory.truncated_influence_vertex_count,
714            ),
715            (
716                "discarded_influence_count",
717                inventory.discarded_influence_count,
718            ),
719            (
720                "renormalized_influence_vertex_count",
721                inventory.renormalized_influence_vertex_count,
722            ),
723            (
724                "rejected_influence_count",
725                inventory.rejected_influence_count,
726            ),
727            ("non_triangle_face_count", inventory.non_triangle_face_count),
728            ("triangulated_face_count", inventory.triangulated_face_count),
729        ] {
730            push_nonzero_violation(&mut violations, name, value);
731        }
732        if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
733            violations.push(format!(
734                "weld_vertex_count={}!=post:{}",
735                inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
736            ));
737        }
738    }
739    if !violations.is_empty() {
740        return Err(format_rest_bind_violations(
741            "capability inventory",
742            &violations,
743        ));
744    }
745
746    // Reuse the complete conservative projection for every counter and
747    // source-domain fact. These flags are discharged by the private GLB
748    // staging/proof boundary: raw FBX members are not preserved, while the
749    // already-validated texture-linkage aggregate, custom properties, and
750    // external locator spellings cannot carry scale-bearing state into the
751    // normalized document. Every other fact continues to gate the operation.
752    let mut facts = capability_facts(inventory);
753    facts.unknown_source_members_present = false;
754    facts.unregistered_extensions_present = false;
755    facts.unsafe_accessor_layout_present = false;
756    facts.extras_present = false;
757    facts.external_resources_present = false;
758    if captured_source.is_some() {
759        facts.non_triangle_primitives_present = false;
760        facts.unsupported_vertex_attributes_present = false;
761        facts.secondary_skin_influences_present = false;
762    }
763    if !facts.is_supported_for(
764        animsmith_core::scale::ScaleOperation::RestBindUniformScale {
765            source_skin_index: 0,
766            source_root_node_index: 0,
767            expected_factor: 1.0,
768        },
769    ) {
770        return Err(format_rest_bind_violations(
771            "projected capability",
772            &scale_capability_violations(&facts),
773        ));
774    }
775    Ok(facts)
776}
777
778/// Require complete raw-source coverage and reject unmodeled constructs.
779///
780/// # Errors
781///
782/// Returns a stable refusal naming each incomplete shared-raw coverage domain
783/// or unsupported construct row.
784fn require_supported_raw_source_facts(source: &FbxScaleSource) -> Result<(), String> {
785    let source_facts = source.source_facts();
786    let source_counts = source.rest_bind_construct_counts;
787    let mut violations = Vec::new();
788    for (domain, state) in [
789        ("constructs", source_facts.constructs().coverage().state()),
790        ("resources", source_facts.resources().coverage().state()),
791    ] {
792        if state != SourceSetCoverageStateV1::Complete {
793            violations.push(format!(
794                "raw_source.{domain}.coverage={}",
795                source_set_coverage_state_name(state)
796            ));
797        }
798    }
799    let mut saw_custom_properties = false;
800    let mut saw_unmodeled_elements = false;
801    for row in source_facts.constructs().rows() {
802        match row.kind() {
803            SourceConstructKindV1::CustomProperty
804                if row.name().as_str() == "fbx:user-defined-properties" =>
805            {
806                saw_custom_properties = true;
807                if row.count()
808                    != u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
809                {
810                    violations.push(format!(
811                        "raw_source.construct=custom_property({}; count={})!=source:{}",
812                        row.name().as_str(),
813                        row.count(),
814                        source_counts.user_defined_property_count
815                    ));
816                }
817            }
818            SourceConstructKindV1::CustomProperty => violations.push(format!(
819                "raw_source.construct=custom_property({}; count={})",
820                row.name().as_str(),
821                row.count()
822            )),
823            SourceConstructKindV1::UnknownElement
824                if row.name().as_str() == "fbx:unmodeled-elements" =>
825            {
826                saw_unmodeled_elements = true;
827                let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
828                    .unwrap_or(u64::MAX);
829                if row.count() != total_count {
830                    violations.push(format!(
831                        "raw_source.construct=unknown_element({}; count={})!=source:{}",
832                        row.name().as_str(),
833                        row.count(),
834                        total_count
835                    ));
836                } else if source_counts.unsupported_unmodeled_element_count() > 0 {
837                    violations.push(format!(
838                        "raw_source.construct=unknown_element({}; {})",
839                        row.name().as_str(),
840                        unmodeled_element_details(source_counts)
841                    ));
842                }
843            }
844            SourceConstructKindV1::UnknownElement => violations.push(format!(
845                "raw_source.construct=unknown_element({}; count={})",
846                row.name().as_str(),
847                row.count()
848            )),
849            SourceConstructKindV1::Extension => violations.push(format!(
850                "raw_source.construct=extension({}; count={})",
851                row.name().as_str(),
852                row.count()
853            )),
854        }
855    }
856    if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
857        violations.push(format!(
858            "raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
859            source_counts.user_defined_property_count
860        ));
861    }
862    if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
863        violations.push(format!(
864            "raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
865            source_counts.total_unmodeled_element_count()
866        ));
867    }
868    if !violations.is_empty() {
869        return Err(format_rest_bind_violations("raw-source facts", &violations));
870    }
871    Ok(())
872}
873
874/// Project the narrow FBX rest/bind subset from one captured source.
875///
876/// Shared construct/resource coverage is checked before the older
877/// operation-specific inventory. This prevents a truncated positive-only raw
878/// projection from being treated as proof of absence.
879///
880/// # Errors
881///
882/// Returns a stable refusal naming each incomplete shared-raw coverage domain,
883/// unsupported construct row, semantic status, or inventory counter that
884/// prevents proof of the selected domain.
885pub fn rest_bind_capability_facts_for_source(
886    source: &FbxScaleSource,
887) -> Result<ScaleCapabilityFacts, String> {
888    require_supported_raw_source_facts(source)?;
889    let source_counts = source.rest_bind_construct_counts;
890
891    let mut facts = join_source_facts(
892        source,
893        rest_bind_capability_facts_with_context(
894            source.inventory(),
895            RestBindCapabilityContext::CapturedSource {
896                counts: &source_counts,
897                scale_invariant_payload_mesh_count: source
898                    .rest_bind_scale_invariant_payload_mesh_count,
899            },
900        )?,
901    );
902    facts.unknown_source_members_present = false;
903    facts.unregistered_extensions_present = false;
904    facts.extras_present = false;
905    facts.external_resources_present = false;
906    if facts.is_supported_for(
907        animsmith_core::scale::ScaleOperation::RestBindUniformScale {
908            source_skin_index: 0,
909            source_root_node_index: 0,
910            expected_factor: 1.0,
911        },
912    ) {
913        Ok(facts)
914    } else {
915        Err(format_rest_bind_violations(
916            "joined capability",
917            &scale_capability_violations(&facts),
918        ))
919    }
920}
921
922/// Project the FBX domains required by an animation-only assembly input.
923///
924/// The captured source must retain complete, supported raw construct coverage,
925/// normalized hierarchy and coordinate semantics, and baked animation. Mesh,
926/// deformation, material, camera/light, and bind domains are excluded because
927/// assembly cannot copy them from a clip-only input. The original inventory is
928/// left unchanged for evidence.
929///
930/// # Errors
931///
932/// Returns a stable refusal when a non-projected raw construct or a required
933/// hierarchy/animation capability cannot be proved.
934pub fn require_clip_track_capability_for_source(source: &FbxScaleSource) -> Result<(), String> {
935    require_supported_raw_source_facts(source)?;
936    let inventory = source.inventory();
937    let mut violations = Vec::new();
938    for (domain, status) in inventory.domains.clip_track_semantic_statuses() {
939        if matches!(
940            status,
941            FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
942        ) {
943            violations.push(format!(
944                "domain.{domain}={}",
945                fbx_scale_domain_status_name(status)
946            ));
947        }
948    }
949    push_normalized_baked_animation_violations(&mut violations, inventory);
950    if !violations.is_empty() {
951        return Err(format_rest_bind_violations(
952            "clip-track capability inventory",
953            &violations,
954        ));
955    }
956
957    Ok(())
958}
959
960fn push_normalized_baked_animation_violations(
961    violations: &mut Vec<String>,
962    inventory: &FbxScaleCapabilityInventory,
963) {
964    if !inventory.coordinate_normalization.target_right_handed_y_up {
965        violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
966    }
967    if inventory.coordinate_normalization.target_unit_meters != 1.0 {
968        violations.push(format!(
969            "coordinate_normalization.target_unit_meters={}",
970            inventory.coordinate_normalization.target_unit_meters
971        ));
972    }
973    if !inventory.coordinate_normalization.adjust_transforms {
974        violations.push("coordinate_normalization.adjust_transforms=false".into());
975    }
976    if !inventory.animation_takes_baked {
977        violations.push("animation_takes_baked=false".into());
978    }
979    if inventory.authored_curve_keys_preserved {
980        violations.push("authored_curve_keys_preserved=true".into());
981    }
982    if !inventory.inherit_modes_compensated {
983        violations.push("inherit_modes_compensated=false".into());
984    }
985}
986
987fn push_unmodeled_element_violation(
988    violations: &mut Vec<String>,
989    counts: crate::source_facts::RestBindSourceConstructCounts,
990) {
991    if counts.unsupported_unmodeled_element_count() > 0 {
992        violations.push(format!(
993            "unsupported_source_element_count; {}",
994            unmodeled_element_details(counts)
995        ));
996    }
997}
998
999fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
1000    let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
1001    for (kind, count) in counts.unsupported_kind_counts() {
1002        details.push_str("; ");
1003        details.push_str(kind);
1004        details.push('=');
1005        details.push_str(&count.to_string());
1006    }
1007    details
1008}
1009
1010fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
1011    match status {
1012        FbxScaleDomainStatus::Absent => "absent",
1013        FbxScaleDomainStatus::Normalized => "normalized",
1014        FbxScaleDomainStatus::Baked => "baked",
1015        FbxScaleDomainStatus::Derived => "derived",
1016        FbxScaleDomainStatus::Rebuilt => "rebuilt",
1017        FbxScaleDomainStatus::Unsupported => "unsupported",
1018        FbxScaleDomainStatus::Unverifiable => "unverifiable",
1019    }
1020}
1021
1022fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
1023    match state {
1024        SourceSetCoverageStateV1::Complete => "complete",
1025        SourceSetCoverageStateV1::Partial => "partial",
1026        SourceSetCoverageStateV1::Unavailable => "unavailable",
1027    }
1028}
1029
1030fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
1031    if value > 0 {
1032        violations.push(format!("{name}={value}"));
1033    }
1034}
1035
1036fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
1037    format!(
1038        "FBX rest/bind {authority} rejected: {}",
1039        violations.join("; ")
1040    )
1041}
1042
1043fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
1044    let mut violations = Vec::new();
1045    if facts.coverage != ScaleCapabilityCoverage::Complete {
1046        violations.push("coverage=unavailable".into());
1047    }
1048    for (name, present) in [
1049        ("morphs_present", facts.morphs_present),
1050        ("morph_weights_present", facts.morph_weights_present),
1051        ("cameras_present", facts.cameras_present),
1052        ("lights_present", facts.lights_present),
1053        ("instancing_present", facts.instancing_present),
1054        (
1055            "unregistered_extensions_present",
1056            facts.unregistered_extensions_present,
1057        ),
1058        ("extras_present", facts.extras_present),
1059        (
1060            "unknown_source_members_present",
1061            facts.unknown_source_members_present,
1062        ),
1063        (
1064            "non_triangle_primitives_present",
1065            facts.non_triangle_primitives_present,
1066        ),
1067        (
1068            "unsupported_vertex_attributes_present",
1069            facts.unsupported_vertex_attributes_present,
1070        ),
1071        (
1072            "secondary_skin_influences_present",
1073            facts.secondary_skin_influences_present,
1074        ),
1075        (
1076            "inverse_bind_issues_present",
1077            facts.inverse_bind_issues_present,
1078        ),
1079        (
1080            "unsafe_accessor_layout_present",
1081            facts.unsafe_accessor_layout_present,
1082        ),
1083        (
1084            "external_resources_present",
1085            facts.external_resources_present,
1086        ),
1087    ] {
1088        if present {
1089            violations.push(format!("{name}=true"));
1090        }
1091    }
1092    violations
1093}
1094
1095#[derive(Debug, Default)]
1096pub(crate) struct AssetConversionFacts {
1097    pub(crate) truncated_influence_vertex_count: usize,
1098    pub(crate) discarded_influence_count: usize,
1099    pub(crate) renormalized_influence_vertex_count: usize,
1100    pub(crate) rejected_influence_count: usize,
1101    pub(crate) missing_skin_influence_corner_count: usize,
1102    pub(crate) pre_weld_vertex_count: usize,
1103    pub(crate) post_weld_vertex_count: usize,
1104}
1105
1106#[derive(Debug, Clone, Copy, Default)]
1107pub(crate) struct RestBindMeshPayloadCounts {
1108    pub(crate) unsupported_mesh_count: usize,
1109    pub(crate) scale_invariant_mesh_count: usize,
1110}
1111
1112fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
1113    FbxSourceIdentity {
1114        source_index: index,
1115        ufbx_typed_id: element.typed_id,
1116        ufbx_element_id: element.element_id,
1117    }
1118}
1119
1120pub(crate) fn inventory(
1121    scene: &ufbx::Scene,
1122    conversion: &AssetConversionFacts,
1123    construct_counts: crate::source_facts::SourceConstructCounts,
1124) -> (FbxScaleCapabilityInventory, RestBindMeshPayloadCounts) {
1125    let non_triangle_face_count = scene
1126        .meshes
1127        .iter()
1128        .flat_map(|mesh| mesh.faces.iter())
1129        .filter(|face| face.num_indices != 3)
1130        .count();
1131    let triangulated_face_count = scene
1132        .meshes
1133        .iter()
1134        .flat_map(|mesh| mesh.faces.iter())
1135        .filter(|face| face.num_indices > 3)
1136        .count();
1137    let omitted_non_polygon_face_count = scene
1138        .meshes
1139        .iter()
1140        .flat_map(|mesh| mesh.faces.iter())
1141        .filter(|face| face.num_indices < 3)
1142        .count();
1143    let empty_source_meshes = scene
1144        .meshes
1145        .iter()
1146        .enumerate()
1147        .filter(|(_, mesh)| mesh.faces.is_empty())
1148        .map(|(index, mesh)| identity(index, &mesh.element))
1149        .collect::<Vec<_>>();
1150    let empty_mesh_definition_count = empty_source_meshes.len();
1151    let generated_normal_mesh_count = scene
1152        .meshes
1153        .iter()
1154        .filter(|mesh| mesh.generated_normals)
1155        .count();
1156    let missing_normal_mesh_count = scene
1157        .meshes
1158        .iter()
1159        .filter(|mesh| !mesh.vertex_normal.exists)
1160        .count();
1161    let skin_cluster_count = scene
1162        .skin_deformers
1163        .iter()
1164        .map(|skin| skin.clusters.len())
1165        .sum();
1166    let empty_skin_deformer_count = scene
1167        .skin_deformers
1168        .iter()
1169        .filter(|skin| skin.clusters.is_empty())
1170        .count();
1171    let incomplete_bind_cluster_count = scene
1172        .skin_clusters
1173        .iter()
1174        .filter(|cluster| super::project_cluster_bind(cluster).is_none())
1175        .count();
1176    let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
1177    for cluster in &scene.skin_clusters {
1178        if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
1179            *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
1180        }
1181    }
1182    let bone_convenience_bind_overwrite_count = clusters_per_bone
1183        .values()
1184        .map(|count| count.saturating_sub(1))
1185        .sum();
1186    let multiple_skin_deformer_mesh_count = scene
1187        .meshes
1188        .iter()
1189        .filter(|mesh| mesh.skin_deformers.len() > 1)
1190        .count();
1191    let dual_quaternion_skin_count = scene
1192        .skin_deformers
1193        .iter()
1194        .filter(|skin| {
1195            skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
1196        })
1197        .count();
1198    let mesh_payload_counts = scene
1199        .meshes
1200        .iter()
1201        .map(|mesh| classify_mesh_source_payload(mesh))
1202        .fold(
1203            RestBindMeshPayloadCounts::default(),
1204            |mut counts, classification| {
1205                match classification {
1206                    MeshSourcePayloadClassification::Absent => {}
1207                    MeshSourcePayloadClassification::ScaleInvariantConversion => {
1208                        counts.unsupported_mesh_count += 1;
1209                        counts.scale_invariant_mesh_count += 1;
1210                    }
1211                    MeshSourcePayloadClassification::Unsupported => {
1212                        counts.unsupported_mesh_count += 1;
1213                    }
1214                }
1215                counts
1216            },
1217        );
1218    let unsupported_vertex_payload_mesh_count = mesh_payload_counts.unsupported_mesh_count;
1219    let shared_mesh_definition_count = scene
1220        .meshes
1221        .iter()
1222        .filter(|mesh| mesh.element.instances.len() > 1)
1223        .count();
1224    let uninstanced_source_meshes = scene
1225        .meshes
1226        .iter()
1227        .enumerate()
1228        .filter(|(_, mesh)| mesh.element.instances.is_empty())
1229        .map(|(index, mesh)| identity(index, &mesh.element))
1230        .collect::<Vec<_>>();
1231    let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
1232    let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
1233    let unsupported_source_element_count =
1234        construct_counts.rest_bind.total_unmodeled_element_count();
1235    let external_resource_count = scene
1236        .textures
1237        .iter()
1238        .filter(|texture| texture.content.is_empty() && texture.has_file)
1239        .count()
1240        + scene
1241            .videos
1242            .iter()
1243            .filter(|video| {
1244                video.content.is_empty()
1245                    && (!video.filename.is_empty()
1246                        || !video.relative_filename.is_empty()
1247                        || !video.absolute_filename.is_empty())
1248            })
1249            .count();
1250    let compensated_inherit_node_count = scene
1251        .nodes
1252        .iter()
1253        .filter(|node| {
1254            node.original_inherit_mode != node.inherit_mode
1255                || node.is_scale_helper
1256                || node.is_scale_compensate_parent
1257        })
1258        .count();
1259
1260    let stackless_animation_present = scene.anim_stacks.is_empty()
1261        && (!scene.anim_layers.is_empty()
1262            || !scene.anim_values.is_empty()
1263            || !scene.anim_curves.is_empty());
1264    let animation = if !scene.anim_stacks.is_empty() {
1265        FbxScaleDomainStatus::Baked
1266    } else if stackless_animation_present {
1267        // No take was available to bake, but authored curve/value/layer rows
1268        // were parsed and discarded by normalized clip extraction.
1269        FbxScaleDomainStatus::Unsupported
1270    } else {
1271        FbxScaleDomainStatus::Absent
1272    };
1273    let domains = FbxScaleDomainInventory {
1274        rest_hierarchy: FbxScaleDomainStatus::Normalized,
1275        translation_animation: animation,
1276        rotation_and_scale_animation: animation,
1277        root_motion_and_velocity: match animation {
1278            FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
1279            status => status,
1280        },
1281        base_mesh_geometry: if scene.meshes.is_empty() {
1282            FbxScaleDomainStatus::Absent
1283        } else if uninstanced_mesh_definition_count > 0
1284            || omitted_non_polygon_face_count > 0
1285            || empty_mesh_definition_count > 0
1286        {
1287            FbxScaleDomainStatus::Unsupported
1288        } else {
1289            FbxScaleDomainStatus::Rebuilt
1290        },
1291        morphs: if scene.blend_deformers.is_empty()
1292            && scene.blend_channels.is_empty()
1293            && scene.blend_shapes.is_empty()
1294        {
1295            FbxScaleDomainStatus::Absent
1296        } else {
1297            FbxScaleDomainStatus::Unsupported
1298        },
1299        skin_binds: if scene.skin_deformers.is_empty() {
1300            FbxScaleDomainStatus::Absent
1301        } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
1302            FbxScaleDomainStatus::Unsupported
1303        } else {
1304            FbxScaleDomainStatus::Derived
1305        },
1306        cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
1307            FbxScaleDomainStatus::Absent
1308        } else {
1309            FbxScaleDomainStatus::Unsupported
1310        },
1311        collision_and_custom_data: if unsupported_source_element_count == 0
1312            && user_defined_property_count == 0
1313        {
1314            FbxScaleDomainStatus::Absent
1315        } else {
1316            FbxScaleDomainStatus::Unsupported
1317        },
1318        other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
1319            || uninstanced_mesh_definition_count > 0
1320            || omitted_non_polygon_face_count > 0
1321            || empty_mesh_definition_count > 0
1322            || multiple_skin_deformer_mesh_count > 0
1323            || dual_quaternion_skin_count > 0
1324            || conversion.truncated_influence_vertex_count > 0
1325            || conversion.missing_skin_influence_corner_count > 0
1326            || conversion.rejected_influence_count > 0
1327            || !scene.blend_deformers.is_empty()
1328            || !scene.blend_channels.is_empty()
1329            || !scene.blend_shapes.is_empty()
1330            || !scene.cache_deformers.is_empty()
1331            || !scene.cache_files.is_empty()
1332        {
1333            FbxScaleDomainStatus::Unsupported
1334        } else if !scene.meshes.is_empty() {
1335            FbxScaleDomainStatus::Rebuilt
1336        } else {
1337            FbxScaleDomainStatus::Absent
1338        },
1339        out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
1340        animation_targeting_matrix_nodes: animation,
1341        shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1342        unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1343        image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
1344    };
1345
1346    let inventory = FbxScaleCapabilityInventory {
1347        domains,
1348        coordinate_normalization: FbxCoordinateNormalization {
1349            original_up_axis: scene.settings.original_axis_up.into(),
1350            original_unit_meters: scene.settings.original_unit_meters,
1351            target_right_handed_y_up: true,
1352            target_unit_meters: 1.0,
1353            adjust_transforms: matches!(
1354                scene.metadata.space_conversion,
1355                ufbx::SpaceConversion::AdjustTransforms
1356            ),
1357        },
1358        animation_takes_baked: true,
1359        authored_curve_keys_preserved: false,
1360        animation_take_count: scene.anim_stacks.len(),
1361        source_animation_curve_count: scene.anim_curves.len(),
1362        generated_geometry_helper_node_count: scene
1363            .nodes
1364            .iter()
1365            .filter(|node| node.is_geometry_transform_helper)
1366            .count(),
1367        generated_scale_helper_node_count: scene
1368            .nodes
1369            .iter()
1370            .filter(|node| node.is_scale_helper)
1371            .count(),
1372        inherit_modes_compensated: matches!(
1373            scene.metadata.inherit_mode_handling,
1374            ufbx::InheritModeHandling::Compensate
1375        ),
1376        compensated_inherit_node_count,
1377        generated_normal_mesh_count,
1378        missing_normal_mesh_count,
1379        skin_deformer_count: scene.skin_deformers.len(),
1380        skin_cluster_count,
1381        empty_skin_deformer_count,
1382        bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
1383        incomplete_bind_cluster_count,
1384        bone_convenience_bind_overwrite_count,
1385        identity_bind_defaults_invented: false,
1386        truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
1387        discarded_influence_count: conversion.discarded_influence_count,
1388        renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
1389        rejected_influence_count: conversion.rejected_influence_count,
1390        missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
1391        non_triangle_face_count,
1392        triangulated_face_count,
1393        omitted_non_polygon_face_count,
1394        empty_mesh_definition_count,
1395        empty_source_meshes,
1396        pre_weld_vertex_count: conversion.pre_weld_vertex_count,
1397        post_weld_vertex_count: conversion.post_weld_vertex_count,
1398        multiple_skin_deformer_mesh_count,
1399        dual_quaternion_skin_count,
1400        blend_deformer_count: scene.blend_deformers.len(),
1401        blend_channel_count: scene.blend_channels.len(),
1402        blend_shape_count: scene.blend_shapes.len(),
1403        cache_deformer_count: scene.cache_deformers.len(),
1404        unsupported_vertex_payload_mesh_count,
1405        camera_count: scene.cameras.len(),
1406        light_count: scene.lights.len(),
1407        shared_mesh_definition_count,
1408        uninstanced_mesh_definition_count,
1409        uninstanced_source_meshes,
1410        user_defined_property_count,
1411        unsupported_source_element_count,
1412        external_resource_count,
1413        source_nodes: scene
1414            .nodes
1415            .iter()
1416            .enumerate()
1417            .map(|(index, node)| identity(index, &node.element))
1418            .collect(),
1419        source_meshes: scene
1420            .meshes
1421            .iter()
1422            .enumerate()
1423            .map(|(index, mesh)| identity(index, &mesh.element))
1424            .collect(),
1425        source_skins: scene
1426            .skin_deformers
1427            .iter()
1428            .enumerate()
1429            .map(|(index, skin)| identity(index, &skin.element))
1430            .collect(),
1431    };
1432    (inventory, mesh_payload_counts)
1433}
1434
1435/// Classify every field in `ufbx::Mesh` at one structural boundary. Omitting
1436/// `..` is deliberate: a ufbx upgrade that adds mesh payload must fail to
1437/// compile until extraction either models it or this predicate refuses it.
1438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439enum MeshSourcePayloadClassification {
1440    Absent,
1441    ScaleInvariantConversion,
1442    Unsupported,
1443}
1444
1445fn classify_mesh_source_payload(mesh: &ufbx::Mesh) -> MeshSourcePayloadClassification {
1446    let ufbx::Mesh {
1447        element: _,
1448        num_vertices: _,
1449        num_indices: _,
1450        num_faces: _,
1451        num_triangles: _,
1452        num_edges: _,
1453        max_face_triangles: _,
1454        num_empty_faces: _,
1455        num_point_faces: _,
1456        num_line_faces: _,
1457        faces: _,
1458        // Authored face/edge members are not retained by triangle extraction.
1459        face_smoothing,
1460        face_material: _,
1461        face_group,
1462        face_hole,
1463        edges,
1464        edge_smoothing,
1465        edge_crease,
1466        edge_visibility,
1467        vertex_indices: _,
1468        vertices: _,
1469        vertex_first_index: _,
1470        vertex_position: _,
1471        vertex_normal: _,
1472        vertex_uv: _,
1473        vertex_tangent,
1474        vertex_bitangent,
1475        vertex_color,
1476        vertex_crease,
1477        uv_sets,
1478        color_sets,
1479        materials: _,
1480        face_groups,
1481        // Mesh parts and skinned views are parser-derived indexes/results.
1482        material_parts: _,
1483        face_group_parts: _,
1484        material_part_usage_order: _,
1485        skinned_is_local: _,
1486        skinned_position: _,
1487        skinned_normal: _,
1488        // Deformer kinds have dedicated inventory counters.
1489        skin_deformers: _,
1490        blend_deformers: _,
1491        cache_deformers: _,
1492        all_deformers: _,
1493        subdivision_preview_levels,
1494        subdivision_render_levels,
1495        subdivision_display_mode,
1496        subdivision_boundary,
1497        subdivision_uv_boundary,
1498        // Winding conversion and generated-normal state are consumed/counted.
1499        reversed_winding: _,
1500        generated_normals: _,
1501        subdivision_evaluated,
1502        subdivision_result,
1503        from_tessellated_nurbs,
1504    } = mesh;
1505
1506    // These fields are produced by ufbx's explicit subdivision/NURBS
1507    // evaluators rather than by the raw polygon-mesh load used here. Keep
1508    // them outside the patch-release allowlist until a reachable fixture can
1509    // prove their normalized handoff independently.
1510    let unsupported_generated_payload_present =
1511        !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
1512            || *subdivision_evaluated
1513            || subdivision_result.is_some()
1514            || *from_tessellated_nurbs;
1515    let scale_invariant_conversion_facts_present = !face_smoothing.is_empty()
1516        || !face_group.is_empty()
1517        || !face_hole.is_empty()
1518        || !edges.is_empty()
1519        || !edge_smoothing.is_empty()
1520        || !edge_crease.is_empty()
1521        || !edge_visibility.is_empty()
1522        || vertex_tangent.exists
1523        || vertex_bitangent.exists
1524        || vertex_color.exists
1525        || vertex_crease.exists
1526        || uv_sets.len() > 1
1527        || !color_sets.is_empty()
1528        || !face_groups.is_empty()
1529        || *subdivision_preview_levels > 0
1530        || *subdivision_render_levels > 0
1531        || !matches!(
1532            subdivision_display_mode,
1533            ufbx::SubdivisionDisplayMode::Disabled
1534        )
1535        || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default);
1536
1537    if unsupported_generated_payload_present {
1538        MeshSourcePayloadClassification::Unsupported
1539    } else if scale_invariant_conversion_facts_present {
1540        MeshSourcePayloadClassification::ScaleInvariantConversion
1541    } else {
1542        MeshSourcePayloadClassification::Absent
1543    }
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548    use super::*;
1549    use animsmith_core::{
1550        InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
1551        SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
1552        SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
1553    };
1554    use std::path::PathBuf;
1555
1556    fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
1557        captured_with_counts(configure, |_| {})
1558    }
1559
1560    fn captured_with_counts(
1561        configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
1562        configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
1563    ) -> FbxScaleSource {
1564        let fixture =
1565            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
1566        let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
1567        let document = baseline.document().clone();
1568        let mut inventory = baseline.inventory().clone();
1569        let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
1570        let rest_bind_scale_invariant_payload_mesh_count =
1571            baseline.rest_bind_scale_invariant_payload_mesh_count;
1572        configure_counts(&mut rest_bind_construct_counts);
1573        inventory.user_defined_property_count =
1574            rest_bind_construct_counts.user_defined_property_count;
1575        inventory.unsupported_source_element_count =
1576            rest_bind_construct_counts.total_unmodeled_element_count();
1577        inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
1578            && inventory.unsupported_source_element_count == 0
1579        {
1580            FbxScaleDomainStatus::Absent
1581        } else {
1582            FbxScaleDomainStatus::Unsupported
1583        };
1584        let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
1585        let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
1586        configure(&mut builder);
1587        let source = builder.finish(document).expect("synthetic raw facts bind");
1588        FbxScaleSource {
1589            source,
1590            inventory,
1591            rest_bind_construct_counts,
1592            rest_bind_scale_invariant_payload_mesh_count,
1593        }
1594    }
1595
1596    fn parser_provenance(path: &str) -> SourceProvenanceV1 {
1597        SourceProvenanceV1::parser_projected(
1598            SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
1599        )
1600    }
1601
1602    fn complete_captured_source() -> FbxScaleSource {
1603        captured_with(|builder| {
1604            builder.mark_complete(SourceFactDomainV1::Constructs);
1605            builder.mark_complete(SourceFactDomainV1::Resources);
1606        })
1607    }
1608
1609    #[test]
1610    fn source_aware_rest_bind_admits_only_reconciled_scale_invariant_conversion_facts() {
1611        let mut source = complete_captured_source();
1612        source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1613        source.inventory.unsupported_vertex_payload_mesh_count = 1;
1614        source.rest_bind_scale_invariant_payload_mesh_count = 1;
1615        source.inventory.truncated_influence_vertex_count = 1;
1616        source.inventory.discarded_influence_count = 2;
1617        source.inventory.renormalized_influence_vertex_count = 1;
1618        source.inventory.rejected_influence_count = 1;
1619        source.inventory.non_triangle_face_count = 1;
1620        source.inventory.triangulated_face_count = 1;
1621        source.inventory.post_weld_vertex_count = source.inventory.pre_weld_vertex_count - 1;
1622
1623        assert!(
1624            rest_bind_capability_facts(source.inventory()).is_err(),
1625            "a detached public inventory cannot prove these conversions are scale-invariant"
1626        );
1627        let facts = rest_bind_capability_facts_for_source(&source)
1628            .expect("same-parse enumerated conversion facts are admissible");
1629        assert!(!facts.non_triangle_primitives_present);
1630        assert!(!facts.unsupported_vertex_attributes_present);
1631        assert!(!facts.secondary_skin_influences_present);
1632
1633        source.rest_bind_scale_invariant_payload_mesh_count = 0;
1634        assert_eq!(
1635            rest_bind_capability_facts_for_source(&source).unwrap_err(),
1636            concat!(
1637                "FBX rest/bind capability inventory rejected: ",
1638                "unsupported_vertex_payload_mesh_count=1!=scale_invariant_source:0"
1639            ),
1640            "an unclassified payload cannot hide inside the public aggregate"
1641        );
1642
1643        source.rest_bind_scale_invariant_payload_mesh_count = 1;
1644        source.inventory.missing_skin_influence_corner_count = 1;
1645        assert_eq!(
1646            rest_bind_capability_facts_for_source(&source).unwrap_err(),
1647            concat!(
1648                "FBX rest/bind capability inventory rejected: ",
1649                "missing_skin_influence_corner_count=1"
1650            ),
1651            "conversion evidence never overrides missing effective skin coverage"
1652        );
1653    }
1654
1655    #[test]
1656    fn clip_track_projection_excludes_deformation_but_keeps_animation_gates() {
1657        let mut source = complete_captured_source();
1658        source.inventory.domains.base_mesh_geometry = FbxScaleDomainStatus::Unsupported;
1659        source.inventory.domains.skin_binds = FbxScaleDomainStatus::Unsupported;
1660        source.inventory.domains.morphs = FbxScaleDomainStatus::Unsupported;
1661        source.inventory.domains.cameras_and_lights = FbxScaleDomainStatus::Unsupported;
1662        source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1663        source.inventory.dual_quaternion_skin_count = 1;
1664        source.inventory.incomplete_bind_cluster_count = 1;
1665        source.inventory.blend_deformer_count = 1;
1666        source.inventory.cache_deformer_count = 1;
1667        assert!(rest_bind_capability_facts_for_source(&source).is_err());
1668        require_clip_track_capability_for_source(&source)
1669            .expect("clip-only projection excludes deformation and bind domains");
1670
1671        source.inventory.animation_takes_baked = false;
1672        assert_eq!(
1673            require_clip_track_capability_for_source(&source).unwrap_err(),
1674            "FBX rest/bind clip-track capability inventory rejected: animation_takes_baked=false"
1675        );
1676        source.inventory.animation_takes_baked = true;
1677        source.inventory.domains.out_of_contract_node_transforms =
1678            FbxScaleDomainStatus::Unsupported;
1679        assert_eq!(
1680            require_clip_track_capability_for_source(&source).unwrap_err(),
1681            "FBX rest/bind clip-track capability inventory rejected: domain.out_of_contract_node_transforms=unsupported"
1682        );
1683
1684        macro_rules! required_domain_refuses {
1685            ($field:ident) => {{
1686                let mut source = complete_captured_source();
1687                source.inventory.domains.$field = FbxScaleDomainStatus::Unsupported;
1688                let error = require_clip_track_capability_for_source(&source).unwrap_err();
1689                assert!(
1690                    error.contains(concat!("domain.", stringify!($field), "=unsupported")),
1691                    "{error}"
1692                );
1693            }};
1694        }
1695        required_domain_refuses!(rest_hierarchy);
1696        required_domain_refuses!(translation_animation);
1697        required_domain_refuses!(rotation_and_scale_animation);
1698        required_domain_refuses!(root_motion_and_velocity);
1699        required_domain_refuses!(out_of_contract_node_transforms);
1700        required_domain_refuses!(animation_targeting_matrix_nodes);
1701
1702        let mut source = complete_captured_source();
1703        source.inventory.domains.rest_hierarchy = FbxScaleDomainStatus::Unverifiable;
1704        assert!(
1705            require_clip_track_capability_for_source(&source)
1706                .unwrap_err()
1707                .contains("domain.rest_hierarchy=unverifiable")
1708        );
1709    }
1710
1711    #[test]
1712    fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
1713        for (source, expected) in [
1714            (
1715                captured_with(|builder| {
1716                    builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1717                    builder.mark_complete(SourceFactDomainV1::Resources);
1718                }),
1719                "FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
1720            ),
1721            (
1722                captured_with(|builder| {
1723                    builder.mark_complete(SourceFactDomainV1::Constructs);
1724                    builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
1725                }),
1726                "FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
1727            ),
1728        ] {
1729            assert_eq!(
1730                rest_bind_capability_facts_for_source(&source).unwrap_err(),
1731                expected,
1732                "partial construct/resource coverage must name the exact raw authority"
1733            );
1734        }
1735    }
1736
1737    #[test]
1738    fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
1739        for kind in [
1740            SourceConstructKindV1::UnknownElement,
1741            SourceConstructKindV1::Extension,
1742        ] {
1743            let source = captured_with(|builder| {
1744                builder.push_construct(
1745                    SourceConstructFactV1::new(
1746                        0,
1747                        kind,
1748                        SourceTextV1::new("synthetic").expect("bounded test name"),
1749                        false,
1750                        1,
1751                        SourceLoaderDispositionV1::Unsupported,
1752                        parser_provenance("fbx:synthetic/construct"),
1753                    )
1754                    .expect("positive test construct"),
1755                );
1756                builder.mark_complete(SourceFactDomainV1::Constructs);
1757                builder.mark_complete(SourceFactDomainV1::Resources);
1758            });
1759            let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
1760            let expected = match kind {
1761                SourceConstructKindV1::UnknownElement => "unknown_element",
1762                SourceConstructKindV1::Extension => "extension",
1763                SourceConstructKindV1::CustomProperty => unreachable!(),
1764            };
1765            assert_eq!(
1766                error,
1767                format!(
1768                    "FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
1769                )
1770            );
1771        }
1772
1773        let custom_and_external = captured_with_counts(
1774            |builder| {
1775                builder.push_construct(
1776                    SourceConstructFactV1::new(
1777                        0,
1778                        SourceConstructKindV1::CustomProperty,
1779                        SourceTextV1::new("fbx:user-defined-properties")
1780                            .expect("bounded test name"),
1781                        false,
1782                        1,
1783                        SourceLoaderDispositionV1::Unsupported,
1784                        parser_provenance("fbx:synthetic/property"),
1785                    )
1786                    .expect("positive custom-property row"),
1787                );
1788                builder.mark_complete(SourceFactDomainV1::Constructs);
1789                builder.push_resource(SourceResourceReferenceV1::new(
1790                    0,
1791                    SourceResourceKindV1::Texture,
1792                    0,
1793                    SourceResourceLocatorV1::classify("texture.png"),
1794                    SourceLoaderDispositionV1::Unknown,
1795                    parser_provenance("fbx:textures/0/filename"),
1796                ));
1797                builder.mark_complete(SourceFactDomainV1::Resources);
1798            },
1799            |counts| counts.user_defined_property_count = 1,
1800        );
1801        let facts = rest_bind_capability_facts_for_source(&custom_and_external)
1802            .expect("custom properties and external images are not scale-bearing");
1803        assert!(!facts.extras_present);
1804        assert!(!facts.external_resources_present);
1805        assert!(facts.is_supported_for(
1806            animsmith_core::scale::ScaleOperation::RestBindUniformScale {
1807                source_skin_index: 0,
1808                source_root_node_index: 1,
1809                expected_factor: 0.01,
1810            }
1811        ));
1812    }
1813}