spine-parts 0.8.2 → 0.10.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
@@ -77,7 +77,9 @@ built from its own proposal with no hand edit at all —
77
77
  - **A character config** (`config.json`) — the part plan, the head box, the bones,
78
78
  which bones may pull which layer, and the idle's sines. `src/config.ts` is its
79
79
  schema and documents every field; the loader refuses an unknown or a missing field
80
- by name. [docs/AUTHORING.md](docs/AUTHORING.md) says where each value comes from.
80
+ by name, and reads nothing under `note`, a `<name>_note` (a string) or a key
81
+ beginning `x-` (any JSON — a project's own records, such as a past build's gate
82
+ results). [docs/AUTHORING.md](docs/AUTHORING.md) says where each value comes from.
81
83
 
82
84
  ## What it gives out
83
85
 
@@ -262,8 +264,8 @@ agent skill.
262
264
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
263
265
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
264
266
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
265
- | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's gate 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 |
266
- | `check --rig --out [--parts]` | build packed, gated under `spine-html`, 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` |
267
+ | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's gate is green; every chain link is turned along its chain and carries its `length`, so a physics constraint added later has a lever, with every offset under it in the turned frame so nothing moves; `--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 |
268
+ | `check --rig --out [--parts] [--source]` | build packed, gated under `spine-html`, 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`; on a rig spec with no `parts.json` or no `idle` (a merged rig, issue #77) the gate still runs and every line that needs the missing input says SKIP by name; `--source <painting.png>` adds `SETUP_POSE_VS_SOURCE`, the setup pose against the painting by assemble's recomposite figures |
267
269
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
268
270
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
269
271
 
package/cli.ts CHANGED
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
18
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
19
19
  import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
20
- import { DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
20
+ import { DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, NO_PARTS_SENTENCE, SEAM_MEAN_BAR, SOURCE_SENTENCE, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
21
21
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
22
22
  import { type CharacterConfig, loadConfig, loadEarlyConfig } from './src/config.ts';
23
23
  import { PartsError, problemLine } from './src/errors.ts';
@@ -79,7 +79,9 @@ usage:
79
79
  eyebrow, each said in a note; an iris or lash on a side with no
80
80
  eyewhite rides head, said in a note — and notes)
81
81
  and the overlay to correct against, <out>/render/landmarks.png and
82
- landmarks_head.png. Prints every
82
+ landmarks_head.png. A figure with no face part gets a face box derived
83
+ from the head run's hair and neck, said in the first note; with no
84
+ head-run hair or neck it is refused (PROPOSE_FACE_PRESENT). Prints every
83
85
  note and a LINT line for each chain link that lies off its mesh's art, for a
84
86
  hip that is not below the chest, and for a hip above ${HIP_MIN_FRACTION} of the figure's
85
87
  height (the shoulders); a headwear/earwear layer with hanging strands
@@ -93,13 +95,18 @@ usage:
93
95
 
94
96
  spine-parts propose --head-box --full <dir | layers.json | file.psd> --canvas <W>x<H>
95
97
  Propose seethrough.head_box (source px, square) from the full run's
96
- layers for a painting of WxH px, held inside the painting; a shift is
97
- printed when one was needed.
98
+ layers for a painting of WxH px (portrait or landscape: the full run is
99
+ mapped back through the pad inputs added), held inside the painting; a
100
+ shift is printed when one was needed.
98
101
 
99
102
  spine-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
100
103
  [--page-edges pot|free] [--pack-shape rect|polygon]
101
- Author the rig: unrotated bones at the config's landmarks (a chain makes
102
- <chain>0..n), a square lattice mesh over every part in config.meshes
104
+ Author the rig: bones at the config's landmarks (a chain makes
105
+ <chain>0..n, each link turned along its chain with its length, so a
106
+ physics constraint added later has a lever; every offset under a turned
107
+ link is in its frame, so nothing moves; a translate key under a turned
108
+ link or a scale/shear key on one is refused, RIG_KEY_FRAME_UNTURNED),
109
+ a square lattice mesh over every part in config.meshes
103
110
  weighted by distance to its candidate bone segments, a region for every
104
111
  part in config.regions (a motion.blink.still part as two: the rows above
105
112
  its row on a second slot <part>_still, which the blink does not move),
@@ -123,12 +130,14 @@ usage:
123
130
  spine-rigc 1.3.0 or later and makes A15 a SKIP that prints its cost (the
124
131
  stage prints that SKIP line). The pose is the same to one level of float
125
132
  rounding, and so is the per-frame mesh work (AUTHORING §5).
126
- spine-parts check --rig <dir> --out <dir> [--parts <dir>] [--page-edges pot|free]
127
- [--pack-shape rect|polygon]
133
+ spine-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
134
+ [--page-edges pot|free] [--pack-shape rect|polygon]
128
135
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
129
- node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
130
- (with an "idle").
131
- ${PARTS_HOME_SENTENCE}.
136
+ node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json:
137
+ a rig spec, which rigc build compiles, not a compiled skeleton.json.
138
+ motion.json is required (rigc build takes --motion; "animations": {}
139
+ builds); an "idle" in it is not.
140
+ ${PARTS_HOME_SENTENCE}; ${NO_PARTS_SENTENCE}.
132
141
  Both are only read. Into --out:
133
142
  build/ (rigc build --profile spine-html --pack --page-edges <value>
134
143
  --pack-shape <value>: the packed atlas is the artifact), gate_spine-html.txt (the gate lines
@@ -163,7 +172,18 @@ usage:
163
172
  read from parts.json's recomposite block (uncovered error px, hole count,
164
173
  the largest hole's box and the parts bordering it) — a line with no bar,
165
174
  never a FAIL, because a pixel no part holds is missing from both sides of
166
- the seam; SKIP when parts.json has no such block. Prints the pack line
175
+ the seam; SKIP when parts.json has no such block. What a bar cannot
176
+ read it says SKIP for, by name, with the reason (AUTHORING §7, Measuring a
177
+ rig spine-parts did not assemble): without parts.json the seam,
178
+ BREATH_VISIBLE, BLINK_NO_HOLE, TIP_OVER_ROOT, STILL_REGIONS_DARK and
179
+ RECOMPOSITE_HOLES; without an idle the loop and every line that reads idle
180
+ frames, and no idle is rendered. The gate always runs. The last line says
181
+ how many of the nine bars measured and names the skipped ones.
182
+ --source adds SETUP_POSE_VS_SOURCE: REPORTED, never a FAIL — the setup
183
+ pose against the painting by assemble's own recomposite figures (mean |d|,
184
+ % within 8, error px over 40, uncovered error px where the pose has alpha
185
+ 128 or less, holes). ${SOURCE_SENTENCE}; a painting that is not this
186
+ rig's is refused, CHECK_SOURCE_SIZE, before anything is built. Prints the pack line
167
187
  beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
168
188
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
169
189
  and the value required.
@@ -242,10 +262,11 @@ usage:
242
262
 
243
263
  spine-parts inputs --source <painting.png> --config <config.json> --out <dir>
244
264
  Cut the two images See-through is fed: <out>/st_input_full.png (the
245
- painting centred on a white square as tall as it is) and, when the config
246
- sets seethrough.head_box, <out>/st_input_head.png (that box, cropped at
247
- its exact size). Refuses a landscape or translucent painting and a head
248
- box outside the painting, by name.
265
+ painting centred on a white square of its longer side: white left and
266
+ right of a portrait painting, above and below a landscape one) and, when
267
+ the config sets seethrough.head_box, <out>/st_input_head.png (that box,
268
+ cropped at its exact size). Refuses a translucent painting and a head box
269
+ outside the painting, by name.
249
270
 
250
271
  spine-parts comfy seethrough --image <png> --out <dir> [--host <url>]
251
272
  [--resolution 1024] [--steps 30] [--seed 42] [--offload] [--lama] [--nf4]
@@ -516,7 +537,7 @@ function flags(args: string[], known: readonly string[], command: string, option
516
537
  }
517
538
 
518
539
  function cmdCheck(args: string[]): number {
519
- const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--page-edges', '--pack-shape']);
540
+ const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--source', '--page-edges', '--pack-shape']);
520
541
  if (typeof f === 'string') return usage(f);
521
542
  const rig = f.get('--rig') as string;
522
543
  const out = f.get('--out') as string;
@@ -526,7 +547,7 @@ function cmdCheck(args: string[]): number {
526
547
  if (!isPackShape(shape)) return usage(shape);
527
548
  try {
528
549
  const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
529
- const r = checkStage({ rig, parts: f.get('--parts') ?? rig, out, pageEdges: edges, packShape: shape }, rigcRunner(bin), bin, console.log);
550
+ const r = checkStage({ rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape }, rigcRunner(bin), bin, console.log);
530
551
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
531
552
  } catch (err) {
532
553
  return printRefusal(err);
@@ -815,7 +836,11 @@ function cmdInputs(args: string[]): number {
815
836
  mkdirSync(out, { recursive: true });
816
837
  writePng(join(out, 'st_input_full.png'), r.full);
817
838
  console.log(`spine-parts inputs: ${source} (${painting.width}x${painting.height}) -> ${out}`);
818
- console.log(` st_input_full.png ${r.full.width}x${r.full.height}: the painting at x ${r.padLeft}, white either side (${r.padLeft} + ${r.full.width - painting.width - r.padLeft} px)`);
839
+ console.log(
840
+ r.padTop > 0
841
+ ? ` st_input_full.png ${r.full.width}x${r.full.height}: the painting at y ${r.padTop}, white above and below (${r.padTop} + ${r.full.height - painting.height - r.padTop} px)`
842
+ : ` st_input_full.png ${r.full.width}x${r.full.height}: the painting at x ${r.padLeft}, white either side (${r.padLeft} + ${r.full.width - painting.width - r.padLeft} px)`,
843
+ );
819
844
  if (r.head !== null && r.headBox !== null) {
820
845
  writePng(join(out, 'st_input_head.png'), r.head);
821
846
  console.log(` st_input_head.png ${r.head.width}x${r.head.height}: seethrough.head_box [${r.headBox.join(', ')}]`);
package/docs/AUTHORING.md CHANGED
@@ -47,9 +47,18 @@ examples use, and the one this guide assumes:
47
47
 
48
48
  `src/config.ts` is the schema and the loader. Every key is known or refused
49
49
  (`CONFIG_KEY_KNOWN`); a required key that is missing is `CONFIG_FIELD_PRESENT`.
50
- The one open door is annotation: `note`, and any key ending in `_note`, may hold a
51
- string anywhere an object is, and nothing reads it — write a hand correction's
52
- reason there, beside the value it corrected (the examples do).
50
+ There are two open doors, and nothing reads what stands behind either. An
51
+ annotation: `note`, and any key ending in `_note`, may hold a string anywhere an
52
+ object is — write a hand correction's reason there, beside the value it corrected
53
+ (the examples do). A record: any key beginning `x-` with a name after it may hold
54
+ any JSON value in the same places — a project's own provenance, such as the gate
55
+ results of a past build (`"x-status": {…}`) or the seeds a search rejected, with
56
+ reasons (`generation.x-seeds_tried`). The record test comes first, so
57
+ `x-seed_note` is a record. `xstatus`, `x_status`, `X-status` and a bare `x-` are
58
+ not record names and are refused like any unknown key. The doors are not open in
59
+ `meshes`, `regions` or `motion.blink.still`: their keys are part names, and an
60
+ `x-` key there is a part like any other. Every refusal of an unknown key, of a
61
+ retired one and of an annotation that is not a string says where a record goes.
53
62
 
54
63
  **Coordinates** in `bones`, `meshes` and `motion` are **rig pixels, y down, origin
55
64
  top-left** — the parts' own space, which is the painting times `assemble.rig_scale`.
@@ -71,11 +80,11 @@ read by no CPU stage).
71
80
  | `assemble.plan` | assemble; also the list every `meshes`/`regions` key must come from | **proposed** by `assemble --propose-plan`. `[part name, "full" \| "head", See-through tag]`, in draw order back to front. Names are free; roles come from the tag |
72
81
  | `assemble.extend_below_crop` | assemble | **proposed** with the plan: `{part, run, tag}` — a head-run part that reaches the bottom of the head crop is continued from a full-run layer, whole connected components |
73
82
  | `assemble.patches` | assemble; `meshes`/`regions` keys may name them too | **authored**, optional: `[{name, box, alpha, draw}]` — an extra part cut from the **painting** itself, for figure no See-through layer holds (a hem both runs dropped). `box` is `[x0, y0, x1, y1]` in **rig** pixels, `x1`/`y1` exclusive — the space of `parts.json` and `recomposite_rig.png`, where the hole is found; `alpha` is `"silhouette"` (the painting's figure silhouette inside the box, the one `--seam silhouette` uses) or `"box"` (the whole box); `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`. A patch is always a region: its bone is `regions.<name>`, in the one place every region's bone lives, and a `meshes` entry for it is refused. See §5 for how to place one |
74
- | `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip}` whose links are named `<chain>0 … <chain>n`. Parents come before children |
83
+ | `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone is never turned; one whose parent is a link is turned back upright |
75
84
  | `meshes.<part>` | rig | **proposed**, then corrected. `grid` (lattice cell size, px), `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's |
76
85
  | `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
