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