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                    ..
380                },
381                rule @ (ScaleRewriteRule::RestBindParentBasis
382                | ScaleRewriteRule::RestBindLocalScale),
383            ) => match rule {
384                ScaleRewriteRule::RestBindParentBasis => {
385                    let property = document.clips[clip_index].tracks[track_index].property;
386                    let bone = document.clips[clip_index].tracks[track_index].bone;
387                    let s_parent =
388                        plan.animation_target_factor_unchecked(document, bone, property)? as f32;
389                    if let TrackValues::Vec3s(values) =
390                        &mut candidate.clips[clip_index].tracks[track_index].values
391                    {
392                        for value in values.iter_mut() {
393                            *value *= s_parent;
394                        }
395                    }
396                }
397                ScaleRewriteRule::RestBindLocalScale => {
398                    let property = document.clips[clip_index].tracks[track_index].property;
399                    let bone = document.clips[clip_index].tracks[track_index].bone;
400                    let multiplier =
401                        plan.animation_target_factor_unchecked(document, bone, property)?;
402                    if let TrackValues::Vec3s(values) =
403                        &mut candidate.clips[clip_index].tracks[track_index].values
404                    {
405                        for value in values {
406                            // `Vec3` is the storage boundary. Form the
407                            // product in f64 and narrow each component once.
408                            *value = (value.as_dvec3() * multiplier).as_vec3();
409                        }
410                    }
411                }
412                ScaleRewriteRule::WholeDocumentLength
413                | ScaleRewriteRule::RestBindNodeBasis
414                | ScaleRewriteRule::RestBindSourceLocal { .. } => {
415                    unreachable!("outer pattern limits animation rules")
416                }
417            },
418            (
419                ScaleFieldTarget::InstanceInverseBind {
420                    instance_index,
421                    slot,
422                    joint,
423                },
424                ScaleRewriteRule::RestBindNodeBasis,
425            ) => {
426                let source_instance = &document.assets.instances[instance_index];
427                let binds = materialized_binds
428                    .entry(instance_index)
429                    .or_insert_with(|| Vec::with_capacity(source_instance.skin_joints.len()));
430                if slot != binds.len() {
431                    return Err(ScaleError::PlanDocumentMismatch {
432                        reason: "compiled_inverse_bind_slot_order_mismatch",
433                    });
434                }
435                let before = instance_bind(document, source_instance, slot, joint)?;
436                binds.push(scale_rows(before, node_factor(joint)));
437            }
438            _ => {
439                return Err(ScaleError::PlanDocumentMismatch {
440                    reason: "invalid_rest_bind_write_target",
441                });
442            }
443        }
444    }
445    for (instance_index, binds) in materialized_binds {
446        candidate.assets.instances[instance_index].skin_ibms = binds;
447    }
448    Ok(candidate)
449}
450
451/// Apply the established projected-local rebase, optionally combining its
452/// translation with a widened connector-conjugation offset.
453///
454/// `None` deliberately avoids adding a zero: direct-edge f32 association and
455/// signed-zero behavior are part of the calibrated Appendix D contract. A
456/// bridged translation stays widened until the complete sum is narrowed, so
457/// compensating terms above the f32 range can still produce a finite local.
458fn rebase_source_local_rest(
459    local_rest: &SourceNodeLocalRest,
460    s_parent: f32,
461    s_node: f32,
462    bridge_offset: Option<DVec3>,
463) -> SourceNodeLocalRest {
464    match local_rest {
465        SourceNodeLocalRest::Trs {
466            translation,
467            rotation,
468            scale,
469        } => {
470            let translation = match bridge_offset {
471                Some(offset) => (translation.as_dvec3() * f64::from(s_parent) + offset).as_vec3(),
472                None => *translation * s_parent,
473            };
474            let scale = match bridge_offset {
475                Some(_) => {
476                    let ratio = f64::from(s_parent) / f64::from(s_node);
477                    (scale.as_dvec3() * ratio).as_vec3()
478                }
479                None => *scale * (s_parent / s_node),
480            };
481            SourceNodeLocalRest::Trs {
482                translation,
483                rotation: *rotation,
484                scale,
485            }
486        }
487        SourceNodeLocalRest::Matrix(matrix) => {
488            let rebased = if let Some(offset) = bridge_offset {
489                let ratio = f64::from(s_parent) / f64::from(s_node);
490                let rebase_linear_column = |column: Vec4| {
491                    (column.truncate().as_dvec3() * ratio)
492                        .as_vec3()
493                        .extend(column.w)
494                };
495                let translation =
496                    (matrix.w_axis.truncate().as_dvec3() * f64::from(s_parent) + offset).as_vec3();
497                Mat4::from_cols(
498                    rebase_linear_column(matrix.x_axis),
499                    rebase_linear_column(matrix.y_axis),
500                    rebase_linear_column(matrix.z_axis),
501                    translation.extend(matrix.w_axis.w),
502                )
503            } else {
504                rebase_matrix(*matrix, s_parent, s_node)
505            };
506            SourceNodeLocalRest::Matrix(rebased)
507        }
508    }
509}
510
511/// Merge one compiled raw-source write into its local container.
512///
513/// A [`super::ScaleFieldPlan`] is the builder's write authority. Even when
514/// deriving one field naturally produces a complete local transform, sibling
515/// fields must retain their original bits unless their own row also says
516/// Rewrite.
517fn source_rest_with_rewritten_field(
518    original: &SourceNodeLocalRest,
519    rewritten: &SourceNodeLocalRest,
520    field: ScaleSourceRestField,
521) -> Result<SourceNodeLocalRest, ScaleError> {
522    match (original, rewritten, field) {
523        (
524            SourceNodeLocalRest::Trs {
525                translation: _,
526                rotation,
527                scale,
528            },
529            SourceNodeLocalRest::Trs {
530                translation: rewritten,
531                ..
532            },
533            ScaleSourceRestField::Translation,
534        ) => Ok(SourceNodeLocalRest::Trs {
535            translation: *rewritten,
536            rotation: *rotation,
537            scale: *scale,
538        }),
539        (
540            SourceNodeLocalRest::Trs {
541                translation,
542                rotation,
543                scale: _,
544            },
545            SourceNodeLocalRest::Trs {
546                scale: rewritten, ..
547            },
548            ScaleSourceRestField::Scale,
549        ) => Ok(SourceNodeLocalRest::Trs {
550            translation: *translation,
551            rotation: *rotation,
552            scale: *rewritten,
553        }),
554        (
555            SourceNodeLocalRest::Matrix(original),
556            SourceNodeLocalRest::Matrix(rewritten),
557            ScaleSourceRestField::MatrixLinear,
558        ) => Ok(SourceNodeLocalRest::Matrix(Mat4::from_cols(
559            rewritten.x_axis.truncate().extend(original.x_axis.w),
560            rewritten.y_axis.truncate().extend(original.y_axis.w),
561            rewritten.z_axis.truncate().extend(original.z_axis.w),
562            original.w_axis,
563        ))),
564        (
565            SourceNodeLocalRest::Matrix(original),
566            SourceNodeLocalRest::Matrix(rewritten),
567            ScaleSourceRestField::MatrixTranslation,
568        ) => Ok(SourceNodeLocalRest::Matrix(Mat4::from_cols(
569            original.x_axis,
570            original.y_axis,
571            original.z_axis,
572            rewritten.w_axis.truncate().extend(original.w_axis.w),
573        ))),
574        _ => Err(ScaleError::PlanDocumentMismatch {
575            reason: "source_local_field_variant_mismatch",
576        }),
577    }
578}
579
580/// Move a projected successor's rest/bind correction through an ordered
581/// chain of unchanged, unprojected source transforms.
582///
583/// If `H` is the parent-to-child product of the connector locals and `L` is
584/// the projected successor's authored local, preserving every connector
585/// exactly requires `L' = H^-1 S_parent H L S_node^-1`. A multiplier-only
586/// rewrite of `L` is wrong whenever `H` has a nonzero translation.
587fn rebase_source_local_through_connector_bridge(
588    local_rest: &SourceNodeLocalRest,
589    connector_tail: usize,
590    by_source_index: &BTreeMap<usize, &SourceNodeAsset>,
591    connector_product_by_tail: &mut BTreeMap<usize, DMat4>,
592    s_parent: f32,
593    s_node: f32,
594    bone: BoneId,
595) -> Result<SourceNodeLocalRest, ScaleError> {
596    if s_parent == 1.0 && s_node == 1.0 {
597        return Ok(local_rest.clone());
598    }
599    let connector =
600        memoized_connector_product(connector_tail, by_source_index, connector_product_by_tail)?;
601    let connector_linear_inverse = DMat3::from_cols(
602        connector.x_axis.truncate(),
603        connector.y_axis.truncate(),
604        connector.z_axis.truncate(),
605    )
606    .inverse();
607    let bridge_offset =
608        connector_linear_inverse * (connector.w_axis.truncate() * (f64::from(s_parent) - 1.0));
609    if !connector_linear_inverse.x_axis.is_finite()
610        || !connector_linear_inverse.y_axis.is_finite()
611        || !connector_linear_inverse.z_axis.is_finite()
612        || !bridge_offset.is_finite()
613    {
614        return Err(ScaleError::NonFiniteTransform { node: bone });
615    }
616    // For affine H=[A,t], H^-1*S_parent*H contributes only the translation
617    // A^-1*((s_parent-1)*t) beyond the established direct-edge rewrite. Keep
618    // every complete bridged expression widened through its single model
619    // boundary, while direct projected edges retain their established f32
620    // association and signed-zero behavior.
621    let rebased = rebase_source_local_rest(local_rest, s_parent, s_node, Some(bridge_offset));
622    if !mat4_is_finite(local_rest_matrix(&rebased)) {
623        return Err(ScaleError::NonFiniteTransform { node: bone });
624    }
625    Ok(rebased)
626}
627
628/// Return the ordered connector product from its nearest projected ancestor
629/// through `connector_tail`, caching every traversed prefix once.
630fn memoized_connector_product(
631    connector_tail: usize,
632    by_source_index: &BTreeMap<usize, &SourceNodeAsset>,
633    connector_product_by_tail: &mut BTreeMap<usize, DMat4>,
634) -> Result<DMat4, ScaleError> {
635    let mut pending = Vec::new();
636    let mut visited = BTreeSet::new();
637    let mut cursor = connector_tail;
638    let mut product = loop {
639        if let Some(&cached) = connector_product_by_tail.get(&cursor) {
640            break cached;
641        }
642        if !visited.insert(cursor) {
643            return Err(ScaleError::IncompleteClosure {
644                reason: "cyclic_connector_source_parent_chain",
645            });
646        }
647        let asset = by_source_index
648            .get(&cursor)
649            .ok_or(ScaleError::IncompleteClosure {
650                reason: "dangling_connector_source_node_index",
651            })?;
652        if asset.bone.is_some() {
653            break DMat4::IDENTITY;
654        }
655        pending.push(cursor);
656        cursor = asset
657            .parent_source_node_index
658            .ok_or(ScaleError::IncompleteClosure {
659                reason: "connector_without_projected_ancestor",
660            })?;
661    };
662    while let Some(source) = pending.pop() {
663        let asset = by_source_index
664            .get(&source)
665            .ok_or(ScaleError::IncompleteClosure {
666                reason: "dangling_connector_source_node_index",
667            })?;
668        product *= local_rest_matrix(&asset.local_rest).as_dmat4();
669        connector_product_by_tail.insert(source, product);
670    }
671    connector_product_by_tail
672        .get(&connector_tail)
673        .copied()
674        .ok_or(ScaleError::IncompleteClosure {
675            reason: "empty_connector_bridge",
676        })
677}
678
679/// `L' = scale(s_parent) * L * scale(1 / s_node)`: the rest/bind local
680/// rebase of DESIGN.md Appendix D §D.2, applied to a raw authored matrix
681/// that may carry terms a TRS decomposition cannot represent.
682///
683/// Left-multiplying by a uniform scale scales the output rows (that is
684/// [`scale_rows`]); right-multiplying by `scale(1 / s_node)` scales the three
685/// linear columns in full, translation column untouched.
686fn rebase_matrix(matrix: Mat4, s_parent: f32, s_node: f32) -> Mat4 {
687    let scaled = scale_rows(matrix, s_parent);
688    let inverse_node = 1.0 / s_node;
689    Mat4::from_cols(
690        scaled.x_axis * inverse_node,
691        scaled.y_axis * inverse_node,
692        scaled.z_axis * inverse_node,
693        scaled.w_axis,
694    )
695}