86
  | `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
- | `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`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley` |
87
+ | `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`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
79
88
  | `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
89
  | `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
90
 
@@ -112,6 +121,23 @@ chain — or, when the accessory has hanging strands, one pendulum chain per str
112
121
  swing, hair of another shape, whether an accessory swings — is yours to add, and
113
122
  `notes` in `proposal.json` says where it guessed.
114
123
 
124
+ **A figure with no `face` part** (turned away, or half hidden behind a partner,
125
+ so See-through found hair and a neck and no face) still gets a proposal. The face
126
+ box every rule scales by is derived from the head run's hair (`front hair`,
127
+ `back hair`: their highest top) and its `neck` (its top, and its centre x for the
128
+ axis): with `L` the span from the hair's top to the neck's top, the face is
129
+ 1.067 `L` tall, its top 0.131 `L` below the hair's, its width 0.798 of its height.
130
+ Those ratios are the means of the two public examples (`demo` 1.073 / 0.105 /
131
+ 0.820, `sample` 1.061 / 0.158 / 0.777) — a sample of two. On each example
132
+ re-assembled without its face, the derived box put `head` and `neck` within
133
+ 5.0 px (0.038 face heights) of the with-face proposal's, `chest` and `hip` at
134
+ the same pixel. The first note says so —
135
+ `no face part: face box derived from the head run's hair (…; top y=…) and neck (…), a span of … px — … (ratios measured on the two public examples); …`
136
+ — and names each face-reading rule left with nothing to read (eye bones and blink,
137
+ brows, mouth). Correct `head` and `neck` against the overlay. With no head-run
138
+ hair or neck there is nothing to derive from, and `PROPOSE_FACE_PRESENT` still
139
+ refuses (§6).
140
+
115
141
  Two rules read the figure rather than one tag, and each says so in a note:
