spine-parts 0.3.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>
@@ -218,7 +221,7 @@ agent skill.
218
221
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
219
222
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
220
223
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
221
- | `rig --config --parts --out` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green |
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 |
222
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` |
223
226
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
224
227
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
package/cli.ts CHANGED
@@ -24,6 +24,7 @@ import { type CharacterConfig, loadConfig, loadEarlyConfig } from './src/config.
24
24
  import { PartsError, problemLine } from './src/errors.ts';
25
25
  import { proposeHeadBox } from './src/headbox.ts';
26
26
  import { makeInputs } from './src/inputs.ts';
27
+ import { DEFAULT_IDLE_KEYS, IDLE_KEYS, type IdleKeys } from './src/rig.ts';
27
28
  import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
28
29
  import { checkProposal, compare, compareLines, drawLandmarks, HIP_MIN_FRACTION, lint, lintLine, type PartSet, propose, readPartSet, serializeProposal } from './src/propose.ts';
29
30
  import { readPng, writePng } from './src/raster/png.ts';
@@ -74,7 +75,9 @@ usage:
74
75
  holds parts.json and parts/). Roles come from each part's See-through tag,
75
76
  never its name. Writes <out>/proposal.json (config-shaped: bones, meshes,
76
77
  regions, motion with its blink — and a blink.still cut for a lash that
77
- reaches far above its eyewhite, when a clear row allows one — and notes)
78
+ reaches far above its eyewhite, when a clear row allows one; no blink
79
+ when no part is an eyewhite, and no brows in it when no part is an
80
+ eyebrow, each said in a note — and notes)
78
81
  and the overlay to correct against, <out>/render/landmarks.png and
79
82
  landmarks_head.png. Prints every
80
83
  note and a LINT line for each chain link that lies off its mesh's art, for a
@@ -93,18 +96,29 @@ usage:
93
96
  layers for a painting of WxH px, held inside the painting; a shift is
94
97
  printed when one was needed.
95
98
 
96
- 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]
97
100
  Author the rig: unrotated bones at the config's landmarks (a chain makes
98
101
  <chain>0..n), a square lattice mesh over every part in config.meshes
99
102
  weighted by distance to its candidate bone segments, a region for every
100
103
  part in config.regions (a motion.blink.still part as two: the rows above
101
104
  its row on a second slot <part>_still, which the blink does not move),
102
- and one idle of sines and a blink. --parts is the
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
103
110
  directory holding parts.json and parts/<name>.png. The result is built
104
111
  through spine-rigc (profile spine-html, packed, then validated under
105
112
  profile spine) in a scratch directory first, and --out receives
106
113
  images/*.png, rig.json, motion.json and mesh_report.json only when both
107
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).
108
122
  spine-parts check --rig <dir> --out <dir> [--parts <dir>]
109
123
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
110
124
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
@@ -375,22 +389,28 @@ function cmdRig(args: string[]): number {
375
389
  let config: string | null = null;
376
390
  let partsDir: string | null = null;
377
391
  let out: string | null = null;
392
+ let idleKeys: string | null = null;
378
393
  for (let i = 0; i < args.length; i++) {
379
394
  const flag = args[i];
380
395
  const value = args[i + 1];
381
- 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}"`);
382
397
  if (value === undefined) return usage(`${flag} needs a value`);
383
398
  i++;
384
399
  if (flag === '--config') config = value;
385
400
  else if (flag === '--parts') partsDir = value;
386
- 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;
387
405
  }
388
406
  if (config === null) return usage('rig needs --config <config.json>');
389
407
  if (partsDir === null) return usage('rig needs --parts <dir> (the directory holding parts.json and parts/)');
390
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`);
391
411
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
392
412
  try {
393
- rigStage({ config, parts: partsDir, out }, rigGateRunner(), scratch, console.log);
413
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys }, rigGateRunner(), scratch, console.log);
394
414
  return EXIT_OK;
395
415
  } catch (err) {
396
416
  return printRefusal(err);
package/docs/AUTHORING.md CHANGED
@@ -76,9 +76,30 @@ read by no CPU stage).
76
76
  | `regions.<part>` | rig | **proposed**: the bone a rigid part rides. Every plan part is exactly one of a mesh or a region (`CONFIG_PART_ATTACHED`) |
77
77
  | `motion.duration` | rig; check (the loop is measured at this time) | proposed as 4 s; a whole number of 1/12 s ticks, because `check` renders at 12 fps |
78
78
  | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`) |
79
- | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre |
79
+ | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members`. The timing is fixed, not a field (table below) |
80
80
  | `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
81
81
 
