spine-parts 0.7.0 → 0.8.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
@@ -34,17 +34,17 @@ was shifted down into the canvas (its proposal ran 118 px above the top edge;
34
34
  <code>propose --head-box</code> now makes that shift itself), and <code>hair_back</code>
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
- <code>49 assertions: 23 measured (23 passed, 0 failed), 26 skipped, 0 not in profile "spine-html"</code><br/>
37
+ <code>50 assertions: 24 measured (24 passed, 0 failed), 26 skipped, 0 not in profile "spine-html"</code><br/>
38
38
  <code>loop: idle 49 frame(s) at 12 fps, f0000 vs f0048 (t = 4s): max |d| 0 (0 required)</code><br/>
39
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/>
40
- <code>pack: skeleton.png 967x1338, 22 region(s), 91.2% covered, padding 2, page edges free</code><br/>
40
+ <code>pack: skeleton.png 922x1348, 22 region(s), 95.0% covered, padding 2, page edges free, shape polygon</code><br/>
41
41
  <code>--seam silhouette</code> repaired the navy blobs the default rule leaves on the white
42
42
  blouse (recomposite error pixels 11,050 → 9,540); the default stays
43
43
  <code>near-white</code>, the reference implementation's rule, so the examples stay
44
44
  comparable with it. The loop is <code>spine-parts loop --palette</code>'s indexed APNG, 48 frames at
45
- 12 fps, 1,706,520 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
45
+ 12 fps, 1,706,719 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
46
46
  per channel over every frame (the lossless APNG beside it, the exactness record, is
47
- 13,645,350 bytes; the GIF, at the same error, 1,812,238). Frame 29 (2.417 s) is the
47
+ 13,645,503 bytes; the GIF, at the same error, 1,812,288). Frame 29 (2.417 s) is the
48
48
  closed eye: the blink holds for 0.084 s, one 12 fps frame rounded up, where the
49
49
  reference implementation held 0.04 s and no frame of its loop showed the eyes shut
