spine-parts 0.5.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/README.md CHANGED
@@ -38,14 +38,14 @@ is taken from the full run, because the twin tails leave the head crop sideways
38
38
  <code>49 assertions: 14 measured (14 passed, 0 failed), 20 skipped, 15 not in profile "spine"</code><br/>
39
39
  <code>loop: idle 49 frame(s) at 12 fps, f0000 vs f0048 (t = 4s): max |d| 0 (0 required)</code><br/>
40
40
  <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/>
41
+ <code>pack: skeleton.png 967x1338, 22 region(s), 91.2% covered, padding 2, page edges free</code><br/>
42
42
  <code>--seam silhouette</code> repaired the navy blobs the default rule leaves on the white
43
43
  blouse (recomposite error pixels 11,050 → 9,540); the default stays
44
44
  <code>near-white</code>, the reference implementation's rule, so the examples stay
45
45
  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
46
+ 12 fps, 1,706,520 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
47
47
  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
48
+ 13,645,350 bytes; the GIF, at the same error, 1,812,238). Frame 29 (2.417 s) is the
49
49
  closed eye: the blink holds for 0.084 s, one 12 fps frame rounded up, where the
50
50
  reference implementation held 0.04 s and no frame of its loop showed the eyes shut
51
51
  (issue #32); the painting is shown at half size, resampled and written
@@ -87,7 +87,7 @@ page** (issue #2):
87
87
 
88
88
  | path under `--out` | what |
89
89
  | --- | --- |
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 |
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 --page-edges free` and gated under both profiles |
91
91
  | `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
92
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
93
93
  | `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 +99,23 @@ artifact paths — here for `examples/sample`:
99
99
 
100
100
  ```
101
101
  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
102
+ 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
103
  out/check/build/skeleton.json
104
104
  out/check/build/skeleton.atlas
105
105
  out/check/build/skeleton.png
106
106
  ```
107
107
 
108
+ The page's edges are free by default: rigc picks the least-area page the parts need,
109
+ not the next power of two. On the two examples that is 967x1338 and 479x1166, where a
110
+ power-of-two page is 1024x2048 and 512x2048, and covered goes from 56.3 % to 91.2 % and
111
+ from 49.7 % to 93.4 %. The cost is the one rigc's help states: *"region attachments
112
+ sampling within 1 LSB of the loose build rather than exactly"*. Measured on both
113
+ examples, every idle frame stays within 1 level of the power-of-two build's. The atlas says
114
+ `filter: Linear, Linear` and has no `repeat` line, so `--page-edges pot` is for a
115
+ consumer that mipmaps or repeats the page; it writes exactly the page builds wrote
116
+ before the flag existed. The measurements are in
117
+ [AUTHORING §5, *Page edges*](docs/AUTHORING.md#5-after-each-stage-what-to-read).
118
+
108
119
  The packer is spine-rigc's, and no other: a packed region is a lossless copy, and an
109
120
  atlas written by anything else would have no oracle behind it.
110
121
 
package/cli.ts CHANGED
@@ -18,7 +18,7 @@ import { basename, dirname, extname, join } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
20
  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';
21
+ 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
22
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
23
23
  import { type CharacterConfig, loadConfig, loadEarlyConfig } from './src/config.ts';
24
24
  import { PartsError, problemLine } from './src/errors.ts';
@@ -98,6 +98,7 @@ usage:
98
98
  printed when one was needed.
99
99
 
100
100
  spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
101
+ [--page-edges pot|free]
101
102
  Author the rig: unrotated bones at the config's landmarks (a chain makes
102
103
  <chain>0..n), a square lattice mesh over every part in config.meshes
103
104
  weighted by distance to its candidate bone segments, a region for every
@@ -110,8 +111,8 @@ usage:
110
111
  bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
111
112
  CONFIG_BLINK_GROUP_UNIQUE, before rigc starts. --parts is the
112
113
  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
114
+ through spine-rigc (profile spine-html, packed with --page-edges as for
115
+ check, then validated under profile spine) in a scratch directory first, and --out receives
115
116
  images/*.png, rig.json, motion.json and mesh_report.json only when both
116
117
  are green. Prints one line 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:
@@ -121,14 +122,14 @@ 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 and gate_spine.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),
@@ -163,6 +164,16 @@ usage:
163
164
  beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
164
165
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
165
166
  and the value required.
167
+ --page-edges is handed to rigc verbatim and defaults to ${DEFAULT_PAGE_EDGES}: the
168
+ least-area page the parts need (on the two public examples 967x1338 and
169
+ 479x1166, where pot writes 1024x2048 and 512x2048), at the cost rigc
170
+ states — region attachments sample within 1 LSB of the loose build
171
+ rather than exactly (measured on both examples: every idle frame within
172
+ 1 level of the pot build's; AUTHORING §5, Page edges). pot is a power of two on both edges, for a consumer
173
+ that mipmaps or repeats the page (the atlas declares filter Linear,
174
+ Linear and no repeat). rigc's pack line ends ", page edges free" under
175
+ free; a line that disagrees with the value passed, or a pot page that is
176
+ not a power of two, is refused, CHECK_PACK_PAGE_EDGES.
166
177
 
167
178
  spine-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
168
179
  Encode a frame set rigc render wrote (its --out directory, or the set
@@ -247,7 +258,7 @@ usage:
247
258
 
248
259
  spine-parts build --config <config.json> --source <painting.png> --full <dir|psd>
249
260
  --head <dir|psd> --out <dir> [--seam near-white|silhouette]
250
- [--project core|visible] [--loop]
261
+ [--project core|visible] [--page-edges pot|free] [--loop]
251
262
  assemble, then rig, then check, in one process, each stage's own lines
252
263
  printed under [assemble], [rig] and [check]; the first stage that refuses
253
264
  stops the build with its own FAIL lines. The config must already carry
@@ -263,7 +274,8 @@ usage:
263
274
  three artifact paths — skeleton .json, .atlas and the packed page: the
264
275
  packed atlas is the result, the loose parts are the intermediate it was
265
276
  made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
266
- ${DEFAULT_PROJECT_RULE} (both as for assemble).
277
+ ${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES}
278
+ (as for check; both packed builds, rig's gate and check's artifact, take it).
267
279
 
268
280
  spine-parts --version
269
281
  spine-parts --help
@@ -398,15 +410,26 @@ function rigGateRunner(): RigcRunner {
398
410
  };
399
411
  }
400
412
 
413
+ /** `--page-edges`, or its default; a value spine-rigc does not take is a usage error naming the two it does. */
414
+ function pageEdgesOf(value: string | undefined): PageEdges | string {
415
+ const edges = value ?? DEFAULT_PAGE_EDGES;
416
+ return (PAGE_EDGES as readonly string[]).includes(edges) ? (edges as PageEdges) : `--page-edges ${edges}; one of ${PAGE_EDGES.join(', ')} is required`;
417
+ }
418
+
419
+ function isPageEdges(v: PageEdges | string): v is PageEdges {
420
+ return (PAGE_EDGES as readonly string[]).includes(v);
421
+ }
422
+
401
423
  function cmdRig(args: string[]): number {
402
424
  let config: string | null = null;
403
425
  let partsDir: string | null = null;
404
426
  let out: string | null = null;
405
427
  let idleKeys: string | null = null;
428
+ let pageEdges: string | null = null;
406
429
  for (let i = 0; i < args.length; i++) {
407
430
  const flag = args[i];
408
431
  const value = args[i + 1];
409
- if (!['--config', '--parts', '--out', '--idle-keys'].includes(flag)) return usage(`rig does not take "${flag}"`);
432
+ if (!['--config', '--parts', '--out', '--idle-keys', '--page-edges'].includes(flag)) return usage(`rig does not take "${flag}"`);
410
433
  if (value === undefined) return usage(`${flag} needs a value`);
411
434
  i++;
412
435
  if (flag === '--config') config = value;
@@ -414,6 +437,9 @@ function cmdRig(args: string[]): number {
414
437
  else if (flag === '--idle-keys') {
415
438
  if (idleKeys !== null) return usage('--idle-keys is given twice');
416
439
  idleKeys = value;
440
+ } else if (flag === '--page-edges') {
441
+ if (pageEdges !== null) return usage('--page-edges is given twice');
442
+ pageEdges = value;
417
443
  } else out = value;
418
444
  }
419
445
  if (config === null) return usage('rig needs --config <config.json>');
@@ -421,9 +447,11 @@ function cmdRig(args: string[]): number {
421
447
  if (out === null) return usage('rig needs --out <dir>');
422
448
  const keys = idleKeys ?? DEFAULT_IDLE_KEYS;
423
449
  if (!(IDLE_KEYS as readonly string[]).includes(keys)) return usage(`--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`);
450
+ const edges = pageEdgesOf(pageEdges ?? undefined);
451
+ if (!isPageEdges(edges)) return usage(edges);
424
452
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
425
453
  try {
426
- rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys }, rigGateRunner(), scratch, console.log);
454
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys, pageEdges: edges }, rigGateRunner(), scratch, console.log);
427
455
  return EXIT_OK;
428
456
  } catch (err) {
429
457
  return printRefusal(err);
@@ -456,13 +484,15 @@ function flags(args: string[], known: readonly string[], command: string, option
456
484
  }
457
485
 
458
486
  function cmdCheck(args: string[]): number {
459
- const f = flags(args, ['--rig', '--out'], 'check', ['--parts']);
487
+ const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--page-edges']);
460
488
  if (typeof f === 'string') return usage(f);
461
489
  const rig = f.get('--rig') as string;
462
490
  const out = f.get('--out') as string;
491
+ const edges = pageEdgesOf(f.get('--page-edges'));
492
+ if (!isPageEdges(edges)) return usage(edges);
463
493
  try {
464
494
  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);
495
+ const r = checkStage({ rig, parts: f.get('--parts') ?? rig, out, pageEdges: edges }, rigcRunner(bin), bin, console.log);
466
496
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
467
497
  } catch (err) {
468
498
  return printRefusal(err);
@@ -767,7 +797,7 @@ function cmdInputs(args: string[]): number {
767
797
  function cmdBuild(args: string[]): number {
768
798
  const flags = new Map<string, string>();
769
799
  let loop = false;
770
- const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project'];
800
+ const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges'];
771
801
  for (let i = 0; i < args.length; i++) {
772
802
  const flag = args[i];
773
803
  if (flag === '--loop') {
@@ -787,6 +817,8 @@ function cmdBuild(args: string[]): number {
787
817
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
788
818
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
789
819
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
820
+ const edges = pageEdgesOf(flags.get('--page-edges'));
821
+ if (!isPageEdges(edges)) return usage(edges);
790
822
  const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
791
823
  let bin: string;
792
824
  try {
@@ -797,7 +829,7 @@ function cmdBuild(args: string[]): number {
797
829
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-build-'));
798
830
  try {
799
831
  const r = build(
800
- { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop },
832
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges },
801
833
  { rig: rigGateRunner(), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
802
834
  console.log,
803
835
  );
package/docs/AUTHORING.md CHANGED
@@ -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,36 @@ 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 with rigc 1.5.1 on the two examples, by `tools/atlas_population.ts`:
305
+
306
+ | build | page | covered | page opaque | page / figure |
307
+ | --- | --- | --- | --- | --- |
308
+ | demo, `pot` | 1024x2048 | 56.3 % | 36.8 % | 2.651 |
309
+ | demo, `free` | 967x1338 | 91.2 % | 59.7 % | 1.636 |
310
+ | sample, `pot` | 512x2048 | 49.7 % | 28.7 % | 3.294 |
311
+ | sample, `free` | 479x1166 | 93.4 % | 53.9 % | 1.755 |
312
+
313
+ rigc's help states the cost: *"a smaller page, at the cost of region attachments
314
+ sampling within 1 LSB of the loose build rather than exactly"*. On these builds
315
+ every one of the 49 idle frames differs from the `pot` build, by at most 1 level
316
+ in any channel:
317
+
318
+ | example | idle frames that differ | pixels that differ (all 49 frames) | max \|d\| |
319
+ | --- | --- | --- | --- |
320
+ | demo | 49 of 49 | 2,373 | 1 |
321
+ | sample | 49 of 49 | 651 | 1 |
322
+
323
+ Only one check figure moved. It has no bar: sample's reported screen-space face
324
+ mean in `STILL_REGIONS_DARK`, 11.475 under `pot` and 11.472 under `free`. Every
325
+ barred figure and every status is the same under both. The atlas rigc writes says
326
+ `filter: Linear, Linear` and has no `repeat` line, so choose `pot` for a consumer
327
+ that mipmaps or repeats the page.
328
+
297
329
  `--project core` (the default) is the reference's projection rule: a layer takes the
298
330
  painting's pixel only inside its top-most `alpha >= 250` area eroded by a 5x5 square,
299
331
  so a part a few pixels wide (a lash, a brow, an iris) takes none. `--project visible`
@@ -569,6 +601,8 @@ See-through with another seed.
569
601
  | `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
602
  | `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
603
  | `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 |
604
+ | `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` |
605
+ | `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
606
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
573
607
 
574
608
  ## 7. The bars `check` enforces, and what only an eye answers today
@@ -676,7 +710,8 @@ What each figure is, and is not:
676
710
  - **The face is measured in the head's frame, and its bar is the model's (issue #33).**
677
711
  Until #33 the face half read the screen-space heat map, where the head's intended
678
712
  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,
713
+ reported as `screen_heat_mean`; sample's reads 11.472 since the page edges
714
+ default to `free`, *Page edges* in §5), so its ceiling was twice the worse example,
680
715
  33.976 — a ceiling a smooth fixture face sliding two rig pixels stayed under (24.8;
681
716
  eight pixels measured 85.5). The judgement means *nothing moves on the face that
682
717
  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.6.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": {
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 { type CheckReport, 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';
@@ -183,21 +183,19 @@ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
183
183
 
184
184
  /**
185
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
186
+ * the spine-html profile with `--pack --page-edges <edges>`, then `validate`
187
+ * of that build under the spine profile. The files are staged exactly as `--out` will receive
188
188
  * them, so what passed is what is written. The scratch directory is the
189
189
  * caller's, and is emptied here before and after.
190
190
  */
191
- function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string): GateRun[] {
191
+ function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string, edges: PageEdges): GateRun[] {
192
192
  rmSync(scratch, { recursive: true, force: true });
193
193
  try {
194
194
  mkdirSync(join(scratch, 'images'), { recursive: true });
195
195
  for (const [file, img] of out.images) writeFileSync(join(scratch, 'images', file), encodePngBytes(img));
196
196
  for (const [file, text] of texts) writeFileSync(join(scratch, file), text);
197
197
  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
- ]);
198
+ const html = gateRun(packedBuildLabel(edges), rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...packedBuildArgs(edges)]);
201
199
  if (html.status !== 0) return [html];
202
200
  return [html, gateRun('validate --profile spine', rigc, ['validate', build, '--profile', 'spine'])];
203
201
  } finally {
@@ -212,6 +210,8 @@ export interface RigStageInput {
212
210
  out: string;
213
211
  /** Where the idle's keys on mesh-driving bones go; `ctl` when absent. See `IDLE_KEYS` in `src/rig.ts`. */
214
212
  idleKeys?: IdleKeys;
213
+ /** The gate build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. The pack is scratch here; the gate is what it is for. */
214
+ pageEdges?: PageEdges;
215
215
  }
216
216
 
217
217
  /** Author the rig, gate it through rigc in `scratch`, and write `out` only when both gates are green. */
@@ -249,7 +249,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
249
249
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
250
250
  : ` 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
251
  );
252
- const gate = gateThroughRigc(rig, texts, rigc, scratch);
252
+ const gate = gateThroughRigc(rig, texts, rigc, scratch, input.pageEdges ?? DEFAULT_PAGE_EDGES);
253
253
  for (const g of gate) {
254
254
  log(` rigc ${g.label}: exit ${g.status}`);
255
255
  for (const l of g.lines) log(` ${l.trim()}`);
@@ -280,6 +280,8 @@ export interface CheckStageInput {
280
280
  /** The directory holding parts.json and parts/; the rig directory when they sit there. */
281
281
  parts: string;
282
282
  out: string;
283
+ /** The packed build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. */
284
+ pageEdges?: PageEdges;
283
285
  }
284
286
 
285
287
  /** The pack line as it is printed: rigc's own, then the page's opaque share beside the spineboy yardstick. */
@@ -315,8 +317,9 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
315
317
  const v = rigc(['--version']);
316
318
  log(`spine-parts check: ${input.rig} -> ${input.out}`);
317
319
  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:');
320
+ const edges = input.pageEdges ?? DEFAULT_PAGE_EDGES;
321
+ const r = runCheck(input.rig, input.out, rigc, input.parts, edges);
322
+ log(` gate spine-html (rigc ${packedBuildLabel(edges)}), verbatim:`);
320
323
  for (const l of r.gateHtml) log(l);
321
324
  log(' gate spine (rigc validate build --profile spine), verbatim:');
322
325
  for (const l of r.gateSpine) log(l);
@@ -427,6 +430,8 @@ export interface BuildInput {
427
430
  seam: SeamRule;
428
431
  project: ProjectRule;
429
432
  loop: boolean;
433
+ /** `--page-edges` for both packed builds, the rig stage's gate and the check's artifact. */
434
+ pageEdges: PageEdges;
430
435
  }
431
436
 
432
437
  /** The two rigc processes, as each command runs its own: `rig` gates through one, `check` measures through the other. */
@@ -487,7 +492,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
487
492
  const out = input.out;
488
493
  mkdirSync(out, { recursive: true });
489
494
  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' : ''}`);
495
+ 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
496
 
492
497
  try {
493
498
  // build runs rig next, which needs the whole config, so the full loader is
@@ -505,14 +510,14 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
505
510
  }
506
511
 
507
512
  try {
508
- rigStage({ config: input.config, parts: out, out: join(out, 'rig') }, run.rig, run.scratch, prefixed('rig'));
513
+ rigStage({ config: input.config, parts: out, out: join(out, 'rig'), pageEdges: input.pageEdges }, run.rig, run.scratch, prefixed('rig'));
509
514
  } catch (err) {
510
515
  return refused('rig', err);
511
516
  }
512
517
 
513
518
  let report: CheckReport;
514
519
  try {
515
- report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check') }, run.check, run.checkBin, prefixed('check'));
520
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges }, run.check, run.checkBin, prefixed('check'));
516
521
  } catch (err) {
517
522
  return refused('check', err);
518
523
  }
