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::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/// Project an accepted rest/bind plan into the versioned assembly basis.
111///
112/// # Errors
113///
114/// Returns the plan's validation error, a selector-operation mismatch, or a
115/// duplicate/empty named-node error that would make name remapping ambiguous.
116pub fn assembly_scale_basis(
117    document: &Document,
118    plan: &ScalePlan,
119) -> Result<AssemblyScaleBasis, ScaleError> {
120    plan.validate_document_inventory(document)?;
121    let ScaleOperation::RestBindUniformScale {
122        source_skin_index,
123        source_root_node_index,
124        expected_factor,
125    } = plan.operation()
126    else {
127        return Err(ScaleError::PlanDocumentMismatch {
128            reason: "assembly_basis_requires_rest_bind",
129        });
130    };
131    let mut names = BTreeSet::new();
132    let mut named_nodes = Vec::with_capacity(document.skeleton.bones.len());
133    for (index, bone) in document.skeleton.bones.iter().enumerate() {
134        if bone.name.is_empty() || !names.insert(bone.name.as_str()) {
135            return Err(ScaleError::PlanDocumentMismatch {
136                reason: "assembly_basis_requires_unique_named_nodes",
137            });
138        }
139        named_nodes.push(AssemblyScaleNamedNode {
140            name: bone.name.clone(),
141            parent: bone
142                .parent
143                .and_then(|parent| document.skeleton.bones.get(parent))
144                .map(|parent| parent.name.clone()),
145            translation_bits: bone.rest.translation.to_array().map(f32::to_bits),
146            rotation_bits: bone.rest.rotation.to_array().map(f32::to_bits),
147            scale_bits: bone.rest.scale.to_array().map(f32::to_bits),
148        });
149        if bone.parent.is_some_and(|parent| parent >= index) {
150            return Err(ScaleError::PlanDocumentMismatch {
151                reason: "assembly_basis_parent_order",
152            });
153        }
154    }
155    let source_by_index = document
156        .assets
157        .source_skeleton
158        .nodes
159        .iter()
160        .map(|node| (node.source_node_index, node))
161        .collect::<std::collections::BTreeMap<_, _>>();
162    let mut source_nodes = Vec::new();
163    for row in plan.ledger().source_topology() {
164        let source = source_by_index.get(&row.source_node_index()).ok_or(
165            ScaleError::PlanDocumentMismatch {
166                reason: "assembly_basis_source_node_missing",
167            },
168        )?;
169        let local_rest = match source.local_rest {
170            SourceNodeLocalRest::Trs {
171                translation,
172                rotation,
173                scale,
174            } => AssemblyScaleSourceRest::Trs {
175                translation_bits: translation.to_array().map(f32::to_bits),
176                rotation_bits: rotation.to_array().map(f32::to_bits),
177                scale_bits: scale.to_array().map(f32::to_bits),
178            },
179            SourceNodeLocalRest::Matrix(matrix) => AssemblyScaleSourceRest::Matrix {
180                matrix_bits: matrix.to_cols_array().map(f32::to_bits),
181            },
182        };
183        let role = match row.kind() {
184            ScaleSourceNodeKind::Projected { role, .. } => match role {
185                ScaleProjectedRole::Root => "projected-root",
186                ScaleProjectedRole::Joint => "projected-joint",
187                ScaleProjectedRole::TransformOnly => "projected-transform-only",
188            },
189            ScaleSourceNodeKind::Connector => "connector",
190            ScaleSourceNodeKind::OutsideDomain { bone: Some(_) } => "outside-projected",
191            ScaleSourceNodeKind::OutsideDomain { bone: None } => "outside-helper",
192        };
193        source_nodes.push(AssemblyScaleSourceNode {
194            source_node_index: row.source_node_index(),
195            parent_source_node_index: row.parent_source_node_index(),
196            name: source.name.clone(),
197            role: role.to_owned(),
198            local_rest,
199        });
200    }
201    let mut target_paths = Vec::new();
202    for (clip_index, clip) in document.clips.iter().enumerate() {
203        for (track_index, track) in clip.tracks.iter().enumerate() {
204            let bone = document
205                .skeleton
206                .bones
207                .get(track.bone)
208                .ok_or(ScaleError::BoneIndexOutOfRange { index: track.bone })?;
209            target_paths.push(AssemblyScaleTargetPath {
210                clip_index,
211                track_index,
212                bone: bone.name.clone(),
213                property: track.property.as_str(),
214                factor_bits: plan
215                    .animation_target_factor_unchecked(document, track.bone, track.property)?
216                    .to_bits(),
217            });
218        }
219    }
220    Ok(AssemblyScaleBasis {
221        version: ASSEMBLY_SCALE_BASIS_VERSION,
222        coordinate_convention: "right-handed-y-up-metres",
223        tolerance_policy_id: plan.tolerance_policy().id,
224        source_skin_index,
225        source_root_node_index,
226        expected_factor_bits: expected_factor.to_bits(),
227        named_nodes,
228        source_nodes,
229        target_paths,
230    })
231}
232
233/// Require two bases to agree on every static semantic field.
234///
235/// Target paths intentionally remain per-input fingerprint material: clip
236/// files may contain different takes. Each target's named node and factor are
237/// checked against its own accepted plan when the basis is built.
238///
239/// # Errors
240///
241/// Returns the first stable mismatch category.
242pub fn require_assembly_scale_compatibility(
243    base: &AssemblyScaleBasis,
244    input: &AssemblyScaleBasis,
245) -> Result<(), AssemblyScaleCompatibilityError> {
246    let tolerance = ScaleTolerancePolicy::APPENDIX_D_V6;
247    let mismatch = if base.version != input.version {
248        Some("basis-version")
249    } else if base.coordinate_convention != input.coordinate_convention {
250        Some("coordinate-convention")
251    } else if base.tolerance_policy_id != input.tolerance_policy_id
252        || base.tolerance_policy_id != tolerance.id
253    {
254        Some("tolerance-policy")
255    } else if base.source_skin_index != input.source_skin_index {
256        Some("source-skin-selector")
257    } else if base.source_root_node_index != input.source_root_node_index {
258        Some("source-root-selector")
259    } else if base.expected_factor_bits != input.expected_factor_bits {
260        Some("expected-factor")
261    } else if !same_named_topology(&base.named_nodes, &input.named_nodes) {
262        Some("named-topology")
263    } else if !same_named_rest(&base.named_nodes, &input.named_nodes, &tolerance) {
264        Some("named-rest-basis")
265    } else if !same_named_orientations(&base.named_nodes, &input.named_nodes, &tolerance) {
266        Some("named-orientation")
267    } else if !same_source_layout(&base.source_nodes, &input.source_nodes) {
268        Some("source-helper-layout")
269    } else if !same_source_rest(&base.source_nodes, &input.source_nodes, &tolerance) {
270        Some("source-helper-rest-basis")
271    } else {
272        None
273    };
274    mismatch.map_or(Ok(()), |reason| {
275        Err(AssemblyScaleCompatibilityError { reason })
276    })
277}
278
279fn same_named_topology(base: &[AssemblyScaleNamedNode], input: &[AssemblyScaleNamedNode]) -> bool {
280    base.len() == input.len()
281        && base
282            .iter()
283            .zip(input)
284            .all(|(base, input)| base.name == input.name && base.parent == input.parent)
285}
286
287fn same_named_rest(
288    base: &[AssemblyScaleNamedNode],
289    input: &[AssemblyScaleNamedNode],
290    tolerance: &ScaleTolerancePolicy,
291) -> bool {
292    base.iter().zip(input).all(|(base, input)| {
293        close_f32_bits(&base.translation_bits, &input.translation_bits, tolerance)
294            && close_f32_bits(&base.scale_bits, &input.scale_bits, tolerance)
295    })
296}
297
298fn same_named_orientations(
299    base: &[AssemblyScaleNamedNode],
300    input: &[AssemblyScaleNamedNode],
301    tolerance: &ScaleTolerancePolicy,
302) -> bool {
303    base.iter()
304        .zip(input)
305        .all(|(base, input)| same_quaternion(&base.rotation_bits, &input.rotation_bits, tolerance))
306}
307
308fn same_source_layout(base: &[AssemblyScaleSourceNode], input: &[AssemblyScaleSourceNode]) -> bool {
309    base.len() == input.len()
310        && base.iter().zip(input).all(|(base, input)| {
311            base.source_node_index == input.source_node_index
312                && base.parent_source_node_index == input.parent_source_node_index
313                && base.name == input.name
314                && base.role == input.role
315                && std::mem::discriminant(&base.local_rest)
316                    == std::mem::discriminant(&input.local_rest)
317        })
318}
319
320fn same_source_rest(
321    base: &[AssemblyScaleSourceNode],
322    input: &[AssemblyScaleSourceNode],
323    tolerance: &ScaleTolerancePolicy,
324) -> bool {
325    base.iter().zip(input).all(
326        |(base, input)| match (&base.local_rest, &input.local_rest) {
327            (
328                AssemblyScaleSourceRest::Trs {
329                    translation_bits: base_translation,
330                    rotation_bits: base_rotation,
331                    scale_bits: base_scale,
332                },
333                AssemblyScaleSourceRest::Trs {
334                    translation_bits: input_translation,
335                    rotation_bits: input_rotation,
336                    scale_bits: input_scale,
337                },
338            ) => {
339                close_f32_bits(base_translation, input_translation, tolerance)
340                    && close_f32_bits(base_scale, input_scale, tolerance)
341                    && same_quaternion(base_rotation, input_rotation, tolerance)
342            }
343            (
344                AssemblyScaleSourceRest::Matrix {
345                    matrix_bits: base_matrix,
346                },
347                AssemblyScaleSourceRest::Matrix {
348                    matrix_bits: input_matrix,
349                },
350            ) => close_f32_bits(base_matrix, input_matrix, tolerance),
351            _ => false,
352        },
353    )
354}
355
356fn close_f32_bits<const N: usize>(
357    base: &[u32; N],
358    input: &[u32; N],
359    tolerance: &ScaleTolerancePolicy,
360) -> bool {
361    base.iter().zip(input).all(|(&base, &input)| {
362        close_f64(
363            f32::from_bits(base) as f64,
364            f32::from_bits(input) as f64,
365            tolerance,
366        )
367    })
368}
369
370fn close_f64(base: f64, input: f64, tolerance: &ScaleTolerancePolicy) -> bool {
371    base.is_finite()
372        && input.is_finite()
373        && (base - input).abs()
374            <= tolerance.scalar_absolute + tolerance.scalar_relative * base.abs().max(input.abs())
375}
376
377fn same_quaternion(base: &[u32; 4], input: &[u32; 4], tolerance: &ScaleTolerancePolicy) -> bool {
378    let base = base.map(|bits| f32::from_bits(bits) as f64);
379    let input = input.map(|bits| f32::from_bits(bits) as f64);
380    if !base
381        .iter()
382        .chain(input.iter())
383        .all(|value| value.is_finite())
384    {
385        return false;
386    }
387    let base_norm = base.iter().map(|value| value * value).sum::<f64>().sqrt();
388    let input_norm = input.iter().map(|value| value * value).sum::<f64>().sqrt();
389    if base_norm == 0.0 || input_norm == 0.0 {
390        return false;
391    }
392    let dot = base
393        .iter()
394        .zip(input)
395        .map(|(base, input)| base * input)
396        .sum::<f64>()
397        / (base_norm * input_norm);
398    2.0 * dot.abs().clamp(-1.0, 1.0).acos() <= tolerance.rotation_residual_radians
399}