rig-c 2.29.0 → 2.31.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,1399 @@
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 { areaBand, triangleAreas } from './areaband.ts';
59
+ import { carrierIn, resolveCarriers, uvCarriers, type Carrier } from './meshcarriers.ts';
60
+ import type { AttachmentRef, DeformTimelineInput, SourceFrame, SourceMesh, WorstSample } from './meshquality.ts';
61
+
62
+ type Pt = readonly [number, number];
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // the input
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * The motion a candidate must stay correct under — declared, never posed. Part-local drawing px, y down (§7, Q3–Q5,
70
+ * rig-parts#126). Nothing in it has a default.
71
+ */
72
+ export interface SkinningEnvelope {
73
+ /** D₀: the bone every other bone's deformation is measured against — the slot's bone (Q4). Its own terms are 0. */
74
+ reference: string;
75
+ /** Every bone either mesh binds, other than `reference`, once each. */
76
+ bones: SkinningEnvelopeBone[];
77
+ }
78
+
79
+ /** One bone of a `SkinningEnvelope`: the bound on how its deformation differs from the reference's. */
80
+ export interface SkinningEnvelopeBone {
81
+ bone: string;
82
+ /**
83
+ * εₖ ≥ ‖Aₖ − A₀‖₂ over the motion, Aₖ and A₀ the linear parts of the two bones' deformations — dimensionless,
84
+ * finite, 0 or more. A rotation by up to θ relative to the reference is 2 sin(θ / 2); `skinningEnvelopeBone` derives
85
+ * it from declared rotation and scale ranges.
86
+ */
87
+ linear: number;
88
+ /** cₖ, drawing px: the pivot of the lever bound |(Dₖ − D₀) q| ≤ εₖ |q − cₖ| + τₖ — usually the bone's setup joint. */
89
+ pivot: [number, number];
90
+ /** τₖ, drawing px, 0 or more: the lever bound's constant — how far Dₖ and D₀ carry the pivot apart. */
91
+ translation: number;
92
+ }
93
+
94
+ /**
95
+ * `MeshMeasureInput.skinning` (issue #1294): the source the measured mesh is a candidate of, the envelope, and the
96
+ * bound. The measured mesh (`MeshMeasureInput.source`) is the candidate; this `source` is the comparison reference.
97
+ */
98
+ export interface SkinningResidualInput {
99
+ /**
100
+ * The original supplied mesh — never a previous candidate — and the caller's id for it. Its sha-256 over the mesh's
101
+ * own fields is reported beside the id (`SkinningEcho.source.digest`), so a report names the bytes it was measured
102
+ * against.
103
+ */
104
+ source: { id: string; mesh: SourceMesh };
105
+ envelope: SkinningEnvelope;
106
+ /** Drawing px: the row's bound, inclusive. `null` declares it absent — the row is measured and `undeclared`. */
107
+ maxResidual: number | null;
108
+ /**
109
+ * Every deform timeline keyed on the attachment or one of its linked meshes, as `MeshReductionInput.deform`; empty
110
+ * when none. A `vertices` or `transform` key moves vertices outside skinning, so any key but `setup` refuses the row
111
+ * (`SKINNING_DEFORM_UNSUPPORTED`, Q10).
112
+ */
113
+ deform: DeformTimelineInput[];
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // the report's fields
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** What `effective.skinning` echoes: the source by id and digest rather than in full, the envelope, the bound and the deform list. */
121
+ export interface SkinningEcho {
122
+ source: { id: string; digest: string };
123
+ envelope: SkinningEnvelope;
124
+ maxResidual: number | null;
125
+ deform: DeformTimelineInput[];
126
+ }
127
+
128
+ /** `MeasureRow.skinning`: written on `MQ_SKINNING_RESIDUAL` when it was measured, and on no other row. */
129
+ export interface SkinningDetail {
130
+ /** One fixed sentence: what the value is a reading of, and what it is not. */
131
+ reading: string;
132
+ /** The comparison reference: the caller's id and the sha-256 of its mesh. */
133
+ source: { id: string; digest: string };
134
+ /** The envelope's reference bone. */
135
+ reference: string;
136
+ /** Samples carried by both meshes — the ones the value is the largest over — and every sample one mesh does not carry, by UV. */
137
+ samples: { measured: number; uncarried: { source: Array<[number, number]>; candidate: Array<[number, number]> } };
138
+ /**
139
+ * At the worst sample: the value's two sums — `covariance`, Σₖ εₖ |Δsₖ|, and `lever`, Σₖ |Δw̄ₖ| (εₖ |p − cₖ| + τₖ) —
140
+ * and each envelope bone's share of each, in envelope order. Null when the value is 0 (nothing worse than ideal).
141
+ */
142
+ worst: { covariance: number; lever: number; bones: Array<{ bone: string; covariance: number; lever: number }> } | null;
143
+ /**
144
+ * The weight-field difference alone, over every bone the envelope names and its reference — the largest over
145
+ * samples of Σ |Δw̄| (`l1`) and of max |Δw̄| (`lInf`). A diagnostic: it reads 0 wherever the two fields agree, as
146
+ * on a linear ramp, however far the meshes draw apart, so it is never a guard (§7, M2).
147
+ */
148
+ weightField: { l1: number; lInf: number };
149
+ /**
150
+ * The contract's own figures: `vertexGap`, the largest distance of any vertex of either mesh from its UV carried
151
+ * through the frame, against `vertexSlack` per axis; `sampleGap`, the largest |p(candidate) − p(source)| over the
152
+ * measured samples — the term outside the value; `weightSumGap`, the largest |Σ shares − 1| of any vertex.
153
+ */
154
+ setup: { vertexGap: number; vertexSlack: [number, number]; sampleGap: number; weightSumGap: number };
155
+ }
156
+
157
+ /** The fixed sentence of `SkinningDetail.reading`. */
158
+ export const SKINNING_READING =
159
+ '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)';
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // plants
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * A fault planted on purpose, for the `mesh-compare` suite's negative controls (MQ116 onward). `measureMeshQuality`
167
+ * plants none.
168
+ *
169
+ * - `drop-covariance`: the first sum left out — the weight-field bound the card proposed first.
170
+ * - `drop-lever`: the second sum left out.
171
+ * - `uncarried-read-as-zero`: no sample carried by both meshes read as a value of 0 rather than not measurable.
172
+ * - `undeclared-bone-ignored`: a bound bone the envelope does not declare read as contributing nothing.
173
+ * - `unknown-bone-accepted`: an envelope bone the source does not bind accepted.
174
+ * - `setup-unchecked`: a vertex off its UV accepted.
175
+ * - `deform-unchecked`: a `vertices` or `transform` deform key accepted.
176
+ * - `echo-when-unset`, `row-when-unset`: the report writes the echo, or the row, for a call that did not set the field.
177
+ */
178
+ export type SkinningPlant =
179
+ | 'drop-covariance'
180
+ | 'drop-lever'
181
+ | 'uncarried-read-as-zero'
182
+ | 'undeclared-bone-ignored'
183
+ | 'unknown-bone-accepted'
184
+ | 'setup-unchecked'
185
+ | 'deform-unchecked'
186
+ | 'echo-when-unset'
187
+ | 'row-when-unset';
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // refusals of the declaration's shape — thrown before any work
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function refuse(code: string, message: string): never {
194
+ throw new MeshReductionError(code, message);
195
+ }
196
+
197
+ function isObject(value: unknown): value is Record<string, unknown> {
198
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
199
+ }
200
+
201
+ function isFiniteNumber(value: unknown): value is number {
202
+ return typeof value === 'number' && Number.isFinite(value);
203
+ }
204
+
205
+ /** A value as a message names it: JSON, except a non-finite number, which JSON would print as null. */
206
+ function shown(value: unknown): string {
207
+ return typeof value === 'number' ? String(value) : JSON.stringify(value);
208
+ }
209
+
210
+ function isPoint(value: unknown): value is [number, number] {
211
+ return Array.isArray(value) && value.length === 2 && isFiniteNumber(value[0]) && isFiniteNumber(value[1]);
212
+ }
213
+
214
+ /**
215
+ * `MeshMeasureInput.skinning`: left out or `null` is accepted as it stands; anything else is a `SkinningResidualInput`
216
+ * in full, else refused `REDUCE_INPUT_MISSING` naming the path. Internal: on `rig-c/mesh` only through `export *`.
217
+ */
218
+ export function validateSkinning(who: string, skinning: unknown): void {
219
+ if (skinning === undefined || skinning === null) return;
220
+ const shape = '{ source: { id, mesh }, envelope: { reference, bones }, maxResidual, deform }';
221
+ if (!isObject(skinning)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning is ${shown(skinning)}; required ${shape}, null, or the field left out`);
222
+ const source = skinning.source;
223
+ 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`);
224
+ 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`);
225
+ const mesh = source.mesh;
226
+ if (!isObject(mesh) || !Array.isArray(mesh.points) || !Array.isArray(mesh.uvs) || !Array.isArray(mesh.triangles)) {
227
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh is not { points, uvs, triangles, hull, weights } with three arrays; required a SourceMesh`);
228
+ }
229
+ mesh.points.forEach((p: unknown, i: number) => {
230
+ if (!isPoint(p)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.points[${i}] is ${shown(p)}; required a pair of finite numbers`);
231
+ });
232
+ if (mesh.uvs.length !== 2 * mesh.points.length || mesh.uvs.some((u: unknown) => !isFiniteNumber(u))) {
233
+ 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})`);
234
+ }
235
+ const vertexCount = mesh.points.length;
236
+ if (mesh.triangles.length % 3 !== 0 || mesh.triangles.some((t: unknown) => !Number.isInteger(t) || (t as number) < 0 || (t as number) >= vertexCount)) {
237
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.source.mesh.triangles is not a list of vertex index triples into ${mesh.points.length} points; required that`);
238
+ }
239
+ if (!Number.isInteger(mesh.hull) || (mesh.hull as number) < 3 || (mesh.hull as number) > mesh.points.length) {
240
+ 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}`);
241
+ }
242
+ if (mesh.weights !== null) {
243
+ if (!Array.isArray(mesh.weights) || mesh.weights.length !== mesh.points.length) {
244
+ 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`);
245
+ }
246
+ mesh.weights.forEach((vertex: unknown, i: number) => {
247
+ if (!Array.isArray(vertex) || vertex.some((b) => !isObject(b) || typeof b.bone !== 'string' || !isFiniteNumber(b.weight) || b.weight < 0)) {
248
+ 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`);
249
+ }
250
+ });
251
+ }
252
+ validateEnvelopeAndBound(who, 'skinning', skinning.envelope, skinning.maxResidual);
253
+ const deform = skinning.deform;
254
+ 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)`);
255
+ deform.forEach((t: unknown, i: number) => {
256
+ 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')) {
257
+ refuse('REDUCE_INPUT_MISSING', `${who}: skinning.deform[${i}] is not { animation, attachment, keys: [{ time, kind }] }; required a DeformTimelineInput`);
258
+ }
259
+ });
260
+ }
261
+
262
+ /**
263
+ * The envelope and the bound of a residual declaration, refused `REDUCE_INPUT_MISSING` naming the path under `at` —
264
+ * `skinning` on a measurement, `targets.skinning` on a reduction (issue #1295), so the two refuse one value in the
265
+ * same words.
266
+ */
267
+ function validateEnvelopeAndBound(who: string, at: string, envelope: unknown, bound: unknown): void {
268
+ if (!isObject(envelope) || !Array.isArray(envelope.bones)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.envelope is ${shown(envelope)}; required { reference, bones: [{ bone, linear, pivot, translation }] }`);
269
+ if (typeof envelope.reference !== 'string' || envelope.reference === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.envelope.reference is ${shown(envelope.reference)}; required a non-empty bone name — the slot's bone (Q4)`);
270
+ const seen = new Set<string>();
271
+ envelope.bones.forEach((entry: unknown, i: number) => {
272
+ const entryAt = `${at}.envelope.bones[${i}]`;
273
+ if (!isObject(entry) || typeof entry.bone !== 'string' || entry.bone === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt} is ${shown(entry)}; required { bone: a non-empty name, linear, pivot, translation }`);
274
+ if (entry.bone === envelope.reference) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.bone is "${entry.bone}", the envelope's reference; required every bone but the reference — the reference's own terms are 0 by definition`);
275
+ if (seen.has(entry.bone)) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.bone "${entry.bone}" is declared twice; required one entry per bone`);
276
+ seen.add(entry.bone);
277
+ if (!isFiniteNumber(entry.linear) || entry.linear < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.linear is ${shown(entry.linear)}; required a finite number, 0 or more (εk ≥ ‖Ak − A0‖, dimensionless)`);
278
+ if (!isPoint(entry.pivot)) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.pivot is ${shown(entry.pivot)}; required a pair of finite drawing px`);
279
+ if (!isFiniteNumber(entry.translation) || entry.translation < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.translation is ${shown(entry.translation)}; required a finite number of drawing px, 0 or more`);
280
+ });
281
+ if (bound === undefined) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.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`);
282
+ if (bound !== null && (!isFiniteNumber(bound) || bound < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.maxResidual is ${shown(bound)}; required a finite number of drawing px, 0 or more, or null`);
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // the source's identity
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** `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. */
290
+ export function sourceMeshDigest(mesh: SourceMesh): string {
291
+ const canonical = JSON.stringify({
292
+ points: mesh.points.map((p) => [p[0], p[1]]),
293
+ uvs: [...mesh.uvs],
294
+ triangles: [...mesh.triangles],
295
+ hull: mesh.hull,
296
+ weights: mesh.weights === null ? null : mesh.weights.map((v) => v.map((b) => ({ bone: b.bone, weight: b.weight }))),
297
+ });
298
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
299
+ }
300
+
301
+ /** The echo `effective.skinning` writes. */
302
+ export function skinningEchoOf(skinning: SkinningResidualInput | null): SkinningEcho | null {
303
+ if (skinning === null) return null;
304
+ return { source: { id: skinning.source.id, digest: sourceMeshDigest(skinning.source.mesh) }, envelope: skinning.envelope, maxResidual: skinning.maxResidual, deform: skinning.deform };
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // the samples and their terms
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /** One §3 sample: its UV, and the art pixel (mask grid) or source hull vertex it is. */
312
+ export interface SkinningSample {
313
+ uv: [number, number];
314
+ pixel: [number, number] | null;
315
+ vertex: number | null;
316
+ }
317
+
318
+ /** One mesh's terms at one sample: p, and per bone (envelope order, then the reference last) w̄ and s. */
319
+ export interface SampleTerms {
320
+ p: [number, number];
321
+ wbar: number[];
322
+ moment: Array<[number, number]>;
323
+ }
324
+
325
+ /** What `termsAt` reads of a mesh: its setup points and its bindings — a `SourceMesh`, or a reduction's working mesh by id. */
326
+ export interface TermsMesh {
327
+ points: ReadonlyArray<readonly [number, number]>;
328
+ weights: ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>> | null;
329
+ }
330
+
331
+ /** §3's sample set over art bits at the threshold: every art pixel centre in row order, then the source hull's UVs. */
332
+ export function skinningSamplesOf(artBits: Uint8Array, width: number, height: number, source: SourceMesh): { samples: SkinningSample[]; art: number } {
333
+ const samples: SkinningSample[] = [];
334
+ for (let y = 0; y < height; y++) {
335
+ for (let x = 0; x < width; x++) {
336
+ if (artBits[y * width + x]) samples.push({ uv: [(x + 0.5) / width, (y + 0.5) / height], pixel: [x, y], vertex: null });
337
+ }
338
+ }
339
+ const art = samples.length;
340
+ for (let v = 0; v < source.hull; v++) samples.push({ uv: [source.uvs[v * 2], source.uvs[v * 2 + 1]], pixel: null, vertex: v });
341
+ return { samples, art };
342
+ }
343
+
344
+ /**
345
+ * A mesh's terms at a carrier — p, and per bone w̄ and the moment s = Σᵢ βᵢ wᵢₖ (xᵢ − p) — over `bones` (index by name).
346
+ * Shares of a bone `bones` does not name are not read: the coverage check refuses such a mesh before this runs.
347
+ */
348
+ export function termsAt(mesh: TermsMesh, c: Pick<Carrier, 'corners' | 'bary'>, bones: ReadonlyMap<string, number>): SampleTerms {
349
+ const n = bones.size;
350
+ const wbar = new Array<number>(n).fill(0);
351
+ const moment: Array<[number, number]> = Array.from({ length: n }, () => [0, 0]);
352
+ let px = 0;
353
+ let py = 0;
354
+ for (let k = 0; k < 3; k++) {
355
+ const pt = mesh.points[c.corners[k]];
356
+ px += c.bary[k] * pt[0];
357
+ py += c.bary[k] * pt[1];
358
+ }
359
+ for (let k = 0; k < 3; k++) {
360
+ const v = c.corners[k];
361
+ const b = c.bary[k];
362
+ const pt = mesh.points[v];
363
+ const dx = pt[0] - px;
364
+ const dy = pt[1] - py;
365
+ for (const bind of mesh.weights![v]) {
366
+ const j = bones.get(bind.bone);
367
+ if (j === undefined) continue;
368
+ wbar[j] += b * bind.weight;
369
+ moment[j][0] += b * bind.weight * dx;
370
+ moment[j][1] += b * bind.weight * dy;
371
+ }
372
+ }
373
+ return { p: [px, py], wbar, moment };
374
+ }
375
+
376
+ /** The envelope as the arithmetic reads it: bones in envelope order, the reference last with εk and τk 0 (it enters only the weight-field diagnostic). */
377
+ export function envelopeTerms(envelope: SkinningEnvelope): { index: Map<string, number>; eps: number[]; tau: number[]; pivot: Pt[] } {
378
+ const index = new Map<string, number>();
379
+ envelope.bones.forEach((b, i) => index.set(b.bone, i));
380
+ index.set(envelope.reference, envelope.bones.length);
381
+ const eps = [...envelope.bones.map((b) => b.linear), 0];
382
+ const tau = [...envelope.bones.map((b) => b.translation), 0];
383
+ const pivot: Pt[] = [...envelope.bones.map((b) => b.pivot), [0, 0]];
384
+ return { index, eps, tau, pivot };
385
+ }
386
+
387
+ /**
388
+ * One sample's value of the residual from the two meshes' terms there — Σₖ εₖ |Δsₖ| + Σₖ |Δw̄ₖ| (εₖ |pʳ − cₖ| + τₖ), each
389
+ * bone's two shares, and the field-only differences. The one place the arithmetic is written: the measurement and the
390
+ * reduction's carried form (`SkinningCarry`, issue #1295) both call it, so equal terms give the same bits.
391
+ */
392
+ export function sampleResidual(
393
+ r: SampleTerms,
394
+ c: SampleTerms,
395
+ eps: readonly number[],
396
+ tau: readonly number[],
397
+ pivot: readonly Pt[],
398
+ plant: SkinningPlant | null = null,
399
+ ): { total: number; cov: number[]; lev: number[]; l1: number; lInf: number } {
400
+ const K = eps.length;
401
+ let total = 0;
402
+ let l1 = 0;
403
+ let lInf = 0;
404
+ const cov = new Array<number>(K).fill(0);
405
+ const lev = new Array<number>(K).fill(0);
406
+ for (let k = 0; k < K; k++) {
407
+ const dw = Math.abs(c.wbar[k] - r.wbar[k]);
408
+ l1 += dw;
409
+ lInf = Math.max(lInf, dw);
410
+ 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]);
411
+ 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]);
412
+ total += cov[k] + lev[k];
413
+ }
414
+ return { total, cov, lev, l1, lInf };
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // the measurement
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /** What `measureMeshQuality` hands the residual: the candidate (the mesh measured), the field, and the art read at the measurement's threshold. */
422
+ export interface SkinningMeasureArgs {
423
+ attachment: AttachmentRef;
424
+ frame: SourceFrame;
425
+ /** Art bits on the mask grid at the measurement's threshold, and the grid's size. */
426
+ artBits: Uint8Array;
427
+ maskWidth: number;
428
+ maskHeight: number;
429
+ minArtSamples: number;
430
+ threshold: number;
431
+ candidate: SourceMesh;
432
+ skinning: SkinningResidualInput;
433
+ }
434
+
435
+ /** The residual's reading, which the row is built from. */
436
+ export type SkinningReading =
437
+ | { state: 'measured'; value: number; worst: WorstSample; detail: SkinningDetail; samples: number; art: number }
438
+ | { state: 'refused' | 'not-measurable'; reason: string; samples: number; art: number };
439
+
440
+ /** 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. */
441
+ const WEIGHT_SUM_SLACK = 5e-7;
442
+
443
+ /** One `r6` half-step, of a point or of a UV (its error carried through the frame is that times the frame's size). */
444
+ const R6_HALF_STEP = 5e-7;
445
+
446
+ function nameOf(ref: AttachmentRef): string {
447
+ return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
448
+ }
449
+
450
+ /** What the measurement's contract checks leave the value to be read over: the samples, and the contract's own figures. */
451
+ export interface SkinningContract {
452
+ samples: SkinningSample[];
453
+ art: number;
454
+ who: string;
455
+ slack: [number, number];
456
+ vertexGap: number;
457
+ weightSumGap: number;
458
+ }
459
+
460
+ /**
461
+ * The residual's contract checked, in the order a fix has to happen in — what the motion is, which bones bind, the
462
+ * shares, the setup positions, the art floor — or the reading that refuses or cannot measure it. Every check
463
+ * `skinningResidual` makes before it carries a sample; the reduction (issue #1295) reads it once at admission.
464
+ * Internal: on `rig-c/mesh` only through `export *`.
465
+ */
466
+ export function skinningContract(args: SkinningMeasureArgs, plant: SkinningPlant | null = null): SkinningReading | SkinningContract {
467
+ const { frame, candidate, skinning } = args;
468
+ const source = skinning.source.mesh;
469
+ const envelope = skinning.envelope;
470
+ const attachment = args.attachment;
471
+ const who = `attachment ${nameOf(attachment)}`;
472
+ const { samples, art } = skinningSamplesOf(args.artBits, args.maskWidth, args.maskHeight, source);
473
+ const out = (state: 'refused' | 'not-measurable', reason: string): SkinningReading => ({ state, reason, samples: samples.length, art });
474
+
475
+ // What moves the vertices: skinning alone, or a deform key besides it.
476
+ if (plant !== 'deform-unchecked') {
477
+ for (const t of skinning.deform) {
478
+ const k = t.keys.findIndex((key) => key.kind !== 'setup');
479
+ if (k >= 0) {
480
+ return out(
481
+ 'refused',
482
+ `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)`,
483
+ );
484
+ }
485
+ }
486
+ }
487
+
488
+ // Which bones bind, and that the envelope declares every one of them.
489
+ if (source.weights === null || candidate.weights === null) {
490
+ if (source.weights === null && candidate.weights === null) {
491
+ 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`);
492
+ }
493
+ 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`);
494
+ }
495
+ const declared = new Set(envelope.bones.map((b) => b.bone));
496
+ const bound = new Set<string>();
497
+ for (const [label, mesh] of [[`source "${skinning.source.id}"`, source], ['candidate', candidate]] as const) {
498
+ for (let v = 0; v < mesh.weights!.length; v++) {
499
+ for (const b of mesh.weights![v]) {
500
+ if (label !== 'candidate') bound.add(b.bone);
501
+ if (b.bone === envelope.reference || declared.has(b.bone) || plant === 'undeclared-bone-ignored') continue;
502
+ return out(
503
+ 'refused',
504
+ `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`,
505
+ );
506
+ }
507
+ }
508
+ }
509
+ if (plant !== 'unknown-bone-accepted') {
510
+ const i = envelope.bones.findIndex((b) => !bound.has(b.bone));
511
+ if (i >= 0) {
512
+ return out(
513
+ 'refused',
514
+ `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(', ')}`,
515
+ );
516
+ }
517
+ }
518
+
519
+ // The shares: each vertex's sum is 1 on the weight grid, or the reference bone's motion would enter the difference.
520
+ let weightSumGap = 0;
521
+ for (const [label, mesh] of [[`source "${skinning.source.id}"`, source], ['candidate', candidate]] as const) {
522
+ for (let v = 0; v < mesh.weights!.length; v++) {
523
+ const sum = mesh.weights![v].reduce((s, b) => s + b.weight, 0);
524
+ const gap = Math.abs(sum - 1);
525
+ weightSumGap = Math.max(weightSumGap, gap);
526
+ if (gap > WEIGHT_SUM_SLACK) {
527
+ return out(
528
+ 'refused',
529
+ `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`,
530
+ );
531
+ }
532
+ }
533
+ }
534
+
535
+ // The setup positions: every vertex of either mesh at its UV carried through the frame, so one UV is one setup point.
536
+ const slack: [number, number] = [2 * R6_HALF_STEP * (1 + frame.width), 2 * R6_HALF_STEP * (1 + frame.height)];
537
+ let vertexGap = 0;
538
+ for (const [label, mesh] of [[`source "${skinning.source.id}"`, source], ['candidate', candidate]] as const) {
539
+ for (let v = 0; v < mesh.points.length; v++) {
540
+ const dx = Math.abs(mesh.points[v][0] - mesh.uvs[v * 2] * frame.width);
541
+ const dy = Math.abs(mesh.points[v][1] - mesh.uvs[v * 2 + 1] * frame.height);
542
+ vertexGap = Math.max(vertexGap, Math.hypot(dx, dy));
543
+ if ((dx > slack[0] || dy > slack[1]) && plant !== 'setup-unchecked') {
544
+ return out(
545
+ 'refused',
546
+ `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`,
547
+ );
548
+ }
549
+ }
550
+ }
551
+
552
+ // The samples: under the floor is not a measurement.
553
+ if (art < args.minArtSamples) {
554
+ 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`);
555
+ }
556
+ return { samples, art, who, slack, vertexGap, weightSumGap };
557
+ }
558
+
559
+ /**
560
+ * The residual over the §3 sample set, or the reason it is refused or not measurable — the order of the checks is
561
+ * the order a fix has to happen in: what the motion is, which bones bind, the shares, the setup positions, the
562
+ * samples. Internal: on `rig-c/mesh` only through `export *`.
563
+ */
564
+ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant | null = null): SkinningReading {
565
+ const contract = skinningContract(args, plant);
566
+ if ('state' in contract) return contract;
567
+ const { attachment, candidate, skinning } = args;
568
+ const source = skinning.source.mesh;
569
+ const envelope = skinning.envelope;
570
+ const { samples, art, who, slack, vertexGap, weightSumGap } = contract;
571
+ const out = (state: 'refused' | 'not-measurable', reason: string): SkinningReading => ({ state, reason, samples: samples.length, art });
572
+ let sourceCarriers: Array<Carrier | null>;
573
+ let candidateCarriers: Array<Carrier | null>;
574
+ try {
575
+ sourceCarriers = uvCarriers(source.uvs, source.triangles, samples, `source "${skinning.source.id}", attachment ${nameOf(attachment)}`);
576
+ candidateCarriers = uvCarriers(candidate.uvs, candidate.triangles, samples, `candidate, attachment ${nameOf(attachment)}`);
577
+ } catch (err) {
578
+ if (err instanceof MeshReductionError && err.code === 'COMPARE_UV_CARRIER_NOT_UNIQUE') return out('refused', `SKINNING_UV_CARRIER_NOT_UNIQUE: ${err.message}`);
579
+ throw err;
580
+ }
581
+
582
+ // The bones in envelope order, the reference last (its εk and τk are 0, so it enters only the weight-field diagnostic).
583
+ const { index, eps, tau, pivot } = envelopeTerms(envelope);
584
+
585
+ const uncarried = {
586
+ source: samples.filter((_s, j) => sourceCarriers[j] === null).map((s) => s.uv),
587
+ candidate: samples.filter((_s, j) => candidateCarriers[j] === null).map((s) => s.uv),
588
+ };
589
+ let measured = 0;
590
+ let worst = -1;
591
+ let worstAt = -1;
592
+ let worstCov: number[] = [];
593
+ let worstLev: number[] = [];
594
+ let l1Max = 0;
595
+ let lInfMax = 0;
596
+ let sampleGap = 0;
597
+ for (let j = 0; j < samples.length; j++) {
598
+ const cs = sourceCarriers[j];
599
+ const cc = candidateCarriers[j];
600
+ if (cs === null || cc === null) continue;
601
+ measured++;
602
+ const r = termsAt(source, cs, index);
603
+ const c = termsAt(candidate, cc, index);
604
+ sampleGap = Math.max(sampleGap, Math.hypot(c.p[0] - r.p[0], c.p[1] - r.p[1]));
605
+ const { total, cov, lev, l1, lInf } = sampleResidual(r, c, eps, tau, pivot, plant);
606
+ l1Max = Math.max(l1Max, l1);
607
+ lInfMax = Math.max(lInfMax, lInf);
608
+ if (total > worst) {
609
+ worst = total;
610
+ worstAt = j;
611
+ worstCov = cov;
612
+ worstLev = lev;
613
+ }
614
+ }
615
+ if (measured === 0 && plant !== 'uncarried-read-as-zero') {
616
+ 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`);
617
+ }
618
+ const value = measured === 0 ? 0 : r6(worst);
619
+ const s = worstAt >= 0 ? samples[worstAt] : null;
620
+ const at: WorstSample['at'] = s === null ? {} : s.pixel !== null ? { pixel: s.pixel, uv: s.uv } : { vertex: s.vertex ?? undefined, uv: s.uv };
621
+ const sum = (xs: readonly number[]): number => xs.reduce((a, b) => a + b, 0);
622
+ const detail: SkinningDetail = {
623
+ reading: SKINNING_READING,
624
+ source: { id: skinning.source.id, digest: sourceMeshDigest(source) },
625
+ reference: envelope.reference,
626
+ samples: { measured, uncarried },
627
+ worst:
628
+ value === 0
629
+ ? null
630
+ : {
631
+ covariance: r6(sum(worstCov)),
632
+ lever: r6(sum(worstLev)),
633
+ bones: envelope.bones.map((b, k) => ({ bone: b.bone, covariance: r6(worstCov[k]), lever: r6(worstLev[k]) })),
634
+ },
635
+ weightField: { l1: r6(l1Max), lInf: r6(lInfMax) },
636
+ setup: { vertexGap: r6(vertexGap), vertexSlack: slack, sampleGap: r6(sampleGap), weightSumGap },
637
+ };
638
+ return { state: 'measured', value, worst: value === 0 ? { at: {} } : { at }, detail, samples: samples.length, art };
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // the helper: declared ranges → one envelope entry
643
+ // ---------------------------------------------------------------------------
644
+
645
+ /**
646
+ * One bone's declared motion, relative to its setup pose, as `skinningEnvelopeBone` reads it. Every field is required.
647
+ *
648
+ * Coordinates: `pivot` is the bone's setup joint (world origin) in the attachment's drawing frame, px, y down.
649
+ * Rotations and scales are the bone's LOCAL values over the motion relative to its setup values — the keys of its
650
+ * rotate and scale timelines — so a bone that does not move declares `[0, 0]` and `[1, 1]`.
651
+ */
652
+ export interface BoneMotionRange {
653
+ bone: string;
654
+ /** Where the range comes from. Only `'keys'` is certified; any other value — `'physics'` — is refused by name. */
655
+ source: 'keys';
656
+ /** The setup joint in drawing px, y down. */
657
+ pivot: [number, number];
658
+ /** Local rotation minus setup rotation over the motion, degrees, [min, max]. */
659
+ rotate: [number, number];
660
+ /** Local scale over setup scale, per axis, [min, max]. */
661
+ scaleX: [number, number];
662
+ scaleY: [number, number];
663
+ /** The most the bone's local translation moves its joint from setup, drawing px, 0 or more. */
664
+ translate: number;
665
+ /** The bone's setup as the rig declares it — checked against the helper's assumptions, never corrected. */
666
+ setup: { scaleX: number; scaleY: number; shearX: number; shearY: number; inherit: 'normal' };
667
+ }
668
+
669
+ /**
670
+ * What `skinningEnvelopeBone` composes: the chain from the skeleton's root to the envelope's reference, and from below
671
+ * the reference down to the bone the entry is for.
672
+ */
673
+ export interface EnvelopeBoneRanges {
674
+ /** Root first, ending at the reference bone: every bone the reference's world transform is composed of. */
675
+ referenceChain: BoneMotionRange[];
676
+ /** Parent first, from the reference's child down to the bone the entry is for (its last element). At least one. */
677
+ chain: BoneMotionRange[];
678
+ }
679
+
680
+ /** `skinningEnvelopeBone`'s planted faults, for the helper's negative controls (MQ133): scale ranges ignored, the joints' motion ignored. */
681
+ export type EnvelopePlant = 'scale-ignored' | 'translation-ignored';
682
+
683
+ function rangeOf(who: string, at: string, value: unknown, positive: boolean): [number, number] {
684
+ if (!Array.isArray(value) || value.length !== 2 || !isFiniteNumber(value[0]) || !isFiniteNumber(value[1]) || value[0] > value[1] || (positive && value[0] <= 0)) {
685
+ refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${shown(value)}; required [min, max], finite, min <= max${positive ? ', min above 0' : ''}`);
686
+ }
687
+ return [value[0], value[1]];
688
+ }
689
+
690
+ /** e = ‖R(θ) diag(σx, σy) − I‖₂ bounded by 2 sin(θ / 2) + max |σ − 1|, and the scale's norm max |σ|, over a declared range. */
691
+ function stepOf(who: string, at: string, r: unknown): { e: number; norm: number; translate: number; pivot: Pt; rotation: number; scale: number } {
692
+ if (!isObject(r) || typeof r.bone !== 'string' || r.bone === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${shown(r)}; required a BoneMotionRange`);
693
+ const name = `${at} (bone "${r.bone}")`;
694
+ if (r.source !== 'keys') {
695
+ refuse(
696
+ 'SKINNING_RANGE_UNSUPPORTED',
697
+ `${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`,
698
+ );
699
+ }
700
+ 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`);
701
+ const rotate = rangeOf(who, `${name}.rotate`, r.rotate, false);
702
+ const sx = rangeOf(who, `${name}.scaleX`, r.scaleX, true);
703
+ const sy = rangeOf(who, `${name}.scaleY`, r.scaleY, true);
704
+ 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`);
705
+ const setup = r.setup;
706
+ if (!isObject(setup) || !isFiniteNumber(setup.scaleX) || !isFiniteNumber(setup.scaleY) || !isFiniteNumber(setup.shearX) || !isFiniteNumber(setup.shearY) || typeof setup.inherit !== 'string') {
707
+ refuse('REDUCE_INPUT_MISSING', `${who}: ${name}.setup is ${shown(setup)}; required { scaleX, scaleY, shearX, shearY, inherit } as the rig declares them`);
708
+ }
709
+ if (Math.abs(setup.scaleX) !== Math.abs(setup.scaleY) || setup.scaleX === 0) {
710
+ 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`);
711
+ }
712
+ 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`);
713
+ 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`);
714
+ const theta = Math.min(Math.max(Math.abs(rotate[0]), Math.abs(rotate[1])), 180);
715
+ const rotation = 2 * Math.sin((theta * Math.PI) / 360);
716
+ const scale = Math.max(Math.abs(sx[0] - 1), Math.abs(sx[1] - 1), Math.abs(sy[0] - 1), Math.abs(sy[1] - 1));
717
+ const norm = Math.max(sx[1], sy[1]);
718
+ return { e: rotation + scale, norm, translate: r.translate, pivot: r.pivot, rotation, scale };
719
+ }
720
+
721
+ /**
722
+ * One `SkinningEnvelopeBone` from declared ranges (issue #1294; rig-parts#126 Q3: one definition of `linear`, rig-c's).
723
+ *
724
+ * The definition. Under the assumptions below, each bone j's local change is, in the drawing frame, an affine map
725
+ * Fⱼ(q) = cⱼ + Mⱼ (q − cⱼ) + tⱼ with ‖Mⱼ − I‖ ≤ eⱼ = 2 sin(θⱼ / 2) + max |σⱼ − 1| (θⱼ the largest |rotation|, capped
726
+ * at 180°, σⱼ every scale ratio's endpoint), ‖Mⱼ‖ ≤ max σⱼ, and |tⱼ| ≤ the declared `translate`. The bone's deformation
727
+ * relative to the reference is G = F₁ ∘ … ∘ Fₘ over `chain`, and the reference's own deformation scales by at most
728
+ * S = Π max σ over `referenceChain`. Then
729
+ *
730
+ * - `linear` ε = S · (Πⱼ (1 + eⱼ) − 1) ≥ ‖Aₖ − A₀‖ — the composition is a product, because a chain's changes multiply;
731
+ * - `pivot` c = the last bone's setup joint;
732
+ * - `translation` τ = S · δ₁, where δₘ = translate of the last bone and δⱼ = eⱼ |c − cⱼ| + (1 + eⱼ) δⱼ₊₁ + translateⱼ,
733
+ * so that |(Dₖ − D₀) q| ≤ ε |q − c| + τ for every q.
734
+ *
735
+ * Every step is a triangle inequality, so the entry is conservative for any motion inside the declared ranges, and
736
+ * claims nothing outside them. The reference chain's rotations and translations cancel in Dₖ − D₀ and are not read.
737
+ *
738
+ * The assumptions, checked where the input can show them and refused by name (`SKINNING_RANGE_UNSUPPORTED`): each
739
+ * bone's range is declared by its keys (`source: 'keys'`; a physics swing is not certified); every bone of both
740
+ * chains has a uniform setup scale, no setup shear and inherit `normal`, so every setup world map is a similarity and
741
+ * conjugating by it keeps each norm. Not checkable here, and assumed: the attachment's drawing frame maps to world by a
742
+ * similarity (the slot's bone at its setup, as the compile places a part), and the chain listed is the bone's real
743
+ * ancestry. Malformed input is `REDUCE_INPUT_MISSING` naming the path.
744
+ */
745
+ export function skinningEnvelopeBone(ranges: EnvelopeBoneRanges): SkinningEnvelopeBone {
746
+ return envelopeBoneWith(ranges, null);
747
+ }
748
+
749
+ /** `skinningEnvelopeBone` with a fault planted (`EnvelopePlant`) — the helper's negative controls. Internal: merely exported. */
750
+ export function skinningEnvelopeBonePlanted(ranges: EnvelopeBoneRanges, plant: EnvelopePlant): SkinningEnvelopeBone {
751
+ return envelopeBoneWith(ranges, plant);
752
+ }
753
+
754
+ function envelopeBoneWith(ranges: EnvelopeBoneRanges, plant: EnvelopePlant | null): SkinningEnvelopeBone {
755
+ const who = 'skinningEnvelopeBone';
756
+ 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[] }`);
757
+ 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`);
758
+ 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`);
759
+ const above = ranges.referenceChain.map((r, i) => stepOf(who, `referenceChain[${i}]`, r));
760
+ const below = ranges.chain.map((r, i) => stepOf(who, `chain[${i}]`, r));
761
+ const S = above.reduce((s, x) => s * (plant === 'scale-ignored' ? 1 : x.norm), 1);
762
+ const eOf = (x: { e: number; rotation: number }): number => (plant === 'scale-ignored' ? x.rotation : x.e);
763
+ const composed = below.reduce((s, x) => s * (1 + eOf(x)), 1) - 1;
764
+ const last = below[below.length - 1];
765
+ const c = last.pivot;
766
+ let delta = plant === 'translation-ignored' ? 0 : last.translate;
767
+ for (let j = below.length - 2; j >= 0; j--) {
768
+ if (plant === 'translation-ignored') break;
769
+ const x = below[j];
770
+ delta = eOf(x) * Math.hypot(c[0] - x.pivot[0], c[1] - x.pivot[1]) + (1 + eOf(x)) * delta + x.translate;
771
+ }
772
+ return { bone: (ranges.chain[ranges.chain.length - 1] as BoneMotionRange).bone, linear: S * composed, pivot: [c[0], c[1]], translation: S * delta };
773
+ }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // the residual carried through a reduction (issue #1295)
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /**
780
+ * `ReductionTargets.skinning` (issue #1295, §7 mechanism 1, the reducer half): the envelope and the bound every step of
781
+ * a reduction is held to, measured against the reduction's own `source` — the original, fixed for the call. A number
782
+ * for `maxResidual` vetoes every removal, boundary run and post-pass whose residual is over it; `null` declares the
783
+ * bound absent — no step reads it and the result's row is `undeclared`.
784
+ */
785
+ export interface ReductionSkinning {
786
+ envelope: SkinningEnvelope;
787
+ maxResidual: number | null;
788
+ }
789
+
790
+ /**
791
+ * `ReductionTargets.skinning` (issue #1295): left out or `null` is accepted as it stands; anything else is
792
+ * `{ envelope, maxResidual }` in full — the measurement's own envelope and bound, refused in its words under
793
+ * `targets.skinning` — else `REDUCE_INPUT_MISSING` naming the path, before any work. The source is the reduction's
794
+ * own `source`, so it is not restated. Internal: on `rig-c/mesh` only through `export *`.
795
+ */
796
+ export function validateReductionSkinning(who: string, value: unknown): void {
797
+ if (value === undefined || value === null) return;
798
+ if (!isObject(value)) {
799
+ refuse('REDUCE_INPUT_MISSING', `${who}: targets.skinning is ${shown(value)}; required { envelope: { reference, bones }, maxResidual }, null, or the field left out — the source it is measured against is the reduction's own source`);
800
+ }
801
+ validateEnvelopeAndBound(who, 'targets.skinning', value.envelope, value.maxResidual);
802
+ }
803
+
804
+ /** A reduction's working mesh as the carried residual reads it: vertices by stable id, read live — the reduction appends to these arrays. */
805
+ export interface CarriedMesh {
806
+ pos: ReadonlyArray<readonly [number, number]>;
807
+ uv: ReadonlyArray<readonly [number, number]>;
808
+ weights: ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>>;
809
+ }
810
+
811
+ /**
812
+ * Faults planted in the carried residual, for the `mesh-compare` suite's controls (MQ134 onward); a reduction plants
813
+ * none. `stale-carrier`: the samples of a removed triangle are not re-carried, so they keep its carrier and value.
814
+ * `lost-maximum`: when the sample that held the largest value is recomputed, the largest is taken over the recomputed
815
+ * samples alone. `no-rollback`: a refused trial leaves its values in place. `previous-candidate`: each accepted trial
816
+ * becomes the baseline, so the next is measured against the previous accepted candidate rather than the source.
817
+ */
818
+ export type CarryPlant = 'stale-carrier' | 'lost-maximum' | 'no-rollback' | 'previous-candidate';
819
+
820
+ /** What the carried residual computed — the work a control and §7 read; the report carries none of it. */
821
+ export interface CarryTally {
822
+ /** Trials made: one per removal, run or post-pass the static rows let through, and one per refinement insertion. */
823
+ trials: number;
824
+ commits: number;
825
+ rollbacks: number;
826
+ /** Samples whose carrier was searched again and whose value was recomputed, over every trial. */
827
+ samplesRecomputed: number;
828
+ /** Sample × triangle containment tests made by those searches. */
829
+ containmentTests: number;
830
+ trianglesRemoved: number;
831
+ trianglesAdded: number;
832
+ /** Trials that re-carried every sample — the bounded fallback, taken only when the area band moved a triangle it did not change. */
833
+ fullRecarries: number;
834
+ /** The most samples one trial recomputed. */
835
+ peakAffected: number;
836
+ }
837
+
838
+ /** The carried residual's reading of the working mesh: a value on the `r6` grid, or why there is none. */
839
+ export type CarryReading = { state: 'measured'; value: number } | { state: 'refused' | 'not-measurable'; reason: string };
840
+
841
+ /** What a control reads when it holds the carried state to the full recompute (`SkinningCarry.compareWithFull`). */
842
+ export interface CarryCheck {
843
+ /** Every sample's value within `CARRY_TOLERANCE`, the same samples measured, and the same uncarried counts. */
844
+ equal: boolean;
845
+ /** The largest |carried − full| over samples both measure. */
846
+ sampleDiff: number;
847
+ /** Samples measured by one and not the other. */
848
+ measuredMismatch: number;
849
+ carried: { max: number; measured: number; uncarried: number; reading: CarryReading };
850
+ full: { max: number; measured: number; uncarried: number };
851
+ }
852
+
853
+ /**
854
+ * How far a carried sample value may sit from the full recompute's, px. The two read the same terms through
855
+ * `sampleResidual`; they differ only in the order a triangle's corners are summed (the working mesh's order against
856
+ * the canonical mesh's) and, for a sample on an edge two triangles share, in which of them carries it — float
857
+ * rounding of terms of order 10² px. Measured on the controls (MQ135) and stated there.
858
+ */
859
+ export const CARRY_TOLERANCE = 1e-9;
860
+
861
+ type Tri = readonly [number, number, number];
862
+
863
+ /** A triangle's identity across trials: its corners rotated to start at the smallest id, winding kept. */
864
+ function triKey(t: Tri): string {
865
+ const k = t[0] < t[1] ? (t[0] < t[2] ? 0 : 2) : t[1] < t[2] ? 1 : 2;
866
+ return `${t[k]},${t[(k + 1) % 3]},${t[(k + 2) % 3]}`;
867
+ }
868
+
869
+ interface CarryLogEntry {
870
+ j: number;
871
+ slot: number;
872
+ b0: number;
873
+ b1: number;
874
+ b2: number;
875
+ value: number;
876
+ }
877
+
878
+ interface Pending {
879
+ removed: number[];
880
+ firstAdded: number;
881
+ log: CarryLogEntry[];
882
+ appended: Array<[number, number]>;
883
+ measured: number;
884
+ uncarried: number;
885
+ refused: string | null;
886
+ broken: string | null;
887
+ plantMax: { value: number; at: number };
888
+ /** Under `previous-candidate`: the candidate terms of each sample the trial recomputed, made the baseline on commit. */
889
+ rebase: Array<[number, SampleTerms | null]>;
890
+ checked: number[];
891
+ /** A fallback trial: every list is rebuilt from the carriers, and rebuilt again on rollback; with the live flags it moved. */
892
+ fallback: boolean;
893
+ liveWas: Array<[number, boolean]>;
894
+ }
895
+
896
+ /**
897
+ * `MQ_SKINNING_RESIDUAL` carried through a reduction's steps (issue #1295) — the value `skinningResidual` reads of the
898
+ * working mesh against the fixed original source, kept per sample so that a step recomputes only what it changed.
899
+ *
900
+ * - **Fixed for the call:** the §3 samples, each one's carrier in the source and the source's terms there (one
901
+ * `uvCarriers` over the source), and the envelope. The baseline is the source supplied, never a previous candidate.
902
+ * - **Carried per sample:** the candidate's carrier (triangle, barycentric) and the sample's value; per triangle, the
903
+ * samples it carries; a max tree over the values, so the largest — the row's value — is re-derived in O(log n) when
904
+ * the sample that held it is recomputed, without a scan.
905
+ * - **A trial** (`trial`) diffs the working triangles against the carried ones: the samples of every removed triangle,
906
+ * and the uncarried samples inside an added triangle's box, are searched again among the added triangles (and,
907
+ * for a sample a removal leaves outside them, among the unchanged triangles that share a vertex with a removed
908
+ * one); everything else is untouched. `commit` keeps it; `rollback` restores every carrier, value, list, count and
909
+ * the maximum. Work per trial: the triangles' keys (one pass over the triangles) and the affected samples times
910
+ * the triangles searched for them.
911
+ * - **The bounded fallback:** the live set — triangles whose UV area clears `areaBand`, the band `uvCarriers` reads —
912
+ * depends on the largest triangle, so a trial that moves an unchanged triangle across the band re-carries every
913
+ * sample, counted in `fullRecarries`; it is the only full scan, and it is taken only then.
914
+ *
915
+ * Pure: no clock, no randomness, no runtime; every loop runs in a fixed order.
916
+ */
917
+ export class SkinningCarry {
918
+ readonly tally: CarryTally = { trials: 0, commits: 0, rollbacks: 0, samplesRecomputed: 0, containmentTests: 0, trianglesRemoved: 0, trianglesAdded: 0, fullRecarries: 0, peakAffected: 0 };
919
+ private readonly n: number;
920
+ private readonly K: number;
921
+ private readonly index: Map<string, number>;
922
+ private readonly eps: number[];
923
+ private readonly tau: number[];
924
+ private readonly pivot: Pt[];
925
+ private readonly su: Float64Array;
926
+ private readonly sv: Float64Array;
927
+ private readonly art: number;
928
+ private readonly pixelSample: Int32Array;
929
+ private readonly maskWidth: number;
930
+ private readonly maskHeight: number;
931
+ /** The source's terms per sample, or null where the source does not carry the sample — fixed for the call. */
932
+ private readonly sourceTerms: ReadonlyArray<SampleTerms | null>;
933
+ /** What each sample is measured against: the source's terms (only the `previous-candidate` plant moves it). */
934
+ private readonly base: Array<SampleTerms | null>;
935
+ private readonly cSlot: Int32Array;
936
+ private readonly cBary: Float64Array;
937
+ private readonly value: Float64Array;
938
+ private readonly size: number;
939
+ private readonly tree: Int32Array;
940
+ private slotCorners: Array<[number, number, number]> = [];
941
+ private slotKey: string[] = [];
942
+ private slotArea: number[] = [];
943
+ private slotLive: boolean[] = [];
944
+ private slotBox: number[] = [];
945
+ private slotSamples: number[][] = [];
946
+ private readonly keyToSlot = new Map<string, number>();
947
+ private measured = 0;
948
+ private uncarried = 0;
949
+ private broken: string | null = null;
950
+ private readonly checked = new Set<number>();
951
+ private pending: Pending | null = null;
952
+ private plantMax = { value: -1, at: -1 };
953
+ private stamp: Int32Array;
954
+ private stampAt = 0;
955
+
956
+ constructor(
957
+ private readonly who: string,
958
+ private readonly mesh: CarriedMesh,
959
+ private readonly nSource: number,
960
+ triangles: ReadonlyArray<Tri>,
961
+ source: SourceMesh,
962
+ envelope: SkinningEnvelope,
963
+ artBits: Uint8Array,
964
+ maskWidth: number,
965
+ maskHeight: number,
966
+ private readonly frame: { width: number; height: number },
967
+ private readonly plant: CarryPlant | null = null,
968
+ ) {
969
+ const { samples, art } = skinningSamplesOf(artBits, maskWidth, maskHeight, source);
970
+ const terms = envelopeTerms(envelope);
971
+ this.index = terms.index;
972
+ this.eps = terms.eps;
973
+ this.tau = terms.tau;
974
+ this.pivot = terms.pivot;
975
+ this.K = this.eps.length;
976
+ this.n = samples.length;
977
+ this.art = art;
978
+ this.maskWidth = maskWidth;
979
+ this.maskHeight = maskHeight;
980
+ this.su = new Float64Array(this.n);
981
+ this.sv = new Float64Array(this.n);
982
+ this.pixelSample = new Int32Array(maskWidth * maskHeight).fill(-1);
983
+ samples.forEach((s, j) => {
984
+ this.su[j] = s.uv[0];
985
+ this.sv[j] = s.uv[1];
986
+ if (s.pixel !== null) this.pixelSample[s.pixel[1] * maskWidth + s.pixel[0]] = j;
987
+ });
988
+ // The source's carriers and terms, once: the measurement's own `uvCarriers` and `termsAt`.
989
+ const sourceCarriers = uvCarriers(source.uvs, source.triangles, samples, `${who}: source`);
990
+ this.sourceTerms = sourceCarriers.map((c) => (c === null ? null : termsAt(source, c, this.index)));
991
+ this.base = [...this.sourceTerms];
992
+ // The candidate starts as the working mesh handed in — the source, before any step.
993
+ this.cSlot = new Int32Array(this.n).fill(-1);
994
+ this.cBary = new Float64Array(this.n * 3);
995
+ this.value = new Float64Array(this.n).fill(-1);
996
+ let size = 1;
997
+ while (size < Math.max(1, this.n)) size *= 2;
998
+ this.size = size;
999
+ this.tree = new Int32Array(2 * size).fill(-1);
1000
+ this.stamp = new Int32Array(this.n);
1001
+ const flatUv: number[] = [];
1002
+ for (let v = 0; v < mesh.uv.length; v++) flatUv.push(mesh.uv[v][0], mesh.uv[v][1]);
1003
+ const flatTri: number[] = [];
1004
+ for (const t of triangles) flatTri.push(t[0], t[1], t[2]);
1005
+ const areas = triangleAreas(flatUv, flatTri);
1006
+ const band = areaBand(areas, flatUv);
1007
+ triangles.forEach((t, i) => this.addSlot(t, areas[i], band));
1008
+ for (let s = 0; s < this.slotCorners.length; s++) this.keyToSlot.set(this.slotKey[s], s);
1009
+ // The working mesh handed in is the source itself (the reduction builds the carry before any step), so its carriers
1010
+ // are the source's — one search, not two; any other mesh is searched as it is.
1011
+ const same = flatTri.length === source.triangles.length && flatTri.every((v, i) => v === source.triangles[i]) && flatUv.length === source.uvs.length && flatUv.every((v, i) => v === source.uvs[i]);
1012
+ const candidateCarriers = same ? sourceCarriers : uvCarriers(flatUv, flatTri, samples, `${who}: candidate`);
1013
+ candidateCarriers.forEach((c, j) => {
1014
+ if (c === null) {
1015
+ this.uncarried++;
1016
+ return;
1017
+ }
1018
+ this.cSlot[j] = c.triangle;
1019
+ this.cBary[j * 3] = c.bary[0];
1020
+ this.cBary[j * 3 + 1] = c.bary[1];
1021
+ this.cBary[j * 3 + 2] = c.bary[2];
1022
+ this.slotSamples[c.triangle].push(j);
1023
+ // The candidate is the source and carries the sample in the same triangle: its terms are the source's, and
1024
+ // `sampleResidual` of equal terms is 0 exactly — so it is not recomputed.
1025
+ this.setValue(j, same ? (this.base[j] === null ? -1 : 0) : this.valueAt(j));
1026
+ });
1027
+ this.plantMax = this.treeMax();
1028
+ }
1029
+
1030
+ // --- the max tree ---------------------------------------------------------
1031
+
1032
+ private better(a: number, b: number): number {
1033
+ if (a < 0) return b;
1034
+ if (b < 0) return a;
1035
+ return this.value[b] > this.value[a] ? b : a;
1036
+ }
1037
+
1038
+ /** Set a sample's value; the tree is updated unless the constructor is still filling it. */
1039
+ private setValue(j: number, v: number): void {
1040
+ const was = this.value[j];
1041
+ if (was >= 0) this.measured--;
1042
+ this.value[j] = v;
1043
+ if (v >= 0) this.measured++;
1044
+ let i = this.size + j;
1045
+ this.tree[i] = v >= 0 ? j : -1;
1046
+ for (i >>= 1; i >= 1; i >>= 1) this.tree[i] = this.better(this.tree[2 * i], this.tree[2 * i + 1]);
1047
+ }
1048
+
1049
+ private treeMax(): { value: number; at: number } {
1050
+ const at = this.tree[1];
1051
+ return at < 0 ? { value: -1, at: -1 } : { value: this.value[at], at };
1052
+ }
1053
+
1054
+ // --- slots ----------------------------------------------------------------
1055
+
1056
+ private addSlot(t: Tri, area: number, band: number): number {
1057
+ const s = this.slotCorners.length;
1058
+ this.slotCorners.push([t[0], t[1], t[2]]);
1059
+ this.slotKey.push(triKey(t));
1060
+ this.slotArea.push(area);
1061
+ this.slotLive.push(Math.abs(area) > band);
1062
+ const xs = [this.mesh.uv[t[0]][0], this.mesh.uv[t[1]][0], this.mesh.uv[t[2]][0]];
1063
+ const ys = [this.mesh.uv[t[0]][1], this.mesh.uv[t[1]][1], this.mesh.uv[t[2]][1]];
1064
+ this.slotBox.push(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
1065
+ this.slotSamples.push([]);
1066
+ return s;
1067
+ }
1068
+
1069
+ private uvArea(t: Tri): number {
1070
+ const uv = this.mesh.uv;
1071
+ const x0 = uv[t[0]][0];
1072
+ const y0 = uv[t[0]][1];
1073
+ return 0.5 * ((uv[t[1]][0] - x0) * (uv[t[2]][1] - y0) - (uv[t[2]][0] - x0) * (uv[t[1]][1] - y0));
1074
+ }
1075
+
1076
+ private test(j: number, s: number): Carrier | null {
1077
+ this.tally.containmentTests++;
1078
+ const c = this.slotCorners[s];
1079
+ const uv = this.mesh.uv;
1080
+ return carrierIn(this.su[j], this.sv[j], this.slotBox[s * 4], this.slotBox[s * 4 + 1], this.slotBox[s * 4 + 2], this.slotBox[s * 4 + 3], c[0], c[1], c[2], (v) => uv[v][0], (v) => uv[v][1], s);
1081
+ }
1082
+
1083
+ // --- values ---------------------------------------------------------------
1084
+
1085
+ private candidateTerms(j: number): SampleTerms | null {
1086
+ const s = this.cSlot[j];
1087
+ if (s < 0) return null;
1088
+ return termsAt({ points: this.mesh.pos, weights: this.mesh.weights }, { corners: this.slotCorners[s], bary: [this.cBary[j * 3], this.cBary[j * 3 + 1], this.cBary[j * 3 + 2]] }, this.index);
1089
+ }
1090
+
1091
+ private valueAt(j: number): number {
1092
+ const r = this.base[j];
1093
+ const c = this.candidateTerms(j);
1094
+ if (r === null || c === null) return -1;
1095
+ return sampleResidual(r, c, this.eps, this.tau, this.pivot).total;
1096
+ }
1097
+
1098
+ // --- a trial --------------------------------------------------------------
1099
+
1100
+ /**
1101
+ * The working triangles after a proposed operation, carried provisionally: the reading of the mesh they make. Ends
1102
+ * in exactly one `commit` or `rollback`.
1103
+ */
1104
+ trial(triangles: ReadonlyArray<Tri>): CarryReading {
1105
+ if (this.pending !== null) throw new Error('SkinningCarry: a trial was begun while another was pending');
1106
+ this.tally.trials++;
1107
+ const seen = new Uint8Array(this.slotCorners.length);
1108
+ const added: Tri[] = [];
1109
+ let largest = 0;
1110
+ let coordinate = 0;
1111
+ const uv = this.mesh.uv;
1112
+ for (const t of triangles) {
1113
+ const s = this.keyToSlot.get(triKey(t));
1114
+ if (s === undefined) added.push(t);
1115
+ else {
1116
+ seen[s] = 1;
1117
+ largest = Math.max(largest, Math.abs(this.slotArea[s]));
1118
+ }
1119
+ for (let k = 0; k < 3; k++) coordinate = Math.max(coordinate, Math.abs(uv[t[k]][0]), Math.abs(uv[t[k]][1]));
1120
+ }
1121
+ const addedAreas = added.map((t) => this.uvArea(t));
1122
+ for (const a of addedAreas) largest = Math.max(largest, Math.abs(a));
1123
+ // areaBand's two bands over the candidate: the relative one and the float32 noise of its UVs.
1124
+ const band = Math.max(largest * 1e-6, 4 * coordinate * coordinate * 2 ** -24);
1125
+ const removed: number[] = [];
1126
+ let moved = false;
1127
+ for (const s of this.keyToSlot.values()) {
1128
+ if (!seen[s]) removed.push(s);
1129
+ else if (Math.abs(this.slotArea[s]) > band !== this.slotLive[s]) moved = true;
1130
+ }
1131
+ removed.sort((a, b) => a - b);
1132
+ const pending: Pending = {
1133
+ removed,
1134
+ firstAdded: this.slotCorners.length,
1135
+ log: [],
1136
+ appended: [],
1137
+ measured: this.measured,
1138
+ uncarried: this.uncarried,
1139
+ refused: null,
1140
+ broken: this.broken,
1141
+ plantMax: { ...this.plantMax },
1142
+ rebase: [],
1143
+ checked: [],
1144
+ fallback: moved,
1145
+ liveWas: [],
1146
+ };
1147
+ this.pending = pending;
1148
+ this.tally.trianglesRemoved += removed.length;
1149
+ this.tally.trianglesAdded += added.length;
1150
+ const addedSlots = added.map((t, i) => this.addSlot(t, addedAreas[i], band));
1151
+ const isRemoved = new Uint8Array(this.slotCorners.length);
1152
+ for (const s of removed) isRemoved[s] = 1;
1153
+
1154
+ // The contract an inserted vertex has to meet, as the measurement checks every vertex of the candidate.
1155
+ for (const s of addedSlots) for (const v of this.slotCorners[s]) if (v >= this.nSource && !this.checked.has(v)) this.checkInserted(v, pending);
1156
+
1157
+ const oldMax = this.treeMax();
1158
+ const affected: number[] = [];
1159
+ this.stampAt++;
1160
+ const take = (j: number): void => {
1161
+ if (this.stamp[j] === this.stampAt) return;
1162
+ this.stamp[j] = this.stampAt;
1163
+ affected.push(j);
1164
+ };
1165
+ if (moved) {
1166
+ // The bounded fallback: an unchanged triangle crossed the band, so every carrier is searched again.
1167
+ this.tally.fullRecarries++;
1168
+ for (let s = 0; s < pending.firstAdded; s++) {
1169
+ if (!seen[s]) continue;
1170
+ pending.liveWas.push([s, this.slotLive[s]]);
1171
+ this.slotLive[s] = Math.abs(this.slotArea[s]) > band;
1172
+ }
1173
+ for (let j = 0; j < this.n; j++) take(j);
1174
+ } else {
1175
+ if (this.plant !== 'stale-carrier') for (const s of removed) for (const j of this.slotSamples[s]) take(j);
1176
+ for (const s of addedSlots) {
1177
+ if (!this.slotLive[s]) continue;
1178
+ const [x0, y0, x1, y1] = [this.slotBox[s * 4], this.slotBox[s * 4 + 1], this.slotBox[s * 4 + 2], this.slotBox[s * 4 + 3]];
1179
+ const W = this.maskWidth;
1180
+ const H = this.maskHeight;
1181
+ const xa = Math.max(0, Math.floor(x0 * W - 0.5) - 1);
1182
+ const xb = Math.min(W - 1, Math.ceil(x1 * W - 0.5) + 1);
1183
+ const ya = Math.max(0, Math.floor(y0 * H - 0.5) - 1);
1184
+ const yb = Math.min(H - 1, Math.ceil(y1 * H - 0.5) + 1);
1185
+ for (let y = ya; y <= yb; y++) {
1186
+ for (let x = xa; x <= xb; x++) {
1187
+ const j = this.pixelSample[y * W + x];
1188
+ if (j >= 0 && this.cSlot[j] === -1) take(j);
1189
+ }
1190
+ }
1191
+ for (let j = this.art; j < this.n; j++) {
1192
+ if (this.cSlot[j] !== -1) continue;
1193
+ if (this.su[j] < x0 - 1e-9 || this.su[j] > x1 + 1e-9 || this.sv[j] < y0 - 1e-9 || this.sv[j] > y1 + 1e-9) continue;
1194
+ take(j);
1195
+ }
1196
+ }
1197
+ }
1198
+ affected.sort((a, b) => a - b);
1199
+ this.tally.peakAffected = Math.max(this.tally.peakAffected, affected.length);
1200
+ this.tally.samplesRecomputed += affected.length;
1201
+
1202
+ // Where a sample a removal leaves outside every added triangle may still lie: on an unchanged triangle that shares a
1203
+ // vertex with a removed one. Computed once, when first needed.
1204
+ let around: number[] | null = null;
1205
+ const neighbourhood = (): number[] => {
1206
+ if (around !== null) return around;
1207
+ const corners = new Set<number>();
1208
+ for (const s of removed) for (const v of this.slotCorners[s]) corners.add(v);
1209
+ around = [];
1210
+ for (const s of this.keyToSlot.values()) if (seen[s] && this.slotLive[s] && this.slotCorners[s].some((v) => corners.has(v))) around.push(s);
1211
+ around.sort((a, b) => a - b);
1212
+ return around;
1213
+ };
1214
+ const liveAdded = addedSlots.filter((s) => this.slotLive[s]);
1215
+ const allLive = moved ? [...this.keyToSlot.values()].filter((s) => seen[s] && this.slotLive[s]).concat(liveAdded).sort((a, b) => a - b) : null;
1216
+ for (const j of affected) {
1217
+ const old = this.cSlot[j];
1218
+ const hits: Carrier[] = [];
1219
+ if (allLive !== null) {
1220
+ for (const s of allLive) {
1221
+ const h = this.test(j, s);
1222
+ if (h !== null) hits.push(h);
1223
+ }
1224
+ } else {
1225
+ for (const s of liveAdded) {
1226
+ const h = this.test(j, s);
1227
+ if (h !== null) hits.push(h);
1228
+ }
1229
+ if (hits.length === 0 && old >= 0 && isRemoved[old]) {
1230
+ for (const s of neighbourhood()) {
1231
+ const h = this.test(j, s);
1232
+ if (h !== null) hits.push(h);
1233
+ }
1234
+ }
1235
+ }
1236
+ let carrier: Carrier | null;
1237
+ try {
1238
+ carrier = resolveCarriers(this.su[j], this.sv[j], hits, `${this.who}: candidate (working vertex ids)`);
1239
+ } catch (err) {
1240
+ if (!(err instanceof MeshReductionError)) throw err;
1241
+ if (pending.refused === null) pending.refused = `SKINNING_UV_CARRIER_NOT_UNIQUE: ${err.message}`;
1242
+ carrier = hits[0];
1243
+ }
1244
+ pending.log.push({ j, slot: old, b0: this.cBary[j * 3], b1: this.cBary[j * 3 + 1], b2: this.cBary[j * 3 + 2], value: this.value[j] });
1245
+ const now = carrier === null ? -1 : carrier.triangle;
1246
+ if (old === -1 && now !== -1) this.uncarried--;
1247
+ if (old !== -1 && now === -1) this.uncarried++;
1248
+ this.cSlot[j] = now;
1249
+ if (carrier !== null) {
1250
+ this.cBary[j * 3] = carrier.bary[0];
1251
+ this.cBary[j * 3 + 1] = carrier.bary[1];
1252
+ this.cBary[j * 3 + 2] = carrier.bary[2];
1253
+ if (!moved) {
1254
+ this.slotSamples[now].push(j);
1255
+ if (now < pending.firstAdded) pending.appended.push([now, j]);
1256
+ }
1257
+ }
1258
+ this.setValue(j, this.valueAt(j));
1259
+ if (this.plant === 'previous-candidate') pending.rebase.push([j, this.candidateTerms(j)]);
1260
+ }
1261
+ if (moved) this.rebuildLists();
1262
+ if (this.plant === 'lost-maximum') {
1263
+ let best = { value: -1, at: -1 };
1264
+ for (const j of affected) if (this.value[j] > best.value) best = { value: this.value[j], at: j };
1265
+ const recomputed = affected.includes(oldMax.at);
1266
+ this.plantMax = recomputed ? best : best.value > this.plantMax.value ? best : this.plantMax;
1267
+ } else this.plantMax = this.treeMax();
1268
+ return this.reading();
1269
+ }
1270
+
1271
+ private checkInserted(v: number, pending: Pending): void {
1272
+ this.checked.add(v);
1273
+ pending.checked.push(v);
1274
+ const shares = this.mesh.weights[v];
1275
+ const sum = shares.reduce((s, b) => s + b.weight, 0);
1276
+ const p = this.mesh.pos[v];
1277
+ const u = this.mesh.uv[v];
1278
+ const slack: [number, number] = [2 * R6_HALF_STEP * (1 + this.frame.width), 2 * R6_HALF_STEP * (1 + this.frame.height)];
1279
+ if (Math.abs(sum - 1) > WEIGHT_SUM_SLACK) {
1280
+ this.broken ??= `SKINNING_WEIGHT_SUM: ${this.who}: the vertex the refinement inserted at (${p[0]}, ${p[1]}) has shares summing to ${sum}; required 1 within ${WEIGHT_SUM_SLACK} (half a step of the 6-decimal weight grid)`;
1281
+ } else if (Math.abs(p[0] - u[0] * this.frame.width) > slack[0] || Math.abs(p[1] - u[1] * this.frame.height) > slack[1]) {
1282
+ this.broken ??= `SKINNING_SETUP_MISMATCH: ${this.who}: the vertex the refinement inserted at (${p[0]}, ${p[1]}) has UV (${u[0]}, ${u[1]}), at (${r6(u[0] * this.frame.width)}, ${r6(u[1] * this.frame.height)}) through the ${this.frame.width}x${this.frame.height} px frame; required within (${slack[0]}, ${slack[1]}) px per axis`;
1283
+ }
1284
+ }
1285
+
1286
+ /** The reading of the state as it stands — after a trial, its provisional state. */
1287
+ reading(): CarryReading {
1288
+ if (this.broken !== null) return { state: 'refused', reason: this.broken };
1289
+ if (this.pending !== null && this.pending.refused !== null) return { state: 'refused', reason: this.pending.refused };
1290
+ if (this.measured === 0) return { state: 'not-measurable', reason: `${this.who}: no sample is carried by both the source's and the candidate's UV triangles, so there is nothing to bound` };
1291
+ return { state: 'measured', value: r6(this.plantMax.value) };
1292
+ }
1293
+
1294
+ /** Every triangle's sample list read again off the carriers — the fallback's, and its rollback's. */
1295
+ private rebuildLists(): void {
1296
+ for (let s = 0; s < this.slotSamples.length; s++) this.slotSamples[s] = [];
1297
+ for (let j = 0; j < this.n; j++) if (this.cSlot[j] >= 0) this.slotSamples[this.cSlot[j]].push(j);
1298
+ }
1299
+
1300
+ /** Keep the pending trial. */
1301
+ commit(): void {
1302
+ const p = this.pending;
1303
+ if (p === null) throw new Error('SkinningCarry: commit with no trial pending');
1304
+ for (const s of p.removed) {
1305
+ this.keyToSlot.delete(this.slotKey[s]);
1306
+ this.slotSamples[s] = [];
1307
+ }
1308
+ for (let s = p.firstAdded; s < this.slotCorners.length; s++) this.keyToSlot.set(this.slotKey[s], s);
1309
+ for (const [j, terms] of p.rebase) {
1310
+ if (this.base[j] === null) continue;
1311
+ this.base[j] = terms;
1312
+ this.setValue(j, this.valueAt(j));
1313
+ }
1314
+ if (p.rebase.length > 0) this.plantMax = this.treeMax();
1315
+ this.pending = null;
1316
+ this.tally.commits++;
1317
+ }
1318
+
1319
+ /** Undo the pending trial: every carrier, value, list, count and the maximum as they were before it. */
1320
+ rollback(): void {
1321
+ const p = this.pending;
1322
+ if (p === null) throw new Error('SkinningCarry: rollback with no trial pending');
1323
+ for (let i = p.appended.length - 1; i >= 0; i--) this.slotSamples[p.appended[i][0]].pop();
1324
+ for (let i = p.log.length - 1; i >= 0; i--) {
1325
+ const e = p.log[i];
1326
+ this.cSlot[e.j] = e.slot;
1327
+ this.cBary[e.j * 3] = e.b0;
1328
+ this.cBary[e.j * 3 + 1] = e.b1;
1329
+ this.cBary[e.j * 3 + 2] = e.b2;
1330
+ if (this.plant !== 'no-rollback') this.setValue(e.j, e.value);
1331
+ }
1332
+ if (this.plant !== 'no-rollback') this.measured = p.measured;
1333
+ this.uncarried = p.uncarried;
1334
+ const keep = p.firstAdded;
1335
+ this.slotCorners.length = keep;
1336
+ this.slotKey.length = keep;
1337
+ this.slotArea.length = keep;
1338
+ this.slotLive.length = keep;
1339
+ this.slotBox.length = keep * 4;
1340
+ this.slotSamples.length = keep;
1341
+ for (const [s, live] of p.liveWas) this.slotLive[s] = live;
1342
+ if (p.fallback) this.rebuildLists();
1343
+ for (const v of p.checked) this.checked.delete(v);
1344
+ this.broken = p.broken;
1345
+ if (this.plant !== 'no-rollback') this.plantMax = p.plantMax;
1346
+ this.pending = null;
1347
+ this.tally.rollbacks++;
1348
+ }
1349
+
1350
+ /** Bytes the carried state holds: the typed arrays, the baseline terms, the triangles and their sample lists. */
1351
+ memoryBytes(): number {
1352
+ const typed = this.su.byteLength + this.sv.byteLength + this.pixelSample.byteLength + this.cSlot.byteLength + this.cBary.byteLength + this.value.byteLength + this.tree.byteLength + this.stamp.byteLength;
1353
+ let based = 0;
1354
+ for (const b of this.base) if (b !== null) based++;
1355
+ const terms = based * 8 * (2 + 3 * this.K);
1356
+ let listed = 0;
1357
+ for (const s of this.keyToSlot.values()) listed += this.slotSamples[s].length;
1358
+ const slots = this.keyToSlot.size * 8 * (3 + 1 + 4) + listed * 8;
1359
+ return typed + terms + slots;
1360
+ }
1361
+
1362
+ /**
1363
+ * The carried state held to the full recompute over `candidate`, the canonical form of the working mesh as it now
1364
+ * stands: every sample carried in it by `uvCarriers`, its terms by `termsAt` and its value by `sampleResidual` — the
1365
+ * measurement's own path, sample for sample.
1366
+ */
1367
+ compareWithFull(candidate: SourceMesh): CarryCheck {
1368
+ const samples: Array<{ uv: [number, number] }> = [];
1369
+ for (let j = 0; j < this.n; j++) samples.push({ uv: [this.su[j], this.sv[j]] });
1370
+ const carriers = uvCarriers(candidate.uvs, candidate.triangles, samples, `${this.who}: candidate`);
1371
+ let sampleDiff = 0;
1372
+ let measuredMismatch = 0;
1373
+ let fullMax = -1;
1374
+ let fullMeasured = 0;
1375
+ let fullUncarried = 0;
1376
+ let carriedMax = -1;
1377
+ for (let j = 0; j < this.n; j++) {
1378
+ const c = carriers[j];
1379
+ if (c === null) fullUncarried++;
1380
+ const r = this.sourceTerms[j];
1381
+ const full = r === null || c === null ? -1 : sampleResidual(r, termsAt(candidate, c, this.index), this.eps, this.tau, this.pivot).total;
1382
+ if (full >= 0) fullMeasured++;
1383
+ fullMax = Math.max(fullMax, full);
1384
+ carriedMax = Math.max(carriedMax, this.value[j]);
1385
+ if ((full >= 0) !== (this.value[j] >= 0)) measuredMismatch++;
1386
+ else if (full >= 0) sampleDiff = Math.max(sampleDiff, Math.abs(full - this.value[j]));
1387
+ }
1388
+ const reading = this.reading();
1389
+ const readMax = reading.state === 'measured' ? this.plantMax.value : -1;
1390
+ const equal = measuredMismatch === 0 && sampleDiff <= CARRY_TOLERANCE && fullUncarried === this.uncarried && fullMeasured === this.measured && Math.abs(readMax - fullMax) <= CARRY_TOLERANCE;
1391
+ return {
1392
+ equal,
1393
+ sampleDiff,
1394
+ measuredMismatch,
1395
+ carried: { max: readMax, measured: this.measured, uncarried: this.uncarried, reading },
1396
+ full: { max: fullMax, measured: fullMeasured, uncarried: fullUncarried },
1397
+ };
1398
+ }
1399
+ }