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 +6 -3
- package/cli.ts +26 -6
- package/docs/AUTHORING.md +64 -12
- package/package.json +2 -2
- package/src/build.ts +20 -4
- package/src/check.ts +4 -4
- package/src/config.ts +28 -4
- package/src/motion.ts +123 -26
- package/src/propose.ts +36 -12
- package/src/rig.ts +84 -8
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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.**
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
rigc's `render` takes no time, so the closed pose is a throwaway
|
|
583
|
-
the blink tracks' closed value, built and rendered beside the
|
|
584
|
-
|
|
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
|
+
"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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
188
|
+
brows?: string[];
|
|
182
189
|
squash: number;
|
|
183
|
-
brow_drop
|
|
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', '
|
|
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
|
-
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
49
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
93
|
+
hold: 0.084,
|
|
65
94
|
open: 0.16,
|
|
66
95
|
browShut: 0.08,
|
|
67
|
-
browHold: 0.
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
/**
|