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::{Document, SourceNodeLocalRest};
8use serde::Serialize;
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Stable semantic version of [`AssemblyScaleBasis`].
12pub const ASSEMBLY_SCALE_BASIS_VERSION: u32 = 1;
13
14/// One named normalized node and its exact authored rest basis.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct AssemblyScaleNamedNode {
17    /// Stable node name used by assembly remapping.
18    pub name: String,
19    /// Parent node name, when any.
20    pub parent: Option<String>,
21    /// Translation component bits.
22    pub translation_bits: [u32; 3],
23    /// Rotation component bits in `[x, y, z, w]` order.
24    pub rotation_bits: [u32; 4],
25    /// Scale component bits.
26    pub scale_bits: [u32; 3],
27}
28
29/// One raw source node, including projected/helper layout.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct AssemblyScaleSourceNode {
32    /// Raw source node identity.
33    pub source_node_index: usize,
34    /// Raw parent identity.
35    pub parent_source_node_index: Option<usize>,
36    /// Authored name, when present.
37    pub name: Option<String>,
38    /// Stable projected/helper role.
39    pub role: String,
40    /// Authored local-rest representation and exact component bits.
41    pub local_rest: AssemblyScaleSourceRest,
42}
43
44/// Authored raw source-node rest representation retained by a basis.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46#[serde(tag = "kind", rename_all = "snake_case")]
47pub enum AssemblyScaleSourceRest {
48    /// Decomposed translation, quaternion, and scale.
49    Trs {
50        /// Translation component bits.
51        translation_bits: [u32; 3],
52        /// Quaternion component bits in `[x, y, z, w]` order.
53        rotation_bits: [u32; 4],
54        /// Scale component bits.
55        scale_bits: [u32; 3],
56    },
57    /// Exact authored column-major local matrix bits.
58    Matrix {
59        /// Matrix component bits.
60        matrix_bits: [u32; 16],
61    },
62}
63
64/// One animation channel target and the plan-owned effective multiplier.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct AssemblyScaleTargetPath {
67    /// Clip position in the input document.
68    pub clip_index: usize,
69    /// Track position inside the clip.
70    pub track_index: usize,
71    /// Named target used by assembly remapping.
72    pub bone: String,
73    /// Stable property name.
74    pub property: &'static str,
75    /// Effective multiplier encoded without float spelling ambiguity.
76    pub factor_bits: u64,
77}
78
79/// Complete versioned semantic basis for one assembly input.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct AssemblyScaleBasis {
82    /// Basis schema version.
83    pub version: u32,
84    /// Fixed model coordinate convention.
85    pub coordinate_convention: &'static str,
86    /// Tolerance identity used for semantic compatibility.
87    pub tolerance_policy_id: &'static str,
88    /// Selected source skin.
89    pub source_skin_index: usize,
90    /// Selected source root node.
91    pub source_root_node_index: usize,
92    /// Declared factor bits.
93    pub expected_factor_bits: u64,
94    /// Normalized named topology and rest/orientation basis.
95    pub named_nodes: Vec<AssemblyScaleNamedNode>,
96    /// Raw projected/helper topology and local-rest basis.
97    pub source_nodes: Vec<AssemblyScaleSourceNode>,
98    /// Animation target paths and effective factors.
99    pub target_paths: Vec<AssemblyScaleTargetPath>,
100}
101
102/// Why two independently supplied assembly inputs do not share one basis.
103#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104#[error("assembly scale basis mismatch ({reason})")]
105pub struct AssemblyScaleCompatibilityError {
106    /// Stable machine-readable mismatch reason.
107    pub reason: &'static str,
108}
109
110/// Selector mode requested while deriving an assembly compatibility basis.
111///
112/// The request is not itself compatibility identity. The constructor checks
113/// it against the accepted basis and source document before producing the
114/// validated identity consumed by the comparator.
115#[derive(Debug, Clone, Copy)]
116#[non_exhaustive]
117pub enum AssemblyScaleSelectorRequest<'a> {
118    /// Preserve exact format-local source skin and root indices.
119    Indexed,
120    /// Derive identity from one exact normalized root name.
121    Named {
122        /// Exact, case-sensitive root name already selected by the caller.
123        root_node_name: &'a str,
124    },
125}
126
127/// Format-neutral source indices resolved from one exact named assembly root.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct AssemblyScaleResolvedNamedSelector {
130    /// The unique non-empty source skin fully governed by the named root.
131    pub source_skin_index: usize,
132    /// The unique source node projected from the named normalized root.
133    pub source_root_node_index: usize,
134}
135
136/// Why a named assembly scale selector did not resolve exactly once.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
138#[non_exhaustive]
139pub enum AssemblyScaleNamedSelectorResolutionError {
140    /// The exact normalized name matched zero or multiple source nodes.
141    #[error("named assembly scale root resolves to {matches} source nodes; expected exactly one")]
142    RootNotUnique {
143        /// Number of matching projected source nodes.
144        matches: usize,
145    },
146    /// The resolved root fully governed zero or multiple source skins.
147    #[error("named assembly scale root fully governs {matches} source skins; expected exactly one")]
148    SkinNotUnique {
149        /// Number of non-empty fully governed source skins.
150        matches: usize,
151    },
152}
153
154#[derive(Debug, Clone)]
155enum AssemblyScaleSelectorIdentity {
156    Indexed,
157    Named {
158        root_node_name: String,
159        skin_joint_names: Vec<String>,
160    },
161}
162
163/// One assembly basis paired with core-validated selector identity.
164///
165/// Named identity cannot be assembled directly by a caller: it is derived
166/// from the source document's unique root and fully governed skin facts.
167#[derive(Debug, Clone)]
168pub struct AssemblyScaleCompatibilityBasis {
169    basis: AssemblyScaleBasis,
170    selector: AssemblyScaleSelectorIdentity,
171}
172
173fn governed_source_node_indices(
174    parent_by_index: &BTreeMap<usize, Option<usize>>,
175    source_root_node_index: usize,
176) -> BTreeSet<usize> {
177    if !parent_by_index.contains_key(&source_root_node_index) {
178        return BTreeSet::new();
179    }
180
181    let mut children_by_index = BTreeMap::<usize, Vec<usize>>::new();
182    for (&source_node_index, parent_source_node_index) in parent_by_index {
183        if let Some(parent_source_node_index) = parent_source_node_index {
184            children_by_index
185                .entry(*parent_source_node_index)
186                .or_default()
187                .push(source_node_index);
188        }
189    }
190
191    let mut governed = BTreeSet::new();
192    let mut pending = vec![source_root_node_index];
193    while let Some(source_node_index) = pending.pop() {
194        if governed.insert(source_node_index)
195            && let Some(children) = children_by_index.get(&source_node_index)
196        {
197            pending.extend(children.iter().copied());
198        }
199    }
200    governed
201}
202
203fn source_skin_is_fully_governed(
204    governed_source_node_indices: &BTreeSet<usize>,
205    joint_source_node_indices: &[usize],
206) -> bool {
207    !joint_source_node_indices.is_empty()
208        && joint_source_node_indices
209            .iter()
210            .all(|joint| governed_source_node_indices.contains(joint))
211}
212
213/// Resolve one exact normalized root name to its unique fully governed skin.
214///
215/// A skin matches only when it is non-empty and every joint is the resolved
216/// root or one of its descendants in the authoritative source-node hierarchy.
217/// Callers remain responsible for choosing named versus indexed selector mode;
218/// this helper only centralizes the format-neutral named-resolution rule.
219///
220/// # Errors
221///
222/// Returns [`AssemblyScaleNamedSelectorResolutionError::RootNotUnique`] when
223/// the name does not resolve to exactly one projected source node, or
224/// [`AssemblyScaleNamedSelectorResolutionError::SkinNotUnique`] when that node
225/// does not fully govern exactly one non-empty source skin.
226pub fn resolve_assembly_scale_named_selector(
227    document: &Document,
228    root_node_name: &str,
229) -> Result<AssemblyScaleResolvedNamedSelector, AssemblyScaleNamedSelectorResolutionError> {
230    let root_matches = document
231        .assets
232        .source_skeleton
233        .nodes
234        .iter()
235        .filter_map(|node| {
236            node.bone
237                .and_then(|bone| document.skeleton.bones.get(bone))
238                .filter(|bone| bone.name == root_node_name)
239                .map(|_| node.source_node_index)
240        })
241        .collect::<Vec<_>>();
242    let [source_root_node_index] = root_matches.as_slice() else {
243        return Err(AssemblyScaleNamedSelectorResolutionError::RootNotUnique {
244            matches: root_matches.len(),
245        });
246    };
247    let parent_by_index = document
248        .assets
249        .source_skeleton
250        .nodes
251        .iter()
252        .map(|node| (node.source_node_index, node.parent_source_node_index))
253        .collect::<BTreeMap<_, _>>();
254    let governed_source_node_indices =
255        governed_source_node_indices(&parent_by_index, *source_root_node_index);
256    let skin_matches = document
257        .assets
258        .source_skeleton
259        .skins
260        .iter()
261        .filter(|skin| {
262            source_skin_is_fully_governed(
263                &governed_source_node_indices,
264                &skin.joint_source_node_indices,
265            )
266        })
267        .collect::<Vec<_>>();
268    let [skin] = skin_matches.as_slice() else {
269        return Err(AssemblyScaleNamedSelectorResolutionError::SkinNotUnique {
270            matches: skin_matches.len(),
271        });
272    };
273    Ok(AssemblyScaleResolvedNamedSelector {
274        source_skin_index: skin.source_skin_index,
275        source_root_node_index: *source_root_node_index,
276    })
277}
278
279impl AssemblyScaleCompatibilityBasis {
280    /// The immutable evidence basis governed by this validated selector.
281    #[must_use]
282    pub fn basis(&self) -> &AssemblyScaleBasis {
283        &self.basis
284    }
285}
286
287/// Project an accepted rest/bind plan into the versioned assembly basis.
288///
289/// # Errors
290///
291/// Returns the plan's validation error, a selector-operation mismatch, or a
292/// duplicate/empty named-node error that would make name remapping ambiguous.
293pub fn assembly_scale_basis(
294    document: &Document,
295    plan: &ScalePlan,
296) -> Result<AssemblyScaleBasis, ScaleError> {
297    plan.validate_document_inventory(document)?;
298    let ScaleOperation::RestBindUniformScale {
299        source_skin_index,
300        source_root_node_index,
301        expected_factor,
302    } = plan.operation()
303    else {
304        return Err(ScaleError::PlanDocumentMismatch {
305            reason: "assembly_basis_requires_rest_bind",
306        });
307    };
308    let mut names = BTreeSet::new();
309    let mut named_nodes = Vec::with_capacity(document.skeleton.bones.len());
310    for (index, bone) in document.skeleton.bones.iter().enumerate() {
311        if bone.name.is_empty() || !names.insert(bone.name.as_str()) {
312            return Err(ScaleError::PlanDocumentMismatch {
313                reason: "assembly_basis_requires_unique_named_nodes",
314            });
315        }
316        named_nodes.push(AssemblyScaleNamedNode {
317            name: bone.name.clone(),
318            parent: bone
319                .parent
320                .and_then(|parent| document.skeleton.bones.get(parent))
321                .map(|parent| parent.name.clone()),
322            translation_bits: bone.rest.translation.to_array().map(f32::to_bits),
323            rotation_bits: bone.rest.rotation.to_array().map(f32::to_bits),
324            scale_bits: bone.rest.scale.to_array().map(f32::to_bits),
325        });
326        if bone.parent.is_some_and(|parent| parent >= index) {
327            return Err(ScaleError::PlanDocumentMismatch {
328                reason: "assembly_basis_parent_order",
329            });
330        }
331    }
332    let source_by_index = document
333        .assets
334        .source_skeleton
335        .nodes
336        .iter()
337        .map(|node| (node.source_node_index, node))
338        .collect::<std::collections::BTreeMap<_, _>>();
339    let mut source_nodes = Vec::new();
340    for row in plan.ledger().source_topology() {
341        let source = source_by_index.get(&row.source_node_index()).ok_or(
342            ScaleError::PlanDocumentMismatch {
343                reason: "assembly_basis_source_node_missing",
344            },
345        )?;
346        let local_rest = match source.local_rest {
347            SourceNodeLocalRest::Trs {
348                translation,
349                rotation,
350                scale,
351            } => AssemblyScaleSourceRest::Trs {
352                translation_bits: translation.to_array().map(f32::to_bits),
353                rotation_bits: rotation.to_array().map(f32::to_bits),
354                scale_bits: scale.to_array().map(f32::to_bits),
355            },
356            SourceNodeLocalRest::Matrix(matrix) => AssemblyScaleSourceRest::Matrix {
357                matrix_bits: matrix.to_cols_array().map(f32::to_bits),
358            },
359        };
360        let role = match row.kind() {
361            ScaleSourceNodeKind::Projected { role, .. } => match role {
362                ScaleProjectedRole::Root => "projected-root",
363                ScaleProjectedRole::Joint => "projected-joint",
364                ScaleProjectedRole::TransformOnly => "projected-transform-only",
365            },
366            ScaleSourceNodeKind::Connector => "connector",
367            ScaleSourceNodeKind::OutsideDomain { bone: Some(_) } => "outside-projected",
368            ScaleSourceNodeKind::OutsideDomain { bone: None } => "outside-helper",
369        };
370        source_nodes.push(AssemblyScaleSourceNode {
371            source_node_index: row.source_node_index(),
372            parent_source_node_index: row.parent_source_node_index(),
373            name: source.name.clone(),
374            role: role.to_owned(),
375            local_rest,
376        });
377    }
378    let mut target_paths = Vec::new();
379    for (clip_index, clip) in document.clips.iter().enumerate() {
380        for (track_index, track) in clip.tracks.iter().enumerate() {
381            let bone = document
382                .skeleton
383                .bones
384                .get(track.bone)
385                .ok_or(ScaleError::BoneIndexOutOfRange { index: track.bone })?;
386            target_paths.push(AssemblyScaleTargetPath {
387                clip_index,
388                track_index,
389                bone: bone.name.clone(),
390                property: track.property.as_str(),
391                factor_bits: plan
392                    .animation_target_factor_unchecked(document, track.bone, track.property)?
393                    .to_bits(),
394            });
395        }
396    }
397    Ok(AssemblyScaleBasis {
398        version: ASSEMBLY_SCALE_BASIS_VERSION,
399        coordinate_convention: "right-handed-y-up-metres",
400        tolerance_policy_id: plan.tolerance_policy().id,
401        source_skin_index,
402        source_root_node_index,
403        expected_factor_bits: expected_factor.to_bits(),
404        named_nodes,
405        source_nodes,
406        target_paths,
407    })
408}
409
410/// Project one accepted plan and pair it with selector identity derived from
411/// the same document.
412///
413/// # Errors
414///
415/// Returns the plan's validation error, or a plan/document mismatch if a named
416/// request does not resolve to exactly the planned root and skin, or if any
417/// selected skin joint lacks one normalized name.
418pub fn assembly_scale_compatibility_basis(
419    document: &Document,
420    plan: &ScalePlan,
421    selector: AssemblyScaleSelectorRequest<'_>,
422) -> Result<AssemblyScaleCompatibilityBasis, ScaleError> {
423    // Derive both halves from one document/plan pair so callers cannot seal a
424    // selector identity from one document around another document's basis.
425    let basis = assembly_scale_basis(document, plan)?;
426    let selector = match selector {
427        AssemblyScaleSelectorRequest::Indexed => AssemblyScaleSelectorIdentity::Indexed,
428        AssemblyScaleSelectorRequest::Named { root_node_name } => {
429            let resolved = resolve_assembly_scale_named_selector(document, root_node_name)
430                .map_err(|error| ScaleError::PlanDocumentMismatch {
431                    reason: match error {
432                        AssemblyScaleNamedSelectorResolutionError::RootNotUnique { .. } => {
433                            "assembly_basis_named_selector_root_not_unique"
434                        }
435                        AssemblyScaleNamedSelectorResolutionError::SkinNotUnique { .. } => {
436                            "assembly_basis_named_selector_skin_not_unique"
437                        }
438                    },
439                })?;
440            if resolved.source_root_node_index != basis.source_root_node_index {
441                return Err(ScaleError::PlanDocumentMismatch {
442                    reason: "assembly_basis_named_selector_root_disagrees_with_plan",
443                });
444            }
445            let skin = document
446                .assets
447                .source_skeleton
448                .skins
449                .iter()
450                .find(|skin| skin.source_skin_index == resolved.source_skin_index)
451                .ok_or(ScaleError::PlanDocumentMismatch {
452                    reason: "assembly_basis_named_selector_skin_not_unique",
453                })?;
454            if resolved.source_skin_index != basis.source_skin_index {
455                return Err(ScaleError::PlanDocumentMismatch {
456                    reason: "assembly_basis_named_selector_skin_disagrees_with_plan",
457                });
458            }
459            let source_nodes = document
460                .assets
461                .source_skeleton
462                .nodes
463                .iter()
464                .map(|node| (node.source_node_index, node))
465                .collect::<std::collections::BTreeMap<_, _>>();
466            let skin_joint_names = skin
467                .joint_source_node_indices
468                .iter()
469                .map(|source_index| {
470                    source_nodes
471                        .get(source_index)
472                        .and_then(|node| node.bone)
473                        .and_then(|bone| document.skeleton.bones.get(bone))
474                        .map(|bone| bone.name.clone())
475                        .ok_or(ScaleError::PlanDocumentMismatch {
476                            reason: "assembly_basis_named_selector_joint_has_no_name",
477                        })
478                })
479                .collect::<Result<Vec<_>, _>>()?;
480            AssemblyScaleSelectorIdentity::Named {
481                root_node_name: root_node_name.to_owned(),
482                skin_joint_names,
483            }
484        }
485    };
486    Ok(AssemblyScaleCompatibilityBasis { basis, selector })
487}
488
489/// Require two bases to agree on every static semantic field.
490///
491/// Target paths intentionally remain per-input fingerprint material: clip
492/// files may contain different takes. Each target's named node and factor are
493/// checked against its own accepted plan when the basis is built.
494///
495/// # Errors
496///
497/// Returns the first stable mismatch category.
498pub fn require_assembly_scale_compatibility(
499    base: &AssemblyScaleBasis,
500    input: &AssemblyScaleBasis,
501) -> Result<(), AssemblyScaleCompatibilityError> {
502    require_assembly_scale_compatibility_inner(
503        base,
504        &AssemblyScaleSelectorIdentity::Indexed,
505        input,
506        &AssemblyScaleSelectorIdentity::Indexed,
507    )
508}
509
510/// Require two core-validated compatibility bases to agree.
511///
512/// # Errors
513///
514/// Returns the first stable mismatch category, including selector-mode or
515/// exact named-selector disagreement.
516pub fn require_assembly_scale_compatibility_with_selectors(
517    base: &AssemblyScaleCompatibilityBasis,
518    input: &AssemblyScaleCompatibilityBasis,
519) -> Result<(), AssemblyScaleCompatibilityError> {
520    require_assembly_scale_compatibility_inner(
521        &base.basis,
522        &base.selector,
523        &input.basis,
524        &input.selector,
525    )
526}
527
528fn require_assembly_scale_compatibility_inner(
529    base: &AssemblyScaleBasis,
530    base_selector: &AssemblyScaleSelectorIdentity,
531    input: &AssemblyScaleBasis,
532    input_selector: &AssemblyScaleSelectorIdentity,
533) -> Result<(), AssemblyScaleCompatibilityError> {
534    let tolerance = ScaleTolerancePolicy::APPENDIX_D_V6;
535    let named_selectors = match (base_selector, input_selector) {
536        (AssemblyScaleSelectorIdentity::Indexed, AssemblyScaleSelectorIdentity::Indexed) => None,
537        (
538            AssemblyScaleSelectorIdentity::Named {
539                root_node_name: base_root,
540                skin_joint_names: base_joints,
541            },
542            AssemblyScaleSelectorIdentity::Named {
543                root_node_name: input_root,
544                skin_joint_names: input_joints,
545            },
546        ) => Some((base_root, base_joints, input_root, input_joints)),
547        _ => {
548            return Err(AssemblyScaleCompatibilityError {
549                reason: "source-selector-mode",
550            });
551        }
552    };
553    let mismatch = if base.version != input.version {
554        Some("basis-version")
555    } else if base.coordinate_convention != input.coordinate_convention {
556        Some("coordinate-convention")
557    } else if base.tolerance_policy_id != input.tolerance_policy_id
558        || base.tolerance_policy_id != tolerance.id
559    {
560        Some("tolerance-policy")
561    } else if named_selectors.is_none() && base.source_skin_index != input.source_skin_index {
562        Some("source-skin-selector")
563    } else if named_selectors.is_none()
564        && base.source_root_node_index != input.source_root_node_index
565    {
566        Some("source-root-selector")
567    } else if named_selectors.is_some_and(|(base_root, base_joints, input_root, input_joints)| {
568        base_root != input_root || base_joints != input_joints
569    }) {
570        Some("source-name-selector")
571    } else if base.expected_factor_bits != input.expected_factor_bits {
572        Some("expected-factor")
573    } else if !same_named_topology(&base.named_nodes, &input.named_nodes) {
574        Some("named-topology")
575    } else if !same_named_rest(&base.named_nodes, &input.named_nodes, &tolerance) {
576        Some("named-rest-basis")
577    } else if !same_named_orientations(&base.named_nodes, &input.named_nodes, &tolerance) {
578        Some("named-orientation")
579    } else if (named_selectors.is_some()
580        && !same_named_source_layout(&base.source_nodes, &input.source_nodes))
581        || (named_selectors.is_none()
582            && !same_source_layout(&base.source_nodes, &input.source_nodes))
583    {
584        Some("source-helper-layout")
585    } else if (named_selectors.is_some()
586        && !same_named_source_rest(&base.source_nodes, &input.source_nodes, &tolerance))
587        || (named_selectors.is_none()
588            && !same_source_rest(&base.source_nodes, &input.source_nodes, &tolerance))
589    {
590        Some("source-helper-rest-basis")
591    } else {
592        None
593    };
594    mismatch.map_or(Ok(()), |reason| {
595        Err(AssemblyScaleCompatibilityError { reason })
596    })
597}
598
599fn same_named_topology(base: &[AssemblyScaleNamedNode], input: &[AssemblyScaleNamedNode]) -> bool {
600    base.len() == input.len()
601        && base
602            .iter()
603            .zip(input)
604            .all(|(base, input)| base.name == input.name && base.parent == input.parent)
605}
606
607fn same_named_rest(
608    base: &[AssemblyScaleNamedNode],
609    input: &[AssemblyScaleNamedNode],
610    tolerance: &ScaleTolerancePolicy,
611) -> bool {
612    base.iter().zip(input).all(|(base, input)| {
613        close_f32_bits(&base.translation_bits, &input.translation_bits, tolerance)
614            && close_f32_bits(&base.scale_bits, &input.scale_bits, tolerance)
615    })
616}
617
618fn same_named_orientations(
619    base: &[AssemblyScaleNamedNode],
620    input: &[AssemblyScaleNamedNode],
621    tolerance: &ScaleTolerancePolicy,
622) -> bool {
623    base.iter()
624        .zip(input)
625        .all(|(base, input)| same_quaternion(&base.rotation_bits, &input.rotation_bits, tolerance))
626}
627
628fn same_source_layout(base: &[AssemblyScaleSourceNode], input: &[AssemblyScaleSourceNode]) -> bool {
629    base.len() == input.len()
630        && base.iter().zip(input).all(|(base, input)| {
631            base.source_node_index == input.source_node_index
632                && base.parent_source_node_index == input.parent_source_node_index
633                && base.name == input.name
634                && base.role == input.role
635                && std::mem::discriminant(&base.local_rest)
636                    == std::mem::discriminant(&input.local_rest)
637        })
638}
639
640#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
641struct NamedSourcePath(Vec<(Option<String>, String, bool)>);
642
643fn named_source_paths(nodes: &[AssemblyScaleSourceNode]) -> Option<Vec<NamedSourcePath>> {
644    let by_index = nodes
645        .iter()
646        .enumerate()
647        .map(|(position, node)| (node.source_node_index, position))
648        .collect::<std::collections::BTreeMap<_, _>>();
649    nodes
650        .iter()
651        .map(|node| {
652            let mut path = Vec::new();
653            let mut current = Some(node.source_node_index);
654            for _ in 0..=nodes.len() {
655                let Some(index) = current else {
656                    path.reverse();
657                    return Some(NamedSourcePath(path));
658                };
659                let row = nodes.get(*by_index.get(&index)?)?;
660                path.push((
661                    row.name.clone(),
662                    row.role.clone(),
663                    matches!(&row.local_rest, AssemblyScaleSourceRest::Matrix { .. }),
664                ));
665                current = row.parent_source_node_index;
666            }
667            None
668        })
669        .collect()
670}
671
672fn same_named_source_layout(
673    base: &[AssemblyScaleSourceNode],
674    input: &[AssemblyScaleSourceNode],
675) -> bool {
676    let (Some(mut base), Some(mut input)) = (named_source_paths(base), named_source_paths(input))
677    else {
678        return false;
679    };
680    base.sort();
681    input.sort();
682    base == input
683}
684
685fn same_named_source_rest(
686    base: &[AssemblyScaleSourceNode],
687    input: &[AssemblyScaleSourceNode],
688    tolerance: &ScaleTolerancePolicy,
689) -> bool {
690    let (Some(base_paths), Some(input_paths)) =
691        (named_source_paths(base), named_source_paths(input))
692    else {
693        return false;
694    };
695    let mut matched = vec![false; input.len()];
696    base.iter().zip(base_paths).all(|(base_node, base_path)| {
697        input
698            .iter()
699            .zip(&input_paths)
700            .enumerate()
701            .find(|(index, (input_node, input_path))| {
702                !matched[*index]
703                    && **input_path == base_path
704                    && same_source_rest_node(base_node, input_node, tolerance)
705            })
706            .is_some_and(|(index, _)| {
707                matched[index] = true;
708                true
709            })
710    })
711}
712
713fn same_source_rest_node(
714    base: &AssemblyScaleSourceNode,
715    input: &AssemblyScaleSourceNode,
716    tolerance: &ScaleTolerancePolicy,
717) -> bool {
718    same_source_rest(
719        std::slice::from_ref(base),
720        std::slice::from_ref(input),
721        tolerance,
722    )
723}
724
725fn same_source_rest(
726    base: &[AssemblyScaleSourceNode],
727    input: &[AssemblyScaleSourceNode],
728    tolerance: &ScaleTolerancePolicy,
729) -> bool {
730    base.iter().zip(input).all(
731        |(base, input)| match (&base.local_rest, &input.local_rest) {
732            (
733                AssemblyScaleSourceRest::Trs {
734                    translation_bits: base_translation,
735                    rotation_bits: base_rotation,
736                    scale_bits: base_scale,
737                },
738                AssemblyScaleSourceRest::Trs {
739                    translation_bits: input_translation,
740                    rotation_bits: input_rotation,
741                    scale_bits: input_scale,
742                },
743            ) => {
744                close_f32_bits(base_translation, input_translation, tolerance)
745                    && close_f32_bits(base_scale, input_scale, tolerance)
746                    && same_quaternion(base_rotation, input_rotation, tolerance)
747            }
748            (
749                AssemblyScaleSourceRest::Matrix {
750                    matrix_bits: base_matrix,
751                },
752                AssemblyScaleSourceRest::Matrix {
753                    matrix_bits: input_matrix,
754                },
755            ) => close_f32_bits(base_matrix, input_matrix, tolerance),
756            _ => false,
757        },
758    )
759}
760
761fn close_f32_bits<const N: usize>(
762    base: &[u32; N],
763    input: &[u32; N],
764    tolerance: &ScaleTolerancePolicy,
765) -> bool {
766    base.iter().zip(input).all(|(&base, &input)| {
767        close_f64(
768            f32::from_bits(base) as f64,
769            f32::from_bits(input) as f64,
770            tolerance,
771        )
772    })
773}
774
775fn close_f64(base: f64, input: f64, tolerance: &ScaleTolerancePolicy) -> bool {
776    base.is_finite()
777        && input.is_finite()
778        && (base - input).abs()
779            <= tolerance.scalar_absolute + tolerance.scalar_relative * base.abs().max(input.abs())
780}
781
782fn same_quaternion(base: &[u32; 4], input: &[u32; 4], tolerance: &ScaleTolerancePolicy) -> bool {
783    let base = base.map(|bits| f32::from_bits(bits) as f64);
784    let input = input.map(|bits| f32::from_bits(bits) as f64);
785    if !base
786        .iter()
787        .chain(input.iter())
788        .all(|value| value.is_finite())
789    {
790        return false;
791    }
792    let base_norm = base.iter().map(|value| value * value).sum::<f64>().sqrt();
793    let input_norm = input.iter().map(|value| value * value).sum::<f64>().sqrt();
794    if base_norm == 0.0 || input_norm == 0.0 {
795        return false;
796    }
797    let dot = base
798        .iter()
799        .zip(input)
800        .map(|(base, input)| base * input)
801        .sum::<f64>()
802        / (base_norm * input_norm);
803    2.0 * dot.abs().clamp(-1.0, 1.0).acos() <= tolerance.rotation_residual_radians
804}