spine-rigc 1.7.0 → 1.8.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
@@ -82,7 +82,8 @@ import { writeCheckPictures } from './src/checkpics.ts';
82
82
  import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
83
83
  import { MODEL_DOCUMENT_FILE, modelDocument } from './src/model.ts';
84
84
  import { deformReportBlock } from './src/deformreport.ts';
85
- import { surveyOfBuild } from './src/deformmeasure.ts';
85
+ import { surveyOfBuild, type DeformSurvey } from './src/deformmeasure.ts';
86
+ import { CoreInputError } from './src/core/index.ts';
86
87
  import {
87
88
  diffLines,
88
89
  diffSkeletons,
@@ -156,6 +157,7 @@ import {
156
157
  POSER_NAMES,
157
158
  PoserChoiceError,
158
159
  refuseUnchosen,
160
+ CandidateAtlasError,
159
161
  SpineRuntimeError,
160
162
  throughPoser,
161
163
  type Frame,
@@ -863,11 +865,20 @@ function memberReportLines(result: CompileResult): string[] {
863
865
  * an author who came here asking whether their deform broke the coverage
864
866
  * deserves the answer rather than a silence. What does move is the stretch.
865
867
  */
866
- function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>): string[] {
867
- // Through rigc's own core over the model document the build carries (issue #969), spine-core when the core
868
- // refuses it; the survey's record says which (`source`), and `tools/survey_hashes.ts` holds the two to one block.
869
- // The block itself is `deformReportBlock` (`src/deformreport.ts`), so a control renders it off either poser's survey.
870
- const survey = surveyOfBuild({ skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText, result.atlasText) }, new Set(), 'auto');
868
+ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>, poser: PoserName | undefined, label: string): string[] {
869
+ // Read and posed through rigc's own core over the model document the build carries (issues #969, #1019) —
870
+ // spine-core is not touched — and through spine-core under `--poser spine` or when the core refuses the
871
+ // document, which the block then names; `tools/survey_hashes.ts` holds the two to one block. The block
872
+ // itself is `deformReportBlock` (`src/deformreport.ts`), so a control renders it off either reader's survey.
873
+ const input = { skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText, result.atlasText), label: `the deform survey of ${label}` };
874
+ let survey: DeformSurvey;
875
+ try {
876
+ survey = surveyOfBuild(input, new Set(), poser === undefined ? 'auto' : poser === 'core' ? 'model' : 'spine-core');
877
+ } catch (err) {
878
+ // `--poser core` on a build the core refuses: a refusal of the invocation, as `render` and `check` give it.
879
+ if (poser === 'core' && err instanceof CoreInputError) throw new ExplainError(`--poser core: the core refused to pose the deform survey's model document — ${err.message}`);
880
+ throw err;
881
+ }
871
882
  return deformReportBlock(survey, result.deformTransforms, exempt);
872
883
  }
873
884
 
