spine-rigc 1.6.2 → 1.7.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/cli.ts CHANGED
@@ -93,7 +93,7 @@ import {
93
93
  } from './src/diff.ts';
94
94
  import { ingest, IngestError, IngestSpecRefused, INGEST_GUTTERS, type IngestFinding, type IngestStage } from './src/ingest.ts';
95
95
  import { NotAPngError } from './src/png.ts';
96
- import { copyAtlasPages } from './src/emit.ts';
96
+ import { copyAtlasPages, plannedPageCopies } from './src/emit.ts';
97
97
  import {
98
98
  DEFAULT_PADDING,
99
99
  DEFAULT_PAGE_EDGES,
@@ -141,10 +141,9 @@ import {
141
141
  GEOMETRY_FILE,
142
142
  GeometryError,
143
143
  UnframeablePoseError,
144
- skinRosterOf,
145
144
  geometryFileOf,
146
145
  geometryText,
147
- loadPosable,
146
+ loadCandidate,
148
147
  PROTOCOL_FPS,
149
148
  renderFrame,
150
149
  sampleAll,
@@ -154,17 +153,16 @@ import {
154
153
  SHEET_TILE,
155
154
  sidecarViewport,
156
155
  SlotSubsetError,
157
- slotSubsetOf,
158
- candidatePosers,
159
156
  POSER_NAMES,
160
157
  PoserChoiceError,
158
+ refuseUnchosen,
159
+ SpineRuntimeError,
161
160
  throughPoser,
162
161
  type Frame,
163
162
  type FramesSidecar,
164
163
  type FrameSet,
165
- type Posable,
166
- type PoserChoice,
167
164
  type PoserName,
165
+ type SkeletonFacts,
168
166
  type SlotSubset,
169
167
  } from './src/render.ts';
170
168
  import {
@@ -456,10 +454,15 @@ function runGate(
456
454
  modelText: string,
457
455
  opts: CompileOptions,
458
456
  profile: ValidateProfile,
457
+ /** The atlas text `build` writes for a compile's own — `--copy-images` renames the pages — which the second compile's document is spelled from, as `modelText` was (issue #1016). */
458
+ written: (atlasText: string) => string,
459
459
  atlas?: AtlasOverride,
460
460
  ): number {
461
461
  // The determinism check compares a second, independent compile — its model
462
- // document included, which is the text `build` writes beside the pair.
462
+ // document included, which is the text `build` writes beside the pair, and
463
+ // which states where each region sits in the atlas written with it (`pages`,
464
+ // issue #1016): so the second document is spelled from the second compile's
465
+ // atlas as it would be written, or from the second, independent pack.
463
466
  const again = compile(opts);
464
467
  const report = validate({
465
468
  skeletonText: result.skeletonText,
@@ -467,7 +470,7 @@ function runGate(
467
470
  atlasDir: opts.outDir,
468
471
  declaredDurations: result.declaredDurations,
469
472
  modelText,
470
- reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText) },
473
+ reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
471
474
  rig: result.rig,
472
475
  profile,
473
476
  });
@@ -864,7 +867,7 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
864
867
  // Through rigc's own core over the model document the build carries (issue #969), spine-core when the core
865
868
  // refuses it; the survey's record says which (`source`), and `tools/survey_hashes.ts` holds the two to one block.
866
869
  // The block itself is `deformReportBlock` (`src/deformreport.ts`), so a control renders it off either poser's survey.
867
- const survey = surveyOfBuild({ skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText) }, new Set(), 'auto');
870
+ const survey = surveyOfBuild({ skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText, result.atlasText) }, new Set(), 'auto');
868
871
  return deformReportBlock(survey, result.deformTransforms, exempt);
869
872
  }
870
873
 