116
142
 
117
143
  - **The hip.** It sits 0.14 face heights below `bottomwear`'s top edge — unless
@@ -216,9 +242,13 @@ full loader before its assemble stage writes anything.
216
242
  generates `painting_<seed>.png` on a ComfyUI box; any other route to a painting
217
243
  skips this step and leaves `generation` out. Config: the `paint` row.
218
244
  1. **The full image.** `spine-parts inputs --source inputs/painting.png --config config.json --out inputs`
219
- writes `st_input_full.png`, the painting centred on a white square as tall as it is.
220
- Config: the `layers` row, `head_box` not yet. A landscape or translucent
221
- painting is refused (`INPUTS_PAINTING_PORTRAIT`, `INPUTS_PAINTING_OPAQUE`).
245
+ writes `st_input_full.png`, the painting centred on a white square of its longer
246
+ side: white left and right of a portrait painting, above and below a landscape
247
+ one. The pad is See-through's input only; `propose --head-box` and `assemble` map
248
+ the full run back through it, so the rig stays in the painting's pixels — unlike
249
+ a painting padded to a square by hand, whose padding becomes part of the rig.
250
+ Config: the `layers` row, `head_box` not yet. A translucent painting is refused
251
+ (`INPUTS_PAINTING_OPAQUE`).
222
252
  2. **See-through, full run** (external).
223
253
  3. `spine-parts layers inputs/layers/full` — read it. A refusal here is about the
224
254
  files, not the art (§6, *Reading the inputs*). A `WARN` line is about the art: a
@@ -265,9 +295,9 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
265
295
  | `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*) |
266
296
  | `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 |