package/src/check.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * | output | what |
25
25
  * | --- | --- |
26
- * | `build/` | `rigc build --profile spine-html --pack` — the packed atlas is the final artifact |
26
+ * | `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 |
27
27
  * | `gate_spine-html.txt` | that build's gate lines, verbatim |
28
28
  * | `gate_spine.txt` | `rigc validate build/ --profile spine`'s gate lines, verbatim |
29
29
  * | `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) |
@@ -184,6 +184,40 @@ export const GEOMETRY_SPEC = 'rigc-geometry/1';
184
184
  /** Issue #2's yardstick, measured on the Spine example export `spineboy.png` (alpha > 0). */
185
185
  export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha > 0)';
186
186
 
187
+ /**
188
+ * What a packed page's edges may be: spine-rigc's `build --pack --page-edges`,
189
+ * handed to it verbatim (the flag is in spine-rigc's CLI since 1.4.0, the
190
+ * floor `check` already requires; 1.5.0 made `free` search every width).
191
+ * `pot` is a power of two on both edges; `free` is the least-area page the
192
+ * placement needs, at the cost rigc's help states: region attachments sample
193
+ * within 1 LSB of the loose build rather than exactly.
194
+ *
195
+ * `free` is the default here although rigc's own default is `pot`: the packed
196
+ * page is this package's final artifact, and on the two public examples `free`
197
+ * takes the page from 1024x2048 to 967x1338 (covered 56.3 % -> 91.2 %) and
198
+ * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1. The cost was
199
+ * measured on the same two builds. Each of the 49 idle frames differs from the
200
+ * `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
201
+ * px over all 49). On sample that moves STILL_REGIONS_DARK's reported
202
+ * screen-space face mean from 11.475 to 11.472; no bar or status moves. The
203
+ * atlas rigc writes says `filter: Linear, Linear` and has no `repeat` line, so
204
+ * a consumer that mipmaps or repeats the page itself is the one that wants
205
+ * `pot`. AUTHORING §5, *Page edges*, holds the tables.
206
+ */
207
+ export const PAGE_EDGES = ['pot', 'free'] as const;
208
+ export type PageEdges = (typeof PAGE_EDGES)[number];
209
+ export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
210
+
211
+ /** The packed build's rigc arguments after `--out <dir>`, as every stage that packs passes them. */
212
+ export function packedBuildArgs(edges: PageEdges): string[] {
213
+ return ['--profile', 'spine-html', '--pack', '--page-edges', edges];
214
+ }
215
+
216
+ /** The packed build as a label: the rigc command line that was run, after `rigc`. */
217
+ export function packedBuildLabel(edges: PageEdges): string {
218
+ return `build ${packedBuildArgs(edges).join(' ')}`;
219
+ }
220
+
187
221
  // ---------------------------------------------------------------------------
