spine-rigc 2.17.1 → 2.18.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.17.1",
3
+ "version": "2.18.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": {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The area band a triangle's sign is read against — the one A39 reads a
3
+ * winding by, and the one the mesh-quality measurement reads orientation and
4
+ * degeneracy by (`src/meshquality.ts`).
5
+ *
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.
8
+ * They moved because a module the `spine-rigc/mesh` entry reaches has to read
9
+ * the same band, and `src/deformsurvey.ts` reaches the compiler and the core
10
+ * (`src/deformstructure.ts`, `src/core/`) — a geometry entry that loaded the
11
+ * compiler to read three numbers would also close an import cycle through
12
+ * `src/compile.ts`, which imports `src/mesh.ts`.
13
+ *
14
+ * Imports nothing: no clock, no runtime, no other module.
15
+ */
16
+
17
+ /**
18
+ * How near zero a triangle's area has to be, as a fraction of the largest
19
+ * triangle in the same mesh at the same pose, before a sign is not read off it.
20
+ *
21
+ * A RELATIVE band, because an absolute one has no scale that means anything on
22
+ * its own — these are pixel² figures on whatever plate the rig was drawn at, and
23
+ * `gallery/flex`'s leaf tops out at 792.6 px² where `spineboy-pro`'s hoverboard
24
+ * reaches 3338.4 px². On those two it comes to 7.9e-4 and 3.3e-3 px².
25
+ *
26
+ * ⚠️ It is **not** what holds the float32 noise off; `float32AreaNoise` is, and
27
+ * the two are combined rather than ranked because on a big mesh the noise bound
28
+ * is the larger of them. This one is the shape band: it keeps a setup triangle
29
+ * that has no area from being read as a reversal of anything, and a triangle the
30
+ * key collapses onto zero from being read as turned over.
31
+ */
32
+ export const DEFORM_AREA_EPSILON = 1e-6;
33
+
34
+ /** Half an ulp of a float32 mantissa — the relative error of one stored coordinate. */
35
+ const FLOAT32_HALF_ULP = 2 ** -24;
36
+
37
+ /**
38
+ * An upper bound on how much of a triangle's signed area is float32 noise.
39
+ *
40
+ * The world vertices arrive in a `Float32Array`, so each coordinate carries up
41
+ * to `|c|·2⁻²⁴` of error. An area is `½·(Δx₁·Δy₂ − Δx₂·Δy₁)`, and propagating
42
+ * that error through one product gives `Δ·|c|·2⁻²⁴` twice over; four such terms
43
+ * across the two products, halved, bounds the area error by `2·C²·2⁻²⁴` with `C`
44
+ * the largest coordinate magnitude in the mesh (which also bounds every `Δ`).
45
+ * Doubled once more for the subtraction, so the constant is 4.
46
+ *
47
+ * On a mesh whose vertices reach 500 units that is 6e-2 px², i.e. **larger** than
48
+ * the relative band above — which is the whole reason this exists. It is a bound
49
+ * rather than a measurement, and deliberately loose: what has to stay clear of it
50
+ * is a genuine reversal, and the smallest one anywhere in the corpus is
51
+ * `spineboy-pro`'s hoverboard triangle at 8.478 px², more than two orders of
52
+ * magnitude above. Nothing measured lands between the two, so nothing between
53
+ * them is being tuned.
54
+ */
55
+ export function float32AreaNoise(world: ArrayLike<number>): number {
56
+ let coordinate = 0;
57
+ for (let i = 0; i < world.length; i++) coordinate = Math.max(coordinate, Math.abs(world[i]));
58
+ return 4 * coordinate * coordinate * FLOAT32_HALF_ULP;
59
+ }
60
+
61
+ /**
62
+ * Twice-signed area, halved, of every triangle of `triangles` over the
63
+ * interleaved `x, y` world vertices in `world`.
64
+ *
65
+ * The SIGN is the whole point and the magnitude is the tolerance's yardstick, so
66
+ * this returns the signed figure rather than an absolute one. Positive and
67
+ * negative are not "correct" and "wrong" — a mesh may be wound either way, and
68
+ * what A39 reads is whether one triangle's sign CHANGED.
69
+ */
70
+ export function triangleAreas(world: ArrayLike<number>, triangles: ArrayLike<number>): number[] {
71
+ const out: number[] = [];
72
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
73
+ const i0 = triangles[t] * 2;
74
+ const i1 = triangles[t + 1] * 2;
75
+ const i2 = triangles[t + 2] * 2;
76
+ const x0 = world[i0];
77
+ const y0 = world[i0 + 1];
78
+ out.push(0.5 * ((world[i1] - x0) * (world[i2 + 1] - y0) - (world[i2] - x0) * (world[i1 + 1] - y0)));
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * The dead band a set of areas is read against.
85
+ *
86
+ * Both bands, and the wider one wins. The relative one is about the SHAPE (a
87
+ * triangle with no area has no winding); the noise one is about the arithmetic (a
88
+ * sign read off float32 rounding is not a measurement). Each is the larger on a
89
+ * different mesh.
90
+ */
91
+ export function areaBand(plainAreas: readonly number[], ...worlds: ReadonlyArray<ArrayLike<number>>): number {
92
+ const largest = plainAreas.reduce((m, a) => Math.max(m, Math.abs(a)), 0);
93
+ let band = largest * DEFORM_AREA_EPSILON;
94
+ for (const world of worlds) band = Math.max(band, float32AreaNoise(world));
95
+ return band;
96
+ }
@@ -25,72 +25,12 @@
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
29
 
29
- /**
30
- * How near zero a triangle's area has to be, as a fraction of the largest
31
- * triangle in the same mesh at the same pose, before a sign is not read off it.
32
- *
33
- * A RELATIVE band, because an absolute one has no scale that means anything on
34
- * its own — these are pixel² figures on whatever plate the rig was drawn at, and
35
- * `gallery/flex`'s leaf tops out at 792.6 px² where `spineboy-pro`'s hoverboard
36
- * reaches 3338.4 px². On those two it comes to 7.9e-4 and 3.3e-3 px².
37
- *
38
- * ⚠️ It is **not** what holds the float32 noise off; `float32AreaNoise` is, and
39
- * the two are combined rather than ranked because on a big mesh the noise bound
40
- * is the larger of them. This one is the shape band: it keeps a setup triangle
41
- * that has no area from being read as a reversal of anything, and a triangle the
42
- * key collapses onto zero from being read as turned over.
43
- */
44
- export const DEFORM_AREA_EPSILON = 1e-6;
45
-
46
- /** Half an ulp of a float32 mantissa — the relative error of one stored coordinate. */
47
- const FLOAT32_HALF_ULP = 2 ** -24;
48
-
49
- /**
50
- * An upper bound on how much of a triangle's signed area is float32 noise.
51
- *
52
- * The world vertices arrive in a `Float32Array`, so each coordinate carries up
53
- * to `|c|·2⁻²⁴` of error. An area is `½·(Δx₁·Δy₂ − Δx₂·Δy₁)`, and propagating
54
- * that error through one product gives `Δ·|c|·2⁻²⁴` twice over; four such terms
55
- * across the two products, halved, bounds the area error by `2·C²·2⁻²⁴` with `C`
56
- * the largest coordinate magnitude in the mesh (which also bounds every `Δ`).
57
- * Doubled once more for the subtraction, so the constant is 4.
58
- *
59
- * On a mesh whose vertices reach 500 units that is 6e-2 px², i.e. **larger** than
60
- * the relative band above — which is the whole reason this exists. It is a bound
61
- * rather than a measurement, and deliberately loose: what has to stay clear of it
62
- * is a genuine reversal, and the smallest one anywhere in the corpus is
63
- * `spineboy-pro`'s hoverboard triangle at 8.478 px², more than two orders of
64
- * magnitude above. Nothing measured lands between the two, so nothing between
65
- * them is being tuned.
66
- */
67
- export function float32AreaNoise(world: ArrayLike<number>): number {
68
- let coordinate = 0;
69
- for (let i = 0; i < world.length; i++) coordinate = Math.max(coordinate, Math.abs(world[i]));
70
- return 4 * coordinate * coordinate * FLOAT32_HALF_ULP;
71
- }
72
-
73
- /**
74
- * Twice-signed area, halved, of every triangle of `triangles` over the
75
- * interleaved `x, y` world vertices in `world`.
76
- *
77
- * The SIGN is the whole point and the magnitude is the tolerance's yardstick, so
78
- * this returns the signed figure rather than an absolute one. Positive and
79
- * negative are not "correct" and "wrong" — a mesh may be wound either way, and
80
- * what A39 reads is whether one triangle's sign CHANGED.
81
- */
82
- export function triangleAreas(world: ArrayLike<number>, triangles: ArrayLike<number>): number[] {
83
- const out: number[] = [];
84
- for (let t = 0; t + 2 < triangles.length; t += 3) {
85
- const i0 = triangles[t] * 2;
86
- const i1 = triangles[t + 1] * 2;
87
- const i2 = triangles[t + 2] * 2;
88
- const x0 = world[i0];
89
- const y0 = world[i0 + 1];
90
- out.push(0.5 * ((world[i1] - x0) * (world[i2 + 1] - y0) - (world[i2] - x0) * (world[i1 + 1] - y0)));
91
- }
92
- return out;
93
- }
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 };
94
34
 
95
35
  /**
96
36
  * The two singular values of the linear map that takes one triangle onto the
@@ -1624,21 +1564,6 @@ function measurePosed(
1624
1564
  };
1625
1565
  }
1626
1566
 
1627
- /**
1628
- * The dead band a set of areas is read against.
1629
- *
1630
- * Both bands, and the wider one wins. The relative one is about the SHAPE (a
1631
- * triangle with no area has no winding); the noise one is about the arithmetic (a
1632
- * sign read off float32 rounding is not a measurement). Each is the larger on a
1633
- * different mesh.
1634
- */
1635
- function areaBand(plainAreas: readonly number[], ...worlds: ReadonlyArray<ArrayLike<number>>): number {
1636
- const largest = plainAreas.reduce((m, a) => Math.max(m, Math.abs(a)), 0);
1637
- let band = largest * DEFORM_AREA_EPSILON;
1638
- for (const world of worlds) band = Math.max(band, float32AreaNoise(world));
1639
- return band;
1640
- }
1641
-
1642
1567
  // ---------------------------------------------------------------------------
1643
1568
  // Between two keys — issue #403
1644
1569
  // ---------------------------------------------------------------------------
package/src/mesh.ts CHANGED
@@ -44,6 +44,13 @@
44
44
  */
45
45
  import type { ModelBinding, ModelVertices } from './model.ts';
46
46
 
47
+ // The mesh-quality measurement and its report (issue #1224) live in their own
48
+ // module and reach a dependant through this file, which is what
49
+ // `spine-rigc/mesh` names. That module imports helpers from this one, so the
50
+ // two form an import cycle: safe only because neither reads the other's
51
+ // bindings while it is being evaluated — every use is inside a function.
52
+ export * from './meshquality.ts';
53
+
47
54
  export interface MeshSpecInput {
48
55
  /** Polygon in part-local pixels, y down, in manifest order. */
49
56
  hull: Array<[number, number]>;
@@ -139,13 +146,34 @@ export interface RibbonSpecInput {
139
146
 
140
147
  export class MeshError extends Error {}
141
148
 
149
+ /**
150
+ * A refusal of the mesh-quality operations (`src/meshquality.ts`, issue #1224),
151
+ * by code: a `MeshError`, so a dependant that already catches `MeshError` keeps
152
+ * catching it, and `code` for one that wants to know which refusal it was. The
153
+ * codes are listed in docs/MESH_REDUCTION.md beside the section that defines
154
+ * each.
155
+ *
156
+ * ⚠️ Defined here and not in `src/meshquality.ts`, because that module imports
157
+ * this one and this one re-exports it: a class that extends `MeshError` at the
158
+ * top of the importing module would read `MeshError` before this module had
159
+ * run, and every import of `spine-rigc/mesh` would throw.
160
+ */
161
+ export class MeshReductionError extends MeshError {
162
+ readonly code: string;
163
+ constructor(code: string, message: string) {
164
+ super(`${code}: ${message}`);
165
+ this.code = code;
166
+ this.name = 'MeshReductionError';
167
+ }
168
+ }
169
+
142
170
  /**
143
171
  * The generator's own grid: 6 decimals, never "-0". Its rows, weights and
144
172
  * shares are built on it, and what it produces reaches the file through the
145
173
  * compiler's `f32` at emission (issue #716), so the emitted text is still each
146
174
  * number's float32 name — this is the generator's resolution, not the file's.
147
175
  */
148
- function r6(n: number): number {
176
+ export function r6(n: number): number {
149
177
  const v = Math.round(n * 1e6) / 1e6;
150
178
  return v === 0 ? 0 : v;
151
179
  }
@@ -681,7 +709,7 @@ const CRACK_DIRS: ReadonlyArray<readonly [number, number]> = [
681
709
  ];
682
710
 
683
711
  /** Perpendicular distance from `p` to the segment `a`-`b`, clamped to its ends. */
684
- function distanceToSegment(
712
+ export function distanceToSegment(
685
713
  p: readonly [number, number],
686
714
  a: readonly [number, number],
687
715
  b: readonly [number, number],
@@ -843,7 +871,7 @@ export function prunePolygon(poly: Array<[number, number]>): Array<[number, numb
843
871
  }
844
872
 
845
873
  /** Do two segments share a point? Touching counts — an ear needs strict simplicity. */
846
- function segmentsMeet(
874
+ export function segmentsMeet(
847
875
  a: readonly [number, number],
848
876
  b: readonly [number, number],
849
877
  c: readonly [number, number],
@@ -979,14 +1007,14 @@ export function earClip(poly: Array<[number, number]>): number[] {
979
1007
  }
980
1008
 
981
1009
  /** Which pixels of a mask are art, as 1/0 bytes. */
982
- function artOf(mask: AlphaMask, threshold: number): Uint8Array {
1010
+ export function artOf(mask: AlphaMask, threshold: number): Uint8Array {
983
1011
  const out = new Uint8Array(mask.width * mask.height);
984
1012
  for (let i = 0; i < out.length; i++) out[i] = mask.alpha[i] >= threshold ? 1 : 0;
985
1013
  return out;
986
1014
  }
987
1015
 
988
1016
  /** 4-connected island labels, 1-based; 0 is background. */
989
- function labelIslands(art: Uint8Array, w: number, h: number): { label: Int32Array; sizes: number[] } {
1017
+ export function labelIslands(art: Uint8Array, w: number, h: number): { label: Int32Array; sizes: number[] } {
990
1018
  const label = new Int32Array(w * h);
991
1019
  const sizes: number[] = [];
992
1020
  const stack: number[] = [];
@@ -1185,7 +1213,7 @@ export function traceAlphaOutline(
1185
1213
  * The authored-mesh measurement floods 4, as it always has; the two agree on
1186
1214
  * every mask the trace accepts.
1187
1215
  */
1188
- function fillEnclosed(
1216
+ export function fillEnclosed(
1189
1217
  inside: Uint8Array,
1190
1218
  w: number,
1191
1219
  h: number,
@@ -1242,7 +1270,7 @@ function fillEnclosed(
1242
1270
  * same convention `src/render.ts` rasterises by. A degenerate triangle — zero
1243
1271
  * doubled area — is skipped rather than given an orientation it does not have.
1244
1272
  */
1245
- function rasteriseTriangles(points: Array<[number, number]>, triangles: number[], w: number, h: number): Uint8Array {
1273
+ export function rasteriseTriangles(points: Array<[number, number]>, triangles: number[], w: number, h: number): Uint8Array {
1246
1274
  const covered = new Uint8Array(w * h);
1247
1275
  for (let t = 0; t + 2 < triangles.length; t += 3) {
1248
1276
  const a = points[triangles[t]];
@@ -1420,7 +1448,7 @@ export function measureAuthoredMeshFit(
1420
1448
  * a distance. A grid with no set pixel at all comes back all `INF`; the one caller
1421
1449
  * never asks (a mask with no art has no covered-outside pixel to ask about).
1422
1450
  */
1423
- function squaredDistanceToSet(inside: Uint8Array, w: number, h: number): Float64Array {
1451
+ export function squaredDistanceToSet(inside: Uint8Array, w: number, h: number): Float64Array {
1424
1452
  const INF = w * w + h * h + 1;
1425
1453
  const dist = new Float64Array(w * h);
1426
1454
  for (let i = 0; i < dist.length; i++) dist[i] = inside[i] ? 0 : INF;