spine-rigc 1.6.2 → 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.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
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,
@@ -93,7 +94,7 @@ import {
93
94
  } from './src/diff.ts';
94
95
  import { ingest, IngestError, IngestSpecRefused, INGEST_GUTTERS, type IngestFinding, type IngestStage } from './src/ingest.ts';
95
96
  import { NotAPngError } from './src/png.ts';
96
- import { copyAtlasPages } from './src/emit.ts';
97
+ import { copyAtlasPages, plannedPageCopies } from './src/emit.ts';
97
98
  import {
98
99
  DEFAULT_PADDING,
99
100
  DEFAULT_PAGE_EDGES,
@@ -141,10 +142,9 @@ import {
141
142
  GEOMETRY_FILE,
142
143
  GeometryError,
143
144
  UnframeablePoseError,
144
- skinRosterOf,
145
145
  geometryFileOf,
146
146
  geometryText,
147
- loadPosable,
147
+ loadCandidate,
148
148
  PROTOCOL_FPS,
149
149
  renderFrame,
150
150
  sampleAll,
@@ -154,17 +154,17 @@ import {
154
154
  SHEET_TILE,
155
155
  sidecarViewport,
156
156
  SlotSubsetError,
157
- slotSubsetOf,
158
- candidatePosers,
159
157
  POSER_NAMES,
160
158
  PoserChoiceError,
159
+ refuseUnchosen,
160
+ CandidateAtlasError,
161
+ SpineRuntimeError,
161
162
  throughPoser,
162
163
  type Frame,
163
164
  type FramesSidecar,
164
165
  type FrameSet,
165
- type Posable,
166
- type PoserChoice,
167
166
  type PoserName,
167
+ type SkeletonFacts,
168
168
  type SlotSubset,
169
169
  } from './src/render.ts';
170
170
  import {
@@ -456,10 +456,15 @@ function runGate(
456
456
  modelText: string,
457
457
  opts: CompileOptions,
458
458
  profile: ValidateProfile,
459
+ /** 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). */
460
+ written: (atlasText: string) => string,
459
461
  atlas?: AtlasOverride,
460
462
  ): number {
461
463
  // The determinism check compares a second, independent compile — its model
462
- // document included, which is the text `build` writes beside the pair.
464
+ // document included, which is the text `build` writes beside the pair, and
465
+ // which states where each region sits in the atlas written with it (`pages`,
466
+ // issue #1016): so the second document is spelled from the second compile's
467
+ // atlas as it would be written, or from the second, independent pack.
463
468
  const again = compile(opts);
464
469
  const report = validate({
465
470
  skeletonText: result.skeletonText,
@@ -467,7 +472,7 @@ function runGate(
467
472
  atlasDir: opts.outDir,
468
473
  declaredDurations: result.declaredDurations,
469
474
  modelText,
470
- reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText) },
475
+ reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
471
476
  rig: result.rig,
472
477
  profile,
473
478
  });
@@ -860,11 +865,20 @@ function memberReportLines(result: CompileResult): string[] {
860
865
  * an author who came here asking whether their deform broke the coverage
861
866
  * deserves the answer rather than a silence. What does move is the stretch.
862
867
  */
863
- function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>): string[] {
864
- // Through rigc's own core over the model document the build carries (issue #969), spine-core when the core
865
- // refuses it; the survey's record says which (`source`), and `tools/survey_hashes.ts` holds the two to one block.
866
- // 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');
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
+ }
868
882
  return deformReportBlock(survey, result.deformTransforms, exempt);
869
883
  }
870
884
 
@@ -1113,10 +1127,17 @@ function cmdBuild(flags: Record<string, string>): void {
1113
1127
 
1114
1128
  // The model's document is spelled before the gate, so the text A18 compares
1115
1129
  // 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);
1130
+ // before anything is (issue #922). It states where each region sits in the
1131
+ // atlas written beside it (`pages`, issue #1016), so it is spelled from that
1132
+ // atlas: under `--copy-images` the text with the copies' page names, planned
1133
+ // here from the text alone (`plannedPageCopies`) and copied after the gate.
1134
+ // Under `--pack` the pages move again after this gate, and the document
1135
+ // written is the one the packed gate below spells and compares.
1136
+ const copying = flags['copy-images'] !== undefined;
1137
+ const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
1138
+ let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
1118
1139
  console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
1119
- const failures = runGate(result, modelText, opts, profile);
1140
+ const failures = runGate(result, modelText, opts, profile, writtenAtlas);
1120
1141
  if (failures > 0) {
1121
1142
  console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
1122
1143
  process.exit(1);
@@ -1139,6 +1160,10 @@ function cmdBuild(flags: Record<string, string>): void {
1139
1160
  let atlasText = result.atlasText;
1140
1161
  if (flags['copy-images'] !== undefined) {
1141
1162
  const copied = copyAtlasPages(atlasText, opts.outDir);
1163
+ // The document's pages were spelled from the plan; the copy is held to it before anything of the pair is written.
1164
+ if (copied.atlasText !== writtenAtlas(result.atlasText)) {
1165
+ 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`);
1166
+ }
1142
1167
  atlasText = copied.atlasText;
1143
1168
  console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
1144
1169
  for (const p of copied.pages) {
@@ -1197,7 +1222,10 @@ function cmdBuild(flags: Record<string, string>): void {
1197
1222
  })),
1198
1223
  packOpts,
1199
1224
  );
1200
- const packFailures = runGate(result, modelText, opts, profile, { text: atlasText, again: packAgain.atlasText });
1225
+ // The document written is spelled from the packed atlas, and this gate's A18 compares it with a
1226
+ // second compile's spelled from the second, independent pack (issue #1016).
1227
+ modelText = modelDocument(result.model, result.skeletonText, atlasText);
1228
+ const packFailures = runGate(result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
1201
1229
  if (packFailures > 0) {
1202
1230
  console.error(
1203
1231
  `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
@@ -1209,8 +1237,9 @@ function cmdBuild(flags: Record<string, string>): void {
1209
1237
 
1210
1238
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
1211
1239
  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
1240
+ // rigc's own record of the compiled rig (`rigc-compiled/2`, issue #922;
1241
+ // its `pages` the atlas just written, issue #1016), written with the pair
1242
+ // and only after the same gate — under `--pack`, the packed one. rigc's own posing core
1214
1243
  // reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
1215
1244
  writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
1216
1245
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
@@ -1445,16 +1474,16 @@ function runCheck(
1445
1474
  flags: Record<string, string>,
1446
1475
  plates?: CheckPlates,
1447
1476
  ): CheckReport {
1448
- const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
1477
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(candidate, atlasFlag);
1449
1478
  // The poser is `render`'s choice, over the same two files (issue #968): the
1450
1479
  // paths are what `candidatePosers` looks beside for `skeleton.model.json`.
1451
1480
  const poser = readPoserFlag(flags);
1452
1481
  return checkAgainstFrames({
1453
1482
  skeletonText: readFileSync(skeletonPath, 'utf8'),
1454
- atlasText: readFileSync(atlasPath, 'utf8'),
1483
+ atlasText,
1455
1484
  atlasDir: dirname(atlasPath),
1456
1485
  framesDir,
1457
- labels: { skeleton: skeletonPath, atlas: atlasPath },
1486
+ labels: { skeleton: skeletonPath, atlas: atlasText === null ? `${atlasPath} — not there (${ATLAS_ABSENT})` : atlasPath },
1458
1487
  candidatePaths: { skeleton: skeletonPath, atlas: atlasPath },
1459
1488
  ...(poser === undefined ? {} : { poser }),
1460
1489
  ...readCheckFlags(flags),
@@ -1558,6 +1587,43 @@ function resolveViewable(flags: Record<string, string>): {
1558
1587
  return { skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
1559
1588
  }
1560
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
+
1561
1627
  /** `--animation`, checked against what the skeleton actually carries. */
1562
1628
  function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
1563
1629
  const name = flags.animation;
@@ -1603,13 +1669,13 @@ function readSkinFlag(flags: Record<string, string>, declared: string[]): string
1603
1669
  */
1604
1670
  function readSlotSubsetFlags(
1605
1671
  flags: Record<string, string>,
1606
- data: Posable['data'],
1672
+ facts: SkeletonFacts,
1607
1673
  skin: string | undefined,
1608
1674
  ): SlotSubset | undefined {
1609
1675
  const list = (raw: string | undefined): string[] | undefined =>
1610
1676
  raw === undefined ? undefined : raw.split(',').map((name) => name.trim()).filter((name) => name !== '');
1611
1677
  try {
1612
- return slotSubsetOf(data, { slots: list(flags.slot), hidden: list(flags.hide) }, skin);
1678
+ return facts.subset({ slots: list(flags.slot), hidden: list(flags.hide) }, skin);
1613
1679
  } catch (err) {
1614
1680
  if (err instanceof SlotSubsetError) throw new UsageError(err.message);
1615
1681
  throw err;
@@ -1617,16 +1683,12 @@ function readSlotSubsetFlags(
1617
1683
  }
1618
1684
 
1619
1685
  /**
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`.
1686
+ * `--poser` as spelled, checked against the posers there are — shared by
1687
+ * `render` and `check`. On `render` it is read where its refusal has always
1688
+ * stood, after the candidate's own flags; the candidate is loaded before that
1689
+ * with the spelling as given (`posersAsked`), so a rigc build the core poses
1690
+ * is chosen without loading spine-core (issue #1014).
1624
1691
  */
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
1692
  function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
1631
1693
  const raw = flags.poser;
1632
1694
  const forced = POSER_NAMES.find((name) => name === raw);
@@ -1636,6 +1698,11 @@ function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
1636
1698
  return forced;
1637
1699
  }
1638
1700
 
1701
+ /** `--poser` as the candidate is loaded with: a known spelling, or the input's own choice — the spelling is refused later, by `readPoserFlag`. */
1702
+ function posersAsked(flags: Record<string, string>): PoserName | undefined {
1703
+ return POSER_NAMES.find((name) => name === flags.poser);
1704
+ }
1705
+
1639
1706
  /** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
1640
1707
  function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
1641
1708
  if (subset === undefined) return {};
@@ -1653,8 +1720,10 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
1653
1720
  /**
1654
1721
  * Does this skeleton declare a setup stage — a numeric width AND height?
1655
1722
  *
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
1723
+ * Read off the file's header for `preview`, which never loads one; `render`
1724
+ * reads the same statement off its candidate (`SkeletonFacts.declaresStage`:
1725
+ * the header again on a rigc build the core poses, the loaded `SkeletonData`
1726
+ * on an export). Both are the same two fields, because
1658
1727
  * `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
1659
1728
  * so a header that omits them leaves `undefined` on a field typed `number`.
1660
1729
  */
@@ -1699,7 +1768,11 @@ const STAGELESS_FRAMING = {
1699
1768
  * two rates — land on one pixel grid and stay comparable.
1700
1769
  */
1701
1770
  function cmdRender(flags: Record<string, string>): void {
1702
- 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);
1703
1776
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
1704
1777
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
1705
1778
  const outRoot = resolve(flags.out ?? 'render');
@@ -1724,11 +1797,20 @@ function cmdRender(flags: Record<string, string>): void {
1724
1797
 
1725
1798
  console.log('rigc render');
1726
1799
  console.log(` .. skeleton ${skeletonPath}`);
1727
- 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);
1800
+ console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
1801
+ // Which implementation of the posing seam draws this (issue #968), chosen
1802
+ // before anything is read off the candidate (issue #1014): a rigc build the
1803
+ // core poses — `skeleton.model.json` beside the pair — reads its names, its
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).
1806
+ const { choice, facts, pages } = loadCandidate(
1807
+ { skeletonText: readFileSync(skeletonPath, 'utf8'), atlasText, atlasDir, label: skeletonPath },
1808
+ { skeleton: skeletonPath, atlas: atlasPath },
1809
+ posersAsked(flags),
1810
+ );
1811
+ const only = readAnimationFlag(flags, [...facts.animations]);
1812
+ const skin = readSkinFlag(flags, [...facts.skins]);
1813
+ const subset = readSlotSubsetFlags(flags, facts, skin);
1732
1814
  // One object, so the framing and the frames cannot be posed under two
1733
1815
  // different skins — which would frame one shot with another shot's box.
1734
1816
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
@@ -1742,23 +1824,25 @@ function cmdRender(flags: Record<string, string>): void {
1742
1824
  if (skin !== undefined) console.log(` .. skin ${skin}`);
1743
1825
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
1744
1826
 
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);
1827
+ // Rigc's own core when the candidate is a rigc build, and spine-core
1828
+ // otherwise, or where the core refuses the input by name. Never silently:
1829
+ // the `poser` line below says which, and why. `--poser` is refused here, where
1830
+ // it always was: a spelling there is no poser for, then `core` on an input
1831
+ // that cannot carry it.
1832
+ readPoserFlag(flags);
1833
+ refuseUnchosen(choice);
1750
1834
 
1751
1835
  // Everything is posed before anything is written, so a core refusal partway
1752
1836
  // re-poses the whole input on spine-core rather than leaving half of a frame
1753
1837
  // set drawn by each.
1754
- const posed = throughPoser(choice, (poser) => {
1838
+ const posed = throughPoser(choice, (poser, roster) => {
1755
1839
  // `null` is a skeleton that posed no vertex at all. One that posed a vertex
1756
1840
  // it cannot frame — Infinity or NaN — is thrown from the framing as a
1757
1841
  // `GeometryError` naming the number (issue #873), and one whose every vertex
1758
1842
  // sits at one point — every drawn bone unposed by the skin, or collapsed — as
1759
1843
  // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
1760
1844
  // lets the second name the skins that pose a bone, whichever poser draws.
1761
- const viewport = framingViewport(poser, maxSide, pose, skinRosterOf(data));
1845
+ const viewport = framingViewport(poser, maxSide, pose, roster);
1762
1846
  if (!viewport) {
1763
1847
  throw new UsageError(
1764
1848
  `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
@@ -1784,7 +1868,7 @@ function cmdRender(flags: Record<string, string>): void {
1784
1868
  const geometryTexts = new Map<string, string>();
1785
1869
  if (geometry) {
1786
1870
  for (const [name, frames] of sampled) {
1787
- const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
1871
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
1788
1872
  geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
1789
1873
  }
1790
1874
  }
@@ -1793,7 +1877,7 @@ function cmdRender(flags: Record<string, string>): void {
1793
1877
  const { viewport, sampled, geometryTexts } = posed.value;
1794
1878
  console.log(` .. poser ${posed.note}`);
1795
1879
  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}`);
1880
+ if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
1797
1881
 
1798
1882
  mkdirSync(outRoot, { recursive: true });
1799
1883
  const sets: FrameSet[] = [];
@@ -1819,7 +1903,7 @@ function cmdRender(flags: Record<string, string>): void {
1819
1903
  const duration = frames[frames.length - 1].time;
1820
1904
  sets.push({
1821
1905
  dir: dirName,
1822
- animation: name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name,
1906
+ animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
1823
1907
  fps,
1824
1908
  sampled: frames.length,
1825
1909
  written: frames.length,
@@ -2716,9 +2800,12 @@ function cmdBoneDist(flags: Record<string, string>): void {
2716
2800
  /**
2717
2801
  * Refuse a compiled pair whose art `explain` cannot pose through, by name.
2718
2802
  *
2719
- * 🚨 `explain` poses the rig — `deformReportLines` loads the emitted pair through
2720
- * `spine-core` to measure what each deform key did — and a pose resolves EVERY
2721
- * 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
2722
2809
  * `ingest --art none` writes there is nothing to resolve against: the entries
2723
2810
  * state a size and name no `image`, so the compile atlases nothing, and the load
2724
2811
  * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
@@ -2789,6 +2876,8 @@ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
2789
2876
  }
2790
2877
 
2791
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);
2792
2881
  const { label, opts } = resolveCut(flags);
2793
2882
  console.log(`rigc explain ${label}`);
2794
2883
  // See the identical pair of lines in `cmdBuild` for why both paths are named
@@ -3044,7 +3133,7 @@ function cmdExplain(flags: Record<string, string>): void {
3044
3133
  // The `DEFORM` block goes after the timelines and before the constraints,
3045
3134
  // because it is a measurement OF the deform timelines printed above — the keys
3046
3135
  // it names are the keys the reader has just read, by the same index.
3047
- 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);
3048
3137
 
3049
3138
  if (result.physics.length) {
3050
3139
  console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
@@ -3560,7 +3649,9 @@ const FLAG_MEANINGS: Record<string, string> = {
3560
3649
  "`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
3561
3650
  'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
3562
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` " +
3563
- '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',
3564
3655
  'texture-from':
3565
3656
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3566
3657
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -3775,11 +3866,11 @@ const COMMANDS: CommandDoc[] = [
3775
3866
  {
3776
3867
  name: 'explain',
3777
3868
  usage: [
3778
- '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)',
3779
3870
  'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
3780
3871
  'rigc explain --cut <name> --cuts <cuts.json>',
3781
3872
  ],
3782
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts'],
3873
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
3783
3874
  notes: [
3784
3875
  'this line said "the same arguments as build, minus --profile" and was false in both',
3785
3876
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
@@ -4281,6 +4372,21 @@ try {
4281
4372
  console.error(`rigc: ${err.message}`);
4282
4373
  process.exit(1);
4283
4374
  }
4375
+ // An input posed through spine-core — a Spine export, `--poser spine`, a
4376
+ // fallback — on a run where the runtime cannot be used (issue #1014). The
4377
+ // sentence names the input and why it needs the runtime; exit 1, like a file
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
+ }
4386
+ if (err instanceof SpineRuntimeError) {
4387
+ console.error(`rigc ${command}: ${err.message}`);
4388
+ process.exit(1);
4389
+ }
4284
4390
  // An install refused before its first write (issue #831): the invocation was
4285
4391
  // fine and an entry on disk was not what this command would write, so exit 1
4286
4392
  // 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, 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 |
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 |
@@ -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
@@ -931,12 +933,16 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
931
933
 
932
934
  ⭐ **It reads what `ingest` writes, given the pack** — `--atlas-in <pack.atlas>`,
933
935
  with `build`'s meaning (§0.2). That matters more here than it looks: printing the
934
- `DEFORM` block means **posing** the rig, a pose resolves *every* attachment
935
- against the atlas whether or not anything deforms it, and a spec written by
936
- `ingest --art none` states sizes and names no image. So the size-only pair
937
- `build --atlas-in` gates green is readable here through the same flag, and
938
- without it the pair is refused by name at exit 2 rather than posed
939
- (§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`,
940
946
  `--pack`, `--page-size`, `--padding`, `--page-edges` and `--copy-images` are
941
947
  `build`'s and are not here: five of them decide what is *written*, and this
942
948
  command writes nothing.
@@ -5751,9 +5757,11 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5751
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 |
5752
5758
 
5753
5759
  ⚠️ **One refusal in this section is not a `CompileError`, and it is `explain`'s.**
5754
- `explain` prints the `DEFORM` block by **posing** the rig, and a pose resolves every
5755
- attachment against the atlas — so it needs the art `build` needs, reached the same
5756
- 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
5757
5765
  the report**, at **exit 2**, in rigc's own sentence:
5758
5766
 
5759
5767
  ```
@@ -6481,8 +6489,9 @@ Inside `setup` and each frame:
6481
6489
  scale and shear all live in `a b c d`.
6482
6490
  - `attachments` — every slot showing a region or a mesh, in the frame's **draw
6483
6491
  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
6492
+ in world units after skinning and deform, over the **whole** attachment — on a
6493
+ Spine export spine-core's own `computeWorldVertices`, and on a rigc build the
6494
+ core's, the same numbers to the bit (the `--poser` row). A region's four are its
6486
6495
  corners in the order bottom-left, top-left, top-right, bottom-right; a mesh's are
6487
6496
  in the attachment's own order, so vertex `i` is the same vertex in every frame and
6488
6497
  in `rest`. `color` is the slot colour times the attachment colour, straight RGBA
@@ -6501,7 +6510,7 @@ are not numbered like them — so `vertices` are the attachment's own, and the f
6501
6510
  show what the clip left of them. Bounding boxes, points, paths and clipping polygons
6502
6511
  draw nothing and are not listed. Numbers are printed the way every JSON file this
6503
6512
  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
6513
+ so a vertex in the file *is* the poser's vertex, not a rounding of it; a pose that
6505
6514
  is not finite is refused rather than written as `null`, naming where — the setup pose,
6506
6515
  `animation "a" frame i at F fps (t=…s)`, or the rest table — and then the bone and
6507
6516
  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.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
+ }