spine-rigc 1.7.0 → 1.9.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 (79) hide show
  1. package/cli.ts +88 -21
  2. package/docs/AUTHORING.md +18 -12
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a04.ts +40 -0
  6. package/src/assertions/bodies/a06.ts +195 -0
  7. package/src/assertions/bodies/a08.ts +76 -0
  8. package/src/assertions/bodies/a11.ts +15 -0
  9. package/src/assertions/bodies/a13.ts +51 -0
  10. package/src/assertions/bodies/a14.ts +35 -0
  11. package/src/assertions/bodies/a15.ts +97 -0
  12. package/src/assertions/bodies/a17.ts +26 -0
  13. package/src/assertions/bodies/a19.ts +295 -0
  14. package/src/assertions/bodies/a20.ts +122 -0
  15. package/src/assertions/bodies/a21.ts +190 -0
  16. package/src/assertions/bodies/a22.ts +39 -0
  17. package/src/assertions/bodies/a23.ts +255 -0
  18. package/src/assertions/bodies/a27.ts +33 -0
  19. package/src/assertions/bodies/a28.ts +70 -0
  20. package/src/assertions/bodies/a33.ts +110 -0
  21. package/src/assertions/bodies/a36.ts +81 -0
  22. package/src/assertions/bodies/a37.ts +77 -0
  23. package/src/assertions/bodies/a38.ts +73 -0
  24. package/src/assertions/bodies/a41.ts +83 -0
  25. package/src/assertions/bodies/a42.ts +97 -0
  26. package/src/assertions/bodies/a44.ts +23 -0
  27. package/src/assertions/bodies/a45.ts +172 -0
  28. package/src/assertions/bodies/a47.ts +59 -0
  29. package/src/assertions/bodies/a48.ts +83 -0
  30. package/src/assertions/constraint_words.ts +177 -0
  31. package/src/assertions/facts/animated_bones.ts +28 -0
  32. package/src/assertions/facts/atlas_pages.ts +19 -0
  33. package/src/assertions/facts/atlas_regions.ts +52 -0
  34. package/src/assertions/facts/constraints.ts +131 -0
  35. package/src/assertions/facts/linked_meshes.ts +38 -0
  36. package/src/assertions/facts/mesh_attachments.ts +71 -0
  37. package/src/assertions/facts/region_joins.ts +34 -0
  38. package/src/assertions/facts/skin_entries.ts +37 -0
  39. package/src/assertions/facts/skin_members.ts +53 -0
  40. package/src/assertions/facts/skin_meshes.ts +49 -0
  41. package/src/assertions/facts/slot_colour.ts +43 -0
  42. package/src/assertions/facts/stage.ts +24 -0
  43. package/src/assertions/facts/vertex_polygons.ts +53 -0
  44. package/src/assertions/harness.ts +109 -0
  45. package/src/assertions/kinds.ts +102 -0
  46. package/src/assertions/mesh_kinds.ts +56 -0
  47. package/src/assertions/model/animated_bones.ts +30 -0
  48. package/src/assertions/model/atlas_pages.ts +15 -0
  49. package/src/assertions/model/atlas_regions.ts +76 -0
  50. package/src/assertions/model/constraints.ts +207 -0
  51. package/src/assertions/model/given.ts +45 -0
  52. package/src/assertions/model/index.ts +247 -0
  53. package/src/assertions/model/linked_meshes.ts +24 -0
  54. package/src/assertions/model/mesh_attachments.ts +90 -0
  55. package/src/assertions/model/parse.ts +138 -0
  56. package/src/assertions/model/region_joins.ts +59 -0
  57. package/src/assertions/model/skin_entries.ts +59 -0
  58. package/src/assertions/model/skin_members.ts +62 -0
  59. package/src/assertions/model/skin_meshes.ts +90 -0
  60. package/src/assertions/model/slot_colour.ts +80 -0
  61. package/src/assertions/model/stage.ts +28 -0
  62. package/src/assertions/model/vertex_polygons.ts +71 -0
  63. package/src/assertions/reasons.ts +127 -0
  64. package/src/assertions/region_lookups.ts +61 -0
  65. package/src/assertions/values.ts +39 -0
  66. package/src/check.ts +36 -9
  67. package/src/compile.ts +204 -57
  68. package/src/core/index.ts +161 -29
  69. package/src/core/world.ts +21 -20
  70. package/src/deformmeasure.ts +377 -154
  71. package/src/deformreport.ts +3 -0
  72. package/src/deformstructure.ts +344 -0
  73. package/src/emit_spine.ts +21 -41
  74. package/src/keyorder.ts +40 -0
  75. package/src/model.ts +188 -22
  76. package/src/render.ts +243 -54
  77. package/src/render_core.ts +101 -13
  78. package/src/transform.ts +79 -30
  79. package/src/validate.ts +742 -2781
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
 
