spine-parts 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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>
@@ -85,7 +88,7 @@ page** (issue #2):
85
88
  | path under `--out` | what |
86
89
  | --- | --- |
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
- | `parts/*.png`, `parts.json`, `recomposite_rig.png` | the loose parts, each cropped to its alpha box; the record of where every part came from and how many of its pixels were re-taken from the painting; the flat stack of parts |
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
93
  | `check/` | both gate files verbatim, the idle's frames, `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 |
@@ -168,9 +171,9 @@ than queueing behind someone else's job.
168
171
  ## The loop, for an agent
169
172
 
170
173
  ```sh
171
- spine-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png; the config needs only key, seethrough, assemble.rig_scale
174
+ spine-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png
172
175
  # See-through on st_input_full.png (external, or `spine-parts comfy seethrough`) -> layers/full
173
- spine-parts layers layers/full # every layer: box, opaque px, depth
176
+ spine-parts layers layers/full # every layer: box, opaque px, depth, plausibility figures; WARN lines
174
177
  spine-parts propose --head-box --full layers/full --canvas 1664x2432
175
178
  # -> seethrough.head_box into config.json
176
179
  spine-parts inputs --source painting.png --config config.json --out inputs # now st_input_head.png too
@@ -179,6 +182,10 @@ spine-parts sheet --source painting.png --layers layers/full --layers layers/hea
179
182
  spine-parts assemble --propose-plan --source painting.png --full layers/full --head layers/head --config config.json
180
183
  # -> assemble.plan and extend_below_crop
181
184
  spine-parts assemble --source painting.png --full layers/full --head layers/head --config config.json --out work
185
+ # -> work/rig: parts.json and parts/; the config holds no bones, meshes, regions or motion yet
186
+ # -> read the `uncovered hole N:` lines and look at work/render/recomposite_error_rig.png:
187
+ # red is painting that no part holds, and no later gate can see it
188
+ # -> a large red hole neither run holds? add an assemble.patches entry (cut from the painting) and assemble again
182
189
  spine-parts propose --parts work/rig --source painting.png --out work
183
190
  # -> proposal.json and render/landmarks.png; correct it, copy bones/meshes/regions/motion into config.json
184
191
  spine-parts propose --parts work/rig --source painting.png --out work --from-config config.json
@@ -187,6 +194,11 @@ spine-parts build --config config.json --source painting.png --full layers/full
187
194
  # -> read out/check/check.json; every FAIL line names what has to change
188
195
  ```
189
196
 
197
+ At each step the config holds only what that step reads; the one table of what
198
+ that is, step by step, is [docs/AUTHORING.md §4](docs/AUTHORING.md#4-the-command-order).
199
+ The selftest runs this block in order, command by command, on each fetched example,
200
+ from a config holding only what the first step reads (`RL01`).
201
+
190
202
  `propose` is deliberately not a step of `build`: the proposal is a draft to correct
191
203
  against its overlay, and a config with bones is `build`'s input. `rig`, `check` and
192
204
  `loop` are the same stages one at a time. [docs/AUTHORING.md](docs/AUTHORING.md) is
@@ -202,15 +214,15 @@ agent skill.
202
214
  | `inputs --source <png> --config <json> --out <dir>` | the two images See-through is fed: the painting on a white square, and the head box's crop once the config has one |
203
215
  | `comfy paint --config --out [--host]` | optional: generate the painting on a ComfyUI box from the config's `generation` block |
204
216
  | `comfy seethrough --image --out [--host]` | optional: run the ComfyUI See-through wrapper on one image and write the form `layers` reads |
205
- | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth |
217
+ | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth, and its translucent, background and area figures; a `WARN` line for a layer `--propose-plan` will leave out |
206
218
  | `sheet --source <png> --layers <path>… --out <png>` | a labelled contact sheet of the painting and every layer or part |
207
- | `assemble --propose-plan …` | propose `assemble.plan` and `extend_below_crop` from the two runs |
208
- | `assemble --source --full --head --config --out [--seam] [--project]` | merge the two runs into rig-space parts, `parts.json` and the recomposite |
219
+ | `assemble --propose-plan …` | propose `assemble.plan` and `extend_below_crop` from the two runs, leaving out an implausible layer with a note naming the rule |
220
+ | `assemble --source --full --head --config --out [--seam] [--project]` | merge the two runs into rig-space parts, `parts.json`, the recomposite and its error map, and list the uncovered holes |
209
221
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
210
222
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
211
223
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
212
- | `rig --config --parts --out` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green |
213
- | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the five judgement lines |
224
+ | `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` |
214
226
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
215
227
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
216
228
 
@@ -239,7 +251,9 @@ These are limits of the approach, stated so nobody reads more into a green run:
239
251
  encoder, which this package does not carry.
240
252
  - **The proposer reads tags, not pictures.** A swinging element painted inside another
241
253
  layer (a sash tail in the skirt), hair that is none of the shapes it knows, and
242
- whether an accessory swings are the corrector's to add.
254
+ whether an accessory swings are the corrector's to add. The one accessory shape it
255
+ measures is a hanging strand on a headwear or earwear layer: each gets a pendulum
256
+ chain, and a strand it cannot chain is named in a note rather than left stiff.
243
257
 
244
258
  ### How much of a rig the model painted
245
259
 
package/cli.ts CHANGED
@@ -16,16 +16,17 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
- import { assembleStage, build, checkStage, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
21
- import { findRigc, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK } from './src/check.ts';
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
+ 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';
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 { type LayerSet, readLayers } from './src/layers.ts';
28
- import { checkProposal, compare, compareLines, drawLandmarks, lint, lintLine, type PartSet, propose, readPartSet, serializeProposal } from './src/propose.ts';
27
+ import { DEFAULT_IDLE_KEYS, IDLE_KEYS, type IdleKeys } from './src/rig.ts';
28
+ import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
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';
30
31
  import { buildSheet, defaultCaption, type Tile, tilesFrom } from './src/sheet.ts';
31
32
 
@@ -52,9 +53,14 @@ usage:
52
53
  spine-parts layers <dir | layers.json | file.psd>
53
54
  Read a See-through decomposition — the ComfyUI wrapper form (a directory
54
55
  holding layers.json and one PNG per layer) or an upstream .psd — and print
55
- every layer: draw order, name, tag group, box, size, opaque pixels, depth.
56
- Refuses, by name, a missing file, an unknown tag, a PNG whose size is not
57
- its box, and anything else outside the input contract.
56
+ every layer: draw order, name, tag group, box, size, opaque pixels, depth,
57
+ and three plausibility figures over its opaque pixels — translucent share
58
+ (alpha below 128), background share (min channel above 235) and area as a
59
+ multiple of the rest of the figure (the other layers' union). A layer that
60
+ crosses a plausibility rule gets a WARN line naming the rule and the bar;
61
+ --propose-plan leaves it out. Refuses, by name, a missing file, an
62
+ unknown tag, a PNG whose size is not its box, and anything else outside
63
+ the input contract — never a WARN.
58
64
 
59
65
  spine-parts sheet --source <painting.png> --layers <path> [--layers <path> ...]
60
66
  --out <sheet.png> [--cell <px>] [--cols <n>]
@@ -68,9 +74,17 @@ usage:
68
74
  Propose bones, meshes, regions and an idle from the assembled parts (<dir>
69
75
  holds parts.json and parts/). Roles come from each part's See-through tag,
70
76
  never its name. Writes <out>/proposal.json (config-shaped: bones, meshes,
71
- regions, motion with its blink, and notes) and the overlay to correct
72
- against, <out>/render/landmarks.png and landmarks_head.png. Prints every
73
- note and a LINT line for each chain link that lies off its mesh's art.
77
+ regions, motion with its blink — and a blink.still cut for a lash that
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 — and notes)
81
+ and the overlay to correct against, <out>/render/landmarks.png and
82
+ landmarks_head.png. Prints every
83
+ note and a LINT line for each chain link that lies off its mesh's art, for a
84
+ hip that is not below the chest, and for a hip above ${HIP_MIN_FRACTION} of the figure's
85
+ height (the shoulders); a headwear/earwear layer with hanging strands
86
+ gets one pendulum chain per strand and a note with each strand's x, rows
87
+ and width ("-- no chain proposed" is the one to act on; AUTHORING §3).
74
88
  --compare prints each shared bone's distance, proposal to config, in px.
75
89
 
76
90
  spine-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
@@ -82,21 +96,35 @@ usage:
82
96
  layers for a painting of WxH px, held inside the painting; a shift is
83
97
  printed when one was needed.
84
98
 
85
- spine-parts rig --config <config.json> --parts <dir> --out <dir>
99
+ spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
86
100
  Author the rig: unrotated bones at the config's landmarks (a chain makes
87
101
  <chain>0..n), a square lattice mesh over every part in config.meshes
88
102
  weighted by distance to its candidate bone segments, a region for every
89
- part in config.regions, and one idle of sines and a blink. --parts is the
103
+ part in config.regions (a motion.blink.still part as two: the rows above
104
+ its row on a second slot <part>_still, which the blink does not move),
105
+ and one idle of sines and a blink whose closed hold is at least one
106
+ 12 fps frame, so the idle frames and the loop show the eyes shut
107
+ (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
90
110
  directory holding parts.json and parts/<name>.png. The result is built
91
111
  through spine-rigc (profile spine-html, packed, then validated under
92
112
  profile spine) in a scratch directory first, and --out receives
93
113
  images/*.png, rig.json, motion.json and mesh_report.json only when both
94
114
  are green. Prints one line per mesh and the rigc gate lines.
115
+ --idle-keys says where the idle's keys on a bone a mesh is weighted to go:
116
+ ctl (the default) keys a same-origin <bone>_ctl parent instead, which
117
+ passes A15_IDLE_NO_MESH_BONE_KEYS on any spine-rigc; direct keys the bone
118
+ itself and declares invariants.idleDrivesMeshes in rig.json, which needs
119
+ spine-rigc 1.3.0 or later and makes A15 a SKIP that prints its cost (the
120
+ stage prints that SKIP line). The pose is the same to one level of float
121
+ rounding, and so is the per-frame mesh work (AUTHORING §5).
95
122
  spine-parts check --rig <dir> --out <dir> [--parts <dir>]
96
123
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
97
124
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
98
- (with an "idle"); --parts holds parts.json and parts/ and defaults to
99
- --rig. Both are only read. Into --out:
125
+ (with an "idle").
126
+ ${PARTS_HOME_SENTENCE}.
127
+ Both are only read. Into --out:
100
128
  build/ (rigc build --profile spine-html --pack: the packed atlas is the
101
129
  artifact), gate_spine-html.txt and gate_spine.txt (the gate lines
102
130
  verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640),
@@ -114,7 +142,11 @@ usage:
114
142
  STILL_REGIONS_DARK (the heat map over the face outline and the feet).
115
143
  Regions come from parts.json's See-through tags; a line with nothing to
116
144
  read says SKIP and why — neither a pass nor a failure — and PASS needs
117
- every line that measured to be PASS. Prints the pack line
145
+ every line that measured to be PASS. Then RECOMPOSITE_HOLES: REPORTED,
146
+ read from parts.json's recomposite block (uncovered error px, hole count,
147
+ the largest hole's box and the parts bordering it) — a line with no bar,
148
+ never a FAIL, because a pixel no part holds is missing from both sides of
149
+ the seam; SKIP when parts.json has no such block. Prints the pack line
118
150
  beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
119
151
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
120
152
  and the value required.
@@ -137,14 +169,25 @@ usage:
137
169
  --config <config.json> --out <dir> [--seam near-white|silhouette]
138
170
  [--project core|visible]
139
171
  Merge the full-body and head-crop See-through runs into rig-space parts:
140
- <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json
141
- and <out>/render/recomposite_rig.png. Reads config.seethrough.head_box and
142
- .resolution and config.assemble.rig_scale, .plan and .extend_below_crop.
172
+ <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json,
173
+ <out>/render/recomposite_rig.png and <out>/render/${ERROR_MAP_FILE} (the error
174
+ map: uncovered error px red, covered error px blue, the rest the painting in
175
+ light grey). Reads config.seethrough.head_box and
176
+ .resolution and config.assemble.rig_scale, .plan, .extend_below_crop and
177
+ .patches — extra parts cut from the painting itself over a rig-pixel box
178
+ (alpha "silhouette": the painting's figure inside it; "box": all of it),
179
+ drawn "back", "front" or {"before": <plan part>}, recorded in parts.json
180
+ as from "painting:<name>" and counted 100 % source — and no rig section:
181
+ bones, meshes, regions and motion need not exist yet, because propose
182
+ drafts them from these parts (AUTHORING §4, the table).
143
183
  Prints one line per part, the seam override counts, the \`pixels:\` totals
144
184
  (opaque = visible + occluded; taken from the painting; visible but not
145
185
  projected), and \`recomposite vs source\` (mean |d| and % within 8 over the
146
186
  mean channel; error px: max channel > 40; uncovered: of those, where no
147
- part has alpha above 128). Writes nothing unless every check passed.
187
+ part has alpha above 128), then the uncovered holes (8-connected) and the
188
+ largest ${HOLES_LISTED}, each \`uncovered hole N: <px> px at x,y wxh (between
189
+ "<part>" <px> px, …)\` — the list parts.json holds under recomposite.
190
+ Writes nothing unless every check passed.
148
191
  --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
149
192
  the painting's pixel: core (the reference's) erodes every layer's top-most
150
193
  opaque area by 5x5 first, so a part a few pixels wide takes none; visible
@@ -154,8 +197,11 @@ usage:
154
197
  spine-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
155
198
  --head <dir|psd> --config <config.json>
156
199
  Print {plan, extend_below_crop, notes} for config.assemble, from the two
157
- runs. Reads only config.seethrough.head_box, config.seethrough.resolution
158
- and config.assemble.rig_scale — the rest of the config need not exist yet.
200
+ runs. A layer \`layers\` WARNs about (PLAN_LAYER_TRANSLUCENT,
201
+ PLAN_LAYER_BACKGROUND, PLAN_LAYER_OVERSIZED) is not proposed, and a note
202
+ names it, its figures and the rule. Reads only config.seethrough.head_box,
203
+ config.seethrough.resolution and config.assemble.rig_scale — the rest of
204
+ the config need not exist yet.
159
205
 
160
206
  spine-parts inputs --source <painting.png> --config <config.json> --out <dir>
161
207
  Cut the two images See-through is fed: <out>/st_input_full.png (the
@@ -194,7 +240,8 @@ usage:
194
240
  stops the build with its own FAIL lines. The config must already carry
195
241
  bones, meshes, regions and motion — propose is not a step of build: run it,
196
242
  correct the proposal against its overlay, and write the result into the
197
- config. Into --out: parts/ + parts.json + recomposite_rig.png (assemble),
243
+ config. Into --out: parts/ + parts.json + recomposite_rig.png +
244
+ ${ERROR_MAP_FILE} (assemble),
198
245
  rig/ (rig), check/ (check, with the packed build in check/build/), and with
199
246
  --loop idle.png (lossless APNG), idle-indexed.png (indexed APNG) and
200
247
  idle.gif from check/idle_frames/, then one loop: line with the three sizes
@@ -233,7 +280,8 @@ function fixed(v: number | null): string {
233
280
  function printLayerTable(set: LayerSet): void {
234
281
  console.log(`spine-parts layers: ${set.form === 'wrapper' ? 'ComfyUI wrapper form' : 'PSD'}, ${set.source}`);
235
282
  console.log(` canvas ${set.canvas.w}x${set.canvas.h}, ${set.layers.length} layer(s), back to front`);
236
- const rows = set.layers.map((l) => [
283
+ const figures = layerFigures(set);
284
+ const rows = set.layers.map((l, i) => [
237
285
  String(l.drawOrder),
238
286
  l.name,
239
287
  l.tag.group,
@@ -242,14 +290,26 @@ function printLayerTable(set: LayerSet): void {
242
290
  `${l.pixels.width}x${l.pixels.height}`,
243
291
  String(l.opaquePx),
244
292
  fixed(l.depth),
293
+ pct(figures[i].translucent),
294
+ pct(figures[i].background),
295
+ times(figures[i].areaRatio),
245
296
  ]);
246
- const head = ['order', 'name', 'group', 'left,top', 'right,bottom', 'size', 'opaque_px', 'depth'];
297
+ const head = ['order', 'name', 'group', 'left,top', 'right,bottom', 'size', 'opaque_px', 'depth', 'translucent', 'background', 'area'];
247
298
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
248
299
  const line = (cells: string[]): string => ` ${cells.map((c, i) => c.padEnd(widths[i])).join(' ')}`.trimEnd();
249
300
  console.log(line(head));
250
301
  for (const r of rows) console.log(line(r));
251
302
  const painted = set.layers.filter((l) => l.opaquePx > 0).length;
252
303
  console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
304
+ // A reader refuses nothing on plausibility: it says what --propose-plan will leave out, and why.
305
+ let warned = 0;
306
+ for (const f of figures) {
307
+ for (const rule of implausibleRules(f)) {
308
+ console.log(` WARN ${rule}: layer "${f.name}" — ${figuresPhrase(f)}; ${ruleSummary(rule)} is required, so --propose-plan leaves it out`);
309
+ warned++;
310
+ }
311
+ }
312
+ console.log(` ${warned} WARN line(s)`);
253
313
  }
254
314
 
255
315
  function cmdLayers(args: string[]): number {
@@ -298,6 +358,9 @@ function cmdSheet(args: string[]): number {
298
358
  const tiles: Tile[] = [{ name: 'source', image: src, caption: basename(source) }];
299
359
  for (const path of layers) tiles.push(...tilesFrom(path));
300
360
  const sheet = buildSheet(tiles, cols, cell);
361
+ // The README's loop writes sheets/layers.png into a folder nothing made yet;
362
+ // every other command creates its --out, so this one does too.
363
+ mkdirSync(dirname(out), { recursive: true });
301
364
  writePng(out, sheet);
302
365
  console.log(`spine-parts sheet: ${out}`);
303
366
  console.log(` ${tiles.length} tile(s) in ${cols} column(s) of ${cell} px, sheet ${sheet.width}x${sheet.height}`);
@@ -326,22 +389,28 @@ function cmdRig(args: string[]): number {
326
389
  let config: string | null = null;
327
390
  let partsDir: string | null = null;
328
391
  let out: string | null = null;
392
+ let idleKeys: string | null = null;
329
393
  for (let i = 0; i < args.length; i++) {
330
394
  const flag = args[i];
331
395
  const value = args[i + 1];
332
- if (!['--config', '--parts', '--out'].includes(flag)) return usage(`rig does not take "${flag}"`);
396
+ if (!['--config', '--parts', '--out', '--idle-keys'].includes(flag)) return usage(`rig does not take "${flag}"`);
333
397
  if (value === undefined) return usage(`${flag} needs a value`);
334
398
  i++;
335
399
  if (flag === '--config') config = value;
336
400
  else if (flag === '--parts') partsDir = value;
337
- else out = value;
401
+ else if (flag === '--idle-keys') {
402
+ if (idleKeys !== null) return usage('--idle-keys is given twice');
403
+ idleKeys = value;
404
+ } else out = value;
338
405
  }
339
406
  if (config === null) return usage('rig needs --config <config.json>');
340
407
  if (partsDir === null) return usage('rig needs --parts <dir> (the directory holding parts.json and parts/)');
341
408
  if (out === null) return usage('rig needs --out <dir>');
409
+ const keys = idleKeys ?? DEFAULT_IDLE_KEYS;
410
+ if (!(IDLE_KEYS as readonly string[]).includes(keys)) return usage(`--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`);
342
411
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
343
412
  try {
344
- rigStage({ config, parts: partsDir, out }, rigGateRunner(), scratch, console.log);
413
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys }, rigGateRunner(), scratch, console.log);
345
414
  return EXIT_OK;
346
415
  } catch (err) {
347
416
  return printRefusal(err);
@@ -396,7 +465,8 @@ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes:
396
465
  const res = lint(P, spec);
397
466
  for (const f of res.findings) console.log(lintLine(f));
398
467
  for (const m of res.unknownMeshes) console.log(`note: mesh ${JSON.stringify(m)} names no part in parts.json, so it was not linted`);
399
- console.log(`${res.findings.length} LINT line(s) over ${Object.keys(spec.meshes).length - res.unknownMeshes.length} mesh(es)`);
468
+ for (const b of res.missingTorsoBones) console.log(`note: no single bone named ${JSON.stringify(b)}, so the hip was not linted against the chest${b === 'hip' ? ' or the figure height' : ''}`);
469
+ console.log(`${res.findings.length} LINT line(s) over ${Object.keys(spec.meshes).length - res.unknownMeshes.length} mesh(es) and the hip`);
400
470
  return res.findings.length;
401
471
  }
402
472
 
@@ -541,7 +611,7 @@ function cmdAssemble(args: string[]): number {
541
611
  const out = flags.get('--out') as string;
542
612
  assembleStage(
543
613
  { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule },
544
- { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png') },
614
+ { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png'), errorMap: join(out, 'render', ERROR_MAP_FILE) },
545
615
  console.log,
546
616
  );
547
617
  return EXIT_OK;