rig-c 2.29.0 → 2.30.0

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