188
222
  // the rigc process, injected
189
223
  // ---------------------------------------------------------------------------
@@ -423,19 +457,68 @@ export interface PackLine {
423
457
  regions: number;
424
458
  coveredPct: number;
425
459
  padding: number;
460
+ /** `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). */
461
+ pageEdges: PageEdges;
426
462
  }
427
463
 
428
- const PACK = /pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), ([\d.]+)% covered, padding (\d+)/;
464
+ /** rigc's pack line, whole: the line is this and nothing else, or it is refused. */
465
+ const PACK = /^pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), (\d+(?:\.\d+)?)% covered, padding (\d+)(, page edges free)?$/;
429
466
 
467
+ /**
468
+ * Every pack line among the gate lines — a line that reads `pack:` once its
469
+ * `..` gutter is taken off. A pack line spine-rigc 1.5.1 would not print (an
470
+ * unknown suffix, a missing field) is refused, `CHECK_PACK_LINE_READS`, naming
471
+ * the line and the form required: a line half-read would lose the one field
472
+ * that changed.
473
+ */
430
474
  export function parsePackLines(lines: readonly string[]): PackLine[] {
431
475
  const out: PackLine[] = [];
476
+ const problems: Problem[] = [];
432
477
  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]) });
478
+ const text = l.trim().replace(/^\.\.\s+/, '');
479
+ if (!text.startsWith('pack:')) continue;
480
+ const m = PACK.exec(text);
481
+ if (m === null) {
482
+ problems.push({
483
+ code: 'CHECK_PACK_LINE_READS',
484
+ object: `rigc's pack line ${JSON.stringify(text)}`,
485
+ 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',
486
+ });
487
+ continue;
488
+ }
489
+ 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
490
  }
