Skip to main content

animsmith_core/scale/
reference.rs

1//! Private analytic scale-candidate construction.
2//!
3//! Production format frontends rewrite exact source representations, reload
4//! their emitted artifacts, and wrap those documents with
5//! [`ScaleCandidate::from_document`]. This module owns only the independent
6//! analytic reference writer used by fixtures and calibration. Its numeric
7//! derivations deliberately remain separate from proof-owned expectations.
8
9use super::numeric::{scale_rows, scale_translation_only};
10use super::planning::{check_factor_narrows, validate_plan_document_inventory};
11use super::validation::{
12    instance_bind, local_rest_matrix, source_node_index_map, validate_scale_input,
13};
14use super::{
15    ScaleBoneRestField, ScaleError, ScaleFieldDisposition, ScaleFieldTarget, ScaleOperation,
16    ScalePlan, ScaleRewriteRule, ScaleSourceRestField,
17};
18use crate::model::{
19    BoneId, Document, Property, SourceNodeAsset, SourceNodeLocalRest, SourceSkeletonCoverage,
20    TrackValues, mat4_is_finite,
21};
22use glam::{DMat3, DMat4, DVec3, Mat4, Vec4};
23use std::collections::{BTreeMap, BTreeSet};
24
25/// A candidate document supplied to [`super::prove_scale`].
26///
27/// This type deliberately has no mutation method. Its only public constructor,
28/// [`ScaleCandidate::from_document`], wraps the document a format frontend
29/// reloaded from its exact emitted artifact bytes. Reference and calibration
30/// tests use the non-default `fixtures` feature instead of a production
31/// candidate-building API.
32#[derive(Debug, Clone)]
33#[non_exhaustive]
34pub struct ScaleCandidate {
35    pub(in crate::scale) document: Document,
36}
37
38impl ScaleCandidate {
39    /// Wrap a candidate `document` that a format frontend reloaded from the
40    /// exact artifact bytes it emitted, so it can be handed to
41    /// [`super::prove_scale`].
42    ///
43    /// DESIGN.md Appendix D §D.8 assigns "exact source rewriting" to the
44    /// format frontend, which necessarily produces candidates this module did
45    /// not build: `animsmith_gltf`'s whole-document linear-unit rewrite
46    /// operates on raw glTF JSON and buffer bytes and then reloads the
47    /// artifact. Without this constructor that reloaded [`Document`] could
48    /// never reach [`super::prove_scale`], and the artifact-level proof D.6
49    /// requires would have no in-memory layer to sit on top of.
50    ///
51    /// This constructor asserts nothing about `document`. It does not need
52    /// to: [`super::prove_scale`] already re-validates both documents it is
53    /// given and re-derives every claim from them, so this type carries no
54    /// safety obligation that [`super::prove_scale`] does not independently
55    /// redo.
56    pub fn from_document(document: Document) -> Self {
57        Self { document }
58    }
59
60    /// The candidate document.
61    pub fn document(&self) -> &Document {
62        &self.document
63    }
64
65    /// Consume this candidate, taking ownership of the document.
66    pub fn into_document(self) -> Document {
67        self.document
68    }
69}
70
71#[cfg(doctest)]
72mod candidate_api_contract {
73    /// Compile-fail coverage for the removed production-looking reference
74    /// builder. Each former path stays in its own compilation unit so restoring
75    /// one cannot be masked by the other remaining unavailable.
76    ///
77    /// ```compile_fail
78    /// use animsmith_core::build_scale_candidate;
79    /// ```
80    ///
81    /// ```compile_fail
82    /// use animsmith_core::scale::build_scale_candidate;
83    /// ```
84    ///
85    /// The reloaded-artifact wrapper remains opaque; external callers use its
86    /// explicit constructor rather than a struct literal.
87    ///
88    /// ```compile_fail
89    /// use animsmith_core::{Document, ScaleCandidate};
90    ///
91    /// let _ = ScaleCandidate {
92    ///     document: Document::default(),
93    /// };
94    /// ```
95    ///
96    /// Field privacy is pinned independently from the non-exhaustive struct
97    /// construction boundary above.
98    ///
99    /// ```compile_fail
100    /// use animsmith_core::ScaleCandidate;
101    ///
102    /// fn read_private_document(candidate: ScaleCandidate) {
103    ///     let _ = candidate.document;
104    /// }
105    /// ```
106    struct RemovedPublicBuilder;
107}
108
109/// Build the analytic reference candidate from an accepted [`ScalePlan`],
110/// without mutating `document`.
111///
112/// This is private implementation support. Cross-crate analytic tests opt in
113/// to `animsmith_core::fixtures::build_scale_reference_candidate`; production
114/// format frontends rewrite exact source bytes and use
115/// [`ScaleCandidate::from_document`] on the emitted reload.
116///
117/// `document` need not be numerically identical to the document `plan` was
118/// computed against. Re-deriving its structural planning inventory must,
119/// however, produce the same affected domain and proof inventory. This permits
120/// intentional numerical replay while rejecting a structurally stale plan
121/// before one of its proof walks can omit newly introduced payload.
122///
123/// # Errors
124///
125/// Returns [`ScaleError::PlanDocumentMismatch`] if `document` derives a
126/// different plan inventory, [`ScaleError::BoneIndexOutOfRange`] if an
127/// affected node in `plan` is out of range for `document`,
128/// [`ScaleError::MissingInverseBind`] if an affected skin slot has no
129/// inverse-bind evidence to conjugate, or any document-shape error — checked
130/// on the *candidate* as well as the input, so a build can never hand back a
131/// structurally invalid or non-finite document as `Ok`.
132#[cfg_attr(not(any(test, feature = "fixtures")), allow(dead_code))]
133pub(crate) fn build_scale_candidate(
134    document: &Document,
135    plan: &ScalePlan,
136) -> Result<ScaleCandidate, ScaleError> {
137    validate_plan_document_inventory(document, plan)?;
138    let candidate = match plan.operation() {
139        ScaleOperation::WholeDocumentLinearUnits { .. } => build_whole_document(document, plan)?,
140        ScaleOperation::RestBindUniformScale { .. } => build_rest_bind(document, plan)?,
141    };
142    // The same fail-closed shape check the input had to pass, re-run on the
143    // output: a builder is the one place in this module that writes numbers,
144    // so it must not be the one place that returns unvalidated ones. Without
145    // this, an overflowing or annihilating factor produces a candidate whose
146    // only remaining defence is `prove_scale`, which a caller is free not to
147    // run.
148    validate_scale_input(&candidate)?;
149    Ok(ScaleCandidate {
150        document: candidate,
151    })
152}
153
154pub(in crate::scale) fn build_whole_document(
155    document: &Document,
156    plan: &ScalePlan,
157) -> Result<Document, ScaleError> {
158    let q = check_factor_narrows(plan.common_factor(), plan.common_factor())?;
159    let mut candidate = document.clone();
160    let source_positions: BTreeMap<_, _> = candidate
161        .assets
162        .source_skeleton
163        .nodes
164        .iter()
165        .enumerate()
166        .map(|(position, node)| (node.source_node_index, position))
167        .collect();
168    for row in plan.field_rows() {
169        let ScaleFieldDisposition::Rewrite(rule) = row.disposition else {
170            continue;
171        };
172        if rule != ScaleRewriteRule::WholeDocumentLength {
173            return Err(ScaleError::PlanDocumentMismatch {
174                reason: "invalid_whole_document_rewrite_rule",
175            });
176        }
177        match row.target {
178            ScaleFieldTarget::BoneRest {
179                bone,
180                field: ScaleBoneRestField::Translation,
181            } => candidate.skeleton.bones[bone].rest.translation *= q,
182            ScaleFieldTarget::BoneInverseBind { bone } => {
183                let inverse_bind = candidate.skeleton.bones[bone].inverse_bind.as_mut().ok_or(
184                    ScaleError::PlanDocumentMismatch {
185                        reason: "compiled_bone_inverse_bind_missing",
186                    },
187                )?;
188                *inverse_bind = scale_translation_only(*inverse_bind, q);
189            }
190            ScaleFieldTarget::SourceNodeRest {
191                source_node_index,
192                field: ScaleSourceRestField::Translation | ScaleSourceRestField::MatrixTranslation,
193            } => {
194                let position = *source_positions.get(&source_node_index).ok_or(
195                    ScaleError::PlanDocumentMismatch {
196                        reason: "compiled_source_node_missing",
197                    },
198                )?;
199                let node = &mut candidate.assets.source_skeleton.nodes[position];
200                node.local_rest = match &node.local_rest {
201                    SourceNodeLocalRest::Trs {
202                        translation,
203                        rotation,
204                        scale,
205                    } => SourceNodeLocalRest::Trs {
206                        translation: *translation * q,
207                        rotation: *rotation,
208                        scale: *scale,
209                    },
210                    SourceNodeLocalRest::Matrix(matrix) => {
211                        SourceNodeLocalRest::Matrix(scale_translation_only(*matrix, q))
212                    }
213                };
214            }
215            ScaleFieldTarget::AnimationValues {
216                clip_index,
217                track_index,
218                property: Property::Translation,
219                ..
220            } => {
221                if let TrackValues::Vec3s(values) =
222                    &mut candidate.clips[clip_index].tracks[track_index].values
223                {
224                    for value in values {
225                        *value *= q;
226                    }
227                }
228            }
229            ScaleFieldTarget::MeshPositions {
230                mesh_index,
231                primitive_index,
232            } => {
233                for position in
234                    &mut candidate.assets.meshes[mesh_index].primitives[primitive_index].positions
235                {
236                    *position *= q;
237                }
238            }
239            ScaleFieldTarget::InstanceInverseBind {
240                instance_index,
241                slot,
242                ..
243            } => {
244                let inverse_bind = &mut candidate.assets.instances[instance_index].skin_ibms[slot];
245                *inverse_bind = scale_translation_only(*inverse_bind, q);
246            }
247            _ => {
248                return Err(ScaleError::PlanDocumentMismatch {
249                    reason: "invalid_whole_document_write_target",
250                });
251            }
252        }
253    }
254    // Unavailable source coverage is not identity evidence and therefore has
255    // no public/replay ledger rows. Preserve the released whole-document
256    // behavior for any best-effort raw locals a frontend nevertheless kept:
257    // they still receive the unit conversion, but their identities cannot
258    // make an otherwise compatible plan stale or become proof authority.
259    if candidate.assets.source_skeleton.coverage != SourceSkeletonCoverage::Complete {
260        for node in &mut candidate.assets.source_skeleton.nodes {
261            node.local_rest = match &node.local_rest {
262                SourceNodeLocalRest::Trs {
263                    translation,
264                    rotation,
265                    scale,
266                } => SourceNodeLocalRest::Trs {
267                    translation: *translation * q,
268                    rotation: *rotation,
269                    scale: *scale,
270                },
271                SourceNodeLocalRest::Matrix(matrix) => {
272                    SourceNodeLocalRest::Matrix(scale_translation_only(*matrix, q))
273                }
274            };
275        }
276    }
277    Ok(candidate)
278}
279
280pub(in crate::scale) fn build_rest_bind(
281    document: &Document,
282    plan: &ScalePlan,
283) -> Result<Document, ScaleError> {
284    let affected = plan.affected_set();
285    let s = check_factor_narrows(plan.common_factor(), plan.common_factor())?;
286    let by_source_index = source_node_index_map(document);
287    let mut connector_product_by_tail = BTreeMap::new();
288    let parent_factor = |node: BoneId| -> Result<f32, ScaleError> {
289        let bone = document
290            .skeleton
291            .bones
292            .get(node)
293            .ok_or(ScaleError::BoneIndexOutOfRange { index: node })?;
294        Ok(match bone.parent {
295            Some(parent) if affected.contains(&parent) => s,
296            _ => 1.0,
297        })
298    };
299    let node_factor = |node: BoneId| -> f32 { if affected.contains(&node) { s } else { 1.0 } };
300
301    let mut candidate = document.clone();
302    let source_positions: BTreeMap<_, _> = candidate
303        .assets
304        .source_skeleton
305        .nodes
306        .iter()
307        .enumerate()
308        .map(|(position, node)| (node.source_node_index, position))
309        .collect();
310    let mut materialized_binds: BTreeMap<usize, Vec<Mat4>> = BTreeMap::new();
311    for row in plan.field_rows() {
312        let ScaleFieldDisposition::Rewrite(rule) = row.disposition else {
313            continue;
314        };
315        match (row.target, rule) {
316            (
317                ScaleFieldTarget::BoneRest {
318                    bone,
319                    field: ScaleBoneRestField::Translation,
320                },
321                ScaleRewriteRule::RestBindParentBasis,
322            ) => candidate.skeleton.bones[bone].rest.translation *= parent_factor(bone)?,
323            (
324                ScaleFieldTarget::BoneRest {
325                    bone,
326                    field: ScaleBoneRestField::Scale,
327                },
328                ScaleRewriteRule::RestBindLocalScale,
329            ) => {
330                candidate.skeleton.bones[bone].rest.scale *=
331                    parent_factor(bone)? / node_factor(bone);
332            }
333            (ScaleFieldTarget::BoneInverseBind { bone }, ScaleRewriteRule::RestBindNodeBasis) => {
334                let inverse_bind = candidate.skeleton.bones[bone].inverse_bind.as_mut().ok_or(
335                    ScaleError::PlanDocumentMismatch {
336                        reason: "compiled_bone_inverse_bind_missing",
337                    },
338                )?;
339                *inverse_bind = scale_rows(*inverse_bind, node_factor(bone));
340            }
341            (
342                ScaleFieldTarget::SourceNodeRest {
343                    source_node_index,
344                    field,
345                },
346                ScaleRewriteRule::RestBindSourceLocal { connector_tail },
347            ) => {
348                let position = *source_positions.get(&source_node_index).ok_or(
349                    ScaleError::PlanDocumentMismatch {
350                        reason: "compiled_source_node_missing",
351                    },
352                )?;
353                let bone = candidate.assets.source_skeleton.nodes[position]
354                    .bone
355                    .ok_or(ScaleError::SourceNodeNotNormalized { source_node_index })?;
356                let local_rest = &candidate.assets.source_skeleton.nodes[position].local_rest;
357                let s_parent = parent_factor(bone)?;
358                let s_node = node_factor(bone);
359                let rebased = if let Some(connector_tail) = connector_tail {
360                    rebase_source_local_through_connector_bridge(
361                        local_rest,
362                        connector_tail,
363                        &by_source_index,
364                        &mut connector_product_by_tail,
365                        s_parent,
366                        s_node,
367                        bone,
368                    )?
369                } else {
370                    rebase_source_local_rest(local_rest, s_parent, s_node, None)
371                };
372                candidate.assets.source_skeleton.nodes[position].local_rest =
373                    source_rest_with_rewritten_field(local_rest, &rebased, field)?;
374            }
375            (
376                ScaleFieldTarget::AnimationValues {
377                    clip_index,
378                    track_index,
379                    bone,
380                    ..
381                },
382                rule @ (ScaleRewriteRule::RestBindParentBasis
383                | ScaleRewriteRule::RestBindLocalScale),
384            ) => match rule {
385                ScaleRewriteRule::RestBindParentBasis => {
386                    let s_parent = parent_factor(bone)?;
387                    if let TrackValues::Vec3s(values) =
388                        &mut candidate.clips[clip_index].tracks[track_index].values
389                    {
390                        for value in values.iter_mut() {
391                            *value *= s_parent;
392                        }
393                    }
394                }
395                ScaleRewriteRule::RestBindLocalScale => {
396                    let multiplier =
397                        scale_animation_multiplier(document, bone, &affected, plan.common_factor());
398                    if let TrackValues::Vec3s(values) =
399                        &mut candidate.clips[clip_index].tracks[track_index].values
400                    {
401                        for value in values {
402                            // `Vec3` is the storage boundary. Form the
403                            // product in f64 and narrow each component once.
404                            *value = (value.as_dvec3() * multiplier).as_vec3();
405                        }
406                    }
407                }
408                ScaleRewriteRule::WholeDocumentLength
409                | ScaleRewriteRule::RestBindNodeBasis
410                | ScaleRewriteRule::RestBindSourceLocal { .. } => {
411                    unreachable!("outer pattern limits animation rules")
412                }
413            },
414            (
415                ScaleFieldTarget::InstanceInverseBind {
416                    instance_index,
417                    slot,
418                    joint,
419                },
420                ScaleRewriteRule::RestBindNodeBasis,
421            ) => {
422                let source_instance = &document.assets.instances[instance_index];
423                let binds = materialized_binds
424                    .entry(instance_index)
425                    .or_insert_with(|| Vec::with_capacity(source_instance.skin_joints.len()));
426                if slot != binds.len() {
427                    return Err(ScaleError::PlanDocumentMismatch {
428                        reason: "compiled_inverse_bind_slot_order_mismatch",
429                    });
430                }
431                let before = instance_bind(document, source_instance, slot, joint)?;
432                binds.push(scale_rows(before, node_factor(joint)));
433            }
434            _ => {
435                return Err(ScaleError::PlanDocumentMismatch {
436                    reason: "invalid_rest_bind_write_target",
437                });
438            }
439        }
440    }
441    for (instance_index, binds) in materialized_binds {
442        candidate.assets.instances[instance_index].skin_ibms = binds;
443    }
444    Ok(candidate)
445}
446
447/// Apply the established projected-local rebase, optionally combining its
448/// translation with a widened connector-conjugation offset.
449///
450/// `None` deliberately avoids adding a zero: direct-edge f32 association and
451/// signed-zero behavior are part of the calibrated Appendix D contract. A
452/// bridged translation stays widened until the complete sum is narrowed, so
453/// compensating terms above the f32 range can still produce a finite local.
454fn rebase_source_local_rest(
455    local_rest: &SourceNodeLocalRest,
456    s_parent: f32,
457    s_node: f32,
458    bridge_offset: Option<DVec3>,
459) -> SourceNodeLocalRest {
460    match local_rest {
461        SourceNodeLocalRest::Trs {
462            translation,
463            rotation,
464            scale,
465        } => {
466            let translation = match bridge_offset {
467                Some(offset) => (translation.as_dvec3() * f64::from(s_parent) + offset).as_vec3(),
468                None => *translation * s_parent,
469            };
470            let scale = match bridge_offset {
471                Some(_) => {
472                    let ratio = f64::from(s_parent) / f64::from(s_node);
473                    (scale.as_dvec3() * ratio).as_vec3()
474                }
475                None => *scale * (s_parent / s_node),
476            };
477            SourceNodeLocalRest::Trs {
478                translation,
479                rotation: *rotation,
480                scale,
481            }
482        }
483        SourceNodeLocalRest::Matrix(matrix) => {
484            let rebased = if let Some(offset) = bridge_offset {
485                let ratio = f64::from(s_parent) / f64::from(s_node);
486                let rebase_linear_column = |column: Vec4| {
487                    (column.truncate().as_dvec3() * ratio)
488                        .as_vec3()
489                        .extend(column.w)
490                };
491                let translation =
492                    (matrix.w_axis.truncate().as_dvec3() * f64::from(s_parent) + offset).as_vec3();
493                Mat4::from_cols(
494                    rebase_linear_column(matrix.x_axis),
495                    rebase_linear_column(matrix.y_axis),
496                    rebase_linear_column(matrix.z_axis),
497                    translation.extend(matrix.w_axis.w),
498                )
499            } else {
500                rebase_matrix(*matrix, s_parent, s_node)
501            };
502            SourceNodeLocalRest::Matrix(rebased)
503        }
504    }
505}
506
507/// Merge one compiled raw-source write into its local container.
508///
509/// A [`super::ScaleFieldPlan`] is the builder's write authority. Even when
510/// deriving one field naturally produces a complete local transform, sibling
511/// fields must retain their original bits unless their own row also says
512/// Rewrite.
513fn source_rest_with_rewritten_field(
514    original: &SourceNodeLocalRest,
515    rewritten: &SourceNodeLocalRest,
516    field: ScaleSourceRestField,
517) -> Result<SourceNodeLocalRest, ScaleError> {
518    match (original, rewritten, field) {
519        (
520            SourceNodeLocalRest::Trs {
521                translation: _,
522                rotation,
523                scale,
524            },
525            SourceNodeLocalRest::Trs {
526                translation: rewritten,
527                ..
528            },
529            ScaleSourceRestField::Translation,
530        ) => Ok(SourceNodeLocalRest::Trs {
531            translation: *rewritten,
532            rotation: *rotation,
533            scale: *scale,
534        }),
535        (
536            SourceNodeLocalRest::Trs {
537                translation,
538                rotation,
539                scale: _,
540            },
541            SourceNodeLocalRest::Trs {
542                scale: rewritten, ..
543            },
544            ScaleSourceRestField::Scale,
545        ) => Ok(SourceNodeLocalRest::Trs {
546            translation: *translation,
547            rotation: *rotation,
548            scale: *rewritten,
549        }),
550        (
551            SourceNodeLocalRest::Matrix(original),
552            SourceNodeLocalRest::Matrix(rewritten),
553            ScaleSourceRestField::MatrixLinear,
554        ) => Ok(SourceNodeLocalRest::Matrix(Mat4::from_cols(
555            rewritten.x_axis.truncate().extend(original.x_axis.w),
556            rewritten.y_axis.truncate().extend(original.y_axis.w),
557            rewritten.z_axis.truncate().extend(original.z_axis.w),
558            original.w_axis,
559        ))),
560        (
561            SourceNodeLocalRest::Matrix(original),
562            SourceNodeLocalRest::Matrix(rewritten),
563            ScaleSourceRestField::MatrixTranslation,
564        ) => Ok(SourceNodeLocalRest::Matrix(Mat4::from_cols(
565            original.x_axis,
566            original.y_axis,
567            original.z_axis,
568            rewritten.w_axis.truncate().extend(original.w_axis.w),
569        ))),
570        _ => Err(ScaleError::PlanDocumentMismatch {
571            reason: "source_local_field_variant_mismatch",
572        }),
573    }
574}
575
576/// Move a projected successor's rest/bind correction through an ordered
577/// chain of unchanged, unprojected source transforms.
578///
579/// If `H` is the parent-to-child product of the connector locals and `L` is
580/// the projected successor's authored local, preserving every connector
581/// exactly requires `L' = H^-1 S_parent H L S_node^-1`. A multiplier-only
582/// rewrite of `L` is wrong whenever `H` has a nonzero translation.
583fn rebase_source_local_through_connector_bridge(
584    local_rest: &SourceNodeLocalRest,
585    connector_tail: usize,
586    by_source_index: &BTreeMap<usize, &SourceNodeAsset>,
587    connector_product_by_tail: &mut BTreeMap<usize, DMat4>,
588    s_parent: f32,
589    s_node: f32,
590    bone: BoneId,
591) -> Result<SourceNodeLocalRest, ScaleError> {
592    if s_parent == 1.0 && s_node == 1.0 {
593        return Ok(local_rest.clone());
594    }
595    let connector =
596        memoized_connector_product(connector_tail, by_source_index, connector_product_by_tail)?;
597    let connector_linear_inverse = DMat3::from_cols(
598        connector.x_axis.truncate(),
599        connector.y_axis.truncate(),
600        connector.z_axis.truncate(),
601    )
602    .inverse();
603    let bridge_offset =
604        connector_linear_inverse * (connector.w_axis.truncate() * (f64::from(s_parent) - 1.0));
605    if !connector_linear_inverse.x_axis.is_finite()
606        || !connector_linear_inverse.y_axis.is_finite()
607        || !connector_linear_inverse.z_axis.is_finite()
608        || !bridge_offset.is_finite()
609    {
610        return Err(ScaleError::NonFiniteTransform { node: bone });
611    }
612    // For affine H=[A,t], H^-1*S_parent*H contributes only the translation
613    // A^-1*((s_parent-1)*t) beyond the established direct-edge rewrite. Keep
614    // every complete bridged expression widened through its single model
615    // boundary, while direct projected edges retain their established f32
616    // association and signed-zero behavior.
617    let rebased = rebase_source_local_rest(local_rest, s_parent, s_node, Some(bridge_offset));
618    if !mat4_is_finite(local_rest_matrix(&rebased)) {
619        return Err(ScaleError::NonFiniteTransform { node: bone });
620    }
621    Ok(rebased)
622}
623
624/// Return the ordered connector product from its nearest projected ancestor
625/// through `connector_tail`, caching every traversed prefix once.
626fn memoized_connector_product(
627    connector_tail: usize,
628    by_source_index: &BTreeMap<usize, &SourceNodeAsset>,
629    connector_product_by_tail: &mut BTreeMap<usize, DMat4>,
630) -> Result<DMat4, ScaleError> {
631    let mut pending = Vec::new();
632    let mut visited = BTreeSet::new();
633    let mut cursor = connector_tail;
634    let mut product = loop {
635        if let Some(&cached) = connector_product_by_tail.get(&cursor) {
636            break cached;
637        }
638        if !visited.insert(cursor) {
639            return Err(ScaleError::IncompleteClosure {
640                reason: "cyclic_connector_source_parent_chain",
641            });
642        }
643        let asset = by_source_index
644            .get(&cursor)
645            .ok_or(ScaleError::IncompleteClosure {
646                reason: "dangling_connector_source_node_index",
647            })?;
648        if asset.bone.is_some() {
649            break DMat4::IDENTITY;
650        }
651        pending.push(cursor);
652        cursor = asset
653            .parent_source_node_index
654            .ok_or(ScaleError::IncompleteClosure {
655                reason: "connector_without_projected_ancestor",
656            })?;
657    };
658    while let Some(source) = pending.pop() {
659        let asset = by_source_index
660            .get(&source)
661            .ok_or(ScaleError::IncompleteClosure {
662                reason: "dangling_connector_source_node_index",
663            })?;
664        product *= local_rest_matrix(&asset.local_rest).as_dmat4();
665        connector_product_by_tail.insert(source, product);
666    }
667    connector_product_by_tail
668        .get(&connector_tail)
669        .copied()
670        .ok_or(ScaleError::IncompleteClosure {
671            reason: "empty_connector_bridge",
672        })
673}
674
675/// `L' = scale(s_parent) * L * scale(1 / s_node)`: the rest/bind local
676/// rebase of DESIGN.md Appendix D §D.2, applied to a raw authored matrix
677/// that may carry terms a TRS decomposition cannot represent.
678///
679/// Left-multiplying by a uniform scale scales the output rows (that is
680/// [`scale_rows`]); right-multiplying by `scale(1 / s_node)` scales the three
681/// linear columns in full, translation column untouched.
682fn rebase_matrix(matrix: Mat4, s_parent: f32, s_node: f32) -> Mat4 {
683    let scaled = scale_rows(matrix, s_parent);
684    let inverse_node = 1.0 / s_node;
685    Mat4::from_cols(
686        scaled.x_axis * inverse_node,
687        scaled.y_axis * inverse_node,
688        scaled.z_axis * inverse_node,
689        scaled.w_axis,
690    )
691}
692
693/// The local-scale multiplier which preserves animated pose scale across a
694/// rest/bind basis reparameterization.
695///
696/// The builder changes local rest scale by `s_parent / s_node`. Because an
697/// animation scale *replaces* rather than multiplies the rest scale, an
698/// animated value needs that same multiplier relative to the original value:
699/// the selected closure root is `1 / s`, every affected strict descendant is
700/// `s / s = 1`, and nodes outside the closure remain one.
701fn scale_animation_multiplier(
702    document: &Document,
703    node: BoneId,
704    affected: &BTreeSet<BoneId>,
705    common_factor: f64,
706) -> f64 {
707    if !affected.contains(&node) {
708        return 1.0;
709    }
710    match document
711        .skeleton
712        .bones
713        .get(node)
714        .and_then(|bone| bone.parent)
715    {
716        Some(parent) if affected.contains(&parent) => 1.0,
717        _ => 1.0 / common_factor,
718    }
719}