267
297
  | `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) |
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 gate summaries `0 failed` (the compile and the packed pages) | `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) |
298
+ | `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`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `sample` with only its face removed: 587 px) |
299
+ | `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`); in `rig.json`, each chain link's (or its `_ctl`'s) `length` and `rotation` | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages); every link's `length` the distance to the next link and `rotation` its direction (Spine degrees, counter-clockwise, y up, local to the parent), every offset under it — a child bone's `x, y`, a weight's bind `x, y`, a region's `x, y` — in that turned frame, and a region on a link carrying `rotation` that turns it back upright. Nothing moved: `flattenRig` (`src/rig.ts`) turns every offset back and gives the unturned numbers | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
300
+ | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the six judgement lines, `RECOMPOSITE_HOLES` and, under `--source`, `SETUP_POSE_VS_SOURCE` (§7), `check.json`; the last line says how many of the nine bars measured and names the ones that said SKIP | `check: PASS; 9 of 9 bar(s) measured, 0 skipped` on the examples, and a judgement line SKIP only where the character lacks what it reads; on a rig spec with no `parts.json` or no `idle` (a merged rig, §7 *Measuring a rig spine-parts did not assemble*), PASS with the skipped bars named | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
271
301
  | `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) |
272
302
 
273
303
  `loop` writes three files from one frame set, and they are not interchangeable.
@@ -570,7 +600,7 @@ See-through with another seed.
570
600
  | rule | means | change |
571
601
  | --- | --- | --- |
572
602
  | `CONFIG_FILE_PRESENT`, `CONFIG_IS_JSON` | no such file, or not JSON | `--config` |
573
- | `CONFIG_KEY_KNOWN`, `CONFIG_KEY_RETIRED`, `CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE` | an unknown key (a retired one says what replaces it), a missing one, a wrong type | the named field |
603
+ | `CONFIG_KEY_KNOWN`, `CONFIG_KEY_RETIRED`, `CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE` | an unknown key (a retired one says what replaces it), a missing one, a wrong type; a project's own record under a plain key, or an annotation (`note`, `<name>_note`) holding something other than a string | the named field; a record goes under a key beginning `x-` (any JSON, read by nothing), a remark under `note` or `<name>_note` (a string) — §3 |
574
604
  | `CONFIG_HEAD_BOX_SQUARE` | `seethrough.head_box` is not a non-empty square | `head_box` — take `propose --head-box`'s |
575
605
  | `CONFIG_TAG_KNOWN`, `CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` | a plan entry names no v3 tag, a part name that is not a file name, or a part or layer twice — a patch named like a plan part or another patch included | `assemble.plan`, `assemble.patches` |
576
606
  | `CONFIG_NAME_RESOLVES` | a parent, segment, region, track, blink member, blink still part or bone, extend part or patch `draw.before` names nothing declared above it | the named reference, or the declaration it needs |
@@ -590,7 +620,7 @@ See-through with another seed.
590
620
  | rule | means | change |
591
621
  | --- | --- | --- |
592
622
  | `INPUTS_SOURCE_PRESENT` | no such painting | `--source` |
593
- | `INPUTS_PAINTING_PORTRAIT`, `INPUTS_PAINTING_OPAQUE` | the painting is wider than tall, or has a pixel below alpha 255 | the painting |
623
+ | `INPUTS_PAINTING_OPAQUE` | the painting has a pixel below alpha 255 | the painting |
594
624
  | `INPUTS_HEAD_BOX_INSIDE` | `seethrough.head_box` leaves the painting (the crop is not padded) | `head_box` — take `propose --head-box`'s |
595
625
 
596
626
  ### The optional ComfyUI adapter (`comfy paint`, `comfy seethrough`)
@@ -609,7 +639,6 @@ See-through with another seed.
609
639
  | rule | means | change |
610
640
  | --- | --- | --- |
611
641
  | `ASSEMBLE_FIELD_PRESENT` | `seethrough.head_box` is missing | `propose --head-box` proposes it (§3) |
612
- | `ASSEMBLE_SOURCE_PORTRAIT` | the painting is wider than tall | the painting |
613
642
  | `ASSEMBLE_RIG_SIZE` | `rig_scale` makes an empty rig | `assemble.rig_scale` |
614
643
  | `ASSEMBLE_HEAD_BOX_INSIDE` | the head box is outside the painting | `head_box` — `propose --head-box` holds it inside |
615
644
  | `ASSEMBLE_RUN_CANVAS` | a run's canvas is not `resolution` square | `seethrough.resolution`, or the run |
@@ -623,9 +652,9 @@ See-through with another seed.
623
652
 
624
653
  | rule | means | change |
625
654
  | --- | --- | --- |
626
- | `HEADBOX_FACE_PRESENT`, `HEADBOX_RUN_SQUARE`, `HEADBOX_CANVAS_SIZE`, `HEADBOX_CANVAS_PORTRAIT`, `HEADBOX_FITS_CANVAS` | the full run has no face or is not square; `--canvas` is not a positive size or is landscape; or the painting cannot hold the proposed box at its size (shrinking it would cut the head) | `--full`, `--canvas`, or the painting |
655
+ | `HEADBOX_FACE_PRESENT`, `HEADBOX_RUN_SQUARE`, `HEADBOX_CANVAS_SIZE`, `HEADBOX_FITS_CANVAS` | the full run has no face or is not square; `--canvas` is not a positive size; or the painting cannot hold the proposed box at its size — its shorter side is the bound (shrinking it would cut the head) | `--full`, `--canvas`, or the painting |
627
656
  | `PROPOSE_SOURCE_PRESENT`, `PROPOSE_PNG_PRESENT`, `PROPOSE_PNG_MATCHES_BOX` | the painting or a part PNG is missing, or a PNG is not its box | `--source`, `--parts` (re-run assemble) |
628
- | `PROPOSE_FACE_PRESENT` | no part comes from a `face` layer; every other rule scales by it | `assemble.plan` |
657
+ | `PROPOSE_FACE_PRESENT` | no part comes from a `face` layer, and the face-less fallback cannot derive a face box either: it needs the head run's `neck` and at least one head-run `front hair`/`back hair` part whose top is above the neck's, and the message names the half that is missing. With both, `propose` does not refuse — it derives the box and says so in its first note (§5) | `assemble.plan`: take the face, or the head run's hair and neck |
629
658
  | `PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
