spine-parts 0.8.0 → 0.8.2

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
@@ -86,7 +86,7 @@ page** (issue #2):
86
86
 
87
87
  | path under `--out` | what |
88
88
  | --- | --- |
89
- | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack --page-edges free --pack-shape polygon` and gated under `spine-html`, which holds every rule `spine` measures (on the demo, rigc 2.1.3: the 14 rules `validate --profile spine` measures are among the build's 24; 16 are not in `spine`) |
89
+ | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack --page-edges free --pack-shape polygon` and gated under `spine-html`, which holds every rule `spine` measures (on the demo, rigc 2.1.3 and 2.10.1: the 14 rules `validate --profile spine` measures are among the build's 24; 16 are not in `spine`) |
90
90
  | `parts/*.png`, `parts.json`, `recomposite_rig.png`, `recomposite_error_rig.png` | the loose parts, each cropped to its alpha box; the record of where every part came from, how many of its pixels were re-taken from the painting, and the recomposite's uncovered holes with their boxes; the flat stack of parts; its error map — red where no part covers a pixel the painting has, blue where a part covers it in the wrong colour |
91
91
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
92
92
  | `check/` | both gate files verbatim, the idle's frames and their `geometry.json` (skinned vertices per frame), `contact.png`, `motion_heat.png`, `check.json` (with `pack_mode`, the `--page-edges` and `--pack-shape` the page was packed under) |
package/cli.ts CHANGED
@@ -108,7 +108,8 @@ usage:
108
108
  (RIG_BLINK_HOLD_SPANS_A_FRAME otherwise); no blink when the config
109
109
  states no motion.blink, and a blink group that names no bone, or one
110
110
  bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
111
- CONFIG_BLINK_GROUP_UNIQUE, before rigc starts. --parts is the
111
+ CONFIG_BLINK_GROUP_UNIQUE, before rigc starts, as are two tracks on one
112
+ bone property, CONFIG_BONE_PROPERTY_KEYED_ONCE. --parts is the
112
113
  directory holding parts.json and parts/<name>.png. The result is built
113
114
  through spine-rigc (profile spine-html, packed with --page-edges and
114
115
  --pack-shape as for check; rigc's build gates the compile and the packed pages on disk) in a
package/docs/AUTHORING.md CHANGED
@@ -75,7 +75,7 @@ read by no CPU stage).
75
75
  | `meshes.<part>` | rig | **proposed**, then corrected. `grid` (lattice cell size, px), `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's |
76
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
- | `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`) |
78
+ | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley` |
79
79
  | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members` — as is a group that names one bone twice (`CONFIG_BLINK_GROUP_UNIQUE`; rigc: `group "eyes" names member "eye" twice`). An `irides-*` or `eyelash-*` part on a side with no eyewhite has no eye bone to ride: `propose` places it on `head` as a region, which the blink does not move, and notes `no eyewhite part for eye_r (looked for: eyewhite-r): iris (head:irides-r) is placed on head as a region, …`. The timing is fixed, not a field (table below) |
80
80
  | `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
81
81
 
@@ -282,9 +282,10 @@ max for the APNG); a figure is a measurement of the file, not a bar. On the demo
282
282
  13,645,503 B lossless, 1,706,719 B indexed and 1,812,288 B GIF, both palette files at
283
283
  max 57, mean 1.601 — the two share one quantiser, so their error is the same by
284
284
  construction and the size is the difference. That is `--seam silhouette` (the README's
285
- animation, spine-rigc 2.1.3); with `--pack-shape rect` the same build writes 13,645,350,
286
- 1,706,520 and 1,812,238 B, and with `--page-edges pot --pack-shape rect` 13,645,519,
287
- 1,706,785 and 1,812,625 B, all at the same error. The loop shows the blink closed: the
285
+ animation, spine-rigc 2.1.3, and 2.10.1 to the byte); with `--pack-shape rect` the same
286
+ build writes 13,645,350, 1,706,520 and 1,812,238 B, and with `--page-edges pot
287
+ --pack-shape rect` 13,645,519, 1,706,785 and 1,812,625 B, all at the same error (2.10.1
288
+ reproduces both). The loop shows the blink closed: the
288
289
  eyes' hold is at least one 12 fps frame (below), so whatever `motion.blink.t` is, one
289
290
  idle frame lands inside it, and `check.json`'s `BLINK_NO_HOLE.idle_frames_closed`
290
291
  names it — `[29]` on both examples.
