spine-rigc 2.28.0 → 2.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * The skinning-envelope residual — `MQ_SKINNING_RESIDUAL` of
3
+ * `mesh-quality-report/1` (issue #1294, mechanism 1 of #1266):
4
+ * docs/MESH_REDUCTION.md §7 derives it and *Mechanism 1 — implemented* says
5
+ * what it is and what it is not. This module computes it for
6
+ * `measureMeshQuality` (`src/meshquality.ts`), which turns it into a row, and
7
+ * holds the helper that turns declared rotation and scale ranges into an
8
+ * envelope entry (`skinningEnvelopeBone`).
9
+ *
10
+ * ## The quantity
11
+ *
12
+ * A UV sample of the attachment (§3: every art pixel centre, every source hull
13
+ * vertex's UV) is carried by one triangle of each mesh — the original source
14
+ * and the candidate — at barycentric β (`uvCarriers`). In each mesh, per bone
15
+ * k, the carried point has the interpolated share w̄ₖ = Σᵢ βᵢ wᵢₖ, the setup
16
+ * position p = Σᵢ βᵢ xᵢ and the weighted position moment
17
+ * sₖ = Σᵢ βᵢ wᵢₖ (xᵢ − p). For any motion under which bone k's deformation
18
+ * Dₖ (posed ∘ setup⁻¹, in the drawing frame) differs from the reference bone's
19
+ * D₀ by a linear part of spectral norm at most εₖ and a lever
20
+ * |(Dₖ − D₀) q| ≤ εₖ |q − cₖ| + τₖ, the candidate draws the sample within
21
+ *
22
+ * Σₖ εₖ |sₖ(candidate) − sₖ(source)| + Σₖ |w̄ₖ(candidate) − w̄ₖ(source)| (εₖ |p − cₖ| + τₖ)
23
+ *
24
+ * of where the source draws it, p the source's setup position — provided each
25
+ * vertex's shares sum to 1 and both meshes place the sample at the same setup
26
+ * position. Those two are this measurement's contract, checked rather than
27
+ * assumed: a vertex whose shares miss 1 by more than half a weight-grid step,
28
+ * or whose point is off its UV carried through the frame by more than two
29
+ * `r6` half-steps, refuses the row by name. What the contract still lets
30
+ * through — the setup positions of one UV in the two meshes differing by
31
+ * rounding — is reported beside the value (`setup.sampleGap`), never folded
32
+ * into it: its term is ‖A₀ + Σₖ w̄ₖ (Aₖ − A₀)‖ |Δp|, and ‖A₀‖, the reference
33
+ * bone's own motion, is not something the envelope declares.
34
+ *
35
+ * ## What it never does
36
+ *
37
+ * - Pose, link the runtime, or read a frame: the envelope is declared, so the
38
+ * value is pose-free and frame-free. It bounds positions under the declared
39
+ * motion only — no orientation, stretch or squash, nothing outside the
40
+ * envelope — and never replaces `compareMeshesInMotion`.
41
+ * - Fill a gap. A bone a vertex binds that the envelope does not declare, an
42
+ * envelope bone the source does not bind, a deform key that moves vertices
43
+ * outside skinning, an unweighted mesh beside a weighted one: each refuses
44
+ * the row by name. No sample carried by both meshes, or fewer art samples
45
+ * than the floor, is `not-measurable` — never a value of 0.
46
+ * - Compare against anything but the source it is handed. The reference is
47
+ * the original supplied mesh, never a previous candidate.
48
+ *
49
+ * Pure: no clock, no randomness, no runtime. Every loop runs in index order,
50
+ * so one input reads one answer (A18's standard).
51
+ *
52
+ * ⚠️ This module imports types only from `src/meshquality.ts`, which imports
53
+ * it; `src/mesh.ts` re-exports both. As with the other mesh modules, nothing
54
+ * at the top level reads another module's bindings.
55
+ */
56
+ import { createHash } from 'node:crypto';
57
+ import { MeshReductionError, r6 } from './mesh.ts';
58
+ import { uvCarriers, type Carrier } from './meshcarriers.ts';
59
+ import type { AttachmentRef, DeformTimelineInput, SourceFrame, SourceMesh, WorstSample } from './meshquality.ts';
60
+
61
+ type Pt = readonly [number, number];
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // the input
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * The motion a candidate must stay correct under — declared, never posed. Part-local drawing px, y down (§7, Q3–Q5,
69
+ * rig-parts#126). Nothing in it has a default.
70
+ */
71
+ export interface SkinningEnvelope {
72
+ /** D₀: the bone every other bone's deformation is measured against — the slot's bone (Q4). Its own terms are 0. */
73
+ reference: string;
74
+ /** Every bone either mesh binds, other than `reference`, once each. */
75
+ bones: SkinningEnvelopeBone[];
76
+ }
77
+
78
+ /** One bone of a `SkinningEnvelope`: the bound on how its deformation differs from the reference's. */
79
+ export interface SkinningEnvelopeBone {
80
+ bone: string;
81
+ /**
82
+ * εₖ ≥ ‖Aₖ − A₀‖₂ over the motion, Aₖ and A₀ the linear parts of the two bones' deformations — dimensionless,
83
+ * finite, 0 or more. A rotation by up to θ relative to the reference is 2 sin(θ / 2); `skinningEnvelopeBone` derives
84
+ * it from declared rotation and scale ranges.
85
+ */
86
+ linear: number;
87
+ /** cₖ, drawing px: the pivot of the lever bound |(Dₖ − D₀) q| ≤ εₖ |q − cₖ| + τₖ — usually the bone's setup joint. */
88
+ pivot: [number, number];
89
+ /** τₖ, drawing px, 0 or more: the lever bound's constant — how far Dₖ and D₀ carry the pivot apart. */
90
+ translation: number;
91
+ }
92
+
93
+ /**
94
+ * `MeshMeasureInput.skinning` (issue #1294): the source the measured mesh is a candidate of, the envelope, and the
95
+ * bound. The measured mesh (`MeshMeasureInput.source`) is the candidate; this `source` is the comparison reference.
96
+ */
97
+ export interface SkinningResidualInput {
98
+ /**
99
+ * The original supplied mesh — never a previous candidate — and the caller's id for it. Its sha-256 over the mesh's
100
+ * own fields is reported beside the id (`SkinningEcho.source.digest`), so a report names the bytes it was measured
101
+ * against.
102
+ */
103
+ source: { id: string; mesh: SourceMesh };
104
+ envelope: SkinningEnvelope;
105
+ /** Drawing px: the row's bound, inclusive. `null` declares it absent — the row is measured and `undeclared`. */
106
+ maxResidual: number | null;
107
+ /**
108
+ * Every deform timeline keyed on the attachment or one of its linked meshes, as `MeshReductionInput.deform`; empty
109
+ * when none. A `vertices` or `transform` key moves vertices outside skinning, so any key but `setup` refuses the row
110
+ * (`SKINNING_DEFORM_UNSUPPORTED`, Q10).
111
+ */
112
+ deform: DeformTimelineInput[];
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // the report's fields
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** What `effective.skinning` echoes: the source by id and digest rather than in full, the envelope, the bound and the deform list. */
120
+ export interface SkinningEcho {
121
+ source: { id: string; digest: string };
122
+ envelope: SkinningEnvelope;
123
+ maxResidual: number | null;
124
+ deform: DeformTimelineInput[];
125
+ }
126
+
127
+ /** `MeasureRow.skinning`: written on `MQ_SKINNING_RESIDUAL` when it was measured, and on no other row. */
128
+ export interface SkinningDetail {
129
+ /** One fixed sentence: what the value is a reading of, and what it is not. */
130
+ reading: string;
131
+ /** The comparison reference: the caller's id and the sha-256 of its mesh. */
132
+ source: { id: string; digest: string };
133
+ /** The envelope's reference bone. */
134
+ reference: string;
135
+ /** Samples carried by both meshes — the ones the value is the largest over — and every sample one mesh does not carry, by UV. */
136
+ samples: { measured: number; uncarried: { source: Array<[number, number]>; candidate: Array<[number, number]> } };
137
+ /**
138
+ * At the worst sample: the value's two sums — `covariance`, Σₖ εₖ |Δsₖ|, and `lever`, Σₖ |Δw̄ₖ| (εₖ |p − cₖ| + τₖ) —
139
+ * and each envelope bone's share of each, in envelope order. Null when the value is 0 (nothing worse than ideal).
140
+ */
141
+ worst: { covariance: number; lever: number; bones: Array<{ bone: string; covariance: number; lever: number }> } | null;
142
+ /**
143
+ * The weight-field difference alone, over every bone the envelope names and its reference — the largest over
144
+ * samples of Σ |Δw̄| (`l1`) and of max |Δw̄| (`lInf`). A diagnostic: it reads 0 wherever the two fields agree, as
145
+ * on a linear ramp, however far the meshes draw apart, so it is never a guard (§7, M2).
146
+ */
147
+ weightField: { l1: number; lInf: number };
148
+ /**
149
+ * The contract's own figures: `vertexGap`, the largest distance of any vertex of either mesh from its UV carried
150
+ * through the frame, against `vertexSlack` per axis; `sampleGap`, the largest |p(candidate) − p(source)| over the
151
+ * measured samples — the term outside the value; `weightSumGap`, the largest |Σ shares − 1| of any vertex.
152
+ */
153
+ setup: { vertexGap: number; vertexSlack: [number, number]; sampleGap: number; weightSumGap: number };
154
+ }
155
+
156
+ /** The fixed sentence of `SkinningDetail.reading`. */
157
+ export const SKINNING_READING =
158
+ 'the largest over the samples carried by both meshes of Σk εk·|Δsk| + Σk |Δw̄k|·(εk·|p − ck| + τk): a bound, under the declared envelope only, on how far the candidate draws a UV from where the source draws it — positions only; it certifies no orientation, stretch or squash, no motion outside the envelope, and replaces no motion comparison (docs/MESH_REDUCTION.md §7)';
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // plants
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * A fault planted on purpose, for the `mesh-compare` suite's negative controls (MQ116 onward). `measureMeshQuality`
166
+ * plants none.
167
+ *
168
+ * - `drop-covariance`: the first sum left out — the weight-field bound the card proposed first.
169
+ * - `drop-lever`: the second sum left out.
170
+ * - `uncarried-read-as-zero`: no sample carried by both meshes read as a value of 0 rather than not measurable.
171
+ * - `undeclared-bone-ignored`: a bound bone the envelope does not declare read as contributing nothing.
172
+ * - `unknown-bone-accepted`: an envelope bone the source does not bind accepted.
173
+ * - `setup-unchecked`: a vertex off its UV accepted.
174
+ * - `deform-unchecked`: a `vertices` or `transform` deform key accepted.
175
+ * - `echo-when-unset`, `row-when-unset`: the report writes the echo, or the row, for a call that did not set the field.
176
+ */
177
+ export type SkinningPlant =
178
+ | 'drop-covariance'
179
+ | 'drop-lever'
180
+ | 'uncarried-read-as-zero'
181
+ | 'undeclared-bone-ignored'
182
+ | 'unknown-bone-accepted'
183
+ | 'setup-unchecked'
184
+ | 'deform-unchecked'
185
+ | 'echo-when-unset'
186
+ | 'row-when-unset';
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // refusals of the declaration's shape — thrown before any work
190
+ // ---------------------------------------------------------------------------
191
+
192
+ function refuse(code: string, message: string): never {
193
+ throw new MeshReductionError(code, message);
194
+ }
195
+
196
+ function isObject(value: unknown): value is Record<string, unknown> {
197
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
198
+ }
199
+
200
+ function isFiniteNumber(value: unknown): value is number {
201
+ return typeof value === 'number' && Number.isFinite(value);
202
+ }
203
+
204
+ /** A value as a message names it: JSON, except a non-finite number, which JSON would print as null. */
205
+ function shown(value: unknown): string {
206
+ return typeof value === 'number' ? String(value) : JSON.stringify(value);
207
+ }
208
+
209
+ function isPoint(value: unknown): value is [number, number] {
210
+ return Array.isArray(value) && value.length === 2 && isFiniteNumber(value[0]) && isFiniteNumber(value[1]);
211
+ }
212
+
213
+ /**
214
+ * `MeshMeasureInput.skinning`: left out or `null` is accepted as it stands; anything else is a `SkinningResidualInput`
215
+ * in full, else refused `REDUCE_INPUT_MISSING` naming the path. Internal: on `rig-c/mesh` only through `export *`.
216
+ */
217
+ export function validateSkinning(who: string, skinning: unknown): void {
218
+ if (skinning === undefined || skinning === null) return;
219
+ const shape = '{ source: { id, mesh }, envelope: { reference, bones }, maxResidual, deform }';
220
+ if (!isObject(skinning)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning is ${shown(skinning)}; required ${shape}, null, or the field left out`);
221
+ const source = skinning.source;
222
+ if (!isObject(source)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source is ${shown(source)}; required { id, mesh } — the original supplied mesh and the caller's id for it`);
223
+ if (typeof source.id !== 'string' || source.id === '') refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.id is ${shown(source.id)}; required a non-empty string naming the source`);
224
+ const mesh = source.mesh;
225
+ if (!isObject(mesh) || !Array.isArray(mesh.points) || !Array.isArray(mesh.uvs) || !Array.isArray(mesh.triangles)) {
226
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh is not { points, uvs, triangles, hull, weights } with three arrays; required a SourceMesh`);
227
+ }
228
+ mesh.points.forEach((p: unknown, i: number) => {
229
+ if (!isPoint(p)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.points[${i}] is ${shown(p)}; required a pair of finite numbers`);
230
+ });
231
+ if (mesh.uvs.length !== 2 * mesh.points.length || mesh.uvs.some((u: unknown) => !isFiniteNumber(u))) {
232
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.uvs holds ${mesh.uvs.length} numbers for ${mesh.points.length} points; required one finite u, v pair per point (${2 * mesh.points.length})`);
233
+ }
234
+ const vertexCount = mesh.points.length;
235
+ if (mesh.triangles.length % 3 !== 0 || mesh.triangles.some((t: unknown) => !Number.isInteger(t) || (t as number) < 0 || (t as number) >= vertexCount)) {
236
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.triangles is not a list of vertex index triples into ${mesh.points.length} points; required that`);
237
+ }
238
+ if (!Number.isInteger(mesh.hull) || (mesh.hull as number) < 3 || (mesh.hull as number) > mesh.points.length) {
239
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.hull is ${shown(mesh.hull)}; required the whole number of hull vertices, 3 to ${mesh.points.length}`);
240
+ }
241
+ if (mesh.weights !== null) {
242
+ if (!Array.isArray(mesh.weights) || mesh.weights.length !== mesh.points.length) {
243
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.weights is not one binding list per point (${mesh.points.length}); required that, or null for an unweighted mesh`);
244
+ }
245
+ mesh.weights.forEach((vertex: unknown, i: number) => {
246
+ if (!Array.isArray(vertex) || vertex.some((b) => !isObject(b) || typeof b.bone !== 'string' || !isFiniteNumber(b.weight) || b.weight < 0)) {
247
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.weights[${i}] is not a list of { bone, weight } with finite shares, 0 or more; required that`);
248
+ }
249
+ });
250
+ }
251
+ const envelope = skinning.envelope;
252
+ if (!isObject(envelope) || !Array.isArray(envelope.bones)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.envelope is ${shown(envelope)}; required { reference, bones: [{ bone, linear, pivot, translation }] }`);
253
+ if (typeof envelope.reference !== 'string' || envelope.reference === '') refuse('REDUCE_INPUT_MISSING', `${who}: skinning.envelope.reference is ${shown(envelope.reference)}; required a non-empty bone name — the slot's bone (Q4)`);
254
+ const seen = new Set<string>();
255
+ envelope.bones.forEach((entry: unknown, i: number) => {
256
+ const at = `skinning.envelope.bones[${i}]`;
257
+ if (!isObject(entry) || typeof entry.bone !== 'string' || entry.bone === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${shown(entry)}; required { bone: a non-empty name, linear, pivot, translation }`);
258
+ if (entry.bone === envelope.reference) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.bone is "${entry.bone}", the envelope's reference; required every bone but the reference — the reference's own terms are 0 by definition`);
259
+ if (seen.has(entry.bone)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.bone "${entry.bone}" is declared twice; required one entry per bone`);
260
+ seen.add(entry.bone);
261
+ if (!isFiniteNumber(entry.linear) || entry.linear < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.linear is ${shown(entry.linear)}; required a finite number, 0 or more (εk ≥ ‖Ak − A0‖, dimensionless)`);
262
+ if (!isPoint(entry.pivot)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.pivot is ${shown(entry.pivot)}; required a pair of finite drawing px`);
263
+ if (!isFiniteNumber(entry.translation) || entry.translation < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.translation is ${shown(entry.translation)}; required a finite number of drawing px, 0 or more`);
264
+ });
265
+ const bound = skinning.maxResidual;
266
+ if (bound === undefined) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.maxResidual is missing; required a finite number of drawing px, 0 or more, or null (declared absent: measured, reported undeclared) — no field has a default`);
267
+ if (bound !== null && (!isFiniteNumber(bound) || bound < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.maxResidual is ${shown(bound)}; required a finite number of drawing px, 0 or more, or null`);
268
+ const deform = skinning.deform;
269
+ if (!Array.isArray(deform)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.deform is ${shown(deform)}; required the deform timelines keyed on the attachment or its linked meshes (empty when none)`);
270
+ deform.forEach((t: unknown, i: number) => {
271
+ if (!isObject(t) || typeof t.animation !== 'string' || !isObject(t.attachment) || !Array.isArray(t.keys) || t.keys.some((k: unknown) => !isObject(k) || typeof k.kind !== 'string')) {
272
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.deform[${i}] is not { animation, attachment, keys: [{ time, kind }] }; required a DeformTimelineInput`);
273
+ }
274
+ });
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // the source's identity
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /** `sha256:<hex>` of the mesh's own fields, written in a fixed key order — the same bytes for the same mesh, whatever order its objects were built in. */
282
+ export function sourceMeshDigest(mesh: SourceMesh): string {
283
+ const canonical = JSON.stringify({
284
+ points: mesh.points.map((p) => [p[0], p[1]]),
285
+ uvs: [...mesh.uvs],
286
+ triangles: [...mesh.triangles],
287
+ hull: mesh.hull,
288
+ weights: mesh.weights === null ? null : mesh.weights.map((v) => v.map((b) => ({ bone: b.bone, weight: b.weight }))),
289
+ });
290
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
291
+ }
292
+
293
+ /** The echo `effective.skinning` writes. */
294
+ export function skinningEchoOf(skinning: SkinningResidualInput | null): SkinningEcho | null {
295
+ if (skinning === null) return null;
296
+ return { source: { id: skinning.source.id, digest: sourceMeshDigest(skinning.source.mesh) }, envelope: skinning.envelope, maxResidual: skinning.maxResidual, deform: skinning.deform };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // the samples and their terms
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /** One §3 sample: its UV, and the art pixel (mask grid) or source hull vertex it is. */
304
+ export interface SkinningSample {
305
+ uv: [number, number];
306
+ pixel: [number, number] | null;
307
+ vertex: number | null;
308
+ }
309
+
310
+ /** One mesh's terms at one sample: p, and per bone (envelope order, then the reference last) w̄ and s. */
311
+ export interface SampleTerms {
312
+ p: [number, number];
313
+ wbar: number[];
314
+ moment: Array<[number, number]>;
315
+ }
316
+
317
+ /** §3's sample set over art bits at the threshold: every art pixel centre in row order, then the source hull's UVs. */
318
+ export function skinningSamplesOf(artBits: Uint8Array, width: number, height: number, source: SourceMesh): { samples: SkinningSample[]; art: number } {
319
+ const samples: SkinningSample[] = [];
320
+ for (let y = 0; y < height; y++) {
321
+ for (let x = 0; x < width; x++) {
322
+ if (artBits[y * width + x]) samples.push({ uv: [(x + 0.5) / width, (y + 0.5) / height], pixel: [x, y], vertex: null });
323
+ }
324
+ }
325
+ const art = samples.length;
326
+ for (let v = 0; v < source.hull; v++) samples.push({ uv: [source.uvs[v * 2], source.uvs[v * 2 + 1]], pixel: null, vertex: v });
327
+ return { samples, art };
328
+ }
329
+
330
+ /**
331
+ * A mesh's terms at a carrier — p, and per bone w̄ and the moment s = Σᵢ βᵢ wᵢₖ (xᵢ − p) — over `bones` (index by name).
332
+ * Shares of a bone `bones` does not name are not read: the coverage check refuses such a mesh before this runs.
333
+ */
334
+ export function termsAt(mesh: SourceMesh, c: Carrier, bones: ReadonlyMap<string, number>): SampleTerms {
335
+ const n = bones.size;
336
+ const wbar = new Array<number>(n).fill(0);
337
+ const moment: Array<[number, number]> = Array.from({ length: n }, () => [0, 0]);
338
+ let px = 0;
339
+ let py = 0;
340
+ for (let k = 0; k < 3; k++) {
341
+ const pt = mesh.points[c.corners[k]];
342
+ px += c.bary[k] * pt[0];
343
+ py += c.bary[k] * pt[1];
344
+ }
345
+ for (let k = 0; k < 3; k++) {
346
+ const v = c.corners[k];
347
+ const b = c.bary[k];
348
+ const pt = mesh.points[v];
349
+ const dx = pt[0] - px;
350
+ const dy = pt[1] - py;
351
+ for (const bind of mesh.weights![v]) {
352
+ const j = bones.get(bind.bone);
353
+ if (j === undefined) continue;
354
+ wbar[j] += b * bind.weight;
355
+ moment[j][0] += b * bind.weight * dx;
356
+ moment[j][1] += b * bind.weight * dy;
357
+ }
358
+ }
359
+ return { p: [px, py], wbar, moment };
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // the measurement
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /** What `measureMeshQuality` hands the residual: the candidate (the mesh measured), the field, and the art read at the measurement's threshold. */
367
+ export interface SkinningMeasureArgs {
368
+ attachment: AttachmentRef;
369
+ frame: SourceFrame;
370
+ /** Art bits on the mask grid at the measurement's threshold, and the grid's size. */
371
+ artBits: Uint8Array;
372
+ maskWidth: number;
373
+ maskHeight: number;
374
+ minArtSamples: number;
375
+ threshold: number;
376
+ candidate: SourceMesh;
377
+ skinning: SkinningResidualInput;
378
+ }
379
+
380
+ /** The residual's reading, which the row is built from. */
381
+ export type SkinningReading =
382
+ | { state: 'measured'; value: number; worst: WorstSample; detail: SkinningDetail; samples: number; art: number }
383
+ | { state: 'refused' | 'not-measurable'; reason: string; samples: number; art: number };
384
+
385
+ /** Half a step of the 6-decimal weight grid the bindings are written on (§6, P19): a vertex's shares further from 1 are not closed. */
386
+ const WEIGHT_SUM_SLACK = 5e-7;
387
+
388
+ /** One `r6` half-step, of a point or of a UV (its error carried through the frame is that times the frame's size). */
389
+ const R6_HALF_STEP = 5e-7;
390
+
391
+ function nameOf(ref: AttachmentRef): string {
392
+ return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
393
+ }
394
+
395
+ /**
396
+ * The residual over the §3 sample set, or the reason it is refused or not measurable — the order of the checks is
397
+ * the order a fix has to happen in: what the motion is, which bones bind, the shares, the setup positions, the
398
+ * samples. Internal: on `rig-c/mesh` only through `export *`.
399
+ */
400
+ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant | null = null): SkinningReading {
401
+ const { attachment, frame, candidate, skinning } = args;
402
+ const source = skinning.source.mesh;
403
+ const envelope = skinning.envelope;
404
+ const who = `attachment ${nameOf(attachment)}`;
405
+ const { samples, art } = skinningSamplesOf(args.artBits, args.maskWidth, args.maskHeight, source);
406
+ const out = (state: 'refused' | 'not-measurable', reason: string): SkinningReading => ({ state, reason, samples: samples.length, art });
407
+
408
+ // What moves the vertices: skinning alone, or a deform key besides it.
409
+ if (plant !== 'deform-unchecked') {
410
+ for (const t of skinning.deform) {
411
+ const k = t.keys.findIndex((key) => key.kind !== 'setup');
412
+ if (k >= 0) {
413
+ return out(
414
+ 'refused',
415
+ `SKINNING_DEFORM_UNSUPPORTED: ${who}: skinning.deform names animation "${t.animation}" keyed on ${nameOf(t.attachment)}, whose key ${k} is a "${t.keys[k].kind}" key; required setup keys only — a vertices or transform deform moves vertices outside skinning, which the envelope does not bound (Q10)`,
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ // Which bones bind, and that the envelope declares every one of them.
422
+ if (source.weights === null || candidate.weights === null) {
423
+ if (source.weights === null && candidate.weights === null) {
424
+ return out('not-measurable', `${who}: neither the source "${skinning.source.id}" nor the candidate is weighted, so no vertex has a binding to compare — an unweighted mesh follows its slot's bone, which no envelope entry describes`);
425
+ }
426
+ return out('refused', `SKINNING_UNWEIGHTED: ${who}: the ${source.weights === null ? `source "${skinning.source.id}"` : 'candidate'} is unweighted and the ${source.weights === null ? 'candidate' : 'source'} is weighted; required both weighted — the residual compares two bindings of one attachment`);
427
+ }
428
+ const declared = new Set(envelope.bones.map((b) => b.bone));
429
+ const bound = new Set<string>();
430
+ for (const [label, mesh] of [[`source "${skinning.source.id}"`, source], ['candidate', candidate]] as const) {
431
+ for (let v = 0; v < mesh.weights!.length; v++) {
432
+ for (const b of mesh.weights![v]) {
433
+ if (label !== 'candidate') bound.add(b.bone);
434
+ if (b.bone === envelope.reference || declared.has(b.bone) || plant === 'undeclared-bone-ignored') continue;
435
+ return out(
436
+ 'refused',
437
+ `SKINNING_BONE_NOT_DECLARED: ${who}: ${label} vertex ${v} binds bone "${b.bone}", which skinning.envelope neither declares nor names as its reference "${envelope.reference}"; required an envelope entry for every bone either mesh binds — an undeclared bone's motion is unbounded`,
438
+ );
439
+ }
440
+ }
441
+ }
442
+ if (plant !== 'unknown-bone-accepted') {
443
+ const i = envelope.bones.findIndex((b) => !bound.has(b.bone));
444
+ if (i >= 0) {
445
+ return out(
446
+ 'refused',
447
+ `SKINNING_BONE_UNKNOWN: ${who}: skinning.envelope.bones[${i}] declares bone "${envelope.bones[i].bone}", which no vertex of the source "${skinning.source.id}" binds; required envelope bones the source binds — the source binds ${[...bound].sort().map((b) => `"${b}"`).join(', ')}`,
448
+ );
449
+ }
450
+ }
451
+
452
+ // The shares: each vertex's sum is 1 on the weight grid, or the reference bone's motion would enter the difference.
453
+ let weightSumGap = 0;
454
+ for (const [label, mesh] of [[`source "${skinning.source.id}"`, source], ['candidate', candidate]] as const) {
455
+ for (let v = 0; v < mesh.weights!.length; v++) {
456
+ const sum = mesh.weights![v].reduce((s, b) => s + b.weight, 0);
457
+ const gap = Math.abs(sum - 1);
458
+ weightSumGap = Math.max(weightSumGap, gap);
459
+ if (gap > WEIGHT_SUM_SLACK) {
460
+ return out(
461
+ 'refused',
462
+ `SKINNING_WEIGHT_SUM: ${who}: ${label} vertex ${v}'s shares sum to ${sum}; required 1 within ${WEIGHT_SUM_SLACK} (half a step of the 6-decimal weight grid) — a deficit moves the vertex with the reference bone's own motion, which the envelope does not bound`,
463
+ );
464
+ }
465
+ }
466
+ }
467
+
468
+ // The setup positions: every vertex of either mesh at its UV carried through the frame, so one UV is one setup point.
469
+ const slack: [number, number] = [2 * R6_HALF_STEP * (1 + frame.width), 2 * R6_HALF_STEP * (1 + frame.height)];
470
+ let vertexGap = 0;
471
+ for (const [label, mesh] of [[`source "${skinning.source.id}"`, source], ['candidate', candidate]] as const) {
472
+ for (let v = 0; v < mesh.points.length; v++) {
473
+ const dx = Math.abs(mesh.points[v][0] - mesh.uvs[v * 2] * frame.width);
474
+ const dy = Math.abs(mesh.points[v][1] - mesh.uvs[v * 2 + 1] * frame.height);
475
+ vertexGap = Math.max(vertexGap, Math.hypot(dx, dy));
476
+ if ((dx > slack[0] || dy > slack[1]) && plant !== 'setup-unchecked') {
477
+ return out(
478
+ 'refused',
479
+ `SKINNING_SETUP_MISMATCH: ${who}: ${label} vertex ${v} is at (${mesh.points[v][0]}, ${mesh.points[v][1]}) and its UV (${mesh.uvs[v * 2]}, ${mesh.uvs[v * 2 + 1]}) carried through the ${frame.width}x${frame.height} px frame is at (${r6(mesh.uvs[v * 2] * frame.width)}, ${r6(mesh.uvs[v * 2 + 1] * frame.height)}); required within (${slack[0]}, ${slack[1]}) px per axis (two r6 half-steps, of the point and of the UV) — both meshes have to place one UV at one setup position`,
480
+ );
481
+ }
482
+ }
483
+ }
484
+
485
+ // The samples: under the floor is not a measurement.
486
+ if (art < args.minArtSamples) {
487
+ return out('not-measurable', `${who} has ${art} art sample(s) at alpha >= ${args.threshold}; required at least ${args.minArtSamples} (minArtSamples, P9) — the source hull's UVs are samples and do not count towards it`);
488
+ }
489
+ let sourceCarriers: Array<Carrier | null>;
490
+ let candidateCarriers: Array<Carrier | null>;
491
+ try {
492
+ sourceCarriers = uvCarriers(source.uvs, source.triangles, samples, `source "${skinning.source.id}", attachment ${nameOf(attachment)}`);
493
+ candidateCarriers = uvCarriers(candidate.uvs, candidate.triangles, samples, `candidate, attachment ${nameOf(attachment)}`);
494
+ } catch (err) {
495
+ if (err instanceof MeshReductionError && err.code === 'COMPARE_UV_CARRIER_NOT_UNIQUE') return out('refused', `SKINNING_UV_CARRIER_NOT_UNIQUE: ${err.message}`);
496
+ throw err;
497
+ }
498
+
499
+ // The bones in envelope order, the reference last (its εk and τk are 0, so it enters only the weight-field diagnostic).
500
+ const index = new Map<string, number>();
501
+ envelope.bones.forEach((b, i) => index.set(b.bone, i));
502
+ index.set(envelope.reference, envelope.bones.length);
503
+ const eps = [...envelope.bones.map((b) => b.linear), 0];
504
+ const tau = [...envelope.bones.map((b) => b.translation), 0];
505
+ const pivot: Pt[] = [...envelope.bones.map((b) => b.pivot), [0, 0]];
506
+ const K = eps.length;
507
+
508
+ const uncarried = {
509
+ source: samples.filter((_s, j) => sourceCarriers[j] === null).map((s) => s.uv),
510
+ candidate: samples.filter((_s, j) => candidateCarriers[j] === null).map((s) => s.uv),
511
+ };
512
+ let measured = 0;
513
+ let worst = -1;
514
+ let worstAt = -1;
515
+ let worstCov: number[] = [];
516
+ let worstLev: number[] = [];
517
+ let l1Max = 0;
518
+ let lInfMax = 0;
519
+ let sampleGap = 0;
520
+ for (let j = 0; j < samples.length; j++) {
521
+ const cs = sourceCarriers[j];
522
+ const cc = candidateCarriers[j];
523
+ if (cs === null || cc === null) continue;
524
+ measured++;
525
+ const r = termsAt(source, cs, index);
526
+ const c = termsAt(candidate, cc, index);
527
+ sampleGap = Math.max(sampleGap, Math.hypot(c.p[0] - r.p[0], c.p[1] - r.p[1]));
528
+ let total = 0;
529
+ let l1 = 0;
530
+ let lInf = 0;
531
+ const cov = new Array<number>(K).fill(0);
532
+ const lev = new Array<number>(K).fill(0);
533
+ for (let k = 0; k < K; k++) {
534
+ const dw = Math.abs(c.wbar[k] - r.wbar[k]);
535
+ l1 += dw;
536
+ lInf = Math.max(lInf, dw);
537
+ if (plant !== 'drop-covariance') cov[k] = eps[k] * Math.hypot(c.moment[k][0] - r.moment[k][0], c.moment[k][1] - r.moment[k][1]);
538
+ if (plant !== 'drop-lever') lev[k] = dw * (eps[k] * Math.hypot(r.p[0] - pivot[k][0], r.p[1] - pivot[k][1]) + tau[k]);
539
+ total += cov[k] + lev[k];
540
+ }
541
+ l1Max = Math.max(l1Max, l1);
542
+ lInfMax = Math.max(lInfMax, lInf);
543
+ if (total > worst) {
544
+ worst = total;
545
+ worstAt = j;
546
+ worstCov = cov;
547
+ worstLev = lev;
548
+ }
549
+ }
550
+ if (measured === 0 && plant !== 'uncarried-read-as-zero') {
551
+ return out('not-measurable', `${who}: no sample is carried by both the source "${skinning.source.id}"'s and the candidate's UV triangles, so there is nothing to bound — ${uncarried.source.length} uncarried by the source, ${uncarried.candidate.length} by the candidate`);
552
+ }
553
+ const value = measured === 0 ? 0 : r6(worst);
554
+ const s = worstAt >= 0 ? samples[worstAt] : null;
555
+ const at: WorstSample['at'] = s === null ? {} : s.pixel !== null ? { pixel: s.pixel, uv: s.uv } : { vertex: s.vertex ?? undefined, uv: s.uv };
556
+ const sum = (xs: readonly number[]): number => xs.reduce((a, b) => a + b, 0);
557
+ const detail: SkinningDetail = {
558
+ reading: SKINNING_READING,
559
+ source: { id: skinning.source.id, digest: sourceMeshDigest(source) },
560
+ reference: envelope.reference,
561
+ samples: { measured, uncarried },
562
+ worst:
563
+ value === 0
564
+ ? null
565
+ : {
566
+ covariance: r6(sum(worstCov)),
567
+ lever: r6(sum(worstLev)),
568
+ bones: envelope.bones.map((b, k) => ({ bone: b.bone, covariance: r6(worstCov[k]), lever: r6(worstLev[k]) })),
569
+ },
570
+ weightField: { l1: r6(l1Max), lInf: r6(lInfMax) },
571
+ setup: { vertexGap: r6(vertexGap), vertexSlack: slack, sampleGap: r6(sampleGap), weightSumGap },
572
+ };
573
+ return { state: 'measured', value, worst: value === 0 ? { at: {} } : { at }, detail, samples: samples.length, art };
574
+ }
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // the helper: declared ranges → one envelope entry
578
+ // ---------------------------------------------------------------------------
579
+
580
+ /**
581
+ * One bone's declared motion, relative to its setup pose, as `skinningEnvelopeBone` reads it. Every field is required.
582
+ *
583
+ * Coordinates: `pivot` is the bone's setup joint (world origin) in the attachment's drawing frame, px, y down.
584
+ * Rotations and scales are the bone's LOCAL values over the motion relative to its setup values — the keys of its
585
+ * rotate and scale timelines — so a bone that does not move declares `[0, 0]` and `[1, 1]`.
586
+ */
587
+ export interface BoneMotionRange {
588
+ bone: string;
589
+ /** Where the range comes from. Only `'keys'` is certified; any other value — `'physics'` — is refused by name. */
590
+ source: 'keys';
591
+ /** The setup joint in drawing px, y down. */
592
+ pivot: [number, number];
593
+ /** Local rotation minus setup rotation over the motion, degrees, [min, max]. */
594
+ rotate: [number, number];
595
+ /** Local scale over setup scale, per axis, [min, max]. */
596
+ scaleX: [number, number];
597
+ scaleY: [number, number];
598
+ /** The most the bone's local translation moves its joint from setup, drawing px, 0 or more. */
599
+ translate: number;
600
+ /** The bone's setup as the rig declares it — checked against the helper's assumptions, never corrected. */
601
+ setup: { scaleX: number; scaleY: number; shearX: number; shearY: number; inherit: 'normal' };
602
+ }
603
+
604
+ /**
605
+ * What `skinningEnvelopeBone` composes: the chain from the skeleton's root to the envelope's reference, and from below
606
+ * the reference down to the bone the entry is for.
607
+ */
608
+ export interface EnvelopeBoneRanges {
609
+ /** Root first, ending at the reference bone: every bone the reference's world transform is composed of. */
610
+ referenceChain: BoneMotionRange[];
611
+ /** Parent first, from the reference's child down to the bone the entry is for (its last element). At least one. */
612
+ chain: BoneMotionRange[];
613
+ }
614
+
615
+ /** `skinningEnvelopeBone`'s planted faults, for the helper's negative controls (MQ133): scale ranges ignored, the joints' motion ignored. */
616
+ export type EnvelopePlant = 'scale-ignored' | 'translation-ignored';
617
+
618
+ function rangeOf(who: string, at: string, value: unknown, positive: boolean): [number, number] {
619
+ if (!Array.isArray(value) || value.length !== 2 || !isFiniteNumber(value[0]) || !isFiniteNumber(value[1]) || value[0] > value[1] || (positive && value[0] <= 0)) {
620
+ refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${shown(value)}; required [min, max], finite, min <= max${positive ? ', min above 0' : ''}`);
621
+ }
622
+ return [value[0], value[1]];
623
+ }
624
+
625
+ /** e = ‖R(θ) diag(σx, σy) − I‖₂ bounded by 2 sin(θ / 2) + max |σ − 1|, and the scale's norm max |σ|, over a declared range. */
626
+ function stepOf(who: string, at: string, r: unknown): { e: number; norm: number; translate: number; pivot: Pt; rotation: number; scale: number } {
627
+ if (!isObject(r) || typeof r.bone !== 'string' || r.bone === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${shown(r)}; required a BoneMotionRange`);
628
+ const name = `${at} (bone "${r.bone}")`;
629
+ if (r.source !== 'keys') {
630
+ refuse(
631
+ 'SKINNING_RANGE_UNSUPPORTED',
632
+ `${who}: ${name}.source is ${shown(r.source)}; required "keys" — a range not declared by the bone's own keys (a physics swing, a range read off a posed schedule) is not certified by this helper, so it is refused rather than turned into a bound`,
633
+ );
634
+ }
635
+ if (!isPoint(r.pivot)) refuse('REDUCE_INPUT_MISSING', `${who}: ${name}.pivot is ${shown(r.pivot)}; required the setup joint as a pair of finite drawing px`);
636
+ const rotate = rangeOf(who, `${name}.rotate`, r.rotate, false);
637
+ const sx = rangeOf(who, `${name}.scaleX`, r.scaleX, true);
638
+ const sy = rangeOf(who, `${name}.scaleY`, r.scaleY, true);
639
+ if (!isFiniteNumber(r.translate) || r.translate < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${name}.translate is ${shown(r.translate)}; required a finite number of drawing px, 0 or more`);
640
+ const setup = r.setup;
641
+ if (!isObject(setup) || !isFiniteNumber(setup.scaleX) || !isFiniteNumber(setup.scaleY) || !isFiniteNumber(setup.shearX) || !isFiniteNumber(setup.shearY) || typeof setup.inherit !== 'string') {
642
+ refuse('REDUCE_INPUT_MISSING', `${who}: ${name}.setup is ${shown(setup)}; required { scaleX, scaleY, shearX, shearY, inherit } as the rig declares them`);
643
+ }
644
+ if (Math.abs(setup.scaleX) !== Math.abs(setup.scaleY) || setup.scaleX === 0) {
645
+ refuse('SKINNING_RANGE_UNSUPPORTED', `${who}: ${name}.setup scale is (${setup.scaleX}, ${setup.scaleY}); required a uniform setup scale — under a nonuniform one the bone's deformation is not a rotation and scale of the drawing, and the bound is not proven`);
646
+ }
647
+ if (setup.shearX !== 0 || setup.shearY !== 0) refuse('SKINNING_RANGE_UNSUPPORTED', `${who}: ${name}.setup shear is (${setup.shearX}, ${setup.shearY}); required (0, 0) — a sheared setup is outside the helper's assumptions`);
648
+ if (setup.inherit !== 'normal') refuse('SKINNING_RANGE_UNSUPPORTED', `${who}: ${name}.setup.inherit is ${shown(setup.inherit)}; required "normal" — another inherit mode composes the chain otherwise than the helper does`);
649
+ const theta = Math.min(Math.max(Math.abs(rotate[0]), Math.abs(rotate[1])), 180);
650
+ const rotation = 2 * Math.sin((theta * Math.PI) / 360);
651
+ const scale = Math.max(Math.abs(sx[0] - 1), Math.abs(sx[1] - 1), Math.abs(sy[0] - 1), Math.abs(sy[1] - 1));
652
+ const norm = Math.max(sx[1], sy[1]);
653
+ return { e: rotation + scale, norm, translate: r.translate, pivot: r.pivot, rotation, scale };
654
+ }
655
+
656
+ /**
657
+ * One `SkinningEnvelopeBone` from declared ranges (issue #1294; rig-parts#126 Q3: one definition of `linear`, rig-c's).
658
+ *
659
+ * The definition. Under the assumptions below, each bone j's local change is, in the drawing frame, an affine map
660
+ * Fⱼ(q) = cⱼ + Mⱼ (q − cⱼ) + tⱼ with ‖Mⱼ − I‖ ≤ eⱼ = 2 sin(θⱼ / 2) + max |σⱼ − 1| (θⱼ the largest |rotation|, capped
661
+ * at 180°, σⱼ every scale ratio's endpoint), ‖Mⱼ‖ ≤ max σⱼ, and |tⱼ| ≤ the declared `translate`. The bone's deformation
662
+ * relative to the reference is G = F₁ ∘ … ∘ Fₘ over `chain`, and the reference's own deformation scales by at most
663
+ * S = Π max σ over `referenceChain`. Then
664
+ *
665
+ * - `linear` ε = S · (Πⱼ (1 + eⱼ) − 1) ≥ ‖Aₖ − A₀‖ — the composition is a product, because a chain's changes multiply;
666
+ * - `pivot` c = the last bone's setup joint;
667
+ * - `translation` τ = S · δ₁, where δₘ = translate of the last bone and δⱼ = eⱼ |c − cⱼ| + (1 + eⱼ) δⱼ₊₁ + translateⱼ,
668
+ * so that |(Dₖ − D₀) q| ≤ ε |q − c| + τ for every q.
669
+ *
670
+ * Every step is a triangle inequality, so the entry is conservative for any motion inside the declared ranges, and
671
+ * claims nothing outside them. The reference chain's rotations and translations cancel in Dₖ − D₀ and are not read.
672
+ *
673
+ * The assumptions, checked where the input can show them and refused by name (`SKINNING_RANGE_UNSUPPORTED`): each
674
+ * bone's range is declared by its keys (`source: 'keys'`; a physics swing is not certified); every bone of both
675
+ * chains has a uniform setup scale, no setup shear and inherit `normal`, so every setup world map is a similarity and
676
+ * conjugating by it keeps each norm. Not checkable here, and assumed: the attachment's drawing frame maps to world by a
677
+ * similarity (the slot's bone at its setup, as the compile places a part), and the chain listed is the bone's real
678
+ * ancestry. Malformed input is `REDUCE_INPUT_MISSING` naming the path.
679
+ */
680
+ export function skinningEnvelopeBone(ranges: EnvelopeBoneRanges): SkinningEnvelopeBone {
681
+ return envelopeBoneWith(ranges, null);
682
+ }
683
+
684
+ /** `skinningEnvelopeBone` with a fault planted (`EnvelopePlant`) — the helper's negative controls. Internal: merely exported. */
685
+ export function skinningEnvelopeBonePlanted(ranges: EnvelopeBoneRanges, plant: EnvelopePlant): SkinningEnvelopeBone {
686
+ return envelopeBoneWith(ranges, plant);
687
+ }
688
+
689
+ function envelopeBoneWith(ranges: EnvelopeBoneRanges, plant: EnvelopePlant | null): SkinningEnvelopeBone {
690
+ const who = 'skinningEnvelopeBone';
691
+ if (!isObject(ranges) || !Array.isArray(ranges.referenceChain) || !Array.isArray(ranges.chain)) refuse('REDUCE_INPUT_MISSING', `${who}: the input is ${shown(ranges)}; required { referenceChain: BoneMotionRange[], chain: BoneMotionRange[] }`);
692
+ if (ranges.referenceChain.length === 0) refuse('REDUCE_INPUT_MISSING', `${who}: referenceChain is empty; required the bones from the skeleton's root to the reference, at least the reference`);
693
+ if (ranges.chain.length === 0) refuse('REDUCE_INPUT_MISSING', `${who}: chain is empty; required the bones below the reference down to the bone the entry is for, at least that bone`);
694
+ const above = ranges.referenceChain.map((r, i) => stepOf(who, `referenceChain[${i}]`, r));
695
+ const below = ranges.chain.map((r, i) => stepOf(who, `chain[${i}]`, r));
696
+ const S = above.reduce((s, x) => s * (plant === 'scale-ignored' ? 1 : x.norm), 1);
697
+ const eOf = (x: { e: number; rotation: number }): number => (plant === 'scale-ignored' ? x.rotation : x.e);
698
+ const composed = below.reduce((s, x) => s * (1 + eOf(x)), 1) - 1;
699
+ const last = below[below.length - 1];
700
+ const c = last.pivot;
701
+ let delta = plant === 'translation-ignored' ? 0 : last.translate;
702
+ for (let j = below.length - 2; j >= 0; j--) {
703
+ if (plant === 'translation-ignored') break;
704
+ const x = below[j];
705
+ delta = eOf(x) * Math.hypot(c[0] - x.pivot[0], c[1] - x.pivot[1]) + (1 + eOf(x)) * delta + x.translate;
706
+ }
707
+ return { bone: (ranges.chain[ranges.chain.length - 1] as BoneMotionRange).bone, linear: S * composed, pivot: [c[0], c[1]], translation: S * delta };
708
+ }