@@ -1463,16 +1474,16 @@ function runCheck(
1463
1474
  flags: Record<string, string>,
1464
1475
  plates?: CheckPlates,
1465
1476
  ): CheckReport {
1466
- const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
1477
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(candidate, atlasFlag);
1467
1478
  // The poser is `render`'s choice, over the same two files (issue #968): the
1468
1479
  // paths are what `candidatePosers` looks beside for `skeleton.model.json`.
1469
1480
  const poser = readPoserFlag(flags);
1470
1481
  return checkAgainstFrames({
1471
1482
  skeletonText: readFileSync(skeletonPath, 'utf8'),
1472
- atlasText: readFileSync(atlasPath, 'utf8'),
1483
+ atlasText,
1473
1484
  atlasDir: dirname(atlasPath),
1474
1485
  framesDir,
1475
- labels: { skeleton: skeletonPath, atlas: atlasPath },
1486
+ labels: { skeleton: skeletonPath, atlas: atlasText === null ? `${atlasPath} — not there (${ATLAS_ABSENT})` : atlasPath },
1476
1487
  candidatePaths: { skeleton: skeletonPath, atlas: atlasPath },
1477
1488
  ...(poser === undefined ? {} : { poser }),
1478
1489
  ...readCheckFlags(flags),
@@ -1576,6 +1587,43 @@ function resolveViewable(flags: Record<string, string>): {
1576
1587
  return { skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
1577
1588
  }
1578
1589
 
1590
+ /** What `render` and `check` say where a rigc build's atlas is not there (issue #1020). */
1591
+ const ATLAS_ABSENT = 'a build the core poses from a rigc-compiled/2 document needs none';
1592
+
1593
+ /**
1594
+ * The candidate `render` and `check` draw, with its atlas text — or `null`
1595
+ * where the atlas file is not there and need not be (issue #1020).
1596
+ *
1597
+ * ⭐ A rigc build is drawn by the core from its `skeleton.model.json`, and a
1598
+ * `rigc-compiled/2` document states where each region sits on its page, so
1599
+ * the build's `skeleton.atlas` is not needed to draw it. Its absence is let
1600
+ * through here only where it can be that case — no `--atlas` named, a model
1601
+ * document beside the skeleton, and no atlas beside it at all — and the poser
1602
+ * choice decides the rest: anything that is read through an atlas after all
1603
+ * (a `rigc-compiled/1` document, a build the core refuses) is refused there
1604
+ * naming the file and why (`CandidateAtlasError`). An atlas that IS there is
1605
+ * read, as it always was: the core holds it to the document's `pages` and
1606
+ * draws through spine-core, saying why, when it is not the one the build
1607
+ * wrote (#1016), and `check` reads its `scale:` lines for the texture note.
1608
+ * Everywhere else this is `resolveViewable`'s refusal, word for word.
1609
+ */
1610
+ function resolveDrawable(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string; atlasText: string | null } {
1611
+ const abs = resolve(target);
1612
+ if (atlasFlag === undefined && existsSync(abs)) {
1613
+ const directory = statSync(abs).isDirectory();
1614
+ const skeletonPath = directory ? join(abs, 'skeleton.json') : abs;
1615
+ const dir = dirname(skeletonPath);
1616
+ const atlasPath = join(dir, 'skeleton.atlas');
1617
+ const noAtlas = directory ? !existsSync(atlasPath) : abs.endsWith('.json') && !readdirSync(dir).some((f) => f.endsWith('.atlas'));
1618
+ if (noAtlas && existsSync(join(dir, MODEL_DOCUMENT_FILE))) {
1619
+ if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
1620
+ return { skeletonPath, atlasPath, atlasText: null };
1621
+ }
1622
+ }
1623
+ const { skeletonPath, atlasPath } = resolveViewable({ candidate: target, ...(atlasFlag === undefined ? {} : { atlas: atlasFlag }) });
1624
+ return { skeletonPath, atlasPath, atlasText: readFileSync(atlasPath, 'utf8') };
1625
+ }
1626
+
1579
1627
  /** `--animation`, checked against what the skeleton actually carries. */
1580
1628
  function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
1581
1629
  const name = flags.animation;
@@ -1720,7 +1768,11 @@ const STAGELESS_FRAMING = {
1720
1768
  * two rates — land on one pixel grid and stay comparable.
1721
1769
  */
1722
1770
  function cmdRender(flags: Record<string, string>): void {
1723
- const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
1771
+ if (flags.candidate === undefined) {
1772
+ throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
1773
+ }
1774
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
1775
+ const atlasDir = dirname(atlasPath);
1724
1776
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
1725
1777
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
1726
1778
  const outRoot = resolve(flags.out ?? 'render');
@@ -1745,13 +1797,14 @@ function cmdRender(flags: Record<string, string>): void {
1745
1797
 
1746
1798
  console.log('rigc render');
1747
1799
  console.log(` .. skeleton ${skeletonPath}`);
1748
- console.log(` .. atlas ${atlasPath}`);
1800
+ console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
1749
1801
  // Which implementation of the posing seam draws this (issue #968), chosen
1750
1802
  // before anything is read off the candidate (issue #1014): a rigc build the
1751
1803
  // core poses — `skeleton.model.json` beside the pair — reads its names, its
1752
- // subset roster and its pages without loading spine-core at all.
1804
+ // subset roster and its pages without loading spine-core at all, and with a
1805
+ // rigc-compiled/2 document without its atlas either (issue #1020).
1753
1806
  const { choice, facts, pages } = loadCandidate(
1754
- { skeletonText: readFileSync(skeletonPath, 'utf8'), atlasText: readFileSync(atlasPath, 'utf8'), atlasDir, label: skeletonPath },
1807
+ { skeletonText: readFileSync(skeletonPath, 'utf8'), atlasText, atlasDir, label: skeletonPath },
1755
1808
  { skeleton: skeletonPath, atlas: atlasPath },
1756
1809
  posersAsked(flags),
1757
1810
  );
@@ -2747,9 +2800,12 @@ function cmdBoneDist(flags: Record<string, string>): void {
2747
2800
  /**
2748
2801
  * Refuse a compiled pair whose art `explain` cannot pose through, by name.
2749
2802
  *
2750
- * 🚨 `explain` poses the rig — `deformReportLines` loads the emitted pair through
2751
- * `spine-core` to measure what each deform key did — and a pose resolves EVERY
2752
- * attachment against the atlas, whether or not anything deforms it. On the specs
2803
+ * 🚨 `explain` poses the rig — `deformReportLines` measures what each deform key
2804
+ * did — and spine-core's reading of the pair resolves EVERY attachment against
2805
+ * the atlas, whether or not anything deforms it. (Since issue #1019 a build's
2806
+ * survey is read off its model document and posed by the core, which resolves
2807
+ * nothing against the atlas; spine-core reads it under `--poser spine` and on a
2808
+ * fallback, and this refusal is unchanged and comes before either.) On the specs
2753
2809
  * `ingest --art none` writes there is nothing to resolve against: the entries
2754
2810
  * state a size and name no `image`, so the compile atlases nothing, and the load
2755
2811
  * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
@@ -2820,6 +2876,8 @@ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
2820
2876
  }
2821
2877
 
2822
2878
  function cmdExplain(flags: Record<string, string>): void {
2879
+ // `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
2880
+ const poser = readPoserFlag(flags);
2823
2881
  const { label, opts } = resolveCut(flags);
2824
2882
  console.log(`rigc explain ${label}`);
2825
2883
  // See the identical pair of lines in `cmdBuild` for why both paths are named
@@ -3075,7 +3133,7 @@ function cmdExplain(flags: Record<string, string>): void {
3075
3133
  // The `DEFORM` block goes after the timelines and before the constraints,
3076
3134
  // because it is a measurement OF the deform timelines printed above — the keys
3077
3135
  // it names are the keys the reader has just read, by the same index.
3078
- for (const line of deformReportLines(result, new Set(result.rig.deformMayFold))) console.log(line);
3136
+ for (const line of deformReportLines(result, new Set(result.rig.deformMayFold), poser, label)) console.log(line);
3079
3137
 
3080
3138
  if (result.physics.length) {
3081
3139
  console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
@@ -3591,7 +3649,9 @@ const FLAG_MEANINGS: Record<string, string> = {
3591
3649
  "`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
3592
3650
  'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
3593
3651
  "beside the skeleton and the skeleton is the one the document records (spine.sha256), `spine` otherwise and wherever the core refuses the input by name; the `poser` " +
3594
- 'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name',
3652
+ 'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
3653
+ "`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
3654
+ '`spine` (the Spine skeleton parsed and posed by spine-core); default `core`, and `spine` where the core refuses the document, which the block names',
3595
3655
  'texture-from':
3596
3656
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3597
3657
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -3806,11 +3866,11 @@ const COMMANDS: CommandDoc[] = [
3806
3866
  {
3807
3867
  name: 'explain',
3808
3868
  usage: [
3809
- 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] (it never gates, and writes nothing)',
3869
+ 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
3810
3870
  'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
3811
3871
  'rigc explain --cut <name> --cuts <cuts.json>',
3812
3872
  ],
3813
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts'],
3873
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
3814
3874
  notes: [
3815
3875
  'this line said "the same arguments as build, minus --profile" and was false in both',
3816
3876
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
@@ -4316,6 +4376,13 @@ try {
4316
4376
  // fallback — on a run where the runtime cannot be used (issue #1014). The
4317
4377
  // sentence names the input and why it needs the runtime; exit 1, like a file
4318
4378
  // that is not a PNG: the invocation was fine and the run could not pose it.
4379
+ // A candidate with no atlas beside it that has to be read through one (issue
4380
+ // #1020): a refusal of the invocation like a missing atlas on an export, exit
4381
+ // 2 and nothing written, and the message names the file and why it is needed.
4382
+ if (err instanceof CandidateAtlasError) {
4383
+ console.error(`rigc ${command}: ${err.message}`);
4384
+ process.exit(2);
4385
+ }
4319
4386
  if (err instanceof SpineRuntimeError) {
4320
4387
  console.error(`rigc ${command}: ${err.message}`);
4321
4388
  process.exit(1);
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/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** |
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, the `poser` line says so, and with no atlas there `render` and `check` refuse it naming the file. A `rigc-compiled/2` build is rendered and checked with its `skeleton.atlas` gone (`--candidate` below). 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** |
@@ -207,10 +207,10 @@ What the flags mean:
207
207
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
208
208
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
209
209
  | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
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 |
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. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. With the atlas gone, `check`'s texture note says whether it declares a `scale:` line is not read (the document does not state one), and `check.json`'s `textureFrom.candidateScales` is `null` (`RC13`). **`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. 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`) |
213
+ | `--poser` | `render`, `check` and `explain`: `core` or `spine` — on `render` and `check`, 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; its atlas, when one is named or present, the one 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`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; the order its animations and skins are listed in, and its stage, off its own `skeleton.json`, because the document states neither; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`). On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1) |
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 |
@@ -933,12 +933,16 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
933
933
 
934
934
  ⭐ **It reads what `ingest` writes, given the pack** — `--atlas-in <pack.atlas>`,
935
935
  with `build`'s meaning (§0.2). That matters more here than it looks: printing the
936
- `DEFORM` block means **posing** the rig, a pose resolves *every* attachment
937
- against the atlas whether or not anything deforms it, and a spec written by
938
- `ingest --art none` states sizes and names no image. So the size-only pair
939
- `build --atlas-in` gates green is readable here through the same flag, and
940
- without it the pair is refused by name at exit 2 rather than posed
941
- (§5.1). ⚠️ `--profile`,
936
+ `DEFORM` block means **posing** the rig, spine-core's pose resolves *every*
937
+ attachment against the atlas whether or not anything deforms it, and a spec
938
+ written by `ingest --art none` states sizes and names no image. So the
939
+ size-only pair `build --atlas-in` gates green is readable here through the same
940
+ flag, and without it the pair is refused by name at exit 2 rather than posed
941
+ (§5.1). ⚠️ Since issue #1019 the block's survey on a build is read off the
942
+ model document and posed by rigc's own core, which resolves nothing against the
943
+ atlas (`--poser` in the flag table); the refusal is unchanged and still comes
944
+ first, so a size-only pair needs `--atlas-in` here whichever reader the survey
945
+ takes. ⚠️ `--profile`,
942
946
  `--pack`, `--page-size`, `--padding`, `--page-edges` and `--copy-images` are
943
947
  `build`'s and are not here: five of them decide what is *written*, and this
944
948
  command writes nothing.
@@ -5753,9 +5757,11 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5753
5757
  | `skin "S" slot "X" attachment "P": "name" is V, which is not a string. An attachment's name is the runtime's `Attachment.name` …` | §3.4 — write the name as a string, or leave the key out and the attachment is named by its placeholder. It is written verbatim and `path` defaults to it, so a number there would reach the atlas as a region name nobody spelled |
5754
5758
 
5755
5759
  ⚠️ **One refusal in this section is not a `CompileError`, and it is `explain`'s.**
5756
- `explain` prints the `DEFORM` block by **posing** the rig, and a pose resolves every
5757
- attachment against the atlas — so it needs the art `build` needs, reached the same
5758
- two ways (§0.2). A pair whose art it cannot resolve is refused **before a line of
5760
+ `explain` prints the `DEFORM` block by **posing** the rig, and spine-core's pose
5761
+ resolves every attachment against the atlas — so it needs the art `build` needs,
5762
+ reached the same two ways (§0.2). (Since issue #1019 a build's survey is read and
5763
+ posed through rigc's own core by default, which reads no atlas; the refusal below
5764
+ is unchanged and comes before either.) A pair whose art it cannot resolve is refused **before a line of
5759
5765
  the report**, at **exit 2**, in rigc's own sentence:
5760
5766
 
5761
5767
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.7.0",
3
+ "version": "1.8.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": {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A03, the body (issue #1025, step 4c of #380): every region attachment has a
3
+ * finite, positive width and height (case 6c).
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the region
6
+ * attachments are a fact (`../facts/skin_entries.ts`) rather than the list
7
+ * `validate()` filled from spine-core's loaded skins, so the same body runs over
8
+ * the model document. The `check` call — and so where the verdict sits in the
9
+ * report — stays in `validate()`.
10
+ */
11
+ import type { Verdicts } from '../harness.ts';
12
+ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
13
+ import { SKIP_NO_REGION_ATTACHMENT } from '../reasons.ts';
14
+
15
+ export function a03RegionWidthHeightFinite({ fail, skip }: Verdicts, { regionAttachments }: SkinEntryFacts): void {
16
+ // ⟨subject⟩_⟨property⟩: with no region there is no size to find non-finite,
17
+ // and a loop over nothing used to report that as held (#580).
18
+ if (regionAttachments.length === 0) return skip('A03_REGION_WIDTH_HEIGHT_FINITE', SKIP_NO_REGION_ATTACHMENT);
19
+ for (const att of regionAttachments) {
20
+ if (!Number.isFinite(att.width) || !Number.isFinite(att.height)) {
21
+ fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" loaded w=${att.width} h=${att.height}`);
22
+ }
23
+ if (att.width <= 0 || att.height <= 0) {
24
+ fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" has a non-positive size`);
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A11, the body (issue #1025, step 4c of #380): no clipping attachment — the
3
+ * renderer skips them silently. A `renderer` rule, so `spine` never runs it.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the count is
6
+ * a fact (`../facts/skin_entries.ts`).
7
+ */
8
+ import type { Verdicts } from '../harness.ts';
9
+ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
10
+
11
+ export function a11NoClippingAttachments({ fail }: Verdicts, { clippingCount }: SkinEntryFacts): void {
12
+ if (clippingCount > 0) {
13
+ fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
14
+ }
15
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A17, the body (issue #1025, step 4c of #380): every page the atlas names is
3
+ * a file on disk, resolved against the directory the atlas sits in.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the pages are
6
+ * a fact (`../facts/atlas_pages.ts`), and the directory is the caller's. Why
7
+ * the guard is a SKIP and never a bare `return` (issues #568, #608) is argued
8
+ * above the `check` call in `validate()`, which stays there with A06 and A19.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { AtlasPageFacts } from '../facts/atlas_pages.ts';
14
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
15
+
16
+ export function a17AtlasPageFilesExist({ fail, skip }: Verdicts, { atlas }: AtlasPageFacts, input: { atlasDir: string }): void {
17
+ if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
18
+ // The other absence, one ring in (#580): the atlas LOADED and declares no
19
+ // page, so there is no file to find missing. `!atlas` and "zero pages" print
20
+ // different reasons because they are different facts about the artifact.
21
+ if (atlas.pages.length === 0) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS_PAGE);
22
+ for (const page of atlas.pages) {
23
+ const abs = resolve(input.atlasDir, page.name);
24
+ if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
25
+ }
26
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * A45, the body (issue #1025, step 4c of #380): the separable colour timelines
3
+ * own their channels and pose as written.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
6
+ * three facts (`../facts/slot_colour.ts`) where it read the skeleton JSON and
7
+ * the loaded skeleton: the animations' slot timelines in the file's order, as
8
+ * the file spells them; whether an animation exists; and a slot's colour posed
9
+ * at a time on a fresh track, which `validate()` takes from spine-core and the
10
+ * model side from rigc's core. The `check` call stays in `validate()`.
11
+ */
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { SlotColourFacts } from '../facts/slot_colour.ts';
14
+ import { SKIP_NO_SEPARABLE_COLOR } from '../reasons.ts';
15
+ import { atStoredKey, isObj } from '../values.ts';
16
+ import { SLOT_COLOR_CHANNELS } from '../../timelines.ts';
17
+
18
+ // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
19
+ // `alpha` pose no dark colour, so under A43's name a failure would be a
20
+ // verdict saying "two-colour tint" about a file that states one colour —
21
+ // the name would say something other than what it decides (the rule #712
22
+ // applied to A43 itself, against a clause on A10). And the SKIP is the
23
+ // sharper half: a rig with a `dark` and no separable timeline has A43's
24
+ // subject present, so a clause there could only PASS on it, which is a pass
25
+ // for an absent subject.
26
+ //
27
+ // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
28
+ // clauses are about the file against the runtime:
29
+ //
30
+ // 1. **One channel, one timeline.** Each colour timeline poses its
31
+ // channels at EVERY time — before its first key it writes the setup
32
+ // value — so when two of one slot share a channel, the one applied
33
+ // later (the one the file states later) overwrites the other
34
+ // everywhere and the first one's keys on it are read by nothing
35
+ // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
36
+ // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
37
+ // loads without a word.
38
+ // 2. **Posed as written.** Stepped to each key's own time, the channels
39
+ // the timeline writes are the key's: a colour that is not six hex
40
+ // digits loads as NaN, and a key whose time another key repeats is
41
+ // read by nothing, and both parse in silence.
42
+ //
43
+ // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
44
+ // setup alpha is NOT this rule's to see, and nothing that reads only the
45
+ // file can see it: the result is a correct `rgba`, which is also what an
46
+ // author keying the light colour and holding its alpha would write. What
47
+ // differs is what happens UNDER another track that moves the alpha, which
48
+ // is the consumer's composing rather than the object (CLAUDE.md). That
49
+ // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
50
+ //
51
+ // ⚠️ The required values are parsed HERE, as A43's are, and the channel
52
+ // table is `timelines.ts`'s rather than the loaded timelines' property ids:
53
+ // a check that asked the parser what a timeline writes would agree with it
54
+ // whatever it did. A selftest control holds that table to the runtime's ids.
55
+ export function a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten({ fail, skip, stats }: Verdicts, facts: SlotColourFacts): void {
56
+ const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
57
+ /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
58
+ const readRgbHex = (hex: unknown): [number, number, number] | null => {
59
+ if (typeof hex !== 'string') return null;
60
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
61
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
62
+ return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
63
+ };
64
+ /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
65
+ const HEX_STEP = 1 / 510;
66
+ const FLOAT_STEP = 1e-6;
67
+ const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
68
+ const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
69
+
70
+ // -- the subjects, read off the FILE ---------------------------------
71
+ const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
72
+ for (const { animation: animName, slot: slotName, timelines } of facts.slotTimelines) {
73
+ if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
74
+ subjects.push({ anim: animName, slot: slotName, timelines });
75
+ }
76
+ }
77
+ if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
78
+ stats.separableColorTimelines = subjects.reduce(
79
+ (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
80
+ 0,
81
+ );
82
+
83
+ for (const { anim: animName, slot: slotName, timelines } of subjects) {
84
+ const at = `animation "${animName}" slot "${slotName}"`;
85
+ // In FILE order, which is the order `readAnimation` pushes them and so
86
+ // the order they apply in.
87
+ const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
88
+
89
+ // -- clause 1: one channel, one timeline ---------------------------
90
+ let shared = false;
91
+ for (const channel of ['rgb', 'alpha', 'dark'] as const) {
92
+ const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
93
+ if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
94
+ shared = true;
95
+ const last = writers[writers.length - 1];
96
+ fail(
97
+ NAME,
98
+ `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
99
+ `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
100
+ `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
101
+ 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
102
+ );
103
+ }
104
+ if (shared) continue;
105
+
106
+ // -- clause 2: posed as written ------------------------------------
107
+ //
108
+ // ⚠️ Two things this does NOT do, both measured rather than skipped:
109
+ // it does not read the loaded timeline's CLASS back, and it does not
110
+ // hold a channel the timeline leaves alone to the setup pose. Against
111
+ // the linked parser neither can fail — every `rgb` / `alpha` array that
112
+ // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
113
+ // three other outcomes (an empty array, a slot the skeleton lacks, a
114
+ // name outside the switch) throw at `A00` — so either would be a clause
115
+ // nobody can see fire. The selftest reads both off spine-core directly
116
+ // (`S82`–`S84`), where they are measurements of the runtime rather than
117
+ // checks on a file.
118
+ if (!facts.hasAnimation(animName)) {
119
+ fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
120
+ continue;
121
+ }
122
+ for (const name of ['rgb', 'alpha'] as const) {
123
+ const keys = timelines[name];
124
+ if (!Array.isArray(keys)) continue;
125
+ const where = `${at} ${name}`;
126
+ for (const rawKey of keys) {
127
+ if (!isObj(rawKey)) continue;
128
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
129
+ // At the key as the runtime stores it, not one float step before
130
+ // it — see `atStoredKey` (issue #771).
131
+ const posed = facts.posedSlot(animName, slotName, atStoredKey(time));
132
+ if (!posed) {
133
+ fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
134
+ continue;
135
+ }
136
+ const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
137
+ if (name === 'rgb') {
138
+ const stated = readRgbHex(rawKey.color);
139
+ if (stated === null) {
140
+ fail(
141
+ NAME,
142
+ `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
143
+ `digits — and the runtime poses (${show(light.slice(0, 3))})`,
144
+ );
145
+ } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
146
+ fail(
147
+ NAME,
148
+ `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
149
+ `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
150
+ );
151
+ }
152
+ } else {
153
+ // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
154
+ // omits it there, so absence is read the parser's way rather than
155
+ // as a malformed key.
156
+ const stated = rawKey.value === undefined ? 0 : rawKey.value;
157
+ if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
158
+ fail(
159
+ NAME,
160
+ `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
161
+ (typeof stated !== 'number'
162
+ ? ' — an alpha key\'s value is a number'
163
+ : stated < 0 || stated > 1
164
+ ? ' — the runtime clamps a posed alpha to 0..1'
165
+ : ''),
166
+ );
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The atlas's pages, as the bodies that read them see them (issue #1025, step
3
+ * 4c of #380) — the census's F20, without `pma`, which the model document does
4
+ * not hold and a later cut supplies from the caller.
5
+ *
6
+ * Links nothing from the runtime: a spine-core `TextureAtlas` satisfies the
7
+ * shape structurally, which is how `validate()` supplies it, and the model
8
+ * side supplies the document's `pages` section (`../model/atlas_pages.ts`).
9
+ */
10
+
11
+ /** One page: its name, a path relative to the directory the atlas sits in. */
12
+ export interface AtlasPage {
13
+ readonly name: string;
14
+ }
15
+
16
+ /** The atlas, or `null` when there is none to read — the round trip refused it, or the model side read no document. */
17
+ export interface AtlasPageFacts {
18
+ readonly atlas: { readonly pages: readonly AtlasPage[] } | null;
19
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The skin entries, as the bodies that walk them read them (issue #1025, step
3
+ * 4c of #380) — the census's F03, F05 and F06: every skin's attachments, of
4
+ * which kind each is, and a region's name and size.
5
+ *
6
+ * ⭐ **The order is a fact, and it is the file's.** A body that prints one
7
+ * line per offending entry prints them in the order this list holds them, so a
8
+ * supplier that walked another order would print the same findings in another
9
+ * order — a different report about the same rig. The order is the one
10
+ * spine-core's loaded skins list their entries in: the skins in the file's
11
+ * order (`default` first, the rest in the editor's order the emitter writes —
12
+ * `editorSkinOrder` in `src/compile.ts`), and within a skin the entries slot by
13
+ * slot in the skeleton's slot order, each slot's in the order its table states
14
+ * them. The model side produces it by calling the emitter's own order, never
15
+ * by restating it (`../model/skin_entries.ts`); the selftest measures the two
16
+ * walks equal on every multi-skin build it makes.
17
+ *
18
+ * Links nothing from the runtime: a spine-core `RegionAttachment` satisfies
19
+ * the entry shape structurally, which is how `validate()` supplies it.
20
+ */
21
+
22
+ /** One region attachment: the name it loaded under (its `name`, else its placeholder) and its size. */
23
+ export interface RegionEntry {
24
+ readonly name: string;
25
+ readonly width: number;
26
+ readonly height: number;
27
+ }
28
+
29
+ /** What the skins hold, for A03 and A11. */
30
+ export interface SkinEntryFacts {
31
+ /** Every region attachment of every skin, in the file's walk order (the header's ⭐). */
32
+ readonly regionAttachments: readonly RegionEntry[];
33
+ /** How many clipping attachments the skins hold, every skin counted. */
34
+ readonly clippingCount: number;
35
+ }