spine-rigc 2.19.1 → 2.20.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": "spine-rigc",
3
- "version": "2.19.1",
3
+ "version": "2.20.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": {
@@ -16,6 +16,7 @@
16
16
  "./rig": "./src/rig.ts",
17
17
  "./mesh": "./src/mesh.ts",
18
18
  "./errors": "./src/errors.ts",
19
+ "./meshcompare": "./src/meshcompare.ts",
19
20
  "./cli": "./cli.ts",
20
21
  "./package.json": "./package.json",
21
22
  "./*.ts": "./*.ts",
package/src/areaband.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * degeneracy by (`src/meshquality.ts`).
5
5
  *
6
6
  * Moved here unchanged from `src/deformsurvey.ts`, which imports these back
7
- * and re-exports every name it exported before, so no caller changed an import.
7
+ * and re-exports every name it exported before, so no caller changed an import
8
+ * — and `stretchSingularValues` after them (issue #1230), for the motion
9
+ * comparison (`src/meshcompare.ts`), which reads a triangle's stretch the way
10
+ * the deform survey does and must not reach the compiler either.
8
11
  * They moved because a module the `spine-rigc/mesh` entry reaches has to read
9
12
  * the same band, and `src/deformsurvey.ts` reaches the compiler and the core
10
13
  * (`src/deformstructure.ts`, `src/core/`) — a geometry entry that loaded the
@@ -94,3 +97,63 @@ export function areaBand(plainAreas: readonly number[], ...worlds: ReadonlyArray
94
97
  for (const world of worlds) band = Math.max(band, float32AreaNoise(world));
95
98
  return band;
96
99
  }
100
+
101
+ /**
102
+ * The two singular values of the linear map that takes one triangle onto the
103
+ * other — the largest and smallest factor by which it scales a direction.
104
+ *
105
+ * ## Why this is the texture's stretch
106
+ *
107
+ * A mesh's uvs are fixed to the attachment and a deform never touches them, so
108
+ * the texture is mapped affinely onto the *plain* triangle and the same texels
109
+ * end up on the *deformed* one. The change in that mapping is exactly `J = D·P⁻¹`
110
+ * with `P` and `D` the two triangles' edge pairs, and its singular values are the
111
+ * worst stretch and the worst squash the drawing takes there. A σ of 1.4 means
112
+ * every texel in that direction is drawn 1.4 px wide; 0.6 means the art is
113
+ * crushed to 60%.
114
+ *
115
+ * `σ₁·σ₂ = |det J|` is the signed-area ratio's magnitude, which is why the two
116
+ * quantities in the report cannot disagree — and `DR01` is the control that says
117
+ * so on a case whose ratio the closed form predicts.
118
+ *
119
+ * Returns `null` for a plain triangle with no area: `P` is singular, there is no
120
+ * map, and inventing one would be the report's own version of the false green
121
+ * this file exists to avoid. Those triangles are counted as `degenerate`.
122
+ */
123
+ export function stretchSingularValues(
124
+ plain: ArrayLike<number>,
125
+ deformed: ArrayLike<number>,
126
+ triangles: ArrayLike<number>,
127
+ t: number,
128
+ ): { max: number; min: number } | null {
129
+ const i0 = triangles[t * 3] * 2;
130
+ const i1 = triangles[t * 3 + 1] * 2;
131
+ const i2 = triangles[t * 3 + 2] * 2;
132
+ const ux = plain[i1] - plain[i0];
133
+ const uy = plain[i1 + 1] - plain[i0 + 1];
134
+ const vx = plain[i2] - plain[i0];
135
+ const vy = plain[i2 + 1] - plain[i0 + 1];
136
+ const det = ux * vy - vx * uy;
137
+ if (det === 0) return null;
138
+ const px = deformed[i1] - deformed[i0];
139
+ const py = deformed[i1 + 1] - deformed[i0 + 1];
140
+ const qx = deformed[i2] - deformed[i0];
141
+ const qy = deformed[i2 + 1] - deformed[i0 + 1];
142
+ // J = D·P⁻¹, written out — P⁻¹ = (1/det)·[[vy, −vx], [−uy, ux]].
143
+ const a = (px * vy - qx * uy) / det;
144
+ const b = (-px * vx + qx * ux) / det;
145
+ const c = (py * vy - qy * uy) / det;
146
+ const d = (-py * vx + qy * ux) / det;
147
+ // σ₁² + σ₂² = ‖J‖²_F and σ₁·σ₂ = |det J|, which is two equations for the two
148
+ // values and needs no eigen decomposition. The discriminant is non-negative in
149
+ // exact arithmetic (it is `(σ₁² − σ₂²)²`); the clamp is for rounding on a map
150
+ // that is very nearly a rotation.
151
+ const frobenius = a * a + b * b + c * c + d * d;
152
+ const determinant = a * d - b * c;
153
+ const discriminant = Math.max(0, frobenius * frobenius - 4 * determinant * determinant);
154
+ const root = Math.sqrt(discriminant);
155
+ return {
156
+ max: Math.sqrt(Math.max(0, (frobenius + root) / 2)),
157
+ min: Math.sqrt(Math.max(0, (frobenius - root) / 2)),
158
+ };
159
+ }
package/src/cli/shared.ts CHANGED
@@ -39,6 +39,7 @@ import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
39
39
  import { parseJsonWithPosition } from '../json-position.ts';
40
40
  import { RUNG_IDS } from '../ladder.ts';
41
41
  import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
42
+ import { PACKAGE_ROOT, readPackageMeta, readVersion } from '../package_meta.ts';
42
43
  import { DEFAULT_MAX_RESIDUAL, DEFAULT_SCALE_MAX, DEFAULT_SCALE_MIN } from '../pose.ts';
43
44
  import {
44
45
  CandidateAtlasError,
@@ -98,37 +99,9 @@ export class ExplainError extends Error {}
98
99
  // and for naming a remedy `bench` can only give from a repo checkout.
99
100
  // ---------------------------------------------------------------------------
100
101
 
101
- interface PackageMeta {
102
- version?: string;
103
- repository?: string | { url?: string };
104
- }
105
-
106
- let packageMeta: PackageMeta | null | undefined;
107
-
108
- /**
109
- * The directory `cli.ts` sits in: `package.json`, `skills/` and, in a
110
- * checkout, `examples/` and `scripts/` are beside it, in the repository and
111
- * once installed. Two levels above this file (`src/cli/`), which is where the
112
- * commands that read those live since issue #1052 — every path they built off
113
- * `cli.ts`'s own directory is built off this one, and comes out the same.
114
- */
115
- export const PACKAGE_ROOT = join(import.meta.dir, '..', '..');
116
-
117
- /** `package.json` sits next to `cli.ts` both in the repo and once installed (`PACKAGE_ROOT`). */
118
- export function readPackageMeta(): PackageMeta | null {
119
- if (packageMeta === undefined) {
120
- try {
121
- packageMeta = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as PackageMeta;
122
- } catch {
123
- packageMeta = null;
124
- }
125
- }
126
- return packageMeta;
127
- }
128
-
129
- export function readVersion(): string {
130
- return readPackageMeta()?.version ?? 'unknown';
131
- }
102
+ // The reader moved to `../package_meta.ts` (issue #1230), so a library module can read
103
+ // the version without loading the CLI; every name it had here is re-exported unchanged.
104
+ export { PACKAGE_ROOT, readPackageMeta, readVersion };
132
105
 
133
106
  // ---------------------------------------------------------------------------
134
107
  // argument parsing
@@ -25,72 +25,14 @@
25
25
  import { CoreInputError, underSkin, type CompiledDocument } from './core/index.ts';
26
26
  import { float32Rows, meshWorld, poseDial as corePoseDial, poseJump, recordIdentity, sliderRecordOf, slotDraw, type CoreSurveyPose } from './core/hooks.ts';
27
27
  import { modelStructure, type SurveyAnimation, type SurveyDeformTimeline, type SurveyMesh, type SurveySkin, type SurveySlider, type SurveyStructure } from './deformstructure.ts';
28
- import { areaBand, DEFORM_AREA_EPSILON, float32AreaNoise, triangleAreas } from './areaband.ts';
28
+ import { areaBand, DEFORM_AREA_EPSILON, float32AreaNoise, stretchSingularValues, triangleAreas } from './areaband.ts';
29
29
 
30
30
  // The area band lives in `src/areaband.ts` (moved unchanged, issue #1224) so the
31
- // geometry entry can read it without reaching the compiler; every name this
32
- // module exported before is exported from here still.
33
- export { DEFORM_AREA_EPSILON, float32AreaNoise, triangleAreas };
34
-
35
- /**
36
- * The two singular values of the linear map that takes one triangle onto the
37
- * other — the largest and smallest factor by which it scales a direction.
38
- *
39
- * ## Why this is the texture's stretch
40
- *
41
- * A mesh's uvs are fixed to the attachment and a deform never touches them, so
42
- * the texture is mapped affinely onto the *plain* triangle and the same texels
43
- * end up on the *deformed* one. The change in that mapping is exactly `J = D·P⁻¹`
44
- * with `P` and `D` the two triangles' edge pairs, and its singular values are the
45
- * worst stretch and the worst squash the drawing takes there. A σ of 1.4 means
46
- * every texel in that direction is drawn 1.4 px wide; 0.6 means the art is
47
- * crushed to 60%.
48
- *
49
- * `σ₁·σ₂ = |det J|` is the signed-area ratio's magnitude, which is why the two
50
- * quantities in the report cannot disagree — and `DR01` is the control that says
51
- * so on a case whose ratio the closed form predicts.
52
- *
53
- * Returns `null` for a plain triangle with no area: `P` is singular, there is no
54
- * map, and inventing one would be the report's own version of the false green
55
- * this file exists to avoid. Those triangles are counted as `degenerate`.
56
- */
57
- export function stretchSingularValues(
58
- plain: ArrayLike<number>,
59
- deformed: ArrayLike<number>,
60
- triangles: ArrayLike<number>,
61
- t: number,
62
- ): { max: number; min: number } | null {
63
- const i0 = triangles[t * 3] * 2;
64
- const i1 = triangles[t * 3 + 1] * 2;
65
- const i2 = triangles[t * 3 + 2] * 2;
66
- const ux = plain[i1] - plain[i0];
67
- const uy = plain[i1 + 1] - plain[i0 + 1];
68
- const vx = plain[i2] - plain[i0];
69
- const vy = plain[i2 + 1] - plain[i0 + 1];
70
- const det = ux * vy - vx * uy;
71
- if (det === 0) return null;
72
- const px = deformed[i1] - deformed[i0];
73
- const py = deformed[i1 + 1] - deformed[i0 + 1];
74
- const qx = deformed[i2] - deformed[i0];
75
- const qy = deformed[i2 + 1] - deformed[i0 + 1];
76
- // J = D·P⁻¹, written out — P⁻¹ = (1/det)·[[vy, −vx], [−uy, ux]].
77
- const a = (px * vy - qx * uy) / det;
78
- const b = (-px * vx + qx * ux) / det;
79
- const c = (py * vy - qy * uy) / det;
80
- const d = (-py * vx + qy * ux) / det;
81
- // σ₁² + σ₂² = ‖J‖²_F and σ₁·σ₂ = |det J|, which is two equations for the two
82
- // values and needs no eigen decomposition. The discriminant is non-negative in
83
- // exact arithmetic (it is `(σ₁² − σ₂²)²`); the clamp is for rounding on a map
84
- // that is very nearly a rotation.
85
- const frobenius = a * a + b * b + c * c + d * d;
86
- const determinant = a * d - b * c;
87
- const discriminant = Math.max(0, frobenius * frobenius - 4 * determinant * determinant);
88
- const root = Math.sqrt(discriminant);
89
- return {
90
- max: Math.sqrt(Math.max(0, (frobenius + root) / 2)),
91
- min: Math.sqrt(Math.max(0, (frobenius - root) / 2)),
92
- };
93
- }
31
+ // geometry entry can read it without reaching the compiler, and
32
+ // `stretchSingularValues` joined it (moved unchanged, issue #1230) so the motion
33
+ // comparison can too; every name this module exported before is exported from
34
+ // here still.
35
+ export { DEFORM_AREA_EPSILON, float32AreaNoise, stretchSingularValues, triangleAreas };
94
36
 
95
37
  /** A quantity's worst triangle on one key, and which triangle it was. */
96
38
  export interface DeformExtreme {
package/src/mesh.ts CHANGED
@@ -118,7 +118,11 @@ export interface MeshGeometry {
118
118
  points: Array<[number, number]>;
119
119
  /** Normalised region UVs, v measured from the top edge. */
120
120
  uvs: number[];
121
- /** Triangle indices, counter-clockwise in Spine world (y up). */
121
+ /**
122
+ * Triangle indices, counter-clockwise in Spine world (y up) — every builder
123
+ * here, measured on spine-core's posed world by `CT15` in `selftest.ts`
124
+ * (issue #1236: `contour` and `ring` wound the other way until then).
125
+ */
122
126
  triangles: number[];
123
127
  /** Per-vertex weights, parallel to `points`. */
124
128
  weights: MeshVertexWeight[][];
@@ -221,6 +225,33 @@ export function signedArea(poly: Array<[number, number]>): number {
221
225
  return a / 2;
222
226
  }
223
227
 
228
+ /**
229
+ * Turn a triangle list built ALONG `poly` — every triple carrying the
230
+ * polygon's own winding, as an ear-clip of it or a ring strip around it does —
231
+ * counter-clockwise in Spine world, the winding `MeshGeometry.triangles`
232
+ * promises. `poly` is in part-local pixels, y down.
233
+ *
234
+ * Counter-clockwise in Spine world (y up) is a NEGATIVE shoelace area in y-down
235
+ * pixels: the flip changes the sign of the area and not the direction the loop
236
+ * turns as drawn. So a polygon whose y-down area is positive — clockwise on
237
+ * screen, and therefore clockwise in Spine world — has each triangle's last two
238
+ * corners swapped, the way `buildSegmentsLattice` does per triangle; one with a
239
+ * negative area is already right and is returned as it came. The triangle SET
240
+ * is unchanged either way: only the order of two indices inside a triple moves.
241
+ *
242
+ * The decision is the polygon's, not each triangle's, so a near-collinear ear
243
+ * whose own sign is rounding noise is turned with its neighbours rather than
244
+ * read on its own (issue #1236 — `contour` and `ring` emitted every triangle
245
+ * clockwise in Spine world before this existed, under comments that derived the
246
+ * opposite from the y flip).
247
+ */
248
+ export function windCounterClockwiseInSpineWorld(poly: Array<[number, number]>, triangles: readonly number[]): number[] {
249
+ const out = triangles.slice();
250
+ if (signedArea(poly) <= 0) return out;
251
+ for (let t = 0; t + 2 < out.length; t += 3) [out[t + 1], out[t + 2]] = [out[t + 2], out[t + 1]];
252
+ return out;
253
+ }
254
+
224
255
  /**
225
256
  * Is every hull edge visible from `center`? If not, scaling the polygon toward
226
257
  * the centre can fold the inner ring through the rim and the triangles cross —
@@ -379,23 +410,25 @@ export function buildRingMesh(input: MeshSpecInput): MeshGeometry {
379
410
  const uvs: number[] = [];
380
411
  for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
381
412
 
382
- // Counter-clockwise in Spine world: the manifest polygon runs clockwise on
383
- // screen (y down), and the y flip into world space reverses that.
384
- const triangles: number[] = [];
413
+ // Each triple below runs along the hull's own list order, so it carries the
414
+ // hull's winding; `windCounterClockwiseInSpineWorld` then turns the list to
415
+ // the winding every generator here emits (issue #1236).
416
+ const along: number[] = [];
385
417
  const hub = 3 * n;
386
418
  const strip = (outerBase: number, innerBase: number) => {
387
419
  for (let i = 0; i < n; i++) {
388
420
  const j = (i + 1) % n;
389
- triangles.push(outerBase + i, outerBase + j, innerBase + j);
390
- triangles.push(outerBase + i, innerBase + j, innerBase + i);
421
+ along.push(outerBase + i, outerBase + j, innerBase + j);
422
+ along.push(outerBase + i, innerBase + j, innerBase + i);
391
423
  }
392
424
  };
393
425
  strip(0, n); // window edge -> seam
394
426
  strip(n, 2 * n); // seam -> aperture ring
395
427
  for (let i = 0; i < n; i++) {
396
428
  const j = (i + 1) % n;
397
- triangles.push(2 * n + i, 2 * n + j, hub);
429
+ along.push(2 * n + i, 2 * n + j, hub);
398
430
  }
431
+ const triangles = windCounterClockwiseInSpineWorld(hull, along);
399
432
 
400
433
  return { kind: 'ring', points, uvs, triangles, weights, hullVertices: n };
401
434
  }
@@ -940,9 +973,16 @@ function pointInTriangle(
940
973
  *
941
974
  * Repeatedly find a vertex whose two edges make a convex turn in the polygon's
942
975
  * own winding and whose triangle holds no other vertex, emit it as a triangle,
943
- * and remove it. The emitted triples carry the polygon's own winding, so a
944
- * clockwise-on-screen outline yields triangles that are counter-clockwise in
945
- * Spine world after the y flip — the convention the rest of this file keeps.
976
+ * and remove it. The emitted triples carry the polygon's own winding — the
977
+ * order its list runs in — and nothing else decides it: clipping the same
978
+ * outline mirrored picks the same ears in the same order and writes the same
979
+ * triples. ⚠️ A y flip does NOT turn that winding over. Flipping y changes the
980
+ * sign of a signed area, but the y-up frame is drawn with y up, so the loop
981
+ * turns the same way on screen as it does in Spine world: an outline that is
982
+ * clockwise on screen (positive `signedArea` in y-down pixels) yields
983
+ * triangles that are clockwise in Spine world. A caller that emits them turns
984
+ * them with `windCounterClockwiseInSpineWorld` (issue #1236: this paragraph
985
+ * said the opposite, and `contour` emitted clockwise for that reason).
946
986
  *
947
987
  * ## What it refuses, and why by name
948
988
  *
@@ -1592,7 +1632,7 @@ export function buildContourMesh(input: ContourSpecInput): MeshGeometry {
1592
1632
  'has a neck too thin to mesh',
1593
1633
  );
1594
1634
  }
1595
- const triangles = earClip(points);
1635
+ const triangles = windCounterClockwiseInSpineWorld(points, earClip(points));
1596
1636
 
1597
1637
  // The bound on the grid the fit is measured on: its last term is one cell of
1598
1638
  // that grid, which is why it is derived from the texel figures rather than
@@ -1905,7 +1945,7 @@ export function meshEdges(vertexCount: number, triangles: readonly number[], hul
1905
1945
  // bottom-left), each cell two triangles with the diagonal alternating by
1906
1946
  // `(i + j) % 2`, so the lattice has no preferred shear. Emitted
1907
1947
  // counter-clockwise in Spine world, the winding every generator here
1908
- // writes (GR02 holds the grid to it).
1948
+ // writes (GR02 holds the grid to it, CT15 every generator).
1909
1949
  // 4. **Outline first.** The boundary is walked from the edges used by exactly
1910
1950
  // one triangle, in first-seen order, and every interior vertex follows in
1911
1951
  // index order — the arrangement `checkHullOrder` requires.