spine-rigc 0.4.0 → 0.6.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/NOTICE.md +21 -0
- package/README.md +336 -223
- package/bin/rigc.cjs +40 -0
- package/cli.ts +558 -49
- package/docs/AUTHORING.md +538 -23
- package/docs/PROMPTING.md +106 -0
- package/docs/SPEC_COVERAGE.md +16 -14
- package/package.json +4 -2
- package/src/compile.ts +189 -142
- package/src/emit.ts +88 -0
- package/src/json-position.ts +253 -0
- package/src/png.ts +72 -7
- package/src/preview.ts +243 -0
- package/src/render.ts +58 -0
- package/src/types.ts +8 -0
- package/src/validate.ts +58 -13
- package/tools/plate.ts +164 -21
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Prompting an AI to author with rigc
|
|
2
|
+
|
|
3
|
+
This page is for the person **operating** an AI agent — any AI agent — that will
|
|
4
|
+
author a rig with rigc. It is not authoring guidance; that is
|
|
5
|
+
[AUTHORING.md](AUTHORING.md), and the agent should read that file, not this one.
|
|
6
|
+
This page tells you what to put in the prompt, and it earns its claims from a
|
|
7
|
+
measured case: in August 2026 a deliberately small model — Gemini 3.7 Flash on
|
|
8
|
+
the Antigravity harness — authored a benchmark rung unattended for three hours,
|
|
9
|
+
and every stumble below is one it actually made, priced by the instruments in
|
|
10
|
+
[PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md)'s protocol. A large model makes fewer of these mistakes
|
|
11
|
+
unprompted; the clauses cost you three lines and make the small model's run
|
|
12
|
+
land and the large model's run land cleaner.
|
|
13
|
+
|
|
14
|
+
## The prompt skeleton
|
|
15
|
+
|
|
16
|
+
For ordinary use (not a benchmark), the whole prompt is short:
|
|
17
|
+
|
|
18
|
+
> Author a Spine rig with rigc for <what you want>. The images are in
|
|
19
|
+
> `<dir>`. Read `docs/AUTHORING.md` in full before writing either spec —
|
|
20
|
+
> sections 1–5 are the two file formats and the failure map, section 8 is how
|
|
21
|
+
> to fit poses against picture references, section 10 is the editor's
|
|
22
|
+
> conventions. Build with `rigc build`, keep going until `rigc validate` is
|
|
23
|
+
> green, and treat every named failure as pointing at the file to change.
|
|
24
|
+
|
|
25
|
+
Then add the clauses below. If you are running the **benchmark**, do not
|
|
26
|
+
improvise a prompt at all — hand the agent
|
|
27
|
+
[pilot/rung3-runner-prompt.md](https://github.com/firejune/rigc/blob/main/docs/pilot/rung3-runner-prompt.md) verbatim; it
|
|
28
|
+
carries the honesty rule, and an improvised summary of that rule is how a run
|
|
29
|
+
stops being scorable.
|
|
30
|
+
|
|
31
|
+
## Five clauses the pilot paid for
|
|
32
|
+
|
|
33
|
+
**1. Any search loop must print progress and carry a time-box.**
|
|
34
|
+
The pilot wrote a pose-fitting script with no output and no bound, and its
|
|
35
|
+
operator spent part of the evening deciding whether a silent 99 %-CPU process
|
|
36
|
+
was working or hung. (It was working — but nobody could tell.) Say:
|
|
37
|
+
|
|
38
|
+
> Long-running scripts must print one progress line per unit of work and state
|
|
39
|
+
> an expected total up front. If a search would exceed ten minutes, stop and
|
|
40
|
+
> narrow it instead.
|
|
41
|
+
|
|
42
|
+
**2. Intermediate results go to files, not scrollback.**
|
|
43
|
+
The pilot's first fitters printed results to stdout; a crashed step would have
|
|
44
|
+
lost the lot. It later switched to JSON files on its own — start there. Say:
|
|
45
|
+
|
|
46
|
+
> Write measured values (placements, angles, fitted poses) to files in the
|
|
47
|
+
> working directory as you go. Treat your transcript as disposable.
|
|
48
|
+
|
|
49
|
+
**3. Point the fitting method at AUTHORING §8, by number.**
|
|
50
|
+
Given frames to match, the pilot's first instinct was a nested grid over every
|
|
51
|
+
knob at once — tens of thousands of renders per frame. §8 describes the cheap
|
|
52
|
+
loop (one knob at a time, render back, keep what the measurement keeps). The
|
|
53
|
+
pilot had the file "in hand" and did not apply the section until it had burned
|
|
54
|
+
hours. Say:
|
|
55
|
+
|
|
56
|
+
> Fit poses the way AUTHORING §8 does — one variable at a time against a
|
|
57
|
+
> render-back measurement. Do not grid-search several knobs jointly.
|
|
58
|
+
|
|
59
|
+
**4. Keys are structure, not samples.**
|
|
60
|
+
A fitter produces one pose per frame, and the pilot keyed nearly every one of
|
|
61
|
+
them. The result *placed* its parts well — and still scored poorly on every
|
|
62
|
+
timing measure, because animation data is sparse keys plus interpolation
|
|
63
|
+
(AUTHORING §10.3–§10.4), and a key on every frame is a pixel transcription
|
|
64
|
+
wearing a skeleton. Say:
|
|
65
|
+
|
|
66
|
+
> Key extremes and contacts, then let curves carry the in-betweens, per
|
|
67
|
+
> AUTHORING §10.3–10.4. If your fitted poses disagree with a curve, move the
|
|
68
|
+
> curve, not key count. Also §4.4: when a bone's two axes need different
|
|
69
|
+
> timing or easing, key the single-axis timelines, not the paired one.
|
|
70
|
+
|
|
71
|
+
**5. Keep the loop log live.**
|
|
72
|
+
The pilot wrote its `LOOP.md` at the end, from memory — two entries for a
|
|
73
|
+
three-hour session, with eight generations of fitting scripts invisible
|
|
74
|
+
between them. A log written afterwards records the story, not the work. Say:
|
|
75
|
+
|
|
76
|
+
> Append to `LOOP.md` at the moment each build/measure/change step happens,
|
|
77
|
+
> not at the end. One line per step is enough.
|
|
78
|
+
|
|
79
|
+
**6. Make the agent typecheck its own helper scripts.**
|
|
80
|
+
Bun strips types and runs, so an agent can call methods that do not exist and
|
|
81
|
+
watch the script fail — or worse, silently misbehave — without ever learning
|
|
82
|
+
the API was imagined. The pilot left behind a helper written against
|
|
83
|
+
spine-core methods that were never real; the repository's `typecheck` gate
|
|
84
|
+
caught it at landing, hours too late to help the run. Say:
|
|
85
|
+
|
|
86
|
+
> After writing any helper script, run `bunx tsc --noEmit <file>` (or the
|
|
87
|
+
> project's `typecheck` task) before trusting its output.
|
|
88
|
+
|
|
89
|
+
## What you do not need to say
|
|
90
|
+
|
|
91
|
+
The same pilot, unprompted, did all of this correctly: it looped
|
|
92
|
+
`build → validate` until green and read the named failures as pointers; it ran
|
|
93
|
+
the whole thing without a human answer for three hours; it killed its own
|
|
94
|
+
stray processes; it recorded its model name honestly; and under the benchmark
|
|
95
|
+
protocol it ran the scored diff exactly once, at the end, editing nothing
|
|
96
|
+
after. The tool's own error surface carries that part of the conversation —
|
|
97
|
+
prompt for the five clauses above and then let the agent work.
|
|
98
|
+
|
|
99
|
+
## If you are scoring the run
|
|
100
|
+
|
|
101
|
+
That is a different document: [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) is the protocol,
|
|
102
|
+
`pilot/rung3-runner-prompt.md` is the exact prompt, and the evaluator sheet it
|
|
103
|
+
names is for the judge only. The one rule that reaches this page: **never put
|
|
104
|
+
baseline figures, reference structure, or previous runs' measures into the
|
|
105
|
+
runner's prompt** — a number in the prompt is a target, and a run that aimed
|
|
106
|
+
at a target measured nothing.
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -5,18 +5,20 @@ what rigc emits today, and what the official example projects actually use — s
|
|
|
5
5
|
ordered by what the benchmark ladder needs rather than by the spec's alphabet.
|
|
6
6
|
|
|
7
7
|
Reproduce the measurements with `bun run fetch-examples && bun run bench:usage`
|
|
8
|
-
([`bench/count_features.ts`](
|
|
9
|
-
[`feature_matrix.json`](feature_matrix.json)).
|
|
8
|
+
([`bench/count_features.ts`](https://github.com/firejune/rigc/blob/main/bench/count_features.ts) → [`feature_matrix.csv`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.csv),
|
|
9
|
+
[`feature_matrix.json`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.json)).
|
|
10
10
|
|
|
11
11
|
> 🔼 **This note is dated and does not move; the measurements below are the corpus as it
|
|
12
12
|
> stood on 2026-08-22 and stay as written. Its statements about what rigc *does* have gone
|
|
13
|
-
> stale in five places, and the live ledger is [LADDER.md](LADDER.md):**
|
|
13
|
+
> stale in five places, and the live ledger is [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md):**
|
|
14
14
|
>
|
|
15
15
|
> - **B2 is fixed** — A16 accepts `4.3`, `4.3.N` and `4.3.N-<suffix>`, so all twelve example
|
|
16
16
|
> exports pass it.
|
|
17
|
-
> - **The profile split exists** — `--profile spine | spine-html`, default `spine
|
|
18
|
-
>
|
|
19
|
-
>
|
|
17
|
+
> - **The profile split exists** — `--profile spine | spine-html`, **default `spine` since
|
|
18
|
+
> 2026-08-29** (issue #221; it was `spine-html` when this survey was written), so the nine
|
|
19
|
+
> renderer-policy assertions of §2.2 no longer apply to foreign data, and no longer apply
|
|
20
|
+
> unasked. That closes the validator half of B3; the emitter half (no packer, no atlas
|
|
21
|
+
> importer) is untouched.
|
|
20
22
|
> - **`walkTimelines` reaches all eleven groups** — §2.3 item 1 and §4.3 item 4 are done, so
|
|
21
23
|
> A05 is no longer blind to `ik` / `transform` / `path` / `slider` / `drawOrder` /
|
|
22
24
|
> `drawOrderFolder` / `events`.
|
|
@@ -25,7 +27,7 @@ Reproduce the measurements with `bun run fetch-examples && bun run bench:usage`
|
|
|
25
27
|
> attachments, the 4.3 typed constraint array and the invariants, all as data. So §2.4's
|
|
26
28
|
> "the bone tree is code, not data", §4.0's B1 row and executive-summary item 7 describe a
|
|
27
29
|
> state that no longer exists. Rung 3 transcribed into that format scores 1.000 on every
|
|
28
|
-
> measure of every section — figures in [LADDER.md](LADDER.md).
|
|
30
|
+
> measure of every section — figures in [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
|
|
29
31
|
> - Since that change rigc also emits **ik and transform constraints**, the **single-axis bone
|
|
30
32
|
> timelines** (`translatex`/`translatey`/`scalex`/`scaley`/`shear`/`shearx`/`sheary`), bone
|
|
31
33
|
> `length`/`scale`/`shear`/`inherit`/`skin`/`color` setup fields, slot `dark`/`blend`, region
|
|
@@ -659,7 +661,7 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
|
|
|
659
661
|
| `A12_NO_DARK_COLOR` | **renderer-profile** | slot `dark`, `rgba2`/`rgb2` timelines — "parsed, then ignored" |
|
|
660
662
|
| `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
|
|
661
663
|
| `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
|
|
662
|
-
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page
|
|
664
|
+
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page that can never be transparent — no alpha channel and no `tRNS` chunk |
|
|
663
665
|
| `A20_MESH_WEIGHTS_COHERENT` | **renderer-profile / archetype** | **unweighted meshes**, weights not summing to 1, out-of-range bone indices |
|
|
664
666
|
| `A21_MESH_RIM_PINNED` | archetype | ring/ribbon rim not pinned |
|
|
665
667
|
| `A22_MESH_UVS_IN_UNIT_RANGE` | validity-ish | UVs outside 0..1 |
|
|
@@ -716,7 +718,7 @@ one.
|
|
|
716
718
|
|
|
717
719
|
> 🔼 Both halves of that paragraph are now stale. The walker reaches all eleven groups and the
|
|
718
720
|
> mutants that prove it exist; the suite runs on fixtures it **generates**, so it needs no
|
|
719
|
-
> project's art. Current shape: [CLAUDE.md](
|
|
721
|
+
> project's art. Current shape: [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md), *The selftest and its fixtures*.
|
|
720
722
|
|
|
721
723
|
### 2.4 The input model — where the rig actually comes from
|
|
722
724
|
|
|
@@ -751,10 +753,10 @@ The manifest supplies *positions* for those bones (`anchors`), never their *name
|
|
|
751
753
|
## Part 3 — what the example ladder actually uses
|
|
752
754
|
|
|
753
755
|
Corpus: the nine official example projects on branch `4.3`, fetched by
|
|
754
|
-
[`scripts/fetch-examples.sh`](
|
|
756
|
+
[`scripts/fetch-examples.sh`](https://github.com/firejune/rigc/blob/main/scripts/fetch-examples.sh) into the gitignored `examples/<name>/`
|
|
755
757
|
(**12 skeleton JSON files** — some examples ship more than one — **10 `.atlas` files**, 8 `license.txt`).
|
|
756
|
-
Counted by [`bench/count_features.ts`](
|
|
757
|
-
[`feature_matrix.json`](feature_matrix.json) and [`feature_matrix.csv`](feature_matrix.csv)
|
|
758
|
+
Counted by [`bench/count_features.ts`](https://github.com/firejune/rigc/blob/main/bench/count_features.ts); full output in
|
|
759
|
+
[`feature_matrix.json`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.json) and [`feature_matrix.csv`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.csv)
|
|
758
760
|
(12 rows × 135 columns), both regenerated by `bun run bench:usage`.
|
|
759
761
|
|
|
760
762
|
> ⚠️ **Verification note.** The timeline columns below were re-derived independently from the raw JSON
|
|
@@ -913,7 +915,7 @@ Crossing Part 2 with Part 3. Categories, as requested:
|
|
|
913
915
|
**(d) JSON-inexpressible** — things the format genuinely cannot hold.
|
|
914
916
|
|
|
915
917
|
**Nothing below was implemented when this was written. This is a survey.** Three of its items
|
|
916
|
-
have been built since — see the banner at the top of this document and [LADDER.md](LADDER.md) for
|
|
918
|
+
have been built since — see the banner at the top of this document and [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) for
|
|
917
919
|
which; the requirement text itself is left exactly as surveyed.
|
|
918
920
|
|
|
919
921
|
### 4.0 Three blockers that precede rung 1
|
|
@@ -977,7 +979,7 @@ Nothing structurally new (attachment timeline, blend, inherit all arrived by run
|
|
|
977
979
|
#### Rung 6 — `6-arcs`
|
|
978
980
|
|
|
979
981
|
> ✅ **Measured, 2026-08-23.** The rows below were written as predictions; the
|
|
980
|
-
> transcription in [`bench/transcriptions/6-arcs/`](
|
|
982
|
+
> transcription in [`bench/transcriptions/6-arcs/`](https://github.com/firejune/rigc/tree/main/bench/transcriptions/6-arcs/)
|
|
981
983
|
> settled them. It compiles green under `--profile spine` and `bench 6` reports
|
|
982
984
|
> **1.000 on all 44 measures in all six sections**, so the rig spec expresses this
|
|
983
985
|
> skeleton — weighted meshes, `edges` and the 4.3 transform constraints alike. A
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"rigc": "./
|
|
7
|
+
"rigc": "./bin/rigc.cjs"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
10
|
"bun": ">=1.2.0"
|
|
@@ -31,12 +31,14 @@
|
|
|
31
31
|
"cli"
|
|
32
32
|
],
|
|
33
33
|
"files": [
|
|
34
|
+
"bin/rigc.cjs",
|
|
34
35
|
"cli.ts",
|
|
35
36
|
"src",
|
|
36
37
|
"tools/plate.ts",
|
|
37
38
|
"tools/font5x7.ts",
|
|
38
39
|
"docs/AUTHORING.md",
|
|
39
40
|
"docs/SPEC_COVERAGE.md",
|
|
41
|
+
"docs/PROMPTING.md",
|
|
40
42
|
"NOTICE.md"
|
|
41
43
|
],
|
|
42
44
|
"publishConfig": {
|
package/src/compile.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { basename, dirname, relative, resolve } from 'node:path';
|
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { readPngInfo } from './png.ts';
|
|
23
23
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
24
|
+
import { parseJsonWithPosition } from './json-position.ts';
|
|
24
25
|
import {
|
|
25
26
|
parseRigSpec,
|
|
26
27
|
type RigAttachment,
|
|
@@ -255,8 +256,17 @@ export function bezierForChannel(
|
|
|
255
256
|
// ---------------------------------------------------------------------------
|
|
256
257
|
|
|
257
258
|
function readJson<T>(path: string): T {
|
|
259
|
+
let text: string;
|
|
258
260
|
try {
|
|
259
|
-
|
|
261
|
+
text = readFileSync(path, 'utf8');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
// A parse failure gets a line/column appended to the runtime's own
|
|
267
|
+
// message — see `parseJsonWithPosition` for why `JSON.parse` alone
|
|
268
|
+
// cannot say where.
|
|
269
|
+
return parseJsonWithPosition(text) as T;
|
|
260
270
|
} catch (err) {
|
|
261
271
|
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
262
272
|
}
|
|
@@ -319,6 +329,36 @@ export interface CompileOptions {
|
|
|
319
329
|
imagesDir?: string;
|
|
320
330
|
}
|
|
321
331
|
|
|
332
|
+
/**
|
|
333
|
+
* One part = one page. No packer, so no PMA trap, no rotation, no strip
|
|
334
|
+
* offsets. Region covers the page exactly => u2=v2=1.
|
|
335
|
+
*
|
|
336
|
+
* Two text-shape traps are load-bearing here:
|
|
337
|
+
* * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
338
|
+
* * a blank line closes the page block -> none between header and regions.
|
|
339
|
+
*
|
|
340
|
+
* Exported (rather than inlined into `compile`) so `--copy-images` can call it a
|
|
341
|
+
* second time with `page` rewritten to the copies' filenames, after the copy
|
|
342
|
+
* itself has happened — see [`src/emit.ts`](emit.ts). Everything else about an
|
|
343
|
+
* image (`region`, `width`, `height`) is unchanged by that rewrite; only where the
|
|
344
|
+
* bytes live moved.
|
|
345
|
+
*/
|
|
346
|
+
export function buildAtlasText(images: CompiledImage[]): string {
|
|
347
|
+
const atlasLines: string[] = [];
|
|
348
|
+
images.forEach((img, i) => {
|
|
349
|
+
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
350
|
+
atlasLines.push(img.page);
|
|
351
|
+
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
352
|
+
atlasLines.push('filter: Linear, Linear');
|
|
353
|
+
atlasLines.push('pma: false');
|
|
354
|
+
atlasLines.push(img.region);
|
|
355
|
+
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
356
|
+
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
357
|
+
atlasLines.push('rotate: 0');
|
|
358
|
+
});
|
|
359
|
+
return `${atlasLines.join('\n')}\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
322
362
|
export function compile(opts: CompileOptions): CompileResult {
|
|
323
363
|
const rigPath = resolve(opts.rigPath);
|
|
324
364
|
const motionPath = resolve(opts.motionPath);
|
|
@@ -330,17 +370,38 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
330
370
|
const motion = readJson<MotionSpec>(motionPath);
|
|
331
371
|
const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
|
|
332
372
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
373
|
+
// The rig spec names its own path in every message `parseRigSpec` throws (its
|
|
374
|
+
// `where` argument, above). The motion spec gets no such treatment below —
|
|
375
|
+
// every "animation … bone … property" refusal is built from data alone — so a
|
|
376
|
+
// reader with two input files and one error has no way to tell which one is at
|
|
377
|
+
// fault. This wraps the motion-only regions (the version/archetype check right
|
|
378
|
+
// below, the physics table and the animation loop further down) and prefixes
|
|
379
|
+
// the motion path onto any `CompileError` that escapes them, unless the
|
|
380
|
+
// message already names it.
|
|
381
|
+
const withMotionSource = <T>(fn: () => T): T => {
|
|
382
|
+
try {
|
|
383
|
+
return fn();
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err instanceof CompileError && !err.message.includes(motionPath)) {
|
|
386
|
+
throw new CompileError(`${motionPath}: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
withMotionSource(() => {
|
|
393
|
+
if (motion.spec !== 'rigc-motion/1') {
|
|
394
|
+
throw new CompileError(`unknown motion spec version: ${String(motion.spec)}`);
|
|
395
|
+
}
|
|
396
|
+
// The motion spec was authored against one formation. Pairing it with another
|
|
397
|
+
// rig would aim its keys at bones whose names happen to match and whose meaning
|
|
398
|
+
// does not — the class of wrongness that loads, plays and lies.
|
|
399
|
+
if (motion.archetype !== rig.name) {
|
|
400
|
+
throw new CompileError(
|
|
401
|
+
`motion spec names archetype "${motion.archetype}" but the rig spec at ${rigPath} is called "${rig.name}"`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
344
405
|
|
|
345
406
|
// The stage. The rig may state it outright (a foreign skeleton has no crop);
|
|
346
407
|
// otherwise the manifest's crop is it. With neither there is nothing to
|
|
@@ -546,25 +607,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
546
607
|
}
|
|
547
608
|
|
|
548
609
|
// -- 2. atlas --------------------------------------------------------------
|
|
549
|
-
|
|
550
|
-
// offsets. Region covers the page exactly => u2=v2=1.
|
|
551
|
-
//
|
|
552
|
-
// Two text-shape traps are load-bearing here:
|
|
553
|
-
// * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
554
|
-
// * a blank line closes the page block -> none between header and regions.
|
|
555
|
-
const atlasLines: string[] = [];
|
|
556
|
-
images.forEach((img, i) => {
|
|
557
|
-
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
558
|
-
atlasLines.push(img.page);
|
|
559
|
-
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
560
|
-
atlasLines.push('filter: Linear, Linear');
|
|
561
|
-
atlasLines.push('pma: false');
|
|
562
|
-
atlasLines.push(img.region);
|
|
563
|
-
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
564
|
-
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
565
|
-
atlasLines.push('rotate: 0');
|
|
566
|
-
});
|
|
567
|
-
const atlasText = `${atlasLines.join('\n')}\n`;
|
|
610
|
+
const atlasText = buildAtlasText(images);
|
|
568
611
|
|
|
569
612
|
// -- 3. bones --------------------------------------------------------------
|
|
570
613
|
//
|
|
@@ -728,128 +771,132 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
728
771
|
constraints.push(buildRigConstraint(spec as RigConstraintInput, boneNames));
|
|
729
772
|
constraintNames.add(spec.name);
|
|
730
773
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
774
|
+
withMotionSource(() => {
|
|
775
|
+
for (const [name, spec] of Object.entries(motion.physics ?? {})) {
|
|
776
|
+
if (constraintNames.has(name)) {
|
|
777
|
+
throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
|
|
778
|
+
}
|
|
779
|
+
constraintNames.add(name);
|
|
780
|
+
if (!boneNames.has(spec.bone)) {
|
|
781
|
+
throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
|
|
782
|
+
}
|
|
783
|
+
const entry: SpineConstraint = {
|
|
784
|
+
name,
|
|
785
|
+
type: 'physics',
|
|
786
|
+
bone: spec.bone,
|
|
787
|
+
};
|
|
788
|
+
const components: string[] = [];
|
|
789
|
+
for (const comp of PHYSICS_COMPONENTS) {
|
|
790
|
+
const v = spec[comp];
|
|
791
|
+
if (v === undefined || v === 0) continue;
|
|
792
|
+
entry[comp] = r6(v);
|
|
793
|
+
components.push(comp);
|
|
794
|
+
}
|
|
795
|
+
if (!components.length) {
|
|
796
|
+
// The parser is happy with this and the constraint does nothing at all.
|
|
797
|
+
// A23 catches it too; refusing here means it never reaches the gate.
|
|
798
|
+
throw new CompileError(
|
|
799
|
+
`physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
for (const [param, dflt] of PHYSICS_PARAMS) {
|
|
803
|
+
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
804
|
+
if (v === undefined || v === dflt) continue;
|
|
805
|
+
entry[param] = r6(v);
|
|
806
|
+
}
|
|
807
|
+
constraints.push(entry);
|
|
808
|
+
physicsReport.push({
|
|
809
|
+
name,
|
|
810
|
+
bone: spec.bone,
|
|
811
|
+
components,
|
|
812
|
+
mix: spec.mix ?? 1,
|
|
813
|
+
drivesMesh: meshBones.has(spec.bone),
|
|
814
|
+
});
|
|
757
815
|
}
|
|
758
|
-
|
|
759
|
-
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
760
|
-
if (v === undefined || v === dflt) continue;
|
|
761
|
-
entry[param] = r6(v);
|
|
762
|
-
}
|
|
763
|
-
constraints.push(entry);
|
|
764
|
-
physicsReport.push({
|
|
765
|
-
name,
|
|
766
|
-
bone: spec.bone,
|
|
767
|
-
components,
|
|
768
|
-
mix: spec.mix ?? 1,
|
|
769
|
-
drivesMesh: meshBones.has(spec.bone),
|
|
770
|
-
});
|
|
771
|
-
}
|
|
816
|
+
});
|
|
772
817
|
|
|
773
818
|
// -- 5. animations ---------------------------------------------------------
|
|
774
819
|
const animations: SpineSkeletonJson['animations'] = {};
|
|
775
820
|
const declaredDurations: Record<string, number> = {};
|
|
776
821
|
const slotNames = new Set(slots.map((s) => s.name));
|
|
777
822
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (
|
|
793
|
-
|
|
823
|
+
withMotionSource(() => {
|
|
824
|
+
for (const [animName, anim] of Object.entries(motion.animations)) {
|
|
825
|
+
declaredDurations[animName] = anim.duration;
|
|
826
|
+
const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
827
|
+
const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
828
|
+
const physicsTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
829
|
+
const claimed = new Set<string>();
|
|
830
|
+
let compiledDuration = 0;
|
|
831
|
+
|
|
832
|
+
for (const track of anim.tracks) {
|
|
833
|
+
const isPhysicsTrack = track.property in PHYSICS_TRACKS;
|
|
834
|
+
const isBoneTrack = !isPhysicsTrack && track.property in BONE_TRACKS;
|
|
835
|
+
const targets = resolveTargets(track, motion, animName);
|
|
836
|
+
targets.forEach((target, index) => {
|
|
837
|
+
if (isPhysicsTrack) {
|
|
838
|
+
if (!constraintNames.has(target)) {
|
|
839
|
+
throw new CompileError(`animation "${animName}" keys unknown physics constraint "${target}"`);
|
|
840
|
+
}
|
|
841
|
+
} else if (isBoneTrack) {
|
|
842
|
+
if (!boneNames.has(target)) {
|
|
843
|
+
throw new CompileError(`animation "${animName}" keys unknown bone "${target}"`);
|
|
844
|
+
}
|
|
845
|
+
} else if (!slotNames.has(target)) {
|
|
846
|
+
throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
|
|
794
847
|
}
|
|
795
|
-
|
|
796
|
-
if (
|
|
797
|
-
throw new CompileError(
|
|
848
|
+
const claim = `${target}.${track.property}`;
|
|
849
|
+
if (claimed.has(claim)) {
|
|
850
|
+
throw new CompileError(
|
|
851
|
+
`animation "${animName}" has two tracks on ${claim}; merge them into one track`,
|
|
852
|
+
);
|
|
798
853
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
: isBoneTrack
|
|
814
|
-
? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
815
|
-
: compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
|
|
816
|
-
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
817
|
-
if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
|
|
818
|
-
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
819
|
-
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
820
|
-
});
|
|
821
|
-
}
|
|
854
|
+
claimed.add(claim);
|
|
855
|
+
|
|
856
|
+
const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
|
|
857
|
+
const keys = isPhysicsTrack
|
|
858
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, PHYSICS_TRACKS, 'physics constraint')
|
|
859
|
+
: isBoneTrack
|
|
860
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
861
|
+
: compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
|
|
862
|
+
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
863
|
+
if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
|
|
864
|
+
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
865
|
+
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
866
|
+
});
|
|
867
|
+
}
|
|
822
868
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
869
|
+
const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
|
|
870
|
+
if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
871
|
+
|
|
872
|
+
const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
|
|
873
|
+
// An event timeline counts towards the animation's length the same as any
|
|
874
|
+
// other: `readAnimation` takes the duration from the longest timeline it
|
|
875
|
+
// built, and `EventTimeline.getDuration()` is its last frame like the rest.
|
|
876
|
+
if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
877
|
+
|
|
878
|
+
// Rule 4: the declared duration is verified, because skeleton JSON does not
|
|
879
|
+
// carry one — the loader takes the max key time.
|
|
880
|
+
//
|
|
881
|
+
// This arm is about the DECLARED DURATION being wrong, so it compares one
|
|
882
|
+
// number per animation and tolerates a frame of it. The other arm —
|
|
883
|
+
// `checkKeyTime`, above, per key — is about a key landing past the end, and
|
|
884
|
+
// a frame is 16,667 times too coarse to see one. Both are needed: this one
|
|
885
|
+
// catches an animation that stops a second early, and only that one catches
|
|
886
|
+
// a key on a track whose neighbour already sits on the declared duration.
|
|
887
|
+
if (Math.abs(compiledDuration - anim.duration) > FRAME) {
|
|
888
|
+
throw new CompileError(
|
|
889
|
+
`animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
animations[animName] = {};
|
|
893
|
+
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
894
|
+
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
895
|
+
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
896
|
+
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
897
|
+
if (eventKeys) animations[animName].events = eventKeys;
|
|
845
898
|
}
|
|
846
|
-
|
|
847
|
-
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
848
|
-
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
849
|
-
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
850
|
-
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
851
|
-
if (eventKeys) animations[animName].events = eventKeys;
|
|
852
|
-
}
|
|
899
|
+
});
|
|
853
900
|
|
|
854
901
|
// -- 6. assemble -----------------------------------------------------------
|
|
855
902
|
const header: SpineSkeletonJson['skeleton'] = {
|