630
659
  | `PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`, `PARTS_COUNTS_ADD_UP`, `PARTS_HOLES_LARGEST_FIRST`, `PARTS_HOLE_PART_KNOWN`) | the `parts.json` read is not assemble's contract (`PARTS_COUNTS_ADD_UP`: `visible_px + occluded_px` is not `opaque_px`, `visible_not_projected_px` is above `visible_px`, or the `recomposite` block's figures disagree — more uncovered than error pixels, a hole larger than its box, the wrong number of holes listed, listed areas that do not sum to `uncovered_error_px`; the last two codes are the block's order and its border names, §5) | re-run assemble; do not edit `parts.json` |
631
660
 
@@ -638,6 +667,7 @@ See-through with another seed.
638
667
  | `RIG_NAME_RESOLVES`, `RIG_SEGMENT_DEFINED`, `RIG_CHAIN_POINTS` | a segment names an undeclared bone, a bone with no `tip` and no next link, or a chain too short to make a link | that bone's `tip`, or write the segment out as `[bone, [x0,y0], [x1,y1]]` |
639
668
  | `RIG_LATTICE_ONE_LOOP` | the lattice over a part does not close into one outline even after the repair passes | that mesh's `grid` |
640
669
  | `RIG_CONTROL_NAME_FREE` | a keyed, mesh-weighted bone needs `<bone>_ctl` and that name is taken | rename the declared bone |
