spine-parts 0.3.0 → 0.5.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
@@ -43,9 +43,12 @@ is taken from the full run, because the twin tails leave the head crop sideways
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,468 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
46
+ 12 fps, 1,706,785 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,611 bytes; the GIF, at the same error, 1,812,041); the painting is shown at half size, resampled and written
48
+ 13,645,519 bytes; the GIF, at the same error, 1,812,625). Frame 29 (2.417 s) is the
49
+ closed eye: the blink holds for 0.084 s, one 12 fps frame rounded up, where the
50
+ reference implementation held 0.04 s and no frame of its loop showed the eyes shut
51
+ (issue #32); the painting is shown at half size, resampled and written
49
52
  by this package's PNG codec (nothing here encodes JPEG). <code>spine-parts sheet</code>
50
53
  made the contact sheet.
51
54
  </em></p>
@@ -87,7 +90,7 @@ page** (issue #2):
87
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 |
88
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 |
89
92
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
90
- | `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` |
91
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 |
92
95
 
93
96
  The last lines of a green build are the pack line, printed beside the Spine example
@@ -129,6 +132,27 @@ spine-parts at what it wrote:
129
132
  | 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/` |
130
133
  | 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 |
131
134
 
135
+ **The head run is needed on every route that was read.** See-through's model
136
+ already separates the head in a second stage of its own: it crops the head the
137
+ first stage found, runs the face tags on that crop at the same `resolution`, and
138
+ pastes the result back **resized to the full image's scale**. So the face comes
139
+ back at the full run's pixel density whichever route ran it, and the separate
140
+ head run on `st_input_head.png` is what gives the eyes more pixels
141
+ (read on 2026-09-28 at see-through `a25a549` and wrapper 0.5.0 `98d754b`):
142
+
143
+ - *Hugging Face Space* — the same two-stage code, with `resolution` capped at
144
+ 1280 (768 by default): the head run is still needed.
145
+ - *ModelScope demo* — its code was not read, so what the head run adds on that
146
+ route has not been measured.
147
+ - *Upstream CLI* — the `.psd` frame is the `resolution`-sided square the first
148
+ stage ran on (1280 by default), and the head stage is pasted into it at that
149
+ scale: the head run is still needed.
150
+ - *ComfyUI wrapper* — `SeeThrough_GenerateLayers` runs both stages with no input
151
+ to change the second, at most `resolution` 2048, which on the examples' 2432 px
152
+ square is 1.19 source px per layer px against 0.77 and 0.47 for their head
153
+ crops; at the 1024 the examples ran, the full run's right eye white is 22×13 px
154
+ and the head run's 60×38: the head run is still needed.
155
+
132
156
  **See-through is required; ComfyUI is not.** The wrapper is one route among
133
157
  four, and `spine-parts comfy` is only a convenience for that route: every
134
158
  stage after See-through reads files, whichever route wrote them. On the two
@@ -218,8 +242,8 @@ agent skill.
218
242
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
219
243
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
220
244
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
221
- | `rig --config --parts --out` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green |
222
- | `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` |
245
+ | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared |
246
+ | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json` |
223
247
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
224
248
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
225
249
 
package/cli.ts CHANGED
@@ -18,12 +18,13 @@ 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 { findRigc, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
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';
25
25
  import { proposeHeadBox } from './src/headbox.ts';
26
26
  import { makeInputs } from './src/inputs.ts';
27
+ import { DEFAULT_IDLE_KEYS, IDLE_KEYS, type IdleKeys } from './src/rig.ts';
27
28
  import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
28
29
  import { checkProposal, compare, compareLines, drawLandmarks, HIP_MIN_FRACTION, lint, lintLine, type PartSet, propose, readPartSet, serializeProposal } from './src/propose.ts';
29
30
  import { readPng, writePng } from './src/raster/png.ts';
@@ -74,7 +75,10 @@ usage:
74
75
  holds parts.json and parts/). Roles come from each part's See-through tag,
75
76
  never its name. Writes <out>/proposal.json (config-shaped: bones, meshes,
76
77
  regions, motion with its blink — and a blink.still cut for a lash that
77
- reaches far above its eyewhite, when a clear row allows one — and notes)
78
+ reaches far above its eyewhite, when a clear row allows one; no blink
79
+ when no part is an eyewhite, and no brows in it when no part is an
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)
78
82
  and the overlay to correct against, <out>/render/landmarks.png and
79
83
  landmarks_head.png. Prints every
80
84
  note and a LINT line for each chain link that lies off its mesh's art, for a
@@ -93,18 +97,30 @@ usage:
93
97
  layers for a painting of WxH px, held inside the painting; a shift is
94
98
  printed when one was needed.
95
99
 
96
- spine-parts rig --config <config.json> --parts <dir> --out <dir>
100
+ spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
97
101
  Author the rig: unrotated bones at the config's landmarks (a chain makes
98
102
  <chain>0..n), a square lattice mesh over every part in config.meshes