82
+ The blink's timing is the tree's (`BLINK` in `src/motion.ts`), in seconds after
83
+ `motion.blink.t`:
84
+
85
+ | phase | eyes (`scaley` 1 → `squash` → 1) | brows (`translatey` 0 → `-brow_drop` → 0) | easing |
86
+ | --- | --- | --- | --- |
87
+ | shut | 0.07 | 0.08 | `shut` |
88
+ | hold | **0.084** (the reference: 0.04) | **0.084** (the reference: 0.04) | none |
89
+ | open | 0.16 | 0.20 | `open` |
90
+
91
+ The holds are the one departure from the reference implementation, and the reason is
92
+ the loop (issue #32). `check` renders the idle at 12 fps, a frame every 0.083333 s, and
93
+ the loop is encoded from those frames. A 0.04 s hold is shorter than a frame, so it
94
+ held a frame only for some `t`: at the examples' 2.3 s the eyes were shut from 2.37 s to
95
+ 2.41 s, between frames 28 (2.333 s, `scaley` 0.8356) and 29 (2.417 s, 0.2435), and over
96
+ every key time the 6-decimal grid can write, 129,999 of the 250,000 phases against the
97
+ frame grid showed no closed frame. A closed window at least one frame long holds a frame
98
+ wherever it starts, so the hold is 1/12 s rounded up to the third place; the brows' hold
99
+ grew by the same 0.044 s, so they still reach their drop 0.01 s after the lids shut and
100
+ leave it 0.01 s after the lids open. The rig stage refuses a hold under one frame by
101
+ name (`RIG_BLINK_HOLD_SPANS_A_FRAME`, §6), counted over the same 250,000 phases.
102
+
82
103
  The proposer's reach (from `src/propose.ts`): roles come from each part's tag —
83
104
  `face` makes `hip`/`chest`/`neck`/`head`, the eyewhites make the eye bones, brows are
84
105
  told apart by position, `bottomwear` makes the hip and three skirt chains,
@@ -239,8 +260,8 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
239
260
  | `layers` | the table, then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
240
261
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
241
262
  | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`; and look at `render/recomposite_error_rig.png` | on the examples: `sample` 0.84 / 98.0 % / 4,512 / 1,185, 259 holes, the largest 94 px; `demo` 2.39 / 95.8 % / 11,050 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
242
- | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3) |
243
- | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; then rigc's gate lines | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
263
+ | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for |
264
+ | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
244
265
  | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the five judgement lines and `RECOMPOSITE_HOLES` (§7), `check.json` | `check: PASS`, and a judgement line SKIP only where the character lacks what it reads | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
245
266
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
246
267
 
@@ -253,9 +274,12 @@ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
253
274
  (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
254
275
  print their palette error, per channel over R, G and B of every frame (and alpha's
255
276
  max for the APNG); a figure is a measurement of the file, not a bar. On the demo:
256
- 13,645,611 B lossless, 1,706,468 B indexed and 1,812,041 B GIF, both palette files at
277
+ 13,645,519 B lossless, 1,706,785 B indexed and 1,812,625 B GIF, both palette files at
257
278
  max 57, mean 1.601 — the two share one quantiser, so their error is the same by
258
- construction and the size is the difference.
279
+ construction and the size is the difference. The loop shows the blink closed: the
280
+ eyes' hold is at least one 12 fps frame (below), so whatever `motion.blink.t` is, one
281
+ idle frame lands inside it, and `check.json`'s `BLINK_NO_HOLE.idle_frames_closed`
282
+ names it — `[29]` on both examples.
259
283
 
260
284
  `--seam near-white` (the default) is the reference implementation's rule: where the
261
285
  flat stack of parts differs from the painting by more than 60, the top part takes the
@@ -276,6 +300,29 @@ error pixels from 4,512 to 4,116 and 11,050 to 9,820, and the check seam from 0.
276
300
  0.206 and 0.326 to 0.325, with no other check figure changed. The default stays the
277
301
  reference's so the examples stay comparable with it. `build` takes both flags.
278
302
 
303
+ **`rig --idle-keys ctl|direct`.** spine-rigc's `A15_IDLE_NO_MESH_BONE_KEYS`
304
+ (profile `spine-html`) refuses an idle that keys a bone a mesh is weighted to. `ctl`
305
+ (the default, the reference's answer) gives every such bone a same-origin
306
+ `<bone>_ctl` parent and moves its keys there, which passes A15 on any spine-rigc;
307
+ the stage prints `idle keys ctl: N mesh-driving bone(s) keyed …`. `direct` keys the
308
+ bones themselves and writes `invariants.idleDrivesMeshes: { "why": … }` into
309
+ `rig.json` (spine-rigc 1.3.0 or later), so A15 reports
310
+ `SKIP A15_IDLE_NO_MESH_BONE_KEYS: declared by the rig (…): idle keys N bone(s) that
311
+ drive M mesh attachment(s) totalling V vertices …`, which the stage prints; with no
312
+ mesh-driving bone keyed it declares nothing, because rigc refuses a declaration
313
+ that switches nothing off. The two write the same slots, skins and keys (`RG18`).
314
+ Measured on the examples with `bun tools/idle_cost.ts` (spine-parts #13): `direct`
315
+ has 41 bones for the demo's 72 and 32 for the sample's 56; every shown mesh moves on
316
+ every idle frame under both (8 of 8 and 6 of 6, so 0 a dirty-skip renderer could
317
+ skip); the pose costs 14.8 against 15.3 us per frame (demo) and 8.3 against 8.8 us
318
+ (sample), the difference all in `updateWorldTransform`; the rendered idle frames
319
+ differ by at most one level in a channel. The default stays `ctl`: the examples'
320
+ `expected/rig.json` and `motion.json` are the reference's output, and the
321
+ declaration is in the rig spec only, so `rigc validate <build> --profile spine-html`
322
+ run on a `direct` build (it has no rig spec to read) refuses A15 once per keyed
323
+ bone. `check` validates the build under `spine` only, where A15 is not in the
324
+ profile, and is green on both.
325
+
279
326
  **Patching a hole no layer holds.** When an `uncovered hole` stays large after the
280
327
  plan is right — both See-through runs dropped a piece of the figure, red in
281
328
  `recomposite_error_rig.png` — add an `assemble.patches` entry:
@@ -434,6 +481,8 @@ See-through with another seed.
434
481
  | `CONFIG_PATCH_BOX` | an `assemble.patches` box with `x1 <= x0` or `y1 <= y0` | that patch's `box` (`x1`, `y1` are exclusive) |
435
482
  | `CONFIG_AMPS_MATCH_CHAIN` | a chain track's `amps` is not one per link | that track's `amps` |
436
483
  | `CONFIG_PERIOD_DIVIDES_DURATION` | a period that is not a whole fraction of the idle — the loop could not close | that track's `period`, or `motion.duration` |
484
+ | `CONFIG_BLINK_GROUP_MEMBERS` | `motion.blink.eyes` or `motion.blink.brows` is `[]`: a group with no members, which rigc refuses at the gate | a figure with no eyewhite part has nothing to blink: leave `motion.blink` out; one with no eyebrow part: leave `brows` and `brow_drop` out |
485
+ | `CONFIG_BLINK_BROWS_PAIRED` | `motion.blink.brows` without `brow_drop`, or `brow_drop` without `brows` | state both or neither |
437
486
  | `CONFIG_STILL_OFF_THE_BLINK` | a `motion.blink.still` entry names a region on a bone the blink's `eyes` does not name (nothing to hold still), or its `bone` is one the blink's `eyes` names (the still piece would blink) | that entry's part, or its `bone` — the eye bone's parent, `head` as proposed |
438
487
 
439
488
  ### inputs
@@ -490,6 +539,7 @@ See-through with another seed.
490
539
  | `RIG_LATTICE_ONE_LOOP` | the lattice over a part does not close into one outline even after the repair passes | that mesh's `grid` |
491
540
  | `RIG_CONTROL_NAME_FREE` | a keyed, mesh-weighted bone needs `<bone>_ctl` and that name is taken | rename the declared bone |
492
541
  | `RIG_BLINK_INSIDE_IDLE` | the blink runs outside the idle | `motion.blink.t` |
542
+ | `RIG_BLINK_HOLD_SPANS_A_FRAME` | the tree's `BLINK.hold` is shorter than one frame at `IDLE_FPS`, so for some `motion.blink.t` no idle frame — and no frame of the loop — shows the closed eye; the detail counts the phases that miss and names the first | nothing in the config: `BLINK.hold` in `src/motion.ts`, at least `1/IDLE_FPS` s (§3) |
493
543
  | `RIG_STILL_ROW_INSIDE_PART`, `RIG_STILL_ROW_CLEAR`, `RIG_STILL_PIECES_HAVE_ART`, `RIG_STILL_NAME_FREE` | a `motion.blink.still` row that is not strictly inside the part, that crosses art (a cut through art changes the render even at rest), that leaves one piece with no art, or whose `<part>_still` slot name another part already has | that entry's `row` — a row with no art between the crease and the lash line — or rename the other part |
494
544
  | `RIG_RIGC_GREEN` | spine-rigc refused the rig; its own FAIL or compile-error line is quoted, and nothing was written | the field rigc's line names — spine-rigc's own AUTHORING §5 maps each of its assertions (`node_modules/spine-rigc/docs/AUTHORING.md`) |
495
545
 
@@ -575,13 +625,15 @@ What each figure is, and is not:
575
625
  layers".** A gap shows the page, a rim a colour no part has there, a doubled line a
576
626
  part drawn off its place; each changes the setup-pose render against the flat stack,
577
627
  which is what the seam bar measures. No separate line is written for it.
578
- - **The blink is measured at the setup pose, not in an idle frame.** On both examples
579
- the eyes are fully shut from 2.37 s to 2.41 s, and no 12 fps idle frame falls inside
580
- that window (`idle_frames_closed` is empty: frames 28 and 29 are 2.333 s and
581
- 2.417 s), so the idle render and the loop encoded from it never show the closed eye.
582
- rigc's `render` takes no time, so the closed pose is a throwaway animation holding
583
- the blink tracks' closed value, built and rendered beside the seam's still on the
584
- same grid — the comparison is then of what the blink alone changed.
628
+ - **The blink is measured at the setup pose, not in an idle frame.** Since issue #32
629
+ an idle frame does fall inside the closed window — on both examples the eyes are
630
+ fully shut from 2.37 s to 2.454 s and `idle_frames_closed` is `[29]` (2.417 s) — but
631
+ that frame also carries the idle's sway, so it is not a comparison of what the blink
632
+ alone changed. rigc's `render` takes no time, so the closed pose is a throwaway
633
+ animation holding the blink tracks' closed value, built and rendered beside the
634
+ seam's still on the same grid. `idle_frames_closed` is what says the loop shows the
635
+ closed eye; before #32 it was `[]` on both examples, with the 0.04 s hold between
636
+ frames 28 and 29.
585
637
  - **The colour-patch figure is not reliable enough for a bar.** "Nearest colour in the
586
638
  open eye's box" counts a legitimate colour the open eye never showed (skin under the
587
639
  lid) as a patch, and a patch the open eye happened to contain as none. It is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI-authored Spine 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through spine-rigc before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "ag-psd": "^31.0.2",
60
- "spine-rigc": "^1.2.3"
60
+ "spine-rigc": "^1.3.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.4.2",
package/src/build.ts CHANGED
@@ -53,7 +53,7 @@ import { type LayerSet, readLayers } from './layers.ts';
53
53
  import { readParts, writeParts } from './parts.ts';
54
54
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
55
55
  import type { Raster } from './raster/types.ts';
56
- import { buildRig, rigJsonText, type RigOutput } from './rig.ts';
56
+ import { buildRig, DEFAULT_IDLE_KEYS, type IdleKeys, rigJsonText, type RigOutput } from './rig.ts';
57
57
 
58
58
  /** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
59
59
  export type Log = (line: string) => void;
@@ -162,13 +162,22 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
162
162
  interface GateRun {
163
163
  label: string;
164
164
  status: number;
165
- /** rigc's FAIL lines and its assertion summary, as it printed them. */
165
+ /** rigc's FAIL lines, its assertion summary and the one SKIP this stage's own declaration causes, as it printed them. */
166
166
  lines: string[];
167
167
  }
168
168
 
169
+ /**
170
+ * `A15_IDLE_NO_MESH_BONE_KEYS`'s SKIP under `invariants.idleDrivesMeshes` —
171
+ * the line in which rigc states what `--idle-keys direct` declared and what it
172
+ * costs. It is the one SKIP the rig stage prints: every other SKIP is a check
173
+ * with nothing to measure, while this one is switched off by a field this
174
+ * stage wrote, so it is shown rather than folded into the skipped count.
175
+ */
176
+ const DECLARED_SKIP = /^ {2}SKIP {2}A15_IDLE_NO_MESH_BONE_KEYS: declared by the rig/;
177
+
169
178
  function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
170
179
  const r = rigc(args);
171
- const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l));
180
+ const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l));
172
181
  return { label, status: r.status, lines };
173
182
  }
174
183
 
@@ -201,6 +210,8 @@ export interface RigStageInput {
201
210
  /** The directory holding parts.json and parts/<name>.png. */
202
211
  parts: string;
203
212
  out: string;
213
+ /** Where the idle's keys on mesh-driving bones go; `ctl` when absent. See `IDLE_KEYS` in `src/rig.ts`. */
214
+ idleKeys?: IdleKeys;
204
215
  }
205
216
 
206
217
  /** Author the rig, gate it through rigc in `scratch`, and write `out` only when both gates are green. */
@@ -212,7 +223,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
212
223
  const png = join(input.parts, 'parts', `${p.name}.png`);
213
224
  if (existsSync(png)) images.set(p.name, readPng(png));
214
225
  }
215
- const rig = buildRig(cfg, parts, images);
226
+ const rig = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS);
216
227
  const texts: Array<[string, string]> = [
217
228
  ['rig.json', rigJsonText(rig.rig)],
218
229
  ['motion.json', rigJsonText(rig.motion)],
@@ -233,6 +244,11 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
233
244
  log(
234
245
  ` bones ${rig.rig.bones.length} (${rig.controls.length} control) slots ${rig.rig.slots.length} meshes ${rig.meshReport.length} regions ${regions} vertices ${vertices}; idle ${rig.motion.animations.idle.duration} s, ${tracks.length} track(s), ${keys} key(s)`,
235
246
  );
247
+ log(
248
+ rig.idleKeys === 'ctl'
249
+ ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
250
+ : ` idle keys direct: ${rig.meshKeyed.length} mesh-driving bone(s) keyed in place, ${rig.rig.invariants === undefined ? 'so no invariants.idleDrivesMeshes is declared (it would switch nothing off)' : 'invariants.idleDrivesMeshes declared'}`,
251
+ );
236
252
  const gate = gateThroughRigc(rig, texts, rigc, scratch);
237
253
  for (const g of gate) {
238
254
  log(` rigc ${g.label}: exit ${g.status}`);
package/src/check.ts CHANGED
@@ -64,6 +64,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync,
64
64
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
65
65
  import { cropToSpineY } from './coords.ts';
66
66
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
67
+ import { framesInside, IDLE_FPS } from './motion.ts';
67
68
  import {
68
69
  bandExcursion,
69
70
  blinkFigures,
@@ -107,8 +108,8 @@ export const SEAM_PX_LEVEL_HIGH = 80;
107
108
  /** Loop: frame 0 vs the frame at t = duration, largest |d|, exactly this. */
108
109
  export const LOOP_MAX_BAR = 0;
109
110
 
110
- /** The idle render the reference makes, and the one issue #1's loop is encoded from. */
111
- export const IDLE_FPS = 12;
111
+ /** The idle render the reference makes, and the one issue #1's loop is encoded from. Defined beside the blink it has to see (`src/motion.ts`). */
112
+ export { IDLE_FPS };
112
113
  export const IDLE_MAX_PX = 640;
113
114
  /** The throwaway setup-pose animation: one key at 0 and one at this time, rendered at this rate. */
114
115
  const STILL_DURATION = 0.1;
@@ -1085,8 +1086,7 @@ function blinkLine(
1085
1086
  const b = blinkFigures(open.frames[0].image, closed.frames[0].image, open.background, box, BLINK_PATCH_LEVEL);
1086
1087
  const from = Math.max(...shut.map((s) => s.window[0]));
1087
1088
  const to = Math.min(...shut.map((s) => s.window[1]));
1088
- const frames: number[] = [];
1089
- for (let k = 0; k < idle.sampled; k++) if (k / idle.fps >= from - 1e-9 && k / idle.fps <= to + 1e-9) frames.push(k);
1089
+ const frames = framesInside(from, to, idle.fps, idle.sampled);
1090
1090
  if (b.holePx > BLINK_HOLE_BAR) {
1091
1091
  problems.push({
1092
1092
  code: 'CHECK_BLINK_NO_HOLE',
package/src/config.ts CHANGED
@@ -175,12 +175,19 @@ export interface BlinkStill {
175
175
  bone: string;
176
176
  }
177
177
 
178
+ /**
179
+ * The idle's one blink. `eyes` names at least one bone; `brows` and
180
+ * `brow_drop` are stated together or not at all, and a stated `brows` names
181
+ * at least one bone — rigc refuses a group with no members, and a
182
+ * `brow_drop` with no brows is a value nothing reads. A figure with no eye
183
+ * bone has no blink: leave `blink` out (`CONFIG_BLINK_GROUP_MEMBERS`).
184
+ */
178
185
  export interface Blink {
179
186
  t: number;
180
187
  eyes: string[];
181
- brows: string[];
188
+ brows?: string[];
182
189
  squash: number;
183
- brow_drop: number;
190
+ brow_drop?: number;
184
191
  still?: Record<string, BlinkStill>;
185
192
  }
186
193
 
@@ -823,6 +830,12 @@ function checkCoverage(c: Check, parts: string[], patches: string[], meshes: Jso
823
830
  }
824
831
  }
825
832
 
833
+ /** What an empty blink group is told: the tags whose parts make its bones, and what to write instead. */
834
+ const BLINK_GROUP_EMPTY: Readonly<Record<'eyes' | 'brows', string>> = {
835
+ eyes: 'is []; the eyes group must name at least one bone (rigc refuses a group with no members). The eye bones come from eyewhite-r / eyewhite-l parts; a figure with none has nothing to blink, so leave config.motion.blink out',
836
+ brows: 'is []; the brows group must name at least one bone (rigc refuses a group with no members). The brow bones come from eyebrow-r / eyebrow-l parts; a figure with none has nothing to drop, so leave brows and brow_drop out',
837
+ };
838
+
826
839
  function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string, number>, regions: Json): void {
827
840
  const m = c.object('config.motion', v, ['duration', 'tracks'], ['blink']);
828
841
  if (m === null) return;
@@ -872,14 +885,25 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
872
885
  });
873
886
  }
874
887
  if ('blink' in m) {
875
- const b = c.object(`${p}.blink`, m.blink, ['t', 'eyes', 'brows', 'squash', 'brow_drop'], ['still']);
888
+ const b = c.object(`${p}.blink`, m.blink, ['t', 'eyes', 'squash'], ['brows', 'brow_drop', 'still']);
876
889
  if (b === null) return;
877
890
  if ('t' in b) c.number(`${p}.blink.t`, b.t, 'non-negative');
878
891
  if ('squash' in b) c.number(`${p}.blink.squash`, b.squash, 'positive');
879
892
  if ('brow_drop' in b) c.number(`${p}.blink.brow_drop`, b.brow_drop);
893
+ if ('brows' in b && !('brow_drop' in b)) {
894
+ c.fail('CONFIG_BLINK_BROWS_PAIRED', `${p}.blink.brow_drop`, 'is absent while config.motion.blink.brows is stated; the brows group drops by brow_drop, so state both or neither');
895
+ }
896
+ if ('brow_drop' in b && !('brows' in b)) {
897
+ c.fail('CONFIG_BLINK_BROWS_PAIRED', `${p}.blink.brows`, `is absent while config.motion.blink.brow_drop is ${show(b.brow_drop)}; nothing would drop, so state both or neither`);
898
+ }
880
899
  for (const key of ['eyes', 'brows'] as const) {
881
900
  if (key in b && c.array(`${p}.blink.${key}`, b[key], false)) {
882
- (b[key] as Json[]).forEach((name, k) => {
901
+ const list = b[key] as Json[];
902
+ // rigc refuses a group with no members (`group "eyes" declares no
903
+ // members`) one stage later, at the gate; the stage whose input is
904
+ // wrong is this one.
905
+ if (list.length === 0) c.fail('CONFIG_BLINK_GROUP_MEMBERS', `${p}.blink.${key}`, BLINK_GROUP_EMPTY[key]);
906
+ list.forEach((name, k) => {
883
907
  if (typeof name !== 'string' || !bones.has(name)) c.fail('CONFIG_NAME_RESOLVES', `${p}.blink.${key}[${k}]`, `is ${show(name)}; a declared bone is required`);
884
908
  });
885
909
  }
