Skip to main content

animsmith_core/scale/
assembly_basis.rs

1//! Versioned semantic basis projected from an accepted scale plan.
2
3use super::{
4    ScaleError, ScaleOperation, ScalePlan, ScaleProjectedRole, ScaleSourceNodeKind,
5    ScaleTolerancePolicy,
6};
7use crate::model::{
8    Document, Property, SourceNodeLocalRest, SourceSkeletonCoverage, TrackValues,
9    validate_document_shape,
10};
11use serde::Serialize;
12use std::collections::{BTreeMap, BTreeSet};
13
14/// Stable semantic version of [`AssemblyScaleBasis`].
15pub const ASSEMBLY_SCALE_BASIS_VERSION: u32 = 1;
16
17/// One named normalized node and its exact authored rest basis.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct AssemblyScaleNamedNode {
20    /// Stable node name used by assembly remapping.
21    pub name: String,
22    /// Parent node name, when any.
23    pub parent: Option<String>,
24    /// Translation component bits.
25    pub translation_bits: [u32; 3],
26    /// Rotation component bits in `[x, y, z, w]` order.
27    pub rotation_bits: [u32; 4],
28    /// Scale component bits.
29    pub scale_bits: [u32; 3],
30}
31
32/// One raw source node, including projected/helper layout.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct AssemblyScaleSourceNode {
35    /// Raw source node identity.
36    pub source_node_index: usize,
37    /// Raw parent identity.
38    pub parent_source_node_index: Option<usize>,
39    /// Authored name, when present.
40    pub name: Option<String>,
41    /// Stable projected/helper role.
42    pub role: String,
43    /// Authored local-rest representation and exact component bits.
44    pub local_rest: AssemblyScaleSourceRest,
45}
46
47/// Authored raw source-node rest representation retained by a basis.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum AssemblyScaleSourceRest {
51    /// Decomposed translation, quaternion, and scale.
52    Trs {
53        /// Translation component bits.
54        translation_bits: [u32; 3],
55        /// Quaternion component bits in `[x, y, z, w]` order.
56        rotation_bits: [u32; 4],
57        /// Scale component bits.
58        scale_bits: [u32; 3],
59    },
60    /// Exact authored column-major local matrix bits.
61    Matrix {
62        /// Matrix component bits.
63        matrix_bits: [u32; 16],
64    },
65}
66
67/// One animation channel target and the plan-owned effective multiplier.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct AssemblyScaleTargetPath {
70    /// Clip position in the input document.
71    pub clip_index: usize,
72    /// Track position inside the clip.
73    pub track_index: usize,
74    /// Named target used by assembly remapping.
75    pub bone: String,
76    /// Stable property name.
77    pub property: &'static str,
78    /// Effective multiplier encoded without float spelling ambiguity.
79    pub factor_bits: u64,
80}
81
82/// Complete versioned semantic basis for one assembly input.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84pub struct AssemblyScaleBasis {
85    /// Basis schema version.
86    pub version: u32,
87    /// Fixed model coordinate convention.
88    pub coordinate_convention: &'static str,
89    /// Tolerance identity used for semantic compatibility.
90    pub tolerance_policy_id: &'static str,
91    /// Selected source skin.
92    pub source_skin_index: usize,
93    /// Selected source root node.
94    pub source_root_node_index: usize,
95    /// Declared factor bits.
96    pub expected_factor_bits: u64,
97    /// Normalized named topology and rest/orientation basis.
98    pub named_nodes: Vec<AssemblyScaleNamedNode>,
99    /// Raw projected/helper topology and local-rest basis.
100    pub source_nodes: Vec<AssemblyScaleSourceNode>,
101    /// Animation target paths and effective factors.
102    pub target_paths: Vec<AssemblyScaleTargetPath>,
103}
104
105/// Versioned semantic basis for a meshless clip whose source has no skin.
106///
107/// Such a clip has animation targets to rebase, but no inverse-bind domain.
108/// This basis deliberately cannot be mistaken for [`AssemblyScaleBasis`].
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct AssemblyScaleSkinlessClipBasis {
111    /// Basis schema version.
112    pub version: u32,
113    /// Fixed model coordinate convention.
114    pub coordinate_convention: &'static str,
115    /// Tolerance identity used for semantic compatibility.
116    pub tolerance_policy_id: &'static str,
117    /// Exact named selector resolved in this input.
118    pub root_node_name: String,
119    /// Format-local source root selected by that name.
120    pub source_root_node_index: usize,
121    /// Declared factor bits inherited from the proved base plan.
122    pub expected_factor_bits: u64,
123    /// Normalized named topology and rest/orientation basis.
124    pub named_nodes: Vec<AssemblyScaleNamedNode>,
125    /// Animation target paths and base-plan-owned effective factors.
126    pub target_paths: Vec<AssemblyScaleTargetPath>,
127}
128
129/// Why two independently supplied assembly inputs do not share one basis.
130#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
131#[error("assembly scale basis mismatch ({reason})")]
132pub struct AssemblyScaleCompatibilityError {
133    /// Stable machine-readable mismatch reason.
134    pub reason: &'static str,
135}
136
137/// Selector mode requested while deriving an assembly compatibility basis.
138///
139/// The request is not itself compatibility identity. The constructor checks
140/// it against the accepted basis and source document before producing the
141/// validated identity consumed by the comparator.
142#[derive(Debug, Clone, Copy)]
143#[non_exhaustive]
144pub enum AssemblyScaleSelectorRequest<'a> {
145    /// Preserve exact format-local source skin and root indices.
146    Indexed,
147    /// Derive identity from one exact normalized root name.
148    Named {
149        /// Exact, case-sensitive root name already selected by the caller.
150        root_node_name: &'a str,
151    },
152}
153
154/// Format-neutral source indices resolved from one exact named assembly root.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct AssemblyScaleResolvedNamedSelector {
157    /// The unique non-empty source skin fully governed by the named root.
158    pub source_skin_index: usize,
159    /// The unique source node projected from the named normalized root.
160    pub source_root_node_index: usize,
161}
162
163/// Why a named assembly scale selector did not resolve exactly once.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
165#[non_exhaustive]
166pub enum AssemblyScaleNamedSelectorResolutionError {
167    /// The exact normalized name matched zero or multiple source nodes.
168    #[error("named assembly scale root resolves to {matches} source nodes; expected exactly one")]
169    RootNotUnique {
170        /// Number of matching projected source nodes.
171        matches: usize,
172    },
173    /// The resolved root fully governed zero or multiple source skins.
174    #[error("named assembly scale root fully governs {matches} source skins; expected exactly one")]
175    SkinNotUnique {
176        /// Number of non-empty fully governed source skins.
177        matches: usize,
178    },
179}
180
181#[derive(Debug, Clone)]
182enum AssemblyScaleSelectorIdentity {
183    Indexed,
184    Named {
185        root_node_name: String,
186        skin_joint_names: Vec<String>,
187    },
188}
189
190/// One assembly basis paired with core-validated selector identity.
191///
192/// Named identity cannot be assembled directly by a caller: it is derived
193/// from the source document's unique root and fully governed skin facts.
194#[derive(Debug, Clone)]
195pub struct AssemblyScaleCompatibilityBasis {
196    basis: AssemblyScaleBasis,
197    selector: AssemblyScaleSelectorIdentity,
198    animation_target_factors: BTreeMap<(String, Property), f64>,
199    affected_node_names: BTreeSet<String>,
200}
201
202fn named_nodes(document: &Document) -> Result<Vec<AssemblyScaleNamedNode>, ScaleError> {
203    let mut names = BTreeSet::new();
204    let mut named_nodes = Vec::with_capacity(document.skeleton.bones.len());
205    for (index, bone) in document.skeleton.bones.iter().enumerate() {
206        if bone.name.is_empty() || !names.insert(bone.name.as_str()) {
207            return Err(ScaleError::PlanDocumentMismatch {
208                reason: "assembly_basis_requires_unique_named_nodes",
209            });
210        }
211        named_nodes.push(AssemblyScaleNamedNode {
212            name: bone.name.clone(),
213            parent: bone
214                .parent
215                .and_then(|parent| document.skeleton.bones.get(parent))
216                .map(|parent| parent.name.clone()),
217            translation_bits: bone.rest.translation.to_array().map(f32::to_bits),
218            rotation_bits: bone.rest.rotation.to_array().map(f32::to_bits),
219            scale_bits: bone.rest.scale.to_array().map(f32::to_bits),
220        });
221        if bone.parent.is_some_and(|parent| parent >= index) {
222            return Err(ScaleError::PlanDocumentMismatch {
223                reason: "assembly_basis_parent_order",
224            });
225        }
226    }
227    Ok(named_nodes)
228}
229
230fn governed_source_node_indices(
231    parent_by_index: &BTreeMap<usize, Option<usize>>,
232    source_root_node_index: usize,
233) -> BTreeSet<usize> {
234    if !parent_by_index.contains_key(&source_root_node_index) {
235        return BTreeSet::new();
236    }
237
238    let mut children_by_index = BTreeMap::<usize, Vec<usize>>::new();
239    for (&source_node_index, parent_source_node_index) in parent_by_index {
240        if let Some(parent_source_node_index) = parent_source_node_index {
241            children_by_index
242                .entry(*parent_source_node_index)
243                .or_default()
244                .push(source_node_index);
245        }
246    }
247
248    let mut governed = BTreeSet::new();
249    let mut pending = vec![source_root_node_index];
250    while let Some(source_node_index) = pending.pop() {
251        if governed.insert(source_node_index)
252            && let Some(children) = children_by_index.get(&source_node_index)
253        {
254            pending.extend(children.iter().copied());
255        }
256    }
257    governed
258}
259
260fn source_skin_is_fully_governed(
261    governed_source_node_indices: &BTreeSet<usize>,
262    joint_source_node_indices: &[usize],
263) -> bool {
264    !joint_source_node_indices.is_empty()
265        && joint_source_node_indices
266            .iter()
267            .all(|joint| governed_source_node_indices.contains(joint))
268}
269
270/// Resolve one exact normalized root name to its unique fully governed skin.
271///
272/// A skin matches only when it is non-empty and every joint is the resolved
273/// root or one of its descendants in the authoritative source-node hierarchy.
274/// Callers remain responsible for choosing named versus indexed selector mode;
275/// this helper only centralizes the format-neutral named-resolution rule.
276///
277/// # Errors
278///
279/// Returns [`AssemblyScaleNamedSelectorResolutionError::RootNotUnique`] when
280/// the name does not resolve to exactly one projected source node, or
281/// [`AssemblyScaleNamedSelectorResolutionError::SkinNotUnique`] when that node
282/// does not fully govern exactly one non-empty source skin.
283pub fn resolve_assembly_scale_named_selector(
284    document: &Document,
285    root_node_name: &str,
286) -> Result<AssemblyScaleResolvedNamedSelector, AssemblyScaleNamedSelectorResolutionError> {
287    let root_matches = document
288        .assets
289        .source_skeleton
290        .nodes
291        .iter()
292        .filter_map(|node| {
293            node.bone
294                .and_then(|bone| document.skeleton.bones.get(bone))
295                .filter(|bone| bone.name == root_node_name)
296                .map(|_| node.source_node_index)
297        })
298        .collect::<Vec<_>>();
299    let [source_root_node_index] = root_matches.as_slice() else {
300        return Err(AssemblyScaleNamedSelectorResolutionError::RootNotUnique {
301            matches: root_matches.len(),
302        });
303    };
304    let parent_by_index = document
305        .assets
306        .source_skeleton
307        .nodes
308        .iter()
309        .map(|node| (node.source_node_index, node.parent_source_node_index))
310        .collect::<BTreeMap<_, _>>();
311    let governed_source_node_indices =
312        governed_source_node_indices(&parent_by_index, *source_root_node_index);
313    let skin_matches = document
314        .assets
315        .source_skeleton
316        .skins
317        .iter()
318        .filter(|skin| {
319            source_skin_is_fully_governed(
320                &governed_source_node_indices,
321                &skin.joint_source_node_indices,
322            )
323        })
324        .collect::<Vec<_>>();
325    let [skin] = skin_matches.as_slice() else {
326        return Err(AssemblyScaleNamedSelectorResolutionError::SkinNotUnique {
327            matches: skin_matches.len(),
328        });
329    };
330    Ok(AssemblyScaleResolvedNamedSelector {
331        source_skin_index: skin.source_skin_index,
332        source_root_node_index: *source_root_node_index,
333    })
334}
335
336impl AssemblyScaleCompatibilityBasis {
337    /// The immutable evidence basis governed by this validated selector.
338    #[must_use]
339    pub fn basis(&self) -> &AssemblyScaleBasis {
340        &self.basis
341    }
342}
343
344/// Project an accepted rest/bind plan into the versioned assembly basis.
345///
346/// # Errors
347///
348/// Returns the plan's validation error, a selector-operation mismatch, or a
349/// duplicate/empty named-node error that would make name remapping ambiguous.
350pub fn assembly_scale_basis(
351    document: &Document,
352    plan: &ScalePlan,
353) -> Result<AssemblyScaleBasis, ScaleError> {
354    plan.validate_document_inventory(document)?;
355    let ScaleOperation::RestBindUniformScale {
356        source_skin_index,
357        source_root_node_index,
358        expected_factor,
359    } = plan.operation()
360    else {
361        return Err(ScaleError::PlanDocumentMismatch {
362            reason: "assembly_basis_requires_rest_bind",
363        });
364    };
365    let named_nodes = named_nodes(document)?;
366    let source_by_index = document
367        .assets
368        .source_skeleton
369        .nodes
370        .iter()
371        .map(|node| (node.source_node_index, node))
372        .collect::<std::collections::BTreeMap<_, _>>();
373    let mut source_nodes = Vec::new();
374    for row in plan.ledger().source_topology() {
375        let source = source_by_index.get(&row.source_node_index()).ok_or(
376            ScaleError::PlanDocumentMismatch {
377                reason: "assembly_basis_source_node_missing",
378            },
379        )?;
380        let local_rest = match source.local_rest {
381            SourceNodeLocalRest::Trs {
382                translation,
383                rotation,
384                scale,
385            } => AssemblyScaleSourceRest::Trs {
386                translation_bits: translation.to_array().map(f32::to_bits),
387                rotation_bits: rotation.to_array().map(f32::to_bits),
388                scale_bits: scale.to_array().map(f32::to_bits),
389            },
390            SourceNodeLocalRest::Matrix(matrix) => AssemblyScaleSourceRest::Matrix {
391                matrix_bits: matrix.to_cols_array().map(f32::to_bits),
392            },
393        };
394        let role = match row.kind() {
395            ScaleSourceNodeKind::Projected { role, .. } => match role {
396                ScaleProjectedRole::Root => "projected-root",
397                ScaleProjectedRole::Joint => "projected-joint",
398                ScaleProjectedRole::TransformOnly => "projected-transform-only",
399            },
400            ScaleSourceNodeKind::Connector => "connector",
401            ScaleSourceNodeKind::OutsideDomain { bone: Some(_) } => "outside-projected",
402            ScaleSourceNodeKind::OutsideDomain { bone: None } => "outside-helper",
403        };
404        source_nodes.push(AssemblyScaleSourceNode {
405            source_node_index: row.source_node_index(),
406            parent_source_node_index: row.parent_source_node_index(),
407            name: source.name.clone(),
408            role: role.to_owned(),
409            local_rest,
410        });
411    }
412    let mut target_paths = Vec::new();
413    for (clip_index, clip) in document.clips.iter().enumerate() {
414        for (track_index, track) in clip.tracks.iter().enumerate() {
415            let bone = document
416                .skeleton
417                .bones
418                .get(track.bone)
419                .ok_or(ScaleError::BoneIndexOutOfRange { index: track.bone })?;
420            target_paths.push(AssemblyScaleTargetPath {
421                clip_index,
422                track_index,
423                bone: bone.name.clone(),
424                property: track.property.as_str(),
425                factor_bits: plan
426                    .animation_target_factor_unchecked(document, track.bone, track.property)?
427                    .to_bits(),
428            });
429        }
430    }
431    Ok(AssemblyScaleBasis {
432        version: ASSEMBLY_SCALE_BASIS_VERSION,
433        coordinate_convention: "right-handed-y-up-metres",
434        tolerance_policy_id: plan.tolerance_policy().id,
435        source_skin_index,
436        source_root_node_index,
437        expected_factor_bits: expected_factor.to_bits(),
438        named_nodes,
439        source_nodes,
440        target_paths,
441    })
442}
443
444/// Project one accepted plan and pair it with selector identity derived from
445/// the same document.
446///
447/// # Errors
448///
449/// Returns the plan's validation error, or a plan/document mismatch if a named
450/// request does not resolve to exactly the planned root and skin, or if any
451/// selected skin joint lacks one normalized name.
452pub fn assembly_scale_compatibility_basis(
453    document: &Document,
454    plan: &ScalePlan,
455    selector: AssemblyScaleSelectorRequest<'_>,
456) -> Result<AssemblyScaleCompatibilityBasis, ScaleError> {
457    // Derive both halves from one document/plan pair so callers cannot seal a
458    // selector identity from one document around another document's basis.
459    let basis = assembly_scale_basis(document, plan)?;
460    let selector = match selector {
461        AssemblyScaleSelectorRequest::Indexed => AssemblyScaleSelectorIdentity::Indexed,
462        AssemblyScaleSelectorRequest::Named { root_node_name } => {
463            let resolved = resolve_assembly_scale_named_selector(document, root_node_name)
464                .map_err(|error| ScaleError::PlanDocumentMismatch {
465                    reason: match error {
466                        AssemblyScaleNamedSelectorResolutionError::RootNotUnique { .. } => {
467                            "assembly_basis_named_selector_root_not_unique"
468                        }
469                        AssemblyScaleNamedSelectorResolutionError::SkinNotUnique { .. } => {
470                            "assembly_basis_named_selector_skin_not_unique"
471                        }
472                    },
473                })?;
474            if resolved.source_root_node_index != basis.source_root_node_index {
475                return Err(ScaleError::PlanDocumentMismatch {
476                    reason: "assembly_basis_named_selector_root_disagrees_with_plan",
477                });
478            }
479            let skin = document
480                .assets
481                .source_skeleton
482                .skins
483                .iter()
484                .find(|skin| skin.source_skin_index == resolved.source_skin_index)
485                .ok_or(ScaleError::PlanDocumentMismatch {
486                    reason: "assembly_basis_named_selector_skin_not_unique",
487                })?;
488            if resolved.source_skin_index != basis.source_skin_index {
489                return Err(ScaleError::PlanDocumentMismatch {
490                    reason: "assembly_basis_named_selector_skin_disagrees_with_plan",
491                });
492            }
493            let source_nodes = document
494                .assets
495                .source_skeleton
496                .nodes
497                .iter()
498                .map(|node| (node.source_node_index, node))
499                .collect::<std::collections::BTreeMap<_, _>>();
500            let skin_joint_names = skin
501                .joint_source_node_indices
502                .iter()
503                .map(|source_index| {
504                    source_nodes
505                        .get(source_index)
506                        .and_then(|node| node.bone)
507                        .and_then(|bone| document.skeleton.bones.get(bone))
508                        .map(|bone| bone.name.clone())
509                        .ok_or(ScaleError::PlanDocumentMismatch {
510                            reason: "assembly_basis_named_selector_joint_has_no_name",
511                        })
512                })
513                .collect::<Result<Vec<_>, _>>()?;
514            AssemblyScaleSelectorIdentity::Named {
515                root_node_name: root_node_name.to_owned(),
516                skin_joint_names,
517            }
518        }
519    };
520    let mut animation_target_factors = BTreeMap::new();
521    for (bone, named) in document.skeleton.bones.iter().enumerate() {
522        for property in [Property::Translation, Property::Rotation, Property::Scale] {
523            animation_target_factors.insert(
524                (named.name.clone(), property),
525                plan.animation_target_factor_unchecked(document, bone, property)?,
526            );
527        }
528    }
529    let affected_node_names = plan
530        .affected_nodes()
531        .iter()
532        .filter_map(|&bone| document.skeleton.bones.get(bone))
533        .map(|bone| bone.name.clone())
534        .collect();
535    Ok(AssemblyScaleCompatibilityBasis {
536        basis,
537        selector,
538        animation_target_factors,
539        affected_node_names,
540    })
541}
542
543/// Rebase animation targets in one meshless, skinless clip against a proved
544/// named base plan.
545///
546/// The base plan remains the sole owner of rest/bind selection and proof. The
547/// clip must have the same normalized named topology for the selected skin's
548/// joints, the root, each actual animation target, and their named ancestry.
549/// Each rest component must also match unless every take explicitly supplies
550/// that node/property channel, in which case assembly never consumes the clip's
551/// fallback rest for that component. Targets must remain inside the base plan's
552/// affected closure, but unreferenced base-only descendants are not clip
553/// requirements. The clip must also have exactly one source node for
554/// `root_node_name`, no source skins, and no mesh instances. Only animation
555/// values are changed; cubic-spline tangent triplets are stored in the same
556/// value array and therefore receive the same factor.
557///
558/// # Errors
559///
560/// Returns a stable compatibility mismatch when the input is not the narrow
561/// meshless animation-clip domain or its normalized skeleton/track targets are
562/// malformed.
563pub fn rebase_assembly_scale_skinless_clip(
564    base: &AssemblyScaleCompatibilityBasis,
565    document: &Document,
566    root_node_name: &str,
567) -> Result<(Document, AssemblyScaleSkinlessClipBasis), AssemblyScaleCompatibilityError> {
568    let AssemblyScaleSelectorIdentity::Named {
569        root_node_name: base_root,
570        skin_joint_names,
571    } = &base.selector
572    else {
573        return Err(AssemblyScaleCompatibilityError {
574            reason: "source-selector-mode",
575        });
576    };
577    if base_root != root_node_name {
578        return Err(AssemblyScaleCompatibilityError {
579            reason: "source-name-selector",
580        });
581    }
582    validate_document_shape(document).map_err(|_| AssemblyScaleCompatibilityError {
583        reason: "skinless-clip-invalid-document",
584    })?;
585    if document.assets.source_skeleton.coverage != SourceSkeletonCoverage::Complete {
586        return Err(AssemblyScaleCompatibilityError {
587            reason: "skinless-clip-source-coverage",
588        });
589    }
590    if !document.assets.source_skeleton.skins.is_empty() {
591        return Err(AssemblyScaleCompatibilityError {
592            reason: "skinless-clip-has-source-skins",
593        });
594    }
595    if !document.assets.instances.is_empty() {
596        return Err(AssemblyScaleCompatibilityError {
597            reason: "skinless-clip-has-mesh-instances",
598        });
599    }
600    let root_matches = document
601        .assets
602        .source_skeleton
603        .nodes
604        .iter()
605        .filter_map(|node| {
606            node.bone
607                .and_then(|bone| document.skeleton.bones.get(bone))
608                .filter(|bone| bone.name == root_node_name)
609                .map(|_| node.source_node_index)
610        })
611        .collect::<Vec<_>>();
612    let [source_root_node_index] = root_matches.as_slice() else {
613        return Err(AssemblyScaleCompatibilityError {
614            reason: "source-root-name-not-unique",
615        });
616    };
617    let input_named_nodes = named_nodes(document).map_err(|_| AssemblyScaleCompatibilityError {
618        reason: "named-topology",
619    })?;
620    // A meshless clip must carry the complete selected rig, not geometry-only
621    // descendants that happen to lie in the base plan's broader rewrite
622    // closure. Actual animation targets widen that rig domain when the base
623    // plan also governs them, so animated helpers remain proved rather than
624    // being admitted by a permissive name intersection.
625    let mut relevant_names = skin_joint_names.iter().cloned().collect::<BTreeSet<_>>();
626    relevant_names.insert(base_root.clone());
627    let mut universally_animated_properties = None::<BTreeMap<String, BTreeSet<Property>>>;
628    for clip in &document.clips {
629        let mut clip_properties = BTreeMap::<String, BTreeSet<Property>>::new();
630        for track in &clip.tracks {
631            let bone =
632                document
633                    .skeleton
634                    .bones
635                    .get(track.bone)
636                    .ok_or(AssemblyScaleCompatibilityError {
637                        reason: "animation-target-bone",
638                    })?;
639            if !base
640                .animation_target_factors
641                .contains_key(&(bone.name.clone(), track.property))
642            {
643                return Err(AssemblyScaleCompatibilityError {
644                    reason: "animation-target-bone",
645                });
646            }
647            if !base.affected_node_names.contains(&bone.name) {
648                return Err(AssemblyScaleCompatibilityError {
649                    reason: "animation-target-outside-scale-domain",
650                });
651            }
652            relevant_names.insert(bone.name.clone());
653            clip_properties
654                .entry(bone.name.clone())
655                .or_default()
656                .insert(track.property);
657        }
658        if let Some(properties) = &mut universally_animated_properties {
659            properties.retain(|name, properties| {
660                let Some(clip_properties) = clip_properties.get(name) else {
661                    return false;
662                };
663                properties.retain(|property| clip_properties.contains(property));
664                !properties.is_empty()
665            });
666        } else {
667            universally_animated_properties = Some(clip_properties);
668        }
669    }
670    let universally_animated_properties = universally_animated_properties.unwrap_or_default();
671    loop {
672        let before = relevant_names.len();
673        for node in &base.basis.named_nodes {
674            if relevant_names.contains(&node.name)
675                && let Some(parent) = &node.parent
676            {
677                relevant_names.insert(parent.clone());
678            }
679        }
680        if relevant_names.len() == before {
681            break;
682        }
683    }
684    let base_named_nodes = base
685        .basis
686        .named_nodes
687        .iter()
688        .filter(|node| relevant_names.contains(&node.name))
689        .cloned()
690        .collect::<Vec<_>>();
691    let input_named_nodes = input_named_nodes
692        .into_iter()
693        .filter(|node| relevant_names.contains(&node.name))
694        .collect::<Vec<_>>();
695    let tolerance = ScaleTolerancePolicy::APPENDIX_D_V6;
696    if !same_named_topology(&base_named_nodes, &input_named_nodes) {
697        return Err(AssemblyScaleCompatibilityError {
698            reason: "named-topology",
699        });
700    }
701    if !same_named_rest(
702        &base_named_nodes,
703        &input_named_nodes,
704        &universally_animated_properties,
705        &tolerance,
706    ) {
707        return Err(AssemblyScaleCompatibilityError {
708            reason: "named-rest-basis",
709        });
710    }
711    if !same_named_orientations(
712        &base_named_nodes,
713        &input_named_nodes,
714        &universally_animated_properties,
715        &tolerance,
716    ) {
717        return Err(AssemblyScaleCompatibilityError {
718            reason: "named-orientation",
719        });
720    }
721
722    let mut rebased = document.clone();
723    let mut target_paths = Vec::new();
724    for (clip_index, clip) in rebased.clips.iter_mut().enumerate() {
725        for (track_index, track) in clip.tracks.iter_mut().enumerate() {
726            let bone =
727                document
728                    .skeleton
729                    .bones
730                    .get(track.bone)
731                    .ok_or(AssemblyScaleCompatibilityError {
732                        reason: "animation-target-bone",
733                    })?;
734            let factor = *base
735                .animation_target_factors
736                .get(&(bone.name.clone(), track.property))
737                .ok_or(AssemblyScaleCompatibilityError {
738                    reason: "animation-target-bone",
739                })?;
740            match (&mut track.values, track.property) {
741                (TrackValues::Vec3s(values), Property::Translation) => {
742                    let factor = factor as f32;
743                    for value in values {
744                        *value *= factor;
745                    }
746                }
747                (TrackValues::Vec3s(values), Property::Scale) => {
748                    for value in values {
749                        *value = (value.as_dvec3() * factor).as_vec3();
750                    }
751                }
752                (TrackValues::Quats(_), Property::Rotation) => {}
753                _ => {
754                    return Err(AssemblyScaleCompatibilityError {
755                        reason: "animation-value-kind",
756                    });
757                }
758            }
759            target_paths.push(AssemblyScaleTargetPath {
760                clip_index,
761                track_index,
762                bone: bone.name.clone(),
763                property: track.property.as_str(),
764                factor_bits: factor.to_bits(),
765            });
766        }
767    }
768    validate_document_shape(&rebased).map_err(|_| AssemblyScaleCompatibilityError {
769        reason: "skinless-clip-rebase-invalid-document",
770    })?;
771    Ok((
772        rebased,
773        AssemblyScaleSkinlessClipBasis {
774            version: ASSEMBLY_SCALE_BASIS_VERSION,
775            coordinate_convention: base.basis.coordinate_convention,
776            tolerance_policy_id: base.basis.tolerance_policy_id,
777            root_node_name: root_node_name.to_owned(),
778            source_root_node_index: *source_root_node_index,
779            expected_factor_bits: base.basis.expected_factor_bits,
780            named_nodes: input_named_nodes,
781            target_paths,
782        },
783    ))
784}
785
786/// Require two bases to agree on every static semantic field.
787///
788/// Target paths intentionally remain per-input fingerprint material: clip
789/// files may contain different takes. Each target's named node and factor are
790/// checked against its own accepted plan when the basis is built.
791///
792/// # Errors
793///
794/// Returns the first stable mismatch category.
795pub fn require_assembly_scale_compatibility(
796    base: &AssemblyScaleBasis,
797    input: &AssemblyScaleBasis,
798) -> Result<(), AssemblyScaleCompatibilityError> {
799    require_assembly_scale_compatibility_inner(
800        base,
801        &AssemblyScaleSelectorIdentity::Indexed,
802        input,
803        &AssemblyScaleSelectorIdentity::Indexed,
804    )
805}
806
807/// Require two core-validated compatibility bases to agree.
808///
809/// # Errors
810///
811/// Returns the first stable mismatch category, including selector-mode or
812/// exact named-selector disagreement.
813pub fn require_assembly_scale_compatibility_with_selectors(
814    base: &AssemblyScaleCompatibilityBasis,
815    input: &AssemblyScaleCompatibilityBasis,
816) -> Result<(), AssemblyScaleCompatibilityError> {
817    require_assembly_scale_compatibility_inner(
818        &base.basis,
819        &base.selector,
820        &input.basis,
821        &input.selector,
822    )
823}
824
825fn require_assembly_scale_compatibility_inner(
826    base: &AssemblyScaleBasis,
827    base_selector: &AssemblyScaleSelectorIdentity,
828    input: &AssemblyScaleBasis,
829    input_selector: &AssemblyScaleSelectorIdentity,
830) -> Result<(), AssemblyScaleCompatibilityError> {
831    let tolerance = ScaleTolerancePolicy::APPENDIX_D_V6;
832    let no_rest_waivers = BTreeMap::new();
833    let named_selectors = match (base_selector, input_selector) {
834        (AssemblyScaleSelectorIdentity::Indexed, AssemblyScaleSelectorIdentity::Indexed) => None,
835        (
836            AssemblyScaleSelectorIdentity::Named {
837                root_node_name: base_root,
838                skin_joint_names: base_joints,
839            },
840            AssemblyScaleSelectorIdentity::Named {
841                root_node_name: input_root,
842                skin_joint_names: input_joints,
843            },
844        ) => Some((base_root, base_joints, input_root, input_joints)),
845        _ => {
846            return Err(AssemblyScaleCompatibilityError {
847                reason: "source-selector-mode",
848            });
849        }
850    };
851    let mismatch = if base.version != input.version {
852        Some("basis-version")
853    } else if base.coordinate_convention != input.coordinate_convention {
854        Some("coordinate-convention")
855    } else if base.tolerance_policy_id != input.tolerance_policy_id
856        || base.tolerance_policy_id != tolerance.id
857    {
858        Some("tolerance-policy")
859    } else if named_selectors.is_none() && base.source_skin_index != input.source_skin_index {
860        Some("source-skin-selector")
861    } else if named_selectors.is_none()
862        && base.source_root_node_index != input.source_root_node_index
863    {
864        Some("source-root-selector")
865    } else if named_selectors.is_some_and(|(base_root, base_joints, input_root, input_joints)| {
866        base_root != input_root || base_joints != input_joints
867    }) {
868        Some("source-name-selector")
869    } else if base.expected_factor_bits != input.expected_factor_bits {
870        Some("expected-factor")
871    } else if !same_named_topology(&base.named_nodes, &input.named_nodes) {
872        Some("named-topology")
873    } else if !same_named_rest(
874        &base.named_nodes,
875        &input.named_nodes,
876        &no_rest_waivers,
877        &tolerance,
878    ) {
879        Some("named-rest-basis")
880    } else if !same_named_orientations(
881        &base.named_nodes,
882        &input.named_nodes,
883        &no_rest_waivers,
884        &tolerance,
885    ) {
886        Some("named-orientation")
887    } else if (named_selectors.is_some()
888        && !same_named_source_layout(&base.source_nodes, &input.source_nodes))
889        || (named_selectors.is_none()
890            && !same_source_layout(&base.source_nodes, &input.source_nodes))
891    {
892        Some("source-helper-layout")
893    } else if (named_selectors.is_some()
894        && !same_named_source_rest(&base.source_nodes, &input.source_nodes, &tolerance))
895        || (named_selectors.is_none()
896            && !same_source_rest(&base.source_nodes, &input.source_nodes, &tolerance))
897    {
898        Some("source-helper-rest-basis")
899    } else {
900        None
901    };
902    mismatch.map_or(Ok(()), |reason| {
903        Err(AssemblyScaleCompatibilityError { reason })
904    })
905}
906
907fn same_named_topology(base: &[AssemblyScaleNamedNode], input: &[AssemblyScaleNamedNode]) -> bool {
908    base.len() == input.len()
909        && base
910            .iter()
911            .zip(input)
912            .all(|(base, input)| base.name == input.name && base.parent == input.parent)
913}
914
915fn same_named_rest(
916    base: &[AssemblyScaleNamedNode],
917    input: &[AssemblyScaleNamedNode],
918    universally_animated_properties: &BTreeMap<String, BTreeSet<Property>>,
919    tolerance: &ScaleTolerancePolicy,
920) -> bool {
921    base.iter().zip(input).all(|(base, input)| {
922        (close_f32_bits(&base.translation_bits, &input.translation_bits, tolerance)
923            || named_property_is_universally_animated(
924                universally_animated_properties,
925                &base.name,
926                Property::Translation,
927            ))
928            && (close_f32_bits(&base.scale_bits, &input.scale_bits, tolerance)
929                || named_property_is_universally_animated(
930                    universally_animated_properties,
931                    &base.name,
932                    Property::Scale,
933                ))
934    })
935}
936
937fn same_named_orientations(
938    base: &[AssemblyScaleNamedNode],
939    input: &[AssemblyScaleNamedNode],
940    universally_animated_properties: &BTreeMap<String, BTreeSet<Property>>,
941    tolerance: &ScaleTolerancePolicy,
942) -> bool {
943    base.iter().zip(input).all(|(base, input)| {
944        same_quaternion(&base.rotation_bits, &input.rotation_bits, tolerance)
945            || named_property_is_universally_animated(
946                universally_animated_properties,
947                &base.name,
948                Property::Rotation,
949            )
950    })
951}
952
953fn named_property_is_universally_animated(
954    universally_animated_properties: &BTreeMap<String, BTreeSet<Property>>,
955    name: &str,
956    property: Property,
957) -> bool {
958    universally_animated_properties
959        .get(name)
960        .is_some_and(|properties| properties.contains(&property))
961}
962
963fn same_source_layout(base: &[AssemblyScaleSourceNode], input: &[AssemblyScaleSourceNode]) -> bool {
964    base.len() == input.len()
965        && base.iter().zip(input).all(|(base, input)| {
966            base.source_node_index == input.source_node_index
967                && base.parent_source_node_index == input.parent_source_node_index
968                && base.name == input.name
969                && base.role == input.role
970                && std::mem::discriminant(&base.local_rest)
971                    == std::mem::discriminant(&input.local_rest)
972        })
973}
974
975#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
976struct NamedSourcePath(Vec<(Option<String>, String, bool)>);
977
978fn named_source_paths(nodes: &[AssemblyScaleSourceNode]) -> Option<Vec<NamedSourcePath>> {
979    let by_index = nodes
980        .iter()
981        .enumerate()
982        .map(|(position, node)| (node.source_node_index, position))
983        .collect::<std::collections::BTreeMap<_, _>>();
984    nodes
985        .iter()
986        .map(|node| {
987            let mut path = Vec::new();
988            let mut current = Some(node.source_node_index);
989            for _ in 0..=nodes.len() {
990                let Some(index) = current else {
991                    path.reverse();
992                    return Some(NamedSourcePath(path));
993                };
994                let row = nodes.get(*by_index.get(&index)?)?;
995                path.push((
996                    row.name.clone(),
997                    row.role.clone(),
998                    matches!(&row.local_rest, AssemblyScaleSourceRest::Matrix { .. }),
999                ));
1000                current = row.parent_source_node_index;
1001            }
1002            None
1003        })
1004        .collect()
1005}
1006
1007fn same_named_source_layout(
1008    base: &[AssemblyScaleSourceNode],
1009    input: &[AssemblyScaleSourceNode],
1010) -> bool {
1011    let (Some(mut base), Some(mut input)) = (named_source_paths(base), named_source_paths(input))
1012    else {
1013        return false;
1014    };
1015    base.sort();
1016    input.sort();
1017    base == input
1018}
1019
1020fn same_named_source_rest(
1021    base: &[AssemblyScaleSourceNode],
1022    input: &[AssemblyScaleSourceNode],
1023    tolerance: &ScaleTolerancePolicy,
1024) -> bool {
1025    let (Some(base_paths), Some(input_paths)) =
1026        (named_source_paths(base), named_source_paths(input))
1027    else {
1028        return false;
1029    };
1030    let mut matched = vec![false; input.len()];
1031    base.iter().zip(base_paths).all(|(base_node, base_path)| {
1032        input
1033            .iter()
1034            .zip(&input_paths)
1035            .enumerate()
1036            .find(|(index, (input_node, input_path))| {
1037                !matched[*index]
1038                    && **input_path == base_path
1039                    && same_source_rest_node(base_node, input_node, tolerance)
1040            })
1041            .is_some_and(|(index, _)| {
1042                matched[index] = true;
1043                true
1044            })
1045    })
1046}
1047
1048fn same_source_rest_node(
1049    base: &AssemblyScaleSourceNode,
1050    input: &AssemblyScaleSourceNode,
1051    tolerance: &ScaleTolerancePolicy,
1052) -> bool {
1053    same_source_rest(
1054        std::slice::from_ref(base),
1055        std::slice::from_ref(input),
1056        tolerance,
1057    )
1058}
1059
1060fn same_source_rest(
1061    base: &[AssemblyScaleSourceNode],
1062    input: &[AssemblyScaleSourceNode],
1063    tolerance: &ScaleTolerancePolicy,
1064) -> bool {
1065    base.iter().zip(input).all(
1066        |(base, input)| match (&base.local_rest, &input.local_rest) {
1067            (
1068                AssemblyScaleSourceRest::Trs {
1069                    translation_bits: base_translation,
1070                    rotation_bits: base_rotation,
1071                    scale_bits: base_scale,
1072                },
1073                AssemblyScaleSourceRest::Trs {
1074                    translation_bits: input_translation,
1075                    rotation_bits: input_rotation,
1076                    scale_bits: input_scale,
1077                },
1078            ) => {
1079                close_f32_bits(base_translation, input_translation, tolerance)
1080                    && close_f32_bits(base_scale, input_scale, tolerance)
1081                    && same_quaternion(base_rotation, input_rotation, tolerance)
1082            }
1083            (
1084                AssemblyScaleSourceRest::Matrix {
1085                    matrix_bits: base_matrix,
1086                },
1087                AssemblyScaleSourceRest::Matrix {
1088                    matrix_bits: input_matrix,
1089                },
1090            ) => close_f32_bits(base_matrix, input_matrix, tolerance),
1091            _ => false,
1092        },
1093    )
1094}
1095
1096fn close_f32_bits<const N: usize>(
1097    base: &[u32; N],
1098    input: &[u32; N],
1099    tolerance: &ScaleTolerancePolicy,
1100) -> bool {
1101    base.iter().zip(input).all(|(&base, &input)| {
1102        close_f64(
1103            f32::from_bits(base) as f64,
1104            f32::from_bits(input) as f64,
1105            tolerance,
1106        )
1107    })
1108}
1109
1110fn close_f64(base: f64, input: f64, tolerance: &ScaleTolerancePolicy) -> bool {
1111    base.is_finite()
1112        && input.is_finite()
1113        && (base - input).abs()
1114            <= tolerance.scalar_absolute + tolerance.scalar_relative * base.abs().max(input.abs())
1115}
1116
1117fn same_quaternion(base: &[u32; 4], input: &[u32; 4], tolerance: &ScaleTolerancePolicy) -> bool {
1118    let base = base.map(|bits| f32::from_bits(bits) as f64);
1119    let input = input.map(|bits| f32::from_bits(bits) as f64);
1120    if !base
1121        .iter()
1122        .chain(input.iter())
1123        .all(|value| value.is_finite())
1124    {
1125        return false;
1126    }
1127    let base_norm = base.iter().map(|value| value * value).sum::<f64>().sqrt();
1128    let input_norm = input.iter().map(|value| value * value).sum::<f64>().sqrt();
1129    if base_norm == 0.0 || input_norm == 0.0 {
1130        return false;
1131    }
1132    let dot = base
1133        .iter()
1134        .zip(input)
1135        .map(|(base, input)| base * input)
1136        .sum::<f64>()
1137        / (base_norm * input_norm);
1138    2.0 * dot.abs().clamp(-1.0, 1.0).acos() <= tolerance.rotation_residual_radians
1139}