@@ -1226,7 +1237,7 @@ function cmdBuild(flags: Record<string, string>): void {
1226
1237
 
1227
1238
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
1228
1239
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
1229
- // rigc's own record of the compiled rig (`rigc-compiled/2`, issue #922;
1240
+ // rigc's own record of the compiled rig (`rigc-compiled/3`, issue #922;
1230
1241
  // its `pages` the atlas just written, issue #1016), written with the pair
1231
1242
  // and only after the same gate — under `--pack`, the packed one. rigc's own posing core
1232
1243
  // reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
@@ -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 or /3 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/3`: the stage the header declares, or `null`; the bones, slots, skins, constraints, events and animations the Spine files were emitted from, in the spec's order, and `editorOrder`, the order `skeleton.json` lists the skins, each skin's slot keys and the animations in; the rest of what posing them takes; then `pages`, every page of the `skeleton.atlas` written beside it, its `pma` and its `scale:` line (`null` where it states none), 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/2` document (rigc 1.7) states no stage, editor order, `pma` or `scale`, and is still read: `render` and `check` then read those off `skeleton.json` and the atlas, and the `poser` line names both files. A `rigc-compiled/1` document (rigc 1.6) has no `pages` either 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` or `/3` 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/3` or `/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` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `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; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; 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.9.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,40 @@
1
+ /**
2
+ * A04, the body (issue #1025, step 4c of #380): every mesh has triangles, in
3
+ * threes, each an index into its own vertices (case 6f).
4
+ *
5
+ * Moved out of `src/validate.ts` clause by clause. The three clauses about
6
+ * the triangles are about the rig — a document's mesh states its triangles
7
+ * and its vertices, so the same wrongness has a place to live there (where
8
+ * the reader refuses two of the three by the record's name) — and moved. The
9
+ * two about the vertex run — a weighted run whose length is not a multiple of
10
+ * three, an unweighted array of another length than the `uvs` — are about
11
+ * the encoding: the document states the weighted form outright, and the flat
12
+ * run is written by the emitter. They stay with the round trip, which hands
13
+ * their findings in as each mesh's `encoding`, printed where the body always
14
+ * printed them.
15
+ */
16
+ import type { Verdicts } from '../harness.ts';
17
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
18
+ import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
19
+
20
+ export function a04MeshTrianglesAndEncoding({ fail, skip }: Verdicts, { meshes }: MeshFacts): void {
21
+ if (meshes.length === 0) return skip('A04_MESH_TRIANGLES_AND_ENCODING', SKIP_NO_MESH_ATTACHMENT);
22
+ for (const mesh of meshes) {
23
+ if (!mesh.triangles || mesh.triangles.length === 0) {
24
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" has no triangles`);
25
+ continue;
26
+ }
27
+ if (mesh.triangles.length % 3 !== 0) {
28
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" triangle count is not a multiple of 3`);
29
+ }
30
+ const vertexCount = mesh.worldVerticesLength / 2;
31
+ for (const idx of mesh.triangles) {
32
+ if (idx < 0 || idx >= vertexCount) {
33
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" index ${idx} is outside 0..${vertexCount - 1}`);
34
+ break;
35
+ }
36
+ }
37
+ // The encoding's own clauses, kept with the round trip (the header).
38
+ for (const detail of mesh.encoding) fail('A04_MESH_TRIANGLES_AND_ENCODING', detail);
39
+ }
40
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * A06, the body (issue #1025, step 4c of #380): every page the atlas names is
3
+ * the size its PNG is, every region lies inside its page, and — under
4
+ * `spine-html` — no page claims premultiplied alpha, no two regions on one page
5
+ * overlap and no region is turned.
6
+ *
7
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
8
+ * regions are a fact (`../facts/atlas_regions.ts`), the directory is the
9
+ * caller's, and the profile's policy switch is passed in. The `check` call —
10
+ * and the argument for the SKIP guards it shares with A17 and A19 (#568, #608)
11
+ * — stays in `validate()`.
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+ import type { Verdicts } from '../harness.ts';
16
+ import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
17
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
18
+ import { pageFootprint, pageGridSentence } from '../../atlas.ts';
19
+ import { readPngHeader } from '../../png.ts';
20
+
21
+ export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
22
+ if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
23
+ if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
24
+ for (const page of atlas.pages) {
25
+ const abs = resolve(input.atlasDir, page.name);
26
+ if (!existsSync(abs)) continue; // A17 owns this
27
+ // 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
28
+ // #732). The reader's throw used to reach this rule's catch and print as
29
+ // `threw: not a PNG (bad signature)`: the verdict right, the sentence a
30
+ // stack message that named neither what the file was nor what rigc reads.
31
+ // `pngProblem` ([`src/png.ts`](png.ts)) is the one sentence every reader
32
+ // of a page states; this rule prefixes the size the atlas declares, the
33
+ // value the file would have had to carry.
34
+ //
35
+ // ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
36
+ // header carries its dimensions in a fixed field, and reading them would
37
+ // print a number no oracle in this tree has checked (rigc links no WebP
38
+ // reader to compare a parse against), about a page that is refused here
39
+ // either way — and measured, the variant that PASSED a WebP page whose
40
+ // size agreed built green under the default profile and wrote a
41
+ // directory that `render` then refused.
42
+ const header = readPngHeader(abs);
43
+ if (header.problem !== null) {
44
+ fail(
45
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
46
+ `page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so the ` +
47
+ `size was not measured: ${header.problem}`,
48
+ );
49
+ }
50
+ const info = header.info;
51
+ const onPage = atlas.regions.filter((region) => region.page.name === page.name);
52
+ const gridSaid = info === null ? null : pageGridSentence(page, info, onPage);
53
+ if (gridSaid !== null) {
54
+ // 🔒 **Still validity, and now for a measured reason rather than an
55
+ // inherited one** (issue #715). The card that opened this expected the
56
+ // clause to move behind the profile switch once the runtime's mapping
57
+ // was measured — it is declared-size-relative, so a rescaled page
58
+ // draws. What settles it the other way is that the sentence below can
59
+ // name a repair the FORMAT already provides (`scale:`), and that two of
60
+ // the three readers a wrong grid breaks are rigc's own and run under
61
+ // both profiles. The sentence, and the one derivation of the ratio under
62
+ // it, is `pageGridSentence` in [`src/atlas.ts`](atlas.ts): the compiler's
63
+ // region lift states the same one when it is asked for texels on such a
64
+ // page (issue #750), so the two cannot come to describe one page two ways.
65
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', gridSaid);
66
+ }
67
+ // 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
68
+ // CONVENTION, not the atlas format's rules — a packed page with
69
+ // `rotate: 90` is what the Spine packer produces and every official example
70
+ // ships one. Under `spine` an atlas is judged only on whether its declared
71
+ // size matches the file it names.
72
+ if (policy && page.pma) {
73
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `page "${page.name}" claims premultiplied alpha; parts are straight alpha`);
74
+ }
75
+ }
76
+ // 🔒 **A region's rectangle lies inside the page it names, and that is
77
+ // VALIDITY** (issue #694). The clause is not new — it has read the same four
78
+ // numbers since issue #266 — but it stood inside the policy half below, and
79
+ // behind a guard that skipped any page carrying one region that covered it.
80
+ // Both fences were wrong for it. A rectangle that leaves its page is not a
81
+ // convention somebody else may hold differently: `u2 > 1` samples whatever
82
+ // the wrap mode returns, which is never the drawing the pack was made of, so
83
+ // the part draws garbage or nothing at all. Measured on a pack shaped like
84
+ // the two production atlases that found this — a page declaring 2048x256
85
+ // with regions at `2274,0 980x200` and `0,252 100x258` — every atlas
86
+ // assertion passed under the default profile and the first of the two parts
87
+ // drew 0 of the 10,517 pixels it draws when the same rig is built loose.
88
+ //
89
+ // ⭐ The tool's other half already said so, which is what settles where the
90
+ // clause belongs. `resolveFromAtlas` ([`src/compile.ts`](compile.ts)) refuses
91
+ // exactly this rectangle as a `CompileError` under every profile when a pack
92
+ // arrives through `--atlas-in`, so while it was policy here the compiler and
93
+ // the gate disagreed about the same four numbers — and the gate is the only
94
+ // half that a skeleton and a pack somebody else made ever reach.
95
+ //
96
+ // ⚠️ It is stated over `atlas.regions` rather than per page group, because
97
+ // the question is about one region and its own page and needs no neighbour:
98
+ // a page carrying a single full-page region is measured too, and answers
99
+ // trivially. The rectangle is `pageFootprint`'s and nobody else's here
100
+ // (issue #579): what `TextureAtlas` transposes at 90 and not at 270 is
101
+ // `u2`/`v2`, a UV pair `MeshAttachment.computeUVs` never reads for an atlas
102
+ // region, and the page rectangle is a different quantity — transposed at
103
+ // BOTH quarter turns. A region that fits only *because* it is turned is
104
+ // inside its page, and this clause says so.
105
+ for (const region of atlas.regions) {
106
+ const foot = pageFootprint(region);
107
+ if (
108
+ region.x < 0 ||
109
+ region.y < 0 ||
110
+ region.x + foot.width > region.page.width ||
111
+ region.y + foot.height > region.page.height
112
+ ) {
113
+ fail(
114
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
115
+ `region "${region.name}" occupies ${region.x},${region.y} ${foot.width}x${foot.height} of page ` +
116
+ `"${region.page.name}", which is ${region.page.width}x${region.page.height} — a region that runs off ` +
117
+ 'its page samples texels that are not there',
118
+ );
119
+ }
120
+ }
121
+ if (!policy) return;
122
+ // ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2). This
123
+ // clause used to be the first alternative alone — every region's UVs
124
+ // `(0,0)-(1,1)` — which is rigc's *unpacked* convention and exactly what
125
+ // `build --pack` stops being true, so `--pack --profile spine-html` had to be
126
+ // a named CLI refusal. A pack is not a defect, and refusing it under this
127
+ // profile meant the one shape that exercises the renderer's shared-page
128
+ // sampling could never be gated by the renderer's own rulebook.
129
+ //
130
+ // What the first alternative bought was the attachment -> region -> file
131
+ // chain being checkable exactly, and `A27` already owns that half and already
132
+ // stands down on a multi-region page. What is left to check on a *tiling*
133
+ // page is what makes shared-page sampling well defined at all: no two regions
134
+ // on one page overlapping. A foreign pack can fail that while loading clean,
135
+ // and two overlapping rectangles put one drawing inside another's.
136
+ //
137
+ // ⚠️ Its sibling — every region inside the page it names — moved above and
138
+ // out of this profile in issue #694, and the two are not symmetrical. That
139
+ // one is broken for every consumer; this one is a statement about what a
140
+ // pack MEANS, and the corpus is the evidence rather than the taste:
141
+ // measured over the ten atlases in `examples/`, 0 of 132 regions are off
142
+ // their page and 49 pairs on four of those pages overlap, editor-exported
143
+ // and correct. A rule that called those files broken would be one
144
+ // consumer's convention refusing everybody else's data, which is the thing
145
+ // the profile split exists to prevent.
146
+ //
147
+ // ⚠️ Rotation stays refused either way, and that is not the same clause: it
148
+ // is about rigc's own packer never turning a region, which is a statement
149
+ // about what rigc WRITES. It stopped being a statement about what rigc can
150
+ // read in issue #570 — `extractRegion` now lifts a rotated region back off
151
+ // its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
152
+ // must not be re-merged: an artifact under the renderer's own rulebook that
153
+ // rigc did not pack is still a foreign artifact, whatever rigc can measure.
154
+ const regionsPerPage = new Map<string, AtlasRegionEntry[]>();
155
+ for (const region of atlas.regions) {
156
+ const on = regionsPerPage.get(region.page.name);
157
+ if (on) on.push(region);
158
+ else regionsPerPage.set(region.page.name, [region]);
159
+ }
160
+ for (const [pageName, on] of regionsPerPage) {
161
+ // The `onePartPerPage` guard that stood here went with the clause it was
162
+ // written for: with only the pair check left, a page carrying one region
163
+ // has no pair and the loop below does nothing on it anyway. Nothing reads
164
+ // `u`/`v`/`u2`/`v2` in this assertion any more, which is the point of
165
+ // #579 kept rather than restated.
166
+ const rects = on.map((region) => {
167
+ const foot = pageFootprint(region);
168
+ return { name: region.name, x: region.x, y: region.y, width: foot.width, height: foot.height };
169
+ });
170
+ for (let i = 0; i < rects.length; i++) {
171
+ for (let j = i + 1; j < rects.length; j++) {
172
+ const a = rects[i];
173
+ const b = rects[j];
174
+ if (a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height) {
175
+ fail(
176
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
177
+ `regions "${a.name}" (${a.x},${a.y} ${a.width}x${a.height}) and "${b.name}" (${b.x},${b.y} ` +
178
+ `${b.width}x${b.height}) overlap on page "${pageName}"; a page is one part covering it exactly or ` +
179
+ 'a tiling of regions that do not, and two rectangles over the same texels put one drawing inside ' +
180
+ "the other's",
181
+ );
182
+ }
183
+ }
184
+ }
185
+ }
186
+ for (const region of atlas.regions) {
187
+ if (region.degrees !== 0) {
188
+ fail(
189
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
190
+ `region "${region.name}" is rotated; rigc's own packer never turns a region (see PACK_NO_ROTATE in ` +
191
+ 'src/atlas.ts), so this atlas came from somewhere else',
192
+ );
193
+ }
194
+ }
195
+ }