package/src/motion.ts CHANGED
@@ -24,10 +24,25 @@
24
24
  * ## Blink
25
25
  *
26
26
  * The `eyes` group's `scaley` goes 1 -> `squash` -> 1 and the `brows`
27
- * group's `translatey` 0 -> `-brow_drop` -> 0, with the reference's fixed
28
- * timing: eyes shut over {@link BLINK.shut} s, hold {@link BLINK.hold} s, open
29
- * over {@link BLINK.open} s; brows 0.08 / 0.04 / 0.20 s. The two named easings
30
- * are the reference's. A blink whose window does not fit strictly inside the
27
+ * group's `translatey` 0 -> `-brow_drop` -> 0 (no brows group and no brow
28
+ * track when the config states no `brows`; no blink groups or tracks at all
29
+ * when it states no `blink`), with a fixed timing: eyes shut
30
+ * over {@link BLINK.shut} s, hold {@link BLINK.hold} s, open over
31
+ * {@link BLINK.open} s; brows 0.08 / 0.084 / 0.20 s. The shut and open times
32
+ * and the two named easings are the reference's; the holds are not (issue
33
+ * #32). The reference held 0.04 s, under one frame of the idle the loop is
34
+ * encoded from ({@link IDLE_FPS} fps, a frame every 0.083333 s), so whether
35
+ * any frame showed the closed eye depended on `blink.t`: at the examples'
36
+ * 2.3 s the eyes were shut from 2.37 s to 2.41 s, between frames 28
37
+ * (2.333 s, scaley 0.8356) and 29 (2.417 s, 0.2435), and over every key time
38
+ * the 6-decimal grid can write, 129,999 of 250,000 phases against the frame
39
+ * grid showed none. A closed interval at least one frame period long holds a
40
+ * frame wherever it starts, so the hold is 1/12 s rounded up to the third
41
+ * place, 0.084 s; {@link blinkHoldMisses} counts the phases and the rig stage
42
+ * refuses a hold with any miss (`RIG_BLINK_HOLD_SPANS_A_FRAME`). The brows'
43
+ * hold grew by the same 0.044 s, so they still reach their drop 0.01 s after
44
+ * the lids shut and leave it 0.01 s after the lids open, as the reference
45
+ * ordered them. A blink whose window does not fit strictly inside the
31
46
  * idle would write keys out of order, and is refused (`RIG_BLINK_INSIDE_IDLE`).