491
+ refuseIfAny(problems);
436
492
  return out;
437
493
  }
438
494
 
495
+ function powerOfTwo(n: number): boolean {
496
+ return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;
497
+ }
498
+
499
+ /**
500
+ * Where a pack line disagrees with the `--page-edges` the build was given:
501
+ * the line's own suffix must say the same, and a `pot` page must be a power of
502
+ * two on both edges, as rigc's help defines it. One problem per disagreement,
503
+ * `CHECK_PACK_PAGE_EDGES`; empty is agreement.
504
+ */
505
+ export function packEdgeProblems(pack: readonly PackLine[], edges: PageEdges): Problem[] {
506
+ const problems: Problem[] = [];
507
+ for (const p of pack) {
508
+ const object = `packed page ${p.page}`;
509
+ if (p.pageEdges !== edges) {
510
+ problems.push({
511
+ code: 'CHECK_PACK_PAGE_EDGES',
512
+ object,
513
+ 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`,
514
+ });
515
+ } else if (edges === 'pot' && !(powerOfTwo(p.width) && powerOfTwo(p.height))) {
516
+ 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` });
517
+ }
518
+ }
519
+ return problems;
520
+ }
521
+
439
522
  /** 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
523
  export function opaqueShare(r: Raster): number {
441
524
  let n = 0;
@@ -745,9 +828,11 @@ function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Pro
745
828
  * gate file is written first, so the refusal's evidence is on disk. Otherwise
746
829
  * it measures everything, writes every output including `check.json`, and
747
830
  * returns the report; `report.problems` names each bar that was not met, and
748
- * `figures.PASS` is true exactly when it is empty.
831
+ * `figures.PASS` is true exactly when it is empty. `pageEdges` reaches rigc's
832
+ * `--page-edges` verbatim, and a pack line that disagrees with it is refused
833
+ * (`CHECK_PACK_PAGE_EDGES`).
749
834
  */
