animsmith_core/scale/mod.rs
1//! Format-neutral scale plan and proof contracts (DESIGN.md Appendix D).
2//!
3//! This module owns the two distinct scale operations Appendix D defines —
4//! [`ScaleOperation::WholeDocumentLinearUnits`] and
5//! [`ScaleOperation::RestBindUniformScale`] — plus their shared pure
6//! planning, candidate construction, and proof layer. It deliberately
7//! consumes and returns only format-neutral facts: an already-loaded
8//! [`Document`] and a [`ScaleCapabilityFacts`] projection that a format
9//! frontend (for example `animsmith-gltf`'s raw capability preflight)
10//! builds from its own source-specific inventory. This module does not
11//! accept paths, glTF/ufbx types, config parsers, or publication policy,
12//! and it does not itself decide CLI selectors, evidence schemas, or
13//! artifact/evidence publication — those are producer concerns layered on
14//! top.
15//!
16//! The public vocabulary and entrypoints continue to resolve through this
17//! facade. Private implementation modules own numeric leaves, validation,
18//! planning/replay, reference construction, and proof respectively; proof's
19//! residual recorder is nested under proof so its paired maximum/count state
20//! cannot be mutated outside that implementation boundary.
21//!
22//! [`ScaleOperation::RestBindUniformScale`] selects by raw, format-neutral
23//! source identity — `source_skin_index` and `source_root_node_index` — not
24//! by normalized [`crate::model::BoneId`] or mesh-instance ordinal.
25//! Resolving those selectors, and classifying the affected domain's affine
26//! shape, walks [`crate::model::SceneAssets::source_skeleton`]: the only
27//! place a full (possibly sheared) authored local matrix survives, since
28//! [`crate::model::Bone::rest`] is a lossy TRS decomposition that can never
29//! look sheared even when the source was.
30//!
31//! [`plan_scale`] is pure and fail-closed: it never mutates its input and
32//! returns a typed [`ScaleError`] for every unsupported affine domain,
33//! incomplete closure, incomplete capability, invalid selector, invalid
34//! factor. An internal reference builder constructs analytic candidates for
35//! fixtures and calibration; production format frontends instead rewrite
36//! exact source bytes and wrap the emitted reload with
37//! [`ScaleCandidate::from_document`]. [`prove_scale`] independently re-derives
38//! the plan's claims from the source and candidate documents and reports the observed
39//! residual maxima against the fixed [`ScaleTolerancePolicy::APPENDIX_D_V6`]
40//! tolerance identity.
41//!
42//! Those residuals are the producer evidence record of §D.6, which is why
43//! two properties of this module are contracts rather than implementation
44//! details. Every typed [`ScaleProofObligation`] is declared only when
45//! the planned document carries the evidence for it. Candidate construction
46//! and proof re-derive that structural inventory and report a stale plan as
47//! [`ScaleError::PlanDocumentMismatch`]; a counterpart missing inside an
48//! inventory-matched walk is [`ScaleError::MissingProofEvidence`]. Neither
49//! case becomes a zero residual — a record asserting `0.0` for something
50//! nothing checked would be false, not merely incomplete. The two
51//! observed-factor fields §D.6 asks for are both recorded, together with
52//! [`ScaleProof::observed_factor_divergence`] between them, so the record
53//! states their relationship instead of leaving a consumer to guess which to
54//! trust. Rest/bind derives them independently from raw and normalized state;
55//! whole-document conversion records its declared factor in both because it
56//! has no source factor to measure.
57
58use crate::model::{
59 AffineDomainViolation, BoneId, Document, DocumentShapeError, Interpolation, Property,
60 SourceInverseBindAccessorStatus, SourceSkeletonCoverage,
61};
62#[cfg(test)]
63use crate::model::{
64 Clip, MeshInstanceShapeViolation, Skeleton, TrackValues, Transform, mat4_is_finite,
65};
66#[cfg(test)]
67use glam::{Mat3, Mat4, Vec4};
68#[cfg(test)]
69use std::collections::BTreeMap;
70use std::collections::BTreeSet;
71
72mod assembly_basis;
73mod numeric;
74mod planning;
75mod proof;
76mod reference;
77mod validation;
78
79pub use assembly_basis::{
80 ASSEMBLY_SCALE_BASIS_VERSION, AssemblyScaleBasis, AssemblyScaleCompatibilityBasis,
81 AssemblyScaleCompatibilityError, AssemblyScaleNamedNode,
82 AssemblyScaleNamedSelectorResolutionError, AssemblyScaleResolvedNamedSelector,
83 AssemblyScaleSelectorRequest, AssemblyScaleSourceNode, AssemblyScaleSourceRest,
84 AssemblyScaleTargetPath, assembly_scale_basis, assembly_scale_compatibility_basis,
85 require_assembly_scale_compatibility, require_assembly_scale_compatibility_with_selectors,
86 resolve_assembly_scale_named_selector,
87};
88pub use planning::plan_scale;
89use planning::validate_plan_document_inventory;
90pub use proof::{ScaleProof, ScaleProofResidual, prove_scale};
91pub use reference::ScaleCandidate;
92
93#[cfg(test)]
94use proof::{
95 BoundsAccumulator, SkinSlot, accumulate_skinned_bounds, check_residual, check_sampling_budget,
96 observed_factor_from_source, per_sample_work_units, skin_influence_magnitude, world_at_time,
97};
98
99#[cfg(test)]
100use numeric::{
101 column_operand_magnitude, largest_entry, mat4_abs, product_operand_magnitude,
102 scale_translation_only, translation_composition_rounding_base,
103};
104#[cfg(test)]
105use planning::classify_affine;
106#[cfg(any(test, feature = "fixtures"))]
107pub(crate) use reference::build_scale_candidate;
108#[cfg(test)]
109use reference::{build_rest_bind, build_whole_document};
110#[cfg(test)]
111use validation::{
112 WorldBonePose, WorldPose, affected_skin_instance_indices, child_translation_rounding_magnitude,
113 instance_bind, rest_world_pose, source_node_index_map, validate_scale_input,
114};
115#[cfg(test)]
116use validation::{
117 affected_skin_classification_steps, derive_rest_bind_plan_domain,
118 reset_affected_skin_classification_steps, resolve_rest_bind_skin, rest_bind_affected_closure,
119 source_world_matrix, world_rests,
120};
121
122// --- Tolerance policy ----------------------------------------------------
123
124/// Fixed Appendix D tolerance identity and thresholds. Classification and
125/// proof share this one versioned policy and compute in `f64`, narrowing
126/// only at the writer model boundary. There is exactly one supported
127/// instance, [`ScaleTolerancePolicy::APPENDIX_D_V6`]: a policy change is a
128/// new policy identity, not a runtime knob.
129///
130/// The superseded v5 identity is deliberately not retained as an alias:
131///
132/// ```compile_fail
133/// use animsmith_core::ScaleTolerancePolicy;
134///
135/// let _ = ScaleTolerancePolicy::APPENDIX_D_V5;
136/// ```
137#[derive(Debug, Clone, Copy, PartialEq)]
138#[non_exhaustive]
139pub struct ScaleTolerancePolicy {
140 /// Stable policy identity recorded in producer evidence.
141 pub id: &'static str,
142 /// Relative orthogonality tolerance for rejecting shear.
143 pub relative_orthogonality: f64,
144 /// Relative tolerance for equal-length affine columns (uniform scale).
145 pub equal_axis: f64,
146 /// Relative tolerance for one common factor across an affected domain.
147 ///
148 /// This is the normative input band: it is what an operator's declared
149 /// factor is judged against, and
150 /// [`Self::postcondition_unit_scale_residual`] is derived from it so that
151 /// a plan this band accepts is guaranteed to produce a candidate that
152 /// satisfies the unit-scale postcondition.
153 pub common_factor: f64,
154 /// `abs(det) <= singular_determinant_relative * product(axis_lengths)`
155 /// classifies a linear part as singular.
156 pub singular_determinant_relative: f64,
157 /// Absolute term of the scalar/vector comparison tolerance.
158 pub scalar_absolute: f64,
159 /// Relative term of the scalar/vector comparison tolerance.
160 pub scalar_relative: f64,
161 /// Maximum shortest-path rotation residual, in radians.
162 pub rotation_residual_radians: f64,
163 /// Maximum postcondition unit-scale residual, measured **per axis**
164 /// (L-infinity) as `max(|scale_axis - 1|)` — not as an L2 norm over the
165 /// three axes.
166 ///
167 /// The norm is normative, and it is the same dimensionless per-axis
168 /// relative quantity [`Self::common_factor`] and [`Self::equal_axis`]
169 /// measure, so the input band and this postcondition are directly
170 /// commensurable (DESIGN.md Appendix D §D.1). This value is *derived*
171 /// from [`Self::common_factor`] rather than declared independently: see
172 /// [`Self::APPENDIX_D_V6`] for the composition argument and
173 /// [`Self::UNIT_SCALE_BANDS`] for the multiplier.
174 pub postcondition_unit_scale_residual: f64,
175 /// Maximum sampled proof work [`prove_scale`] will perform, in
176 /// per-sample-time work units.
177 ///
178 /// Total work is `sample_time_count * per_sample_work_units`, where the
179 /// per-sample cost counts every pass the sampled obligations actually
180 /// make — bones, skin slots, and skinned vertices, each once per document
181 /// side. See the private `per_sample_work_units` for the exact formula
182 /// and for why the slot term cannot be folded into either of the other
183 /// two. A
184 /// document above this budget is refused with
185 /// [`ScaleError::ProofSamplingBudgetExceeded`] *before* any sampling
186 /// runs; proof never silently samples a subset.
187 ///
188 /// This is part of the versioned policy identity, not a per-run flag —
189 /// DESIGN.md Appendix D §D.6/§D.7 forbid per-run tolerance knobs, and a
190 /// budget that changed per run would make two evidence records carrying
191 /// the same policy id describe different amounts of checking.
192 pub proof_sample_work_budget: u64,
193 /// How many binary32 ulps of *operand* magnitude an obligation that
194 /// compares `f32`-rounded arithmetic may deviate by, on top of
195 /// [`Self::scalar_absolute`] and [`Self::scalar_relative`].
196 ///
197 /// The term this multiplies is **absolute**, not relative: it is
198 /// `f32_rounding_ulps * magnitude * f32::EPSILON` where `magnitude` is
199 /// the largest quantity the compared arithmetic passed through, not the
200 /// quantity being compared. Where the compared value *is* that largest
201 /// quantity the term adds `4 * 2^-23 = 4.77e-7` of it — twenty times
202 /// less than [`Self::scalar_relative`] already allows — so it cannot
203 /// loosen the obligations it applies to in their own regime.
204 ///
205 /// It exists for the regime where the two diverge. A rotation can make
206 /// the compared quantity orders of magnitude smaller than the operands
207 /// it was computed from while it still carries those operands' absolute
208 /// rounding error: a bound component near zero on a mesh 4000 units
209 /// across, a near-identity `W * B` whose translation column cancelled two
210 /// 3190-magnitude terms, or a world translation whose parent chain
211 /// cancelled two of them one composition earlier. A purely relative band
212 /// is then derived from the small number and the error from the large
213 /// one, and [`prove_scale`] refuses a correct candidate that
214 /// [`plan_scale`] accepted. See [`Self::APPENDIX_D_V6`] for the
215 /// measurement this count comes from and DESIGN.md Appendix D §D.1 for
216 /// which magnitude each obligation takes it from.
217 ///
218 /// The count is only as meaningful as that magnitude. Two revisions of
219 /// this policy have now found the *base* wrong rather than the count too
220 /// small — first the skinned extent alone, which missed the `W * B`
221 /// composition, then `abs(W) * abs(B)` alone, which missed what `W`'s own
222 /// parent chain had already cancelled — and in both the measured excess
223 /// was hundreds of thousands of ulps, not a factor of two. A residual
224 /// above this count is evidence about the base before it is evidence
225 /// about the count.
226 pub f32_rounding_ulps: u32,
227}
228
229impl ScaleTolerancePolicy {
230 /// How many [`Self::common_factor`] bands
231 /// [`Self::postcondition_unit_scale_residual`] is derived from.
232 ///
233 /// Three of them are analytic and one is float headroom; see
234 /// [`Self::APPENDIX_D_V6`].
235 pub const UNIT_SCALE_BANDS: f64 = 4.0;
236
237 /// The only supported tolerance policy: DESIGN.md Appendix D, version 6.
238 ///
239 /// Version 6 supersedes `appendix-d-v5`, which superseded v4, v3, v2 and
240 /// v1. Each identity change is a change of *meaning*, not a retune:
241 ///
242 /// 1. [`Self::postcondition_unit_scale_residual`] is a per-axis
243 /// (L-infinity) residual derived from [`Self::common_factor`], instead
244 /// of v1's independently declared `1e-5` L2 norm over three axes. Under
245 /// v1 the two were incommensurable, and a source whose observed factor
246 /// had relative error `e` produced a postcondition residual of
247 /// `sqrt(3) * e`, so every `e` in `(5.77e-6, 1e-5]` was accepted by
248 /// [`plan_scale`] and then rejected by [`prove_scale`].
249 /// 2. [`Self::proof_sample_work_budget`] bounds the sampled proof work a
250 /// document may demand.
251 /// 3. [`Self::f32_rounding_ulps`] is new in v3, and adds an absolute
252 /// `f32`-rounding term to the five obligations that compare
253 /// `f32`-rounded arithmetic against a base that a rotation can make
254 /// arbitrarily smaller than the operands the arithmetic ran on —
255 /// [`ProofResidualKind::Bounds`], [`ProofResidualKind::SkinMatrix`],
256 /// [`ProofResidualKind::UnaffectedInverseBind`],
257 /// [`ProofResidualKind::RestTranslation`], and
258 /// [`ProofResidualKind::Trajectory`]. Without it [`plan_scale`] accepts
259 /// and [`prove_scale`] refuses a correct candidate whenever
260 /// `magnitude / component` is large.
261 /// 4. v4 widens finite non-negative weight normalization and accumulation
262 /// to binary64, makes the Bounds magnitude a weight-proportional
263 /// combination of each influence's transform and slot-composition
264 /// provenance, and removes v3's blended-point L2 stage. Bounds residuals
265 /// are per axis, and the normalized blend is already bounded by those
266 /// weighted operands.
267 /// 5. v5 makes parent-chain translation provenance additive per composed
268 /// link, in binary64, instead of taking a depth-independent maximum.
269 /// Each spatial row carries the new local contribution plus a parent
270 /// term capped by `contribution / EPSILON`. This provisions the smaller
271 /// of one parent-scale ulp and losing the entire contribution, so zero
272 /// and underflowed descendants cannot charge the same translated parent
273 /// repeatedly. Only the three spatial output rows participate; the
274 /// affine homogeneous row contributes zero under this cap. The same
275 /// recurrence constructs rest and sampled poses, and its result reaches
276 /// RestTranslation, Trajectory, SkinMatrix and Bounds through their
277 /// existing consumers.
278 /// 6. v6 changes only the association of the shared affine axis-length
279 /// mean: the three finite widened lengths are sorted ascending before
280 /// the ordinary sum and division by three. This removes authored-column
281 /// order from the classifier, planning, and proof witness without
282 /// changing any numeric threshold, the v5 parent-chain provenance
283 /// recurrence, or the evidence schema.
284 ///
285 /// `postcondition_unit_scale_residual` is
286 /// `UNIT_SCALE_BANDS * common_factor = 4e-5`, rounded up to the next
287 /// power of two, `2^-14 = 6.103515625e-5`. That value is also
288 /// `512 * 2^-23`, and so lies on the binary32 mantissa grid the
289 /// composed-scale measurement lives on. Landing on that grid is what
290 /// makes §D.1's inclusive "at most" reachable for this obligation: the
291 /// measured residual near unit magnitude is always an integer multiple of
292 /// `2^-23`, so a bound off that grid could never be met with equality and
293 /// would be an exclusive bound wearing an inclusive name.
294 ///
295 /// The four bands are:
296 ///
297 /// - one for [`ScaleError::FactorMismatch`], which binds the domain's
298 /// observed common factor `s_0` to the caller's declared factor
299 /// `s_declared`;
300 /// - one for [`ScaleError::MixedFactor`], which binds each affected node's
301 /// observed factor `s_i` to `s_0`;
302 /// - one for [`AffineDomainViolation::NonUniformScale`], which binds each
303 /// individual *axis* of node `i` to `s_i`; and
304 /// - one reserved as headroom for the `f32` world-matrix composition and
305 /// decomposition that produces the measured composed scale.
306 ///
307 /// The first three compose, and the third is easy to miss: `s_i` is the
308 /// *average* of node `i`'s three world axis lengths (the affine
309 /// classifier returns that average), while the postcondition measures an
310 /// individual
311 /// axis, and the equal-axis check permits each axis its own further band
312 /// away from that average. The candidate's composed scale on axis `k` of
313 /// node `i` is `axis_ik / s_declared`, and each of the three bands is
314 /// stated relative to `max` of its operands, so each contributes at most
315 /// `c / (1 - c)` when re-expressed relative to the smaller one. The
316 /// analytic worst case is therefore `(1 - c)^-3 - 1 = 3.00006e-5` for
317 /// `c = 1e-5`.
318 ///
319 /// Three bands rounded up (`2^-15 = 3.0517578125e-5`) would leave that
320 /// worst case only `4` binary32 ulps of room — `2^-15 - 3.00006e-5 =
321 /// 5.17e-7 = 4.34 * 2^-23` — which is not headroom for a float
322 /// measurement, it is a rounding artefact. A fourth band makes the
323 /// reserved-headroom claim above true rather than aspirational, and it
324 /// does not blunt the obligation: every build defect this check exists to
325 /// catch — a dropped rebase, a factor applied twice, a stale no-op — is
326 /// `>= 1e-3`, so `6.1e-5` still leaves better than a `16x` detection
327 /// margin.
328 pub const APPENDIX_D_V6: Self = Self {
329 id: "appendix-d-v6",
330 relative_orthogonality: 1e-5,
331 equal_axis: 1e-5,
332 common_factor: 1e-5,
333 singular_determinant_relative: 1e-6,
334 scalar_absolute: 1e-6,
335 scalar_relative: 1e-5,
336 rotation_residual_radians: 1e-5,
337 // 2^-14, exactly: four `common_factor` bands rounded up onto the
338 // binary32 mantissa grid (`= 512 * 2^-23`).
339 postcondition_unit_scale_residual: 6.103_515_625e-5,
340 // DESIGN.md Appendix D §D.1 owns the exact charge and released
341 // resource boundary. docs/scale-calibration.md records the historical
342 // populations and machine-local timings that selected this value.
343 proof_sample_work_budget: 400_000_000,
344 // Empirically calibrated, not an analytic bound. The checked-in
345 // `calibrate_f32_rounding_ulps` sweep can be regenerated with
346 //
347 // cargo test -p animsmith-core --release --lib \
348 // calibrate_f32_rounding_ulps -- --ignored --nocapture
349 //
350 // docs/scale-calibration.md owns the exact populations, measured
351 // demands, historical alternatives, and cost discussion. DESIGN.md
352 // Appendix D §D.1 owns the shipped recurrence and refusal semantics.
353 f32_rounding_ulps: 4,
354 };
355
356 /// The expected ceiling on [`ScaleProof::observed_factor_divergence`]:
357 /// [`Self::common_factor`] plus
358 /// [`Self::postcondition_unit_scale_residual`], `7.103515625e-5` under
359 /// [`Self::APPENDIX_D_V6`].
360 ///
361 /// For rest/bind, [`ScalePlan::observed_factor`] and
362 /// [`ScaleProof::observed_factor`] are independent witnesses measured from
363 /// genuinely different state — the raw source projection composed through
364 /// `parent_source_node_index`, and the normalized skeleton composed
365 /// through `world_rest_matrices`. Their independence is the point. For
366 /// whole-document conversion both fields are the declared factor, so their
367 /// divergence is exactly zero. For rest/bind, the sum comes from:
368 ///
369 /// - planning binds its witness to the caller's declared factor within
370 /// [`Self::common_factor`], or refuses with
371 /// [`ScaleError::FactorMismatch`]; and
372 /// - for a candidate the internal reference builder produced from the
373 /// source
374 /// under proof, that candidate's composed root scale is the proof
375 /// witness divided by the declared factor, so the unit-scale
376 /// postcondition binds the proof witness to the declared factor within
377 /// [`Self::postcondition_unit_scale_residual`].
378 ///
379 /// The two bands are not stated the same way, and the sum is a ceiling
380 /// only up to that difference. Planning's is relative to the `max` of its
381 /// two operands, exactly as this divergence is. The postcondition's is not
382 /// a relative band on the two witnesses at all: it is an absolute
383 /// L-infinity deviation from `1` on the *candidate's* composed scale, and
384 /// that candidate's scale is the proof witness rebased by the declared
385 /// factor — so what it bounds is `|proved - declared|` as a fraction of
386 /// the declared factor, not as a fraction of `max(planned, proved)`.
387 ///
388 /// **Reported, not enforced, and expected rather than proved.** Nothing
389 /// refuses a document for exceeding this. The second step above holds for
390 /// a candidate this module built from the source it is being proved
391 /// against, which [`prove_scale`] deliberately does not require, and it
392 /// costs the binary32 rounding of the rebase on the way — so the sum is
393 /// the ceiling the design guarantees, not a bound proved to the last ulp.
394 /// A divergence beyond it means the two witnesses were composed from state
395 /// that does not agree — most often differing *stored* transforms, since
396 /// [`crate::model::SourceNodeAsset::local_rest`] and
397 /// [`crate::model::Bone::rest`] are separately stored descriptions of the
398 /// same rest pose. It is not evidence of disagreeing parent chains: under
399 /// [`crate::model::SourceSkeletonCoverage::Complete`] coverage the two
400 /// chains are required to describe the same tree, and every entry point in
401 /// this module refuses a document where they do not.
402 ///
403 /// Derived from two bands this policy already declares rather than
404 /// introduced as a third, so it adds no tolerance and no policy identity —
405 /// and a consumer of the evidence record does not have to sum two
406 /// separate policy fields to know what the recorded divergence means.
407 pub fn observed_factor_divergence_ceiling(&self) -> f64 {
408 self.common_factor + self.postcondition_unit_scale_residual
409 }
410
411 /// `abs_error <= scalar_absolute + scalar_relative * max(abs(before), abs(after))`.
412 ///
413 /// Every proof call site must pass the actual before/after magnitudes of
414 /// the specific residual being checked — never a proxy such as the
415 /// plan's declared factor — so a residual near a large coordinate gets a
416 /// correspondingly looser absolute tolerance than one near a small
417 /// coordinate.
418 pub fn scalar_tolerance(&self, before: f64, after: f64) -> f64 {
419 self.scalar_absolute + self.scalar_relative * before.abs().max(after.abs())
420 }
421
422 /// [`Self::scalar_tolerance`] plus [`Self::f32_rounding_ulps`] binary32
423 /// ulps of `magnitude`.
424 ///
425 /// `magnitude` is the largest quantity the compared `f32` arithmetic
426 /// passed through — never the quantity being compared, which is what
427 /// `before`/`after` already carry. The two coincide for a comparison
428 /// whose operands are its own magnitude, and diverge without limit for
429 /// one whose result was made small by cancellation; DESIGN.md Appendix D
430 /// §D.1 names the magnitude each obligation takes.
431 ///
432 /// The added term is absolute in `magnitude` and so cannot widen a
433 /// comparison relative to its own operands: at `magnitude ==
434 /// max(before, after)` it is `f32_rounding_ulps * 2^-23 = 4.77e-7` of
435 /// them, against the `1e-5` [`Self::scalar_relative`] already allows.
436 pub fn f32_rounded_tolerance(&self, before: f64, after: f64, magnitude: f64) -> f64 {
437 self.scalar_tolerance(before, after)
438 + f64::from(self.f32_rounding_ulps) * magnitude.abs() * f64::from(f32::EPSILON)
439 }
440
441 /// `abs(a - b) <= tolerance * max(abs(a), abs(b))`.
442 ///
443 /// Genuinely relative, per DESIGN.md Appendix D §D.1: the orthogonality,
444 /// equal-axis, and common-factor tolerances are declared *relative*
445 /// `1e-5`, so the comparison base is the operands' own magnitude and
446 /// nothing else. Flooring that base at `1.0` — as an earlier revision did
447 /// — silently converts these into absolute tolerances for every operand
448 /// below unit magnitude, which is exactly the regime these operations
449 /// exist for: at a common factor of `0.01` a `1.0` floor accepts `1e-3`
450 /// relative error, `100x` the declared policy, and lets `plan_scale`
451 /// accept a plan whose candidate then fails its own unit-scale
452 /// postcondition.
453 ///
454 /// There is **no** floor on the comparison base — not `1.0`, and not
455 /// [`Self::scalar_absolute`] either. A `scalar_absolute` floor is a
456 /// smaller version of the same defect and breaks the same closure
457 /// property, just further down: below `1e-6` the band stops tracking the
458 /// operands and freezes at the constant `1e-5 * 1e-6 = 1e-11`, which is a
459 /// *relative* band of `1e-11 / abs(s)` and therefore widens without limit
460 /// as `s` shrinks. It crosses
461 /// [`Self::postcondition_unit_scale_residual`] at
462 /// `s = 1e-11 / 2^-14 = 1e-11 * 16384 = 1.6384e-7` (`3.2768e-7` against
463 /// the tighter `2^-15` bound an earlier revision declared — halving the
464 /// postcondition bound doubles the crossing point, because the crossing
465 /// point is inversely proportional to it), so every declared factor
466 /// below that had a band of accepted plans whose candidates then failed
467 /// the unit-scale postcondition — at `s = 1e-9` the band admits `1e-2`
468 /// relative error, `1000x` the declared policy.
469 ///
470 /// Nothing needs the floor for the degenerate `a == b == 0` case either:
471 /// that compares `0.0 <= 0.0`, which holds. (Both call sites have already
472 /// proved their operands strictly positive in any case — a declared
473 /// factor by `planning::plan_rest_bind`'s range check, an observed one by
474 /// [`planning::classify_affine`].)
475 fn relative(&self, tolerance: f64, a: f64, b: f64) -> bool {
476 (a - b).abs() <= tolerance * a.abs().max(b.abs())
477 }
478}
479
480// --- Capability projection ------------------------------------------------
481
482/// Whether a format-neutral capability projection covers the whole source.
483///
484/// A projection built from an incomplete or partially-inspected source must
485/// report [`ScaleCapabilityCoverage::Unavailable`]: an absent flag is not
486/// evidence the underlying domain is absent from the source.
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
488pub enum ScaleCapabilityCoverage {
489 /// The projection cannot vouch for the complete source domain.
490 #[default]
491 Unavailable,
492 /// Every documented domain in the source was inspected.
493 Complete,
494}
495
496/// Format-neutral capability facts a frontend projects from its raw source
497/// inventory before any scale plan or candidate exists.
498///
499/// This is deliberately coarser than a format's own raw capability
500/// manifest (for example `animsmith_gltf::GltfCapabilityManifest`): it only
501/// carries the flags this module's planning needs to fail closed on an
502/// unsupported domain, per DESIGN.md Appendix D §D.4. A frontend projects
503/// its richer, format-specific manifest down to these flags.
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
505#[non_exhaustive]
506pub struct ScaleCapabilityFacts {
507 /// Whether the projection covers the complete source domain.
508 pub coverage: ScaleCapabilityCoverage,
509 /// A morph target is present.
510 pub morphs_present: bool,
511 /// Static or animated morph weights are present.
512 pub morph_weights_present: bool,
513 /// The format adapter owns exact preservation of every present morph
514 /// payload for whole-document conversion.
515 ///
516 /// Presence alone is never permission: this witness is set only after a
517 /// raw adapter has validated its operation-specific write set and proof.
518 pub whole_document_morphs_preservable: bool,
519 /// A camera is present.
520 pub cameras_present: bool,
521 /// A punctual light is present.
522 pub lights_present: bool,
523 /// GPU-instancing data is present.
524 pub instancing_present: bool,
525 /// An extension is not covered by a registered length-field handler.
526 pub unregistered_extensions_present: bool,
527 /// Non-null application-specific extras are present.
528 pub extras_present: bool,
529 /// A JSON/source member outside the modeled schema was ignored.
530 pub unknown_source_members_present: bool,
531 /// A non-triangle-list primitive is present.
532 pub non_triangle_primitives_present: bool,
533 /// A vertex attribute outside the normalized writer subset is present.
534 pub unsupported_vertex_attributes_present: bool,
535 /// A secondary skin-influence set is present.
536 pub secondary_skin_influences_present: bool,
537 /// An inverse-bind accessor is missing, empty, mismatched, or unreadable.
538 pub inverse_bind_issues_present: bool,
539 /// A scale-bearing source layout cannot be safely bounded or rewritten.
540 pub unsafe_accessor_layout_present: bool,
541 /// An external (non-embedded) resource is referenced.
542 pub external_resources_present: bool,
543}
544
545impl ScaleCapabilityFacts {
546 /// A capability projection declaring complete coverage and no
547 /// unsupported domain for either operation.
548 ///
549 /// This operation-agnostic query stays conservative for callers that do
550 /// not yet have an operation in hand. Planning uses
551 /// [`Self::is_supported_for`] so a format adapter that owns raw morph
552 /// preservation can admit morphs for whole-document conversion without
553 /// weakening the rest/bind boundary.
554 pub fn is_supported(&self) -> bool {
555 self.common_domains_supported() && !self.morphs_present && !self.morph_weights_present
556 }
557
558 /// Whether this complete projection is supported for `operation`.
559 ///
560 /// Morph targets are deliberately operation-specific. A
561 /// whole-document format adapter may scale raw `POSITION` deltas and
562 /// preserve dimensionless weights outside [`crate::Document`]; rest/bind
563 /// still refuses every morph because its raw preservation proof has not
564 /// been defined. All other capability domains retain the same refusal for
565 /// both operations.
566 pub fn is_supported_for(&self, operation: ScaleOperation) -> bool {
567 self.common_domains_supported()
568 && match operation {
569 ScaleOperation::WholeDocumentLinearUnits { .. } => {
570 (!self.morphs_present && !self.morph_weights_present)
571 || self.whole_document_morphs_preservable
572 }
573 ScaleOperation::RestBindUniformScale { .. } => {
574 !self.morphs_present && !self.morph_weights_present
575 }
576 }
577 }
578
579 fn common_domains_supported(&self) -> bool {
580 self.coverage == ScaleCapabilityCoverage::Complete
581 && !self.cameras_present
582 && !self.lights_present
583 && !self.instancing_present
584 && !self.unregistered_extensions_present
585 && !self.extras_present
586 && !self.unknown_source_members_present
587 && !self.non_triangle_primitives_present
588 && !self.unsupported_vertex_attributes_present
589 && !self.secondary_skin_influences_present
590 && !self.inverse_bind_issues_present
591 && !self.unsafe_accessor_layout_present
592 && !self.external_resources_present
593 }
594}
595
596// --- Operation and request -------------------------------------------------
597
598/// The two distinct scale operations DESIGN.md Appendix D §D.1 defines.
599///
600/// Neither variant infers its factor or applicability from mesh bounds,
601/// character height, joint lengths, inverse-bind magnitude, filename, or an
602/// asset category. The caller names the operation and declares or accepts
603/// the exact factor [`plan_scale`] validates.
604#[derive(Debug, Clone, Copy, PartialEq)]
605#[non_exhaustive]
606pub enum ScaleOperation {
607 /// Whole-document linear-unit conversion: every represented length is
608 /// converted by the declared finite positive `factor`.
609 WholeDocumentLinearUnits {
610 /// Declared finite positive conversion factor `q`.
611 factor: f64,
612 },
613 /// Rest/bind hierarchy reparameterization: removes one compensating
614 /// inherited scale from a restricted skinned hierarchy.
615 ///
616 /// Both selectors are raw, format-neutral source identity — a source
617 /// node/skin array index, per DESIGN.md Appendix D §D.7 — not a
618 /// normalized [`BoneId`] or mesh-instance ordinal. [`plan_scale`]
619 /// resolves them through [`crate::model::SceneAssets::source_skeleton`].
620 RestBindUniformScale {
621 /// Stable source-skin-array index selecting the skin whose joints
622 /// anchor the affected domain.
623 source_skin_index: usize,
624 /// Stable source-node-array index of the scaled ancestor root.
625 source_root_node_index: usize,
626 /// Caller-declared expected common factor `s`. Planning measures
627 /// the source's observed rest-world factor and rejects a mismatch
628 /// rather than inferring `s` from geometry.
629 expected_factor: f64,
630 },
631}
632
633/// Pure planning input: the operation, the document to plan against, and a
634/// format-neutral capability projection of the raw source.
635#[derive(Debug, Clone, Copy)]
636pub struct ScaleRequest<'a> {
637 /// Selected operation and its declared parameters.
638 pub operation: ScaleOperation,
639 /// Document to plan against.
640 pub document: &'a Document,
641 /// Format-neutral capability projection of the raw source.
642 pub capability: &'a ScaleCapabilityFacts,
643}
644
645// --- Errors ----------------------------------------------------------------
646
647/// Typed, fail-closed rejection from [`plan_scale`], reference candidate
648/// construction, or [`prove_scale`].
649#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
650#[non_exhaustive]
651pub enum ScaleError {
652 /// The whole-document conversion factor is not finite and positive.
653 #[error("scale factor must be finite and positive, got {factor}")]
654 InvalidFactor {
655 /// The rejected factor.
656 factor: f64,
657 },
658 /// The declared rest/bind expected factor is not finite and positive.
659 #[error("rest/bind expected factor must be finite and positive, got {factor}")]
660 InvalidExpectedFactor {
661 /// The rejected factor.
662 factor: f64,
663 },
664 /// A declared factor (or a factor derived from it, such as the rest/bind
665 /// reciprocal `1 / expected_factor`) is finite and positive in `f64` but
666 /// has no usable `f32` image at the writer model boundary: it either
667 /// overflows to infinity or flushes a nonzero factor to zero.
668 ///
669 /// This is deliberately a distinct variant from
670 /// [`ScaleError::InvalidFactor`] / [`ScaleError::InvalidExpectedFactor`],
671 /// whose message would be an outright lie here — `1e-50` *is* finite and
672 /// positive; what it is not is representable once the model narrows to
673 /// `f32`. Rejecting it at plan time is what stops a build from silently
674 /// multiplying every translation, mesh `POSITION`, and inverse-bind
675 /// translation by `0.0f32` and handing the annihilated document to a
676 /// proof that then signs off on it, because `0 == 0 * 0` within any
677 /// tolerance.
678 #[error(
679 "factor {factor} (derived from declared factor {declared}) is not representable at the f32 writer model boundary: it narrows to {narrowed}"
680 )]
681 FactorNotRepresentable {
682 /// The declared factor the caller supplied.
683 declared: f64,
684 /// The declared factor, or the reciprocal derived from it, that
685 /// failed to narrow. Equal to `declared` when the declared factor
686 /// itself failed.
687 factor: f64,
688 /// The unusable `f32` image of `factor`.
689 narrowed: f32,
690 },
691 /// `source_root_node_index` is not a source node in the document's
692 /// source skeleton.
693 #[error(
694 "source root node index {source_root_node_index} is not a source node in the document's source skeleton"
695 )]
696 InvalidRootSelector {
697 /// The rejected source-node index.
698 source_root_node_index: usize,
699 },
700 /// `source_skin_index` is not a skin in the document's source skeleton,
701 /// or the skin declares no joints.
702 #[error(
703 "source skin index {source_skin_index} is not a skin in the document's source skeleton, or has no joints"
704 )]
705 InvalidSkinSelector {
706 /// The rejected source-skin index.
707 source_skin_index: usize,
708 },
709 /// The capability projection is unavailable or declares an unsupported
710 /// domain.
711 #[error("capability projection is incomplete or declares unsupported domain(s)")]
712 IncompleteCapability,
713 /// `document.assets.source_skeleton` does not declare complete coverage.
714 #[error(
715 "document.assets.source_skeleton coverage is not complete: rest/bind planning requires a format-neutral source-node/source-skin projection"
716 )]
717 IncompleteSourceSkeleton,
718 /// A selected root, selected skin joint, or terminal affected source row
719 /// did not normalize to a document skeleton bone. Unprojected rows are
720 /// otherwise accepted only when they are strict connectors between
721 /// projected rows.
722 #[error("source node {source_node_index} did not normalize to a document skeleton bone")]
723 SourceNodeNotNormalized {
724 /// The unnormalized source-node index.
725 source_node_index: usize,
726 },
727 /// A raw source-node rest transform is non-finite.
728 #[error("source node {source_node_index} has a non-finite raw rest transform")]
729 NonFiniteSourceTransform {
730 /// The source node with the non-finite transform.
731 source_node_index: usize,
732 },
733 /// A transform composed during scale planning, candidate construction,
734 /// or proof is non-finite. Entry-time skeleton rest failures are reported
735 /// through [`ScaleError::InvalidDocumentShape`].
736 #[error("node {node} has a non-finite rest transform")]
737 NonFiniteTransform {
738 /// The node with the non-finite transform.
739 node: BoneId,
740 },
741 /// A runtime scale walk encountered a parent that cannot be resolved.
742 /// Entry-time skeleton topology failures are reported through
743 /// [`ScaleError::InvalidDocumentShape`].
744 #[error("node {node} has invalid parent {parent}")]
745 InvalidParent {
746 /// The node with an invalid parent.
747 node: BoneId,
748 /// The invalid parent index.
749 parent: BoneId,
750 },
751 /// A plan or document reference a bone index outside
752 /// `document.skeleton.bones` for the document actually supplied.
753 ///
754 /// This guards every boundary where a [`ScalePlan`] built from one
755 /// document could be replayed against a different one: [`ScalePlan`]
756 /// has no public constructor other than [`plan_scale`], but
757 /// reference candidate construction and [`prove_scale`] each take the
758 /// document to operate on as a separate argument and must not trust that
759 /// it still matches the plan's shape.
760 #[error("bone index {index} is out of range for this document")]
761 BoneIndexOutOfRange {
762 /// The out-of-range index.
763 index: usize,
764 },
765 /// A plan replayed against a document derives a different write or proof
766 /// inventory than it did when planned.
767 ///
768 /// Plans may be reused across numerically different documents, but only
769 /// while re-deriving the supplied source's structural planning inventory
770 /// selects the same complete domain. Otherwise a stale affected-node list
771 /// or evidence flag could leave newly introduced payload outside every
772 /// proof walk.
773 #[error("plan does not describe the supplied document: {reason}")]
774 PlanDocumentMismatch {
775 /// Stable machine-readable mismatch kind.
776 reason: &'static str,
777 },
778 /// The affected closure could not be completed.
779 #[error("affected domain closure is not complete: {reason}")]
780 IncompleteClosure {
781 /// Stable machine-readable reason.
782 reason: &'static str,
783 },
784 /// Unskinned geometry is attached inside the affected closure.
785 #[error("node {node} carries unskinned geometry inside the affected closure")]
786 UnsupportedUnskinnedGeometry {
787 /// The node carrying unskinned geometry.
788 node: BoneId,
789 },
790 /// A node's rest-world linear part is outside the supported affine class.
791 #[error(
792 "node {node} rest-world linear part is not orientation-preserving positive uniform scale ({reason:?})"
793 )]
794 InvalidAffineDomain {
795 /// The rejected node.
796 node: BoneId,
797 /// Stable machine-readable violation kind.
798 reason: AffineDomainViolation,
799 },
800 /// The declared `expected_factor` does not match the source's observed
801 /// common factor.
802 #[error("declared expected factor {expected} does not match observed source factor {observed}")]
803 FactorMismatch {
804 /// Declared expected factor.
805 expected: f64,
806 /// Observed source factor.
807 observed: f64,
808 },
809 /// One node's effective factor differs from the domain's common factor.
810 #[error("node {node} effective factor {observed} differs from common factor {expected}")]
811 MixedFactor {
812 /// The domain's common factor.
813 expected: f64,
814 /// The node's observed factor.
815 observed: f64,
816 /// The node with the mismatched factor.
817 node: BoneId,
818 },
819 /// A proof residual exceeded the fixed tolerance policy.
820 #[error("proof residual {observed} for {kind:?} exceeds tolerance {tolerance}")]
821 ProofResidualExceeded {
822 /// Which proof obligation failed.
823 kind: ProofResidualKind,
824 /// Observed residual.
825 observed: f64,
826 /// Tolerance the residual exceeded.
827 tolerance: f64,
828 },
829 /// The document's sampled proof work exceeds
830 /// [`ScaleTolerancePolicy::proof_sample_work_budget`].
831 ///
832 /// Raised by [`prove_scale`] *before* any sample time is evaluated, so a
833 /// document whose key count and vertex count multiply out beyond the
834 /// versioned policy's budget is refused outright rather than proved
835 /// against a silently truncated subset of its sample times. The budget is
836 /// a property of the policy identity recorded in evidence, not a per-run
837 /// flag.
838 #[error(
839 "proof sampling work {work} ({sample_times} sample times x {per_sample_cost} work units) exceeds the {policy_id} budget {budget}"
840 )]
841 ProofSamplingBudgetExceeded {
842 /// The tolerance-policy identity whose budget was exceeded.
843 policy_id: &'static str,
844 /// Distinct sample times the plan's obligations would evaluate,
845 /// summed over every clip.
846 sample_times: u64,
847 /// Work units one sample time costs: `bone_count` plus the vertex
848 /// count of every skinned instance inside the affected closure.
849 per_sample_cost: u64,
850 /// `sample_times * per_sample_cost`, saturating.
851 work: u64,
852 /// The policy's [`ScaleTolerancePolicy::proof_sample_work_budget`].
853 budget: u64,
854 },
855 /// The plan's typed obligation ledger declared a claim provable, but
856 /// [`prove_scale`] could not find the evidence to check it (for example
857 /// a clip or track present in `source` with no counterpart in
858 /// `candidate`). This is a distinct failure from
859 /// [`ScaleError::ProofResidualExceeded`]: the claim was never checked at
860 /// all, so proof must fail rather than silently report a zero residual.
861 #[error("proof obligation {kind:?} could not find expected evidence ({detail})")]
862 MissingProofEvidence {
863 /// Which proof obligation was left unchecked.
864 kind: ProofResidualKind,
865 /// Stable machine-readable reason.
866 detail: &'static str,
867 },
868 /// The shared model shape required by strict mutating operations is
869 /// malformed. [`Document`] is publicly mutable, so planning, building,
870 /// and proof validate each supplied snapshot independently.
871 #[error(transparent)]
872 InvalidDocumentShape(#[from] DocumentShapeError),
873 /// No inverse-bind evidence exists for a skin joint: the owning mesh
874 /// instance declares an empty `skin_ibms` (falling back to the bone's
875 /// own [`crate::model::Bone::inverse_bind`]) and that bone also has no
876 /// inverse-bind matrix. Identity is never substituted for genuinely
877 /// missing evidence — only for a source skin whose complete-coverage
878 /// [`crate::model::SourceSkinAsset::inverse_bind_accessor`] proves the
879 /// format-defined identity default with
880 /// [`crate::model::SourceInverseBindAccessorStatus::Absent`] (checked
881 /// internally by this module's private inverse-bind resolution).
882 #[error("no inverse-bind evidence for skin joint {node}")]
883 MissingInverseBind {
884 /// The joint with no inverse-bind evidence.
885 node: BoneId,
886 },
887 /// A mesh primitive is malformed independently of any skin: a non-finite
888 /// base `POSITION`.
889 ///
890 /// Checked at every public entry point, on the candidate as well as the
891 /// input, because base `POSITION` is a rewritten domain: without it a
892 /// whole-document build with an overflowing factor returns a document
893 /// full of non-finite vertices as `Ok`.
894 #[error("mesh {mesh_index} primitive {primitive_index} is invalid ({reason})")]
895 InvalidMeshPrimitive {
896 /// Index into `document.assets.meshes` of the offending mesh.
897 mesh_index: usize,
898 /// Index into that mesh's `primitives` of the offending primitive.
899 primitive_index: usize,
900 /// Stable machine-readable reason.
901 reason: &'static str,
902 },
903 /// A primary skin-weight attribute contains a finite negative value.
904 ///
905 /// Skin weights are coefficients of a convex blend, never signed affine
906 /// coefficients. Refusing this at the shared scale-input boundary keeps
907 /// planning, candidate construction, and proof on that one semantic
908 /// domain and gives evidence consumers a stable kind without requiring
909 /// them to parse a [`ScaleError::InvalidMeshPrimitive`] reason string.
910 #[error(
911 "mesh {mesh_index} primitive {primitive_index} vertex {vertex_index} primary skin influence {influence_index} has a negative weight"
912 )]
913 NegativeSkinWeight {
914 /// Index into `document.assets.meshes`.
915 mesh_index: usize,
916 /// Index into that mesh's `primitives`.
917 primitive_index: usize,
918 /// Vertex carrying the rejected weight tuple.
919 vertex_index: usize,
920 /// Component within the primary four-influence tuple.
921 influence_index: usize,
922 },
923 /// A skinned primitive is malformed: `joints`/`weights` shorter than
924 /// `positions`, a non-finite position or weight, a joint-influence slot
925 /// outside the owning instance's `skin_joints`, or a skinned result that
926 /// is not finite.
927 ///
928 /// The last case reports two distinct `reason`s. A skinned position that
929 /// left the `f32` range is `"skinned_magnitude_overflow"`: the document's
930 /// geometry does not fit the arithmetic this proof runs in. A `NaN` is
931 /// `"non_finite_result"`: an input that survived every finiteness check
932 /// above is degenerate in some other way. Both fail closed; neither is
933 /// bounded by a magnitude domain, because skinning accumulates a dot
934 /// product per axis and where that overflows depends on the rotation
935 /// rather than on the magnitude of the result.
936 #[error("instance {instance_index} primitive {primitive_index} is invalid ({reason})")]
937 InvalidSkinnedPrimitive {
938 /// Index into `document.assets.instances` of the owning instance.
939 instance_index: usize,
940 /// Index into the owning mesh's `primitives` of the offending
941 /// primitive.
942 primitive_index: usize,
943 /// Stable machine-readable reason.
944 reason: &'static str,
945 },
946 /// `candidate`'s skeleton/source-projection, clip/track/instance/mesh/
947 /// primitive structure does not match `source`'s, or an exact unchanged
948 /// semantic value differs. This includes a changed parent or source-node
949 /// projection, a changed world-rest affine outside a rest/bind closure, a
950 /// missing or extra clip, track, instance, mesh, or primitive, a track
951 /// whose identity, interpolation, times, or value shape disagrees with
952 /// its source counterpart, or a mesh instance whose identity — the node
953 /// it hangs off, the source node it came from, the mesh it draws, or the
954 /// joints it binds — disagrees with its source counterpart. Proof pairs
955 /// source and candidate structure by identity or index, which requires
956 /// this parity to hold. For rest/bind this also covers an admitted static
957 /// connector local that changed bits or a projected successor whose raw
958 /// local is not the independently derived bridged rebase. An extra,
959 /// missing, re-parented, relocated, or otherwise rewritten unchanged
960 /// value is never silently ignored.
961 #[error("candidate document structure does not match source ({reason})")]
962 CandidateStructureMismatch {
963 /// Stable machine-readable reason.
964 reason: &'static str,
965 },
966}
967
968/// Which proof obligation produced a [`ScaleError::ProofResidualExceeded`]
969/// or [`ScaleError::MissingProofEvidence`].
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971#[non_exhaustive]
972pub enum ProofResidualKind {
973 /// Rest-world translation residual.
974 RestTranslation,
975 /// Rest-world rotation residual.
976 RestRotation,
977 /// Postcondition unit-scale residual.
978 UnitScale,
979 /// Transform-only attachment full-affine residual (an off-origin point
980 /// transformed through the expected and actual world matrix), per
981 /// DESIGN.md Appendix D §D.2/§D.6.
982 TransformOnlyAffine,
983 /// Per-element animation-track value residual, checked directly against
984 /// each domain's analytic expectation: a rewritten translation element
985 /// (value *or* cubic tangent) against `before * multiplier`, and every
986 /// retained rotation/scale element against `before` itself.
987 ///
988 /// Distinct from [`Self::KeyTranslation`], which samples the *composed*
989 /// track at key times: sampling proves what an evaluator would read, but
990 /// only a direct element comparison proves both that rewritten domains
991 /// received their declared multiplier and that domains this plan declares
992 /// untouched really are untouched.
993 TrackValue,
994 /// Base mesh `POSITION` residual, per vertex, against this operation's
995 /// analytic expectation (`before * q` for whole-document conversion,
996 /// `before` for rest/bind reparameterization).
997 MeshPosition,
998 /// Keyframe-time translation residual.
999 KeyTranslation,
1000 /// Cubic-segment interior-time translation residual.
1001 CubicInterior,
1002 /// Sampled world-space trajectory residual.
1003 Trajectory,
1004 /// Skin-matrix (`W * B`) residual.
1005 SkinMatrix,
1006 /// Skinned mesh bounds residual.
1007 Bounds,
1008 /// Effective inverse-bind residual for a skin slot *outside* the affected
1009 /// closure — a skin neither operation touches, whose binds must therefore
1010 /// come through unchanged.
1011 ///
1012 /// Slots inside the closure are covered, more strongly, by
1013 /// [`Self::SkinMatrix`]: that obligation compares the composed `W * B`,
1014 /// which is what actually deforms a vertex. Outside the closure there is
1015 /// no rebase to compose against, so each slot is compared through the
1016 /// model's inverse-bind fallback chain.
1017 UnaffectedInverseBind,
1018 /// The factor [`prove_scale`] re-derived from the documents it was given.
1019 /// Only ever reported as [`ScaleError::MissingProofEvidence`]: it names a
1020 /// source whose scaled root the proof could not resolve, never a residual.
1021 ObservedFactor,
1022}
1023
1024// --- Plan --------------------------------------------------------------
1025
1026/// The structural semantic operation a rewritten field receives.
1027///
1028/// No variant stores a resolved factor or expected value. Candidate
1029/// construction and proof independently resolve the selected operation's
1030/// numeric arithmetic from the field identity and topology.
1031#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1032#[non_exhaustive]
1033pub enum ScaleRewriteRule {
1034 /// A whole-document linear-unit length field.
1035 WholeDocumentLength,
1036 /// A rest/bind field governed by the target node's parent-basis factor.
1037 RestBindParentBasis,
1038 /// A rest/bind field governed by the local `s_parent / s_node` rebase.
1039 RestBindLocalScale,
1040 /// A rest/bind inverse bind governed by its joint's node-basis factor.
1041 RestBindNodeBasis,
1042 /// A projected source-local rest, optionally bridged through connectors.
1043 RestBindSourceLocal {
1044 /// The immediate connector tail below the projected parent, if any.
1045 connector_tail: Option<usize>,
1046 },
1047}
1048
1049/// Whether a modeled field is preserved exactly or analytically rewritten.
1050#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1051#[non_exhaustive]
1052pub enum ScaleFieldDisposition {
1053 /// The field is outside the core builder's write set.
1054 ///
1055 /// This is ownership, not a universal normalized-artifact equality
1056 /// promise: format frontends may independently re-derive normalized
1057 /// bones, binds, tracks, or meshes within the established residual
1058 /// policy. Authored raw source-local fields copied by the core builder are
1059 /// additionally checked bit-exact by [`prove_scale`].
1060 PreserveExact,
1061 /// The field is in the write set and receives the stated semantic rule.
1062 Rewrite(ScaleRewriteRule),
1063}
1064
1065/// One normalized bone-rest field.
1066#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067#[non_exhaustive]
1068pub enum ScaleBoneRestField {
1069 /// Local translation.
1070 Translation,
1071 /// Local rotation.
1072 Rotation,
1073 /// Local scale.
1074 Scale,
1075}
1076
1077/// One authored source-node rest field or component group.
1078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1079#[non_exhaustive]
1080pub enum ScaleSourceRestField {
1081 /// TRS translation.
1082 Translation,
1083 /// TRS rotation.
1084 Rotation,
1085 /// TRS scale.
1086 Scale,
1087 /// Matrix linear columns.
1088 MatrixLinear,
1089 /// Matrix translation column.
1090 MatrixTranslation,
1091 /// Matrix homogeneous row.
1092 MatrixHomogeneous,
1093}
1094
1095/// The exact container-level target of one field disposition.
1096#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1097#[non_exhaustive]
1098pub enum ScaleFieldTarget {
1099 /// One normalized bone-rest field.
1100 BoneRest {
1101 /// Normalized bone identity.
1102 bone: BoneId,
1103 /// Rest field.
1104 field: ScaleBoneRestField,
1105 },
1106 /// One authored source-node rest field.
1107 SourceNodeRest {
1108 /// Raw source-node identity.
1109 source_node_index: usize,
1110 /// Rest field or component group.
1111 field: ScaleSourceRestField,
1112 },
1113 /// One animation track's stored values.
1114 AnimationValues {
1115 /// Clip position in the normalized document.
1116 clip_index: usize,
1117 /// Track position inside the clip.
1118 track_index: usize,
1119 /// Target normalized bone.
1120 bone: BoneId,
1121 /// Animated property.
1122 property: Property,
1123 },
1124 /// One bone convenience inverse bind.
1125 BoneInverseBind {
1126 /// Normalized bone identity.
1127 bone: BoneId,
1128 },
1129 /// One logical instance inverse-bind slot.
1130 InstanceInverseBind {
1131 /// Instance position in the normalized document.
1132 instance_index: usize,
1133 /// Slot position inside the instance skin.
1134 slot: usize,
1135 /// Joint named by the slot.
1136 joint: BoneId,
1137 },
1138 /// One primitive's complete base-position array.
1139 MeshPositions {
1140 /// Mesh position in the normalized document.
1141 mesh_index: usize,
1142 /// Primitive position inside the mesh.
1143 primitive_index: usize,
1144 },
1145 /// One primitive's preserved normal array.
1146 MeshNormals {
1147 /// Mesh position in the normalized document.
1148 mesh_index: usize,
1149 /// Primitive position inside the mesh.
1150 primitive_index: usize,
1151 },
1152}
1153
1154/// One exact semantic field row in a compiled scale plan.
1155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1156#[non_exhaustive]
1157pub struct ScaleFieldPlan {
1158 target: ScaleFieldTarget,
1159 disposition: ScaleFieldDisposition,
1160 element_count: usize,
1161}
1162
1163impl ScaleFieldPlan {
1164 /// The exact container-level field target.
1165 pub fn target(&self) -> ScaleFieldTarget {
1166 self.target
1167 }
1168
1169 /// Whether and how the target is rewritten.
1170 pub fn disposition(&self) -> ScaleFieldDisposition {
1171 self.disposition
1172 }
1173
1174 /// Number of stored elements covered by the container row.
1175 pub fn element_count(&self) -> usize {
1176 self.element_count
1177 }
1178}
1179
1180/// One numeric-value-free payload-shape row used by stale-plan replay.
1181///
1182/// Rows include empty containers and structural identities/counts, but never
1183/// key times or stored floating-point values, so intentional numeric replay
1184/// against an identically shaped document remains supported.
1185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1186#[non_exhaustive]
1187pub enum ScalePayloadShapeRow {
1188 /// Top-level normalized collection counts.
1189 Document {
1190 /// Skeleton bone count.
1191 bone_count: usize,
1192 /// Authoritative source-skeleton node count; zero under unavailable
1193 /// coverage.
1194 source_node_count: usize,
1195 /// Whether the source projection claims complete coverage.
1196 source_coverage: SourceSkeletonCoverage,
1197 /// Clip count.
1198 clip_count: usize,
1199 /// Mesh-instance count.
1200 instance_count: usize,
1201 /// Mesh count.
1202 mesh_count: usize,
1203 },
1204 /// One normalized topology row.
1205 Bone {
1206 /// Normalized bone identity.
1207 bone: BoneId,
1208 /// Normalized parent identity.
1209 parent: Option<BoneId>,
1210 },
1211 /// One source skin's complete structural inventory.
1212 SourceSkin {
1213 /// Raw source-skin identity.
1214 source_skin_index: usize,
1215 /// Explicit source skeleton root.
1216 skeleton_root_source_node_index: Option<usize>,
1217 /// Declared joint count.
1218 joint_count: usize,
1219 /// Attachment count.
1220 attachment_count: usize,
1221 /// Inverse-bind accessor status.
1222 inverse_bind_status: SourceInverseBindAccessorStatus,
1223 /// Declared inverse-bind accessor count.
1224 inverse_bind_declared_count: Option<usize>,
1225 /// Number of readable matrices retained.
1226 inverse_bind_matrix_count: usize,
1227 },
1228 /// One ordered source-skin joint identity.
1229 SourceSkinJoint {
1230 /// Raw source-skin identity.
1231 source_skin_index: usize,
1232 /// Joint slot.
1233 slot: usize,
1234 /// Raw source-node identity.
1235 source_node_index: usize,
1236 },
1237 /// One ordered source-skin attachment identity.
1238 SourceSkinAttachment {
1239 /// Raw source-skin identity.
1240 source_skin_index: usize,
1241 /// Attachment position.
1242 attachment_index: usize,
1243 /// Raw attachment node identity.
1244 source_node_index: usize,
1245 /// Raw mesh identity, when declared.
1246 source_mesh_index: Option<usize>,
1247 },
1248 /// One clip, including an empty clip.
1249 Clip {
1250 /// Clip position.
1251 clip_index: usize,
1252 /// Track count.
1253 track_count: usize,
1254 },
1255 /// One track's structural identity and arities.
1256 Track {
1257 /// Clip position.
1258 clip_index: usize,
1259 /// Track position.
1260 track_index: usize,
1261 /// Target bone.
1262 bone: BoneId,
1263 /// Animated property.
1264 property: Property,
1265 /// Interpolation mode.
1266 interpolation: Interpolation,
1267 /// Number of key times, without storing their numeric values.
1268 key_count: usize,
1269 /// Number of stored value elements.
1270 value_count: usize,
1271 },
1272 /// One mesh instance, including an unskinned instance.
1273 Instance {
1274 /// Instance position.
1275 instance_index: usize,
1276 /// Normalized attachment node.
1277 node: BoneId,
1278 /// Raw source-node attachment identity.
1279 source_node_index: usize,
1280 /// Mesh identity.
1281 mesh: usize,
1282 /// Logical joint-slot count.
1283 joint_count: usize,
1284 /// Stored instance inverse-bind count.
1285 inverse_bind_count: usize,
1286 },
1287 /// One logical joint slot, preserving slot order and identity.
1288 InstanceJoint {
1289 /// Instance position.
1290 instance_index: usize,
1291 /// Slot position.
1292 slot: usize,
1293 /// Joint identity.
1294 joint: BoneId,
1295 },
1296 /// One mesh, including an empty mesh.
1297 Mesh {
1298 /// Mesh position.
1299 mesh_index: usize,
1300 /// Stable source mesh identity.
1301 source_mesh_index: usize,
1302 /// Primitive count.
1303 primitive_count: usize,
1304 },
1305 /// One primitive's modeled shape.
1306 Primitive {
1307 /// Mesh position.
1308 mesh_index: usize,
1309 /// Primitive position.
1310 primitive_index: usize,
1311 /// Base-position count.
1312 position_count: usize,
1313 /// Preserved normal count.
1314 normal_count: usize,
1315 /// Primary joint-tuple count read by skin/bounds proof.
1316 joint_count: usize,
1317 /// Primary weight-tuple count read by skin/bounds proof.
1318 weight_count: usize,
1319 },
1320}
1321
1322/// One typed proof claim kind derived from the plan's validated inventory.
1323///
1324/// Exact members are not duplicated here: inspect [`ScalePlan::affected_nodes`],
1325/// [`ScalePlan::transform_only_attachments`], and the field, payload, and
1326/// topology rows exposed by [`ScalePlan::ledger`].
1327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1328#[non_exhaustive]
1329pub enum ScaleProofObligation {
1330 /// Preserve normalized parents and complete source projection topology.
1331 ExactTopology,
1332 /// Preserve clip, track, instance, skin, mesh, and primitive identities.
1333 ExactPayloadIdentity,
1334 /// Preserve exact world rest for the nodes outside a rest/bind closure.
1335 ExactUnchangedWorldRest,
1336 /// Prove affected rest-world translation and orientation.
1337 RestWorld,
1338 /// Prove affected rest-world facts and the nested unit-scale postcondition.
1339 RestWorldAndUnitScale,
1340 /// Probe complete expected affines of transform-only attachments.
1341 TransformOnlyAffine,
1342 /// Compare all rewritten and preserved animation values.
1343 TrackValues,
1344 /// Compare all rewritten and preserved base positions.
1345 MeshPositions,
1346 /// Compare affected translation tracks at their key times.
1347 KeyTranslations,
1348 /// Compare affected translation tracks at bounded cubic interior times.
1349 CubicInteriors,
1350 /// Compare sampled world-space trajectories.
1351 Trajectories,
1352 /// Run the one shared affected-skin walk producing skin and bounds results.
1353 SkinAndBounds,
1354 /// Check rewritten inverse-bind slots.
1355 AffectedInverseBinds,
1356 /// Check preserved inverse-bind slots outside the closure.
1357 UnaffectedInverseBinds,
1358 /// Preserve connector locals and check bridged projected successors.
1359 ExactConnectorProjection,
1360}
1361
1362/// A projected source row's role in a rest/bind topology.
1363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1364#[non_exhaustive]
1365pub enum ScaleProjectedRole {
1366 /// Selected scaled root.
1367 Root,
1368 /// Selected skin joint other than the root.
1369 Joint,
1370 /// Affected non-joint attachment or path node.
1371 TransformOnly,
1372}
1373
1374/// The typed kind of one canonical rest/bind source-topology row.
1375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1376#[non_exhaustive]
1377pub enum ScaleSourceNodeKind {
1378 /// A source node projected into the normalized skeleton.
1379 Projected {
1380 /// Normalized bone identity.
1381 bone: BoneId,
1382 /// Root, joint, or transform-only role.
1383 role: ScaleProjectedRole,
1384 /// Nearest projected parent in source identity space.
1385 projected_parent: Option<usize>,
1386 /// Immediate connector tail below that projected parent, if any.
1387 incoming_connector_tail: Option<usize>,
1388 },
1389 /// A static unprojected connector preserved exactly.
1390 Connector,
1391 /// A source row outside a rest/bind domain, or any row in a
1392 /// whole-document plan where connector roles are not applicable.
1393 OutsideDomain {
1394 /// Normalized projection identity, if one exists.
1395 bone: Option<BoneId>,
1396 },
1397}
1398
1399/// One row in the canonical source-keyed rest/bind topology.
1400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1401#[non_exhaustive]
1402pub struct ScaleSourceTopologyRow {
1403 source_node_index: usize,
1404 parent_source_node_index: Option<usize>,
1405 kind: ScaleSourceNodeKind,
1406}
1407
1408#[derive(Debug, Clone, PartialEq, Eq)]
1409struct ScaleLedger {
1410 field_rows: Vec<ScaleFieldPlan>,
1411 payload_shapes: Vec<ScalePayloadShapeRow>,
1412 obligations: Vec<ScaleProofObligation>,
1413}
1414
1415#[derive(Debug, Clone, PartialEq)]
1416struct WholeDocumentParams {
1417 factor: f64,
1418}
1419
1420#[derive(Debug, Clone, PartialEq)]
1421struct RestBindParams {
1422 source_skin_index: usize,
1423 source_root_node_index: usize,
1424 expected_factor: f64,
1425 transform_only_attachments: Vec<BoneId>,
1426}
1427
1428#[derive(Debug, Clone, PartialEq)]
1429enum ScaleCompiledPlan {
1430 WholeDocument(WholeDocumentParams),
1431 RestBind(RestBindParams),
1432}
1433
1434/// Read-only view of one compiled plan's exact domain, field, topology, and
1435/// proof-obligation ledger.
1436///
1437/// The view has no constructor and cannot be converted back into a
1438/// [`ScalePlan`]; only [`plan_scale`] can compile an authoritative ledger.
1439#[derive(Debug, Clone, Copy)]
1440pub struct ScalePlanLedger<'a> {
1441 plan: &'a ScalePlan,
1442}
1443
1444impl<'a> ScalePlanLedger<'a> {
1445 fn ledger(self) -> &'a ScaleLedger {
1446 &self.plan.ledger
1447 }
1448
1449 /// Exact container-level modeled field rows in deterministic source order.
1450 pub fn field_rows(self) -> std::slice::Iter<'a, ScaleFieldPlan> {
1451 self.ledger().field_rows.iter()
1452 }
1453
1454 /// Numeric-value-free payload-shape rows, including empty containers.
1455 pub fn payload_shapes(self) -> std::slice::Iter<'a, ScalePayloadShapeRow> {
1456 self.ledger().payload_shapes.iter()
1457 }
1458
1459 /// Typed proof obligations derived from the same field and payload inventory.
1460 pub fn obligations(self) -> std::slice::Iter<'a, ScaleProofObligation> {
1461 self.ledger().obligations.iter()
1462 }
1463
1464 /// Canonical source-keyed topology for the complete modeled projection.
1465 pub fn source_topology(self) -> std::slice::Iter<'a, ScaleSourceTopologyRow> {
1466 self.plan.source_topology.iter()
1467 }
1468}
1469
1470impl ScaleSourceTopologyRow {
1471 /// Raw source-node identity.
1472 pub fn source_node_index(&self) -> usize {
1473 self.source_node_index
1474 }
1475
1476 /// Authoritative raw parent identity.
1477 pub fn parent_source_node_index(&self) -> Option<usize> {
1478 self.parent_source_node_index
1479 }
1480
1481 /// Whether this row is projected or is a preserved connector.
1482 pub fn kind(&self) -> ScaleSourceNodeKind {
1483 self.kind
1484 }
1485}
1486
1487/// Pure, typed plan returned by [`plan_scale`].
1488///
1489/// Planning never mutates its input document; it only inspects it. Reference
1490/// candidate construction from an accepted plan is a distinct, separately
1491/// fallible fixture step.
1492///
1493/// Every field is private: a [`ScalePlan`] can only be produced by
1494/// [`plan_scale`], so an external caller cannot hand-construct or mutate one
1495/// into a state whose `affected_nodes` disagree with `operation`'s
1496/// selectors. Read plan contents through the accessor methods.
1497#[derive(Debug, Clone, PartialEq)]
1498#[non_exhaustive]
1499pub struct ScalePlan {
1500 tolerance_policy: ScaleTolerancePolicy,
1501 observed_factor: f64,
1502 affected_nodes: Vec<BoneId>,
1503 source_topology: Vec<ScaleSourceTopologyRow>,
1504 ledger: ScaleLedger,
1505 compiled: ScaleCompiledPlan,
1506}
1507
1508impl ScalePlan {
1509 /// Echoed operation and its declared parameters.
1510 pub fn operation(&self) -> ScaleOperation {
1511 match &self.compiled {
1512 ScaleCompiledPlan::WholeDocument(plan) => ScaleOperation::WholeDocumentLinearUnits {
1513 factor: plan.factor,
1514 },
1515 ScaleCompiledPlan::RestBind(plan) => ScaleOperation::RestBindUniformScale {
1516 source_skin_index: plan.source_skin_index,
1517 source_root_node_index: plan.source_root_node_index,
1518 expected_factor: plan.expected_factor,
1519 },
1520 }
1521 }
1522
1523 /// The fixed tolerance policy this plan and its proof share.
1524 pub fn tolerance_policy(&self) -> ScaleTolerancePolicy {
1525 self.tolerance_policy
1526 }
1527
1528 /// Affected normalized-node closure, in ascending bone-id order.
1529 ///
1530 /// For [`ScaleOperation::WholeDocumentLinearUnits`] this is every node
1531 /// in the document. For [`ScaleOperation::RestBindUniformScale`] this is
1532 /// the closed connected hierarchy of DESIGN.md Appendix D §D.2: the
1533 /// scaled ancestor, every selected skin joint and the normalized paths
1534 /// between them, and every descendant transform-only attachment. Raw
1535 /// source-only connector rows on those paths are not normalized nodes
1536 /// and therefore do not appear in this list.
1537 pub fn affected_nodes(&self) -> &[BoneId] {
1538 &self.affected_nodes
1539 }
1540
1541 /// Descendant nodes in [`Self::affected_nodes`] that carry no skin —
1542 /// the "transform-only child" case of DESIGN.md Appendix D §D.2/§D.3.
1543 /// Always empty for [`ScaleOperation::WholeDocumentLinearUnits`].
1544 pub fn transform_only_attachments(&self) -> &[BoneId] {
1545 match &self.compiled {
1546 ScaleCompiledPlan::WholeDocument(_) => &[],
1547 ScaleCompiledPlan::RestBind(plan) => &plan.transform_only_attachments,
1548 }
1549 }
1550
1551 /// The one common factor `s` (or `q` for whole-document conversion)
1552 /// applied across [`Self::affected_nodes`].
1553 ///
1554 /// This is always the factor the *caller declared*, never the one
1555 /// measured from the source: reference construction applies exactly this
1556 /// value, and [`prove_scale`] states every analytic expectation in terms
1557 /// of it. [`Self::observed_factor`] reports the measured
1558 /// counterpart, and the two are separate numbers on purpose — DESIGN.md
1559 /// Appendix D §D.6 requires producer evidence to record both.
1560 pub fn common_factor(&self) -> f64 {
1561 match &self.compiled {
1562 ScaleCompiledPlan::WholeDocument(plan) => plan.factor,
1563 ScaleCompiledPlan::RestBind(plan) => plan.expected_factor,
1564 }
1565 }
1566
1567 /// The factor this plan *observed* in the source, as distinct from the
1568 /// caller-declared [`Self::common_factor`] the build applies.
1569 ///
1570 /// For [`ScaleOperation::RestBindUniformScale`] this is the rest-world
1571 /// uniform factor measured at the scaled root of DESIGN.md Appendix D
1572 /// §D.2 — the average of its rest-world linear part's three column
1573 /// lengths, the same quantity the domain classification returns. It is
1574 /// within [`ScaleTolerancePolicy::common_factor`] of
1575 /// [`Self::common_factor`] (planning rejects it otherwise with
1576 /// [`ScaleError::FactorMismatch`]) but is generally not equal to it: a
1577 /// source authored at `0.010_000_02` is accepted against a declared
1578 /// `0.01`, and both numbers belong in evidence.
1579 ///
1580 /// For [`ScaleOperation::WholeDocumentLinearUnits`] this equals
1581 /// [`Self::common_factor`] exactly, because there is nothing to measure.
1582 /// That operation's factor is *declared*, not observed: §D.1 states that
1583 /// a whole-document conversion "changes physical size", is "appropriate
1584 /// only when the source was authored in a different linear unit", and
1585 /// that neither operation "may infer its factor or applicability from
1586 /// mesh bounds, character height, joint lengths, inverse-bind magnitude,
1587 /// filename, or an asset category". A source authored in centimetres and
1588 /// one authored in metres are numerically identical documents, so no
1589 /// measurement of either could distinguish them; the declared factor is
1590 /// the only fact there is, and reporting it here keeps the evidence
1591 /// contract uniform across the two operations rather than leaving a hole
1592 /// a consumer would have to special-case.
1593 pub fn observed_factor(&self) -> f64 {
1594 self.observed_factor
1595 }
1596
1597 /// Validate this plan's complete structural inventory against `document`.
1598 ///
1599 /// This re-derives and exactly compares the affected domain, canonical
1600 /// source topology, transform-only attachments, payload shapes, field
1601 /// dispositions, and proof obligations. Numeric source values are not
1602 /// compared, so a document with the same structural ledger remains a
1603 /// valid replay source, but the replay document must still satisfy the
1604 /// finite-value and nonnegative-weight scale-input requirements.
1605 ///
1606 /// # Errors
1607 ///
1608 /// Returns [`ScaleError::PlanDocumentMismatch`] when the re-derived
1609 /// inventory differs, or the corresponding planning/input error when
1610 /// `document` cannot produce a valid inventory for this operation.
1611 pub fn validate_document_inventory(&self, document: &Document) -> Result<(), ScaleError> {
1612 validate_plan_document_inventory(document, self)
1613 }
1614
1615 /// Inspect the exact read-only topology, field, and obligation ledger.
1616 pub fn ledger(&self) -> ScalePlanLedger<'_> {
1617 ScalePlanLedger { plan: self }
1618 }
1619
1620 /// Resolve the effective multiplier for one animation track's stored
1621 /// values from this compiled plan.
1622 ///
1623 /// This is the assembly compatibility boundary from Appendix D §D.5: a
1624 /// producer can fingerprint the exact target-basis factor without
1625 /// reproducing rest/bind arithmetic outside the shared plan. Values and
1626 /// CUBICSPLINE tangents use the same multiplier because both occupy the
1627 /// track's one typed field row.
1628 ///
1629 /// # Errors
1630 ///
1631 /// Returns [`ScaleError::PlanDocumentMismatch`] when the document or
1632 /// requested track no longer matches the compiled ledger.
1633 pub fn animation_value_factor(
1634 &self,
1635 document: &Document,
1636 clip_index: usize,
1637 track_index: usize,
1638 ) -> Result<f64, ScaleError> {
1639 validate_plan_document_inventory(document, self)?;
1640 let row = self
1641 .field_rows()
1642 .iter()
1643 .find(|row| {
1644 matches!(
1645 row.target(),
1646 ScaleFieldTarget::AnimationValues {
1647 clip_index: candidate_clip,
1648 track_index: candidate_track,
1649 ..
1650 } if candidate_clip == clip_index && candidate_track == track_index
1651 )
1652 })
1653 .ok_or(ScaleError::PlanDocumentMismatch {
1654 reason: "compiled_animation_row_missing",
1655 })?;
1656 let ScaleFieldTarget::AnimationValues { bone, property, .. } = row.target() else {
1657 unreachable!("the selected row is an animation row")
1658 };
1659 self.animation_target_factor_unchecked(document, bone, property)
1660 }
1661
1662 /// Resolve the effective multiplier for an animation target basis.
1663 ///
1664 /// Unlike [`Self::animation_value_factor`], this accepts a semantic target
1665 /// rather than an existing track row. Character assembly uses it to
1666 /// compare a base skeleton with independently supplied clip files before
1667 /// any channel is copied or remapped. All factor arithmetic remains owned
1668 /// by the compiled plan.
1669 ///
1670 /// # Errors
1671 ///
1672 /// Returns [`ScaleError::PlanDocumentMismatch`] when `document` no longer
1673 /// matches the plan or the target is outside its skeleton.
1674 pub fn animation_target_factor(
1675 &self,
1676 document: &Document,
1677 bone: BoneId,
1678 property: Property,
1679 ) -> Result<f64, ScaleError> {
1680 validate_plan_document_inventory(document, self)?;
1681 self.animation_target_factor_unchecked(document, bone, property)
1682 }
1683
1684 pub(in crate::scale) fn animation_target_factor_unchecked(
1685 &self,
1686 document: &Document,
1687 bone: BoneId,
1688 property: Property,
1689 ) -> Result<f64, ScaleError> {
1690 let affected = self.affected_set();
1691 let node_factor = if affected.contains(&bone) {
1692 self.common_factor()
1693 } else {
1694 1.0
1695 };
1696 let parent_factor = document
1697 .skeleton
1698 .bones
1699 .get(bone)
1700 .ok_or(ScaleError::BoneIndexOutOfRange { index: bone })?
1701 .parent
1702 .filter(|parent| affected.contains(parent))
1703 .map_or(1.0, |_| self.common_factor());
1704 Ok(match (self.operation(), property) {
1705 (ScaleOperation::WholeDocumentLinearUnits { .. }, Property::Translation) => {
1706 self.common_factor()
1707 }
1708 (ScaleOperation::WholeDocumentLinearUnits { .. }, _) => 1.0,
1709 (ScaleOperation::RestBindUniformScale { .. }, Property::Translation) => parent_factor,
1710 (ScaleOperation::RestBindUniformScale { .. }, Property::Scale) => {
1711 parent_factor / node_factor
1712 }
1713 (ScaleOperation::RestBindUniformScale { .. }, Property::Rotation) => 1.0,
1714 })
1715 }
1716
1717 fn affected_set(&self) -> BTreeSet<BoneId> {
1718 self.affected_nodes().iter().copied().collect()
1719 }
1720
1721 fn field_rows(&self) -> &[ScaleFieldPlan] {
1722 &self.ledger.field_rows
1723 }
1724
1725 fn obligations(&self) -> &[ScaleProofObligation] {
1726 &self.ledger.obligations
1727 }
1728}
1729
1730#[cfg(test)]
1731mod tests;