32
47
  * The squash pivots at each eye bone's origin, so a part's rows above it move
33
48
  * down by (1 - squash) times their height above it; the rows a
@@ -38,15 +53,22 @@
38
53
  *
39
54
  * spine-rigc's `spine-html` profile refuses an `idle` that keys a bone a mesh
40
55
  * is weighted to (the player skips idle work on meshes). The reference's
41
- * answer, ported as is: for every keyed bone that some mesh names among its
42
- * candidates, add a parent `<bone>_ctl` at the SAME origin, re-parent the bone
43
- * under it, and move the keys to the control. The pose is identical.
56
+ * answer, ported as is and still the default (`rig --idle-keys ctl`): for
57
+ * every keyed bone that some mesh names among its candidates, add a parent
58
+ * `<bone>_ctl` at the SAME origin, re-parent the bone under it, and move the
59
+ * keys to the control. The pose is the same up to float rounding: the
60
+ * extra bone in each chain moves the rendered idle frames by at most one
61
+ * level in a channel (measured on both public examples, spine-parts #13).
44
62
  *
45
63
  * ⚠️ **This satisfies the rule's wording only.** The meshes are still
46
64
  * deformed every frame — by the control's motion, through the bone they are
47
65
  * weighted to — so whatever cost `A15` exists to keep off the player is paid
48
- * all the same. How a weighted-mesh painting rig should meet that rule is an
49
- * open question about the rule, not something this function settles.
66
+ * all the same. Measured (spine-parts #13): on both public examples every
67
+ * shown mesh has a driving bone whose world transform changes on every idle
68
+ * frame, with the controls and without them, so a dirty-skip renderer can
69
+ * skip none either way. spine-rigc 1.3.0 answered the rule's side
70
+ * (`invariants.idleDrivesMeshes`); `rig --idle-keys direct` keys the bones in
71
+ * place and declares it (`src/rig.ts`, `IDLE_KEYS`).
50
72
  *
51
73
  * One correction to the reference: it moved single-bone tracks to the control
52
74
  * but left the blink GROUPS naming the original bone, so a mesh-bound eye or
@@ -58,13 +80,20 @@ import { pyRound } from './round.ts';
58
80
 
59
81
  export const KEYS_PER_PERIOD = 8;
60
82
 
61
- /** The blink's fixed timing, in seconds after `blink.t`. */
83
+ /**
84
+ * The rate the idle is rendered at — `check`'s `idle_frames/`, its contact
85
+ * sheet, and the loop `build --loop` encodes from them. The reference renders
86
+ * at it too.
87
+ */
88
+ export const IDLE_FPS = 12;
89
+
90
+ /** The blink's fixed timing, in seconds after `blink.t`. The holds are 1/{@link IDLE_FPS} s rounded up (see the Blink section above). */
62
91
  export const BLINK = {
63
92
  shut: 0.07,
64
- hold: 0.04,
93
+ hold: 0.084,
65
94
  open: 0.16,
66
95
  browShut: 0.08,
67
- browHold: 0.04,
96
+ browHold: 0.084,
68
97
  browOpen: 0.2,
69
98
  } as const;
70
99
 
@@ -141,7 +170,9 @@ export function idleMotion(cfg: CharacterConfig, chains: ReadonlyMap<string, str
141
170
  if (bl !== undefined) {
142
171
  const tb = bl.t;
143
172
  groups.eyes = [...bl.eyes];
144
- groups.brows = [...bl.brows];
173
+ // A blink without brows (no eyebrow part) writes no brows group: rigc
174
+ // refuses a group with no members, and the loader refuses an empty one.
175
+ if (bl.brows !== undefined) groups.brows = [...bl.brows];
145
176
  const at = (dt: number): number => pyRound(tb + dt, 6);
146
177
  tracks.push({
147
178
  group: 'eyes',
@@ -155,18 +186,21 @@ export function idleMotion(cfg: CharacterConfig, chains: ReadonlyMap<string, str
155
186
  { t: T, v: [1] },
156
187
  ],
157
188
  });
158
- tracks.push({
159
- group: 'brows',
160
- property: 'translatey',
161
- keys: [
162
- { t: 0, v: [0] },
163
- { t: tb, v: [0], ease: 'shut' },
164
- { t: at(BLINK.browShut), v: [-bl.brow_drop] },
165
- { t: at(BLINK.browShut + BLINK.browHold), v: [-bl.brow_drop], ease: 'open' },
166
- { t: at(BLINK.browShut + BLINK.browHold + BLINK.browOpen), v: [0] },
167
- { t: T, v: [0] },
168
- ],
169
- });
189
+ const drop = bl.brow_drop;
190
+ if (bl.brows !== undefined && drop !== undefined) {
191
+ tracks.push({
192
+ group: 'brows',
193
+ property: 'translatey',
194
+ keys: [
195
+ { t: 0, v: [0] },
196
+ { t: tb, v: [0], ease: 'shut' },
197
+ { t: at(BLINK.browShut), v: [-drop] },
198
+ { t: at(BLINK.browShut + BLINK.browHold), v: [-drop], ease: 'open' },
199
+ { t: at(BLINK.browShut + BLINK.browHold + BLINK.browOpen), v: [0] },
200
+ { t: T, v: [0] },
201
+ ],
202
+ });
203
+ }
170
204
  }
171
205
  const name = `${cfg.key}_painting`;
172
206
  return {
@@ -179,9 +213,72 @@ export function idleMotion(cfg: CharacterConfig, chains: ReadonlyMap<string, str
179
213
  };
180
214
  }
181
215
 
182
- /** The last time any blink key sits at before the closing key, in seconds after `blink.t`. */
216
+ /**
217
+ * The idle frames `0 .. count - 1` at `fps` whose time lies in the closed
218
+ * window `[from, to]` — the frames that show a held key. The same reading
219
+ * `check` writes as `BLINK_NO_HOLE.idle_frames_closed`.
220
+ */
221
+ export function framesInside(from: number, to: number, fps: number, count: number): number[] {
222
+ const out: number[] = [];
223
+ for (let k = 0; k < count; k++) if (k / fps >= from - 1e-9 && k / fps <= to + 1e-9) out.push(k);
224
+ return out;
225
+ }
226
+
227
+ export interface HoldMisses {
228
+ /** The phases tried: every microsecond key time over one period of the frame grid against the 6-decimal grid. */
229
+ phases: number;
230
+ /** Of those, the ones whose closed window holds no frame. */
231
+ misses: number;
232
+ /** The first missing `blink.t`, in seconds, or null. */
233
+ first: number | null;
234
+ }
235
+
236
+ /**
237
+ * Over every `blink.t` the idle can write, how many put no frame at `fps`
238
+ * inside the eyes' closed window `[t + shut, t + shut + hold]`, both ends
239
+ * rounded to 6 decimals as {@link idleMotion} writes them.
240
+ *
241
+ * Key times are 6-decimal numbers, and a frame falls at `k * 10^6 / fps`
242
+ * microseconds, so the pattern of frames against the microsecond grid
243
+ * repeats every `10^6 / gcd(fps, 10^6)` microseconds (250,000 at 12 fps).
244
+ * Trying every `t` over one such period is therefore every case, not a
245
+ * sample. A 1/120 s step was the brief's first choice and was rejected: it
246
+ * tries 30 of those 250,000 phases.
247
+ */
248
+ export function blinkHoldMisses(shut: number, hold: number, fps: number): Readonly<HoldMisses> {
249
+ const key = `${shut} ${hold} ${fps}`;
250
+ const known = HOLD_MISSES.get(key);
251
+ if (known !== undefined) return known;
252
+ const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
253
+ const period = 1e6 / gcd(fps, 1e6);
254
+ let misses = 0;
255
+ let first: number | null = null;
256
+ for (let j = 0; j < period; j++) {
257
+ const t = j / 1e6;
258
+ const from = pyRound(t + shut, 6);
259
+ const to = pyRound(t + shut + hold, 6);
260
+ const k = Math.ceil((from - 1e-9) * fps);
261
+ if (!(k / fps >= from - 1e-9 && k / fps <= to + 1e-9)) {
262
+ misses++;
263
+ if (first === null) first = pyRound(t, 6);
264
+ }
265
+ }
266
+ const out = { phases: period, misses, first };
267
+ HOLD_MISSES.set(key, out);
268
+ return out;
269
+ }
270
+
271
+ /** {@link blinkHoldMisses} is a pure function of its three numbers and costs a quarter of a million roundings; the rig stage asks it the same question on every build. */
272
+ const HOLD_MISSES = new Map<string, Readonly<HoldMisses>>();
273
+
274
+ /** The last time any blink key sits at before the closing key, in seconds after `blink.t`, for a blink with brows. */
183
275
  export const BLINK_SPAN = Math.max(BLINK.shut + BLINK.hold + BLINK.open, BLINK.browShut + BLINK.browHold + BLINK.browOpen);
184
276
 
277
+ /** {@link BLINK_SPAN} for this blink: without brows, only the eyes' keys have to fit inside the idle. */
278
+ export function blinkSpan(bl: { brows?: readonly string[] }): number {
279
+ return bl.brows === undefined ? BLINK.shut + BLINK.hold + BLINK.open : BLINK_SPAN;
280
+ }
281
+
185
282
  /**
186
283
  * The bones that get a `<bone>_ctl`: keyed by a single track or named by a
187
284
  * blink group, AND named by some mesh's candidates. Sorted, as the reference
package/src/propose.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * makes it the skirt is that it came from `bottomwear`. The rules, by tag:
9
9
  *
10
10
  * - `face` (head run first) -> `hip`/`chest`/`neck`/`head`; `eyewhite-r/-l`
11
- * -> `eye_r`/`eye_l`; `mouth` -> `mouth` at the centroid of its biggest blob
11
+ * -> `eye_r`/`eye_l`, the blink's `eyes` group — no eyewhite, no blink, and
12
+ * no eyebrow, no `brows` in it, each said in a note (issue #35); `mouth` -> `mouth` at the centroid of its biggest blob
12
13
  * (stray pixels inflate a box); `eyebrow-*` -> `brow_r`/`brow_l` by POSITION
13
14
  * left or right of the eye axis, never by tag, because the head run has been
14
15
  * observed to swap the two brow tags.
@@ -93,6 +94,21 @@ export interface ProposedChainTrack {
93
94
  lag: number;
94
95
  }
95
96
 
97
+ /** `brows` and `brow_drop` are absent together when no eyebrow part exists: rigc refuses a group with no members. */
98
+ export interface ProposedBlink {
99
+ t: number;
100
+ eyes: string[];
101
+ brows?: string[];
102
+ squash: number;
103
+ brow_drop?: number;
104
+ still?: Record<string, { row: number; bone: string }>;
105
+ }
106
+
107
+ /** The tags whose parts make the eye bones, the blink's `eyes` group. Nothing else does: irides and lashes ride those bones as regions. */
108
+ export const EYE_GROUP_TAGS: readonly string[] = ['eyewhite-r', 'eyewhite-l'];
109
+ /** The tags whose parts make the brow bones, the blink's `brows` group. */
110
+ export const BROW_GROUP_TAGS: readonly string[] = ['eyebrow-r', 'eyebrow-l'];
111
+
96
112
  export interface Proposal {
97
113
  bones: BoneEntry[];
98
114
  meshes: Record<string, MeshSpec>;
@@ -100,7 +116,8 @@ export interface Proposal {
100
116
  motion: {
101
117
  duration: number;
102
118
  tracks: Array<ProposedSingleTrack | ProposedChainTrack>;
103
- blink: { t: number; eyes: string[]; brows: string[]; squash: number; brow_drop: number; still?: Record<string, { row: number; bone: string }> };
119
+ /** Absent when no part feeds the `eyes` group ({@link EYE_GROUP_TAGS}): a blink with nothing to blink is not written. */
120
+ blink?: ProposedBlink;
104
121
  };
105
122
  notes: string[];
106
123
  }
@@ -671,15 +688,22 @@ export function propose(P: PartSet): Proposal {
671
688
  if (browOf.has(p.name)) role = browOf.get(p.name);
672
689
  if (role !== undefined) regions[p.name] = role;
673
690
  }
674
- const blink: Proposal['motion']['blink'] = {
675
- t: 2.3,
676
- eyes: (['r', 'l'] as const).filter((s) => eyes.has(s)).map((s) => `eye_${s}`),
677
- brows: (['r', 'l'] as const).filter((s) => bySide.has(s)).map((s) => `brow_${s}`),
678
- squash: 0.12,
679
- brow_drop: 1.2,
680
- };
681
- const still = lashStills(P, ew, notes);
682
- if (Object.keys(still).length > 0) blink.still = still;
691
+ // A blink group rigc is handed must name a member (rigc refuses `group
692
+ // "eyes" declares no members`), so a group with none is not written, and a
693
+ // blink with no eyes is not written at all: a value with nothing to act on
694
+ // would be invented. Each omission is a note naming the tags looked for.
695
+ const eyeBonesProposed = (['r', 'l'] as const).filter((s) => eyes.has(s)).map((s) => `eye_${s}`);
696
+ const browBonesProposed = (['r', 'l'] as const).filter((s) => bySide.has(s)).map((s) => `brow_${s}`);
697
+ let blink: ProposedBlink | undefined;
698
+ if (eyeBonesProposed.length === 0) {
699
+ const brows = browBonesProposed.length === 0 ? '' : `; ${browBonesProposed.join(', ')} ${browBonesProposed.length === 1 ? 'is' : 'are'} placed and nothing drops ${browBonesProposed.length === 1 ? 'it' : 'them'}`;
700
+ notes.push(`no blink: no eyewhite part (looked for: ${EYE_GROUP_TAGS.join(', ')}), so the blink's eyes group would name no bone${brows}`);
701
+ } else {
702
+ blink = browBonesProposed.length === 0 ? { t: 2.3, eyes: eyeBonesProposed, squash: 0.12 } : { t: 2.3, eyes: eyeBonesProposed, brows: browBonesProposed, squash: 0.12, brow_drop: 1.2 };
703
+ const still = lashStills(P, ew, notes);
704
+ if (Object.keys(still).length > 0) blink.still = still;
705
+ if (browBonesProposed.length === 0) notes.push(`blink without brows: no eyebrow part (looked for: ${BROW_GROUP_TAGS.join(', ')}), so the blink has no brows group and no brow_drop`);
706
+ }
683
707
  tracks.push(
684
708
  { bone: 'chest', prop: 'translatey', amp: 1.3, period: 4.0, phase: 0.0, base: 1.3 },
685
709
  { bone: 'chest', prop: 'scalex', amp: 0.004, period: 4.0, phase: 0.0, base: 1.004 },
@@ -1069,7 +1093,7 @@ export function propose(P: PartSet): Proposal {
1069
1093
  } else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
1070
1094
  }
1071
1095
  }
1072
- return { bones, meshes, regions, motion: { duration: 4.0, tracks, blink }, notes };
1096
+ return { bones, meshes, regions, motion: blink === undefined ? { duration: 4.0, tracks } : { duration: 4.0, tracks, blink }, notes };
1073
1097
  }
1074
1098
 
1075
1099
  // ---------------------------------------------------------------------------
package/src/rig.ts CHANGED
@@ -28,7 +28,15 @@
28
28
  * changes no pixel outside the blink (issue #26, `RG16`). A `painting:`
29
29
  * patch (`assemble.patches`) is always a region; its slot sits where
30
30
  * parts.json puts it, which is where its `draw` put it.
31
- * - **The idle** and its control bones (`src/motion.ts`).
31
+ * - **The idle** and, under `idleKeys: 'ctl'` (the default), its control
32
+ * bones (`src/motion.ts`). Under `idleKeys: 'direct'` the keys stay on the
33
+ * bones the meshes are weighted to, no `<bone>_ctl` is added, and the rig
34
+ * spec declares `invariants.idleDrivesMeshes` with
35
+ * {@link IDLE_DRIVES_MESHES_WHY} — spine-rigc 1.3.0's statement that this
36
+ * idle deforms meshes on purpose, which `A15_IDLE_NO_MESH_BONE_KEYS` then
37
+ * reports as a SKIP with its cost instead of refusing each bone. The
38
+ * declaration is written only when the idle keys at least one mesh-driving
39
+ * bone: rigc refuses a declaration that switches nothing off.
32
40
  *
33
41
  * Every part image is padded by {@link PAD} transparent pixels on each side
34
42
  * before it is meshed or placed, and the padded image is what `images/`
@@ -47,7 +55,7 @@ import { type BoneEntry, type CharacterConfig, type Point, ROOT_BONE } from './c
47
55
  import { cropToSpineY } from './coords.ts';
48
56
  import { type Problem, refuseIfAny } from './errors.ts';
49
57
  import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
50
- import { BLINK_SPAN, CONTROL_SUFFIX, controlledBones, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
58
+ import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
51
59
  import { PAINTING_RUN, type PartsFile, readFrom } from './parts.ts';
52
60
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
53
61
  import { pyRound } from './round.ts';
@@ -102,8 +110,40 @@ export interface RigSpec {
102
110
  bones: RigBone[];
103
111
  slots: Array<{ name: string; bone: string; attachment: string }>;
104
112
  skins: { default: Record<string, Record<string, MeshAttachment | RegionAttachment>> };
113
+ /** Written only under `idleKeys: 'direct'`, and only when the idle keys a mesh-driving bone. */
114
+ invariants?: { idleDrivesMeshes: { why: string } };
105
115
  }
106
116
 
117
+ /**
118
+ * Where the idle's keys on a mesh-driving bone go (`rig --idle-keys`).
119
+ *
120
+ * - `ctl`: onto a same-origin `<bone>_ctl` parent, which passes
121
+ * `A15_IDLE_NO_MESH_BONE_KEYS` under every spine-rigc this package has run
122
+ * on. It satisfies the rule's wording only — see `src/motion.ts`.
123
+ * - `direct`: onto the bone itself, with `invariants.idleDrivesMeshes`
124
+ * declared, which needs spine-rigc 1.3.0 or later (an older rigc refuses
125
+ * the unknown invariant by name).
126
+ *
127
+ * Measured on the two public examples (spine-parts #13, `tools/idle_cost.ts`):
128
+ * `direct` removes 31 of 72 bones (demo) and 24 of 56 (sample); every shown
129
+ * mesh (8 and 6) has a driving bone whose world transform changes on every
130
+ * idle frame under both, so the meshes a dirty-skip renderer could skip are 0
131
+ * in both; and the per-frame pose time differs only in
132
+ * `updateWorldTransform` (1.2 against 0.7 us on the demo), about 3 % of a
133
+ * frame dominated by `computeWorldVertices`. The controls buy no renderer
134
+ * work. `ctl` stays the default because the examples' expected `rig.json` and
135
+ * `motion.json` are the reference implementation's output, and because the
136
+ * declaration lives in the rig spec only: `rigc validate <build> --profile
137
+ * spine-html`, which has no rig spec to read, refuses a `direct` build once
138
+ * per keyed mesh bone.
139
+ */
140
+ export const IDLE_KEYS = ['ctl', 'direct'] as const;
141
+ export type IdleKeys = (typeof IDLE_KEYS)[number];
142
+ export const DEFAULT_IDLE_KEYS: IdleKeys = 'ctl';
143
+
144
+ /** The `why` of the `invariants.idleDrivesMeshes` that `idleKeys: 'direct'` declares. */
145
+ export const IDLE_DRIVES_MESHES_WHY = 'painting rig: the idle is meant to deform the meshes it keys (spine-parts rig --idle-keys direct)';
146
+
107
147
  export interface MeshReport {
108
148
  part: string;
109
149
  vertices: number;
@@ -122,12 +162,38 @@ export interface RigOutput {
122
162
  meshReport: MeshReport[];
123
163
  /** The padded images, by file name (`<part>.png`), in parts.json order. */
124
164
  images: Array<[string, Raster]>;
125
- /** The bones that got a `<bone>_ctl`, sorted. */
165
+ /** The bones that got a `<bone>_ctl`, sorted; empty under `idleKeys: 'direct'`. */
126
166
  controls: string[];
167
+ /** The idle-keyed bones some mesh is weighted to, sorted — the bones `ctl` moves the keys off, and `direct` keys in place. */
168
+ meshKeyed: string[];
169
+ idleKeys: IdleKeys;
127
170
  /** The one-loop passes each mesh took, for the printed report. */
128
171
  loopPasses: Record<string, number>;
129
172
  }
130
173
 
174
+ /**
175
+ * `RIG_BLINK_HOLD_SPANS_A_FRAME` (issue #32): the eyes' hold must put an
176
+ * idle frame inside the closed window for every `blink.t`, or the idle
177
+ * frames, the contact sheet and the loop show a blink that never closes. The
178
+ * three numbers are the tree's own constants (`BLINK.shut`, `BLINK.hold`,
179
+ * `IDLE_FPS`), which the rig stage passes on every build that has a blink;
180
+ * they are parameters so the refusal can be planted without editing them.
181
+ */
182
+ export function blinkHoldProblems(shut: number, hold: number, fps: number): Problem[] {
183
+ const m = blinkHoldMisses(shut, hold, fps);
184
+ if (m.misses === 0) return [];
185
+ return [
186
+ {
187
+ code: 'RIG_BLINK_HOLD_SPANS_A_FRAME',
188
+ object: 'BLINK.hold (src/motion.ts)',
189
+ detail:
190
+ `is ${hold} s; the idle is rendered at IDLE_FPS = ${fps}, a frame every ${pyRound(1 / fps, 6)} s, so the closed window [t + ${shut}, t + ${pyRound(shut + hold, 6)}] s holds no frame ` +
191
+ `for ${m.misses} of the ${m.phases} phases a 6-decimal blink.t takes against the frame grid (the first at t = ${m.first} s), and the idle frames and the loop show no closed eye there; ` +
192
+ `a hold of at least one frame, 1/${fps} s, is required`,
193
+ },
194
+ ];
195
+ }
196
+
131
197
  /** The slot, attachment and image name suffix of a `motion.blink.still` part's upper piece. */
132
198
  export const STILL_SUFFIX = '_still';
133
199
 
@@ -150,7 +216,13 @@ function pt(p: Point): string {
150
216
  * Build the rig. `images` holds every parts.json part's PNG by part name.
151
217
  * Every problem found is thrown at once, as one `PartsError`.
152
218
  */
153
- export function buildRig(cfg: CharacterConfig, parts: PartsFile, images: ReadonlyMap<string, Raster>, maxLoopPasses: number = ONE_LOOP_PASSES): RigOutput {
219
+ export function buildRig(
220
+ cfg: CharacterConfig,
221
+ parts: PartsFile,
222
+ images: ReadonlyMap<string, Raster>,
223
+ maxLoopPasses: number = ONE_LOOP_PASSES,
224
+ idleKeys: IdleKeys = DEFAULT_IDLE_KEYS,
225
+ ): RigOutput {
154
226
  const problems: Problem[] = [];
155
227
  const fail = (code: string, object: string, detail: string): void => {
156
228
  problems.push({ code, object, detail });
@@ -261,13 +333,15 @@ export function buildRig(cfg: CharacterConfig, parts: PartsFile, images: Readonl
261
333
 
262
334
  // ---- motion ------------------------------------------------------------
263
335
  const bl = cfg.motion.blink;
264
- if (bl !== undefined && !(bl.t > 0 && bl.t + BLINK_SPAN < cfg.motion.duration)) {
336
+ const span = bl === undefined ? 0 : blinkSpan(bl);
337
+ if (bl !== undefined && !(bl.t > 0 && bl.t + span < cfg.motion.duration)) {
265
338
  fail(
266
339
  'RIG_BLINK_INSIDE_IDLE',
267
340
  'config.motion.blink.t',
268
- `is ${bl.t} s; the blink's keys run from t to t + ${pyRound(BLINK_SPAN, 6)} s between the idle's first key at 0 and its last at ${cfg.motion.duration} s, so 0 < t < ${pyRound(cfg.motion.duration - BLINK_SPAN, 6)} is required`,
341
+ `is ${bl.t} s; the blink's keys run from t to t + ${pyRound(span, 6)} s between the idle's first key at 0 and its last at ${cfg.motion.duration} s, so 0 < t < ${pyRound(cfg.motion.duration - span, 6)} is required`,
269
342
  );
270
343
  }
344
+ if (bl !== undefined) problems.push(...blinkHoldProblems(BLINK.shut, BLINK.hold, IDLE_FPS));
271
345
  // ---- blink.still: a blinking region cut at a row ----------------------
272
346
  const stills = bl?.still ?? {};
273
347
  const partNames = new Set(parts.parts.map((p) => p.name));
@@ -307,7 +381,8 @@ export function buildRig(cfg: CharacterConfig, parts: PartsFile, images: Readonl
307
381
  const motion = idleMotion(cfg, chains);
308
382
  const meshBones = new Set<string>();
309
383
  for (const segs of meshSegments.values()) for (const s of segs) meshBones.add(s.bone);
310
- const controls = controlledBones(motion, meshBones);
384
+ const meshKeyed = controlledBones(motion, meshBones);
385
+ const controls = idleKeys === 'ctl' ? meshKeyed : [];
311
386
  for (const k of controls) {
312
387
  const ctl = `${k}${CONTROL_SUFFIX}`;
313
388
  if (B.has(ctl)) {
@@ -440,7 +515,8 @@ export function buildRig(cfg: CharacterConfig, parts: PartsFile, images: Readonl
440
515
  slots,
441
516
  skins: { default: skin },
442
517
  };
443
- return { rig, motion, meshReport, images: outImages, controls, loopPasses };
518
+ if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
519
+ return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses };
444
520
  }
445
521
 
446
522
  /**