spine-rigc 2.17.0 → 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/README.md +7 -1
- package/package.json +1 -1
- package/src/areaband.ts +96 -0
- package/src/deformsurvey.ts +5 -80
- package/src/mesh.ts +36 -8
- package/src/meshquality.ts +1507 -0
package/README.md
CHANGED
|
@@ -135,7 +135,13 @@ Both are opt-in and both are narrow — no trimming, no rotation, no scaling —
|
|
|
135
135
|
|
|
136
136
|
rigc runs on [Bun](https://bun.sh). The package ships its TypeScript sources and
|
|
137
137
|
Bun runs them, so there is no build step and no `dist/` that can drift from the
|
|
138
|
-
repository it was cut from.
|
|
138
|
+
repository it was cut from. What is measured is the install itself — pack,
|
|
139
|
+
install into an empty directory, build, render — on Linux, macOS and Windows
|
|
140
|
+
runners and on Linux at the declared minimum, Bun 1.2.0 (the `installs` and
|
|
141
|
+
`installs-matrix` jobs in
|
|
142
|
+
[`ci.yml`](https://github.com/firejune/rigc/blob/main/.github/workflows/ci.yml),
|
|
143
|
+
repository material, not in the npm package); a platform
|
|
144
|
+
whose leg is red there is not one the package is known to run on.
|
|
139
145
|
|
|
140
146
|
**The npm package is `spine-rigc`; the command it installs is `rigc`.** npm
|
|
141
147
|
refuses the name `rigc` as too similar to packages that already exist, so the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
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": {
|
package/src/areaband.ts
ADDED
|
@@ -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
|
+
}
|
package/src/deformsurvey.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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;
|