750
- export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir): CheckReport {
835
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir, pageEdges: PageEdges = DEFAULT_PAGE_EDGES): CheckReport {
751
836
  const inp = readCheckInputs(rigDir, partsHome);
752
837
  requireRigcVersion(rigc);
753
838
  const out = resolve(outDir);
@@ -764,11 +849,12 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
764
849
  };
765
850
 
766
851
  // 1. build, packed, under the policy profile
767
- const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, '--profile', 'spine-html', '--pack']);
852
+ const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, ...packedBuildArgs(pageEdges)]);
768
853
  const gateHtml = buildGateLines(build.out);
769
854
  write('gate_spine-html.txt', `${gateHtml.join('\n')}\n`);
770
- if (build.status !== 0) refuseIfAny(rigcFailed('build --profile spine-html --pack', build, gateHtml));
855
+ if (build.status !== 0) refuseIfAny(rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
771
856
  const pack = parsePackLines(gateHtml);
857
+ refuseIfAny(packEdgeProblems(pack, pageEdges));
772
858
  const packOpaque = pack.filter((p) => existsSync(join(buildDir, p.page))).map((p) => ({ page: p.page, share: opaqueShare(readPng(join(buildDir, p.page))) }));
773
859
 
774
860
  // 2. validate under the validity profile
@@ -907,7 +993,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
907
993
  PASS: false,
908
994
  };
909
995
  const barProblems: Problem[] = [];
910
- if (!htmlGreen) barProblems.push(...rigcFailed('build --profile spine-html --pack', build, gateHtml));
996
+ if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
911
997
  if (!spineGreen) barProblems.push(...rigcFailed('validate --profile spine', validate, gateSpine));
912
998
  if (figures.loop_max_diff !== LOOP_MAX_BAR) {
913
999
  barProblems.push({