spine-parts 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE.md CHANGED
@@ -39,12 +39,14 @@ wrapper code.
39
39
  | Weights | Licence as stated on the model card | Observed |
40
40
  | --- | --- | --- |
41
41
  | [`layerdifforg/seethroughv0.0.2_layerdiff3d`](https://huggingface.co/layerdifforg/seethroughv0.0.2_layerdiff3d) | `license:apache-2.0` tag | 2026-09-27, Hugging Face model API |
42
+ | (same card, after its 2026-09-28 commit *"Add licence terms, NOTICE and base model metadata"*) | `license:openrail++` tag; the card's *Licence* section says the maintainers' own contributions are Apache-2.0 and the weights also inherit the licences of the models they are derived from — Animagine XL 4.0 / Stable Diffusion XL 1.0 (CreativeML Open RAIL++-M) and LayerDiffuse (CreativeML Open RAIL-M) | 2026-09-28, Hugging Face model API and card |
42
43
  | [`layerdifforg/seethroughv0.0.1_marigold`](https://huggingface.co/layerdifforg/seethroughv0.0.1_marigold) | no licence tag on the card | 2026-09-27, Hugging Face model API |
44
+ | (same card, after its 2026-09-28 revision) | `license:openrail++` tag | 2026-09-28, Hugging Face model API |
43
45
 
44
46
  Maintainer statement, on the Hugging Face discussion
45
47
  [`layerdifforg/seethroughv0.0.2_layerdiff3d/discussions/1`](https://huggingface.co/layerdifforg/seethroughv0.0.2_layerdiff3d/discussions/1)
46
48
  (user `24yearsold`, 2026-04-07): *"Yes all our models are aligned with our main
47
- repo to use Apache 2.0."*
49
+ repo to use Apache 2.0."* The 2026-09-28 card revision above narrows that: Apache-2.0 for the maintainers' contributions, with the base models' Open RAIL terms inherited. spine-parts does not distribute these weights; a user who fetches them accepts the terms the card states on the day they fetch.
48
50
 
49
51
  ### Training data
50
52
 
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,10 +87,10 @@ 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
- | `check/` | both gate files verbatim, the idle's frames, `contact.png`, `motion_heat.png`, `check.json` |
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` |
94
94
  | `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 |
95
95
 
96
96
  The last lines of a green build are the pack line, printed beside the Spine example
@@ -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
 
@@ -132,6 +143,27 @@ spine-parts at what it wrote:
132
143
  | Upstream CLI | `python inference/scripts/inference_psd.py --srcp <image> --save_to_psd` in a checkout of [shitagaki-lab/see-through](https://github.com/shitagaki-lab/see-through) | the `.psd` in `workspace/layerdiff_output/` |
133
144
  | ComfyUI wrapper | [jtydhr88/ComfyUI-See-through](https://github.com/jtydhr88/ComfyUI-See-through) on your own ComfyUI box; the optional `comfy` adapter (`spine-parts comfy seethrough`) drives it | the `layers.json` + PNGs it writes |
134
145
 
146
+ **The head run is needed on every route that was read.** See-through's model
147
+ already separates the head in a second stage of its own: it crops the head the
148
+ first stage found, runs the face tags on that crop at the same `resolution`, and
149
+ pastes the result back **resized to the full image's scale**. So the face comes
150
+ back at the full run's pixel density whichever route ran it, and the separate
151
+ head run on `st_input_head.png` is what gives the eyes more pixels
152
+ (read on 2026-09-28 at see-through `a25a549` and wrapper 0.5.0 `98d754b`):
153
+
154
+ - *Hugging Face Space* — the same two-stage code, with `resolution` capped at
155
+ 1280 (768 by default): the head run is still needed.
156
+ - *ModelScope demo* — its code was not read, so what the head run adds on that
157
+ route has not been measured.
158
+ - *Upstream CLI* — the `.psd` frame is the `resolution`-sided square the first
159
+ stage ran on (1280 by default), and the head stage is pasted into it at that
160
+ scale: the head run is still needed.
161
+ - *ComfyUI wrapper* — `SeeThrough_GenerateLayers` runs both stages with no input
162
+ to change the second, at most `resolution` 2048, which on the examples' 2432 px
163
+ square is 1.19 source px per layer px against 0.77 and 0.47 for their head
164
+ crops; at the 1024 the examples ran, the full run's right eye white is 22×13 px
165
+ and the head run's 60×38: the head run is still needed.
166
+
135
167
  **See-through is required; ComfyUI is not.** The wrapper is one route among
136
168
  four, and `spine-parts comfy` is only a convenience for that route: every
137
169
  stage after See-through reads files, whichever route wrote them. On the two
@@ -222,7 +254,7 @@ agent skill.
222
254
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
223
255
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
224
256
  | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared |
225
- | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the five judgement lines, and report the recomposite's holes from `parts.json` |
257
+ | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json` |
226
258
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
227
259
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
228
260
 
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, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK } 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';
@@ -77,7 +77,8 @@ usage:
77
77
  regions, motion with its blink — and a blink.still cut for a lash that
78
78
  reaches far above its eyewhite, when a clear row allows one; no blink
79
79
  when no part is an eyewhite, and no brows in it when no part is an
80
- eyebrow, each said in a note — and notes)
80
+ eyebrow, each said in a note; an iris or lash on a side with no
81
+ eyewhite rides head, said in a note — and notes)
81
82
  and the overlay to correct against, <out>/render/landmarks.png and
82
83
  landmarks_head.png. Prints every
83
84
  note and a LINT line for each chain link that lies off its mesh's art, for a
@@ -97,6 +98,7 @@ usage:
97
98
  printed when one was needed.
98
99
 
99
100
  spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
101
+ [--page-edges pot|free]
100
102
  Author the rig: unrotated bones at the config's landmarks (a chain makes
101
103
  <chain>0..n), a square lattice mesh over every part in config.meshes
102
104
  weighted by distance to its candidate bone segments, a region for every
@@ -105,11 +107,12 @@ usage:
105
107
  and one idle of sines and a blink whose closed hold is at least one
106
108
  12 fps frame, so the idle frames and the loop show the eyes shut
107
109
  (RIG_BLINK_HOLD_SPANS_A_FRAME otherwise); no blink when the config
108
- states no motion.blink, and a blink group that names no bone is refused
109
- by the loader, CONFIG_BLINK_GROUP_MEMBERS, before rigc starts. --parts is the
110
+ states no motion.blink, and a blink group that names no bone, or one
111
+ bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
112
+ CONFIG_BLINK_GROUP_UNIQUE, before rigc starts. --parts is the
110
113
  directory holding parts.json and parts/<name>.png. The result is built
111
- through spine-rigc (profile spine-html, packed, then validated under
112
- 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
113
116
  images/*.png, rig.json, motion.json and mesh_report.json only when both
114
117
  are green. Prints one line per mesh and the rigc gate lines.
115
118
  --idle-keys says where the idle's keys on a bone a mesh is weighted to go:
@@ -119,27 +122,38 @@ usage:
119
122
  spine-rigc 1.3.0 or later and makes A15 a SKIP that prints its cost (the
120
123
  stage prints that SKIP line). The pose is the same to one level of float
121
124
  rounding, and so is the per-frame mesh work (AUTHORING §5).
122
- spine-parts check --rig <dir> --out <dir> [--parts <dir>]
125
+ spine-parts check --rig <dir> --out <dir> [--parts <dir>] [--page-edges pot|free]
123
126
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
124
127
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
125
128
  (with an "idle").
126
129
  ${PARTS_HOME_SENTENCE}.
127
130
  Both are only read. Into --out:
128
- build/ (rigc build --profile spine-html --pack: the packed atlas is the
129
- artifact), gate_spine-html.txt and gate_spine.txt (the gate lines
130
- verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640),
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
133
+ verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640
134
+ --geometry: the frames and geometry.json, the skinned vertices and
135
+ every bone's world transform per frame),
131
136
  contact.png, motion_heat.png and check.json. PASS needs both gates
132
137
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
133
138
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
134
- frame at t = duration) at max |d| 0. Then five judgement lines, each in
139
+ frame at t = duration) at max |d| 0. Then six judgement lines, each in
135
140
  check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
136
141
  bars (AUTHORING §7): BREATH_VISIBLE (the topwear moves, the footwear does
137
142
  not, each rendered alone), BLINK_NO_HOLE (the setup pose with the blink
138
143
  held shut shows no background inside the eyewhite box), CHAIN_LAG (every
139
144
  rotate track lags its keyed ancestor and amplitude grows down each
140
145
  chain, read off motion.json), TIP_OVER_ROOT (each handwear/bottomwear
141
- part's lower half travels further than its upper half) and
142
- STILL_REGIONS_DARK (the heat map over the face outline and the feet).
146
+ part's lower half travels further than its upper half),
147
+ STILL_REGIONS_DARK (the face outline in the frame of the bone the face's
148
+ slot rides, at most twice the resampler's own error on this rig — read
149
+ off the whole rig moved rigidly as the idle moves that bone — with the
150
+ screen-space figure reported beside it; the feet in screen space) and
151
+ TEXTURE_STRETCH (every mesh triangle's edges over the idle against their
152
+ rest length, max(ratio, 1/ratio) <= ${TEXTURE_STRETCH_CEILING}; the worst
153
+ triangle is named by slot, triangle, vertices, edge and frame).
154
+ Needs spine-rigc ${RIGC_GEOMETRY_VERSION} or later, whose render writes
155
+ geometry.json; an older rigc is refused, CHECK_RIGC_VERSION, before
156
+ anything is built.
143
157
  Regions come from parts.json's See-through tags; a line with nothing to
144
158
  read says SKIP and why — neither a pass nor a failure — and PASS needs
145
159
  every line that measured to be PASS. Then RECOMPOSITE_HOLES: REPORTED,
@@ -150,6 +164,16 @@ usage:
150
164
  beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
151
165
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
152
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.
153
177
 
154
178
  spine-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
155
179
  Encode a frame set rigc render wrote (its --out directory, or the set
@@ -234,7 +258,7 @@ usage:
234
258
 
235
259
  spine-parts build --config <config.json> --source <painting.png> --full <dir|psd>
236
260
  --head <dir|psd> --out <dir> [--seam near-white|silhouette]
237
- [--project core|visible] [--loop]
261
+ [--project core|visible] [--page-edges pot|free] [--loop]
238
262
  assemble, then rig, then check, in one process, each stage's own lines
239
263
  printed under [assemble], [rig] and [check]; the first stage that refuses
240
264
  stops the build with its own FAIL lines. The config must already carry
@@ -250,7 +274,8 @@ usage:
250
274
  three artifact paths — skeleton .json, .atlas and the packed page: the
251
275
  packed atlas is the result, the loose parts are the intermediate it was
252
276
  made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
253
- ${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).
254
279
 
255
280
  spine-parts --version
256
281
  spine-parts --help
@@ -385,15 +410,26 @@ function rigGateRunner(): RigcRunner {
385
410
  };
386
411
  }
387
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
+
388
423
  function cmdRig(args: string[]): number {
389
424
  let config: string | null = null;
390
425
  let partsDir: string | null = null;
391
426
  let out: string | null = null;
392
427
  let idleKeys: string | null = null;
428
+ let pageEdges: string | null = null;
393
429
  for (let i = 0; i < args.length; i++) {
394
430
  const flag = args[i];
395
431
  const value = args[i + 1];
396
- 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}"`);
397
433
  if (value === undefined) return usage(`${flag} needs a value`);
398
434
  i++;
399
435
  if (flag === '--config') config = value;
@@ -401,6 +437,9 @@ function cmdRig(args: string[]): number {
401
437
  else if (flag === '--idle-keys') {
402
438
  if (idleKeys !== null) return usage('--idle-keys is given twice');
403
439
  idleKeys = value;
440
+ } else if (flag === '--page-edges') {
441
+ if (pageEdges !== null) return usage('--page-edges is given twice');
442
+ pageEdges = value;
404
443
  } else out = value;
405
444
  }
406
445
  if (config === null) return usage('rig needs --config <config.json>');
@@ -408,9 +447,11 @@ function cmdRig(args: string[]): number {
408
447
  if (out === null) return usage('rig needs --out <dir>');
409
448
  const keys = idleKeys ?? DEFAULT_IDLE_KEYS;
410
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);
411
452
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
412
453
  try {
413
- 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);
414
455
  return EXIT_OK;
415
456
  } catch (err) {
416
457
  return printRefusal(err);
@@ -443,13 +484,15 @@ function flags(args: string[], known: readonly string[], command: string, option
443
484
  }
444
485
 
445
486
  function cmdCheck(args: string[]): number {
446
- const f = flags(args, ['--rig', '--out'], 'check', ['--parts']);
487
+ const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--page-edges']);
447
488
  if (typeof f === 'string') return usage(f);
448
489
  const rig = f.get('--rig') as string;
449
490
  const out = f.get('--out') as string;
491
+ const edges = pageEdgesOf(f.get('--page-edges'));
492
+ if (!isPageEdges(edges)) return usage(edges);
450
493
  try {
451
494
  const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
452
- 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);
453
496
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
454
497
  } catch (err) {
455
498
  return printRefusal(err);
@@ -754,7 +797,7 @@ function cmdInputs(args: string[]): number {
754
797
  function cmdBuild(args: string[]): number {
755
798
  const flags = new Map<string, string>();
756
799
  let loop = false;
757
- const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project'];
800
+ const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges'];
758
801
  for (let i = 0; i < args.length; i++) {
759
802
  const flag = args[i];
760
803
  if (flag === '--loop') {
@@ -774,6 +817,8 @@ function cmdBuild(args: string[]): number {
774
817
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
775
818
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
776
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);
777
822
  const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
778
823
  let bin: string;
779
824
  try {
@@ -784,7 +829,7 @@ function cmdBuild(args: string[]): number {
784
829
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-build-'));
785
830
  try {
786
831
  const r = build(
787
- { 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 },
788
833
  { rig: rigGateRunner(), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
789
834
  console.log,
790
835
  );
package/docs/AUTHORING.md CHANGED
@@ -76,7 +76,7 @@ read by no CPU stage).
76
76
  | `regions.<part>` | rig | **proposed**: the bone a rigid part rides. Every plan part is exactly one of a mesh or a region (`CONFIG_PART_ATTACHED`) |
77
77
  | `motion.duration` | rig; check (the loop is measured at this time) | proposed as 4 s; a whole number of 1/12 s ticks, because `check` renders at 12 fps |
78
78
  | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`) |
79
- | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members`. The timing is fixed, not a field (table below) |
79
+ | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members` — as is a group that names one bone twice (`CONFIG_BLINK_GROUP_UNIQUE`; rigc: `group "eyes" names member "eye" twice`). An `irides-*` or `eyelash-*` part on a side with no eyewhite has no eye bone to ride: `propose` places it on `head` as a region, which the blink does not move, and notes `no eyewhite part for eye_r (looked for: eyewhite-r): iris (head:irides-r) is placed on head as a region, …`. The timing is fixed, not a field (table below) |
80
80
  | `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
81
81
 
82
82
  The blink's timing is the tree's (`BLINK` in `src/motion.ts`), in seconds after
@@ -101,7 +101,8 @@ leave it 0.01 s after the lids open. The rig stage refuses a hold under one fram
101
101
  name (`RIG_BLINK_HOLD_SPANS_A_FRAME`, §6), counted over the same 250,000 phases.
102
102
 
103
103
  The proposer's reach (from `src/propose.ts`): roles come from each part's tag —
104
- `face` makes `hip`/`chest`/`neck`/`head`, the eyewhites make the eye bones, brows are
104
+ `face` makes `hip`/`chest`/`neck`/`head`, the eyewhites make the eye bones (irides and
105
+ lashes ride them, or `head` on a side with no eyewhite), brows are
105
106
  told apart by position, `bottomwear` makes the hip and three skirt chains,
106
107
  `handwear` makes sleeve chains (one blob or two), `front hair` makes fringe chains
107
108
  and a lock chain for each strand that hangs below the chin, `back hair` makes a bun
@@ -226,7 +227,11 @@ full loader before its assemble stage writes anything.
226
227
  4. `spine-parts propose --head-box --full inputs/layers/full --canvas <W>x<H>` →
227
228
  `seethrough.head_box`. Run `spine-parts inputs` again: it now also writes
228
229
  `st_input_head.png`, the painting cropped to that box at its exact size.
229
- 5. **See-through, head run** (external), on that crop.
230
+ 5. **See-through, head run** (external), on that crop. See-through's own second,
231
+ head stage does not replace it: that stage's layers are pasted back at the full
232
+ run's scale on every route that was read (README *Getting See-through layers*),
233
+ so the full run's eyes stay at its density — 22×13 px for the demo's right eye
234
+ white at `resolution` 1024, against 60×38 px in the head run.
230
235
  6. `spine-parts assemble --propose-plan --source … --full … --head … --config config.json`
231
236
  → paste `plan` and `extend_below_crop` into `config.assemble`. Config: the
232
237
  `layers` row, with `head_box`.
@@ -260,9 +265,9 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
260
265
  | `layers` | the table, then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
261
266
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
262
267
  | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`; and look at `render/recomposite_error_rig.png` | on the examples: `sample` 0.84 / 98.0 % / 4,512 / 1,185, 259 holes, the largest 94 px; `demo` 2.39 / 95.8 % / 11,050 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
263
- | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for |
268
+ | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head` |
264
269
  | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
265
- | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the five judgement lines and `RECOMPOSITE_HOLES` (§7), `check.json` | `check: PASS`, and a judgement line SKIP only where the character lacks what it reads | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
270
+ | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the six judgement lines and `RECOMPOSITE_HOLES` (§7), `check.json` | `check: PASS`, and a judgement line SKIP only where the character lacks what it reads | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
266
271
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
267
272
 
268
273
  `loop` writes three files from one frame set, and they are not interchangeable.
@@ -274,9 +279,11 @@ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
274
279
  (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
275
280
  print their palette error, per channel over R, G and B of every frame (and alpha's
276
281
  max for the APNG); a figure is a measurement of the file, not a bar. On the demo:
277
- 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
278
283
  max 57, mean 1.601 — the two share one quantiser, so their error is the same by
279
- 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
280
287
  eyes' hold is at least one 12 fps frame (below), so whatever `motion.blink.t` is, one
281
288
  idle frame lands inside it, and `check.json`'s `BLINK_NO_HOLE.idle_frames_closed`
282
289
  names it — `[29]` on both examples.
@@ -289,6 +296,36 @@ outside the figure. On the demo it lowered recomposite error pixels from 11,050
289
296
  9,540 and changed no check bar; on the sample it changed nothing. The default stays
290
297
  the reference's so the examples stay comparable with it.
291
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
+
292
329
  `--project core` (the default) is the reference's projection rule: a layer takes the
293
330
  painting's pixel only inside its top-most `alpha >= 250` area eroded by a 5x5 square,
294
331
  so a part a few pixels wide (a lash, a brow, an iris) takes none. `--project visible`
@@ -482,6 +519,7 @@ See-through with another seed.
482
519
  | `CONFIG_AMPS_MATCH_CHAIN` | a chain track's `amps` is not one per link | that track's `amps` |
483
520
  | `CONFIG_PERIOD_DIVIDES_DURATION` | a period that is not a whole fraction of the idle — the loop could not close | that track's `period`, or `motion.duration` |
484
521
  | `CONFIG_BLINK_GROUP_MEMBERS` | `motion.blink.eyes` or `motion.blink.brows` is `[]`: a group with no members, which rigc refuses at the gate | a figure with no eyewhite part has nothing to blink: leave `motion.blink` out; one with no eyebrow part: leave `brows` and `brow_drop` out |
522
+ | `CONFIG_BLINK_GROUP_UNIQUE` | `motion.blink.eyes` or `motion.blink.brows` names one bone more than once; the detail gives the bone and every index it sits at. rigc refuses it at the gate (`group "eyes" names member "eye" twice`) | name each bone once: the group keys every member it names |
485
523
  | `CONFIG_BLINK_BROWS_PAIRED` | `motion.blink.brows` without `brow_drop`, or `brow_drop` without `brows` | state both or neither |
486
524
  | `CONFIG_STILL_OFF_THE_BLINK` | a `motion.blink.still` entry names a region on a bone the blink's `eyes` does not name (nothing to hold still), or its `bone` is one the blink's `eyes` names (the still piece would blink) | that entry's part, or its `bone` — the eye bone's parent, `head` as proposed |
487
525
 
@@ -550,6 +588,7 @@ See-through with another seed.
550
588
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
551
589
  | `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/` |
552
590
  | `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) |
591
+ | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^1.5.1), or put a newer `rigc` first on `PATH` |
553
592
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
554
593
  | `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 |
555
594
  | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted) | a track whose last key is not its first |
@@ -558,9 +597,12 @@ See-through with another seed.
558
597
  | `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
559
598
  | `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip) |
560
599
  | `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's lower half travels less than 1.4725 times as far as its upper half | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them) |
561
- | `CHECK_STILL_REGIONS_DARK` | the heat map is brighter than the ceiling over the face outline or over the feet | the part that moves there: a mesh weighted to a swinging bone (`segments`), or a region on the wrong bone |
562
- | `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR` | rigc's render is not what its `frames.json` says | a rigc problem; report it |
600
+ | `CHECK_STILL_REGIONS_DARK` | over the face outline, carried into the frame of the bone the face's slot rides: something moves there that the head does not carry, above twice the resampler's own error on this rig (both figures quoted, with the screen-space one beside them); over the feet, the heat map is brighter than the ceiling | on the face: a mesh over it weighted to a bone the head does not carry (`segments`), or a swinging part (a bang, a chain) whose art crosses the face outline; on the feet: a mesh weighted to a swinging bone, or a region on the wrong bone |
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 |
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 |
563
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` |
564
606
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
565
607
 
566
608
  ## 7. The bars `check` enforces, and what only an eye answers today
@@ -578,8 +620,8 @@ implementation's bars):
578
620
  On the examples: `sample` 23/23 and 14/14, seam 0.207 with 0 pixels over 40, loop 0;
579
621
  `demo` (default rule) 23/23 and 14/14, seam 0.326 with 2 pixels over 40, loop 0.
580
622
 
581
- A green gate cannot see a wrong animation, so `check` also writes five **judgement
582
- lines** (issue #11), each a key of `check.json` and a console line
623
+ A green gate cannot see a wrong animation, so `check` also writes six **judgement
624
+ lines** (issue #11; `TEXTURE_STRETCH`, issue #31), each a key of `check.json` and a console line
583
625
  `NAME: PASS|FAIL|SKIP — <figures and bars>`, read the same way as the lines above: a
584
626
  FAIL makes `PASS` false and prints its own `FAIL CHECK_<NAME>` line (§6); a SKIP
585
627
  says why the rig gave the line nothing to read, and is neither a pass nor a failure —
@@ -590,7 +632,9 @@ change from idle frame 0, in levels of 255, on the idle's 640-pixel grid.
590
632
  Each bar follows one rule: a floor is half the weaker example's figure and a ceiling
591
633
  twice the worse one's, so the weaker example clears it by a factor of two; a bar the
592
634
  model itself fixes (a part on an unkeyed bone does not move, a lag is above 0, a hole
593
- is 0 pixels) is that value, not a margin. The figures are this port's, measured on
635
+ is 0 pixels) is that value, not a margin — and where the model's value is an error
636
+ the rig itself measures (a face the head carries is still in the head's frame up to
637
+ the resampler's error), the bar is that measurement times the same two. The figures are this port's, measured on
594
638
  the two examples [observed]:
595
639
 
596
640
  | line | measures | bar | `demo` | `sample` | SKIP when |
@@ -600,7 +644,8 @@ the two examples [observed]:
600
644
  | ″ (reported) | the same box: each closed-eye pixel's max-channel distance to the nearest colour the open eye's box holds — max, and pixels over 40 | none | 15; 0 | 11; 0 | as above |
601
645
  | `CHAIN_LAG` | `motion.json`'s rotate tracks read as sines (DFT of the keys: period, amplitude, phase) and arranged by the bone tree — a keyed bone's parent is its nearest keyed ancestor | every lag ≥ 0.001 cycle; amplitude non-decreasing down each unbranched chain | lags 0.040 (neck to head) to 0.120; 12 chains | lags 0.040 to 0.100; 9 chains | no rotate track under another of the same period |
602
646
  | `TIP_OVER_ROOT` | each `handwear`/`bottomwear` part alone: how far the centroid of its art travels in the lower half of its box against the upper half | ratio ≥ 1.4725 | `bottomwear` 2.945, `sleeves` 3.716 | `bottomwear` 4.396, `sleeves` 12.475 | no such part |
603
- | `STILL_REGIONS_DARK` | the idle's heat over the face outline (where `face` is the top part of the flat stack, less the boxes of `eyewhite`, `irides`, `eyelash`, `eyebrow` and `mouth`) and over the feet (where `footwear` is on top); max reported | face mean ≤ 33.976; feet mean ≤ 3.244 | 16.988; 1.622 | 11.475; 0 | no `face` and no `footwear` part (one of the two absent leaves that half unmeasured) |
647
+ | `STILL_REGIONS_DARK` | the face outline **in the head's own frame**: each idle frame sampled (bilinear) where the head bone has carried each setup-pose pixel of the region, the heat taken over those samples. The head bone is the bone the `face` parts' slot rides in `rig.json`, and its per-frame world transform is `rigc render --geometry`'s. The region is the frame pixels whose whole footprint — 1 + 1/scale rig px each way, what the rasteriser's and this sampler's bilinear taps can reach — is `face` on top of the flat stack, clear by that reach of where `eyewhite`, `irides`, `eyelash`, `eyebrow` and `mouth` go in the head's frame over the idle. `resampler_heat_mean` is the same figure off the whole rig moved rigidly as the idle moves the head (the root keyed to the head's motion, every other key dropped), where every pixel is still in the head's frame by construction. `screen_heat_mean`/`max` over `screen_px` are the screen-space figures this line held until issue #33 (where `face` is on top, less the features' setup boxes), reported. The feet (where `footwear` is on top): the idle's heat in screen space — they ride the root, which the idle does not move, so the screen is their own frame | face `head_frame_heat_mean` ≤ 2 × `resampler_heat_mean` (`mean_ceiling`); feet mean ≤ 3.244 | face 1.321 ≤ 2.292 (resampler 1.146; screen 16.988); feet 1.622 | face 0.884 ≤ 1.765 (resampler 0.883; screen 11.475); feet 0 | no `face` and no `footwear` part (one of the two absent leaves that half unmeasured; so do face parts on two slot bones, or idle frames with no `geometry.json`) |
648
+ | `TEXTURE_STRETCH` | every mesh triangle's three edges in every idle frame, read from the `geometry.json` the idle render writes (`rigc render --geometry`, spine-rigc 1.4.0 or later): skinned length over rest length, the rest being the setup pose's bones with no deform. The figure is the rig's worst max(ratio, 1/ratio); each mesh's largest and smallest ratio, with triangle, vertices, edge and frame, is the detail | max(ratio, 1/ratio) ≤ 1.926544 | 1.388: `hair_back` triangle 449, edge 42-43 at 0.720 in frame 26 (largest stretch 1.291, frame 2) | 1.227: `sleeves` triangle 169, edge 126-51 at 0.815 in frame 28 (largest stretch 1.196, frame 5) | the rig draws no mesh, or the render wrote no `geometry.json` |
604
649
 
605
650
  After them, one **reported line** with no bar:
606
651
 
@@ -651,22 +696,71 @@ What each figure is, and is not:
651
696
  mean heat is 1.1 times the upper half's while its centroid travels 3.7 times as far.
652
697
  The halves split the box across its rows, which assumes the part hangs — true of a
653
698
  front-facing standing figure, the only input this tool takes.
654
- - **The face ceiling is weak, and the reason is in the examples.** The head rolls, so
655
- the face outline is lit in both (16.988 and 11.475); twice the worse is a ceiling a
656
- smooth face sliding two rig pixels stays under (the selftest's fixture measures 24.8
657
- at that slide and 85.5 at eight). A face-outline instrument that removes the head's
658
- own motion needs the head bone's world transform per frame, which rigc's `render`
659
- does not export. The feet half has the same shape on the demo: its long skirt swings
660
- over the shoes, which lights 1.622.
661
-
662
- Still only an eye answers, each a missing instrument rather than a question to ask:
663
-
664
- - **no visible texture stretch** (it appears when amplitudes grow): the measure is a
665
- mesh triangle's deformed edge length over its rest length, frame by frame, and
666
- nothing this package runs gives deformed vertices — spine-parts does not link
667
- `spine-core`, and rigc's `render` exports pixels, not vertices;
668
- - **the face outline held still in the head's own frame** (above): the heat map
669
- without the head's roll.
699
+ - **Stretch is read on edges, both ways, and the ceiling doubles |ln r|.** Compression
700
+ distorts a texture as much as stretch — an edge at half its rest length squeezes
701
+ the texels on it by the factor an edge at twice its length spreads them — so the
702
+ figure is max(ratio, 1/ratio), and on both examples the worst triangle is a
703
+ compression (demo 0.720, sample 0.815). The ceiling rule is applied to the quantity
704
+ whose zero means "nothing happened", as it is for heat: for a ratio that is |ln r|,
705
+ so twice demo's |ln 1.388| is ln(1.388²) = ln 1.926544 and sample clears it by 3.2
706
+ times. Doubling the ratio itself (2.776) was rejected: its zero is not at 1, and it
707
+ passes an edge drawn at 2.7 times its rest length. The rest is the art's own
708
+ proportions — on both examples every rest edge equals its `uvs` edge times the
709
+ attachment's size to within 2.4e-5, the uvs' six-decimal rounding.
710
+ - **The face is measured in the head's frame, and its bar is the model's (issue #33).**
711
+ Until #33 the face half read the screen-space heat map, where the head's intended
712
+ roll lights the outline on both examples (16.988 and 11.475, the figures now
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,
715
+ 33.976 — a ceiling a smooth fixture face sliding two rig pixels stayed under (24.8;
716
+ eight pixels measured 85.5). The judgement means *nothing moves on the face that
717
+ the head does not move*, so the heat is now taken after each frame's face region is
718
+ carried back through the head bone's transform. A region the head carries is then
719
+ still up to the resampler's error, and nothing more. That error depends on the art
720
+ and the scale — 1.146 on the demo, 0.883 on the sample, 0.47 on the selftest's
721
+ smooth fixture face — so no one constant can be the bar: it is measured on the same
722
+ rig, off a throwaway animation that moves the whole rig rigidly as the idle moves
723
+ the head, and the ceiling is twice that (the tree's one margin). The calibration is
724
+ its own `rigc build`: sharing the setup still's build widened the still's viewport
725
+ (rigc fits it to every animation a skeleton holds) and moved the seam.
726
+ - **The region is the pixels that read the face and nothing else.** A pixel at the
727
+ outline's rim, or next to a feature, reads a neighbour's art through the two
728
+ bilinear taps, and that neighbour moving lights it with nothing on the face moving:
729
+ measured over the rim, a fixture face that does not move at all over a breathing
730
+ torso read 1.533 against a bar of 0 (its head never moves, so its resampler error is
731
+ exactly 0), and the proposer's own strand fixture went red on the brows the blink
732
+ drops below their setup boxes. So the region keeps only pixels whose whole
733
+ footprint is the face (228 of the demo's 515 screen-space pixels, 271 of the
734
+ sample's 453), clear of where each feature goes in the head's frame over the idle
735
+ (its setup box carried by its own bone and back through the head's). Measured over
736
+ the rim instead, both examples sat at 1.31 and 1.34 times their calibration, and
737
+ that excess was the bangs' art sliding over the rim.
738
+ - **What the examples show in the head's frame.** The sample is still to its
739
+ calibration (0.884 against 0.883). The demo reads 1.15 times it (1.321 against
740
+ 1.146), and most of that is the calibration's own noise, not motion: the face
741
+ rendered alone (`--slot face`), rigid on the head by construction, reads 1.296 on
742
+ the idle's grid against 1.146 on the calibration's — rigc fits each render's
743
+ viewport to its own animation, so the two grids sit at a different sub-pixel phase
744
+ on the art (13 %). The rest, 0.025, is `hair_front`: rendered without it the figure
745
+ is the face-alone one. Both clear the bar, the demo by a factor of 1.74 and the sample by 2.00.
746
+ - **What the line now catches, and where it stops.** The selftest's fixture face,
747
+ a mesh weighted wholly to a bone that slides two rig pixels under its head, reads
748
+ 27.886 against a ceiling of 0 on a still head (screen space 26.901, which the
749
+ retired 33.976 passed) and 27.936 against 0.94 on a rolling one. The same plant on
750
+ the real examples, at their render scale of about 0.5 (one rig pixel is half a frame
751
+ pixel): two pixels read 3.216 on the sample (ceiling 1.765, red) and 2.181 on the
752
+ demo (2.292, green); one pixel reads 1.614 and 1.526 (both green). A smooth skin
753
+ interior sliding half a frame pixel changes little, and the rim that shows it most is
754
+ the rim the region leaves out. The line also does not judge a face that moves with
755
+ the bone its own slot rides: that is the head moving, whatever the bone, so a face
756
+ region on the wrong bone is the feet half's and the eye's to catch, not this line's.
757
+ - **The feet stay in screen space.** They ride the root, which the idle does not key,
758
+ so the screen is their own frame. On the demo the long skirt swings over the shoes,
759
+ which lights 1.622.
760
+
761
+ Nothing is left that only an eye answers. "No visible texture stretch" left this list
762
+ with issue #31 (`TEXTURE_STRETCH`), and "the face outline held still in the head's own
763
+ frame" with issue #33 (the face half of `STILL_REGIONS_DARK`, above).
670
764
 
671
765
  ## 8. What one character costs
672
766
 
@@ -680,3 +774,10 @@ Still only an eye answers, each a missing instrument rather than a question to a
680
774
  About 6.5 minutes of GPU and under a minute of CPU per character, before any hand
681
775
  correction. The GPU figures are one run each on one machine; the painting's figure
682
776
  excludes choosing among seeds (the demo was chosen from eighteen candidates).
777
+ Since issue #33 `check` runs one more `rigc build` and one more idle render (the face
778
+ calibration, §7); the idle's `geometry.json` is the one issue #31 already writes. The `build` row
779
+ was not re-measured: A/B against the tree before that change, alternating on one
780
+ machine under a load average of 5 to 11, the difference in user + sys was +5.6 and
781
+ +0.8 s on the demo and +6.5 and −1.6 s on the sample over two rounds — inside what
782
+ the load moves the same build by (the pre-change demo build alone took 43.6 to
783
+ 45.8 s there).