@@ -1113,10 +1116,17 @@ function cmdBuild(flags: Record<string, string>): void {
1113
1116
 
1114
1117
  // The model's document is spelled before the gate, so the text A18 compares
1115
1118
  // is the text written, and a model the document cannot carry is refused
1116
- // before anything is (issue #922).
1117
- const modelText = modelDocument(result.model, result.skeletonText);
1119
+ // before anything is (issue #922). It states where each region sits in the
1120
+ // atlas written beside it (`pages`, issue #1016), so it is spelled from that
1121
+ // atlas: under `--copy-images` the text with the copies' page names, planned
1122
+ // here from the text alone (`plannedPageCopies`) and copied after the gate.
1123
+ // Under `--pack` the pages move again after this gate, and the document
1124
+ // written is the one the packed gate below spells and compares.
1125
+ const copying = flags['copy-images'] !== undefined;
1126
+ const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
1127
+ let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
1118
1128
  console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
1119
- const failures = runGate(result, modelText, opts, profile);
1129
+ const failures = runGate(result, modelText, opts, profile, writtenAtlas);
1120
1130
  if (failures > 0) {
1121
1131
  console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
1122
1132
  process.exit(1);
@@ -1139,6 +1149,10 @@ function cmdBuild(flags: Record<string, string>): void {
1139
1149
  let atlasText = result.atlasText;
1140
1150
  if (flags['copy-images'] !== undefined) {
1141
1151
  const copied = copyAtlasPages(atlasText, opts.outDir);
1152
+ // The document's pages were spelled from the plan; the copy is held to it before anything of the pair is written.
1153
+ if (copied.atlasText !== writtenAtlas(result.atlasText)) {
1154
+ throw new Error(`internal: --copy-images wrote an atlas whose page names are not the ones ${MODEL_DOCUMENT_FILE} was spelled with — nothing of the pair was written`);
1155
+ }
1142
1156
  atlasText = copied.atlasText;
1143
1157
  console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
1144
1158
  for (const p of copied.pages) {
@@ -1197,7 +1211,10 @@ function cmdBuild(flags: Record<string, string>): void {
1197
1211
  })),
1198
1212
  packOpts,
1199
1213
  );
1200
- const packFailures = runGate(result, modelText, opts, profile, { text: atlasText, again: packAgain.atlasText });
1214
+ // The document written is spelled from the packed atlas, and this gate's A18 compares it with a
1215
+ // second compile's spelled from the second, independent pack (issue #1016).
1216
+ modelText = modelDocument(result.model, result.skeletonText, atlasText);
1217
+ const packFailures = runGate(result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
1201
1218
  if (packFailures > 0) {
1202
1219
  console.error(
1203
1220
  `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
@@ -1209,8 +1226,9 @@ function cmdBuild(flags: Record<string, string>): void {
1209
1226
 
1210
1227
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
1211
1228
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
1212
- // rigc's own record of the compiled rig (`rigc-compiled/1`, issue #922),
1213
- // written with the pair and only after the same gate. rigc's own posing core
1229
+ // rigc's own record of the compiled rig (`rigc-compiled/2`, issue #922;
1230
+ // its `pages` the atlas just written, issue #1016), written with the pair
1231
+ // and only after the same gate — under `--pack`, the packed one. rigc's own posing core
1214
1232
  // reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
1215
1233
  writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
1216
1234
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
@@ -1603,13 +1621,13 @@ function readSkinFlag(flags: Record<string, string>, declared: string[]): string
1603
1621
  */
1604
1622
  function readSlotSubsetFlags(
1605
1623
  flags: Record<string, string>,
1606
- data: Posable['data'],
1624
+ facts: SkeletonFacts,
1607
1625
  skin: string | undefined,
1608
1626
  ): SlotSubset | undefined {
1609
1627
  const list = (raw: string | undefined): string[] | undefined =>
1610
1628
  raw === undefined ? undefined : raw.split(',').map((name) => name.trim()).filter((name) => name !== '');
1611
1629
  try {
1612
- return slotSubsetOf(data, { slots: list(flags.slot), hidden: list(flags.hide) }, skin);
1630
+ return facts.subset({ slots: list(flags.slot), hidden: list(flags.hide) }, skin);
1613
1631
  } catch (err) {
1614
1632
  if (err instanceof SlotSubsetError) throw new UsageError(err.message);
1615
1633
  throw err;
@@ -1617,16 +1635,12 @@ function readSlotSubsetFlags(
1617
1635
  }
1618
1636
 
1619
1637
  /**
1620
- * `--poser core|spine`, resolved against the candidate (issue #968): both
1621
- * posers for it, and which one this run asked for. `core` on an input that
1622
- * cannot carry it is refused as a usage error by name; absent, the input's own
1623
- * choice stands — see `candidatePosers`.
1638
+ * `--poser` as spelled, checked against the posers there are — shared by
1639
+ * `render` and `check`. On `render` it is read where its refusal has always
1640
+ * stood, after the candidate's own flags; the candidate is loaded before that
1641
+ * with the spelling as given (`posersAsked`), so a rigc build the core poses
1642
+ * is chosen without loading spine-core (issue #1014).
1624
1643
  */
1625
- function readPoserChoice(flags: Record<string, string>, data: Posable['data'], skeletonPath: string, atlasPath: string): PoserChoice {
1626
- return candidatePosers(data, skeletonPath, atlasPath, readPoserFlag(flags));
1627
- }
1628
-
1629
- /** `--poser` as spelled, checked against the posers there are — shared by `render` and `check`. */
1630
1644
  function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
1631
1645
  const raw = flags.poser;
1632
1646
  const forced = POSER_NAMES.find((name) => name === raw);
@@ -1636,6 +1650,11 @@ function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
1636
1650
  return forced;
1637
1651
  }
1638
1652
 
1653
+ /** `--poser` as the candidate is loaded with: a known spelling, or the input's own choice — the spelling is refused later, by `readPoserFlag`. */
1654
+ function posersAsked(flags: Record<string, string>): PoserName | undefined {
1655
+ return POSER_NAMES.find((name) => name === flags.poser);
1656
+ }
1657
+
1639
1658
  /** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
1640
1659
  function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
1641
1660
  if (subset === undefined) return {};
@@ -1653,8 +1672,10 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
1653
1672
  /**
1654
1673
  * Does this skeleton declare a setup stage — a numeric width AND height?
1655
1674
  *
1656
- * Read off the loaded `SkeletonData` for `render` and off the file's header for
1657
- * `preview`, which never loads one; both are the same two fields, because
1675
+ * Read off the file's header for `preview`, which never loads one; `render`
1676
+ * reads the same statement off its candidate (`SkeletonFacts.declaresStage`:
1677
+ * the header again on a rigc build the core poses, the loaded `SkeletonData`
1678
+ * on an export). Both are the same two fields, because
1658
1679
  * `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
1659
1680
  * so a header that omits them leaves `undefined` on a field typed `number`.
1660
1681
  */
@@ -1725,10 +1746,18 @@ function cmdRender(flags: Record<string, string>): void {
1725
1746
  console.log('rigc render');
1726
1747
  console.log(` .. skeleton ${skeletonPath}`);
1727
1748
  console.log(` .. atlas ${atlasPath}`);
1728
- const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
1729
- const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
1730
- const skin = readSkinFlag(flags, data.skins.map((s) => s.name));
1731
- const subset = readSlotSubsetFlags(flags, data, skin);
1749
+ // Which implementation of the posing seam draws this (issue #968), chosen
1750
+ // before anything is read off the candidate (issue #1014): a rigc build the
1751
+ // core poses — `skeleton.model.json` beside the pair — reads its names, its
1752
+ // subset roster and its pages without loading spine-core at all.
1753
+ const { choice, facts, pages } = loadCandidate(
1754
+ { skeletonText: readFileSync(skeletonPath, 'utf8'), atlasText: readFileSync(atlasPath, 'utf8'), atlasDir, label: skeletonPath },
1755
+ { skeleton: skeletonPath, atlas: atlasPath },
1756
+ posersAsked(flags),
1757
+ );
1758
+ const only = readAnimationFlag(flags, [...facts.animations]);
1759
+ const skin = readSkinFlag(flags, [...facts.skins]);
1760
+ const subset = readSlotSubsetFlags(flags, facts, skin);
1732
1761
  // One object, so the framing and the frames cannot be posed under two
1733
1762
  // different skins — which would frame one shot with another shot's box.
1734
1763
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
@@ -1742,23 +1771,25 @@ function cmdRender(flags: Record<string, string>): void {
1742
1771
  if (skin !== undefined) console.log(` .. skin ${skin}`);
1743
1772
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
1744
1773
 
1745
- // Which implementation of the posing seam draws this (issue #968): rigc's own
1746
- // core when the candidate is a rigc build — `skeleton.model.json` beside the
1747
- // pair — and spine-core otherwise, or where the core refuses the input by
1748
- // name. Never silently: the `poser` line below says which, and why.
1749
- const choice = readPoserChoice(flags, data, skeletonPath, atlasPath);
1774
+ // Rigc's own core when the candidate is a rigc build, and spine-core
1775
+ // otherwise, or where the core refuses the input by name. Never silently:
1776
+ // the `poser` line below says which, and why. `--poser` is refused here, where
1777
+ // it always was: a spelling there is no poser for, then `core` on an input
1778
+ // that cannot carry it.
1779
+ readPoserFlag(flags);
1780
+ refuseUnchosen(choice);
1750
1781
 
1751
1782
  // Everything is posed before anything is written, so a core refusal partway
1752
1783
  // re-poses the whole input on spine-core rather than leaving half of a frame
1753
1784
  // set drawn by each.
1754
- const posed = throughPoser(choice, (poser) => {
1785
+ const posed = throughPoser(choice, (poser, roster) => {
1755
1786
  // `null` is a skeleton that posed no vertex at all. One that posed a vertex
1756
1787
  // it cannot frame — Infinity or NaN — is thrown from the framing as a
1757
1788
  // `GeometryError` naming the number (issue #873), and one whose every vertex
1758
1789
  // sits at one point — every drawn bone unposed by the skin, or collapsed — as
1759
1790
  // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
1760
1791
  // lets the second name the skins that pose a bone, whichever poser draws.
1761
- const viewport = framingViewport(poser, maxSide, pose, skinRosterOf(data));
1792
+ const viewport = framingViewport(poser, maxSide, pose, roster);
1762
1793
  if (!viewport) {
1763
1794
  throw new UsageError(
1764
1795
  `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
@@ -1784,7 +1815,7 @@ function cmdRender(flags: Record<string, string>): void {
1784
1815
  const geometryTexts = new Map<string, string>();
1785
1816
  if (geometry) {
1786
1817
  for (const [name, frames] of sampled) {
1787
- const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
1818
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
1788
1819
  geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
1789
1820
  }
1790
1821
  }
@@ -1793,7 +1824,7 @@ function cmdRender(flags: Record<string, string>): void {
1793
1824
  const { viewport, sampled, geometryTexts } = posed.value;
1794
1825
  console.log(` .. poser ${posed.note}`);
1795
1826
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
1796
- if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
1827
+ if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
1797
1828
 
1798
1829
  mkdirSync(outRoot, { recursive: true });
1799
1830
  const sets: FrameSet[] = [];
@@ -1819,7 +1850,7 @@ function cmdRender(flags: Record<string, string>): void {
1819
1850
  const duration = frames[frames.length - 1].time;
1820
1851
  sets.push({
1821
1852
  dir: dirName,
1822
- animation: name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name,
1853
+ animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
1823
1854
  fps,
1824
1855
  sampled: frames.length,
1825
1856
  written: frames.length,
@@ -4281,6 +4312,14 @@ try {
4281
4312
  console.error(`rigc: ${err.message}`);
4282
4313
  process.exit(1);
4283
4314
  }
4315
+ // An input posed through spine-core — a Spine export, `--poser spine`, a
4316
+ // fallback — on a run where the runtime cannot be used (issue #1014). The
4317
+ // sentence names the input and why it needs the runtime; exit 1, like a file
4318
+ // that is not a PNG: the invocation was fine and the run could not pose it.
4319
+ if (err instanceof SpineRuntimeError) {
4320
+ console.error(`rigc ${command}: ${err.message}`);
4321
+ process.exit(1);
4322
+ }
4284
4323
  // An install refused before its first write (issue #831): the invocation was
4285
4324
  // fine and an entry on disk was not what this command would write, so exit 1
4286
4325
  // like a file that is not a PNG. The message names every such entry, what is
package/docs/AUTHORING.md CHANGED
@@ -195,7 +195,7 @@ What the flags mean:
195
195
  | --- | --- |
196
196
  | `--rig` | the rig spec — skeleton structure |
197
197
  | `--motion` | the motion spec — time |
198
- | `--out` | directory for `skeleton.json` + `skeleton.atlas`, and on `build` `skeleton.model.json` beside them; atlas page paths and `skeleton.images` are written relative to it. `skeleton.model.json` is rigc's own record of the compiled rig (`rigc-compiled/1`: the bones, slots, skins, constraints, events and animations the Spine files were emitted from, in the spec's order, and the rest of what posing them takes), written after the same gate as the pair and ending in `spine.sha256`, the digest of the `skeleton.json` written beside it. It is what rigc's own posing core (`src/core/`) poses: `render` and `check` — and `bench --frames`, which folds `check` in — pose a build through it, and `--poser` below says when they do not. On `check` it is a directory of **pictures** instead: one per frame the table lists (every compared frame under `--all-frames`), reference · candidate · difference · overlay at the comparison grid's native size, as `<dir>/<set>/f####.png` beside a `frames.json` that says what they are pictures of. Each `<dir>/<set>/` is cleared first; a file at `<dir>`, or a directory that is or holds `--frames`, is refused by name — **§9.2.1** |
198
+ | `--out` | directory for `skeleton.json` + `skeleton.atlas`, and on `build` `skeleton.model.json` beside them; atlas page paths and `skeleton.images` are written relative to it. `skeleton.model.json` is rigc's own record of the compiled rig (`rigc-compiled/2`: the bones, slots, skins, constraints, events and animations the Spine files were emitted from, in the spec's order, and the rest of what posing them takes; then `pages`, every page of the `skeleton.atlas` written beside it and where each region sits on it — after `--pack` the packed pages, after `--copy-images` the copies' names), written after the same gate as the pair and ending in `spine.sha256`, the digest of the `skeleton.json` written beside it. A `rigc-compiled/1` document (rigc 1.6) has no `pages` and is still read: the core then takes the placement from the atlas beside it. It is what rigc's own posing core (`src/core/`) poses: `render` and `check` — and `bench --frames`, which folds `check` in — pose a build through it, and `--poser` below says when they do not. On `check` it is a directory of **pictures** instead: one per frame the table lists (every compared frame under `--all-frames`), reference · candidate · difference · overlay at the comparison grid's native size, as `<dir>/<set>/f####.png` beside a `frames.json` that says what they are pictures of. Each `<dir>/<set>/` is cleared first; a file at `<dir>`, or a directory that is or holds `--frames`, is refused by name — **§9.2.1** |
199
199
  | `--copy-images` | `build` only: also copies every page **the emitted atlas names** into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (§3.1 says why it is spelled `../<out>/` and not `./`). Under `--atlas-in` those pages are the pack's, not one per part (**§0.2**). Without it, page paths point at the source art |
200
200
  | `--pack` | `build` only: arrange every part onto **shared** atlas page(s), written into `--out` as real PNGs, instead of one page per part. Lossless — nothing is resampled, trimmed or rotated. The default is one page per part — **§0.1** |
201
201
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
@@ -210,7 +210,7 @@ What the flags mean:
210
210
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
211
211
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
212
212
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
213
- | `--poser` | `render` and `check`: `core` or `spine` — which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes, with the atlas beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core. Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `the "default" skin names` (no `--skin`, and the default skin names a skin-required bone or constraint, each named) · `and no "default" one` (no `--skin`, and skins with none named `default` — spine-core draws nothing there either, so the render stops at `there is nothing to draw`, exit 2, and only `--poser core` prints this refusal) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row |
213
+ | `--poser` | `render` and `check`: `core` or `spine` — which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes, with the atlas beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core. Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `the "default" skin names` (no `--skin`, and the default skin names a skin-required bone or constraint, each named) · `and no "default" one` (no `--skin`, and skins with none named `default` — spine-core draws nothing there either, so the render stops at `there is nothing to draw`, exit 2, and only `--poser core` prints this refusal) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check` (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its animation and skin names, slot and skin rosters, stage and bone tree come off its own `skeleton.json` and model document, its pages off rigc's atlas reader. The runtime is loaded for a Spine export, `--poser spine`, a fallback the `poser` line names and `--texture-from`; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`) |
214
214
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
215
215
  | `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
216
216
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
@@ -231,7 +231,9 @@ What the flags mean:
231
231
  `render` also takes `--fps <n>` (the rate it samples at, default 12 — the same
232
232
  protocol rate the reference frames use) and `--max <px>` (the long side of a
233
233
  frame, default 256). A clipping attachment is applied as the runtime applies it —
234
- spine-core's own `SkeletonClipping`, in spine-webgl's call order — so a slot from
234
+ spine-core's own `SkeletonClipping`, in spine-webgl's call order, on a Spine export,
235
+ and the core's clipper, drawing the same pixels, on a rigc build (the `--poser`
236
+ row) — so a slot from
235
237
  the clip's through its `end` slot draws only what falls inside the polygon, in
236
238
  `render`, `check` and every other picture rigc's own rasteriser draws (`preview`
237
239
  plays in Spine's player, which always clipped); the framing box still counts
@@ -6481,8 +6483,9 @@ Inside `setup` and each frame:
6481
6483
  scale and shear all live in `a b c d`.
6482
6484
  - `attachments` — every slot showing a region or a mesh, in the frame's **draw
6483
6485
  order**: `{ slot, attachment, vertices, color }`. `vertices` are `x, y` per vertex
6484
- in world units after skinning and deform — the runtime's own
6485
- `computeWorldVertices` over the **whole** attachment. A region's four are its
6486
+ in world units after skinning and deform, over the **whole** attachment — on a
6487
+ Spine export spine-core's own `computeWorldVertices`, and on a rigc build the
6488
+ core's, the same numbers to the bit (the `--poser` row). A region's four are its
6486
6489
  corners in the order bottom-left, top-left, top-right, bottom-right; a mesh's are
6487
6490
  in the attachment's own order, so vertex `i` is the same vertex in every frame and
6488
6491
  in `rest`. `color` is the slot colour times the attachment colour, straight RGBA
@@ -6501,7 +6504,7 @@ are not numbered like them — so `vertices` are the attachment's own, and the f
6501
6504
  show what the clip left of them. Bounding boxes, points, paths and clipping polygons
6502
6505
  draw nothing and are not listed. Numbers are printed the way every JSON file this
6503
6506
  tool writes prints them — the shortest decimal that reads back as the same double —
6504
- so a vertex in the file *is* the runtime's vertex, not a rounding of it; a pose that
6507
+ so a vertex in the file *is* the poser's vertex, not a rounding of it; a pose that
6505
6508
  is not finite is refused rather than written as `null`, naming where — the setup pose,
6506
6509
  `animation "a" frame i at F fps (t=…s)`, or the rest table — and then the bone and
6507
6510
  its world-transform field, or the slot, the attachment and the vertex, with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, round-trips it through spine-core and named assertions, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/atlas.ts CHANGED
@@ -80,18 +80,20 @@
80
80
  *
81
81
  * `src/compile.ts` must stay independent of the runtime — that is what keeps the
82
82
  * compiler and the gate from checking each other's assumptions — so the importer
83
- * cannot reach for `TextureAtlas`. The reader below is therefore written straight
84
- * off `TextureAtlas`'s own field table (`dist/TextureAtlas.js`, the
85
- * `pageFields` / `regionFields` maps and the loop under them), including the
86
- * parts that look like mistakes and are not: the page name is trimmed and a
87
- * region name is the RAW line, a blank line closes a page block, `readEntry`
88
- * stops at four values, and `originalWidth/Height` fall back to `width/height`
89
- * only when BOTH are zero.
83
+ * cannot reach for `TextureAtlas`. The reader below therefore states, for every
84
+ * field the format has, what the runtime reads it as — the page and region
85
+ * fields of `dist/TextureAtlas.js` — including the readings that look like
86
+ * mistakes and are not: the page name is trimmed and a region name is the RAW
87
+ * line, a blank line closes a page block, an entry holds at most four values,
88
+ * and `originalWidth/Height` fall back to `width/height` only when BOTH are
89
+ * zero.
90
90
  *
91
91
  * A second opinion about a format is a liability, so it is measured rather than
92
- * asserted: the selftest parses every `.atlas` in the example corpus with both
93
- * this reader and `spine-core`'s and compares every field of every region. If
94
- * they ever disagree, that control goes red and this file is wrong.
92
+ * asserted: `PKR01` parses every `.atlas` in the example corpus with both this
93
+ * reader and `spine-core`'s `TextureAtlas` and compares every page's name, size
94
+ * and `pma` and every region's eleven fields — 10 atlas files, 132 regions, 3
95
+ * of them rotated, 0 fields apart when this was written (issue #1015). If they
96
+ * ever disagree, that control goes red and this file is wrong.
95
97
  */
96
98
  import { CompileError } from './errors.ts';
97
99
  import { Plate, readPlate } from '../tools/plate.ts';
@@ -272,8 +274,9 @@ function int(text: string | undefined): number {
272
274
  /**
273
275
  * Read an atlas file's text into pages and regions.
274
276
  *
275
- * A transcription of `TextureAtlas`'s constructor, and deliberately a dull one —
276
- * every branch below is there because the runtime has it. Two additions, both of
277
+ * The format as the runtime reads it, field for field, and deliberately a dull
278
+ * reader — every branch below states a reading `PKR01` holds against
279
+ * `TextureAtlas`'s parse of the corpus (see the header). Two additions, both of
277
280
  * them fields a PLAYER has no use for and an IMPORTER does: `nameLine`, which
278
281
  * `rewritePageNames` needs, and the page's `scale:`, which is what turns a
279
282
  * region's texels back into the drawing's own size (`AtlasPage.scale`).
@@ -1337,7 +1340,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
1337
1340
  * a plate's rows run downwards, so the kept rectangle's top row is
1338
1341
  * `originalHeight - offsetY - height`.
1339
1342
  *
1340
- * ## A rotated region is TRANSCRIBED, not guessed at (issue #570)
1343
+ * ## A rotated region is MEASURED, not guessed at (issue #570)
1341
1344
  *
1342
1345
  * This refused a rotated region until 2026-09-17, on the argument that the
1343
1346
  * runtime holds "three opinions" about the mapping. Measurement refutes the
@@ -1348,7 +1351,13 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
1348
1351
  * `dist/attachments/MeshAttachment.js:126-162`) is the one routine that
1349
1352
  * states where a region's texels are for **all four** `degrees`, and it is
1350
1353
  * the routine `substituteTexture` in [`src/render.ts`](render.ts) already
1351
- * goes through. The loop below is its inverse, term for term;
1354
+ * goes through. The loop below inverts what it samples: `PKR02` asks it,
1355
+ * for every texel of every region of the corpus atlases, which page texel
1356
+ * the runtime samples and compares the lift — 132 regions, 2,848,402
1357
+ * texels, 0 apart, three of them turned (90 twice, 270 once) and each of
1358
+ * those lifting differently with the turn ignored; `PK29` lifts the
1359
+ * packer's fixture at each of 0, 90, 180 and 270 back to its PNG byte for
1360
+ * byte;
1352
1361
  * * `TextureAtlas`'s `u2`/`v2` (`dist/TextureAtlas.js:164-171`) transpose the
1353
1362
  * rectangle at 90 and not at 270, so at 270 they describe a rectangle the
1354
1363
  * page does not have — but `MeshAttachment.computeUVs` never reads them for
@@ -1358,8 +1367,9 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
1358
1367
  * region-attachment rendering defect (issue #199) and not a statement about
1359
1368
  * where the drawing sits.
1360
1369
  *
1361
- * Inverting the runtime's own expression on texel centres puts kept-rectangle
1362
- * pixel `(x, y)` — `x` from the drawing's left, `y` down from `top` — at page
1370
+ * On texel centres — at 90 and 270 measured against the runtime's sampling
1371
+ * (`PKR02`), at 180 against the packer fixture's turn (`PK29`) — the mapping
1372
+ * puts kept-rectangle pixel `(x, y)` — `x` from the drawing's left, `y` down from `top` — at page
1363
1373
  * pixel `(X + x, Y + y)` unturned, `(X + y, Y + width - 1 - x)` at 90,
1364
1374
  * `(X + width - 1 - x, Y + height - 1 - y)` at 180 and
1365
1375
  * `(X + height - 1 - y, Y + x)` at 270, writing `X`/`Y` for the region's own
package/src/check.ts CHANGED
@@ -69,7 +69,7 @@ import {
69
69
  BACKGROUND,
70
70
  frameGeometry,
71
71
  PROTOCOL_FPS,
72
- posableFromText,
72
+ loadCandidate,
73
73
  renderFrame,
74
74
  sampleAnimation,
75
75
  sampleSetupPose,
@@ -83,11 +83,8 @@ import {
83
83
  nonFinitePoseOf,
84
84
  unframeableSentence,
85
85
  UnframeablePoseError,
86
- skinRosterOf,
87
- candidatePosers,
88
- spinePoser,
86
+ refuseUnchosen,
89
87
  throughPoser,
90
- PoserChoiceError,
91
88
  SHEET_COLUMNS,
92
89
  SHEET_FILE,
93
90
  SHEET_GAP,
@@ -95,9 +92,10 @@ import {
95
92
  type Frame,
96
93
  type FramesSidecar,
97
94
  type FrameSet,
95
+ type MakeCorePoser,
96
+ type Posable,
98
97
  type PoseOptions,
99
98
  type Poser,
100
- type PoserChoice,
101
99
  type PoserName,
102
100
  type TextureSubstitution,
103
101
  type Viewport,
@@ -1062,22 +1060,11 @@ export interface CheckOptions {
1062
1060
  * What builds the core poser — `candidatePosers`' own `make`. The suite's
1063
1061
  * `CH01` passes a planted copy, and nothing else passes any.
1064
1062
  */
1065
- makeCorePoser?: Parameters<typeof candidatePosers>[4];
1063
+ makeCorePoser?: MakeCorePoser;
1066
1064
  }
1067
1065
 
1068
- /**
1069
- * Both posers for the candidate `check` is handed, and which one it asked for:
1070
- * `candidatePosers` over the candidate's files when the caller named them, and
1071
- * spine-core alone when it handed texts only — there is then no directory to
1072
- * find a model document in, and the reason says so.
1073
- */
1074
- function checkPoserChoice(data: Parameters<typeof spinePoser>[0], options: CheckOptions): PoserChoice {
1075
- const paths = options.candidatePaths;
1076
- if (paths !== undefined) return candidatePosers(data, paths.skeleton, paths.atlas, options.poser, options.makeCorePoser);
1077
- const why = 'the candidate was handed to check as text, with no path to find a skeleton.model.json beside';
1078
- if (options.poser === 'core') throw new PoserChoiceError(`--poser core: ${why}`);
1079
- return { forced: options.poser, core: null, why: options.poser === 'spine' ? '--poser spine' : why, spine: spinePoser(data) };
1080
- }
1066
+ /** Why a candidate handed to `check` as text alone is posed through spine-core — there is no directory to find a model document in. */
1067
+ const UNPLACED_CANDIDATE = 'the candidate was handed to check as text, with no path to find a skeleton.model.json beside';
1081
1068
 
1082
1069
  // ---------------------------------------------------------------------------
1083
1070
 
@@ -1145,14 +1132,27 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1145
1132
  }
1146
1133
  const notes: string[] = [];
1147
1134
 
1148
- const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
1135
+ // Which implementation poses the candidate (issue #968), chosen once for the
1136
+ // whole run — every set, the setup pose and the non-finite sentence — so no
1137
+ // two figures in one report can come from two posers: `candidatePaths` over
1138
+ // the candidate's files, as `render` chooses, and spine-core alone when it
1139
+ // was handed texts only. Chosen BEFORE anything is read off the candidate
1140
+ // (issue #1014), so a rigc build the core poses reads its names, its stage,
1141
+ // its bone tree and its pages without loading spine-core.
1142
+ const { choice, facts, pages } = loadCandidate(
1143
+ { skeletonText: options.skeletonText, atlasText: options.atlasText, atlasDir: options.atlasDir, label: options.labels?.skeleton ?? 'the candidate' },
1144
+ options.candidatePaths ?? null,
1145
+ options.poser,
1146
+ { ...(options.makeCorePoser === undefined ? {} : { make: options.makeCorePoser }), unplaced: UNPLACED_CANDIDATE },
1147
+ );
1148
+ const posable: Pick<Posable, 'pages'> = { pages };
1149
1149
  // A candidate that declares no stage is framed exactly as one that does,
1150
1150
  // because no framing here reads a stage: the world box is fitted from what the
1151
1151
  // candidate draws. Said on the one candidate where a reader can ask what box
1152
1152
  // stood in for the absent one (issue #714) — `SkeletonJson` copies the header
1153
1153
  // fields across unconditionally (`SkeletonJson.js:70-73`), so an omitted
1154
- // extent is `undefined` here, not 0.
1155
- if (typeof posable.data.width !== 'number' || typeof posable.data.height !== 'number') {
1154
+ // extent is `undefined` there, not 0, and the header says the same.
1155
+ if (!facts.declaresStage) {
1156
1156
  notes.push(
1157
1157
  'the candidate declares no stage (no `skeleton.width`/`height`), and nothing stands in for one: its world ' +
1158
1158
  'box is fitted from the pixels it draws, as it is for every candidate, so the absence moves no figure below.',
@@ -1167,18 +1167,16 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1167
1167
  // The skin is refused here rather than deeper in the sampler, for the reason
1168
1168
  // every miss in this project is refused where the names are: the skeleton is
1169
1169
  // open on this line and the alternatives can be listed.
1170
- if (options.skin !== undefined && !posable.data.skins.some((s) => s.name === options.skin)) {
1170
+ if (options.skin !== undefined && !facts.skins.includes(options.skin)) {
1171
1171
  throw new CheckError(
1172
1172
  `the candidate declares no skin ${JSON.stringify(options.skin)}; it declares [${
1173
- posable.data.skins.map((s) => s.name).join(', ') || 'none'
1173
+ facts.skins.join(', ') || 'none'
1174
1174
  }]`,
1175
1175
  );
1176
1176
  }
1177
- // Which implementation poses the candidate (issue #968), chosen once for the
1178
- // whole run — every set, the setup pose and the non-finite sentence — so no
1179
- // two figures in one report can come from two posers. `--poser core` on an
1180
- // input that cannot carry it is refused here, before anything is posed.
1181
- const choice = checkPoserChoice(posable.data, options);
1177
+ // `--poser core` on an input that cannot carry it is refused here, before
1178
+ // anything is posed.
1179
+ refuseUnchosen(choice);
1182
1180
  const poseOptions: PoseOptions | undefined =
1183
1181
  substitution || options.skin !== undefined
1184
1182
  ? { ...(substitution ? { texture: true } : {}), ...(options.skin === undefined ? {} : { skin: options.skin }) }
@@ -1220,7 +1218,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1220
1218
  sets = [
1221
1219
  {
1222
1220
  dir,
1223
- animation: posable.data.animations.length === 0 ? null : animation,
1221
+ animation: facts.animations.length === 0 ? null : animation,
1224
1222
  fps,
1225
1223
  sampled: disk[disk.length - 1].index + 1,
1226
1224
  written: disk.length,
@@ -1292,8 +1290,8 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1292
1290
  // poser runs — the model document lists the spec's order, and a refusal that
1293
1291
  // listed the names in another order would be a byte of the report that
1294
1292
  // depends on the poser.
1295
- const have = posable.data.animations.map((a) => a.name);
1296
- const posing = throughPoser(choice, (poser) => {
1293
+ const have = [...facts.animations];
1294
+ const posing = throughPoser(choice, (poser, roster) => {
1297
1295
  const sampled = sets.map((set) => prepareSet(located.root, set, poser, have, options.as, poseOptions));
1298
1296
  // 🔒 A pose that is not finite is refused before anything measures it (issue
1299
1297
  // #873), in the words `render` and the geometry export use for it. Measured
@@ -1325,7 +1323,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1325
1323
  posedSets.map((p) => p.frames),
1326
1324
  poser.slots,
1327
1325
  options.skin,
1328
- skinRosterOf(posable.data),
1326
+ roster,
1329
1327
  );
1330
1328
  if (unframeable !== null) throw new UnframeablePoseError(unframeable);
1331
1329
  return sampled;
@@ -1433,7 +1431,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1433
1431
  throw new CheckError(
1434
1432
  `no reference frame could be compared, so there is nothing to frame against${nothingToFrameWhy(
1435
1433
  prepared,
1436
- posable.data.animations.map((a) => a.name),
1434
+ [...facts.animations],
1437
1435
  options.as,
1438
1436
  located.sidecar !== null,
1439
1437
  )}`,
@@ -1574,8 +1572,8 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1574
1572
  // `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
1575
1573
  // side of the honesty rule: the reference is still nothing but pixels.
1576
1574
  const chains = chainsOf(
1577
- posable.data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
1578
- posable.data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
1575
+ facts.bones.map(({ name, parent }) => ({ name, parent })),
1576
+ facts.slots.map(({ name, bone }) => ({ name, bone })),
1579
1577
  );
1580
1578
  const chainOfSlot = new Map<string, number>();
1581
1579
  chains.forEach((chain, index) => {
@@ -2629,7 +2627,7 @@ function nothingToFrameWhy(
2629
2627
  function checkOneSet(
2630
2628
  root: string,
2631
2629
  prepared: PreparedSet,
2632
- posable: ReturnType<typeof posableFromText>,
2630
+ posable: Pick<Posable, 'pages'>,
2633
2631
  framing: SetFraming,
2634
2632
  background: RGBA,
2635
2633
  chains: BoneChain[],
@@ -3310,7 +3308,7 @@ function labelBox(index: number): { width: number; height: number } {
3310
3308
  function checkAgainstSheet(
3311
3309
  root: string,
3312
3310
  prepared: PreparedSet,
3313
- posable: ReturnType<typeof posableFromText>,
3311
+ posable: Pick<Posable, 'pages'>,
3314
3312
  viewport: Viewport,
3315
3313
  background: RGBA,
3316
3314
  ): { sheet: SheetCheck | null; notes: string[] } {