spine-parts 0.5.0 → 0.7.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 CHANGED
@@ -60,10 +60,15 @@ from them beyond the layers See-through itself writes for the user's own image.
60
60
  ## 2. Spine Runtimes, through spine-rigc
61
61
 
62
62
  spine-parts depends on [`spine-rigc`](https://www.npmjs.com/package/spine-rigc)
63
- (MIT, same author), which compiles and validates Spine skeleton data and links
64
- `@esotericsoftware/spine-core`, part of the
63
+ (MIT, same author), which compiles and validates Spine skeleton data. Its round
64
+ trip runs through `@esotericsoftware/spine-core`, part of the
65
65
  [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes), under the
66
66
  [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license).
67
+ Since spine-rigc 2.0 neither package installs the runtime: spine-rigc carries it
68
+ only as a development dependency, and an install of spine-rigc gates a build with
69
+ its own validator. spine-parts does not install it either; it is this repository's
70
+ development dependency, at the version spine-rigc develops against, for the round
71
+ trip in the selftest and CI and the selftest's posing oracle.
67
72
  spine-rigc's [NOTICE.md](https://github.com/firejune/rigc/blob/main/NOTICE.md)
68
73
  sets out the chain in full; briefly, as a restatement of Esoteric Software's
69
74
  terms and not a term of this project:
@@ -73,8 +78,11 @@ terms and not a term of this project:
73
78
  2. Playing Spine skeleton data in a product requires a Spine Runtime, and the
74
79
  Spine Runtimes License requires **each user of such a product to own a
75
80
  Spine Editor license**.
76
- 3. spine-rigc links `spine-core`, and spine-parts depends on spine-rigc, so the
77
- same obligation applies to running the stages of spine-parts that call it.
81
+ 3. spine-rigc no longer installs `spine-core`, and spine-parts does not either;
82
+ the obligation in 2 attaches to the Spine skeleton data spine-rigc writes,
83
+ which the stages of spine-parts that call it produce, and to `spine-core`
84
+ wherever it is installed beside them — this repository's development
85
+ dependency included.
78
86
 
79
87
  spine-parts also imports spine-rigc's own modules directly, by deep path: its PNG codec
80
88
  (`tools/plate.ts`, `src/png.ts`), its 5x7 label font (`tools/font5x7.ts`) and
@@ -114,10 +122,13 @@ function's comment says which call it stands in for and what was measured.
114
122
  | Package | Licence | Why |
115
123
  | --- | --- | --- |
116
124
  | [`spine-rigc`](https://www.npmjs.com/package/spine-rigc) | MIT | compile, gate, render and check; its PNG codec, font and coordinate conversion |
117
- | ↳ [`@esotericsoftware/spine-core`](https://www.npmjs.com/package/@esotericsoftware/spine-core) | Spine Runtimes License | spine-rigc's round trip (section 2) |
118
125
  | [`ag-psd`](https://www.npmjs.com/package/ag-psd) | MIT | reading an upstream See-through `.psd` |
119
126
  | ↳ [`pako`](https://www.npmjs.com/package/pako) | MIT AND Zlib | ag-psd's deflate |
120
127
  | ↳ [`base64-js`](https://www.npmjs.com/package/base64-js) | MIT | ag-psd's base64 |
121
128
 
122
129
  Development only, not installed with the package: `typescript` (Apache-2.0),
123
- `eslint` (MIT), `typescript-eslint` (MIT), `@types/bun` (MIT).
130
+ `eslint` (MIT), `typescript-eslint` (MIT), `@types/bun` (MIT), and
131
+ [`@esotericsoftware/spine-core`](https://www.npmjs.com/package/@esotericsoftware/spine-core)
132
+ (Spine Runtimes License; section 2), pinned to the version spine-rigc develops
133
+ against (`TY10`): the round trip in the selftest and CI, the selftest's posing
134
+ oracle, and `tools/atlas_population.ts` and `tools/idle_cost.ts`.
package/README.md CHANGED
@@ -35,17 +35,16 @@ was shifted down into the canvas (its proposal ran 118 px above the top edge;
35
35
  is taken from the full run, because the twin tails leave the head crop sideways
36
36
  (a plan edit). <code>check</code> printed, verbatim:<br/>
37
37
  <code>49 assertions: 23 measured (23 passed, 0 failed), 26 skipped, 0 not in profile "spine-html"</code><br/>
38
- <code>49 assertions: 14 measured (14 passed, 0 failed), 20 skipped, 15 not in profile "spine"</code><br/>
39
38
  <code>loop: idle 49 frame(s) at 12 fps, f0000 vs f0048 (t = 4s): max |d| 0 (0 required)</code><br/>
40
39
  <code>seam: setup pose at 714x1216, scale 0.9422: mean |d| 0.324 (&lt;= 1.0), 2 px over 40 (&lt;= 50), 0 px over 80 (reported)</code><br/>
41
- <code>pack: skeleton.png 1024x2048, 22 region(s), 56.3% covered, padding 2</code><br/>
40
+ <code>pack: skeleton.png 967x1338, 22 region(s), 91.2% covered, padding 2, page edges free</code><br/>
42
41
  <code>--seam silhouette</code> repaired the navy blobs the default rule leaves on the white
43
42
  blouse (recomposite error pixels 11,050 → 9,540); the default stays
44
43
  <code>near-white</code>, the reference implementation's rule, so the examples stay
45
44
  comparable with it. The loop is <code>spine-parts loop --palette</code>'s indexed APNG, 48 frames at
46
- 12 fps, 1,706,785 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
45
+ 12 fps, 1,706,520 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
47
46
  per channel over every frame (the lossless APNG beside it, the exactness record, is
48
- 13,645,519 bytes; the GIF, at the same error, 1,812,625). Frame 29 (2.417 s) is the
47
+ 13,645,350 bytes; the GIF, at the same error, 1,812,238). Frame 29 (2.417 s) is the
49
48
  closed eye: the blink holds for 0.084 s, one 12 fps frame rounded up, where the
50
49
  reference implementation held 0.04 s and no frame of its loop showed the eyes shut
51
50
  (issue #32); the painting is shown at half size, resampled and written
@@ -87,7 +86,7 @@ page** (issue #2):
87
86
 
88
87
  | path under `--out` | what |
89
88
  | --- | --- |
90
- | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack` and gated under both profiles |
89
+ | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack --page-edges free` and gated under `spine-html`, which holds every rule `spine` measures (on the demo, rigc 1.5.1 and 2.0.3: the 14 rules `validate --profile spine` measures are among the build's 23; 15 are not in `spine`) |
91
90
  | `parts/*.png`, `parts.json`, `recomposite_rig.png`, `recomposite_error_rig.png` | the loose parts, each cropped to its alpha box; the record of where every part came from, how many of its pixels were re-taken from the painting, and the recomposite's uncovered holes with their boxes; the flat stack of parts; its error map — red where no part covers a pixel the painting has, blue where a part covers it in the wrong colour |
92
91
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
93
92
  | `check/` | both gate files verbatim, the idle's frames and their `geometry.json` (skinned vertices per frame), `contact.png`, `motion_heat.png`, `check.json` |
@@ -99,12 +98,23 @@ artifact paths — here for `examples/sample`:
99
98
 
100
99
  ```
101
100
  build: PASS — the packed atlas is the artifact; parts/ and rig/ are the intermediates it was made from
102
- pack: skeleton.png 512x2048, 20 region(s), 49.7% covered, padding 2; page opaque 28.7% (alpha > 0) — spineboy yardstick 1024x256, 40 region(s), 45.8% opaque (alpha > 0), a reference and not a bar
101
+ pack: skeleton.png 479x1166, 20 region(s), 93.4% covered, padding 2, page edges free; page opaque 53.9% (alpha > 0) — spineboy yardstick 1024x256, 40 region(s), 45.8% opaque (alpha > 0), a reference and not a bar
103
102
  out/check/build/skeleton.json
104
103
  out/check/build/skeleton.atlas
105
104
  out/check/build/skeleton.png
106
105
  ```
107
106
 
107
+ The page's edges are free by default: rigc picks the least-area page the parts need,
108
+ not the next power of two. On the two examples that is 967x1338 and 479x1166, where a
109
+ power-of-two page is 1024x2048 and 512x2048, and covered goes from 56.3 % to 91.2 % and
110
+ from 49.7 % to 93.4 %. The cost is the one rigc's help states: *"region attachments
111
+ sampling within 1 LSB of the loose build rather than exactly"*. Measured on both
112
+ examples, every idle frame stays within 1 level of the power-of-two build's. The atlas says
113
+ `filter: Linear, Linear` and has no `repeat` line, so `--page-edges pot` is for a
114
+ consumer that mipmaps or repeats the page; it writes exactly the page builds wrote
115
+ before the flag existed. The measurements are in
116
+ [AUTHORING §5, *Page edges*](docs/AUTHORING.md#5-after-each-stage-what-to-read).
117
+
108
118
  The packer is spine-rigc's, and no other: a packed region is a lossless copy, and an
109
119
  atlas written by anything else would have no oracle behind it.
110
120
 
@@ -113,7 +123,7 @@ atlas written by anything else would have no oracle behind it.
113
123
  | stage | tool | what it owns |
114
124
  | --- | --- | --- |
115
125
  | painting + See-through layers → parts and specs | **spine-parts** | the merge of two runs, the measurements, the rig spec and motion spec |
116
- | specs → Spine skeleton data | **[spine-rigc](https://github.com/firejune/rigc)** | compile, the round trip through `spine-core`, the named assertions, the packer, the renderer |
126
+ | specs → Spine skeleton data | **[spine-rigc](https://github.com/firejune/rigc)** | compile, the gate (rigc's own validator in an install; the round trip through `spine-core` where the runtime is beside it, as in this repository's CI), the named assertions, the packer, the renderer |
117
127
  | skeleton data → a page | **[spine-html](https://github.com/firejune/spine-html)**, a sibling project | a DOM renderer; rigc's `spine-html` profile is its policy, and every build here is gated under that profile as well as under `spine` |
118
128
 
119
129
  spine-parts never writes Spine data itself. Everything on disk under `check/build/`
@@ -242,8 +252,8 @@ agent skill.
242
252
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
243
253
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
244
254
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
245
- | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared |
246
- | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json` |
255
+ | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's gate is green; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared |
256
+ | `check --rig --out [--parts]` | build packed, gated under `spine-html`, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json` |
247
257
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
248
258
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
249
259
 
@@ -308,13 +318,19 @@ the flag, puts 813 of `sample`'s 993 still-unprojected pixels and 879 of `demo`'
308
318
 
309
319
  [Bun](https://bun.sh) 1.2 or later. `npm install -g spine-parts` installs the
310
320
  `spine-parts` command; it hands off to Bun and says so in one sentence if Bun is not
311
- on `PATH`. spine-rigc comes with it as a dependency.
321
+ on `PATH`. spine-rigc comes with it as a dependency. No Spine runtime does: since
322
+ spine-rigc 2.0 the installed `rigc` gates every build with its own validator over the
323
+ compiled model document, and `rigc --version` says `entry: cli_core.ts —
324
+ @esotericsoftware/spine-core absent`. With `@esotericsoftware/spine-core` installed
325
+ beside it, the same `rigc` runs the spine-core round trip instead, as this repository's
326
+ selftest and CI do; `check.json`'s `rigc_entry` records which one gated a build.
312
327
 
313
328
  ## Licence posture
314
329
 
315
- spine-parts is MIT, and it depends on spine-rigc, which links Esoteric Software's
316
- `spine-core`: using what it produces in a product requires a Spine Editor licence, as
317
- any Spine data does. [NOTICE.md](NOTICE.md) records that chain and every other
330
+ spine-parts is MIT, and it depends on spine-rigc, whose output is Spine skeleton data:
331
+ using what it produces in a product requires a Spine Editor licence, as any Spine data
332
+ does. Neither package installs Esoteric Software's `spine-core` any more; this
333
+ repository uses it in development, for the round trip in its selftest and CI. [NOTICE.md](NOTICE.md) records that chain and every other
318
334
  third-party term this package touches, See-through's included.
319
335
 
320
336
  ## Development
package/cli.ts CHANGED
@@ -15,10 +15,9 @@ import { spawnSync } from 'node:child_process';
15
15
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
18
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
19
  import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
21
- import { findRigc, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
20
+ import { DEFAULT_PAGE_EDGES, findRigc, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
22
21
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
23
22
  import { type CharacterConfig, loadConfig, loadEarlyConfig } from './src/config.ts';
24
23
  import { PartsError, problemLine } from './src/errors.ts';
@@ -98,6 +97,7 @@ usage:
98
97
  printed when one was needed.
99
98
 
100
99
  spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
100
+ [--page-edges pot|free]
101
101
  Author the rig: unrotated bones at the config's landmarks (a chain makes
102
102
  <chain>0..n), a square lattice mesh over every part in config.meshes
103
103
  weighted by distance to its candidate bone segments, a region for every
@@ -110,10 +110,11 @@ usage:
110
110
  bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
111
111
  CONFIG_BLINK_GROUP_UNIQUE, before rigc starts. --parts is the
112
112
  directory holding parts.json and parts/<name>.png. The result is built
113
- through spine-rigc (profile spine-html, packed, then validated under
114
- profile spine) in a scratch directory first, and --out receives
115
- images/*.png, rig.json, motion.json and mesh_report.json only when both
116
- are green. Prints one line per mesh and the rigc gate lines.
113
+ through spine-rigc (profile spine-html, packed with --page-edges as for
114
+ check; rigc's build gates the compile and the packed pages on disk) in a
115
+ scratch directory first, and --out receives images/*.png, rig.json,
116
+ motion.json and mesh_report.json only when it is green. Prints one line
117
+ per mesh and the rigc gate lines.
117
118
  --idle-keys says where the idle's keys on a bone a mesh is weighted to go:
118
119
  ctl (the default) keys a same-origin <bone>_ctl parent instead, which
119
120
  passes A15_IDLE_NO_MESH_BONE_KEYS on any spine-rigc; direct keys the bone
@@ -121,18 +122,19 @@ usage:
121
122
  spine-rigc 1.3.0 or later and makes A15 a SKIP that prints its cost (the
122
123
  stage prints that SKIP line). The pose is the same to one level of float
123
124
  rounding, and so is the per-frame mesh work (AUTHORING §5).
124
- spine-parts check --rig <dir> --out <dir> [--parts <dir>]
125
+ spine-parts check --rig <dir> --out <dir> [--parts <dir>] [--page-edges pot|free]
125
126
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
126
127
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
127
128
  (with an "idle").
128
129
  ${PARTS_HOME_SENTENCE}.
129
130
  Both are only read. Into --out:
130
- build/ (rigc build --profile spine-html --pack: the packed atlas is the
131
- artifact), gate_spine-html.txt and gate_spine.txt (the gate lines
131
+ build/ (rigc build --profile spine-html --pack --page-edges <value>: the
132
+ packed atlas is the artifact), gate_spine-html.txt (the gate lines
132
133
  verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640
133
134
  --geometry: the frames and geometry.json, the skinned vertices and
134
135
  every bone's world transform per frame),
135
- contact.png, motion_heat.png and check.json. PASS needs both gates
136
+ contact.png, motion_heat.png and check.json (with rigc_entry, the rigc
137
+ entry that gated the build). PASS needs every gate summary
136
138
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
137
139
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
138
140
  frame at t = duration) at max |d| 0. Then six judgement lines, each in
@@ -163,6 +165,16 @@ usage:
163
165
  beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
164
166
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
165
167
  and the value required.
168
+ --page-edges is handed to rigc verbatim and defaults to ${DEFAULT_PAGE_EDGES}: the
169
+ least-area page the parts need (on the two public examples 967x1338 and
170
+ 479x1166, where pot writes 1024x2048 and 512x2048), at the cost rigc
171
+ states — region attachments sample within 1 LSB of the loose build
172
+ rather than exactly (measured on both examples: every idle frame within
173
+ 1 level of the pot build's; AUTHORING §5, Page edges). pot is a power of two on both edges, for a consumer
174
+ that mipmaps or repeats the page (the atlas declares filter Linear,
175
+ Linear and no repeat). rigc's pack line ends ", page edges free" under
176
+ free; a line that disagrees with the value passed, or a pot page that is
177
+ not a power of two, is refused, CHECK_PACK_PAGE_EDGES.
166
178
 
167
179
  spine-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
168
180
  Encode a frame set rigc render wrote (its --out directory, or the set
@@ -247,7 +259,7 @@ usage:
247
259
 
248
260
  spine-parts build --config <config.json> --source <painting.png> --full <dir|psd>
249
261
  --head <dir|psd> --out <dir> [--seam near-white|silhouette]
250
- [--project core|visible] [--loop]
262
+ [--project core|visible] [--page-edges pot|free] [--loop]
251
263
  assemble, then rig, then check, in one process, each stage's own lines
252
264
  printed under [assemble], [rig] and [check]; the first stage that refuses
253
265
  stops the build with its own FAIL lines. The config must already carry
@@ -263,7 +275,8 @@ usage:
263
275
  three artifact paths — skeleton .json, .atlas and the packed page: the
264
276
  packed atlas is the result, the loose parts are the intermediate it was
265
277
  made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
266
- ${DEFAULT_PROJECT_RULE} (both as for assemble).
278
+ ${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES}
279
+ (as for check; both packed builds, rig's gate and check's artifact, take it).
267
280
 
268
281
  spine-parts --version
269
282
  spine-parts --help
@@ -384,18 +397,14 @@ function cmdSheet(args: string[]): number {
384
397
  }
385
398
  }
386
399
 
387
- /** spine-rigc's own CLI, resolved the way this package resolves every other rigc path. */
388
- function rigcCli(): string {
389
- return fileURLToPath(import.meta.resolve('spine-rigc/cli.ts'));
400
+ /** `--page-edges`, or its default; a value spine-rigc does not take is a usage error naming the two it does. */
401
+ function pageEdgesOf(value: string | undefined): PageEdges | string {
402
+ const edges = value ?? DEFAULT_PAGE_EDGES;
403
+ return (PAGE_EDGES as readonly string[]).includes(edges) ? (edges as PageEdges) : `--page-edges ${edges}; one of ${PAGE_EDGES.join(', ')} is required`;
390
404
  }
391
405
 
392
- /** The rig stage's rigc: spine-rigc's cli.ts run by this Bun, as the stage has always gated. */
393
- function rigGateRunner(): RigcRunner {
394
- return (args) => {
395
- const r = spawnSync(process.execPath, [rigcCli(), ...args], { encoding: 'utf8', maxBuffer: 1 << 28 });
396
- if (r.error !== undefined) return { status: 127, out: `could not start ${rigcCli()}: ${r.error.message}` };
397
- return { status: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
398
- };
406
+ function isPageEdges(v: PageEdges | string): v is PageEdges {
407
+ return (PAGE_EDGES as readonly string[]).includes(v);
399
408
  }
400
409
 
401
410
  function cmdRig(args: string[]): number {
@@ -403,10 +412,11 @@ function cmdRig(args: string[]): number {
403
412
  let partsDir: string | null = null;
404
413
  let out: string | null = null;
405
414
  let idleKeys: string | null = null;
415
+ let pageEdges: string | null = null;
406
416
  for (let i = 0; i < args.length; i++) {
407
417
  const flag = args[i];
408
418
  const value = args[i + 1];
409
- if (!['--config', '--parts', '--out', '--idle-keys'].includes(flag)) return usage(`rig does not take "${flag}"`);
419
+ if (!['--config', '--parts', '--out', '--idle-keys', '--page-edges'].includes(flag)) return usage(`rig does not take "${flag}"`);
410
420
  if (value === undefined) return usage(`${flag} needs a value`);
411
421
  i++;
412
422
  if (flag === '--config') config = value;
@@ -414,6 +424,9 @@ function cmdRig(args: string[]): number {
414
424
  else if (flag === '--idle-keys') {
415
425
  if (idleKeys !== null) return usage('--idle-keys is given twice');
416
426
  idleKeys = value;
427
+ } else if (flag === '--page-edges') {
428
+ if (pageEdges !== null) return usage('--page-edges is given twice');
429
+ pageEdges = value;
417
430
  } else out = value;
418
431
  }
419
432
  if (config === null) return usage('rig needs --config <config.json>');
@@ -421,9 +434,17 @@ function cmdRig(args: string[]): number {
421
434
  if (out === null) return usage('rig needs --out <dir>');
422
435
  const keys = idleKeys ?? DEFAULT_IDLE_KEYS;
423
436
  if (!(IDLE_KEYS as readonly string[]).includes(keys)) return usage(`--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`);
437
+ const edges = pageEdgesOf(pageEdges ?? undefined);
438
+ if (!isPageEdges(edges)) return usage(edges);
439
+ let bin: string;
440
+ try {
441
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
442
+ } catch (err) {
443
+ return printRefusal(err);
444
+ }
424
445
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
425
446
  try {
426
- rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys }, rigGateRunner(), scratch, console.log);
447
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys, pageEdges: edges }, rigcRunner(bin), scratch, console.log);
427
448
  return EXIT_OK;
428
449
  } catch (err) {
429
450
  return printRefusal(err);
@@ -432,7 +453,16 @@ function cmdRig(args: string[]): number {
432
453
  }
433
454
  }
434
455
 
435
- /** The rigc process: `src/check.ts` is pure and takes the spawn from here. */
456
+ /**
457
+ * The rigc process, for every stage that runs rigc — `rig`'s gate, `check`, and
458
+ * both inside `build`: the `rigc` binary {@link findRigc} located, spawned as
459
+ * it is, so spine-rigc's own launcher (`bin/rigc.cjs`) chooses the entry —
460
+ * `cli.ts` and the spine-core round trip where the runtime resolves beside it,
461
+ * `cli_core.ts` and rigc's own validator where it does not. No stage runs a
462
+ * rigc source file by path: under spine-rigc 2.0, `cli.ts` by path is the
463
+ * spine-core entry whether or not spine-core is there. `src/` is pure and takes
464
+ * the spawn from here.
465
+ */
436
466
  function rigcRunner(bin: string): RigcRunner {
437
467
  return (args) => {
438
468
  const r = spawnSync(bin, [...args], { encoding: 'utf8', maxBuffer: 1 << 28 });
@@ -456,13 +486,15 @@ function flags(args: string[], known: readonly string[], command: string, option
456
486
  }
457
487
 
458
488
  function cmdCheck(args: string[]): number {
459
- const f = flags(args, ['--rig', '--out'], 'check', ['--parts']);
489
+ const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--page-edges']);
460
490
  if (typeof f === 'string') return usage(f);
461
491
  const rig = f.get('--rig') as string;
462
492
  const out = f.get('--out') as string;
493
+ const edges = pageEdgesOf(f.get('--page-edges'));
494
+ if (!isPageEdges(edges)) return usage(edges);
463
495
  try {
464
496
  const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
465
- const r = checkStage({ rig, parts: f.get('--parts') ?? rig, out }, rigcRunner(bin), bin, console.log);
497
+ const r = checkStage({ rig, parts: f.get('--parts') ?? rig, out, pageEdges: edges }, rigcRunner(bin), bin, console.log);
466
498
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
467
499
  } catch (err) {
468
500
  return printRefusal(err);
@@ -767,7 +799,7 @@ function cmdInputs(args: string[]): number {
767
799
  function cmdBuild(args: string[]): number {
768
800
  const flags = new Map<string, string>();
769
801
  let loop = false;
770
- const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project'];
802
+ const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges'];
771
803
  for (let i = 0; i < args.length; i++) {
772
804
  const flag = args[i];
773
805
  if (flag === '--loop') {
@@ -787,6 +819,8 @@ function cmdBuild(args: string[]): number {
787
819
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
788
820
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
789
821
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
822
+ const edges = pageEdgesOf(flags.get('--page-edges'));
823
+ if (!isPageEdges(edges)) return usage(edges);
790
824
  const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
791
825
  let bin: string;
792
826
  try {
@@ -797,8 +831,8 @@ function cmdBuild(args: string[]): number {
797
831
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-build-'));
798
832
  try {
799
833
  const r = build(
800
- { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop },
801
- { rig: rigGateRunner(), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
834
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges },
835
+ { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
802
836
  console.log,
803
837
  );
804
838
  return r.stoppedAt === null ? EXIT_OK : EXIT_REFUSED;
package/docs/AUTHORING.md CHANGED
@@ -18,7 +18,7 @@ into `examples/sample/inputs/`.
18
18
  | what | why | how |
19
19
  | --- | --- | --- |
20
20
  | [Bun](https://bun.sh) 1.2 or later | spine-parts and spine-rigc are Bun programs | `npm install -g spine-parts` installs the command; it says so if Bun is missing |
21
- | [spine-rigc](https://www.npmjs.com/package/spine-rigc) | compiles, gates, packs and renders every rig; spine-parts writes no Spine data itself | installed with spine-parts as a dependency (`node_modules/.bin/rigc`) |
21
+ | [spine-rigc](https://www.npmjs.com/package/spine-rigc) | compiles, gates, packs and renders every rig; spine-parts writes no Spine data itself | installed with spine-parts as a dependency (`node_modules/.bin/rigc`); with no Spine runtime beside it, it gates with its own validator, and `rigc --version` says `entry: cli_core.ts` |
22
22
  | [See-through](https://github.com/shitagaki-lab/see-through), somewhere | the layer decomposition is the input | any route in README *Getting See-through layers*; it runs twice per character, outside this tool |
23
23
  | a GPU, wherever See-through runs | See-through is a diffusion model | nothing in spine-parts itself uses a GPU |
24
24
 
@@ -266,7 +266,7 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
266
266
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
267
267
  | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`; and look at `render/recomposite_error_rig.png` | on the examples: `sample` 0.84 / 98.0 % / 4,512 / 1,185, 259 holes, the largest 94 px; `demo` 2.39 / 95.8 % / 11,050 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
268
268
  | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head` |
269
- | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
269
+ | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages) | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
270
270
  | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the six judgement lines and `RECOMPOSITE_HOLES` (§7), `check.json` | `check: PASS`, and a judgement line SKIP only where the character lacks what it reads | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
271
271
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
272
272
 
@@ -279,9 +279,11 @@ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
279
279
  (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
280
280
  print their palette error, per channel over R, G and B of every frame (and alpha's
281
281
  max for the APNG); a figure is a measurement of the file, not a bar. On the demo:
282
- 13,645,519 B lossless, 1,706,785 B indexed and 1,812,625 B GIF, both palette files at
282
+ 13,645,350 B lossless, 1,706,520 B indexed and 1,812,238 B GIF, both palette files at
283
283
  max 57, mean 1.601 — the two share one quantiser, so their error is the same by
284
- construction and the size is the difference. The loop shows the blink closed: the
284
+ construction and the size is the difference. That is `--seam silhouette` (the README's
285
+ animation); with `--page-edges pot` the same build writes 13,645,519, 1,706,785 and
286
+ 1,812,625 B at the same error. The loop shows the blink closed: the
285
287
  eyes' hold is at least one 12 fps frame (below), so whatever `motion.blink.t` is, one
286
288
  idle frame lands inside it, and `check.json`'s `BLINK_NO_HOLE.idle_frames_closed`
287
289
  names it — `[29]` on both examples.
@@ -294,6 +296,38 @@ outside the figure. On the demo it lowered recomposite error pixels from 11,050
294
296
  9,540 and changed no check bar; on the sample it changed nothing. The default stays
295
297
  the reference's so the examples stay comparable with it.
296
298
 
299
+ **Page edges.** `rig`, `check` and `build` pack through `rigc build --pack
300
+ --page-edges <v>`, and hand the value to rigc verbatim. The default is `free`,
301
+ the least-area page the parts need. `--page-edges pot` is the opt-out: a power of
302
+ two on both edges, which is the page every build wrote before this flag. The pack
303
+ line says which one ran, because rigc ends it with `, page edges free` under `free`.
304
+ Measured on the two examples by `tools/atlas_population.ts` with rigc 1.5.1, and again
305
+ with rigc 2.0.3, which reproduced every figure in both tables and the one check figure
306
+ below:
307
+
308
+ | build | page | covered | page opaque | page / figure |
309
+ | --- | --- | --- | --- | --- |
310
+ | demo, `pot` | 1024x2048 | 56.3 % | 36.8 % | 2.651 |
311
+ | demo, `free` | 967x1338 | 91.2 % | 59.7 % | 1.636 |
312
+ | sample, `pot` | 512x2048 | 49.7 % | 28.7 % | 3.294 |
313
+ | sample, `free` | 479x1166 | 93.4 % | 53.9 % | 1.755 |
314
+
315
+ rigc's help states the cost: *"a smaller page, at the cost of region attachments
316
+ sampling within 1 LSB of the loose build rather than exactly"*. On these builds
317
+ every one of the 49 idle frames differs from the `pot` build, by at most 1 level
318
+ in any channel:
319
+
320
+ | example | idle frames that differ | pixels that differ (all 49 frames) | max \|d\| |
321
+ | --- | --- | --- | --- |
322
+ | demo | 49 of 49 | 2,373 | 1 |
323
+ | sample | 49 of 49 | 651 | 1 |
324
+
325
+ Only one check figure moved. It has no bar: sample's reported screen-space face
326
+ mean in `STILL_REGIONS_DARK`, 11.475 under `pot` and 11.472 under `free`. Every
327
+ barred figure and every status is the same under both. The atlas rigc writes says
328
+ `filter: Linear, Linear` and has no `repeat` line, so choose `pot` for a consumer
329
+ that mipmaps or repeats the page.
330
+
297
331
  `--project core` (the default) is the reference's projection rule: a layer takes the
298
332
  painting's pixel only inside its top-most `alpha >= 250` area eroded by a 5x5 square,
299
333
  so a part a few pixels wide (a lash, a brow, an iris) takes none. `--project visible`
@@ -325,8 +359,8 @@ differ by at most one level in a channel. The default stays `ctl`: the examples'
325
359
  `expected/rig.json` and `motion.json` are the reference's output, and the
326
360
  declaration is in the rig spec only, so `rigc validate <build> --profile spine-html`
327
361
  run on a `direct` build (it has no rig spec to read) refuses A15 once per keyed
328
- bone. `check` validates the build under `spine` only, where A15 is not in the
329
- profile, and is green on both.
362
+ bone. No stage runs `validate`: the gate is `rigc build`, which reads the rig spec
363
+ and its declaration, and is green on both (`RG19`).
330
364
 
331
365
  **Patching a hole no layer holds.** When an `uncovered hole` stays large after the
332
366
  plan is right — both See-through runs dropped a piece of the figure, red in
@@ -556,7 +590,8 @@ See-through with another seed.
556
590
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
557
591
  | `CHECK_INPUT_PRESENT` on `…/parts.json` | `--parts` named a directory without `parts.json` — most often `parts/` itself. The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json` | `--parts` — the directory above `parts/` |
558
592
  | `CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by) | re-run rig (`build` does both) |
559
- | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^1.5.1), or put a newer `rigc` first on `PATH` |
593
+ | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^2.0.3), or put a newer `rigc` first on `PATH` |
594
+ | `CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
560
595
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
561
596
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
562
597
  | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted) | a track whose last key is not its first |
@@ -569,6 +604,8 @@ See-through with another seed.
569
604
  | `CHECK_TEXTURE_STRETCH` | a mesh triangle's edge, in some idle frame, is more than 1.926544 times its rest length or less than 1/1.926544 of it (the mesh, triangle, its three vertices, the edge and the frame are named); or a rest edge has length 0 | the chain tracks' `amps` on the bones that mesh is weighted to (the stretch grows with them), or the mesh's `segments` — a vertex blending two bones that swing against each other |
570
605
  | `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR`, `CHECK_GEOMETRY_FILE` | rigc's render is not what its `frames.json` says, or its `geometry.json` is not a whole `rigc-geometry/1` export of the frames beside it (a frame count, a mesh whose vertex count differs from its rest entry; for the face half, another viewport than `frames.json`'s, other frame indices, the head bone or a feature's bone missing from a frame, a non-finite transform or time) | a rigc problem; report it |
571
606
  | `LOOP_ENCODE` | the loop encoder refused a frame (translucent pixel in a GIF, a size change) | the frames; for a translucent frame write the lossless or the indexed APNG, which keep alpha |
607
+ | `CHECK_PACK_LINE_READS` | rigc printed a `pack:` line that is not `pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>` with `, page edges free` after it or nothing; the line is quoted | a rigc this package does not know the output of; report it, with `rigc --version` |
608
+ | `CHECK_PACK_PAGE_EDGES` | the pack line disagrees with the `--page-edges` the build was run with — its `, page edges free` suffix is there under `pot` or missing under `free`, or a `pot` page is not a power of two on both edges | a rigc problem; report it, with `rigc --version` |
572
609
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
573
610
 
574
611
  ## 7. The bars `check` enforces, and what only an eye answers today
@@ -577,14 +614,17 @@ See-through with another seed.
577
614
  implementation's bars):
578
615
 
579
616
  - `gate_spine-html.txt`: every summary line `(N passed, 0 failed)` — the packed build
580
- under the `spine-html` profile;
581
- - `gate_spine.txt`: the same, `rigc validate` of that build under the `spine` profile;
617
+ under the `spine-html` profile, gated once over the compile and once over the packed
618
+ pages on disk. There is no `spine` gate of its own: `spine-html` holds every rule
619
+ `spine` measures (on the demo, rigc 1.5.1 and 2.0.3: `validate --profile spine`'s 14
620
+ measured rules are all among the build's 23, the 15 others not in `spine`; `CH09`
621
+ holds it on every fetched example);
582
622
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
583
623
  max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40;
584
624
  - **loop**: idle frame 0 against the frame at `t = duration`: max |d| exactly 0.
585
625
 
586
- On the examples: `sample` 23/23 and 14/14, seam 0.207 with 0 pixels over 40, loop 0;
587
- `demo` (default rule) 23/23 and 14/14, seam 0.326 with 2 pixels over 40, loop 0.
626
+ On the examples: `sample` 23/23, seam 0.207 with 0 pixels over 40, loop 0;
627
+ `demo` (default rule) 23/23, seam 0.326 with 2 pixels over 40, loop 0.
588
628
 
589
629
  A green gate cannot see a wrong animation, so `check` also writes six **judgement
590
630
  lines** (issue #11; `TEXTURE_STRETCH`, issue #31), each a key of `check.json` and a console line
@@ -676,7 +716,8 @@ What each figure is, and is not:
676
716
  - **The face is measured in the head's frame, and its bar is the model's (issue #33).**
677
717
  Until #33 the face half read the screen-space heat map, where the head's intended
678
718
  roll lights the outline on both examples (16.988 and 11.475, the figures now
679
- reported as `screen_heat_mean`), so its ceiling was twice the worse example,
719
+ reported as `screen_heat_mean`; sample's reads 11.472 since the page edges
720
+ default to `free`, *Page edges* in §5), so its ceiling was twice the worse example,
680
721
  33.976 — a ceiling a smooth fixture face sliding two rig pixels stayed under (24.8;
681
722
  eight pixels measured 85.5). The judgement means *nothing moves on the face that
682
723
  the head does not move*, so the heat is now taken after each frame's face region is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "AI-authored Spine 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through spine-rigc before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,9 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "ag-psd": "^31.0.2",
60
- "spine-rigc": "^1.5.1"
60
+ "spine-rigc": "^2.0.3"
61
61
  },
62
62
  "devDependencies": {
63
+ "@esotericsoftware/spine-core": "4.3.13",
63
64
  "@types/bun": "^1.4.2",
64
65
  "eslint": "^10.11.0",
65
66
  "typescript": "^5.9.3",
@@ -15,9 +15,11 @@ stage writes are the whole interface.
15
15
 
16
16
  ## Non-negotiables
17
17
 
18
- - **spine-rigc's round trip is never bypassed.** spine-parts writes no Spine data
19
- itself; `rig` writes only after rigc's build and validate are green, and `build`'s
18
+ - **spine-rigc's gate is never bypassed.** spine-parts writes no Spine data
19
+ itself; `rig` writes only after rigc's `build` gate is green, and `build`'s
20
20
  artifact is what rigc wrote. There is no flag that skips it, and none may be added.
21
+ `check.json`'s `rigc_entry` names which gate ran: rigc's own validator in an
22
+ install, the spine-core round trip where the runtime is installed beside rigc.
21
23
  - **No value is invented.** A missing config field is a refusal naming it. Take a
22
24
  value from the command that proposes it (`propose --head-box`,
23
25
  `assemble --propose-plan`, `propose`) or decide it yourself — never guess one
package/src/build.ts CHANGED
@@ -44,7 +44,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:
44
44
  import { dirname, join } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
46
  import { assemble, type AssembleResult, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
47
- import { type CheckReport, JUDGEMENT_LINES, type JudgementLine, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
47
+ import { causeLines, type CheckReport, type PackLine, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
48
48
  import { loadConfig, loadEarlyConfig } from './config.ts';
49
49
  import { PartsError, type Problem, problemLine } from './errors.ts';
50
50
  import { encodeGif } from './gif.ts';
@@ -162,7 +162,12 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
162
162
  interface GateRun {
163
163
  label: string;
164
164
  status: number;
165
- /** rigc's FAIL lines, its assertion summary and the one SKIP this stage's own declaration causes, as it printed them. */
165
+ /**
166
+ * rigc's FAIL lines, its assertion summary, the core entry's `here:` line and
167
+ * the one SKIP this stage's own declaration causes, as it printed them — or,
168
+ * for a run that exited non-zero with none of those, the lines
169
+ * {@link causeLines} says name why.
170
+ */
166
171
  lines: string[];
167
172
  }
168
173
 
@@ -175,31 +180,33 @@ interface GateRun {
175
180
  */
176
181
  const DECLARED_SKIP = /^ {2}SKIP {2}A15_IDLE_NO_MESH_BONE_KEYS: declared by the rig/;
177
182
 
183
+ /** The core entry's line after its summary (spine-rigc 2.0.0 and later): which rules ran, and that the round trip did not. */
184
+ const HERE_LINE = /^ {2}\.\. {4}here: /;
185
+
178
186
  function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
179
187
  const r = rigc(args);
180
- const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l));
181
- return { label, status: r.status, lines };
188
+ const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l) || HERE_LINE.test(l));
189
+ const failed = lines.some((l) => /^ {2}FAIL {2}/.test(l) || /^rigc compile error/.test(l));
190
+ return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines };
182
191
  }
183
192
 
184
193
  /**
185
- * spine-rigc's round trip over the rig in a scratch directory: `build` under
186
- * the spine-html profile with `--pack`, then `validate` of that build under
187
- * the spine profile. The files are staged exactly as `--out` will receive
188
- * them, so what passed is what is written. The scratch directory is the
189
- * caller's, and is emptied here before and after.
194
+ * spine-rigc's gate over the rig in a scratch directory: `build` under the
195
+ * spine-html profile with `--pack --page-edges <edges>`, which runs the gate
196
+ * once over the compile and once over the packed pages on disk. There is no
197
+ * second `validate --profile spine` run: spine-html holds every rule spine
198
+ * measures (selftest `CH09`). The files are staged exactly as `--out` will
199
+ * receive them, so what passed is what is written. The scratch directory is
200
+ * the caller's, and is emptied here before and after.
190
201
  */
191
- function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string): GateRun[] {
202
+ function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string, edges: PageEdges): GateRun[] {
192
203
  rmSync(scratch, { recursive: true, force: true });
193
204
  try {
194
205
  mkdirSync(join(scratch, 'images'), { recursive: true });
195
206
  for (const [file, img] of out.images) writeFileSync(join(scratch, 'images', file), encodePngBytes(img));
196
207
  for (const [file, text] of texts) writeFileSync(join(scratch, file), text);
197
208
  const build = join(scratch, 'build');
198
- const html = gateRun('build --profile spine-html --pack', rigc, [
199
- 'build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, '--profile', 'spine-html', '--pack',
200
- ]);
201
- if (html.status !== 0) return [html];
202
- return [html, gateRun('validate --profile spine', rigc, ['validate', build, '--profile', 'spine'])];
209
+ return [gateRun(packedBuildLabel(edges), rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...packedBuildArgs(edges)])];
203
210
  } finally {
204
211
  rmSync(scratch, { recursive: true, force: true });
205
212
  }
@@ -212,9 +219,11 @@ export interface RigStageInput {
212
219
  out: string;
213
220
  /** Where the idle's keys on mesh-driving bones go; `ctl` when absent. See `IDLE_KEYS` in `src/rig.ts`. */
214
221
  idleKeys?: IdleKeys;
222
+ /** The gate build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. The pack is scratch here; the gate is what it is for. */
223
+ pageEdges?: PageEdges;
215
224
  }
216
225
 
217
- /** Author the rig, gate it through rigc in `scratch`, and write `out` only when both gates are green. */
226
+ /** Author the rig, gate it through rigc in `scratch`, and write `out` only when the gate is green. */
218
227
  export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log): RigOutput {
219
228
  const cfg = loadConfig(input.config);
220
229
  const parts = readParts(join(input.parts, 'parts.json'));
@@ -249,7 +258,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
249
258
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
250
259
  : ` idle keys direct: ${rig.meshKeyed.length} mesh-driving bone(s) keyed in place, ${rig.rig.invariants === undefined ? 'so no invariants.idleDrivesMeshes is declared (it would switch nothing off)' : 'invariants.idleDrivesMeshes declared'}`,
251
260
  );
252
- const gate = gateThroughRigc(rig, texts, rigc, scratch);
261
+ const gate = gateThroughRigc(rig, texts, rigc, scratch, input.pageEdges ?? DEFAULT_PAGE_EDGES);
253
262
  for (const g of gate) {
254
263
  log(` rigc ${g.label}: exit ${g.status}`);
255
264
  for (const l of g.lines) log(` ${l.trim()}`);
@@ -259,7 +268,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
259
268
  const problems: Problem[] = red.map((g) => ({
260
269
  code: 'RIG_RIGC_GREEN',
261
270
  object: `rigc ${g.label}`,
262
- detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}; exit 0 is required before anything is written, and nothing was`,
271
+ detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}; exit 0 is required before anything is written, and nothing was`,
263
272
  }));
264
273
  throw new PartsError(problems);
265
274
  }
@@ -280,6 +289,8 @@ export interface CheckStageInput {
280
289
  /** The directory holding parts.json and parts/; the rig directory when they sit there. */
281
290
  parts: string;
282
291
  out: string;
292
+ /** The packed build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. */
293
+ pageEdges?: PageEdges;
283
294
  }
284
295
 
285
296
  /** The pack line as it is printed: rigc's own, then the page's opaque share beside the spineboy yardstick. */
@@ -314,12 +325,13 @@ export function judgementLine(name: string, line: JudgementLine | ReportedLine):
314
325
  export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string, log: Log): CheckReport {
315
326
  const v = rigc(['--version']);
316
327
  log(`spine-parts check: ${input.rig} -> ${input.out}`);
317
- log(` rigc ${v.out.trim().split('\n')[0]} at ${bin}`);
318
- const r = runCheck(input.rig, input.out, rigc, input.parts);
319
- log(' gate spine-html (rigc build --profile spine-html --pack), verbatim:');
328
+ const versionLines = v.out.trim().split('\n');
329
+ const entryLine = versionLines.find((l) => l.startsWith('entry:'));
330
+ log(` rigc ${versionLines[0]} at ${bin}${entryLine === undefined ? '' : `; ${entryLine}`}`);
331
+ const edges = input.pageEdges ?? DEFAULT_PAGE_EDGES;
332
+ const r = runCheck(input.rig, input.out, rigc, input.parts, edges);
333
+ log(` gate spine-html (rigc ${packedBuildLabel(edges)}), verbatim:`);
320
334
  for (const l of r.gateHtml) log(l);
321
- log(' gate spine (rigc validate build --profile spine), verbatim:');
322
- for (const l of r.gateSpine) log(l);
323
335
  for (const l of packLines(r)) log(` ${l}`);
324
336
  const fig = r.figures;
325
337
  log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
@@ -329,7 +341,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
329
341
  );
330
342
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
331
343
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
332
- log(` gates: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'}, spine ${fig.gate_spine_green ? 'green' : 'RED'}`);
344
+ log(` gate: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'} (${fig.rigc_entry.entry}${fig.rigc_entry.spine_core === null ? ', rigc\'s own validator' : `, the spine-core ${fig.rigc_entry.spine_core} round trip`})`);
333
345
  log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/, ${join(input.out, 'idle_frames')}/`);
334
346
  for (const p of r.problems) log(` FAIL ${problemLine(p)}`);
335
347
  log(fig.PASS ? 'check: PASS' : `check: FAIL — ${r.problems.length} bar(s) not met`);
@@ -427,9 +439,16 @@ export interface BuildInput {
427
439
  seam: SeamRule;
428
440
  project: ProjectRule;
429
441
  loop: boolean;
442
+ /** `--page-edges` for both packed builds, the rig stage's gate and the check's artifact. */
443
+ pageEdges: PageEdges;
430
444
  }
431
445
 
432
- /** The two rigc processes, as each command runs its own: `rig` gates through one, `check` measures through the other. */
446
+ /**
447
+ * The rigc process for each stage: `rig` gates through one, `check` measures
448
+ * through the other. `cli.ts` hands both the same runner, the `rigc` binary
449
+ * spine-rigc's launcher answers for; they are two fields so a caller can tell
450
+ * the stages' calls apart (the selftest counts them).
451
+ */
433
452
  export interface BuildRunners {
434
453
  rig: RigcRunner;
435
454
  check: RigcRunner;
@@ -452,20 +471,28 @@ export interface BuildResult {
452
471
  artifact: string[];
453
472
  }
454
473
 
474
+ /**
475
+ * rigc's own record of the compiled rig, which `build` writes beside the
476
+ * skeleton JSON and atlas (spine-rigc's AUTHORING, the `--out` row; written
477
+ * since 1.6): what rigc's posing core reads, not a Spine file, so it is not the
478
+ * artifact and is not counted as a second skeleton JSON.
479
+ */
480
+ export const RIGC_MODEL_DOCUMENT = 'skeleton.model.json';
481
+
455
482
  /** The three artifact files of a packed build, each refused by name when absent. */
456
- function artifactPaths(buildDir: string, report: CheckReport): string[] {
483
+ export function artifactPaths(buildDir: string, pack: readonly PackLine[]): string[] {
457
484
  const problems: Problem[] = [];
458
485
  const names = existsSync(buildDir) ? readdirSync(buildDir).sort() : [];
459
- const json = names.filter((n) => n.endsWith('.json'));
486
+ const json = names.filter((n) => n.endsWith('.json') && n !== RIGC_MODEL_DOCUMENT);
460
487
  const atlas = names.filter((n) => n.endsWith('.atlas'));
461
- if (json.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} skeleton JSON`, detail: `holds ${json.length} .json file(s) [${json.join(', ')}]; the packed build writes exactly one` });
488
+ if (json.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} skeleton JSON`, detail: `holds ${json.length} .json file(s) [${json.join(', ')}]; the packed build writes exactly one besides rigc's ${RIGC_MODEL_DOCUMENT}` });
462
489
  if (atlas.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} atlas`, detail: `holds ${atlas.length} .atlas file(s) [${atlas.join(', ')}]; the packed build writes exactly one` });
463
- if (report.pack.length === 0) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: 'rigc printed no pack line, so no packed page is named; the build runs with --pack and must print one' });
464
- for (const p of report.pack) {
490
+ if (pack.length === 0) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: 'rigc printed no pack line, so no packed page is named; the build runs with --pack and must print one' });
491
+ for (const p of pack) {
465
492
  if (!names.includes(p.page)) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: `the pack line names ${p.page}, which is not on disk` });
466
493
  }
467
494
  if (problems.length > 0) throw new PartsError(problems);
468
- return [join(buildDir, json[0]), join(buildDir, atlas[0]), ...report.pack.map((p) => join(buildDir, p.page))];
495
+ return [join(buildDir, json[0]), join(buildDir, atlas[0]), ...pack.map((p) => join(buildDir, p.page))];
469
496
  }
470
497
 
471
498
  /**
@@ -487,7 +514,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
487
514
  const out = input.out;
488
515
  mkdirSync(out, { recursive: true });
489
516
  for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
490
- log(`spine-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}${input.loop ? ', with the idle loop' : ''}`);
517
+ log(`spine-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}${input.loop ? ', with the idle loop' : ''}`);
491
518
 
492
519
  try {
493
520
  // build runs rig next, which needs the whole config, so the full loader is
@@ -505,14 +532,14 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
505
532
  }
506
533
 
507
534
  try {
508
- rigStage({ config: input.config, parts: out, out: join(out, 'rig') }, run.rig, run.scratch, prefixed('rig'));
535
+ rigStage({ config: input.config, parts: out, out: join(out, 'rig'), pageEdges: input.pageEdges }, run.rig, run.scratch, prefixed('rig'));
509
536
  } catch (err) {
510
537
  return refused('rig', err);
511
538
  }
512
539
 
513
540
  let report: CheckReport;
514
541
  try {
515
- report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check') }, run.check, run.checkBin, prefixed('check'));
542
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges }, run.check, run.checkBin, prefixed('check'));
516
543
  } catch (err) {
517
544
  return refused('check', err);
518
545
  }
@@ -537,7 +564,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
537
564
 
538
565
  let artifact: string[];
539
566
  try {
540
- artifact = artifactPaths(join(out, 'check', 'build'), report);
567
+ artifact = artifactPaths(join(out, 'check', 'build'), report.pack);
541
568
  } catch (err) {
542
569
  return refused('artifact', err, report);
543
570
  }
package/src/check.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  *
7
7
  * ⭐ **This is the one stage that drives a subprocess, and it does not own the
8
8
  * spawn.** Every rigc step — build, validate, render — is spine-rigc's own CLI,
9
- * never a re-implementation of it, because rigc's round trip through
10
- * `spine-core` is the only oracle behind a skeleton (CLAUDE.md, "spine-rigc's
11
- * validation is not optional here either"). But `src/` is held to no child
9
+ * never a re-implementation of it, because rigc's gate is the only oracle
10
+ * behind a skeleton (CLAUDE.md, "spine-rigc's gate is not optional here
11
+ * either"): the spine-core round trip where the runtime resolves beside rigc,
12
+ * rigc's own validator where it does not, and `check.json`'s `rigc_entry` says
13
+ * which ({@link readRigcEntry}). But `src/` is held to no child
12
14
  * processes (`TY06`, with `src/comfy/` its one named exception), so the process
13
15
  * is injected: `runCheck` takes a {@link RigcRunner}, `cli.ts` hands it one
14
16
  * that spawns the binary {@link findRigc} located, and the selftest hands it
@@ -23,9 +25,8 @@
23
25
  *
24
26
  * | output | what |
25
27
  * | --- | --- |
26
- * | `build/` | `rigc build --profile spine-html --pack` — the packed atlas is the final artifact |
27
- * | `gate_spine-html.txt` | that build's gate lines, verbatim |
28
- * | `gate_spine.txt` | `rigc validate build/ --profile spine`'s gate lines, verbatim |
28
+ * | `build/` | `rigc build --profile spine-html --pack --page-edges free` (or `pot`, when the caller names it; {@link PAGE_EDGES}) — the packed atlas is the final artifact |
29
+ * | `gate_spine-html.txt` | that build's gate lines, verbatim — the one gate: rigc's `build` runs it once over the compile and once over the packed pages on disk, and `spine-html` holds every rule `spine` measures (selftest `CH09`) |
29
30
  * | `idle_frames/` | `rigc render --animation idle --fps 12 --max 640 --geometry`: `frames.json` + `idle/f*.png` + `idle/geometry.json` (each mesh's skinned vertices, which `TEXTURE_STRETCH` reads, and every bone's world transform, which the face half of `STILL_REGIONS_DARK` reads) |
30
31
  * | `contact.png` | rigc's own contact sheet of that render, copied out |
31
32
  * | `motion_heat.png` | frame 0 in grey with each pixel's largest change across the idle in red |
@@ -184,6 +185,40 @@ export const GEOMETRY_SPEC = 'rigc-geometry/1';
184
185
  /** Issue #2's yardstick, measured on the Spine example export `spineboy.png` (alpha > 0). */
185
186
  export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha > 0)';
186
187
 
188
+ /**
189
+ * What a packed page's edges may be: spine-rigc's `build --pack --page-edges`,
190
+ * handed to it verbatim (the flag is in spine-rigc's CLI since 1.4.0, the
191
+ * floor `check` already requires; 1.5.0 made `free` search every width).
192
+ * `pot` is a power of two on both edges; `free` is the least-area page the
193
+ * placement needs, at the cost rigc's help states: region attachments sample
194
+ * within 1 LSB of the loose build rather than exactly.
195
+ *
196
+ * `free` is the default here although rigc's own default is `pot`: the packed
197
+ * page is this package's final artifact, and on the two public examples `free`
198
+ * takes the page from 1024x2048 to 967x1338 (covered 56.3 % -> 91.2 %) and
199
+ * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1 and 2.0.3 alike. The cost was
200
+ * measured on the same two builds. Each of the 49 idle frames differs from the
201
+ * `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
202
+ * px over all 49). On sample that moves STILL_REGIONS_DARK's reported
203
+ * screen-space face mean from 11.475 to 11.472; no bar or status moves. The
204
+ * atlas rigc writes says `filter: Linear, Linear` and has no `repeat` line, so
205
+ * a consumer that mipmaps or repeats the page itself is the one that wants
206
+ * `pot`. AUTHORING §5, *Page edges*, holds the tables.
207
+ */
208
+ export const PAGE_EDGES = ['pot', 'free'] as const;
209
+ export type PageEdges = (typeof PAGE_EDGES)[number];
210
+ export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
211
+
212
+ /** The packed build's rigc arguments after `--out <dir>`, as every stage that packs passes them. */
213
+ export function packedBuildArgs(edges: PageEdges): string[] {
214
+ return ['--profile', 'spine-html', '--pack', '--page-edges', edges];
215
+ }
216
+
217
+ /** The packed build as a label: the rigc command line that was run, after `rigc`. */
218
+ export function packedBuildLabel(edges: PageEdges): string {
219
+ return `build ${packedBuildArgs(edges).join(' ')}`;
220
+ }
221
+
187
222
  // ---------------------------------------------------------------------------
188
223
  // the rigc process, injected
189
224
  // ---------------------------------------------------------------------------
@@ -197,6 +232,81 @@ export interface RigcCall {
197
232
 
198
233
  export type RigcRunner = (args: readonly string[]) => RigcCall;
199
234
 
235
+ /** A line in which a rigc run that stopped says why: Bun's own `error: …`, rigc's `rigc <command>: …` refusals, a `usage: …` line. */
236
+ const CAUSE = /^(error: |rigc[\w -]*: |usage: )/;
237
+
238
+ /**
239
+ * What a rigc run that exited non-zero said about why, for a refusal whose own
240
+ * filter (FAIL lines, the assertion summary) matched nothing: every line that
241
+ * names a cause, or, when none does, the last three non-empty lines. Measured
242
+ * against spine-rigc 2.0.3: its `cli.ts` run by path without
243
+ * `@esotericsoftware/spine-core` beside it dies at import with one
244
+ * `error: Cannot find module '@esotericsoftware/spine-core' from …` line, and its
245
+ * core entry refuses `validate` with one `rigc validate: …` line, neither of
246
+ * which a gate filter reads. The last lines and not the first are the fallback
247
+ * because `rigc build` prints its header and inputs before it can stop. Empty
248
+ * only when the run printed nothing at all.
249
+ */
250
+ export function causeLines(out: string): string[] {
251
+ const lines = out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
252
+ const named = lines.filter((l) => CAUSE.test(l));
253
+ return named.length > 0 ? named : lines.slice(-3);
254
+ }
255
+
256
+ /** The two entries spine-rigc's launcher (`bin/rigc.cjs`, spine-rigc 2.0.0 and later) runs. */
257
+ export const RIGC_ENTRIES = ['cli.ts', 'cli_core.ts'] as const;
258
+ export type RigcEntry = (typeof RIGC_ENTRIES)[number];
259
+
260
+ /** The first spine-rigc whose `--version` names the entry that ran (its launcher's stderr line; spine-rigc 2.0.0's changelog, issue #1061 there). */
261
+ export const RIGC_ENTRY_VERSION = '2.0.0';
262
+
263
+ /**
264
+ * Which rigc entry gated the build, as `check.json` records it: `cli.ts`, the
265
+ * spine-core round trip, with the runtime's version; `cli_core.ts`, rigc's own
266
+ * validator over its model document, with no runtime (`spine_core` null).
267
+ */
268
+ export interface RigcEntryRecord {
269
+ entry: RigcEntry;
270
+ spine_core: string | null;
271
+ }
272
+
273
+ /** The launcher's two `entry:` lines, whole, as spine-rigc 2.0.3's `bin/rigc.cjs` writes them. */
274
+ const ENTRY_FULL = /^entry: cli\.ts — @esotericsoftware\/spine-core (\S+) present$/;
275
+ const ENTRY_CORE = /^entry: cli_core\.ts — @esotericsoftware\/spine-core absent — /;
276
+
277
+ /**
278
+ * The entry a `rigc --version` run names. A run with no `entry:` line is a
279
+ * rigc older than {@link RIGC_ENTRY_VERSION}, or not rigc's launcher, and is
280
+ * refused `CHECK_RIGC_ENTRY`; an `entry:` line in neither form above is refused
281
+ * `CHECK_RIGC_ENTRY_READS`, naming the line — a third entry is not read as
282
+ * either of the two.
283
+ */
284
+ export function readRigcEntry(versionOut: string): RigcEntryRecord {
285
+ const lines = versionOut.split('\n').map((l) => l.trim());
286
+ const line = lines.find((l) => l.startsWith('entry:'));
287
+ if (line === undefined) {
288
+ refuseIfAny([
289
+ {
290
+ code: 'CHECK_RIGC_ENTRY',
291
+ object: '`rigc --version`',
292
+ detail: `printed ${JSON.stringify(versionOut.trim())} and no \`entry:\` line; spine-rigc ${RIGC_ENTRY_VERSION} or later is required — its launcher names the entry that ran, and check.json records which one gated the build (\`bun install\` puts this package's own spine-rigc at node_modules/.bin/rigc)`,
293
+ },
294
+ ]);
295
+ }
296
+ const at = line as string;
297
+ const full = ENTRY_FULL.exec(at);
298
+ if (full !== null) return { entry: 'cli.ts', spine_core: full[1] };
299
+ if (ENTRY_CORE.test(at)) return { entry: 'cli_core.ts', spine_core: null };
300
+ refuseIfAny([
301
+ {
302
+ code: 'CHECK_RIGC_ENTRY_READS',
303
+ object: `rigc's entry line ${JSON.stringify(at)}`,
304
+ detail: 'reads as neither "entry: cli.ts — @esotericsoftware/spine-core <version> present" nor "entry: cli_core.ts — @esotericsoftware/spine-core absent — …"; one of the two is required',
305
+ },
306
+ ]);
307
+ throw new Error('unreachable');
308
+ }
309
+
200
310
  /**
201
311
  * Where the `rigc` binary is: `node_modules/.bin/rigc` in `from` or any
202
312
  * directory above it (a clone has it beside the package; an install has it in
@@ -393,14 +503,34 @@ export function readCheckInputs(rigDir: string, partsHome: string = rigDir): Che
393
503
  // gate lines
394
504
  // ---------------------------------------------------------------------------
395
505
 
396
- /** `check_rig.py`'s filter for the build: summary, stats and pack lines, and anything that failed. */
506
+ /**
507
+ * The `here:` line spine-rigc's core entry (`cli_core.ts`, 2.0.0 and later)
508
+ * prints after its assertion summary, saying which rules ran and that the
509
+ * spine-core round trip did not; `cli.ts` prints none.
510
+ */
511
+ const HERE_LINE = /^\.\.\s+here: /;
512
+
513
+ /**
514
+ * `check_rig.py`'s filter for the build: summary, stats and pack lines, and
515
+ * anything that failed — and, under rigc's core entry, its `here:` line, so the
516
+ * gate file says which validator ran.
517
+ */
397
518
  export function buildGateLines(out: string): string[] {
398
- return out.split('\n').filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:'))) || l.includes('FAIL') || l.includes('compile error'));
519
+ return out
520
+ .split('\n')
521
+ .filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:') || HERE_LINE.test(l.trim()))) || l.includes('FAIL') || l.includes('compile error'));
399
522
  }
400
523
 
401
- /** `check_rig.py`'s filter for validate: the summary and anything that failed. */
402
- export function validateGateLines(out: string): string[] {
403
- return out.split('\n').filter((l) => l.includes(' assertions: ') || l.includes('FAIL'));
524
+ /**
525
+ * The rules a rigc run measured — every `PASS` and `FAIL` line's rule name, in
526
+ * the order printed, each once. A SKIP, a PROF line and a summary are not
527
+ * measurements. The selftest's `CH09` compares a build's set with
528
+ * `rigc validate --profile spine`'s on the same build.
529
+ */
530
+ export function measuredRules(out: string): string[] {
531
+ const seen: string[] = [];
532
+ for (const m of out.matchAll(/^ {2}(?:PASS|FAIL) {2}([A-Z][A-Z0-9_]*)/gm)) if (!seen.includes(m[1])) seen.push(m[1]);
533
+ return seen;
404
534
  }
405
535
 
406
536
  const SUMMARY = /\((\d+) passed, (\d+) failed\)/;
@@ -423,19 +553,68 @@ export interface PackLine {
423
553
  regions: number;
424
554
  coveredPct: number;
425
555
  padding: number;
556
+ /** `free` when the line ends `, page edges free`, `pot` when it carries no suffix — spine-rigc prints the suffix exactly when it packed under `--page-edges free` (its `cli.ts`, the pack line). */
557
+ pageEdges: PageEdges;
426
558
  }
427
559
 
428
- const PACK = /pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), ([\d.]+)% covered, padding (\d+)/;
560
+ /** rigc's pack line, whole: the line is this and nothing else, or it is refused. */
561
+ const PACK = /^pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), (\d+(?:\.\d+)?)% covered, padding (\d+)(, page edges free)?$/;
429
562
 
563
+ /**
564
+ * Every pack line among the gate lines — a line that reads `pack:` once its
565
+ * `..` gutter is taken off. A pack line spine-rigc 1.5.1 to 2.0.3 would not print (an
566
+ * unknown suffix, a missing field) is refused, `CHECK_PACK_LINE_READS`, naming
567
+ * the line and the form required: a line half-read would lose the one field
568
+ * that changed.
569
+ */
430
570
  export function parsePackLines(lines: readonly string[]): PackLine[] {
431
571
  const out: PackLine[] = [];
572
+ const problems: Problem[] = [];
432
573
  for (const l of lines) {
433
- const m = PACK.exec(l);
434
- if (m !== null) out.push({ line: l.trim().replace(/^\.\.\s+/, ''), page: m[1], width: Number(m[2]), height: Number(m[3]), regions: Number(m[4]), coveredPct: Number(m[5]), padding: Number(m[6]) });
574
+ const text = l.trim().replace(/^\.\.\s+/, '');
575
+ if (!text.startsWith('pack:')) continue;
576
+ const m = PACK.exec(text);
577
+ if (m === null) {
578
+ problems.push({
579
+ code: 'CHECK_PACK_LINE_READS',
580
+ object: `rigc's pack line ${JSON.stringify(text)}`,
581
+ detail: 'does not read as "pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>", with ", page edges free" after it or nothing; that form is required',
582
+ });
583
+ continue;
584
+ }
585
+ out.push({ line: text, page: m[1], width: Number(m[2]), height: Number(m[3]), regions: Number(m[4]), coveredPct: Number(m[5]), padding: Number(m[6]), pageEdges: m[7] === undefined ? 'pot' : 'free' });
435
586
  }
587
+ refuseIfAny(problems);
436
588
  return out;
437
589
  }
438
590
 
591
+ function powerOfTwo(n: number): boolean {
592
+ return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;
593
+ }
594
+
595
+ /**
596
+ * Where a pack line disagrees with the `--page-edges` the build was given:
597
+ * the line's own suffix must say the same, and a `pot` page must be a power of
598
+ * two on both edges, as rigc's help defines it. One problem per disagreement,
599
+ * `CHECK_PACK_PAGE_EDGES`; empty is agreement.
600
+ */
601
+ export function packEdgeProblems(pack: readonly PackLine[], edges: PageEdges): Problem[] {
602
+ const problems: Problem[] = [];
603
+ for (const p of pack) {
604
+ const object = `packed page ${p.page}`;
605
+ if (p.pageEdges !== edges) {
606
+ problems.push({
607
+ code: 'CHECK_PACK_PAGE_EDGES',
608
+ object,
609
+ detail: `rigc's pack line says page edges ${p.pageEdges} (${p.pageEdges === 'free' ? 'it ends ", page edges free"' : 'no ", page edges free" suffix'}); the build was run with --page-edges ${edges}, so page edges ${edges} is required`,
610
+ });
611
+ } else if (edges === 'pot' && !(powerOfTwo(p.width) && powerOfTwo(p.height))) {
612
+ problems.push({ code: 'CHECK_PACK_PAGE_EDGES', object, detail: `is ${p.width}x${p.height}; the build was run with --page-edges pot, so a power of two on both edges is required` });
613
+ }
614
+ }
615
+ return problems;
616
+ }
617
+
439
618
  /** Share of a page's pixels with alpha above 0 — the measure issue #2's spineboy figure is in. rigc's "covered" is region rectangles, not pixels. */
440
619
  export function opaqueShare(r: Raster): number {
441
620
  let n = 0;
@@ -704,7 +883,8 @@ export const REPORTED_LINES = ['RECOMPOSITE_HOLES'] as const;
704
883
  /** check.json — the reference's fields in its order, then the judgement lines, then PASS. */
705
884
  export interface CheckFigures {
706
885
  gate_spine_html_green: boolean;
707
- gate_spine_green: boolean;
886
+ /** Which rigc entry gated the build ({@link readRigcEntry}): provenance, not a bar. */
887
+ rigc_entry: RigcEntryRecord;
708
888
  loop_max_diff: number;
709
889
  seam_mean: number;
710
890
  seam_px_over_40: number;
@@ -722,7 +902,6 @@ export interface CheckFigures {
722
902
  export interface CheckReport {
723
903
  figures: CheckFigures;
724
904
  gateHtml: string[];
725
- gateSpine: string[];
726
905
  pack: PackLine[];
727
906
  /** Opaque share (alpha > 0) of each packed page, by page file name. */
728
907
  packOpaque: Array<{ page: string; share: number }>;
@@ -733,10 +912,17 @@ export interface CheckReport {
733
912
  written: string[];
734
913
  }
735
914
 
736
- function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
915
+ /**
916
+ * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
917
+ * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
918
+ * A run that printed nothing is still one problem, never none.
919
+ */
920
+ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
737
921
  const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
738
- const quoted = named.length > 0 ? named : call.out.split('\n').filter((l) => l.trim() !== '').slice(-3);
739
- return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object: `\`rigc ${what}\``, detail: `exit ${call.status}: ${l.trim()}` }));
922
+ const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
923
+ const object = `\`rigc ${what}\``;
924
+ if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing` }];
925
+ return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}` }));
740
926
  }
741
927
 
742
928
  /**
@@ -745,11 +931,13 @@ function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Pro
745
931
  * gate file is written first, so the refusal's evidence is on disk. Otherwise
746
932
  * it measures everything, writes every output including `check.json`, and
747
933
  * returns the report; `report.problems` names each bar that was not met, and
748
- * `figures.PASS` is true exactly when it is empty.
934
+ * `figures.PASS` is true exactly when it is empty. `pageEdges` reaches rigc's
935
+ * `--page-edges` verbatim, and a pack line that disagrees with it is refused
936
+ * (`CHECK_PACK_PAGE_EDGES`).
749
937
  */
750
- export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir): CheckReport {
938
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir, pageEdges: PageEdges = DEFAULT_PAGE_EDGES): CheckReport {
751
939
  const inp = readCheckInputs(rigDir, partsHome);
752
- requireRigcVersion(rigc);
940
+ const rigcEntry = readRigcEntry(requireRigcVersion(rigc));
753
941
  const out = resolve(outDir);
754
942
  mkdirSync(out, { recursive: true });
755
943
  const buildDir = join(out, 'build');
@@ -764,19 +952,18 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
764
952
  };
765
953
 
766
954
  // 1. build, packed, under the policy profile
767
- const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, '--profile', 'spine-html', '--pack']);
955
+ const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, ...packedBuildArgs(pageEdges)]);
768
956
  const gateHtml = buildGateLines(build.out);
769
957
  write('gate_spine-html.txt', `${gateHtml.join('\n')}\n`);
770
- if (build.status !== 0) refuseIfAny(rigcFailed('build --profile spine-html --pack', build, gateHtml));
958
+ if (build.status !== 0) refuseIfAny(rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
771
959
  const pack = parsePackLines(gateHtml);
960
+ refuseIfAny(packEdgeProblems(pack, pageEdges));
772
961
  const packOpaque = pack.filter((p) => existsSync(join(buildDir, p.page))).map((p) => ({ page: p.page, share: opaqueShare(readPng(join(buildDir, p.page))) }));
773
962
 
774
- // 2. validate under the validity profile
775
- const validate = rigc(['validate', buildDir, '--profile', 'spine']);
776
- const gateSpine = validateGateLines(validate.out);
777
- write('gate_spine.txt', `${gateSpine.join('\n')}\n`);
963
+ // 2. no second gate: `rigc validate --profile spine` of this build measured no rule the build's own two passes
964
+ // had not (14 of the build's 23 on the demo, rigc 1.5.1 and 2.0.3 alike; selftest CH09 holds it), and on rigc's
965
+ // core entry — an install without spine-core — `validate` is refused outright
778
966
  const htmlGreen = gateGreen(build.status, gateHtml);
779
- const spineGreen = gateGreen(validate.status, gateSpine);
780
967
 
781
968
  // 3. the idle, the loop, the heat
782
969
  const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
@@ -892,7 +1079,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
892
1079
 
893
1080
  const figures: CheckFigures = {
894
1081
  gate_spine_html_green: htmlGreen,
895
- gate_spine_green: spineGreen,
1082
+ rigc_entry: rigcEntry,
896
1083
  loop_max_diff: loop.max,
897
1084
  seam_mean: round3(seam.mean),
898
1085
  seam_px_over_40: seam.over40,
@@ -907,8 +1094,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
907
1094
  PASS: false,
908
1095
  };
909
1096
  const barProblems: Problem[] = [];
910
- if (!htmlGreen) barProblems.push(...rigcFailed('build --profile spine-html --pack', build, gateHtml));
911
- if (!spineGreen) barProblems.push(...rigcFailed('validate --profile spine', validate, gateSpine));
1097
+ if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
912
1098
  if (figures.loop_max_diff !== LOOP_MAX_BAR) {
913
1099
  barProblems.push({
914
1100
  code: 'CHECK_LOOP_CLOSES',
@@ -931,7 +1117,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
931
1117
  return {
932
1118
  figures,
933
1119
  gateHtml,
934
- gateSpine,
935
1120
  pack,
936
1121
  packOpaque,
937
1122
  idle: { frames: idle.frames.length, fps: idle.fps, duration: idle.duration, lastIndex: last.index, loopAt: { x: loop.x, y: loop.y } },
@@ -1369,10 +1554,12 @@ export function parseRigcVersion(out: string): [number, number, number] | null {
1369
1554
  /**
1370
1555
  * Refuse, before anything is built, a rigc older than
1371
1556
  * {@link RIGC_GEOMETRY_VERSION}: its `render` has no `--geometry`, so the idle
1372
- * render would fail on the flag after the build and validate had run, and
1557
+ * render would fail on the flag after the build had run, and
1373
1558
  * the refusal would name a flag rather than the version that lacks it.
1559
+ * Returns what `--version` printed, stdout then stderr, which is where
1560
+ * {@link readRigcEntry} reads the entry.
1374
1561
  */
1375
- export function requireRigcVersion(rigc: RigcRunner): void {
1562
+ export function requireRigcVersion(rigc: RigcRunner): string {
1376
1563
  const v = rigc(['--version']);
1377
1564
  const got = v.status === 0 ? parseRigcVersion(v.out) : null;
1378
1565
  const need = parseRigcVersion(RIGC_GEOMETRY_VERSION) as [number, number, number];
@@ -1386,6 +1573,7 @@ export function requireRigcVersion(rigc: RigcRunner): void {
1386
1573
  },
1387
1574
  ]);
1388
1575
  }
1576
+ return v.out;
1389
1577
  }
1390
1578
 
1391
1579
  export interface IdleGeometry {