@@ -303,9 +304,10 @@ the least-area page the parts need. `--page-edges pot` is the opt-out: a power o
303
304
  two on both edges, which is the page every build wrote before this flag. The pack
304
305
  line says which one ran, because rigc writes `, page edges free` after the padding under
305
306
  `free`. Measured on the two examples by `tools/atlas_population.ts` with rigc 1.5.1, and
306
- again with rigc 2.0.3 and with rigc 2.1.3 under `--pack-shape rect` (the frames table
307
+ again with rigc 2.0.3, 2.1.3 and 2.10.1 under `--pack-shape rect` (the frames table
307
308
  by the same idle render), which reproduced every figure in both tables and the one
308
- check figure below:
309
+ check figure below (2.10.1: pages, atlases, idle frames and `check.json` byte-identical
310
+ to 2.1.3's):
309
311
 
310
312
  | build | page | covered | page opaque | page / figure |
311
313
  | --- | --- | --- | --- | --- |
@@ -339,9 +341,11 @@ attachment stays its rectangle. rigc gates the result on the packed pages with
339
341
  `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` (SKIP on the compile pass, where every part has
340
342
  its own page; PASS on the packed pass, both shapes, both examples). The pack line ends
341
343
  `, shape rect` or `, shape polygon` under either, and `check.json`'s `pack_mode` records
342
- both flags. Measured on the two examples with spine-rigc 2.1.3 (the full entry,
343
- spine-core 4.3.13 beside it), `spine-parts build` with `--page-edges free` and each
344
- shape, the table columns by `tools/atlas_population.ts`:
344
+ both flags. Measured on the two examples with spine-rigc 2.1.3, and again with 2.10.1,
345
+ whose pages, atlases, idle frames and `check.json` are byte-identical to 2.1.3's under
346
+ both shapes and both edges (the full entry, spine-core 4.3.13 beside it),
347
+ `spine-parts build` with `--page-edges free` and each shape, the table columns by
348
+ `tools/atlas_population.ts`:
345
349
 
346
350
  | build | page | page area | covered | page opaque | page / figure |
347
351
  | --- | --- | --- | --- | --- | --- |
@@ -377,8 +381,11 @@ The hull ceiling (spine-parts #58: hull area over rectangle area, 0.812 on demo
377
381
  rectangles are worth only what fits into them. On the polygon pages, read off the
378
382
  atlas, demo's packed rectangles overlap in 21 pairs (17 a region attachment inside a
379
383
  mesh's rectangle, 4 a mesh inside another's) and sample's in 12 (10 and 2); no two
380
- region attachments overlap. More would take tighter hulls, meshes in place of
381
- regions, or a stronger nesting search in rigc, each its own card.
384
+ region attachments overlap. Meshes in place of regions would be rigc's stage 2 of
385
+ polygon packing (firejune/rigc#1093), which rigc measured and folded: on the three
386
+ production rigs most favourable to it, it adds 0.15 % of the rectangle area over
387
+ stage 1 at tolerance 0, and nothing at the mesher's tolerance 1 or under `pot`; on
388
+ these two examples −0.3 to −1.3 points. No build converts a region attachment.
382
389
 
383
390
  `--project core` (the default) is the reference's projection rule: a layer takes the
384
391
  painting's pixel only inside its top-most `alpha >= 250` area eroded by a 5x5 square,
@@ -574,6 +581,7 @@ See-through with another seed.
574
581
  | `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` |
575
582
  | `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 |
576
583
  | `CONFIG_BLINK_GROUP_UNIQUE` | `motion.blink.eyes` or `motion.blink.brows` names one bone more than once; the detail gives the bone and every index it sits at. rigc refuses it at the gate (`group "eyes" names member "eye" twice`) | name each bone once: the group keys every member it names |
584
+ | `CONFIG_BONE_PROPERTY_KEYED_ONCE` | two tracks key one bone property: two single tracks, a single track on a chain link's `rotate`, or a single track on a blink member's `scaley` (`eyes`) or `translatey` (`brows`); the detail names the property and every track by its config path. rigc refuses it at the gate (`animation "idle" has two tracks on eye.scaley; merge them into one track`) | merge them into one track, or key another property or bone |
577
585
  | `CONFIG_BLINK_BROWS_PAIRED` | `motion.blink.brows` without `brow_drop`, or `brow_drop` without `brows` | state both or neither |
578
586
  | `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 |
579
587
 
@@ -642,7 +650,7 @@ See-through with another seed.
642
650
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
643
651
  | `CHECK_INPUT_PRESENT` on `…/parts.json` | `--parts` named a directory without `parts.json` — most often `parts/` itself. The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json` | `--parts` — the directory above `parts/` |
644
652
  | `CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by) | re-run rig (`build` does both) |
645
- | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^2.1.3), or put a newer `rigc` first on `PATH` |
653
+ | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^2.10.1), or put a newer `rigc` first on `PATH` |
646
654
  | `CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
647
655
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
648
656
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
@@ -658,7 +666,7 @@ See-through with another seed.
658
666
  | `LOOP_ENCODE` | the loop encoder refused a frame (translucent pixel in a GIF, a size change) | the frames; for a translucent frame write the lossless or the indexed APNG, which keep alpha |
659
667
  | `CHECK_PACK_LINE_READS` | rigc printed a `pack:` line that is not `pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>`, then `, page edges free` or nothing, then `, shape rect`, `, shape polygon` or nothing (spine-rigc 2.1 prints the shape; 1.5–2.0 printed none, read as `rect`); an unknown shape is this refusal; the line is quoted | a rigc this package does not know the output of; report it, with `rigc --version` |
660
668
  | `CHECK_PACK_PAGE_EDGES` | the pack line disagrees with the `--page-edges` the build was run with — its `, page edges free` clause is there under `pot` or missing under `free`, or a `pot` page is not a power of two on both edges | a rigc problem; report it, with `rigc --version` |
661
- | `CHECK_PACK_SHAPE` | the pack line disagrees with the `--pack-shape` the build was run with — it ends `, shape rect` under `polygon` or `, shape polygon` under `rect`, or it has no shape clause (the 1.5–2.0 form, read as `rect`) under `polygon` | a rigc older than 2.1.0 on `PATH`: rigc 2.0.3 takes `--pack-shape` without a word, packs by rectangles and prints the form with no shape clause, so this refusal is what catches it — `bun install` (this package depends on spine-rigc ^2.1.3); otherwise a rigc problem, report it with `rigc --version` |
669
+ | `CHECK_PACK_SHAPE` | the pack line disagrees with the `--pack-shape` the build was run with — it ends `, shape rect` under `polygon` or `, shape polygon` under `rect`, or it has no shape clause (the 1.5–2.0 form, read as `rect`) under `polygon` | a rigc older than 2.1.0 on `PATH`: rigc 2.0.3 takes `--pack-shape` without a word, packs by rectangles and prints the form with no shape clause, so this refusal is what catches it — `bun install` (this package depends on spine-rigc ^2.10.1); otherwise a rigc problem, report it with `rigc --version` |
662
670
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
663
671
 
664
672
  ## 7. The bars `check` enforces, and what only an eye answers today
@@ -671,7 +679,7 @@ implementation's bars):
671
679
  pages on disk. There is no `spine` gate of its own: `spine-html` holds every rule
672
680
  `spine` measures (on the demo, rigc 1.5.1 and 2.0.3: `validate --profile spine`'s 14
673
681
  measured rules are all among the build's 23, the 15 others not in `spine`; rigc
674
- 2.1.3: the same 14 among the build's 24, which adds
682
+ 2.1.3 and 2.10.1: the same 14 among the build's 24, which adds
675
683
  `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` on the packed pass, 16 not in `spine`; `CH09`
676
684
  holds it on every fetched example);
677
685
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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": "^2.1.3"
60
+ "spine-rigc": "^2.10.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@esotericsoftware/spine-core": "4.3.13",
package/src/check.ts CHANGED
@@ -196,7 +196,7 @@ export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha >
196
196
  * `free` is the default here although rigc's own default is `pot`: the packed
197
197
  * page is this package's final artifact, and on the two public examples `free`
198
198
  * takes the page from 1024x2048 to 967x1338 (covered 56.3 % -> 91.2 %) and
199
- * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1, 2.0.3 and 2.1.3 (`rect`) alike. The cost was
199
+ * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1, 2.0.3, 2.1.3 and 2.10.1 (`rect`) alike. The cost was
200
200
  * measured on the same two builds. Each of the 49 idle frames differs from the
201
201
  * `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