670
+ | `RIG_KEY_FRAME_UNTURNED` | a `translate` key on a bone whose parent is a chain link turned along its chain, or a `scale`/`shear` key (a track, or the blink's `eyes`/`brows` group) on a turned link: the key would act along the link's axis, not the picture's; the detail names the track, the bone and the turn | key the chain's own parent, or key `rotate`; a link whose turn is 0 (a level chain) keeps every key |
641
671
  | `RIG_BLINK_INSIDE_IDLE` | the blink runs outside the idle | `motion.blink.t` |
642
672
  | `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) |
643
673
  | `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 |
@@ -648,8 +678,11 @@ See-through with another seed.
648
678
  | rule | means | change |
649
679
  | --- | --- | --- |
650
680
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
651
- | `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/` |
652
- | `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) |
681
+ | `CHECK_INPUT_PRESENT` on `…/parts.json` | `--parts` named a directory without `parts.json` — most often `parts/` itself — or, without `--parts`, the rig directory has none while the directory above it does (build's layout, `<out>/rig` under `<out>/parts.json`). 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`. With neither (no `--parts`, and no `parts.json` beside `rig.json` or above it), the rig is measured without parts and nothing is refused (§7) | `--parts` — the directory above `parts/` |
682
+ | `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). `motion.json` is required even with no animation in it, because `rigc build` takes `--motion` (a spec with `"animations": {}` builds green); `CHECK_IDLE_PRESENT` is an `idle` that is there with no positive `duration` — an absent `idle` is measured around (§7) | re-run rig (`build` does both) |
683
+ | `CHECK_SOURCE_SIZE` | the painting `--source` names is not this rig's: no single `rig_scale` takes its width and height to the stage's (assemble makes the canvas `trunc(w × rig_scale)` x `trunc(h × rig_scale)`), or, with `parts.json` read, its `scale_rig_per_source` does not; or the stage is not whole pixels. Refused before anything is built | the painting the rig was made from — the stage's own size, or the painting `assemble` was given |
684
+ | `CHECK_SOURCE_PNG` | `--source` names a file that does not read as a PNG | a PNG |
685
+ | `CHECK_SOURCE_GRID` | the setup-pose still and its black-tinted twin (the coverage `SETUP_POSE_VS_SOURCE` reads) did not come back on one grid over one opaque background | a rigc problem; report it |
653
686
  | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^2.10.1), or put a newer `rigc` first on `PATH` |
654
687
  | `CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
655
688
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
@@ -672,7 +705,9 @@ See-through with another seed.
672
705
  ## 7. The bars `check` enforces, and what only an eye answers today
673
706
 
674
707
  `check.json`'s `PASS` is true exactly when all of these hold (the reference
675
- implementation's bars):
708
+ implementation's bars) — each one that measured; a rig with no `parts.json` or no
709
+ `idle` skips the ones that read it, by name (*Measuring a rig spine-parts did not
710
+ assemble*, at the end of this section):
676
711
 
677
712
  - `gate_spine-html.txt`: every summary line `(N passed, 0 failed)` — the packed build
678
713
  under the `spine-html` profile, gated once over the compile and once over the packed
@@ -703,12 +738,12 @@ twice the worse one's, so the weaker example clears it by a factor of two; a bar
703
738
  model itself fixes (a part on an unkeyed bone does not move, a lag is above 0, a hole
704
739
  is 0 pixels) is that value, not a margin — and where the model's value is an error
705
740
  the rig itself measures (a face the head carries is still in the head's frame up to
706
- the resampler's error), the bar is that measurement times the same two. The figures are this port's, measured on
741
+ the resampler's error), the bar is that measurement times the same two. A bar is set once, from the figures of the day it was set, and is not chased: the torso floor 3.809 is half of sample's 7.618, which reads 7.617 since the chain links are turned (issue #73), a factor of 1.9997. The figures are this port's, measured on
707
742
  the two examples [observed]:
708
743
 
709
744
  | line | measures | bar | `demo` | `sample` | SKIP when |
710
745
  | --- | --- | --- | --- | --- | --- |
711
- | `BREATH_VISIBLE` | `topwear` parts rendered alone (`rigc render --slot`): heat mean over their box; `footwear` parts alone: heat max over theirs | torso mean ≥ 3.809; feet max ≤ 0 | 15.252; 0 | 7.618; 0 | no `topwear` or no `footwear` part |
746
+ | `BREATH_VISIBLE` | `topwear` parts rendered alone (`rigc render --slot`): heat mean over their box; `footwear` parts alone: heat max over theirs | torso mean ≥ 3.809; feet max ≤ 0 | 15.251; 0 | 7.617; 0 | no `topwear` or no `footwear` part |
712
747
  | `BLINK_NO_HOLE` | the setup pose with every `scaley` track on the eyewhite slots' bones held at its closed value, against the setup pose, at full size: pixels in the eyewhite box that show the page where the open eye had art | 0 px | 0 | 0 | no `eyewhite` part, or no `scaley` track on its bones goes below its first key |
713
748
  | ″ (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 |
714
749
  | `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 |
@@ -733,6 +768,24 @@ part edges, and whether a hole matters depends on where it is, which the box and
733
768
  bordering parts say and a count cannot. Read it as a figure, quote it in a report, and
734
769
  look at `recomposite_error_rig.png` when the largest hole is more than a sliver.
735
770
 
771
+ Under `--source <painting.png>`, a second reported line, also never a FAIL
772
+ (issue #77):
773
+
774
+ | line | reports | bar | `demo` | `sample` | SKIP when |
775
+ | --- | --- | --- | --- | --- | --- |
776
+ | `SETUP_POSE_VS_SOURCE` | the setup pose against the painting, by `assemble`'s own `measureRecomposite` over the stage's pixels — `mean_abs` and `within_share` (mean channel, within 8), `error_px` (max channel over 40), `uncovered_error_px` (of those, where the pose has alpha 128 or less), `hole_count` and the largest hole's box — with `painting_px`, `stage_px`, `render_px` and `render_scale` beside them. The painting is resampled onto the stage as assemble resamples it (lanczos3, alpha dropped). rigc renders onto an opaque grey, so the pose over white and its alpha are read off a second still of the same pose with every slot tinted black; both are carried onto the stage with the seam's map turned round (bilinear) | none — status `REPORTED`; assemble applies no bar to the figures it measures, so none is derived here | 3.146 / 93.43 % / 12,399 / 1,732, 518 holes, the largest 61 px at 351,157 6x20 (assemble's recomposite: 2.391 / 95.81 % / 11,050 / 1,564, 453) | 1.095 / 97.30 % / 5,348 / 1,239, 313 holes, the largest 88 px at 479,416 6x29 (assemble's: 0.835 / 97.95 % / 4,512 / 1,185, 259) | not written without `--source` |
777
+
778
+ The figures are assemble's by construction, not a second definition beside them;
779
+ what differs is what they are taken of — the pose rigc draws, not the flat stack —
780
+ and the gap between the two columns above is that drawing: the render's resampling at
781
+ its scale (0.942 on the demo, 1.012 on the sample) and the warp back onto the stage,
782
+ which soften every part edge by a pixel or two. It moves with the render's grid
783
+ [observed, the demo's still rendered at four sizes: mean 2.663 at scale 1.0003, 3.091
784
+ at 1.0073, 3.146 at 0.942, 3.186 at 1.884], so compare the line with itself across
785
+ rigs or edits, and with `RECOMPOSITE_HOLES` for where a hole is, not digit for digit
786
+ with the recomposite. On the examples the line reads the same with and without
787
+ `parts.json` (the still's grid does not move).
788
+
736
789
  What each figure is, and is not:
737
790
 
738
791
  - **The seam is the answer to "at rest, no gap, white rim or doubled line between
@@ -779,8 +832,9 @@ What each figure is, and is not:
779
832
  - **The face is measured in the head's frame, and its bar is the model's (issue #33).**
780
833
  Until #33 the face half read the screen-space heat map, where the head's intended
781
834
  roll lights the outline on both examples (16.988 and 11.475, the figures now
782
- reported as `screen_heat_mean`; sample's reads 11.472 since the page edges
783
- default to `free`, *Page edges* in §5), so its ceiling was twice the worse example,
835
+ reported as `screen_heat_mean`; sample's read 11.472 once the page edges
836
+ defaulted to `free`, *Page edges* in §5, and 11.475 again since the chain links
837
+ are turned, issue #73, which moved both torso means down by 0.001 too), so its ceiling was twice the worse example,
784
838
  33.976 — a ceiling a smooth fixture face sliding two rig pixels stayed under (24.8;
785
839
  eight pixels measured 85.5). The judgement means *nothing moves on the face that
786
840
  the head does not move*, so the heat is now taken after each frame's face region is
@@ -831,6 +885,32 @@ Nothing is left that only an eye answers. "No visible texture stretch" left this
831
885
  with issue #31 (`TEXTURE_STRETCH`), and "the face outline held still in the head's own
832
886
  frame" with issue #33 (the face half of `STILL_REGIONS_DARK`, above).
833
887
 
888
+ ### Measuring a rig spine-parts did not assemble
889
+
890
+ `check` takes a rig spec — `rig.json` and `motion.json`, the `rigc-rig/1` and
891
+ `rigc-motion/1` pair `rigc build` compiles — not a compiled `skeleton.json`, because
892
+ the gate is `rigc build`. A rig merged from several spine-parts outputs outside the
893
+ package (prefixed names, one root, a plate region at slot 0) is such a spec, with no
894
+ `parts.json` beside it and possibly no `idle`. Each bar then measures exactly when what
895
+ it reads is there, and says SKIP with the reason when it is not:
896
+
897
+ | bar or line | reads | SKIP when |
898
+ | --- | --- | --- |
899
+ | the gate | the rig spec | never — it always runs, on whatever the spec declares |
900
+ | loop | the `idle` | no `idle` in `motion.json` (`check.json`: `loop_max_diff` null, `skipped.loop` the reason) |
901
+ | seam | `parts.json` and `parts/` | no `parts.json` (`seam_*` null, `skipped.seam` the reason) |
902
+ | `BREATH_VISIBLE`, `BLINK_NO_HOLE`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK` | `parts.json`'s tags and the idle | either missing, or as in the table above |
903
+ | `CHAIN_LAG`, `TEXTURE_STRETCH` | the idle (and `rig.json`) | no `idle`, or as in the table above |
904
+ | `RECOMPOSITE_HOLES` | `parts.json`'s `recomposite` block | no `parts.json`, or no block |
905
+ | `SETUP_POSE_VS_SOURCE` | the rig spec and the painting | no `--source` |
906
+
907
+ `PASS` reads every bar that measured, and the last console line says how many did:
908
+ `check: PASS; 1 of 9 bar(s) measured, 8 skipped (loop, seam, …)` is a rig whose gate is
909
+ green and nothing else was looked at. Without an idle no idle is rendered
910
+ (`idle_frames/`, `contact.png` and `motion_heat.png` are not written). The demo's own
911
+ rig directory, copied away from its `parts.json`, reads `4 of 9`: the gate, the loop,
912
+ `CHAIN_LAG` and `TEXTURE_STRETCH`.
913
+
834
914
  ## 8. What one character costs
835
915
 
836
916
  | step | sample | demo | source |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.8.2",
3
+ "version": "0.10.0",
4
4
  "description": "AI-authored Spine 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through spine-rigc before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assemble.ts CHANGED
@@ -32,8 +32,13 @@
32
32
  * a part left with no opaque pixel (the reference printed `EMPTY` and wrote a
33
33
  * `parts.json` without it), a head box outside the painting (the reference
34
34
  * read row `-1` as the last row), a run whose canvas is not the configured
35
- * resolution (the reference assumed 1024), and a landscape painting (the
36
- * reference refused that too, as a bare exit).
35
+ * resolution (the reference assumed 1024).
36
+ * - **A landscape painting is mapped back, not refused.** The reference
37
+ * refused one as a bare exit; here the full run's input is the painting on a
38
+ * square of its longer side (`squarePad`, `src/inputs.ts`), and the full
39
+ * run's map carries the vertical pad exactly as it carries the horizontal
40
+ * one. A portrait painting's vertical pad is 0, so its map is the
41
+ * reference's.
37
42
  * - **`seamRule: 'silhouette'`** is available beside the faithful
38
43
  * `'near-white'` rule. The reference's seam override skips every pixel whose
39
44
  * painting colour is near-white (min channel above 235), which protects the
@@ -60,6 +65,7 @@
60
65
  import type { CharacterConfig, EarlyConfig, Extend, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
61
66
  import { type Problem, refuseIfAny } from './errors.ts';
62
67
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
68
+ import { squarePad } from './inputs.ts';
63
69
  import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord } from './parts.ts';
64
70
  import {
65
71
  alphaComposite,
@@ -200,8 +206,10 @@ interface Frame {
200
206
  W: number;
201
207
  H: number;
202
208
  S: number;
203
- /** Full run: the painting was centred on a white `side x side` square, padded this much on the left. */
209
+ /** Full run: the painting was centred on a white `side x side` square (`squarePad`), padded this much on the left… */
204
210
  fullPad: number;
211
+ /** …and this much on top, in source px. At most one of the two is non-zero. */
212
+ fullPadTop: number;
205
213
  fullK: number;
206
214
  headBox: [number, number, number, number];
207
215
  headK: number;
@@ -219,10 +227,7 @@ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerS
219
227
  problems.push({ code, object, detail });
220
228
  };
221
229
  const { sourceW: w, sourceH: h, resolution, headBox, rigScale: S } = g;
222
- const side = Math.max(w, h);
223
- if (h !== side) {
224
- fail('ASSEMBLE_SOURCE_PORTRAIT', 'source painting', `is ${w}x${h}, wider than tall; the full run pads the painting to a square horizontally only, so height >= width is required`);
225
- }
230
+ const pad = squarePad(w, h);
226
231
  const W = Math.trunc(w * S);
227
232
  const H = Math.trunc(h * S);
228
233
  if (W < 1 || H < 1) fail('ASSEMBLE_RIG_SIZE', 'config.assemble.rig_scale', `${S} makes a ${W}x${H} rig from a ${w}x${h} painting; at least 1x1 is required`);
@@ -251,8 +256,9 @@ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerS
251
256
  W,
252
257
  H,
253
258
  S,
254
- fullPad: Math.floor((side - w) / 2),
255
- fullK: side / resolution,
259
+ fullPad: pad.left,
260
+ fullPadTop: pad.top,
261
+ fullK: pad.side / resolution,
256
262
  headBox,
257
263
  headK: (x1 - x0) / resolution,
258
264
  headBottom,
@@ -347,7 +353,8 @@ export function runLayers(set: LayerSet, resolution: number): RunLayer[] {
347
353
  export function runMap(frame: Frame, run: Run): { k: number; sx: number; tx: number; ty: number } {
348
354
  if (run === 'full') {
349
355
  const k = frame.fullK * frame.S;
350
- return { k, sx: f32(k), tx: f32(-frame.fullPad * frame.S), ty: 0 };
356
+ // A portrait painting's top pad is 0, and its ty is the reference's literal 0 rather than f32(-0).
357
+ return { k, sx: f32(k), tx: f32(-frame.fullPad * frame.S), ty: frame.fullPadTop === 0 ? 0 : f32(-frame.fullPadTop * frame.S) };
351
358
  }
352
359
  const k = frame.headK * frame.S;
353
360
  return { k, sx: f32(k), tx: f32(frame.headBox[0] * frame.S), ty: f32(frame.headBox[1] * frame.S) };
@@ -1528,7 +1535,8 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
1528
1535
  }
1529
1536
  }
1530
1537
  }
1531
- if (last >= 0 && (last + 1) * frame.fullK * frame.S > frame.headBottom + 8) extend.push({ part: PROPOSE_ALIAS[t], run: 'full', tag: t });
1538
+ // The run row maps to the rig as the warp maps it: k * row, less the top pad in rig px.
1539
+ if (last >= 0 && (last + 1) * frame.fullK * frame.S - frame.fullPadTop * frame.S > frame.headBottom + 8) extend.push({ part: PROPOSE_ALIAS[t], run: 'full', tag: t });
1532
1540
  }
1533
1541
  return { plan, extend_below_crop: extend, notes: [...notes, ...dropNotes] };
1534
1542
  }
package/src/build.ts CHANGED
@@ -44,7 +44,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:
44
44
  import { dirname, join } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
46
  import { assemble, type AssembleResult, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
47
- import { causeLines, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
47
+ import { BARS, causeLines, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
48
48
  import { loadConfig, loadEarlyConfig } from './config.ts';
49
49
  import { PartsError, type Problem, problemLine } from './errors.ts';
50
50
  import { encodeGif } from './gif.ts';
@@ -288,9 +288,15 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
288
288
  export interface CheckStageInput {
289
289
  /** The directory holding rig.json and motion.json. */
290
290
  rig: string;
291
- /** The directory holding parts.json and parts/; the rig directory when they sit there. */
292
- parts: string;
291
+ /**
292
+ * The directory holding parts.json and parts/ (`--parts`). Absent, it is the
293
+ * rig directory, where a missing parts.json is measured without (issue #77);
294
+ * named, a missing one is refused.
295
+ */
296
+ parts?: string;
293
297
  out: string;
298
+ /** The painting `--source` names (issue #77): adds the setup pose against it. */
299
+ source?: string;
294
300
  /** The packed build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. */
295
301
  pageEdges?: PageEdges;
296
302
  /** The packed build's `--pack-shape`; {@link DEFAULT_PACK_SHAPE} when absent. */
@@ -312,6 +318,16 @@ function showFigure(v: unknown): string {
312
318
  return String(v);
313
319
  }
314
320
 
321
+ /**
322
+ * How many of the {@link BARS} measured and how many said SKIP, by name — the
323
+ * summary's second half (issue #77): `PASS` reads only the bars that measured,
324
+ * so a run that measured one bar says so on the line that says PASS.
325
+ */
326
+ export function barsSummary(r: CheckReport): string {
327
+ const { measured, skipped } = r.bars;
328
+ return `${measured.length} of ${BARS.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
329
+ }
330
+
315
331
  /** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
316
332
  export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
317
333
  if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
@@ -333,22 +349,28 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
333
349
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
334
350
  log(` rigc ${versionLines[0]} at ${bin}${entryLine === undefined ? '' : `; ${entryLine}`}`);
335
351
  const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
336
- const r = runCheck(input.rig, input.out, rigc, input.parts, mode);
352
+ const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source);
337
353
  log(` gate spine-html (rigc ${packedBuildLabel(mode)}), verbatim:`);
338
354
  for (const l of r.gateHtml) log(l);
339
355
  for (const l of packLines(r)) log(` ${l}`);
340
356
  const fig = r.figures;
341
- log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
342
- log(
343
- ` seam: setup pose at ${r.seamViewport.pixelWidth}x${r.seamViewport.pixelHeight}, scale ${r.seamViewport.scale.toFixed(4)}: mean |d| ${fig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
344
- `${fig.seam_px_over_40} px over ${SEAM_PX_LEVEL} (<= ${SEAM_PX_BAR}), ${fig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
345
- );
357
+ if (r.idle === null) log(` loop: SKIP — ${fig.skipped?.loop ?? ''}`);
358
+ else log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
359
+ if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
360
+ else {
361
+ log(
362
+ ` seam: setup pose at ${r.seamViewport.pixelWidth}x${r.seamViewport.pixelHeight}, scale ${r.seamViewport.scale.toFixed(4)}: mean |d| ${fig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
363
+ `${fig.seam_px_over_40} px over ${SEAM_PX_LEVEL} (<= ${SEAM_PX_BAR}), ${fig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
364
+ );
365
+ }
346
366
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
347
367
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
368
+ const vsSource = fig[SOURCE_LINE];
369
+ if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
348
370
  log(` gate: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'} (${fig.rigc_entry.entry}${fig.rigc_entry.spine_core === null ? ', rigc\'s own validator' : `, the spine-core ${fig.rigc_entry.spine_core} round trip`})`);
349
- log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/, ${join(input.out, 'idle_frames')}/`);
371
+ log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/${r.idle === null ? '' : `, ${join(input.out, 'idle_frames')}/`}`);
350
372
  for (const p of r.problems) log(` FAIL ${problemLine(p)}`);
351
- log(fig.PASS ? 'check: PASS' : `check: FAIL — ${r.problems.length} bar(s) not met`);
373
+ log(`${fig.PASS ? 'check: PASS' : `check: FAIL — ${r.problems.length} bar(s) not met`}; ${barsSummary(r)}`);
352
374
  return r;
353
375
  }
354
376