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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-c",
3
- "version": "2.29.0",
3
+ "version": "2.31.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/mesh.ts CHANGED
@@ -50,9 +50,11 @@ import type { ModelBinding, ModelVertices } from './model.ts';
50
50
  // two form an import cycle: safe only because neither reads the other's
51
51
  // bindings while it is being evaluated — every use is inside a function.
52
52
  // The reduction (`reduceMesh`, stage B2 of #1224) sits in the same cycle, by
53
- // the same rule.
53
+ // the same rule. So does the skinning residual and its envelope helper
54
+ // (`src/meshskinning.ts`, issue #1294), which the measurement reads.
54
55
  export * from './meshquality.ts';
55
56
  export * from './meshreduce.ts';
57
+ export * from './meshskinning.ts';
56
58
 
57
59
  export interface MeshSpecInput {
58
60
  /** Polygon in part-local pixels, y down, in manifest order. */
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Where a UV sample lies in a mesh's UV triangulation — §3 and correction 4 of
3
+ * docs/MESH_REDUCTION.md, the common domain two triangulations of one
4
+ * attachment are compared on.
5
+ *
6
+ * `uvCarriers` was written for the motion comparison (`src/meshcompare.ts`,
7
+ * issue #1230), which still re-exports it from `rig-c/meshcompare` where it is
8
+ * promised. It lives here, in a module that links no poser, because the
9
+ * skinning residual (`src/meshskinning.ts`, issue #1294) reads the same carriers
10
+ * from `rig-c/mesh`, which is geometry-only (§0, P1): one definition of "the
11
+ * triangle that carries this UV" for both, so the residual and the motion row
12
+ * it is compared with can never disagree about which triangle a sample is in.
13
+ *
14
+ * Pure: no clock, no randomness, no runtime.
15
+ */
16
+ import { MeshReductionError } from './mesh.ts';
17
+ import { areaBand, triangleAreas } from './areaband.ts';
18
+
19
+ /** `pointInTriangle`'s epsilon in `src/mesh.ts`, applied to each barycentric coordinate. */
20
+ export const CONTAINS = 1e-9;
21
+
22
+ function refuse(code: string, message: string): never {
23
+ throw new MeshReductionError(code, message);
24
+ }
25
+
26
+ /** A sample's carrier: the UV triangle that contains it, its three vertex indices and the sample's barycentric coordinates there. */
27
+ export interface Carrier {
28
+ triangle: number;
29
+ corners: [number, number, number];
30
+ bary: [number, number, number];
31
+ }
32
+
33
+ /**
34
+ * Each sample's carrier in one mesh's UV triangulation — §3 and correction 4.
35
+ * Throws `COMPARE_UV_CARRIER_NOT_UNIQUE` for a sample two triangles carry
36
+ * other than across a vertex or edge they share.
37
+ */
38
+ export function uvCarriers(uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> {
39
+ const areas = triangleAreas(uvs, triangles);
40
+ const band = areaBand(areas, uvs);
41
+ const live: number[] = [];
42
+ const boxes: number[] = [];
43
+ for (let t = 0; t < areas.length; t++) {
44
+ if (Math.abs(areas[t]) <= band) continue;
45
+ live.push(t);
46
+ const xs = [uvs[triangles[t * 3] * 2], uvs[triangles[t * 3 + 1] * 2], uvs[triangles[t * 3 + 2] * 2]];
47
+ const ys = [uvs[triangles[t * 3] * 2 + 1], uvs[triangles[t * 3 + 1] * 2 + 1], uvs[triangles[t * 3 + 2] * 2 + 1]];
48
+ boxes.push(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
49
+ }
50
+ const u = (v: number): number => uvs[v * 2];
51
+ const w = (v: number): number => uvs[v * 2 + 1];
52
+ // Issue #1295: the samples bucketed on a uniform grid, so each live triangle is tested only against the samples in
53
+ // the cells its box (with the containment slack) overlaps — every (sample, triangle) pair the box test could pass is
54
+ // still tested, by the same `carrierIn`, and each sample's hits are gathered in ascending triangle index, the order
55
+ // a scan of every live triangle per sample gathered them in. The carriers are that scan's, value for value; what
56
+ // changes is that the work no longer grows with samples × triangles.
57
+ const n = samples.length;
58
+ const hits: Array<Carrier[] | null> = new Array(n).fill(null);
59
+ if (n > 0 && live.length > 0) {
60
+ let minX = Infinity;
61
+ let minY = Infinity;
62
+ let maxX = -Infinity;
63
+ let maxY = -Infinity;
64
+ for (const s of samples) {
65
+ minX = Math.min(minX, s.uv[0]);
66
+ minY = Math.min(minY, s.uv[1]);
67
+ maxX = Math.max(maxX, s.uv[0]);
68
+ maxY = Math.max(maxY, s.uv[1]);
69
+ }
70
+ const G = Math.max(1, Math.min(1024, Math.ceil(Math.sqrt(n / 4))));
71
+ const cw = (maxX - minX) / G || 1;
72
+ const ch = (maxY - minY) / G || 1;
73
+ const cellX = (x: number): number => Math.min(G - 1, Math.max(0, Math.floor((x - minX) / cw)));
74
+ const cellY = (y: number): number => Math.min(G - 1, Math.max(0, Math.floor((y - minY) / ch)));
75
+ const start = new Int32Array(G * G + 1);
76
+ const cellOf = new Int32Array(n);
77
+ samples.forEach((s, j) => {
78
+ cellOf[j] = cellY(s.uv[1]) * G + cellX(s.uv[0]);
79
+ start[cellOf[j] + 1]++;
80
+ });
81
+ for (let c = 0; c < G * G; c++) start[c + 1] += start[c];
82
+ const order = new Int32Array(n);
83
+ const fill = start.slice(0, G * G);
84
+ for (let j = 0; j < n; j++) order[fill[cellOf[j]]++] = j;
85
+ const slack = 1e-9;
86
+ live.forEach((t, k) => {
87
+ const [x0, y0, x1, y1] = [boxes[k * 4], boxes[k * 4 + 1], boxes[k * 4 + 2], boxes[k * 4 + 3]];
88
+ const cy0 = cellY(y0 - slack);
89
+ const cy1 = cellY(y1 + slack);
90
+ const cx0 = cellX(x0 - slack);
91
+ const cx1 = cellX(x1 + slack);
92
+ for (let cy = cy0; cy <= cy1; cy++) {
93
+ for (let cx = cx0; cx <= cx1; cx++) {
94
+ const c = cy * G + cx;
95
+ for (let q = start[c]; q < start[c + 1]; q++) {
96
+ const j = order[q];
97
+ const hit = carrierIn(samples[j].uv[0], samples[j].uv[1], x0, y0, x1, y1, triangles[t * 3], triangles[t * 3 + 1], triangles[t * 3 + 2], u, w, t);
98
+ if (hit !== null) (hits[j] ??= []).push(hit);
99
+ }
100
+ }
101
+ }
102
+ });
103
+ }
104
+ return samples.map((s, j) => resolveCarriers(s.uv[0], s.uv[1], hits[j] ?? [], who));
105
+ }
106
+
107
+ /**
108
+ * Whether the UV triangle i0, i1, i2 (`t`, its box x0..x1, y0..y1) carries the sample at (px, py), and where: the
109
+ * carrier, or null. The one containment test — `uvCarriers` and the reduction's carried residual (issue #1295) both
110
+ * read it, so a sample is in a triangle by one arithmetic.
111
+ */
112
+ export function carrierIn(
113
+ px: number,
114
+ py: number,
115
+ x0: number,
116
+ y0: number,
117
+ x1: number,
118
+ y1: number,
119
+ i0: number,
120
+ i1: number,
121
+ i2: number,
122
+ u: (v: number) => number,
123
+ v: (v: number) => number,
124
+ t: number,
125
+ ): Carrier | null {
126
+ const slack = 1e-9;
127
+ if (px < x0 - slack || py < y0 - slack || px > x1 + slack || py > y1 + slack) return null;
128
+ const ax = u(i0);
129
+ const ay = v(i0);
130
+ const bx = u(i1) - ax;
131
+ const by = v(i1) - ay;
132
+ const cx = u(i2) - ax;
133
+ const cy = v(i2) - ay;
134
+ const qx = px - ax;
135
+ const qy = py - ay;
136
+ const det = bx * cy - cx * by;
137
+ const l1 = (qx * cy - cx * qy) / det;
138
+ const l2 = (bx * qy - qx * by) / det;
139
+ const l0 = 1 - l1 - l2;
140
+ if (l0 < -CONTAINS || l1 < -CONTAINS || l2 < -CONTAINS) return null;
141
+ return { triangle: t, corners: [i0, i1, i2], bary: [l0, l1, l2] };
142
+ }
143
+
144
+ /**
145
+ * The carrier of a sample from every triangle that contains it: none, the one, or — several — the first when they all
146
+ * meet there at a shared vertex or edge; else `COMPARE_UV_CARRIER_NOT_UNIQUE` (correction 4).
147
+ */
148
+ export function resolveCarriers(px: number, py: number, hits: readonly Carrier[], who: string): Carrier | null {
149
+ if (hits.length === 0) return null;
150
+ if (hits.length === 1) return hits[0];
151
+ // Several carriers are one hit only across a vertex or an edge they all share: the sample's support — the corners
152
+ // it does not sit at zero weight on — is then the same vertex indices in each, and the carried point is theirs.
153
+ const support = (c: Carrier): string =>
154
+ c.corners
155
+ .filter((_v, k) => c.bary[k] > CONTAINS)
156
+ .sort((a, b) => a - b)
157
+ .join(',');
158
+ const first = support(hits[0]);
159
+ const shared = first.split(',').length <= 2 && hits.every((c) => support(c) === first);
160
+ if (!shared) {
161
+ refuse(
162
+ 'COMPARE_UV_CARRIER_NOT_UNIQUE',
163
+ `${who}: the sample at uv (${px}, ${py}) lies in ${hits.length} UV triangles — ${hits.map((c) => `triangle ${c.triangle} (vertices ${c.corners.join(', ')})`).join(' and ')} — that do not meet there at a shared vertex or edge; required one carrier per sample (correction 4: overlapping or folded UV triangles would carry it by an arbitrary one)`,
164
+ );
165
+ }
166
+ return hits[0];
167
+ }
@@ -100,6 +100,7 @@ import {
100
100
  type WorstSample,
101
101
  } from './meshquality.ts';
102
102
  import { areaBand, stretchSingularValues, triangleAreas } from './areaband.ts';
103
+ import { uvCarriers, type Carrier } from './meshcarriers.ts';
103
104
  import { CoreInputError, readModel, underNoSkin, underSkin, type CompiledDocument } from './core/index.ts';
104
105
  import { poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts';
105
106
  import { sampleTime } from './core/animation.ts';
@@ -178,9 +179,6 @@ export type ComparePlant = 'amplitude-not-carried' | 'null-read-as-left-out' | '
178
179
  // fixed tolerances — the tree's own
179
180
  // ---------------------------------------------------------------------------
180
181
 
181
- /** `pointInTriangle`'s epsilon in `src/mesh.ts`, applied to each barycentric coordinate. */
182
- const CONTAINS = 1e-9;
183
-
184
182
  /** §3 *Independent evidence* (a): the rows a reference is refused on — its art fit against its own mask, and only that. */
185
183
  const REFERENCE_ART_FIT: readonly string[] = ['MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT'];
186
184
 
@@ -585,6 +583,9 @@ function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames
585
583
  // §3 — the samples and their carriers
586
584
  // ---------------------------------------------------------------------------
587
585
 
586
+ /** Promised on `rig-c/meshcompare` since issue #1230; defined in `src/meshcarriers.ts` since issue #1294, which the skinning residual shares. */
587
+ export { uvCarriers };
588
+
588
589
  interface Sample {
589
590
  uv: [number, number];
590
591
  /** Part-local drawing px, y down — where a region's polygon is read. */
@@ -594,12 +595,6 @@ interface Sample {
594
595
  vertex: number | null;
595
596
  }
596
597
 
597
- interface Carrier {
598
- triangle: number;
599
- corners: [number, number, number];
600
- bary: [number, number, number];
601
- }
602
-
603
598
  interface MeshOf {
604
599
  uvs: number[];
605
600
  triangles: number[];
@@ -607,68 +602,6 @@ interface MeshOf {
607
602
  weights: SourceMesh['weights'];
608
603
  }
609
604
 
610
- /**
611
- * Each sample's carrier in one mesh's UV triangulation — §3 and correction 4.
612
- * Throws `COMPARE_UV_CARRIER_NOT_UNIQUE` for a sample two triangles carry
613
- * other than across a vertex or edge they share.
614
- */
615
- export function uvCarriers(uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> {
616
- const areas = triangleAreas(uvs, triangles);
617
- const band = areaBand(areas, uvs);
618
- const live: number[] = [];
619
- const boxes: number[] = [];
620
- for (let t = 0; t < areas.length; t++) {
621
- if (Math.abs(areas[t]) <= band) continue;
622
- live.push(t);
623
- const xs = [uvs[triangles[t * 3] * 2], uvs[triangles[t * 3 + 1] * 2], uvs[triangles[t * 3 + 2] * 2]];
624
- const ys = [uvs[triangles[t * 3] * 2 + 1], uvs[triangles[t * 3 + 1] * 2 + 1], uvs[triangles[t * 3 + 2] * 2 + 1]];
625
- boxes.push(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
626
- }
627
- return samples.map((s) => {
628
- const [px, py] = s.uv;
629
- const hits: Carrier[] = [];
630
- live.forEach((t, k) => {
631
- const slack = 1e-9;
632
- if (px < boxes[k * 4] - slack || py < boxes[k * 4 + 1] - slack || px > boxes[k * 4 + 2] + slack || py > boxes[k * 4 + 3] + slack) return;
633
- const i0 = triangles[t * 3];
634
- const i1 = triangles[t * 3 + 1];
635
- const i2 = triangles[t * 3 + 2];
636
- const ax = uvs[i0 * 2];
637
- const ay = uvs[i0 * 2 + 1];
638
- const bx = uvs[i1 * 2] - ax;
639
- const by = uvs[i1 * 2 + 1] - ay;
640
- const cx = uvs[i2 * 2] - ax;
641
- const cy = uvs[i2 * 2 + 1] - ay;
642
- const qx = px - ax;
643
- const qy = py - ay;
644
- const det = bx * cy - cx * by;
645
- const l1 = (qx * cy - cx * qy) / det;
646
- const l2 = (bx * qy - qx * by) / det;
647
- const l0 = 1 - l1 - l2;
648
- if (l0 < -CONTAINS || l1 < -CONTAINS || l2 < -CONTAINS) return;
649
- hits.push({ triangle: t, corners: [i0, i1, i2], bary: [l0, l1, l2] });
650
- });
651
- if (hits.length === 0) return null;
652
- if (hits.length === 1) return hits[0];
653
- // Several carriers are one hit only across a vertex or an edge they all share: the sample's support — the corners
654
- // it does not sit at zero weight on — is then the same vertex indices in each, and the carried point is theirs.
655
- const support = (c: Carrier): string =>
656
- c.corners
657
- .filter((_v, k) => c.bary[k] > CONTAINS)
658
- .sort((a, b) => a - b)
659
- .join(',');
660
- const first = support(hits[0]);
661
- const shared = first.split(',').length <= 2 && hits.every((c) => support(c) === first);
662
- if (!shared) {
663
- refuse(
664
- 'COMPARE_UV_CARRIER_NOT_UNIQUE',
665
- `${who}: the sample at uv (${px}, ${py}) lies in ${hits.length} UV triangles — ${hits.map((c) => `triangle ${c.triangle} (vertices ${c.corners.join(', ')})`).join(' and ')} — that do not meet there at a shared vertex or edge; required one carrier per sample (correction 4: overlapping or folded UV triangles would carry it by an arbitrary one)`,
666
- );
667
- }
668
- return hits[0];
669
- });
670
- }
671
-
672
605
  /** The point a carrier puts the sample at over one pose's world vertices. */
673
606
  function carried(c: Carrier, world: readonly number[]): [number, number] {
674
607
  const [i0, i1, i2] = c.corners;
@@ -71,6 +71,7 @@ import {
71
71
  } from './mesh.ts';
72
72
  import { areaBand, triangleAreas } from './areaband.ts';
73
73
  import { allocationContrast, boundaryNecessityOnce, deformLoad, gradeMax, minAngleP10, type AllocationArt, type AllocationPlant } from './meshallocation.ts';
74
+ import { skinningEchoOf, skinningResidual, validateSkinning, type ReductionSkinning, type SkinningDetail, type SkinningEcho, type SkinningPlant, type SkinningResidualInput } from './meshskinning.ts';
74
75
  import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
75
76
  import { cropToSpineY } from './transform.ts';
76
77
 
@@ -146,6 +147,16 @@ export interface ReductionTargets {
146
147
  minAngle?: number;
147
148
  /** Local density requirements — §5. */
148
149
  regions: RefinementRegion[];
150
+ /**
151
+ * Issue #1295 (§7 mechanism 1, the reducer half) — opt-in. Left out = no step reads a residual and the call is the
152
+ * one it was before the field existed, byte for byte. Set, every removal, boundary run and triangulation post-pass is
153
+ * taken only when `MQ_SKINNING_RESIDUAL` of its result against the call's own `source` — the original, never a
154
+ * previous step — is within `maxResidual` and every other required row passes; refinement insertions are not vetoed
155
+ * one by one, and the refined mesh is held to it before any removal. The result's measurement carries the row.
156
+ * `null` declares it absent (no row read by a step, the result's row `not-measurable` saying so); anything else that
157
+ * is not `{ envelope, maxResidual }` in full is refused `REDUCE_INPUT_MISSING` naming its path, before any work.
158
+ */
159
+ skinning?: ReductionSkinning | null;
149
160
  }
150
161
 
151
162
  /** P16/P17: a local density requirement over a closed polygon. */
@@ -369,6 +380,16 @@ export interface MeshMeasureInput {
369
380
  * `null` art bound is declared absent, #1257). It declares no bound: every allocation row stays `undeclared`.
370
381
  */
371
382
  motionAmplitude?: MotionAmplitude | null;
383
+ /**
384
+ * Issue #1294 (§7, mechanism 1): the skinning-envelope residual of this mesh — the candidate — against the original
385
+ * source it was made from, under a declared envelope (`SkinningResidualInput`, `src/meshskinning.ts`). Left out, it
386
+ * is not asked for: no `MQ_SKINNING_RESIDUAL` row and no echo, so the report is the one written without the field,
387
+ * byte for byte. `null` declares it absent: the row is `not-measurable` saying so, and the echo is `null`. Set, the
388
+ * row is measured over §3's samples at `art.threshold` and is required when `maxResidual` is a number; a source or an
389
+ * envelope the measurement cannot read refuses the row by name. Anything that is not a `SkinningResidualInput` in
390
+ * full is refused `REDUCE_INPUT_MISSING` naming its path, before any work.
391
+ */
392
+ skinning?: SkinningResidualInput | null;
372
393
  }
373
394
 
374
395
  /**
@@ -442,6 +463,8 @@ export interface MeasureRow {
442
463
  motion?: MotionRowDetail;
443
464
  /** The five allocation rows only (issue #1280): what the value is a reading of, and what it was read from. */
444
465
  allocation?: AllocationDetail;
466
+ /** `MQ_SKINNING_RESIDUAL` when measured (issue #1294): the source, the samples, the worst sample's terms and the contract's own figures. */
467
+ skinning?: SkinningDetail;
445
468
  }
446
469
 
447
470
  /** Issue #1280: an allocation row's own fields. Written on those five rows, measured or not, and on no other. */
@@ -610,6 +633,8 @@ export interface EffectiveSettings {
610
633
  retriangulate?: 'delaunay';
611
634
  /** A reduction's `removalOrder`, echoed when the input set it (issue #1283); absent otherwise — the order was ascending source index. */
612
635
  removalOrder?: 'deformation-load';
636
+ /** A measurement's `skinning`, echoed when the input set it — `null` included (issue #1294): the source by id and digest. Absent otherwise. */
637
+ skinning?: SkinningEcho | null;
613
638
  }
614
639
 
615
640
  export interface MeshCounts {
@@ -863,6 +888,7 @@ function validateInput(input: MeshMeasureInput): void {
863
888
  refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
864
889
  }
865
890
  validateMotionAmplitude(who, input.motionAmplitude);
891
+ validateSkinning(who, input.skinning);
866
892
  }
867
893
 
868
894
  /**
@@ -1503,6 +1529,15 @@ export function measureMeshQualityPlanted(input: MeshMeasureInput, plant: Alloca
1503
1529
  return measureValidated(input, artRastersOf(input.art), null, plant);
1504
1530
  }
1505
1531
 
1532
+ /**
1533
+ * `measureMeshQuality` with a fault planted in the skinning residual or its report (issue #1294, `SkinningPlant` in
1534
+ * `src/meshskinning.ts`) — the `mesh-compare` suite's negative controls. Internal, as `measureMeshQualityWith` is.
1535
+ */
1536
+ export function measureMeshQualitySkinningPlanted(input: MeshMeasureInput, plant: SkinningPlant): MeshQualityReport {
1537
+ validateInput(input);
1538
+ return measureValidated(input, artRastersOf(input.art), null, null, plant);
1539
+ }
1540
+
1506
1541
  /**
1507
1542
  * `measureMeshQuality` over art rasters the caller already holds
1508
1543
  * (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
@@ -1612,12 +1647,12 @@ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
1612
1647
  }
1613
1648
 
1614
1649
  /** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
1615
- function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null, plant: AllocationPlant | null): MeshQualityReport {
1650
+ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null, plant: AllocationPlant | null, skinningPlant: SkinningPlant | null = null): MeshQualityReport {
1616
1651
  rasters.tally.uses++;
1617
1652
  const { attachment, art, source, targets } = input;
1618
1653
  const { mask, threshold, frame } = art;
1619
1654
  const scale = frame.pageScale;
1620
- const effective = effectiveOf(input);
1655
+ const effective = effectiveOf(input, skinningPlant);
1621
1656
  const report = (sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination | null): MeshQualityReport => ({
1622
1657
  spec: MESH_QUALITY_REPORT_SPEC,
1623
1658
  operation: 'measure',
@@ -1654,6 +1689,9 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1654
1689
  // --- allocation rows (issue #1280): undeclared or not-measurable, never required ------------
1655
1690
  if (plant !== 'omit-rows') rows.push(...allocationRows(input, outline, rasters, steps, plant));
1656
1691
 
1692
+ // --- the skinning residual (issue #1294): only when the input asked for it ------------------
1693
+ if (input.skinning !== undefined || skinningPlant === 'row-when-unset') rows.push(skinningRow(input, rasters, skinningPlant));
1694
+
1657
1695
  // --- raster rows ---------------------------------------------------------
1658
1696
  const w = mask.width;
1659
1697
  const h = mask.height;
@@ -1805,6 +1843,53 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1805
1843
  return report(counts, { id: input.id, counts, geometry, motion: null, accepted }, null);
1806
1844
  }
1807
1845
 
1846
+ /**
1847
+ * `MQ_SKINNING_RESIDUAL` (issue #1294, `src/meshskinning.ts`): a geometry row in drawing px, required when
1848
+ * `skinning.maxResidual` is a number. `null` for the field reads `not-measurable` saying it was declared absent;
1849
+ * a refusal of the source or the envelope is a `refused` row whose reason opens with its code. The samples are §3's:
1850
+ * art pixel centres at `art.threshold` and the source hull's UVs.
1851
+ */
1852
+ function skinningRow(input: MeshMeasureInput, rasters: ArtRasters, plant: SkinningPlant | null): Built {
1853
+ const { attachment, art } = input;
1854
+ const spec: RowSpec = { code: 'MQ_SKINNING_RESIDUAL', unit: 'px', region: null, attachment };
1855
+ const skinning = input.skinning;
1856
+ if (skinning === undefined || skinning === null) {
1857
+ const why =
1858
+ skinning === null
1859
+ ? `attachment ${nameOf(attachment)}: skinning is null — the residual is declared absent, so there is no source or envelope to measure against`
1860
+ : `attachment ${nameOf(attachment)}: skinning was not set`;
1861
+ return unmeasuredRow(spec, 'not-measurable', why, false);
1862
+ }
1863
+ const bound = skinning.maxResidual === null ? null : { op: '<=' as const, value: skinning.maxResidual };
1864
+ const reading = skinningResidual(
1865
+ {
1866
+ attachment,
1867
+ frame: art.frame,
1868
+ artBits: rasters.artBits(),
1869
+ maskWidth: art.mask.width,
1870
+ maskHeight: art.mask.height,
1871
+ minArtSamples: input.minArtSamples,
1872
+ threshold: art.threshold,
1873
+ candidate: input.source,
1874
+ skinning,
1875
+ },
1876
+ plant,
1877
+ );
1878
+ const sampling = { domain: `art pixel centres at alpha >= ${art.threshold} and the source's hull UVs, on the region UV square, carried by the source's and this mesh's UV triangles`, count: reading.samples };
1879
+ const artField = { threshold: art.threshold, connectivity: null, samples: reading.art };
1880
+ if (reading.state !== 'measured') {
1881
+ const built = unmeasuredRow(spec, reading.state, reading.reason, bound !== null);
1882
+ built.row.art = artField;
1883
+ built.row.sampling = sampling;
1884
+ return built;
1885
+ }
1886
+ const built = measuredRow(spec, reading.value, bound, reading.worst, true);
1887
+ built.row.art = artField;
1888
+ built.row.sampling = sampling;
1889
+ built.row.skinning = reading.detail;
1890
+ return built;
1891
+ }
1892
+
1808
1893
  /** What each allocation row is a reading of — `AllocationDetail.reading`, one fixed sentence per code. */
1809
1894
  const ALLOCATION_READINGS: Record<string, string> = {
1810
1895
  MQ_GRADE:
@@ -2223,7 +2308,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
2223
2308
  // the echo, and the document's text
2224
2309
  // ---------------------------------------------------------------------------
2225
2310
 
2226
- function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
2311
+ function effectiveOf(input: MeshMeasureInput, skinningPlant: SkinningPlant | null = null): EffectiveSettings {
2227
2312
  const { art } = input;
2228
2313
  return {
2229
2314
  preset: input.preset,
@@ -2251,6 +2336,7 @@ function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
2251
2336
  schedule: null,
2252
2337
  budget: null,
2253
2338
  ...(input.motionAmplitude === undefined ? {} : { motionAmplitude: input.motionAmplitude }),
2339
+ ...(input.skinning === undefined ? (skinningPlant === 'echo-when-unset' ? { skinning: null } : {}) : { skinning: skinningEchoOf(input.skinning) }),
2254
2340
  };
2255
2341
  }
2256
2342
 
@@ -2283,6 +2369,11 @@ function targetsJson(t: ReductionTargets | MeasureTargets | null): Json {
2283
2369
  const out: { [key: string]: Json } = { artFit: fitJson(t.artFit), maxBoundaryDeviation: t.maxBoundaryDeviation };
2284
2370
  if (t.minAngle !== undefined) out.minAngle = t.minAngle;
2285
2371
  out.regions = t.regions.map(regionJson);
2372
+ // Issue #1295: a reduction's `targets.skinning`, echoed when the input set it — `null` included — and never otherwise.
2373
+ if ('skinning' in t && t.skinning !== undefined) {
2374
+ const k = t.skinning;
2375
+ out.skinning = k === null ? null : { envelope: { reference: k.envelope.reference, bones: k.envelope.bones.map((b) => ({ bone: b.bone, linear: b.linear, pivot: pair(b.pivot), translation: b.translation })) }, maxResidual: k.maxResidual };
2376
+ }
2286
2377
  return out;
2287
2378
  }
2288
2379
 
@@ -2344,6 +2435,7 @@ function effectiveJson(e: EffectiveSettings): Json {
2344
2435
  ...(e.motionAmplitude === undefined ? {} : { motionAmplitude: amplitudeJson(e.motionAmplitude) }),
2345
2436
  ...(e.retriangulate === undefined ? {} : { retriangulate: e.retriangulate }),
2346
2437
  ...(e.removalOrder === undefined ? {} : { removalOrder: e.removalOrder }),
2438
+ ...(e.skinning === undefined ? {} : { skinning: skinningEchoJson(e.skinning) }),
2347
2439
  };
2348
2440
  }
2349
2441
 
@@ -2388,9 +2480,40 @@ function rowJson(r: MeasureRow): Json {
2388
2480
  if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
2389
2481
  if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
2390
2482
  if (r.allocation !== undefined) out.allocation = allocationDetailJson(r.allocation);
2483
+ if (r.skinning !== undefined) out.skinning = skinningDetailJson(r.skinning);
2391
2484
  return out;
2392
2485
  }
2393
2486
 
2487
+ function deformJson(t: DeformTimelineInput): Json {
2488
+ return {
2489
+ animation: t.animation,
2490
+ attachment: attachmentJson(t.attachment),
2491
+ keys: t.keys.map((k): Json => (k.kind === 'vertices' ? { time: k.time, kind: k.kind, offset: k.offset, vertices: [...k.vertices] } : { time: k.time, kind: k.kind })),
2492
+ };
2493
+ }
2494
+
2495
+ function skinningEchoJson(e: SkinningEcho | null): Json {
2496
+ if (e === null) return null;
2497
+ return {
2498
+ source: { id: e.source.id, digest: e.source.digest },
2499
+ envelope: { reference: e.envelope.reference, bones: e.envelope.bones.map((b) => ({ bone: b.bone, linear: b.linear, pivot: pair(b.pivot), translation: b.translation })) },
2500
+ maxResidual: e.maxResidual,
2501
+ deform: e.deform.map(deformJson),
2502
+ };
2503
+ }
2504
+
2505
+ function skinningDetailJson(d: SkinningDetail): Json {
2506
+ return {
2507
+ reading: d.reading,
2508
+ source: { id: d.source.id, digest: d.source.digest },
2509
+ reference: d.reference,
2510
+ samples: { measured: d.samples.measured, uncarried: { source: d.samples.uncarried.source.map(pair), candidate: d.samples.uncarried.candidate.map(pair) } },
2511
+ worst: d.worst === null ? null : { covariance: d.worst.covariance, lever: d.worst.lever, bones: d.worst.bones.map((b) => ({ bone: b.bone, covariance: b.covariance, lever: b.lever })) },
2512
+ weightField: { l1: d.weightField.l1, lInf: d.weightField.lInf },
2513
+ setup: { vertexGap: d.setup.vertexGap, vertexSlack: pair(d.setup.vertexSlack), sampleGap: d.setup.sampleGap, weightSumGap: d.setup.weightSumGap },
2514
+ };
2515
+ }
2516
+
2394
2517
  function allocationDetailJson(a: AllocationDetail): Json {
2395
2518
  const out: { [key: string]: Json } = { reading: a.reading };
2396
2519
  if (a.contrast !== undefined) {