202
202
  * px over all 49). On sample that moves STILL_REGIONS_DARK's reported
@@ -212,7 +212,7 @@ export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
212
212
  /**
213
213
  * What two packed rectangles may share: spine-rigc's `build --pack
214
214
  * --pack-shape`, handed to it verbatim (in spine-rigc's CLI since 2.1.0; this
215
- * package's range starts at 2.1.3). `rect` keeps every region's cell apart;
215
+ * package's range starts at 2.10.1). `rect` keeps every region's cell apart;
216
216
  * `polygon` packs a region that only meshes draw by its emitted hull, so a
217
217
  * neighbour may sit inside its rectangle where the hull is not, with the
218
218
  * padding kept between footprints — a region attachment stays its rectangle
@@ -222,7 +222,8 @@ export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
222
222
  * reason {@link DEFAULT_PAGE_EDGES} is `free`: the packed page is this
223
223
  * package's final artifact, rigc gates the footprints on the pages on disk
224
224
  * (`A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP`), and the cost is the class already
225
- * accepted for `free`. Measured with spine-rigc 2.1.3 (the full entry,
225
+ * accepted for `free`. Measured with spine-rigc 2.1.3, and again with 2.10.1,
226
+ * whose pages, atlases and idle frames are byte-identical to it (the full entry,
226
227
  * spine-core 4.3.13 beside it), `spine-parts build` on the two public examples
227
228
  * under `--page-edges free`: `polygon` takes demo's page from 967x1338 (91.2 %
228
229
  * covered) to 922x1348 (95.0 %), 3.9 % less area, and sample's from 479x1166
package/src/config.ts CHANGED
@@ -840,6 +840,13 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
840
840
  const m = c.object('config.motion', v, ['duration', 'tracks'], ['blink']);
841
841
  if (m === null) return;
842
842
  const p = 'config.motion';
843
+ // Every bone property the idle will key, and the tracks that key it, in the
844
+ // order idleMotion writes them: single and chain tracks, then the eyes
845
+ // group, then the brows group.
846
+ const claims = new Map<string, string[]>();
847
+ const claim = (target: string, by: string): void => {
848
+ claims.set(target, [...(claims.get(target) ?? []), by]);
849
+ };
843
850
  const duration = 'duration' in m && c.number(`${p}.duration`, m.duration, 'positive') ? (m.duration as number) : null;
844
851
  const periodFits = (at: string, period: Json): void => {
845
852
  if (!c.number(at, period, 'positive') || duration === null) return;
@@ -863,6 +870,8 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
863
870
  if ('lag' in t) c.number(`${at}.lag`, t.lag);
864
871
  const links = typeof t.chain === 'string' ? chains.get(t.chain) : undefined;
865
872
  if (links === undefined) c.fail('CONFIG_NAME_RESOLVES', `${at}.chain`, `is ${show(t.chain)}; a chain declared in config.bones is required`);
873
+ // A chain track keys rotate on every link (idleMotion), one link per point.
874
+ else for (let k = 0; k < links; k++) claim(`${String(t.chain)}${k}.rotate`, `${at} (chain "${String(t.chain)}", link ${k})`);
866
875
  if ('amps' in t && c.array(`${at}.amps`, t.amps, true)) {
867
876
  const amps = t.amps as Json[];
868
877
  amps.forEach((a, k) => c.number(`${at}.amps[${k}]`, a));
@@ -877,7 +886,7 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
877
886
  if (typeof t.bone !== 'string' || !bones.has(t.bone)) c.fail('CONFIG_NAME_RESOLVES', `${at}.bone`, `is ${show(t.bone)}; a declared bone is required`);
878
887
  if ('prop' in t && !(SINGLE_PROPS as readonly string[]).includes(t.prop as string)) {
879
888
  c.fail('CONFIG_FIELD_TYPE', `${at}.prop`, `is ${show(t.prop)}; one of ${SINGLE_PROPS.join(', ')} is required`);
880
- }
889
+ } else if ('prop' in t && typeof t.bone === 'string' && bones.has(t.bone)) claim(`${t.bone}.${String(t.prop)}`, at);
881
890
  if ('amp' in t) c.number(`${at}.amp`, t.amp);
882
891
  if ('period' in t) periodFits(`${at}.period`, t.period);
883
892
  if ('phase' in t) c.number(`${at}.phase`, t.phase);
@@ -886,7 +895,7 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
886
895
  }
887
896
  if ('blink' in m) {
888
897
  const b = c.object(`${p}.blink`, m.blink, ['t', 'eyes', 'squash'], ['brows', 'brow_drop', 'still']);
889
- if (b === null) return;
898
+ if (b === null) return refuseSharedTargets(c, claims);
890
899
  if ('t' in b) c.number(`${p}.blink.t`, b.t, 'non-negative');
891
900
  if ('squash' in b) c.number(`${p}.blink.squash`, b.squash, 'positive');
892
901
  if ('brow_drop' in b) c.number(`${p}.blink.brow_drop`, b.brow_drop);
@@ -908,6 +917,15 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
908
917
  if (typeof name !== 'string' || !bones.has(name)) c.fail('CONFIG_NAME_RESOLVES', `${p}.blink.${key}[${k}]`, `is ${show(name)}; a declared bone is required`);
909
918
  if (typeof name === 'string') at.set(name, [...(at.get(name) ?? []), k]);
910
919
  });
920
+ // The group track keys one property on every member: scaley for the
921
+ // eyes, translatey for the brows — and the brows track is written only
922
+ // when brow_drop is stated beside them (idleMotion). A member named
923
+ // twice is CONFIG_BLINK_GROUP_UNIQUE's, so each bone claims once, at
924
+ // its first index.
925
+ const prop = key === 'eyes' ? 'scaley' : 'translatey';
926
+ if (key === 'eyes' || 'brow_drop' in b) {
927
+ for (const [name, ks] of at) if (bones.has(name)) claim(`${name}.${prop}`, `${p}.blink.${key}[${ks[0]}] (the blink's ${key} group, which keys ${prop} on every member)`);
928
+ }
911
929
  // rigc refuses a member named twice (`group "eyes" names member "eye"
912
930
  // twice`) at the gate, one stage late — issue #45, the sibling of the
913
931
  // empty group above. One refusal per repeated name, in first-seen order.
@@ -924,4 +942,27 @@ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string,
924
942
  }
925
943
  if ('still' in b) checkBlinkStill(c, b.still, b.eyes, bones, regions);
926
944
  }
945
+ refuseSharedTargets(c, claims);
946
+ }
947
+
948
+ /**
949
+ * Two tracks keying one bone property — two single tracks, a single track
950
+ * beside a chain link's rotate or a blink group's member — are one refusal
951
+ * per property, naming every track that keys it (issue #49). rigc refuses the
952
+ * pair at the rig gate (`animation "idle" has two tracks on eye.scaley`), one
953
+ * stage after the input that was wrong; the loader knows every track it
954
+ * writes. The control bones the rig stage adds (`<bone>_ctl`) cannot make two
955
+ * distinct targets one: the rename is the same suffix on every key of a bone,
956
+ * and a declared bone already holding the control's name is the rig stage's
957
+ * refusal, `RIG_CONTROL_NAME_FREE`.
958
+ */
959
+ function refuseSharedTargets(c: Check, claims: ReadonlyMap<string, string[]>): void {
960
+ for (const [target, by] of claims) {
961
+ if (by.length < 2) continue;
962
+ c.fail(
963
+ 'CONFIG_BONE_PROPERTY_KEYED_ONCE',
964
+ `bone property "${target}"`,
965
+ `is keyed by ${by.length} tracks: ${by.join(' and ')}; one track per bone property is required — the idle would hold ${by.length} timelines on ${target}, and rigc refuses two tracks on one bone property. Merge them into one track, or key another property or bone`,
966
+ );
967
+ }
927
968
  }
package/src/raster/png.ts CHANGED
@@ -12,11 +12,11 @@
12
12
  * refuses by name rather than misreading. So adding `pngjs` would have been a
13
13
  * second codec for the same bytes, and two codecs are two answers.
14
14
  *
15
- * 🔌 **The deep path is the interface, for now.** spine-rigc's package.json has
16
- * no `exports` map, so `spine-rigc/tools/plate.ts` is how a consumer reaches the
17
- * codec and it is what the installed package resolves. If rigc adds an
18
- * `exports` map that omits these paths, this import is what breaks, by name,
19
- * and `bun run smoke` is where that is seen first.
15
+ * 🔌 **The deep path is the interface.** spine-rigc's `exports` map carries
16
+ * `./*.ts`, so `spine-rigc/tools/plate.ts` is how a consumer reaches the codec
17
+ * and it is what the installed package resolves. If a rigc release narrows the
18
+ * map to omit these paths, this import is what breaks, by name, and
19
+ * `bun run smoke` is where that is seen first.
20
20
  */
21
21
  import { assertPng } from 'spine-rigc/src/png.ts';
22
22
  import { decodePng, encodePng } from 'spine-rigc/tools/plate.ts';