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    /// Same-parse meshes whose unsupported public payload aggregate consists
369    /// entirely of enumerated scale-invariant conversion-fidelity facts.
370    pub(crate) rest_bind_scale_invariant_payload_mesh_count: usize,
371}
372
373impl FbxScaleSource {
374    /// The normalized document carrying the documented ufbx source projection.
375    pub fn document(&self) -> &Document {
376        self.source.document()
377    }
378
379    /// The bounded importer-sensitive facts retained from the same ufbx parse.
380    pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
381        self.source.source_facts()
382    }
383
384    /// The bounded dependency closure captured with the same ufbx parse.
385    pub fn dependency_closure(&self) -> &DependencyClosureV1 {
386        self.source.dependency_closure()
387    }
388
389    /// The conservative ufbx-side inventory.
390    pub fn inventory(&self) -> &FbxScaleCapabilityInventory {
391        &self.inventory
392    }
393
394    /// Consume the source wrapper and retain its normalized document.
395    pub fn into_document(self) -> Document {
396        self.source.into_document()
397    }
398
399    pub(crate) fn into_source(self) -> LoadedSource {
400        self.source
401    }
402}
403
404/// Project an FBX inventory into the format-neutral core capability gate.
405///
406/// `coverage` means every Appendix D.4 domain has an explicit status, not that
407/// any domain is preserved losslessly. Support remains false: normalized transform stacks, baked
408/// curves, rebuilt meshes, and unverifiable raw payload relationships are
409/// recorded as unsupported facts rather than hidden behind absent flags.
410pub fn capability_facts(inventory: &FbxScaleCapabilityInventory) -> ScaleCapabilityFacts {
411    let mut facts = ScaleCapabilityFacts::default();
412    facts.coverage = ScaleCapabilityCoverage::Complete;
413    let morph_source_present = inventory.blend_deformer_count > 0
414        || inventory.blend_channel_count > 0
415        || inventory.blend_shape_count > 0;
416    facts.morphs_present = morph_source_present;
417    facts.morph_weights_present = morph_source_present;
418    facts.cameras_present = inventory.camera_count > 0;
419    facts.lights_present = inventory.light_count > 0;
420    facts.instancing_present = inventory.shared_mesh_definition_count > 0;
421    facts.unregistered_extensions_present = inventory.unsupported_source_element_count > 0;
422    facts.extras_present = inventory.user_defined_property_count > 0;
423    // FBX transform stacks and authored animation curves are normalized or
424    // baked before Document construction, so their raw members are not in
425    // the model even for the smallest accepted scene.
426    facts.unknown_source_members_present = true;
427    facts.non_triangle_primitives_present = inventory.non_triangle_face_count > 0;
428    facts.unsupported_vertex_attributes_present = inventory.unsupported_vertex_payload_mesh_count
429        > 0
430        || inventory.uninstanced_mesh_definition_count > 0
431        || inventory.empty_mesh_definition_count > 0
432        || inventory.multiple_skin_deformer_mesh_count > 0
433        || inventory.dual_quaternion_skin_count > 0
434        || inventory.cache_deformer_count > 0
435        || inventory.missing_skin_influence_corner_count > 0
436        || inventory.rejected_influence_count > 0
437        || inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count;
438    facts.secondary_skin_influences_present = inventory.truncated_influence_vertex_count > 0;
439    facts.inverse_bind_issues_present =
440        inventory.incomplete_bind_cluster_count > 0 || inventory.empty_skin_deformer_count > 0;
441    // ufbx exposes normalized objects, not accessor/image byte spans. A future
442    // FBX writer must discharge this preservation obligation through the full
443    // inventory route; #286-A cannot declare the source layout rewrite-safe.
444    facts.unsafe_accessor_layout_present = true;
445    facts.external_resources_present = inventory.external_resource_count > 0;
446    facts
447}
448
449/// Project scale capabilities from one immutable captured FBX source.
450///
451/// The operation inventory keeps its detailed normalization/bake ledger. The
452/// shared raw-source facts independently supply custom/unknown construct and
453/// resource presence, and partial shared coverage always fails closed.
454pub fn capability_facts_for_source(source: &FbxScaleSource) -> ScaleCapabilityFacts {
455    join_source_facts(source, capability_facts(source.inventory()))
456}
457
458fn join_source_facts(
459    source: &FbxScaleSource,
460    mut facts: ScaleCapabilityFacts,
461) -> ScaleCapabilityFacts {
462    let source_facts = source.source_facts();
463    if [
464        source_facts.constructs().coverage().state(),
465        source_facts.resources().coverage().state(),
466    ]
467    .into_iter()
468    .any(|state| state != SourceSetCoverageStateV1::Complete)
469    {
470        facts.coverage = ScaleCapabilityCoverage::Unavailable;
471    }
472    for row in source_facts.constructs().rows() {
473        match row.kind() {
474            SourceConstructKindV1::CustomProperty => facts.extras_present = true,
475            SourceConstructKindV1::UnknownElement => {
476                facts.unknown_source_members_present = true;
477            }
478            SourceConstructKindV1::Extension => facts.unregistered_extensions_present = true,
479        }
480    }
481    if source_facts.resources().rows().iter().any(|row| {
482        !matches!(
483            row.locator(),
484            SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
485        )
486    }) {
487        facts.external_resources_present = true;
488    }
489    facts
490}
491
492/// Project the narrow FBX subset that can enter rest/bind scaling.
493///
494/// The accepted operation rewrites a freshly serialized GLB rather than the
495/// FBX container, so the three raw-span rows are intentionally
496/// [`FbxScaleDomainStatus::Unverifiable`]. Every semantic domain must still
497/// be complete when it can affect the normalized document. User-defined FBX
498/// properties are already explicitly discarded by the loader, and external
499/// image declarations require the source-aware companion so their same-load
500/// resource classification and capture can be validated before staging. The
501/// frozen inventory alone remains conservative for external references because
502/// it cannot prove that boundary. Neither known class is a scale-bearing
503/// ambiguity. The source-aware form may also admit enumerated scale-invariant
504/// conversion-fidelity facts while retaining them in the inventory. Unknown
505/// source elements, missing effective influence coverage, incomplete bind
506/// evidence, or an incomplete coordinate projection remain stable refusals
507/// before the producer can stage any output.
508pub fn rest_bind_capability_facts(
509    inventory: &FbxScaleCapabilityInventory,
510) -> Result<ScaleCapabilityFacts, String> {
511    if inventory.external_resource_count != 0 {
512        return Err(format_rest_bind_violations(
513            "capability inventory",
514            &[format!(
515                "external_resource_count={}",
516                inventory.external_resource_count
517            )],
518        ));
519    }
520    rest_bind_capability_facts_with_context(inventory, RestBindCapabilityContext::InventoryOnly)
521}
522
523#[derive(Debug, Clone, Copy)]
524enum RestBindCapabilityContext<'a> {
525    InventoryOnly,
526    CapturedSource {
527        counts: &'a crate::source_facts::RestBindSourceConstructCounts,
528        scale_invariant_payload_mesh_count: usize,
529    },
530}
531
532fn rest_bind_capability_facts_with_context(
533    inventory: &FbxScaleCapabilityInventory,
534    context: RestBindCapabilityContext<'_>,
535) -> Result<ScaleCapabilityFacts, String> {
536    let captured_source = match context {
537        RestBindCapabilityContext::InventoryOnly => None,
538        RestBindCapabilityContext::CapturedSource {
539            counts,
540            scale_invariant_payload_mesh_count,
541        } => Some((counts, scale_invariant_payload_mesh_count)),
542    };
543    let mut violations = Vec::new();
544    for (domain, status) in inventory.domains.rest_bind_semantic_statuses() {
545        if domain == "other_vertex_and_source_data" && captured_source.is_some() {
546            continue;
547        }
548        if matches!(
549            status,
550            FbxScaleDomainStatus::Unsupported | FbxScaleDomainStatus::Unverifiable
551        ) {
552            violations.push(format!(
553                "domain.{domain}={}",
554                fbx_scale_domain_status_name(status)
555            ));
556        }
557    }
558    for (domain, status) in inventory.domains.rest_bind_raw_span_statuses() {
559        if status != FbxScaleDomainStatus::Unverifiable {
560            violations.push(format!(
561                "domain.{domain}={} (expected unverifiable)",
562                fbx_scale_domain_status_name(status)
563            ));
564        }
565    }
566
567    let expected_custom_status = if inventory.unsupported_source_element_count == 0
568        && inventory.user_defined_property_count == 0
569    {
570        FbxScaleDomainStatus::Absent
571    } else {
572        FbxScaleDomainStatus::Unsupported
573    };
574    if inventory.domains.collision_and_custom_data != expected_custom_status {
575        violations.push(format!(
576            "domain.collision_and_custom_data={} (expected {})",
577            fbx_scale_domain_status_name(inventory.domains.collision_and_custom_data),
578            fbx_scale_domain_status_name(expected_custom_status)
579        ));
580    }
581    if let Some((source_counts, _)) = captured_source {
582        if inventory.user_defined_property_count != source_counts.user_defined_property_count {
583            violations.push(format!(
584                "user_defined_property_count={}!=source:{}",
585                inventory.user_defined_property_count, source_counts.user_defined_property_count
586            ));
587        }
588        let source_unmodeled_count = source_counts.total_unmodeled_element_count();
589        if inventory.unsupported_source_element_count != source_unmodeled_count {
590            violations.push(format!(
591                "unsupported_source_element_count={}!=source:{}",
592                inventory.unsupported_source_element_count, source_unmodeled_count
593            ));
594        }
595        push_unmodeled_element_violation(&mut violations, *source_counts);
596    } else {
597        push_nonzero_violation(
598            &mut violations,
599            "unsupported_source_element_count",
600            inventory.unsupported_source_element_count,
601        );
602    }
603
604    if !inventory.coordinate_normalization.target_right_handed_y_up {
605        violations.push("coordinate_normalization.target_right_handed_y_up=false".into());
606    }
607    if inventory.coordinate_normalization.target_unit_meters != 1.0 {
608        violations.push(format!(
609            "coordinate_normalization.target_unit_meters={}",
610            inventory.coordinate_normalization.target_unit_meters
611        ));
612    }
613    if !inventory.coordinate_normalization.adjust_transforms {
614        violations.push("coordinate_normalization.adjust_transforms=false".into());
615    }
616    if !inventory.animation_takes_baked {
617        violations.push("animation_takes_baked=false".into());
618    }
619    if inventory.authored_curve_keys_preserved {
620        violations.push("authored_curve_keys_preserved=true".into());
621    }
622    if !inventory.inherit_modes_compensated {
623        violations.push("inherit_modes_compensated=false".into());
624    }
625    if inventory.identity_bind_defaults_invented {
626        violations.push("identity_bind_defaults_invented=true".into());
627    }
628    for (name, value) in [
629        ("blend_deformer_count", inventory.blend_deformer_count),
630        ("blend_channel_count", inventory.blend_channel_count),
631        ("blend_shape_count", inventory.blend_shape_count),
632        ("camera_count", inventory.camera_count),
633        ("light_count", inventory.light_count),
634        (
635            "shared_mesh_definition_count",
636            inventory.shared_mesh_definition_count,
637        ),
638        (
639            "uninstanced_mesh_definition_count",
640            inventory.uninstanced_mesh_definition_count,
641        ),
642        (
643            "empty_mesh_definition_count",
644            inventory.empty_mesh_definition_count,
645        ),
646        (
647            "multiple_skin_deformer_mesh_count",
648            inventory.multiple_skin_deformer_mesh_count,
649        ),
650        (
651            "dual_quaternion_skin_count",
652            inventory.dual_quaternion_skin_count,
653        ),
654        ("cache_deformer_count", inventory.cache_deformer_count),
655        (
656            "incomplete_bind_cluster_count",
657            inventory.incomplete_bind_cluster_count,
658        ),
659        (
660            "empty_skin_deformer_count",
661            inventory.empty_skin_deformer_count,
662        ),
663        (
664            "missing_normal_mesh_count",
665            inventory.missing_normal_mesh_count,
666        ),
667        (
668            "bone_convenience_bind_overwrite_count",
669            inventory.bone_convenience_bind_overwrite_count,
670        ),
671        (
672            "missing_skin_influence_corner_count",
673            inventory.missing_skin_influence_corner_count,
674        ),
675        (
676            "omitted_non_polygon_face_count",
677            inventory.omitted_non_polygon_face_count,
678        ),
679    ] {
680        push_nonzero_violation(&mut violations, name, value);
681    }
682    if let Some((_, scale_invariant_payload_mesh_count)) = captured_source {
683        if inventory.unsupported_vertex_payload_mesh_count != scale_invariant_payload_mesh_count {
684            violations.push(format!(
685                "unsupported_vertex_payload_mesh_count={}!=scale_invariant_source:{}",
686                inventory.unsupported_vertex_payload_mesh_count, scale_invariant_payload_mesh_count
687            ));
688        }
689    } else {
690        for (name, value) in [
691            (
692                "unsupported_vertex_payload_mesh_count",
693                inventory.unsupported_vertex_payload_mesh_count,
694            ),
695            (
696                "truncated_influence_vertex_count",
697                inventory.truncated_influence_vertex_count,
698            ),
699            (
700                "discarded_influence_count",
701                inventory.discarded_influence_count,
702            ),
703            (
704                "renormalized_influence_vertex_count",
705                inventory.renormalized_influence_vertex_count,
706            ),
707            (
708                "rejected_influence_count",
709                inventory.rejected_influence_count,
710            ),
711            ("non_triangle_face_count", inventory.non_triangle_face_count),
712            ("triangulated_face_count", inventory.triangulated_face_count),
713        ] {
714            push_nonzero_violation(&mut violations, name, value);
715        }
716        if inventory.pre_weld_vertex_count != inventory.post_weld_vertex_count {
717            violations.push(format!(
718                "weld_vertex_count={}!=post:{}",
719                inventory.pre_weld_vertex_count, inventory.post_weld_vertex_count
720            ));
721        }
722    }
723    if !violations.is_empty() {
724        return Err(format_rest_bind_violations(
725            "capability inventory",
726            &violations,
727        ));
728    }
729
730    // Reuse the complete conservative projection for every counter and
731    // source-domain fact. These flags are discharged by the private GLB
732    // staging/proof boundary: raw FBX members are not preserved, while the
733    // already-validated texture-linkage aggregate, custom properties, and
734    // external locator spellings cannot carry scale-bearing state into the
735    // normalized document. Every other fact continues to gate the operation.
736    let mut facts = capability_facts(inventory);
737    facts.unknown_source_members_present = false;
738    facts.unregistered_extensions_present = false;
739    facts.unsafe_accessor_layout_present = false;
740    facts.extras_present = false;
741    facts.external_resources_present = false;
742    if captured_source.is_some() {
743        facts.non_triangle_primitives_present = false;
744        facts.unsupported_vertex_attributes_present = false;
745        facts.secondary_skin_influences_present = false;
746    }
747    if !facts.is_supported_for(
748        animsmith_core::scale::ScaleOperation::RestBindUniformScale {
749            source_skin_index: 0,
750            source_root_node_index: 0,
751            expected_factor: 1.0,
752        },
753    ) {
754        return Err(format_rest_bind_violations(
755            "projected capability",
756            &scale_capability_violations(&facts),
757        ));
758    }
759    Ok(facts)
760}
761
762/// Project the narrow FBX rest/bind subset from one captured source.
763///
764/// Shared construct/resource coverage is checked before the older
765/// operation-specific inventory. This prevents a truncated positive-only raw
766/// projection from being treated as proof of absence.
767///
768/// # Errors
769///
770/// Returns a stable refusal naming each incomplete shared-raw coverage domain,
771/// unsupported construct row, semantic status, or inventory counter that
772/// prevents proof of the selected domain.
773pub fn rest_bind_capability_facts_for_source(
774    source: &FbxScaleSource,
775) -> Result<ScaleCapabilityFacts, String> {
776    let source_facts = source.source_facts();
777    let source_counts = source.rest_bind_construct_counts;
778    let mut violations = Vec::new();
779    for (domain, state) in [
780        ("constructs", source_facts.constructs().coverage().state()),
781        ("resources", source_facts.resources().coverage().state()),
782    ] {
783        if state != SourceSetCoverageStateV1::Complete {
784            violations.push(format!(
785                "raw_source.{domain}.coverage={}",
786                source_set_coverage_state_name(state)
787            ));
788        }
789    }
790    let mut saw_custom_properties = false;
791    let mut saw_unmodeled_elements = false;
792    for row in source_facts.constructs().rows() {
793        match row.kind() {
794            SourceConstructKindV1::CustomProperty
795                if row.name().as_str() == "fbx:user-defined-properties" =>
796            {
797                saw_custom_properties = true;
798                if row.count()
799                    != u64::try_from(source_counts.user_defined_property_count).unwrap_or(u64::MAX)
800                {
801                    violations.push(format!(
802                        "raw_source.construct=custom_property({}; count={})!=source:{}",
803                        row.name().as_str(),
804                        row.count(),
805                        source_counts.user_defined_property_count
806                    ));
807                }
808            }
809            SourceConstructKindV1::CustomProperty => violations.push(format!(
810                "raw_source.construct=custom_property({}; count={})",
811                row.name().as_str(),
812                row.count()
813            )),
814            SourceConstructKindV1::UnknownElement
815                if row.name().as_str() == "fbx:unmodeled-elements" =>
816            {
817                saw_unmodeled_elements = true;
818                let total_count = u64::try_from(source_counts.total_unmodeled_element_count())
819                    .unwrap_or(u64::MAX);
820                if row.count() != total_count {
821                    violations.push(format!(
822                        "raw_source.construct=unknown_element({}; count={})!=source:{}",
823                        row.name().as_str(),
824                        row.count(),
825                        total_count
826                    ));
827                } else if source_counts.unsupported_unmodeled_element_count() > 0 {
828                    violations.push(format!(
829                        "raw_source.construct=unknown_element({}; {})",
830                        row.name().as_str(),
831                        unmodeled_element_details(source_counts)
832                    ));
833                }
834            }
835            SourceConstructKindV1::UnknownElement => violations.push(format!(
836                "raw_source.construct=unknown_element({}; count={})",
837                row.name().as_str(),
838                row.count()
839            )),
840            SourceConstructKindV1::Extension => violations.push(format!(
841                "raw_source.construct=extension({}; count={})",
842                row.name().as_str(),
843                row.count()
844            )),
845        }
846    }
847    if source_counts.user_defined_property_count > 0 && !saw_custom_properties {
848        violations.push(format!(
849            "raw_source.construct=custom_property(fbx:user-defined-properties; count=0)!=source:{}",
850            source_counts.user_defined_property_count
851        ));
852    }
853    if source_counts.total_unmodeled_element_count() > 0 && !saw_unmodeled_elements {
854        violations.push(format!(
855            "raw_source.construct=unknown_element(fbx:unmodeled-elements; count=0)!=source:{}",
856            source_counts.total_unmodeled_element_count()
857        ));
858    }
859    if !violations.is_empty() {
860        return Err(format_rest_bind_violations("raw-source facts", &violations));
861    }
862
863    let mut facts = join_source_facts(
864        source,
865        rest_bind_capability_facts_with_context(
866            source.inventory(),
867            RestBindCapabilityContext::CapturedSource {
868                counts: &source_counts,
869                scale_invariant_payload_mesh_count: source
870                    .rest_bind_scale_invariant_payload_mesh_count,
871            },
872        )?,
873    );
874    facts.unknown_source_members_present = false;
875    facts.unregistered_extensions_present = false;
876    facts.extras_present = false;
877    facts.external_resources_present = false;
878    if facts.is_supported_for(
879        animsmith_core::scale::ScaleOperation::RestBindUniformScale {
880            source_skin_index: 0,
881            source_root_node_index: 0,
882            expected_factor: 1.0,
883        },
884    ) {
885        Ok(facts)
886    } else {
887        Err(format_rest_bind_violations(
888            "joined capability",
889            &scale_capability_violations(&facts),
890        ))
891    }
892}
893
894fn push_unmodeled_element_violation(
895    violations: &mut Vec<String>,
896    counts: crate::source_facts::RestBindSourceConstructCounts,
897) {
898    if counts.unsupported_unmodeled_element_count() > 0 {
899        violations.push(format!(
900            "unsupported_source_element_count; {}",
901            unmodeled_element_details(counts)
902        ));
903    }
904}
905
906fn unmodeled_element_details(counts: crate::source_facts::RestBindSourceConstructCounts) -> String {
907    let mut details = format!("count={}", counts.unsupported_unmodeled_element_count());
908    for (kind, count) in counts.unsupported_kind_counts() {
909        details.push_str("; ");
910        details.push_str(kind);
911        details.push('=');
912        details.push_str(&count.to_string());
913    }
914    details
915}
916
917fn fbx_scale_domain_status_name(status: FbxScaleDomainStatus) -> &'static str {
918    match status {
919        FbxScaleDomainStatus::Absent => "absent",
920        FbxScaleDomainStatus::Normalized => "normalized",
921        FbxScaleDomainStatus::Baked => "baked",
922        FbxScaleDomainStatus::Derived => "derived",
923        FbxScaleDomainStatus::Rebuilt => "rebuilt",
924        FbxScaleDomainStatus::Unsupported => "unsupported",
925        FbxScaleDomainStatus::Unverifiable => "unverifiable",
926    }
927}
928
929fn source_set_coverage_state_name(state: SourceSetCoverageStateV1) -> &'static str {
930    match state {
931        SourceSetCoverageStateV1::Complete => "complete",
932        SourceSetCoverageStateV1::Partial => "partial",
933        SourceSetCoverageStateV1::Unavailable => "unavailable",
934    }
935}
936
937fn push_nonzero_violation(violations: &mut Vec<String>, name: &'static str, value: usize) {
938    if value > 0 {
939        violations.push(format!("{name}={value}"));
940    }
941}
942
943fn format_rest_bind_violations(authority: &str, violations: &[String]) -> String {
944    format!(
945        "FBX rest/bind {authority} rejected: {}",
946        violations.join("; ")
947    )
948}
949
950fn scale_capability_violations(facts: &ScaleCapabilityFacts) -> Vec<String> {
951    let mut violations = Vec::new();
952    if facts.coverage != ScaleCapabilityCoverage::Complete {
953        violations.push("coverage=unavailable".into());
954    }
955    for (name, present) in [
956        ("morphs_present", facts.morphs_present),
957        ("morph_weights_present", facts.morph_weights_present),
958        ("cameras_present", facts.cameras_present),
959        ("lights_present", facts.lights_present),
960        ("instancing_present", facts.instancing_present),
961        (
962            "unregistered_extensions_present",
963            facts.unregistered_extensions_present,
964        ),
965        ("extras_present", facts.extras_present),
966        (
967            "unknown_source_members_present",
968            facts.unknown_source_members_present,
969        ),
970        (
971            "non_triangle_primitives_present",
972            facts.non_triangle_primitives_present,
973        ),
974        (
975            "unsupported_vertex_attributes_present",
976            facts.unsupported_vertex_attributes_present,
977        ),
978        (
979            "secondary_skin_influences_present",
980            facts.secondary_skin_influences_present,
981        ),
982        (
983            "inverse_bind_issues_present",
984            facts.inverse_bind_issues_present,
985        ),
986        (
987            "unsafe_accessor_layout_present",
988            facts.unsafe_accessor_layout_present,
989        ),
990        (
991            "external_resources_present",
992            facts.external_resources_present,
993        ),
994    ] {
995        if present {
996            violations.push(format!("{name}=true"));
997        }
998    }
999    violations
1000}
1001
1002#[derive(Debug, Default)]
1003pub(crate) struct AssetConversionFacts {
1004    pub(crate) truncated_influence_vertex_count: usize,
1005    pub(crate) discarded_influence_count: usize,
1006    pub(crate) renormalized_influence_vertex_count: usize,
1007    pub(crate) rejected_influence_count: usize,
1008    pub(crate) missing_skin_influence_corner_count: usize,
1009    pub(crate) pre_weld_vertex_count: usize,
1010    pub(crate) post_weld_vertex_count: usize,
1011}
1012
1013#[derive(Debug, Clone, Copy, Default)]
1014pub(crate) struct RestBindMeshPayloadCounts {
1015    pub(crate) unsupported_mesh_count: usize,
1016    pub(crate) scale_invariant_mesh_count: usize,
1017}
1018
1019fn identity(index: usize, element: &ufbx::Element) -> FbxSourceIdentity {
1020    FbxSourceIdentity {
1021        source_index: index,
1022        ufbx_typed_id: element.typed_id,
1023        ufbx_element_id: element.element_id,
1024    }
1025}
1026
1027pub(crate) fn inventory(
1028    scene: &ufbx::Scene,
1029    conversion: &AssetConversionFacts,
1030    construct_counts: crate::source_facts::SourceConstructCounts,
1031) -> (FbxScaleCapabilityInventory, RestBindMeshPayloadCounts) {
1032    let non_triangle_face_count = scene
1033        .meshes
1034        .iter()
1035        .flat_map(|mesh| mesh.faces.iter())
1036        .filter(|face| face.num_indices != 3)
1037        .count();
1038    let triangulated_face_count = scene
1039        .meshes
1040        .iter()
1041        .flat_map(|mesh| mesh.faces.iter())
1042        .filter(|face| face.num_indices > 3)
1043        .count();
1044    let omitted_non_polygon_face_count = scene
1045        .meshes
1046        .iter()
1047        .flat_map(|mesh| mesh.faces.iter())
1048        .filter(|face| face.num_indices < 3)
1049        .count();
1050    let empty_source_meshes = scene
1051        .meshes
1052        .iter()
1053        .enumerate()
1054        .filter(|(_, mesh)| mesh.faces.is_empty())
1055        .map(|(index, mesh)| identity(index, &mesh.element))
1056        .collect::<Vec<_>>();
1057    let empty_mesh_definition_count = empty_source_meshes.len();
1058    let generated_normal_mesh_count = scene
1059        .meshes
1060        .iter()
1061        .filter(|mesh| mesh.generated_normals)
1062        .count();
1063    let missing_normal_mesh_count = scene
1064        .meshes
1065        .iter()
1066        .filter(|mesh| !mesh.vertex_normal.exists)
1067        .count();
1068    let skin_cluster_count = scene
1069        .skin_deformers
1070        .iter()
1071        .map(|skin| skin.clusters.len())
1072        .sum();
1073    let empty_skin_deformer_count = scene
1074        .skin_deformers
1075        .iter()
1076        .filter(|skin| skin.clusters.is_empty())
1077        .count();
1078    let incomplete_bind_cluster_count = scene
1079        .skin_clusters
1080        .iter()
1081        .filter(|cluster| super::project_cluster_bind(cluster).is_none())
1082        .count();
1083    let mut clusters_per_bone = std::collections::BTreeMap::<u32, usize>::new();
1084    for cluster in &scene.skin_clusters {
1085        if let (Some(node), Some(_)) = (&cluster.bone_node, super::project_cluster_bind(cluster)) {
1086            *clusters_per_bone.entry(node.element.typed_id).or_default() += 1;
1087        }
1088    }
1089    let bone_convenience_bind_overwrite_count = clusters_per_bone
1090        .values()
1091        .map(|count| count.saturating_sub(1))
1092        .sum();
1093    let multiple_skin_deformer_mesh_count = scene
1094        .meshes
1095        .iter()
1096        .filter(|mesh| mesh.skin_deformers.len() > 1)
1097        .count();
1098    let dual_quaternion_skin_count = scene
1099        .skin_deformers
1100        .iter()
1101        .filter(|skin| {
1102            skin.num_dq_weights > 0 || !matches!(skin.skinning_method, ufbx::SkinningMethod::Linear)
1103        })
1104        .count();
1105    let mesh_payload_counts = scene
1106        .meshes
1107        .iter()
1108        .map(|mesh| classify_mesh_source_payload(mesh))
1109        .fold(
1110            RestBindMeshPayloadCounts::default(),
1111            |mut counts, classification| {
1112                match classification {
1113                    MeshSourcePayloadClassification::Absent => {}
1114                    MeshSourcePayloadClassification::ScaleInvariantConversion => {
1115                        counts.unsupported_mesh_count += 1;
1116                        counts.scale_invariant_mesh_count += 1;
1117                    }
1118                    MeshSourcePayloadClassification::Unsupported => {
1119                        counts.unsupported_mesh_count += 1;
1120                    }
1121                }
1122                counts
1123            },
1124        );
1125    let unsupported_vertex_payload_mesh_count = mesh_payload_counts.unsupported_mesh_count;
1126    let shared_mesh_definition_count = scene
1127        .meshes
1128        .iter()
1129        .filter(|mesh| mesh.element.instances.len() > 1)
1130        .count();
1131    let uninstanced_source_meshes = scene
1132        .meshes
1133        .iter()
1134        .enumerate()
1135        .filter(|(_, mesh)| mesh.element.instances.is_empty())
1136        .map(|(index, mesh)| identity(index, &mesh.element))
1137        .collect::<Vec<_>>();
1138    let uninstanced_mesh_definition_count = uninstanced_source_meshes.len();
1139    let user_defined_property_count = construct_counts.rest_bind.user_defined_property_count;
1140    let unsupported_source_element_count =
1141        construct_counts.rest_bind.total_unmodeled_element_count();
1142    let external_resource_count = scene
1143        .textures
1144        .iter()
1145        .filter(|texture| texture.content.is_empty() && texture.has_file)
1146        .count()
1147        + scene
1148            .videos
1149            .iter()
1150            .filter(|video| {
1151                video.content.is_empty()
1152                    && (!video.filename.is_empty()
1153                        || !video.relative_filename.is_empty()
1154                        || !video.absolute_filename.is_empty())
1155            })
1156            .count();
1157    let compensated_inherit_node_count = scene
1158        .nodes
1159        .iter()
1160        .filter(|node| {
1161            node.original_inherit_mode != node.inherit_mode
1162                || node.is_scale_helper
1163                || node.is_scale_compensate_parent
1164        })
1165        .count();
1166
1167    let stackless_animation_present = scene.anim_stacks.is_empty()
1168        && (!scene.anim_layers.is_empty()
1169            || !scene.anim_values.is_empty()
1170            || !scene.anim_curves.is_empty());
1171    let animation = if !scene.anim_stacks.is_empty() {
1172        FbxScaleDomainStatus::Baked
1173    } else if stackless_animation_present {
1174        // No take was available to bake, but authored curve/value/layer rows
1175        // were parsed and discarded by normalized clip extraction.
1176        FbxScaleDomainStatus::Unsupported
1177    } else {
1178        FbxScaleDomainStatus::Absent
1179    };
1180    let domains = FbxScaleDomainInventory {
1181        rest_hierarchy: FbxScaleDomainStatus::Normalized,
1182        translation_animation: animation,
1183        rotation_and_scale_animation: animation,
1184        root_motion_and_velocity: match animation {
1185            FbxScaleDomainStatus::Baked => FbxScaleDomainStatus::Derived,
1186            status => status,
1187        },
1188        base_mesh_geometry: if scene.meshes.is_empty() {
1189            FbxScaleDomainStatus::Absent
1190        } else if uninstanced_mesh_definition_count > 0
1191            || omitted_non_polygon_face_count > 0
1192            || empty_mesh_definition_count > 0
1193        {
1194            FbxScaleDomainStatus::Unsupported
1195        } else {
1196            FbxScaleDomainStatus::Rebuilt
1197        },
1198        morphs: if scene.blend_deformers.is_empty()
1199            && scene.blend_channels.is_empty()
1200            && scene.blend_shapes.is_empty()
1201        {
1202            FbxScaleDomainStatus::Absent
1203        } else {
1204            FbxScaleDomainStatus::Unsupported
1205        },
1206        skin_binds: if scene.skin_deformers.is_empty() {
1207            FbxScaleDomainStatus::Absent
1208        } else if incomplete_bind_cluster_count > 0 || empty_skin_deformer_count > 0 {
1209            FbxScaleDomainStatus::Unsupported
1210        } else {
1211            FbxScaleDomainStatus::Derived
1212        },
1213        cameras_and_lights: if scene.cameras.is_empty() && scene.lights.is_empty() {
1214            FbxScaleDomainStatus::Absent
1215        } else {
1216            FbxScaleDomainStatus::Unsupported
1217        },
1218        collision_and_custom_data: if unsupported_source_element_count == 0
1219            && user_defined_property_count == 0
1220        {
1221            FbxScaleDomainStatus::Absent
1222        } else {
1223            FbxScaleDomainStatus::Unsupported
1224        },
1225        other_vertex_and_source_data: if unsupported_vertex_payload_mesh_count > 0
1226            || uninstanced_mesh_definition_count > 0
1227            || omitted_non_polygon_face_count > 0
1228            || empty_mesh_definition_count > 0
1229            || multiple_skin_deformer_mesh_count > 0
1230            || dual_quaternion_skin_count > 0
1231            || conversion.truncated_influence_vertex_count > 0
1232            || conversion.missing_skin_influence_corner_count > 0
1233            || conversion.rejected_influence_count > 0
1234            || !scene.blend_deformers.is_empty()
1235            || !scene.blend_channels.is_empty()
1236            || !scene.blend_shapes.is_empty()
1237            || !scene.cache_deformers.is_empty()
1238            || !scene.cache_files.is_empty()
1239        {
1240            FbxScaleDomainStatus::Unsupported
1241        } else if !scene.meshes.is_empty() {
1242            FbxScaleDomainStatus::Rebuilt
1243        } else {
1244            FbxScaleDomainStatus::Absent
1245        },
1246        out_of_contract_node_transforms: FbxScaleDomainStatus::Normalized,
1247        animation_targeting_matrix_nodes: animation,
1248        shared_raw_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1249        unreferenced_accessor_payloads: FbxScaleDomainStatus::Unverifiable,
1250        image_payload_aliases: FbxScaleDomainStatus::Unverifiable,
1251    };
1252
1253    let inventory = FbxScaleCapabilityInventory {
1254        domains,
1255        coordinate_normalization: FbxCoordinateNormalization {
1256            original_up_axis: scene.settings.original_axis_up.into(),
1257            original_unit_meters: scene.settings.original_unit_meters,
1258            target_right_handed_y_up: true,
1259            target_unit_meters: 1.0,
1260            adjust_transforms: matches!(
1261                scene.metadata.space_conversion,
1262                ufbx::SpaceConversion::AdjustTransforms
1263            ),
1264        },
1265        animation_takes_baked: true,
1266        authored_curve_keys_preserved: false,
1267        animation_take_count: scene.anim_stacks.len(),
1268        source_animation_curve_count: scene.anim_curves.len(),
1269        generated_geometry_helper_node_count: scene
1270            .nodes
1271            .iter()
1272            .filter(|node| node.is_geometry_transform_helper)
1273            .count(),
1274        generated_scale_helper_node_count: scene
1275            .nodes
1276            .iter()
1277            .filter(|node| node.is_scale_helper)
1278            .count(),
1279        inherit_modes_compensated: matches!(
1280            scene.metadata.inherit_mode_handling,
1281            ufbx::InheritModeHandling::Compensate
1282        ),
1283        compensated_inherit_node_count,
1284        generated_normal_mesh_count,
1285        missing_normal_mesh_count,
1286        skin_deformer_count: scene.skin_deformers.len(),
1287        skin_cluster_count,
1288        empty_skin_deformer_count,
1289        bind_matrix_provenance: FbxBindMatrixProvenance::UfbxConvertedClusterMatrices,
1290        incomplete_bind_cluster_count,
1291        bone_convenience_bind_overwrite_count,
1292        identity_bind_defaults_invented: false,
1293        truncated_influence_vertex_count: conversion.truncated_influence_vertex_count,
1294        discarded_influence_count: conversion.discarded_influence_count,
1295        renormalized_influence_vertex_count: conversion.renormalized_influence_vertex_count,
1296        rejected_influence_count: conversion.rejected_influence_count,
1297        missing_skin_influence_corner_count: conversion.missing_skin_influence_corner_count,
1298        non_triangle_face_count,
1299        triangulated_face_count,
1300        omitted_non_polygon_face_count,
1301        empty_mesh_definition_count,
1302        empty_source_meshes,
1303        pre_weld_vertex_count: conversion.pre_weld_vertex_count,
1304        post_weld_vertex_count: conversion.post_weld_vertex_count,
1305        multiple_skin_deformer_mesh_count,
1306        dual_quaternion_skin_count,
1307        blend_deformer_count: scene.blend_deformers.len(),
1308        blend_channel_count: scene.blend_channels.len(),
1309        blend_shape_count: scene.blend_shapes.len(),
1310        cache_deformer_count: scene.cache_deformers.len(),
1311        unsupported_vertex_payload_mesh_count,
1312        camera_count: scene.cameras.len(),
1313        light_count: scene.lights.len(),
1314        shared_mesh_definition_count,
1315        uninstanced_mesh_definition_count,
1316        uninstanced_source_meshes,
1317        user_defined_property_count,
1318        unsupported_source_element_count,
1319        external_resource_count,
1320        source_nodes: scene
1321            .nodes
1322            .iter()
1323            .enumerate()
1324            .map(|(index, node)| identity(index, &node.element))
1325            .collect(),
1326        source_meshes: scene
1327            .meshes
1328            .iter()
1329            .enumerate()
1330            .map(|(index, mesh)| identity(index, &mesh.element))
1331            .collect(),
1332        source_skins: scene
1333            .skin_deformers
1334            .iter()
1335            .enumerate()
1336            .map(|(index, skin)| identity(index, &skin.element))
1337            .collect(),
1338    };
1339    (inventory, mesh_payload_counts)
1340}
1341
1342/// Classify every field in `ufbx::Mesh` at one structural boundary. Omitting
1343/// `..` is deliberate: a ufbx upgrade that adds mesh payload must fail to
1344/// compile until extraction either models it or this predicate refuses it.
1345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1346enum MeshSourcePayloadClassification {
1347    Absent,
1348    ScaleInvariantConversion,
1349    Unsupported,
1350}
1351
1352fn classify_mesh_source_payload(mesh: &ufbx::Mesh) -> MeshSourcePayloadClassification {
1353    let ufbx::Mesh {
1354        element: _,
1355        num_vertices: _,
1356        num_indices: _,
1357        num_faces: _,
1358        num_triangles: _,
1359        num_edges: _,
1360        max_face_triangles: _,
1361        num_empty_faces: _,
1362        num_point_faces: _,
1363        num_line_faces: _,
1364        faces: _,
1365        // Authored face/edge members are not retained by triangle extraction.
1366        face_smoothing,
1367        face_material: _,
1368        face_group,
1369        face_hole,
1370        edges,
1371        edge_smoothing,
1372        edge_crease,
1373        edge_visibility,
1374        vertex_indices: _,
1375        vertices: _,
1376        vertex_first_index: _,
1377        vertex_position: _,
1378        vertex_normal: _,
1379        vertex_uv: _,
1380        vertex_tangent,
1381        vertex_bitangent,
1382        vertex_color,
1383        vertex_crease,
1384        uv_sets,
1385        color_sets,
1386        materials: _,
1387        face_groups,
1388        // Mesh parts and skinned views are parser-derived indexes/results.
1389        material_parts: _,
1390        face_group_parts: _,
1391        material_part_usage_order: _,
1392        skinned_is_local: _,
1393        skinned_position: _,
1394        skinned_normal: _,
1395        // Deformer kinds have dedicated inventory counters.
1396        skin_deformers: _,
1397        blend_deformers: _,
1398        cache_deformers: _,
1399        all_deformers: _,
1400        subdivision_preview_levels,
1401        subdivision_render_levels,
1402        subdivision_display_mode,
1403        subdivision_boundary,
1404        subdivision_uv_boundary,
1405        // Winding conversion and generated-normal state are consumed/counted.
1406        reversed_winding: _,
1407        generated_normals: _,
1408        subdivision_evaluated,
1409        subdivision_result,
1410        from_tessellated_nurbs,
1411    } = mesh;
1412
1413    // These fields are produced by ufbx's explicit subdivision/NURBS
1414    // evaluators rather than by the raw polygon-mesh load used here. Keep
1415    // them outside the patch-release allowlist until a reachable fixture can
1416    // prove their normalized handoff independently.
1417    let unsupported_generated_payload_present =
1418        !matches!(subdivision_uv_boundary, ufbx::SubdivisionBoundary::Default)
1419            || *subdivision_evaluated
1420            || subdivision_result.is_some()
1421            || *from_tessellated_nurbs;
1422    let scale_invariant_conversion_facts_present = !face_smoothing.is_empty()
1423        || !face_group.is_empty()
1424        || !face_hole.is_empty()
1425        || !edges.is_empty()
1426        || !edge_smoothing.is_empty()
1427        || !edge_crease.is_empty()
1428        || !edge_visibility.is_empty()
1429        || vertex_tangent.exists
1430        || vertex_bitangent.exists
1431        || vertex_color.exists
1432        || vertex_crease.exists
1433        || uv_sets.len() > 1
1434        || !color_sets.is_empty()
1435        || !face_groups.is_empty()
1436        || *subdivision_preview_levels > 0
1437        || *subdivision_render_levels > 0
1438        || !matches!(
1439            subdivision_display_mode,
1440            ufbx::SubdivisionDisplayMode::Disabled
1441        )
1442        || !matches!(subdivision_boundary, ufbx::SubdivisionBoundary::Default);
1443
1444    if unsupported_generated_payload_present {
1445        MeshSourcePayloadClassification::Unsupported
1446    } else if scale_invariant_conversion_facts_present {
1447        MeshSourcePayloadClassification::ScaleInvariantConversion
1448    } else {
1449        MeshSourcePayloadClassification::Absent
1450    }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use animsmith_core::{
1457        InputIdentity, RawSourceFactsBuilderV1, SourceConstructFactV1, SourceFactDomainV1,
1458        SourceFormatV1, SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceProvenanceV1,
1459        SourceResourceKindV1, SourceResourceReferenceV1, SourceTextV1,
1460    };
1461    use std::path::PathBuf;
1462
1463    fn captured_with(configure: impl FnOnce(&mut RawSourceFactsBuilderV1)) -> FbxScaleSource {
1464        captured_with_counts(configure, |_| {})
1465    }
1466
1467    fn captured_with_counts(
1468        configure: impl FnOnce(&mut RawSourceFactsBuilderV1),
1469        configure_counts: impl FnOnce(&mut crate::source_facts::RestBindSourceConstructCounts),
1470    ) -> FbxScaleSource {
1471        let fixture =
1472            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/rigged_triangle.fbx");
1473        let baseline = crate::load_scale_source(&fixture).expect("checked-in FBX fixture loads");
1474        let document = baseline.document().clone();
1475        let mut inventory = baseline.inventory().clone();
1476        let mut rest_bind_construct_counts = baseline.rest_bind_construct_counts;
1477        let rest_bind_scale_invariant_payload_mesh_count =
1478            baseline.rest_bind_scale_invariant_payload_mesh_count;
1479        configure_counts(&mut rest_bind_construct_counts);
1480        inventory.user_defined_property_count =
1481            rest_bind_construct_counts.user_defined_property_count;
1482        inventory.unsupported_source_element_count =
1483            rest_bind_construct_counts.total_unmodeled_element_count();
1484        inventory.domains.collision_and_custom_data = if inventory.user_defined_property_count == 0
1485            && inventory.unsupported_source_element_count == 0
1486        {
1487            FbxScaleDomainStatus::Absent
1488        } else {
1489            FbxScaleDomainStatus::Unsupported
1490        };
1491        let identity: InputIdentity = baseline.source_facts().primary_identity().clone();
1492        let mut builder = RawSourceFactsBuilderV1::new(SourceFormatV1::Fbx, identity);
1493        configure(&mut builder);
1494        let source = builder.finish(document).expect("synthetic raw facts bind");
1495        FbxScaleSource {
1496            source,
1497            inventory,
1498            rest_bind_construct_counts,
1499            rest_bind_scale_invariant_payload_mesh_count,
1500        }
1501    }
1502
1503    fn parser_provenance(path: &str) -> SourceProvenanceV1 {
1504        SourceProvenanceV1::parser_projected(
1505            SourceLogicalLocatorV1::fbx_parser_path(path).expect("test parser path is valid"),
1506        )
1507    }
1508
1509    fn complete_captured_source() -> FbxScaleSource {
1510        captured_with(|builder| {
1511            builder.mark_complete(SourceFactDomainV1::Constructs);
1512            builder.mark_complete(SourceFactDomainV1::Resources);
1513        })
1514    }
1515
1516    #[test]
1517    fn source_aware_rest_bind_admits_only_reconciled_scale_invariant_conversion_facts() {
1518        let mut source = complete_captured_source();
1519        source.inventory.domains.other_vertex_and_source_data = FbxScaleDomainStatus::Unsupported;
1520        source.inventory.unsupported_vertex_payload_mesh_count = 1;
1521        source.rest_bind_scale_invariant_payload_mesh_count = 1;
1522        source.inventory.truncated_influence_vertex_count = 1;
1523        source.inventory.discarded_influence_count = 2;
1524        source.inventory.renormalized_influence_vertex_count = 1;
1525        source.inventory.rejected_influence_count = 1;
1526        source.inventory.non_triangle_face_count = 1;
1527        source.inventory.triangulated_face_count = 1;
1528        source.inventory.post_weld_vertex_count = source.inventory.pre_weld_vertex_count - 1;
1529
1530        assert!(
1531            rest_bind_capability_facts(source.inventory()).is_err(),
1532            "a detached public inventory cannot prove these conversions are scale-invariant"
1533        );
1534        let facts = rest_bind_capability_facts_for_source(&source)
1535            .expect("same-parse enumerated conversion facts are admissible");
1536        assert!(!facts.non_triangle_primitives_present);
1537        assert!(!facts.unsupported_vertex_attributes_present);
1538        assert!(!facts.secondary_skin_influences_present);
1539
1540        source.rest_bind_scale_invariant_payload_mesh_count = 0;
1541        assert_eq!(
1542            rest_bind_capability_facts_for_source(&source).unwrap_err(),
1543            concat!(
1544                "FBX rest/bind capability inventory rejected: ",
1545                "unsupported_vertex_payload_mesh_count=1!=scale_invariant_source:0"
1546            ),
1547            "an unclassified payload cannot hide inside the public aggregate"
1548        );
1549
1550        source.rest_bind_scale_invariant_payload_mesh_count = 1;
1551        source.inventory.missing_skin_influence_corner_count = 1;
1552        assert_eq!(
1553            rest_bind_capability_facts_for_source(&source).unwrap_err(),
1554            concat!(
1555                "FBX rest/bind capability inventory rejected: ",
1556                "missing_skin_influence_corner_count=1"
1557            ),
1558            "conversion evidence never overrides missing effective skin coverage"
1559        );
1560    }
1561
1562    #[test]
1563    fn source_aware_rest_bind_rejects_partial_relevant_coverage() {
1564        for (source, expected) in [
1565            (
1566                captured_with(|builder| {
1567                    builder.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1568                    builder.mark_complete(SourceFactDomainV1::Resources);
1569                }),
1570                "FBX rest/bind raw-source facts rejected: raw_source.constructs.coverage=partial",
1571            ),
1572            (
1573                captured_with(|builder| {
1574                    builder.mark_complete(SourceFactDomainV1::Constructs);
1575                    builder.mark_budget_exceeded(SourceFactDomainV1::Resources);
1576                }),
1577                "FBX rest/bind raw-source facts rejected: raw_source.resources.coverage=partial",
1578            ),
1579        ] {
1580            assert_eq!(
1581                rest_bind_capability_facts_for_source(&source).unwrap_err(),
1582                expected,
1583                "partial construct/resource coverage must name the exact raw authority"
1584            );
1585        }
1586    }
1587
1588    #[test]
1589    fn source_aware_rest_bind_distinguishes_irrelevant_and_unsupported_shared_domains() {
1590        for kind in [
1591            SourceConstructKindV1::UnknownElement,
1592            SourceConstructKindV1::Extension,
1593        ] {
1594            let source = captured_with(|builder| {
1595                builder.push_construct(
1596                    SourceConstructFactV1::new(
1597                        0,
1598                        kind,
1599                        SourceTextV1::new("synthetic").expect("bounded test name"),
1600                        false,
1601                        1,
1602                        SourceLoaderDispositionV1::Unsupported,
1603                        parser_provenance("fbx:synthetic/construct"),
1604                    )
1605                    .expect("positive test construct"),
1606                );
1607                builder.mark_complete(SourceFactDomainV1::Constructs);
1608                builder.mark_complete(SourceFactDomainV1::Resources);
1609            });
1610            let error = rest_bind_capability_facts_for_source(&source).unwrap_err();
1611            let expected = match kind {
1612                SourceConstructKindV1::UnknownElement => "unknown_element",
1613                SourceConstructKindV1::Extension => "extension",
1614                SourceConstructKindV1::CustomProperty => unreachable!(),
1615            };
1616            assert_eq!(
1617                error,
1618                format!(
1619                    "FBX rest/bind raw-source facts rejected: raw_source.construct={expected}(synthetic; count=1)"
1620                )
1621            );
1622        }
1623
1624        let custom_and_external = captured_with_counts(
1625            |builder| {
1626                builder.push_construct(
1627                    SourceConstructFactV1::new(
1628                        0,
1629                        SourceConstructKindV1::CustomProperty,
1630                        SourceTextV1::new("fbx:user-defined-properties")
1631                            .expect("bounded test name"),
1632                        false,
1633                        1,
1634                        SourceLoaderDispositionV1::Unsupported,
1635                        parser_provenance("fbx:synthetic/property"),
1636                    )
1637                    .expect("positive custom-property row"),
1638                );
1639                builder.mark_complete(SourceFactDomainV1::Constructs);
1640                builder.push_resource(SourceResourceReferenceV1::new(
1641                    0,
1642                    SourceResourceKindV1::Texture,
1643                    0,
1644                    SourceResourceLocatorV1::classify("texture.png"),
1645                    SourceLoaderDispositionV1::Unknown,
1646                    parser_provenance("fbx:textures/0/filename"),
1647                ));
1648                builder.mark_complete(SourceFactDomainV1::Resources);
1649            },
1650            |counts| counts.user_defined_property_count = 1,
1651        );
1652        let facts = rest_bind_capability_facts_for_source(&custom_and_external)
1653            .expect("custom properties and external images are not scale-bearing");
1654        assert!(!facts.extras_present);
1655        assert!(!facts.external_resources_present);
1656        assert!(facts.is_supported_for(
1657            animsmith_core::scale::ScaleOperation::RestBindUniformScale {
1658                source_skin_index: 0,
1659                source_root_node_index: 1,
1660                expected_factor: 0.01,
1661            }
1662        ));
1663    }
1664}