50
50
  (issue #32); the painting is shown at half size, resampled and written
@@ -86,10 +86,10 @@ page** (issue #2):
86
86
 
87
87
  | path under `--out` | what |
88
88
  | --- | --- |
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`) |
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 --pack-shape polygon` and gated under `spine-html`, which holds every rule `spine` measures (on the demo, rigc 2.1.3: the 14 rules `validate --profile spine` measures are among the build's 24; 16 are not in `spine`) |
90
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 |
91
91
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
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` |
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` (with `pack_mode`, the `--page-edges` and `--pack-shape` the page was packed under) |
93
93
  | `idle.png`, `idle-indexed.png`, `idle.gif` | with `--loop`: the idle as a lossless APNG (the exactness record), an indexed APNG with one shared palette (the small one) and a GIF; the last two print their palette error |
94
94
 
95
95
  The last lines of a green build are the pack line, printed beside the Spine example
@@ -98,15 +98,15 @@ artifact paths — here for `examples/sample`:
98
98
 
99
99
  ```
100
100
  build: PASS — the packed atlas is the artifact; parts/ and rig/ are the intermediates it was made from
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
101
+ pack: skeleton.png 477x1151, 20 region(s), 95.0% covered, padding 2, page edges free, shape polygon; page opaque 54.8% (alpha > 0) — spineboy yardstick 1024x256, 40 region(s), 45.8% opaque (alpha > 0), a reference and not a bar
102
102
  out/check/build/skeleton.json
103
103
  out/check/build/skeleton.atlas
104
104
  out/check/build/skeleton.png
105
105
  ```
106
106
 
107
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
108
+ not the next power of two. On the two examples, packed by rectangles, that is 967x1338
109
+ and 479x1166, where a power-of-two page is 1024x2048 and 512x2048, and covered goes from 56.3 % to 91.2 % and
110
110
  from 49.7 % to 93.4 %. The cost is the one rigc's help states: *"region attachments
111
111
  sampling within 1 LSB of the loose build rather than exactly"*. Measured on both
112
112
  examples, every idle frame stays within 1 level of the power-of-two build's. The atlas says
@@ -115,6 +115,16 @@ consumer that mipmaps or repeats the page; it writes exactly the page builds wro
115
115
  before the flag existed. The measurements are in
116
116
  [AUTHORING §5, *Page edges*](docs/AUTHORING.md#5-after-each-stage-what-to-read).
117
117
 
118
+ The pack shape is `polygon` by default (spine-rigc 2.1): a region only meshes draw is
119
+ packed by its emitted hull, so a neighbour may sit inside its rectangle where the hull
120
+ is not; a region attachment stays its rectangle. On the two examples that takes the
121
+ free page from 967x1338 to 922x1348 (covered 91.2 % to 95.0 %, 3.9 % less area) and
122
+ from 479x1166 to 477x1151 (93.4 % to 95.0 %, 1.7 % less), at the cost class `free`
123
+ already has: every idle frame within 1 level of the `rect` build's, and no figure in
124
+ `check.json` but `pack_mode` moves. `--pack-shape rect` writes the page and atlas
125
+ 0.7.0 wrote. The measurements are in
126
+ [AUTHORING §5, *Pack shape*](docs/AUTHORING.md#5-after-each-stage-what-to-read).
127
+
118
128
  The packer is spine-rigc's, and no other: a packed region is a lossless copy, and an
119
129
  atlas written by anything else would have no oracle behind it.
120
130
 
package/cli.ts CHANGED
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
18
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
19
19
  import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage } from './src/build.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';
20
+ import { DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, type PackShape, 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';
21
21
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
22
22
  import { type CharacterConfig, loadConfig, loadEarlyConfig } from './src/config.ts';
23
23
  import { PartsError, problemLine } from './src/errors.ts';
@@ -97,7 +97,7 @@ usage:
97
97
  printed when one was needed.
98
98
 
99
99
  spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
100
- [--page-edges pot|free]
100
+ [--page-edges pot|free] [--pack-shape rect|polygon]
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,8 +110,8 @@ 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 with --page-edges as for
114
- check; rigc's build gates the compile and the packed pages on disk) in a
113
+ through spine-rigc (profile spine-html, packed with --page-edges and
114
+ --pack-shape as for check; rigc's build gates the compile and the packed pages on disk) in a
115
115
  scratch directory first, and --out receives images/*.png, rig.json,
116
116
  motion.json and mesh_report.json only when it is green. Prints one line
117
117
  per mesh and the rigc gate lines.
@@ -123,18 +123,19 @@ usage:
123
123
  stage prints that SKIP line). The pose is the same to one level of float
124
124
  rounding, and so is the per-frame mesh work (AUTHORING §5).
125
125
  spine-parts check --rig <dir> --out <dir> [--parts <dir>] [--page-edges pot|free]
126
+ [--pack-shape rect|polygon]
126
127
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
127
128
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
128
129
  (with an "idle").
129
130
  ${PARTS_HOME_SENTENCE}.
130
131
  Both are only read. Into --out:
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
+ build/ (rigc build --profile spine-html --pack --page-edges <value>
133
+ --pack-shape <value>: the packed atlas is the artifact), gate_spine-html.txt (the gate lines
133
134
  verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640
134
135
  --geometry: the frames and geometry.json, the skinned vertices and
135
136
  every bone's world transform per frame),
136
137
  contact.png, motion_heat.png and check.json (with rigc_entry, the rigc
137
- entry that gated the build). PASS needs every gate summary
138
+ entry that gated the build, and pack_mode, the two pack flags). PASS needs every gate summary
138
139
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
139
140
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
140
141
  frame at t = duration) at max |d| 0. Then six judgement lines, each in
@@ -166,15 +167,25 @@ usage:
166
167
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
167
168
  and the value required.
168
169
  --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
170
+ least-area page the parts need (on the two public examples, rect, 967x1338
171
+ and 479x1166, where pot writes 1024x2048 and 512x2048), at the cost rigc
171
172
  states — region attachments sample within 1 LSB of the loose build
172
173
  rather than exactly (measured on both examples: every idle frame within
173
174
  1 level of the pot build's; AUTHORING §5, Page edges). pot is a power of two on both edges, for a consumer
174
175
  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
+ Linear and no repeat). rigc's pack line carries ", page edges free" under
176
177
  free; a line that disagrees with the value passed, or a pot page that is
177
178
  not a power of two, is refused, CHECK_PACK_PAGE_EDGES.
179
+ --pack-shape is handed to rigc verbatim and defaults to ${DEFAULT_PACK_SHAPE}:
180
+ a region only meshes draw is packed by its emitted hull, so a neighbour
181
+ may sit inside its rectangle where the hull is not (on the two public
182
+ examples, under free, 922x1348 and 477x1151 where rect writes 967x1338
183
+ and 479x1166), at the cost of the same 1-level class as free (measured
184
+ on both examples: every idle frame within 1 level of the rect build's;
185
+ AUTHORING §5, Pack shape). rect keeps every region's cell apart and
186
+ writes the page earlier releases wrote. rigc's pack line ends
187
+ ", shape rect" or ", shape polygon"; a line that disagrees with the
188
+ value passed is refused, CHECK_PACK_SHAPE.
178
189
 
179
190
  spine-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
180
191
  Encode a frame set rigc render wrote (its --out directory, or the set
@@ -259,7 +270,8 @@ usage:
259
270
 
260
271
  spine-parts build --config <config.json> --source <painting.png> --full <dir|psd>
261
272
  --head <dir|psd> --out <dir> [--seam near-white|silhouette]
262
- [--project core|visible] [--page-edges pot|free] [--loop]
273
+ [--project core|visible] [--page-edges pot|free]
274
+ [--pack-shape rect|polygon] [--loop]
263
275
  assemble, then rig, then check, in one process, each stage's own lines
264
276
  printed under [assemble], [rig] and [check]; the first stage that refuses
265
277
  stops the build with its own FAIL lines. The config must already carry
@@ -275,8 +287,9 @@ usage:
275
287
  three artifact paths — skeleton .json, .atlas and the packed page: the
276
288
  packed atlas is the result, the loose parts are the intermediate it was
277
289
  made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
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).
290
+ ${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES} and
291
+ --pack-shape to ${DEFAULT_PACK_SHAPE} (as for check; both packed builds, rig's gate
292
+ and check's artifact, take them).
280
293
 
281
294
  spine-parts --version
282
295
  spine-parts --help
@@ -407,16 +420,27 @@ function isPageEdges(v: PageEdges | string): v is PageEdges {
407
420
  return (PAGE_EDGES as readonly string[]).includes(v);
408
421
  }
409
422
 
423
+ /** `--pack-shape`, or its default; a value spine-rigc does not take is a usage error naming the two it does. */
424
+ function packShapeOf(value: string | undefined): PackShape | string {
425
+ const shape = value ?? DEFAULT_PACK_SHAPE;
426
+ return (PACK_SHAPES as readonly string[]).includes(shape) ? (shape as PackShape) : `--pack-shape ${shape}; one of ${PACK_SHAPES.join(', ')} is required`;
427
+ }
428
+
429
+ function isPackShape(v: PackShape | string): v is PackShape {
430
+ return (PACK_SHAPES as readonly string[]).includes(v);
431
+ }
432
+
410
433
  function cmdRig(args: string[]): number {
411
434
  let config: string | null = null;
412
435
  let partsDir: string | null = null;
413
436
  let out: string | null = null;
414
437
  let idleKeys: string | null = null;
415
438
  let pageEdges: string | null = null;
439
+ let packShape: string | null = null;
416
440
  for (let i = 0; i < args.length; i++) {
417
441
  const flag = args[i];
418
442
  const value = args[i + 1];
419
- if (!['--config', '--parts', '--out', '--idle-keys', '--page-edges'].includes(flag)) return usage(`rig does not take "${flag}"`);
443
+ if (!['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'].includes(flag)) return usage(`rig does not take "${flag}"`);
420
444
  if (value === undefined) return usage(`${flag} needs a value`);
421
445
  i++;
422
446
  if (flag === '--config') config = value;
@@ -427,6 +451,9 @@ function cmdRig(args: string[]): number {
427
451
  } else if (flag === '--page-edges') {
428
452
  if (pageEdges !== null) return usage('--page-edges is given twice');
429
453
  pageEdges = value;
454
+ } else if (flag === '--pack-shape') {
455
+ if (packShape !== null) return usage('--pack-shape is given twice');
456
+ packShape = value;
430
457
  } else out = value;
431
458
  }
432
459
  if (config === null) return usage('rig needs --config <config.json>');
@@ -436,6 +463,8 @@ function cmdRig(args: string[]): number {
436
463
  if (!(IDLE_KEYS as readonly string[]).includes(keys)) return usage(`--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`);
437
464
  const edges = pageEdgesOf(pageEdges ?? undefined);
438
465
  if (!isPageEdges(edges)) return usage(edges);
466
+ const shape = packShapeOf(packShape ?? undefined);
467
+ if (!isPackShape(shape)) return usage(shape);
439
468
  let bin: string;
440
469
  try {
441
470
  bin = findRigc(import.meta.dir, process.env.PATH ?? '');
@@ -444,7 +473,7 @@ function cmdRig(args: string[]): number {
444
473
  }
445
474
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
446
475
  try {
447
- rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys, pageEdges: edges }, rigcRunner(bin), scratch, console.log);
476
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys, pageEdges: edges, packShape: shape }, rigcRunner(bin), scratch, console.log);
448
477
  return EXIT_OK;
449
478
  } catch (err) {
450
479
  return printRefusal(err);
@@ -486,15 +515,17 @@ function flags(args: string[], known: readonly string[], command: string, option
486
515
  }
487
516
 
488
517
  function cmdCheck(args: string[]): number {
489
- const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--page-edges']);
518
+ const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--page-edges', '--pack-shape']);
490
519
  if (typeof f === 'string') return usage(f);
491
520
  const rig = f.get('--rig') as string;
492
521
  const out = f.get('--out') as string;
493
522
  const edges = pageEdgesOf(f.get('--page-edges'));
494
523
  if (!isPageEdges(edges)) return usage(edges);
524
+ const shape = packShapeOf(f.get('--pack-shape'));
525
+ if (!isPackShape(shape)) return usage(shape);
495
526
  try {
496
527
  const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
497
- const r = checkStage({ rig, parts: f.get('--parts') ?? rig, out, pageEdges: edges }, rigcRunner(bin), bin, console.log);
528
+ const r = checkStage({ rig, parts: f.get('--parts') ?? rig, out, pageEdges: edges, packShape: shape }, rigcRunner(bin), bin, console.log);
498
529
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
499
530
  } catch (err) {
500
531
  return printRefusal(err);
@@ -799,7 +830,7 @@ function cmdInputs(args: string[]): number {
799
830
  function cmdBuild(args: string[]): number {
800
831
  const flags = new Map<string, string>();
801
832
  let loop = false;
802
- const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges'];
833
+ const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape'];
803
834
  for (let i = 0; i < args.length; i++) {
804
835
  const flag = args[i];
805
836
  if (flag === '--loop') {
@@ -821,6 +852,8 @@ function cmdBuild(args: string[]): number {
821
852
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
822
853
  const edges = pageEdgesOf(flags.get('--page-edges'));
823
854
  if (!isPageEdges(edges)) return usage(edges);
855
+ const shape = packShapeOf(flags.get('--pack-shape'));
856
+ if (!isPackShape(shape)) return usage(shape);
824
857
  const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
825
858
  let bin: string;
826
859
  try {
@@ -831,7 +864,7 @@ function cmdBuild(args: string[]): number {
831
864
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-build-'));
832
865
  try {
833
866
  const r = build(
834
- { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges },
867
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape },
835
868
  { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
836
869
  console.log,
837
870
  );
package/docs/AUTHORING.md CHANGED
@@ -279,11 +279,12 @@ 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,350 B lossless, 1,706,520 B indexed and 1,812,238 B GIF, both palette files at
282
+ 13,645,503 B lossless, 1,706,719 B indexed and 1,812,288 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
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
+ animation, spine-rigc 2.1.3); with `--pack-shape rect` the same build writes 13,645,350,
286
+ 1,706,520 and 1,812,238 B, and with `--page-edges pot --pack-shape rect` 13,645,519,
287
+ 1,706,785 and 1,812,625 B, all at the same error. The loop shows the blink closed: the
287
288
  eyes' hold is at least one 12 fps frame (below), so whatever `motion.blink.t` is, one
288
289
  idle frame lands inside it, and `check.json`'s `BLINK_NO_HOLE.idle_frames_closed`
289
290
  names it — `[29]` on both examples.
@@ -300,10 +301,11 @@ the reference's so the examples stay comparable with it.
300
301
  --page-edges <v>`, and hand the value to rigc verbatim. The default is `free`,
301
302
  the least-area page the parts need. `--page-edges pot` is the opt-out: a power of
302
303
  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:
304
+ line says which one ran, because rigc writes `, page edges free` after the padding under
305
+ `free`. Measured on the two examples by `tools/atlas_population.ts` with rigc 1.5.1, and
306
+ again with rigc 2.0.3 and with rigc 2.1.3 under `--pack-shape rect` (the frames table
307
+ by the same idle render), which reproduced every figure in both tables and the one
308
+ check figure below:
307
309
 
308
310
  | build | page | covered | page opaque | page / figure |
309
311
  | --- | --- | --- | --- | --- |
@@ -328,6 +330,56 @@ barred figure and every status is the same under both. The atlas rigc writes say
328
330
  `filter: Linear, Linear` and has no `repeat` line, so choose `pot` for a consumer
329
331
  that mipmaps or repeats the page.
330
332
 
333
+ **Pack shape.** `rig`, `check` and `build` also hand rigc `--pack-shape <v>`
334
+ verbatim (spine-rigc 2.1.0 and later). `rect` keeps every region's cell apart, the
335
+ only packing before 2.1. `polygon`, the default here (rigc's own is `rect`), packs a
336
+ region that only meshes draw by its emitted hull, so a neighbour may sit inside its
337
+ rectangle where the hull is not, with the padding kept between footprints; a region
338
+ attachment stays its rectangle. rigc gates the result on the packed pages with
339
+ `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` (SKIP on the compile pass, where every part has
340
+ its own page; PASS on the packed pass, both shapes, both examples). The pack line ends
341
+ `, shape rect` or `, shape polygon` under either, and `check.json`'s `pack_mode` records
342
+ both flags. Measured on the two examples with spine-rigc 2.1.3 (the full entry,
343
+ spine-core 4.3.13 beside it), `spine-parts build` with `--page-edges free` and each
344
+ shape, the table columns by `tools/atlas_population.ts`:
345
+
346
+ | build | page | page area | covered | page opaque | page / figure |
347
+ | --- | --- | --- | --- | --- | --- |
348
+ | demo, `rect` | 967x1338 | 1,293,846 | 91.2 % | 59.7 % | 1.636 |
349
+ | demo, `polygon` | 922x1348 | 1,242,856 (−3.9 %) | 95.0 % | 62.1 % | 1.571 |
350
+ | sample, `rect` | 479x1166 | 558,514 | 93.4 % | 53.9 % | 1.755 |
351
+ | sample, `polygon` | 477x1151 | 549,027 (−1.7 %) | 95.0 % | 54.8 % | 1.725 |
352
+
353
+ Covered is Σ region rectangles over page area, as rigc prints it; under `polygon` the
354
+ rectangles may overlap where a hull leaves room, so it is not a union and can exceed
355
+ 100 %. Page opaque is the measure of what the page holds. Under `--page-edges pot` the
356
+ shape changes nothing on these two examples: both shapes write 1024x2048 and 512x2048,
357
+ and the idle frames are identical.
358
+
359
+ The cost, from `rect` to `polygon` (the idle `check` renders, `rigc render --animation
360
+ idle --fps 12 --max 640`, compared frame by frame, max over R, G, B and A):
361
+
362
+ | example | idle frames that differ | pixels that differ (all 49 frames) | max \|d\| |
363
+ | --- | --- | --- | --- |
364
+ | demo | 49 of 49 | 2,594 | 1 |
365
+ | sample | 49 of 49 | 740 | 1 |
366
+
367
+ That is the class `free` already costs (the table above, measured by the same
368
+ comparison), a little larger. No figure in `check.json` moved but `pack_mode`: every
369
+ bar, every status and every reported figure is the same under both shapes.
370
+ `--pack-shape rect` gives back the page and atlas 0.7.0 wrote: on both examples
371
+ they are byte-identical to rigc 2.0.3's `--page-edges free` build of the same rig (the
372
+ skeleton JSON differs only in its relative `images` path), and selftest `CK53` holds
373
+ `rect` to rigc's own rectangle page.
374
+
375
+ The hull ceiling (spine-parts #58: hull area over rectangle area, 0.812 on demo and
376
+ 0.814 on sample) is not what the packer realises here: the hollows inside the meshes'
377
+ rectangles are worth only what fits into them. On the polygon pages, read off the
378
+ atlas, demo's packed rectangles overlap in 21 pairs (17 a region attachment inside a
379
+ mesh's rectangle, 4 a mesh inside another's) and sample's in 12 (10 and 2); no two
380
+ region attachments overlap. More would take tighter hulls, meshes in place of
381
+ regions, or a stronger nesting search in rigc, each its own card.
382
+
331
383
  `--project core` (the default) is the reference's projection rule: a layer takes the
332
384
  painting's pixel only inside its top-most `alpha >= 250` area eroded by a 5x5 square,
333
385
  so a part a few pixels wide (a lash, a brow, an iris) takes none. `--project visible`
@@ -590,7 +642,7 @@ See-through with another seed.
590
642
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
591
643
  | `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/` |
592
644
  | `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) |
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` |
645
+ | `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.1.3), or put a newer `rigc` first on `PATH` |
594
646
  | `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` |
595
647
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
596
648
  | `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 |
@@ -604,8 +656,9 @@ See-through with another seed.
604
656
  | `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 |
605
657
  | `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 |
606
658
  | `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` |
659
+ | `CHECK_PACK_LINE_READS` | rigc printed a `pack:` line that is not `pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>`, then `, page edges free` or nothing, then `, shape rect`, `, shape polygon` or nothing (spine-rigc 2.1 prints the shape; 1.5–2.0 printed none, read as `rect`); an unknown shape is this refusal; the line is quoted | a rigc this package does not know the output of; report it, with `rigc --version` |
660
+ | `CHECK_PACK_PAGE_EDGES` | the pack line disagrees with the `--page-edges` the build was run with — its `, page edges free` clause 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` |
661
+ | `CHECK_PACK_SHAPE` | the pack line disagrees with the `--pack-shape` the build was run with — it ends `, shape rect` under `polygon` or `, shape polygon` under `rect`, or it has no shape clause (the 1.5–2.0 form, read as `rect`) under `polygon` | a rigc older than 2.1.0 on `PATH`: rigc 2.0.3 takes `--pack-shape` without a word, packs by rectangles and prints the form with no shape clause, so this refusal is what catches it — `bun install` (this package depends on spine-rigc ^2.1.3); otherwise a rigc problem, report it with `rigc --version` |
609
662
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
610
663
 
611
664
  ## 7. The bars `check` enforces, and what only an eye answers today
@@ -617,7 +670,9 @@ implementation's bars):
617
670
  under the `spine-html` profile, gated once over the compile and once over the packed
618
671
  pages on disk. There is no `spine` gate of its own: `spine-html` holds every rule
619
672
  `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`
673
+ measured rules are all among the build's 23, the 15 others not in `spine`; rigc
674
+ 2.1.3: the same 14 among the build's 24, which adds
675
+ `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` on the packed pass, 16 not in `spine`; `CH09`
621
676
  holds it on every fetched example);
622
677
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
623
678
  max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.7.0",
3
+ "version": "0.8.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,7 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "ag-psd": "^31.0.2",
60
- "spine-rigc": "^2.0.3"
60
+ "spine-rigc": "^2.1.3"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@esotericsoftware/spine-core": "4.3.13",
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 { 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';
47
+ import { causeLines, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, 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';
@@ -192,21 +192,21 @@ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
192
192
 
193
193
  /**
194
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
195
+ * spine-html profile with `--pack --page-edges <edges> --pack-shape <shape>`, which runs the gate
196
196
  * once over the compile and once over the packed pages on disk. There is no
197
197
  * second `validate --profile spine` run: spine-html holds every rule spine
198
198
  * measures (selftest `CH09`). The files are staged exactly as `--out` will
199
199
  * receive them, so what passed is what is written. The scratch directory is
200
200
  * the caller's, and is emptied here before and after.
201
201
  */
202
- function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string, edges: PageEdges): GateRun[] {
202
+ function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string, mode: PackMode): GateRun[] {
203
203
  rmSync(scratch, { recursive: true, force: true });
204
204
  try {
205
205
  mkdirSync(join(scratch, 'images'), { recursive: true });
206
206
  for (const [file, img] of out.images) writeFileSync(join(scratch, 'images', file), encodePngBytes(img));
207
207
  for (const [file, text] of texts) writeFileSync(join(scratch, file), text);
208
208
  const build = join(scratch, 'build');
209
- return [gateRun(packedBuildLabel(edges), rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...packedBuildArgs(edges)])];
209
+ return [gateRun(packedBuildLabel(mode), rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...packedBuildArgs(mode)])];
210
210
  } finally {
211
211
  rmSync(scratch, { recursive: true, force: true });
212
212
  }
@@ -221,6 +221,8 @@ export interface RigStageInput {
221
221
  idleKeys?: IdleKeys;
222
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
223
  pageEdges?: PageEdges;
224
+ /** The gate build's `--pack-shape`; {@link DEFAULT_PACK_SHAPE} when absent. */
225
+ packShape?: PackShape;
224
226
  }
225
227
 
226
228
  /** Author the rig, gate it through rigc in `scratch`, and write `out` only when the gate is green. */
@@ -258,7 +260,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
258
260
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
259
261
  : ` 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'}`,
260
262
  );
261
- const gate = gateThroughRigc(rig, texts, rigc, scratch, input.pageEdges ?? DEFAULT_PAGE_EDGES);
263
+ const gate = gateThroughRigc(rig, texts, rigc, scratch, { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE });
262
264
  for (const g of gate) {
263
265
  log(` rigc ${g.label}: exit ${g.status}`);
264
266
  for (const l of g.lines) log(` ${l.trim()}`);
@@ -291,6 +293,8 @@ export interface CheckStageInput {
291
293
  out: string;
292
294
  /** The packed build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. */
293
295
  pageEdges?: PageEdges;
296
+ /** The packed build's `--pack-shape`; {@link DEFAULT_PACK_SHAPE} when absent. */
297
+ packShape?: PackShape;
294
298
  }
295
299
 
296
300
  /** The pack line as it is printed: rigc's own, then the page's opaque share beside the spineboy yardstick. */
@@ -328,9 +332,9 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
328
332
  const versionLines = v.out.trim().split('\n');
329
333
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
330
334
  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:`);
335
+ const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
336
+ const r = runCheck(input.rig, input.out, rigc, input.parts, mode);
337
+ log(` gate spine-html (rigc ${packedBuildLabel(mode)}), verbatim:`);
334
338
  for (const l of r.gateHtml) log(l);
335
339
  for (const l of packLines(r)) log(` ${l}`);
336
340
  const fig = r.figures;
@@ -441,6 +445,8 @@ export interface BuildInput {
441
445
  loop: boolean;
442
446
  /** `--page-edges` for both packed builds, the rig stage's gate and the check's artifact. */
443
447
  pageEdges: PageEdges;
448
+ /** `--pack-shape` for both packed builds, as `pageEdges`; {@link DEFAULT_PACK_SHAPE} when absent. */
449
+ packShape?: PackShape;
444
450
  }
445
451
 
446
452
  /**
@@ -514,7 +520,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
514
520
  const out = input.out;
515
521
  mkdirSync(out, { recursive: true });
516
522
  for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
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' : ''}`);
523
+ log(`spine-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${input.loop ? ', with the idle loop' : ''}`);
518
524
 
519
525
  try {
520
526
  // build runs rig next, which needs the whole config, so the full loader is
@@ -532,14 +538,14 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
532
538
  }
533
539
 
534
540
  try {
535
- rigStage({ config: input.config, parts: out, out: join(out, 'rig'), pageEdges: input.pageEdges }, run.rig, run.scratch, prefixed('rig'));
541
+ rigStage({ config: input.config, parts: out, out: join(out, 'rig'), pageEdges: input.pageEdges, packShape: input.packShape }, run.rig, run.scratch, prefixed('rig'));
536
542
  } catch (err) {
537
543
  return refused('rig', err);
538
544
  }
539
545
 
540
546
  let report: CheckReport;
541
547
  try {
542
- report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges }, run.check, run.checkBin, prefixed('check'));
548
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape }, run.check, run.checkBin, prefixed('check'));
543
549
  } catch (err) {
544
550
  return refused('check', err);
545
551
  }
package/src/check.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * | output | what |
27
27
  * | --- | --- |
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 |
28
+ * | `build/` | `rigc build --profile spine-html --pack --page-edges free --pack-shape polygon` (or `pot`, `rect`, when the caller names them; {@link PAGE_EDGES}, {@link PACK_SHAPES}) — the packed atlas is the final artifact |
29
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`) |
30
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) |
31
31
  * | `contact.png` | rigc's own contact sheet of that render, copied out |
@@ -196,7 +196,7 @@ export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha >
196
196
  * `free` is the default here although rigc's own default is `pot`: the packed
197
197
  * page is this package's final artifact, and on the two public examples `free`
198
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
199
+ * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1, 2.0.3 and 2.1.3 (`rect`) alike. The cost was
200
200
  * measured on the same two builds. Each of the 49 idle frames differs from the
201
201
  * `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
202
202
  * px over all 49). On sample that moves STILL_REGIONS_DARK's reported
@@ -209,14 +209,53 @@ export const PAGE_EDGES = ['pot', 'free'] as const;
209
209
  export type PageEdges = (typeof PAGE_EDGES)[number];
210
210
  export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
211
211
 
212
+ /**
213
+ * What two packed rectangles may share: spine-rigc's `build --pack
214
+ * --pack-shape`, handed to it verbatim (in spine-rigc's CLI since 2.1.0; this
215
+ * package's range starts at 2.1.3). `rect` keeps every region's cell apart;
216
+ * `polygon` packs a region that only meshes draw by its emitted hull, so a
217
+ * neighbour may sit inside its rectangle where the hull is not, with the
218
+ * padding kept between footprints — a region attachment stays its rectangle
219
+ * (rigc's help).
220
+ *
221
+ * `polygon` is the default here although rigc's own default is `rect`, for the
222
+ * reason {@link DEFAULT_PAGE_EDGES} is `free`: the packed page is this
223
+ * package's final artifact, rigc gates the footprints on the pages on disk
224
+ * (`A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP`), and the cost is the class already
225
+ * accepted for `free`. Measured with spine-rigc 2.1.3 (the full entry,
226
+ * spine-core 4.3.13 beside it), `spine-parts build` on the two public examples
227
+ * under `--page-edges free`: `polygon` takes demo's page from 967x1338 (91.2 %
228
+ * covered) to 922x1348 (95.0 %), 3.9 % less area, and sample's from 479x1166
229
+ * (93.4 %) to 477x1151 (95.0 %), 1.7 % less. Each of the 49 idle frames at 12
230
+ * fps differs from the `rect` build's by at most 1 level in any channel (demo
231
+ * 2,594 px, sample 740 px over all 49), and no figure in `check.json` but
232
+ * `pack_mode` moves. Under `--page-edges pot` both shapes give the same page
233
+ * size and the same frames. AUTHORING §5, *Pack shape*, holds the tables.
234
+ * `--pack-shape rect` writes the page and atlas rigc 2.0.3 wrote for 0.7.0
235
+ * (measured byte for byte on both examples; selftest `CK53` holds rect to
236
+ * rigc's own rect page).
237
+ */
238
+ export const PACK_SHAPES = ['rect', 'polygon'] as const;
239
+ export type PackShape = (typeof PACK_SHAPES)[number];
240
+ export const DEFAULT_PACK_SHAPE: PackShape = 'polygon';
241
+
242
+ /** How a packed build packs: its `--page-edges` and its `--pack-shape`, each handed to rigc verbatim. */
243
+ export interface PackMode {
244
+ pageEdges: PageEdges;
245
+ packShape: PackShape;
246
+ }
247
+
248
+ /** Both defaults, {@link DEFAULT_PAGE_EDGES} and {@link DEFAULT_PACK_SHAPE}. */
249
+ export const DEFAULT_PACK_MODE: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE };
250
+
212
251
  /** 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];
252
+ export function packedBuildArgs(mode: PackMode): string[] {
253
+ return ['--profile', 'spine-html', '--pack', '--page-edges', mode.pageEdges, '--pack-shape', mode.packShape];
215
254
  }
216
255
 
217
256
  /** 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(' ')}`;
257
+ export function packedBuildLabel(mode: PackMode): string {
258
+ return `build ${packedBuildArgs(mode).join(' ')}`;
220
259
  }
221
260
 
222
261
  // ---------------------------------------------------------------------------
@@ -553,19 +592,32 @@ export interface PackLine {
553
592
  regions: number;
554
593
  coveredPct: number;
555
594
  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). */
595
+ /** `free` when the line carries `, page edges free` after the padding, `pot` when it does not — spine-rigc prints the clause exactly when it packed under `--page-edges free` (its `src/cli/shared.ts`, the pack line). */
557
596
  pageEdges: PageEdges;
597
+ /**
598
+ * The line's last clause, `, shape rect` or `, shape polygon`: spine-rigc
599
+ * 2.1 names the `--pack-shape` it packed under on every pack line, its
600
+ * default included. A line with no shape clause is the 1.5.1–2.0.3 form,
601
+ * from a rigc that had no other shape, and reads as `rect`.
602
+ */
603
+ packShape: PackShape;
558
604
  }
559
605
 
560
606
  /** 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)?$/;
607
+ const PACK = /^pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), (\d+(?:\.\d+)?)% covered, padding (\d+)(, page edges free)?(?:, shape (rect|polygon))?$/;
608
+
609
+ /** A pack line that names its shape: the spine-rigc 2.1 form. */
610
+ const PACK_SHAPE_CLAUSE = /, shape (?:rect|polygon)$/;
562
611
 
563
612
  /**
564
613
  * 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.
614
+ * `..` gutter is taken off. Two forms are read, each by name: spine-rigc
615
+ * 2.1's, which ends `, shape rect` or `, shape polygon`, and the 1.5.1–2.0.3
616
+ * form, which has no shape clause and reads as `rect`; in both,
617
+ * `, page edges free` sits between the padding and the shape or is absent. Any
618
+ * other line (an unknown shape, an unknown suffix, a missing field, the clauses
619
+ * out of order) is refused, `CHECK_PACK_LINE_READS`, naming the line and the
620
+ * form required: a line half-read would lose the field that changed.
569
621
  */
570
622
  export function parsePackLines(lines: readonly string[]): PackLine[] {
571
623
  const out: PackLine[] = [];
@@ -578,11 +630,21 @@ export function parsePackLines(lines: readonly string[]): PackLine[] {
578
630
  problems.push({
579
631
  code: 'CHECK_PACK_LINE_READS',
580
632
  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',
633
+ detail: 'does not read as "pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>", then ", page edges free" or nothing, then ", shape rect", ", shape polygon" or nothing; that form is required',
582
634
  });
583
635
  continue;
584
636
  }
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' });
637
+ out.push({
638
+ line: text,
639
+ page: m[1],
640
+ width: Number(m[2]),
641
+ height: Number(m[3]),
642
+ regions: Number(m[4]),
643
+ coveredPct: Number(m[5]),
644
+ padding: Number(m[6]),
645
+ pageEdges: m[7] === undefined ? 'pot' : 'free',
646
+ packShape: m[8] === 'polygon' ? 'polygon' : 'rect',
647
+ });
586
648
  }
587
649
  refuseIfAny(problems);
588
650
  return out;
@@ -593,12 +655,20 @@ function powerOfTwo(n: number): boolean {
593
655
  }
594
656
 
595
657
  /**
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.
658
+ * Where a pack line disagrees with the pack mode the build was given. The
659
+ * line's own page-edges clause must say the `--page-edges` passed, and a `pot`
660
+ * page must be a power of two on both edges, as rigc's help defines it,
661
+ * `CHECK_PACK_PAGE_EDGES`. By the same rule the line's shape must be the
662
+ * `--pack-shape` passed, `CHECK_PACK_SHAPE` — a line with no shape clause
663
+ * reads as `rect`, so under `polygon` it disagrees. That is the case that
664
+ * matters: rigc 2.0.3 takes `--pack-shape` without a word, packs by
665
+ * rectangles and prints the form with no shape clause (measured), so a rigc
666
+ * older than 2.1.0 first on `PATH` is refused here rather than shipping a
667
+ * rect page as a polygon one. One problem per disagreement; empty is
668
+ * agreement.
600
669
  */
601
- export function packEdgeProblems(pack: readonly PackLine[], edges: PageEdges): Problem[] {
670
+ export function packEdgeProblems(pack: readonly PackLine[], mode: PackMode): Problem[] {
671
+ const edges = mode.pageEdges;
602
672
  const problems: Problem[] = [];
603
673
  for (const p of pack) {
604
674
  const object = `packed page ${p.page}`;
@@ -606,11 +676,18 @@ export function packEdgeProblems(pack: readonly PackLine[], edges: PageEdges): P
606
676
  problems.push({
607
677
  code: 'CHECK_PACK_PAGE_EDGES',
608
678
  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`,
679
+ detail: `rigc's pack line says page edges ${p.pageEdges} (${p.pageEdges === 'free' ? 'it carries ", page edges free"' : 'no ", page edges free" clause'}); the build was run with --page-edges ${edges}, so page edges ${edges} is required`,
610
680
  });
611
681
  } else if (edges === 'pot' && !(powerOfTwo(p.width) && powerOfTwo(p.height))) {
612
682
  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
683
  }
684
+ if (p.packShape !== mode.packShape) {
685
+ problems.push({
686
+ code: 'CHECK_PACK_SHAPE',
687
+ object,
688
+ detail: `rigc's pack line says shape ${p.packShape} (${PACK_SHAPE_CLAUSE.test(p.line) ? `it ends ", shape ${p.packShape}"` : 'no ", shape" clause: the 1.5.1-2.0.3 form, read as rect'}); the build was run with --pack-shape ${mode.packShape}, so shape ${mode.packShape} is required`,
689
+ });
690
+ }
614
691
  }
615
692
  return problems;
616
693
  }
@@ -885,6 +962,8 @@ export interface CheckFigures {
885
962
  gate_spine_html_green: boolean;
886
963
  /** Which rigc entry gated the build ({@link readRigcEntry}): provenance, not a bar. */
887
964
  rigc_entry: RigcEntryRecord;
965
+ /** How the artifact's page was packed: the `--page-edges` and `--pack-shape` rigc was handed, which its pack line was read to agree with ({@link packEdgeProblems}). Provenance, not a bar. */
966
+ pack_mode: { page_edges: PageEdges; pack_shape: PackShape };
888
967
  loop_max_diff: number;
889
968
  seam_mean: number;
890
969
  seam_px_over_40: number;
@@ -931,11 +1010,12 @@ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[
931
1010
  * gate file is written first, so the refusal's evidence is on disk. Otherwise
932
1011
  * it measures everything, writes every output including `check.json`, and
933
1012
  * returns the report; `report.problems` names each bar that was not met, and
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`).
1013
+ * `figures.PASS` is true exactly when it is empty. `mode` reaches rigc's
1014
+ * `--page-edges` and `--pack-shape` verbatim, a pack line that disagrees with
1015
+ * either is refused (`CHECK_PACK_PAGE_EDGES`, `CHECK_PACK_SHAPE`), and
1016
+ * `check.json` records it as `pack_mode`.
937
1017
  */
938
- export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir, pageEdges: PageEdges = DEFAULT_PAGE_EDGES): CheckReport {
1018
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir, mode: PackMode = DEFAULT_PACK_MODE): CheckReport {
939
1019
  const inp = readCheckInputs(rigDir, partsHome);
940
1020
  const rigcEntry = readRigcEntry(requireRigcVersion(rigc));
941
1021
  const out = resolve(outDir);
@@ -952,12 +1032,12 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
952
1032
  };
953
1033
 
954
1034
  // 1. build, packed, under the policy profile
955
- const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, ...packedBuildArgs(pageEdges)]);
1035
+ const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, ...packedBuildArgs(mode)]);
956
1036
  const gateHtml = buildGateLines(build.out);
957
1037
  write('gate_spine-html.txt', `${gateHtml.join('\n')}\n`);
958
- if (build.status !== 0) refuseIfAny(rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
1038
+ if (build.status !== 0) refuseIfAny(rigcFailed(packedBuildLabel(mode), build, gateHtml));
959
1039
  const pack = parsePackLines(gateHtml);
960
- refuseIfAny(packEdgeProblems(pack, pageEdges));
1040
+ refuseIfAny(packEdgeProblems(pack, mode));
961
1041
  const packOpaque = pack.filter((p) => existsSync(join(buildDir, p.page))).map((p) => ({ page: p.page, share: opaqueShare(readPng(join(buildDir, p.page))) }));
962
1042
 
963
1043
  // 2. no second gate: `rigc validate --profile spine` of this build measured no rule the build's own two passes
@@ -1080,6 +1160,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1080
1160
  const figures: CheckFigures = {
1081
1161
  gate_spine_html_green: htmlGreen,
1082
1162
  rigc_entry: rigcEntry,
1163
+ pack_mode: { page_edges: mode.pageEdges, pack_shape: mode.packShape },
1083
1164
  loop_max_diff: loop.max,
1084
1165
  seam_mean: round3(seam.mean),
1085
1166
  seam_px_over_40: seam.over40,
@@ -1094,7 +1175,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1094
1175
  PASS: false,
1095
1176
  };
1096
1177
  const barProblems: Problem[] = [];
1097
- if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
1178
+ if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(mode), build, gateHtml));
1098
1179
  if (figures.loop_max_diff !== LOOP_MAX_BAR) {
1099
1180
  barProblems.push({
1100
1181
  code: 'CHECK_LOOP_CLOSES',