99
103
  weighted by distance to its candidate bone segments, a region for every
100
104
  part in config.regions (a motion.blink.still part as two: the rows above
101
105
  its row on a second slot <part>_still, which the blink does not move),
102
- and one idle of sines and a blink. --parts is the
106
+ and one idle of sines and a blink whose closed hold is at least one
107
+ 12 fps frame, so the idle frames and the loop show the eyes shut
108
+ (RIG_BLINK_HOLD_SPANS_A_FRAME otherwise); no blink when the config
109
+ states no motion.blink, and a blink group that names no bone, or one
110
+ bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
111
+ CONFIG_BLINK_GROUP_UNIQUE, before rigc starts. --parts is the
103
112
  directory holding parts.json and parts/<name>.png. The result is built
104
113
  through spine-rigc (profile spine-html, packed, then validated under
105
114
  profile spine) in a scratch directory first, and --out receives
106
115
  images/*.png, rig.json, motion.json and mesh_report.json only when both
107
116
  are green. Prints one line per mesh and the rigc gate lines.
117
+ --idle-keys says where the idle's keys on a bone a mesh is weighted to go:
118
+ ctl (the default) keys a same-origin <bone>_ctl parent instead, which
119
+ passes A15_IDLE_NO_MESH_BONE_KEYS on any spine-rigc; direct keys the bone
120
+ itself and declares invariants.idleDrivesMeshes in rig.json, which needs
121
+ spine-rigc 1.3.0 or later and makes A15 a SKIP that prints its cost (the
122
+ stage prints that SKIP line). The pose is the same to one level of float
123
+ rounding, and so is the per-frame mesh work (AUTHORING §5).
108
124
  spine-parts check --rig <dir> --out <dir> [--parts <dir>]
109
125
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
110
126
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
@@ -113,19 +129,30 @@ usage:
113
129
  Both are only read. Into --out:
114
130
  build/ (rigc build --profile spine-html --pack: the packed atlas is the
115
131
  artifact), gate_spine-html.txt and gate_spine.txt (the gate lines
116
- verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640),
132
+ verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640
133
+ --geometry: the frames and geometry.json, the skinned vertices and
134
+ every bone's world transform per frame),
117
135
  contact.png, motion_heat.png and check.json. PASS needs both gates
118
136
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
119
137
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
120
- frame at t = duration) at max |d| 0. Then five judgement lines, each in
138
+ frame at t = duration) at max |d| 0. Then six judgement lines, each in
121
139
  check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
122
140
  bars (AUTHORING §7): BREATH_VISIBLE (the topwear moves, the footwear does
123
141
  not, each rendered alone), BLINK_NO_HOLE (the setup pose with the blink
124
142
  held shut shows no background inside the eyewhite box), CHAIN_LAG (every
125
143
  rotate track lags its keyed ancestor and amplitude grows down each
126
144
  chain, read off motion.json), TIP_OVER_ROOT (each handwear/bottomwear
127
- part's lower half travels further than its upper half) and
128
- STILL_REGIONS_DARK (the heat map over the face outline and the feet).
145
+ part's lower half travels further than its upper half),
146
+ STILL_REGIONS_DARK (the face outline in the frame of the bone the face's
147
+ slot rides, at most twice the resampler's own error on this rig — read
148
+ off the whole rig moved rigidly as the idle moves that bone — with the
149
+ screen-space figure reported beside it; the feet in screen space) and
150
+ TEXTURE_STRETCH (every mesh triangle's edges over the idle against their
151
+ rest length, max(ratio, 1/ratio) <= ${TEXTURE_STRETCH_CEILING}; the worst
152
+ triangle is named by slot, triangle, vertices, edge and frame).
153
+ Needs spine-rigc ${RIGC_GEOMETRY_VERSION} or later, whose render writes
154
+ geometry.json; an older rigc is refused, CHECK_RIGC_VERSION, before
155
+ anything is built.
129
156
  Regions come from parts.json's See-through tags; a line with nothing to
130
157
  read says SKIP and why — neither a pass nor a failure — and PASS needs
131
158
  every line that measured to be PASS. Then RECOMPOSITE_HOLES: REPORTED,
@@ -375,22 +402,28 @@ function cmdRig(args: string[]): number {
375
402
  let config: string | null = null;
376
403
  let partsDir: string | null = null;
377
404
  let out: string | null = null;
405
+ let idleKeys: string | null = null;
378
406
  for (let i = 0; i < args.length; i++) {
379
407
  const flag = args[i];
380
408
  const value = args[i + 1];
381
- if (!['--config', '--parts', '--out'].includes(flag)) return usage(`rig does not take "${flag}"`);
409
+ if (!['--config', '--parts', '--out', '--idle-keys'].includes(flag)) return usage(`rig does not take "${flag}"`);
382
410
  if (value === undefined) return usage(`${flag} needs a value`);
383
411
  i++;
384
412
  if (flag === '--config') config = value;
385
413
  else if (flag === '--parts') partsDir = value;
386
- else out = value;
414
+ else if (flag === '--idle-keys') {
415
+ if (idleKeys !== null) return usage('--idle-keys is given twice');
416
+ idleKeys = value;
417
+ } else out = value;
387
418
  }
388
419
  if (config === null) return usage('rig needs --config <config.json>');
389
420
  if (partsDir === null) return usage('rig needs --parts <dir> (the directory holding parts.json and parts/)');
390
421
  if (out === null) return usage('rig needs --out <dir>');
422
+ const keys = idleKeys ?? DEFAULT_IDLE_KEYS;
423
+ if (!(IDLE_KEYS as readonly string[]).includes(keys)) return usage(`--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`);
391
424
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
392
425
  try {
393
- rigStage({ config, parts: partsDir, out }, rigGateRunner(), scratch, console.log);
426
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys }, rigGateRunner(), scratch, console.log);
394
427
  return EXIT_OK;
395
428
  } catch (err) {
396
429
  return printRefusal(err);
package/docs/AUTHORING.md CHANGED
@@ -76,11 +76,33 @@ 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 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 |
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
+ The blink's timing is the tree's (`BLINK` in `src/motion.ts`), in seconds after
83
+ `motion.blink.t`:
84
+
85
+ | phase | eyes (`scaley` 1 → `squash` → 1) | brows (`translatey` 0 → `-brow_drop` → 0) | easing |
86
+ | --- | --- | --- | --- |
87
+ | shut | 0.07 | 0.08 | `shut` |
88
+ | hold | **0.084** (the reference: 0.04) | **0.084** (the reference: 0.04) | none |
89
+ | open | 0.16 | 0.20 | `open` |
90
+
91
+ The holds are the one departure from the reference implementation, and the reason is
92
+ the loop (issue #32). `check` renders the idle at 12 fps, a frame every 0.083333 s, and
93
+ the loop is encoded from those frames. A 0.04 s hold is shorter than a frame, so it
94
+ held a frame only for some `t`: at the examples' 2.3 s the eyes were shut from 2.37 s to
95
+ 2.41 s, between frames 28 (2.333 s, `scaley` 0.8356) and 29 (2.417 s, 0.2435), and over
96
+ every key time the 6-decimal grid can write, 129,999 of the 250,000 phases against the
97
+ frame grid showed no closed frame. A closed window at least one frame long holds a frame
98
+ wherever it starts, so the hold is 1/12 s rounded up to the third place; the brows' hold
99
+ grew by the same 0.044 s, so they still reach their drop 0.01 s after the lids shut and
100
+ leave it 0.01 s after the lids open. The rig stage refuses a hold under one frame by
101
+ name (`RIG_BLINK_HOLD_SPANS_A_FRAME`, §6), counted over the same 250,000 phases.
102
+
82
103
  The proposer's reach (from `src/propose.ts`): roles come from each part's tag —
83
- `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
84
106
  told apart by position, `bottomwear` makes the hip and three skirt chains,
85
107
  `handwear` makes sleeve chains (one blob or two), `front hair` makes fringe chains
86
108
  and a lock chain for each strand that hangs below the chin, `back hair` makes a bun
@@ -205,7 +227,11 @@ full loader before its assemble stage writes anything.
205
227
  4. `spine-parts propose --head-box --full inputs/layers/full --canvas <W>x<H>` →
206
228
  `seethrough.head_box`. Run `spine-parts inputs` again: it now also writes
207
229
  `st_input_head.png`, the painting cropped to that box at its exact size.
208
- 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.
209
235
  6. `spine-parts assemble --propose-plan --source … --full … --head … --config config.json`
210
236
  → paste `plan` and `extend_below_crop` into `config.assemble`. Config: the
211
237
  `layers` row, with `head_box`.
@@ -239,9 +265,9 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
239
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*) |
240
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 |
241
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) |
242
- | `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) |
243
- | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; then rigc's gate lines | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
244
- | `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) |
268
+ | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head` |
269
+ | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
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) |
245
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) |
246
272
 
247
273
  `loop` writes three files from one frame set, and they are not interchangeable.
@@ -253,9 +279,12 @@ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
253
279
  (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
254
280
  print their palette error, per channel over R, G and B of every frame (and alpha's
255
281
  max for the APNG); a figure is a measurement of the file, not a bar. On the demo:
256
- 13,645,611 B lossless, 1,706,468 B indexed and 1,812,041 B GIF, both palette files at
282
+ 13,645,519 B lossless, 1,706,785 B indexed and 1,812,625 B GIF, both palette files at
257
283
  max 57, mean 1.601 — the two share one quantiser, so their error is the same by
258
- construction and the size is the difference.
284
+ construction and the size is the difference. The loop shows the blink closed: the
285
+ eyes' hold is at least one 12 fps frame (below), so whatever `motion.blink.t` is, one
286
+ idle frame lands inside it, and `check.json`'s `BLINK_NO_HOLE.idle_frames_closed`
287
+ names it — `[29]` on both examples.
259
288
 
260
289
  `--seam near-white` (the default) is the reference implementation's rule: where the
261
290
  flat stack of parts differs from the painting by more than 60, the top part takes the
@@ -276,6 +305,29 @@ error pixels from 4,512 to 4,116 and 11,050 to 9,820, and the check seam from 0.
276
305
  0.206 and 0.326 to 0.325, with no other check figure changed. The default stays the
277
306
  reference's so the examples stay comparable with it. `build` takes both flags.
278
307
 
308
+ **`rig --idle-keys ctl|direct`.** spine-rigc's `A15_IDLE_NO_MESH_BONE_KEYS`
309
+ (profile `spine-html`) refuses an idle that keys a bone a mesh is weighted to. `ctl`
310
+ (the default, the reference's answer) gives every such bone a same-origin
311
+ `<bone>_ctl` parent and moves its keys there, which passes A15 on any spine-rigc;
312
+ the stage prints `idle keys ctl: N mesh-driving bone(s) keyed …`. `direct` keys the
313
+ bones themselves and writes `invariants.idleDrivesMeshes: { "why": … }` into
314
+ `rig.json` (spine-rigc 1.3.0 or later), so A15 reports
315
+ `SKIP A15_IDLE_NO_MESH_BONE_KEYS: declared by the rig (…): idle keys N bone(s) that
316
+ drive M mesh attachment(s) totalling V vertices …`, which the stage prints; with no
317
+ mesh-driving bone keyed it declares nothing, because rigc refuses a declaration
318
+ that switches nothing off. The two write the same slots, skins and keys (`RG18`).
319
+ Measured on the examples with `bun tools/idle_cost.ts` (spine-parts #13): `direct`
320
+ has 41 bones for the demo's 72 and 32 for the sample's 56; every shown mesh moves on
321
+ every idle frame under both (8 of 8 and 6 of 6, so 0 a dirty-skip renderer could
322
+ skip); the pose costs 14.8 against 15.3 us per frame (demo) and 8.3 against 8.8 us
323
+ (sample), the difference all in `updateWorldTransform`; the rendered idle frames
324
+ differ by at most one level in a channel. The default stays `ctl`: the examples'
325
+ `expected/rig.json` and `motion.json` are the reference's output, and the
326
+ declaration is in the rig spec only, so `rigc validate <build> --profile spine-html`
327
+ run on a `direct` build (it has no rig spec to read) refuses A15 once per keyed
328
+ bone. `check` validates the build under `spine` only, where A15 is not in the
329
+ profile, and is green on both.
330
+
279
331
  **Patching a hole no layer holds.** When an `uncovered hole` stays large after the
280
332
  plan is right — both See-through runs dropped a piece of the figure, red in
281
333
  `recomposite_error_rig.png` — add an `assemble.patches` entry:
@@ -434,6 +486,9 @@ See-through with another seed.
434
486
  | `CONFIG_PATCH_BOX` | an `assemble.patches` box with `x1 <= x0` or `y1 <= y0` | that patch's `box` (`x1`, `y1` are exclusive) |
435
487
  | `CONFIG_AMPS_MATCH_CHAIN` | a chain track's `amps` is not one per link | that track's `amps` |
436
488
  | `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` |
489
+ | `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 |
490
+ | `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 |
491
+ | `CONFIG_BLINK_BROWS_PAIRED` | `motion.blink.brows` without `brow_drop`, or `brow_drop` without `brows` | state both or neither |
437
492
  | `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 |
438
493
 
439
494
  ### inputs
@@ -490,6 +545,7 @@ See-through with another seed.
490
545
  | `RIG_LATTICE_ONE_LOOP` | the lattice over a part does not close into one outline even after the repair passes | that mesh's `grid` |
491
546
  | `RIG_CONTROL_NAME_FREE` | a keyed, mesh-weighted bone needs `<bone>_ctl` and that name is taken | rename the declared bone |
492
547
  | `RIG_BLINK_INSIDE_IDLE` | the blink runs outside the idle | `motion.blink.t` |
548
+ | `RIG_BLINK_HOLD_SPANS_A_FRAME` | the tree's `BLINK.hold` is shorter than one frame at `IDLE_FPS`, so for some `motion.blink.t` no idle frame — and no frame of the loop — shows the closed eye; the detail counts the phases that miss and names the first | nothing in the config: `BLINK.hold` in `src/motion.ts`, at least `1/IDLE_FPS` s (§3) |
493
549
  | `RIG_STILL_ROW_INSIDE_PART`, `RIG_STILL_ROW_CLEAR`, `RIG_STILL_PIECES_HAVE_ART`, `RIG_STILL_NAME_FREE` | a `motion.blink.still` row that is not strictly inside the part, that crosses art (a cut through art changes the render even at rest), that leaves one piece with no art, or whose `<part>_still` slot name another part already has | that entry's `row` — a row with no art between the crease and the lash line — or rename the other part |
494
550
  | `RIG_RIGC_GREEN` | spine-rigc refused the rig; its own FAIL or compile-error line is quoted, and nothing was written | the field rigc's line names — spine-rigc's own AUTHORING §5 maps each of its assertions (`node_modules/spine-rigc/docs/AUTHORING.md`) |
495
551
 
@@ -500,6 +556,7 @@ See-through with another seed.
500
556
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
501
557
  | `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/` |
502
558
  | `CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by) | re-run rig (`build` does both) |
559
+ | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^1.5.1), or put a newer `rigc` first on `PATH` |
503
560
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
504
561
  | `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 |
505
562
  | `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 |
@@ -508,8 +565,9 @@ See-through with another seed.
508
565
  | `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 |
509
566
  | `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) |
510
567
  | `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) |
511
- | `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 |
512
- | `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR` | rigc's render is not what its `frames.json` says | a rigc problem; report it |
568
+ | `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 |
569
+ | `CHECK_TEXTURE_STRETCH` | a mesh triangle's edge, in some idle frame, is more than 1.926544 times its rest length or less than 1/1.926544 of it (the mesh, triangle, its three vertices, the edge and the frame are named); or a rest edge has length 0 | the chain tracks' `amps` on the bones that mesh is weighted to (the stretch grows with them), or the mesh's `segments` — a vertex blending two bones that swing against each other |
570
+ | `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 |
513
571
  | `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 |
514
572
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
515
573
 
@@ -528,8 +586,8 @@ implementation's bars):
528
586
  On the examples: `sample` 23/23 and 14/14, seam 0.207 with 0 pixels over 40, loop 0;
529
587
  `demo` (default rule) 23/23 and 14/14, seam 0.326 with 2 pixels over 40, loop 0.
530
588
 
531
- A green gate cannot see a wrong animation, so `check` also writes five **judgement
532
- lines** (issue #11), each a key of `check.json` and a console line
589
+ A green gate cannot see a wrong animation, so `check` also writes six **judgement
590
+ lines** (issue #11; `TEXTURE_STRETCH`, issue #31), each a key of `check.json` and a console line
533
591
  `NAME: PASS|FAIL|SKIP — <figures and bars>`, read the same way as the lines above: a
534
592
  FAIL makes `PASS` false and prints its own `FAIL CHECK_<NAME>` line (§6); a SKIP
535
593
  says why the rig gave the line nothing to read, and is neither a pass nor a failure —
@@ -540,7 +598,9 @@ change from idle frame 0, in levels of 255, on the idle's 640-pixel grid.
540
598
  Each bar follows one rule: a floor is half the weaker example's figure and a ceiling
541
599
  twice the worse one's, so the weaker example clears it by a factor of two; a bar the
542
600
  model itself fixes (a part on an unkeyed bone does not move, a lag is above 0, a hole
543
- is 0 pixels) is that value, not a margin. The figures are this port's, measured on
601
+ is 0 pixels) is that value, not a margin — and where the model's value is an error
602
+ the rig itself measures (a face the head carries is still in the head's frame up to
603
+ the resampler's error), the bar is that measurement times the same two. The figures are this port's, measured on
544
604
  the two examples [observed]:
545
605
 
546
606
  | line | measures | bar | `demo` | `sample` | SKIP when |
@@ -550,7 +610,8 @@ the two examples [observed]:
550
610
  | ″ (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 |
551
611
  | `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 |
552
612
  | `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 |
553
- | `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) |
613
+ | `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`) |
614
+ | `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` |
554
615
 
555
616
  After them, one **reported line** with no bar:
556
617
 
@@ -575,13 +636,15 @@ What each figure is, and is not:
575
636
  layers".** A gap shows the page, a rim a colour no part has there, a doubled line a
576
637
  part drawn off its place; each changes the setup-pose render against the flat stack,
577
638
  which is what the seam bar measures. No separate line is written for it.
578
- - **The blink is measured at the setup pose, not in an idle frame.** On both examples
579
- the eyes are fully shut from 2.37 s to 2.41 s, and no 12 fps idle frame falls inside
580
- that window (`idle_frames_closed` is empty: frames 28 and 29 are 2.333 s and
581
- 2.417 s), so the idle render and the loop encoded from it never show the closed eye.
582
- rigc's `render` takes no time, so the closed pose is a throwaway animation holding
583
- the blink tracks' closed value, built and rendered beside the seam's still on the
584
- same grid — the comparison is then of what the blink alone changed.
639
+ - **The blink is measured at the setup pose, not in an idle frame.** Since issue #32
640
+ an idle frame does fall inside the closed window — on both examples the eyes are
641
+ fully shut from 2.37 s to 2.454 s and `idle_frames_closed` is `[29]` (2.417 s) — but
642
+ that frame also carries the idle's sway, so it is not a comparison of what the blink
643
+ alone changed. rigc's `render` takes no time, so the closed pose is a throwaway
644
+ animation holding the blink tracks' closed value, built and rendered beside the
645
+ seam's still on the same grid. `idle_frames_closed` is what says the loop shows the
646
+ closed eye; before #32 it was `[]` on both examples, with the 0.04 s hold between
647
+ frames 28 and 29.
585
648
  - **The colour-patch figure is not reliable enough for a bar.** "Nearest colour in the
586
649
  open eye's box" counts a legitimate colour the open eye never showed (skin under the
587
650
  lid) as a patch, and a patch the open eye happened to contain as none. It is
@@ -599,22 +662,70 @@ What each figure is, and is not:
599
662
  mean heat is 1.1 times the upper half's while its centroid travels 3.7 times as far.
600
663
  The halves split the box across its rows, which assumes the part hangs — true of a
601
664
  front-facing standing figure, the only input this tool takes.
602
- - **The face ceiling is weak, and the reason is in the examples.** The head rolls, so
603
- the face outline is lit in both (16.988 and 11.475); twice the worse is a ceiling a
604
- smooth face sliding two rig pixels stays under (the selftest's fixture measures 24.8
605
- at that slide and 85.5 at eight). A face-outline instrument that removes the head's
606
- own motion needs the head bone's world transform per frame, which rigc's `render`
607
- does not export. The feet half has the same shape on the demo: its long skirt swings
608
- over the shoes, which lights 1.622.
609
-
610
- Still only an eye answers, each a missing instrument rather than a question to ask:
611
-
612
- - **no visible texture stretch** (it appears when amplitudes grow): the measure is a
613
- mesh triangle's deformed edge length over its rest length, frame by frame, and
614
- nothing this package runs gives deformed vertices — spine-parts does not link
615
- `spine-core`, and rigc's `render` exports pixels, not vertices;
616
- - **the face outline held still in the head's own frame** (above): the heat map
617
- without the head's roll.
665
+ - **Stretch is read on edges, both ways, and the ceiling doubles |ln r|.** Compression
666
+ distorts a texture as much as stretch — an edge at half its rest length squeezes
667
+ the texels on it by the factor an edge at twice its length spreads them — so the
668
+ figure is max(ratio, 1/ratio), and on both examples the worst triangle is a
669
+ compression (demo 0.720, sample 0.815). The ceiling rule is applied to the quantity
670
+ whose zero means "nothing happened", as it is for heat: for a ratio that is |ln r|,
671
+ so twice demo's |ln 1.388| is ln(1.388²) = ln 1.926544 and sample clears it by 3.2
672
+ times. Doubling the ratio itself (2.776) was rejected: its zero is not at 1, and it
673
+ passes an edge drawn at 2.7 times its rest length. The rest is the art's own
674
+ proportions — on both examples every rest edge equals its `uvs` edge times the
675
+ attachment's size to within 2.4e-5, the uvs' six-decimal rounding.
676
+ - **The face is measured in the head's frame, and its bar is the model's (issue #33).**
677
+ Until #33 the face half read the screen-space heat map, where the head's intended
678
+ roll lights the outline on both examples (16.988 and 11.475, the figures now
679
+ reported as `screen_heat_mean`), so its ceiling was twice the worse example,
680
+ 33.976 — a ceiling a smooth fixture face sliding two rig pixels stayed under (24.8;
681
+ eight pixels measured 85.5). The judgement means *nothing moves on the face that
682
+ the head does not move*, so the heat is now taken after each frame's face region is
683
+ carried back through the head bone's transform. A region the head carries is then
684
+ still up to the resampler's error, and nothing more. That error depends on the art
685
+ and the scale — 1.146 on the demo, 0.883 on the sample, 0.47 on the selftest's
686
+ smooth fixture face — so no one constant can be the bar: it is measured on the same
687
+ rig, off a throwaway animation that moves the whole rig rigidly as the idle moves
688
+ the head, and the ceiling is twice that (the tree's one margin). The calibration is
689
+ its own `rigc build`: sharing the setup still's build widened the still's viewport
690
+ (rigc fits it to every animation a skeleton holds) and moved the seam.
691
+ - **The region is the pixels that read the face and nothing else.** A pixel at the
692
+ outline's rim, or next to a feature, reads a neighbour's art through the two
693
+ bilinear taps, and that neighbour moving lights it with nothing on the face moving:
694
+ measured over the rim, a fixture face that does not move at all over a breathing
695
+ torso read 1.533 against a bar of 0 (its head never moves, so its resampler error is
696
+ exactly 0), and the proposer's own strand fixture went red on the brows the blink
697
+ drops below their setup boxes. So the region keeps only pixels whose whole
698
+ footprint is the face (228 of the demo's 515 screen-space pixels, 271 of the
699
+ sample's 453), clear of where each feature goes in the head's frame over the idle
700
+ (its setup box carried by its own bone and back through the head's). Measured over
701
+ the rim instead, both examples sat at 1.31 and 1.34 times their calibration, and
702
+ that excess was the bangs' art sliding over the rim.
703
+ - **What the examples show in the head's frame.** The sample is still to its
704
+ calibration (0.884 against 0.883). The demo reads 1.15 times it (1.321 against
705
+ 1.146), and most of that is the calibration's own noise, not motion: the face
706
+ rendered alone (`--slot face`), rigid on the head by construction, reads 1.296 on
707
+ the idle's grid against 1.146 on the calibration's — rigc fits each render's
708
+ viewport to its own animation, so the two grids sit at a different sub-pixel phase
709
+ on the art (13 %). The rest, 0.025, is `hair_front`: rendered without it the figure
710
+ is the face-alone one. Both clear the bar, the demo by a factor of 1.74 and the sample by 2.00.
711
+ - **What the line now catches, and where it stops.** The selftest's fixture face,
712
+ a mesh weighted wholly to a bone that slides two rig pixels under its head, reads
713
+ 27.886 against a ceiling of 0 on a still head (screen space 26.901, which the
714
+ retired 33.976 passed) and 27.936 against 0.94 on a rolling one. The same plant on
715
+ the real examples, at their render scale of about 0.5 (one rig pixel is half a frame
716
+ pixel): two pixels read 3.216 on the sample (ceiling 1.765, red) and 2.181 on the
717
+ demo (2.292, green); one pixel reads 1.614 and 1.526 (both green). A smooth skin
718
+ interior sliding half a frame pixel changes little, and the rim that shows it most is
719
+ the rim the region leaves out. The line also does not judge a face that moves with
720
+ the bone its own slot rides: that is the head moving, whatever the bone, so a face
721
+ region on the wrong bone is the feet half's and the eye's to catch, not this line's.
722
+ - **The feet stay in screen space.** They ride the root, which the idle does not key,
723
+ so the screen is their own frame. On the demo the long skirt swings over the shoes,
724
+ which lights 1.622.
725
+
726
+ Nothing is left that only an eye answers. "No visible texture stretch" left this list
727
+ with issue #31 (`TEXTURE_STRETCH`), and "the face outline held still in the head's own
728
+ frame" with issue #33 (the face half of `STILL_REGIONS_DARK`, above).
618
729
 
619
730
  ## 8. What one character costs
620
731
 
@@ -628,3 +739,10 @@ Still only an eye answers, each a missing instrument rather than a question to a
628
739
  About 6.5 minutes of GPU and under a minute of CPU per character, before any hand
629
740
  correction. The GPU figures are one run each on one machine; the painting's figure
630
741
  excludes choosing among seeds (the demo was chosen from eighteen candidates).
742
+ Since issue #33 `check` runs one more `rigc build` and one more idle render (the face
743
+ calibration, §7); the idle's `geometry.json` is the one issue #31 already writes. The `build` row
744
+ was not re-measured: A/B against the tree before that change, alternating on one
745
+ machine under a load average of 5 to 11, the difference in user + sys was +5.6 and
746
+ +0.8 s on the demo and +6.5 and −1.6 s on the sample over two rounds — inside what
747
+ the load moves the same build by (the pre-change demo build alone took 43.6 to
748
+ 45.8 s there).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.3.0",
3
+ "version": "0.5.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": "^1.2.3"
60
+ "spine-rigc": "^1.5.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.4.2",
@@ -26,9 +26,9 @@ stage writes are the whole interface.
26
26
  the value found and the value required; AUTHORING §6 maps every rule to the field
27
27
  or input that has to change.
28
28
  - **Green is not "looks right".** `check` measures the gates, the seam, the loop and
29
- five judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`,
30
- `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`); texture stretch and the face outline in
31
- the head's own frame still need an eye (AUTHORING §7). A judgement line that says
29
+ six judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`,
30
+ `TIP_OVER_ROOT`, `STILL_REGIONS_DARK` — its face half in the head's own frame —
31
+ and `TEXTURE_STRETCH`) (AUTHORING §7). A judgement line that says
32
32
  SKIP was not verified: report it as such, with its reason, never as passed.
33
33
  - **A hole in the layers is invisible to every bar.** Painting that no part holds is
34
34
  missing from the setup pose and from the flat stack alike, so the seam passes over
@@ -68,8 +68,10 @@ stage writes are the whole interface.
68
68
  7. `spine-parts build --config … --source … --full … --head … --out out --loop`.
69
69
  It stops at the first stage that refuses, printing that stage's lines under
70
70
  `[assemble]`, `[rig]` or `[check]`. Fix what the FAIL line names and build again.
71
- 8. Read `out/check/check.json` — the five judgement lines the same way as the seam
72
- and the loop: a FAIL names the field to change (AUTHORING §6). Quote
71
+ 8. Read `out/check/check.json` — the six judgement lines the same way as the seam
72
+ and the loop: a FAIL names the field to change (AUTHORING §6);
73
+ `TEXTURE_STRETCH` names the worst mesh triangle (slot, triangle, vertices, edge,
74
+ frame), so quote it even on a PASS. Quote
73
75
  `RECOMPOSITE_HOLES` (uncovered px, hole count, the largest hole's box) in the
74
76
  report whatever `PASS` says. The artifact is the three paths the build prints
75
77
  last: `check/build/skeleton.json`, `skeleton.atlas` and the packed page. Report