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