spine-rigc 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/diff.ts CHANGED
@@ -1702,64 +1702,66 @@ function diffEvents(c: Json, r: Json): DiffSection {
1702
1702
  // ---------------------------------------------------------------------------
1703
1703
 
1704
1704
  /**
1705
- * The stage: `x`, `y`, `width`, `height` of the setup-pose bounding box, or the
1706
- * absence of all four.
1705
+ * The header's box: `x`, `y`, `width`, `height` of the setup-pose bounding box
1706
+ * — the box around the attachments at the setup pose, which is what the Spine
1707
+ * format says the four are — or the absence of all four.
1707
1708
  *
1708
- * 🔍 **Why this measure exists at all** (issue #578). The stage was the one value
1709
- * `build` required and no instrument in this tree could see: `validate`, `check`
1710
- * and `render` all ignore it — `render` frames from the posed bounds — and `diff`
1711
- * had no header measure, so a sweep that handed a deliberately absurd unit stage
1712
- * `0,0,1,1` to 37 real exports read **1.000 on every measure** for 32 of them. A
1713
- * required-and-unmeasured field is the worst combination a field can have: the
1714
- * only way to satisfy it was to invent a number, and nothing would ever say so.
1709
+ * 🔁 **Renamed from `stage_present`/`stage_box` by issue #907, because the old
1710
+ * name had become a lie.** Until then `build` copied rigc's stage — the crop
1711
+ * the art was painted in — into these four fields, so on a rigc candidate the
1712
+ * measure read a stage and on an editor export it read a bounding box, under
1713
+ * one word. Since #907 `build` writes the setup-pose bounding box there too
1714
+ * (`headerBoundsOf` in `src/compile.ts`, held to spine-core's `getBounds`), so
1715
+ * both sides of every comparison carry the same kind of value, and the stage —
1716
+ * which a rig spec states and the model document carries — is not in the file
1717
+ * at all. A measure called `stage` would send a reader to change the stage,
1718
+ * which no longer moves this number.
1715
1719
  *
1716
- * ⭐ **`stage_present` is 1/1 or 0/1 and never `0/0`.** Both sides always have a
1717
- * presence to compare, including when both say "none" — that is agreement, not
1718
- * an absence of data, and `total: 0` here would be the vacuous 1.000 this file
1719
- * refuses. `stage_box` is the one that goes vacuous, and only when there are not
1720
- * two boxes to compare.
1720
+ * 🔍 **Why this measure exists at all** (issue #578). The header box was the
1721
+ * one value `build` required and no instrument in this tree could see:
1722
+ * `validate`, `check` and `render` all ignore it — `render` frames from the
1723
+ * posed bounds — and `diff` had no header measure, so a sweep that handed a
1724
+ * deliberately absurd unit stage `0,0,1,1` to 37 real exports read **1.000 on
1725
+ * every measure** for 32 of them.
1726
+ *
1727
+ * ⭐ **`bounds_present` is 1/1 or 0/1 and never `0/0`.** Both sides always have
1728
+ * a presence to compare, including when both say "none" — that is agreement,
1729
+ * not an absence of data, and `total: 0` here would be the vacuous 1.000 this
1730
+ * file refuses. `bounds_box` is the one that goes vacuous, and only when there
1731
+ * are not two boxes to compare.
1721
1732
  */
1722
- interface StageFacts {
1733
+ interface HeaderBox {
1723
1734
  present: boolean;
1724
1735
  /**
1725
1736
  * The four fields as the header MEANS them: the extent exactly as stated, and
1726
- * the origin of a declared stage as stated or `0` where it is omitted. See
1727
- * `stageFacts` for why the second half is a reading and not a fallback.
1737
+ * the origin of a stated box as stated or `0` where it is omitted. See
1738
+ * `headerBox` for why the second half is a reading and not a fallback.
1728
1739
  */
1729
1740
  box: Array<number | null>;
1730
1741
  }
1731
1742
 
1732
- const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
1743
+ const BOX_FIELDS = ['x', 'y', 'width', 'height'] as const;
1733
1744
 
1734
1745
  /**
1735
- * A stage is declared by its EXTENT: a numeric `width` and `height`.
1746
+ * A header states a box by its EXTENT: a numeric `width` and `height`.
1736
1747
  *
1737
1748
  * `x`/`y` alone are an origin for a box that is not there — no export carries
1738
- * that shape, and rigc refuses to emit it — so they do not make a stage on their
1739
- * own. It is also what the compiler requires and what `A14_NO_FULL_FRAME_MESH`
1740
- * and `A19_OVERLAY_PNGS_HAVE_ALPHA` measure against, so the three agree on the
1741
- * word by construction rather than by memory.
1749
+ * that shape, and rigc never emits it — so they do not make a box on their own.
1742
1750
  *
1743
- * ⭐ **Inside a declared stage, an omitted `x`/`y` IS `0`** (issue #620). That is
1744
- * a reading of the format, not a value invented for a gap — the distinction this
1745
- * file lives or dies by — and four measurements carry it, none of them anybody's
1746
- * word for the convention:
1751
+ * ⭐ **Inside a stated box, an omitted `x`/`y` IS `0`** (issue #620). That is a
1752
+ * reading of the format, not a value invented for a gap — the distinction this
1753
+ * file lives or dies by — and three measurements carry it, none of them
1754
+ * anybody's word for the convention:
1747
1755
  *
1748
- * - **This tree had already decided it, one file over.** `compile.ts` assembles
1749
- * the header as `header.x = rig.skeleton?.x ?? 0`, under the same guard: only
1750
- * when the extent is there. So a rig spec that omits its origin emits `0`, and
1751
- * reading the same omission in a file as absence made the compiler and the
1752
- * comparison disagree about one value in one header. This measure is not
1753
- * adopting the editor's convention so much as stopping contradicting the
1754
- * emitter it is pointed at.
1755
1756
  * - **The header's writer omits a field at its default.** All twelve exports
1756
1757
  * under `examples/` omit `referenceScale`, whose default the parser itself
1757
1758
  * spells two lines below the four raw assignments (`SkeletonJson.js:74`,
1758
1759
  * `getValue(skeletonMap, "referenceScale", 100)`), and none of the 389 bone
1759
1760
  * `x`/`y`/`rotation`/`shearX`/`shearY` values those same files DO write is an
1760
- * explicit `0`. A stage at the origin therefore has no spelling but the
1761
+ * explicit `0`. A box at the origin therefore has no spelling but the
1761
1762
  * omission, so reading the omission as absence reads a value the format cannot
1762
- * express.
1763
+ * express. (An editor round trip of a header stating `"x": 0, "y": 0` wrote
1764
+ * neither back — measured through a licensed 4.3.26 editor for issue #907.)
1763
1765
  * - **The binary reader supplies it unconditionally.** `SkeletonBinary.js:69-72`
1764
1766
  * reads the four as four floats with no key to be missing, so one skeleton's
1765
1767
  * origin is `0` in a `.skel` and absent in a `.json`. A measure that called
@@ -1772,13 +1774,13 @@ const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
1772
1774
  * editor has never exported a frame rate.
1773
1775
  *
1774
1776
  * ⚠️ **The default is the ORIGIN's alone**, and the guard is the paragraph above
1775
- * it: the extent is what declares a stage, so defaulting it would turn the
1776
- * stage-less header of issue #578 into a `0x0` stage at `0,0` and answer the
1777
- * question instead of reading it.
1777
+ * it: the extent is what states a box, so defaulting it would turn the box-less
1778
+ * header of issue #578 into a `0x0` box at `0,0` and answer the question
1779
+ * instead of reading it.
1778
1780
  */
1779
- function stageFacts(root: Json): StageFacts {
1781
+ function headerBox(root: Json): HeaderBox {
1780
1782
  const header = isObj(root.skeleton) ? root.skeleton : {};
1781
- const box = STAGE_FIELDS.map((k) => num(header[k]));
1783
+ const box = BOX_FIELDS.map((k) => num(header[k]));
1782
1784
  const present = box[2] !== null && box[3] !== null;
1783
1785
  return { present, box: present ? [box[0] ?? 0, box[1] ?? 0, box[2], box[3]] : box };
1784
1786
  }
@@ -1787,47 +1789,51 @@ function stageFacts(root: Json): StageFacts {
1787
1789
  * The two header measures.
1788
1790
  *
1789
1791
  * ⚠️ **The box is compared EXACTLY, and that is a measurement rather than a
1790
- * choice.** The brief this was built from asked for "the tolerance the other
1791
- * measures use"; the structural measures in this file have exactly one —
1792
+ * choice.** The structural measures in this file have exactly one tolerance —
1792
1793
  * `FRAME`, one sixtieth of a second, used once, for `animations.duration` — and
1793
1794
  * no spatial one anywhere, because they compare no position at all (that is
1794
- * `bonedist.ts`). A stage is a box an exporter *wrote down*, not a pose anybody
1795
- * measured, so there is nothing for it to be within a tolerance *of*; inventing
1796
- * a spatial epsilon here would be a number nobody measured, in the file whose
1797
- * whole job is to report measured ones.
1795
+ * `bonedist.ts`). Each side's box is a number its writer computed: rigc's is
1796
+ * spine-core's `getBounds` on the header's 1e-6 grid at float32 (`headerBoxNumber`; `tools/core_gate.ts` holds it), and
1797
+ * the editor's is its own arithmetic, which on the twelve examples sits up to
1798
+ * 0.0071 units from `getBounds` on the same skeleton and agrees with its
1799
+ * float32 on 13 of the 48 numbers (issue #907's measurement). A rebuild and its
1800
+ * export therefore read below 4/4 here by the editor's arithmetic alone, and an
1801
+ * epsilon chosen to hide that would be a number nobody measured, in the file
1802
+ * whose whole job is to report measured ones. The measure is reported and gates
1803
+ * nothing on the ladder.
1798
1804
  *
1799
1805
  * ⭐ `diffSkeletonValues` (issue #615) is the second tolerance in the file and
1800
1806
  * it does not weaken this one. It compares positions, so it needs one; both its
1801
1807
  * terms are read off other code — the 1e-6 grid rigc's closed-form models are
1802
- * evaluated on and the parser's float32 storage — rather than chosen here; and the four numbers above are the
1803
- * one place the two overlap, where `stage_box` stays the stricter reading and
1804
- * says so by staying exact.
1808
+ * evaluated on and the parser's float32 storage — rather than chosen here; and
1809
+ * the four numbers above are the one place the two overlap, where `bounds_box`
1810
+ * stays the stricter reading and says so by staying exact.
1805
1811
  */
1806
1812
  function diffHeader(c: Json, r: Json): DiffReported {
1807
- const a = stageFacts(c);
1808
- const b = stageFacts(r);
1813
+ const a = headerBox(c);
1814
+ const b = headerBox(r);
1809
1815
  const both = a.present && b.present;
1810
1816
  const agreed = both ? a.box.filter((v, i) => v === b.box[i]).length : 0;
1811
- const side = (f: StageFacts): string => (f.present ? `${f.box[2]}x${f.box[3]} at ${f.box[0]},${f.box[1]}` : 'none');
1817
+ const side = (f: HeaderBox): string => (f.present ? `${f.box[2]}x${f.box[3]} at ${f.box[0]},${f.box[1]}` : 'none');
1812
1818
  return {
1813
1819
  measures: [
1814
1820
  measure(
1815
- 'skeleton.stage_present',
1816
- 'both sides declare a setup-pose stage, or neither does',
1821
+ 'skeleton.bounds_present',
1822
+ 'both headers carry a setup-pose bounding box, or neither does',
1817
1823
  a.present === b.present ? 1 : 0,
1818
1824
  1,
1819
- `candidate ${side(a)}; reference ${side(b)}. A skeleton declares a stage by stating a width and a height`,
1825
+ `candidate ${side(a)}; reference ${side(b)}. A header carries a box by stating a width and a height`,
1820
1826
  ),
1821
1827
  measure(
1822
- 'skeleton.stage_box',
1823
- 'the stage is the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means)',
1828
+ 'skeleton.bounds_box',
1829
+ 'the header carries the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means)',
1824
1830
  agreed,
1825
- both ? STAGE_FIELDS.length : 0,
1831
+ both ? BOX_FIELDS.length : 0,
1826
1832
  both
1827
1833
  ? `candidate ${side(a)}; reference ${side(b)}`
1828
1834
  : a.present === b.present
1829
- ? 'neither side declares a stage, so there is no box to compare — `stage_present` carries that'
1830
- : 'only one side declares a stage, so there is no second box to compare — `stage_present` carries that',
1835
+ ? 'neither header carries a box, so there is no box to compare — `bounds_present` carries that'
1836
+ : 'only one header carries a box, so there is no second box to compare — `bounds_present` carries that',
1831
1837
  ),
1832
1838
  ],
1833
1839
  };
@@ -1971,7 +1977,7 @@ function valuesAgree(a: number | string, b: number | string): boolean {
1971
1977
  * distinction `DiffMeasure.total` draws everywhere else in this file.
1972
1978
  */
1973
1979
  const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }> = [
1974
- { id: 'values.skeleton', what: 'the header and the setup-pose stage', prefix: 'skeleton/' },
1980
+ { id: 'values.skeleton', what: 'the header and its setup-pose bounding box', prefix: 'skeleton/' },
1975
1981
  { id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
1976
1982
  { id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
1977
1983
  { id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
@@ -2170,7 +2176,7 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
2170
2176
  // with `(no mean)` for the same reason a section's `(reported)` block has one.
2171
2177
  lines.push(
2172
2178
  ` ${'skeleton (reported)'.padEnd(21)} (no mean) over ${report.header.measures.length} measures` +
2173
- ' — the stage, which no reading of the frames could decide',
2179
+ ' — the header\'s setup-pose bounding box, which no reading of the frames could decide',
2174
2180
  );
2175
2181
  lines.push(...measureLines(report.header.measures, 'skeleton.'.length));
2176
2182
  lines.push('');
@@ -2232,10 +2238,10 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
2232
2238
  lines.push(' the block is absent rather than guessed — and its absence beside `names` 0.000');
2233
2239
  lines.push(' is the report saying the candidate named its shots itself and nothing said how.');
2234
2240
  lines.push('');
2235
- lines.push(' `skeleton` is the file\'s own header block and reports two measures for the stage.');
2241
+ lines.push(' `skeleton` is the file\'s own header block and reports two measures for its box.');
2236
2242
  lines.push(' It has no mean for the reason a `(reported)` block never does, and it never');
2237
2243
  lines.push(' gates for two: no reading of the frames recovers a setup-pose bounding box, and');
2238
- lines.push(' the ladder\'s briefs withhold the stage size outright.');
2244
+ lines.push(' each side\'s box is its writer\'s own arithmetic.');
2239
2245
  lines.push('');
2240
2246
  lines.push(' A `(reported)` block has no mean because its measures have unlike units, and');
2241
2247
  lines.push(' it stays out of the section mean above it for the same reason no clause may');
package/src/emit_spine.ts CHANGED
@@ -755,19 +755,33 @@ export function emitAnimations(
755
755
  * stated, `images` spelled relative to `--out` (`skeletonImagesPath`);
756
756
  * each only when present.
757
757
  *
758
+ * - `bounds` — the setup-pose bounding box, `x`, `y`, `width`, `height`,
759
+ * which is what the format says the header's four are (issue #907):
760
+ * computed by `compile` with rigc's core over the model
761
+ * (`headerBoundsOf`), each on the 1e-6 grid at float32 (`headerBoxNumber`), or `null` for no box — a rig that
762
+ * declares no stage (issue #578), a setup pose that draws nothing, a
763
+ * region with no atlas rectangle, or a setup pose the core leaves out
764
+ * (that function's header). Four fields or none.
765
+ *
758
766
  * `referenceScale` is not here: wind and gravity act over it, so a posing
759
767
  * core reads it, and the model holds it (`CompiledModel.referenceScale`,
760
768
  * issue #958). The emitter writes the model's value into the header, where
761
- * `withoutParserDefaults` drops it at the parser's 100. Nor is the stage —
762
- * the setup-pose box, four fields or none (issue #578) — since issue #1026:
763
- * `render` and `check` read whether one is declared, and the model holds it
764
- * (`CompiledModel.stage`), so the emitter writes the model's.
769
+ * `withoutParserDefaults` drops it at the parser's 100.
770
+ *
771
+ * ⚠️ Nor is the stage (`CompiledModel.stage`, issue #1026) — and since issue
772
+ * #907 the header does not carry it at all. Until then the emitter copied the
773
+ * stage into the four box fields, so a reader that took the header for what
774
+ * the format says it is — the box around the figure at rest, for scaling and
775
+ * layout — got the crop the art was painted in. The stage stays the model's:
776
+ * the document states it, and everything that measures against it reads it
777
+ * there.
765
778
  */
766
779
  export interface SkeletonHeader {
767
780
  spine: string;
768
781
  fps?: number;
769
782
  images?: string;
770
783
  audio?: string | null;
784
+ bounds: { x: number; y: number; width: number; height: number } | null;
771
785
  }
772
786
 
773
787
  /** The editor's orders the emitter applies, passed for the reason the header's 🔸 gives. */
@@ -776,7 +790,7 @@ export interface SkeletonOrder extends EditorOrder {
776
790
  }
777
791
 
778
792
  /** The model fields a skeleton is written from. */
779
- export type SkeletonSource = Pick<CompiledModel, 'referenceScale' | 'stage' | 'bones' | 'slots' | 'skins' | 'constraints' | 'events' | 'animations'>;
793
+ export type SkeletonSource = Pick<CompiledModel, 'referenceScale' | 'bones' | 'slots' | 'skins' | 'constraints' | 'events' | 'animations'>;
780
794
 
781
795
  /**
782
796
  * The Spine 4.3 skeleton of a compiled model: the emitter's one entry, and
@@ -791,7 +805,7 @@ export type SkeletonSource = Pick<CompiledModel, 'referenceScale' | 'stage' | 'b
791
805
  * between `skins` and `animations`, where the editor writes it), `constraints`
792
806
  * only when the model holds one (assigned after, so it lands last until the
793
807
  * key-order pass moves it). The header's keys: `spine, x, y, width, height,
794
- * fps, referenceScale, images, audio`, the stage's four only together.
808
+ * fps, referenceScale, images, audio`, the box's four only together.
795
809
  *
796
810
  * Then, on the finished object and once: `withoutParserDefaults` drops every
797
811
  * key at the value the 4.3 parser reads in its absence, and `inEditorKeyOrder`
@@ -800,7 +814,7 @@ export type SkeletonSource = Pick<CompiledModel, 'referenceScale' | 'stage' | 'b
800
814
  * throws, so a refusal raised here is raised by a section emitter, in the
801
815
  * order above.
802
816
  *
803
- * The stage (`model.stage`, issue #1026) is written as the model holds it,
817
+ * The box (`header.bounds`, issue #907) is written as `compile` computed it,
804
818
  * its four fields together or none. `referenceScale` is the model's, written
805
819
  * always and dropped by the parser-default pass at 100 — so a rig stating none and a rig stating 100
806
820
  * write the same bytes, as they did when the header carried the stated value.
@@ -811,11 +825,11 @@ export type SkeletonSource = Pick<CompiledModel, 'referenceScale' | 'stage' | 'b
811
825
  */
812
826
  export function emitSkeleton(model: SkeletonSource, header: SkeletonHeader, order: SkeletonOrder): SpineSkeletonJson {
813
827
  const head: SpineSkeletonJson['skeleton'] = { spine: header.spine };
814
- if (model.stage !== null) {
815
- head.x = model.stage.x;
816
- head.y = model.stage.y;
817
- head.width = model.stage.width;
818
- head.height = model.stage.height;
828
+ if (header.bounds !== null) {
829
+ head.x = header.bounds.x;
830
+ head.y = header.bounds.y;
831
+ head.width = header.bounds.width;
832
+ head.height = header.bounds.height;
819
833
  }
820
834
  if (header.fps !== undefined) head.fps = header.fps;
821
835
  head.referenceScale = model.referenceScale;
package/src/ingest.ts CHANGED
@@ -209,6 +209,18 @@ export interface IngestOptions {
209
209
  * refuses it rather than answering it quietly.
210
210
  */
211
211
  stage?: IngestStage;
212
+ /**
213
+ * The stage a rigc build's model document states (issue #907): its `stage`,
214
+ * or `null` for a rig that declared none. Given, it is read IN PLACE OF the
215
+ * header's `x`, `y`, `width`, `height` — which on a rigc build since #907 are
216
+ * the setup-pose bounding box, not the stage — and everything below reads it
217
+ * as it would read a header stating it (`--stage` beside it is the same
218
+ * refusal). Absent, the header is read, as for an export: an export carries
219
+ * no other box. The caller reads it — `src/` reads no files —
220
+ * (`documentStageBeside` in `src/cli/shared.ts`: the `rigc-compiled/3`
221
+ * document beside the skeleton, only when its digest is that skeleton's).
222
+ */
223
+ documentStage?: IngestStage | null;
212
224
  /** The source file's basename, for the provenance note. No path: no leak. */
213
225
  source: string;
214
226
  /** rigc's own version, for the provenance note. Passed in — `src/` reads no files. */
@@ -1149,9 +1161,9 @@ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): st
1149
1161
  /**
1150
1162
  * The rig spec's `skeleton` block — and the one judgement in this module.
1151
1163
  *
1152
- * 🚨 **A skeleton JSON need not carry the stage, and it cannot be derived.**
1153
- * Posing the rig gives the ANIMATED extent, which is a different number from the
1154
- * editor's setup box. So a file that declares none is written as declaring none
1164
+ * 🚨 **A skeleton JSON need not carry a box, and a stage cannot be derived.**
1165
+ * The stage is the working area the art was painted in; no pose of the rig
1166
+ * states it. So a file that declares none is written as declaring none
1155
1167
  * — `"width": null, "height": null`, the rig spec's spelling for that claim since
1156
1168
  * issue #578 — and `compile` then emits a header with none of the four fields,
1157
1169
  * which is the file that was read, byte for byte. No finding: nothing was lost,
@@ -1173,9 +1185,22 @@ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): st
1173
1185
  * that shape; the blocker names the fields it does state.
1174
1186
  *
1175
1187
  * ⭐ It is still the judgement that costs least to get wrong. `diff` does report
1176
- * the box — `stage_present` and `stage_box`, since issue #578 — but they sit in
1177
- * the `(reported)` block that no rung consults, so a deliberately absurd unit box
1178
- * is green everywhere a candidate is scored.
1188
+ * the header's box — `bounds_present` and `bounds_box` (issue #578, renamed by
1189
+ * #907) — but they sit in the `(reported)` block that no rung consults.
1190
+ *
1191
+ * 🔁 **What the box becomes (issue #907).** A header's box is its setup-pose
1192
+ * bounding box — what the format says the four are, and what every editor
1193
+ * export carries there. It is the only box a file has, so it becomes the
1194
+ * rebuild's stage (`A14` and `A19` measure against it, as they did against the
1195
+ * export's header). The rebuild's own header is not carried from it: `build`
1196
+ * computes the setup-pose bounding box of what it draws (`headerBoundsOf` in
1197
+ * `src/compile.ts`), which for a rebuild drawing the source's vertices is
1198
+ * spine-core's `getBounds` over the source on the header's 1e-6 grid at float32 — not the editor's
1199
+ * arithmetic, which on the twelve examples sits up to 0.0071 units away. A
1200
+ * rigc build's own header is likewise its bounding box, not its stage: the
1201
+ * stage is in `skeleton.model.json`, which the caller reads and hands in as
1202
+ * `documentStage` when that document digests this skeleton — and then it is
1203
+ * read in place of the header's box.
1179
1204
  *
1180
1205
  * 🔇 **Both of its silences were here, and both were around the DECLARED branch
1181
1206
  * rather than the missing one.** That branch used to be a bare early return, so
@@ -1186,7 +1211,10 @@ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): st
1186
1211
  * a decompiler that is right for a reason it never states is a decompiler nobody
1187
1212
  * can check.
1188
1213
  */
1189
- function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonObject {
1214
+ function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note): JsonObject {
1215
+ // A rigc build's stage is its model document's (issue #907, `IngestOptions.documentStage`): read in place of the header's box.
1216
+ const head: JsonObject = opts.documentStage === undefined ? source : Object.fromEntries(Object.entries(source).filter(([key]) => !(STAGE_FIELDS as readonly string[]).includes(key)));
1217
+ if (opts.documentStage) Object.assign(head, opts.documentStage);
1190
1218
  // 🚨 Before a line of transcription, because a header the caller contradicted
1191
1219
  // is not a header to start writing a spec from (issue #626).
1192
1220
  if (declaresStage(head) && opts.stage !== undefined) {
@@ -1243,9 +1271,10 @@ function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonOb
1243
1271
  `skeleton.${omitted.join('/')}`,
1244
1272
  `the source declares a ${String(head.width)}x${String(head.height)} stage and omits ` +
1245
1273
  `${omitted.map((field) => `"${field}"`).join(' and ')}; inside a declared extent an omitted origin is 0, ` +
1246
- 'which is what `compile` emits and what `diff` compares (#620), so the rig spec states x=' +
1274
+ 'which is how `compile` and `diff` read it (#620), so the rig spec states x=' +
1247
1275
  `${String(out.x)}, y=${String(out.y)} rather than leaving the rebuild to a default in another module. ` +
1248
- `⚠️ The rebuild WILL spell ${omitted.length > 1 ? 'those fields' : 'that field'}: same box, different bytes`,
1276
+ `⚠️ The rebuilt header WILL spell ${omitted.length > 1 ? 'those fields' : 'that field'}: it carries the ` +
1277
+ 'setup-pose bounding box `build` computes (#907), whose origin is wherever the art sits',
1249
1278
  );
1250
1279
  }
1251
1280
  return out;
package/src/model.ts CHANGED
@@ -572,18 +572,21 @@ export interface CompiledAnimation {
572
572
  }
573
573
 
574
574
  /**
575
- * The setup-pose box the skeleton declares (issue #1026): its origin `x`, `y`
576
- * and its extent `width`, `height`, exactly as the Spine header states them —
577
- * the rig spec's `skeleton.width`/`height` (or the manifest's crop), and its
578
- * `x`/`y` or 0 beside them, which is what the header has always written
579
- * (issue #578: four fields or none). `null` where the rig declares no stage.
575
+ * The stage the skeleton declares (issue #1026): its origin `x`, `y` and its
576
+ * extent `width`, `height` — the rig spec's `skeleton.width`/`height` (or the
577
+ * manifest's crop), and its `x`/`y` or 0 beside them (issue #578: four fields
578
+ * or none). `null` where the rig declares no stage.
580
579
  *
581
580
  * ⭐ **Why the model holds it.** `render` and `check` say whether a candidate
582
581
  * declares a stage (issue #714), and `A14`/`A19` read its box; until this field
583
582
  * the header of `skeleton.json` was the only place the value was written, so a
584
- * reader of the document had to open the Spine file beside it. The Spine
585
- * emitter writes this value into the header (`emitSkeleton`), as it writes
586
- * `referenceScale` (issue #958): one value, read by both.
583
+ * reader of the document had to open the Spine file beside it.
584
+ *
585
+ * 🔁 **And since issue #907 this is the only place it is written.** The Spine
586
+ * emitter used to copy it into the header's `x`, `y`, `width`, `height`,
587
+ * which the format defines as the setup-pose bounding box; the header now
588
+ * carries that box (`headerBoundsOf` in `src/compile.ts`), and every reader of
589
+ * the stage — `A14`, `A19`, `explain` — reads it here.
587
590
  */
588
591
  export interface ModelStage {
589
592
  x: number;
@@ -626,8 +629,9 @@ export interface ModelEditorOrder {
626
629
 
627
630
  export interface CompiledModel extends CarriedFromCompileResult {
628
631
  /**
629
- * The setup-pose box the skeleton declares, or `null` (issue #1026,
630
- * `ModelStage`). The Spine header carries it as `x`, `y`, `width`, `height`.
632
+ * The stage the skeleton declares, or `null` (issue #1026, `ModelStage`).
633
+ * Not in the Spine header since issue #907, which carries the setup-pose
634
+ * bounding box there.
631
635
  */
632
636
  stage: ModelStage | null;
633
637
  /**
package/src/rig.ts CHANGED
@@ -73,7 +73,14 @@ export const RIG_SPEC_VERSION = 'rigc-rig/1';
73
73
  // ---------------------------------------------------------------------------
74
74
 
75
75
  /**
76
- * The setup-pose bounding box and the runtime hints, all optional.
76
+ * The stage and the runtime hints, all optional.
77
+ *
78
+ * 🔁 The four box fields here are rigc's **stage** — the working area the art
79
+ * was painted in, the frame the coordinate transform and `A14`/`A19` read —
80
+ * and not the Spine header's box of the same names, which the format defines
81
+ * as the setup-pose bounding box. Since issue #907 `build` writes that box
82
+ * into the header, computed from the rig (`headerBoundsOf` in
83
+ * `src/compile.ts`), and states the stage in `skeleton.model.json`.
77
84
  *
78
85
  * `x`/`y` default to 0 and `width`/`height` fall back to the cut manifest's crop
79
86
  * when there is one. With neither a manifest nor a declaration here the compile
@@ -85,7 +92,7 @@ export const RIG_SPEC_VERSION = 'rigc-rig/1';
85
92
  * stage** (issue #578). Omitting them is silence and stays a refusal by name;
86
93
  * stating them `null` is a claim, and the emitted header then carries none of
87
94
  * `x`/`y`/`width`/`height` — which is what an editor export of a skeleton whose
88
- * stage was never set looks like, and what a transcriber of one has to be able
95
+ * bounds were never set looks like, and what a transcriber of one has to be able
89
96
  * to write down. `null` is this spec's spelling for a stated absence everywhere
90
97
  * else it has one (`RigSlot.attachment` = "show nothing", the cut manifest's
91
98
  * `image` = "this cut does not carry the part"), so it is the spelling here too
package/src/types.ts CHANGED
@@ -1167,10 +1167,12 @@ export interface SpineSkeletonJson {
1167
1167
  skeleton: {
1168
1168
  spine: string;
1169
1169
  /**
1170
- * The setup-pose bounding box. All four together or none of them: a rig spec
1170
+ * The setup-pose bounding box — and since issue #907 that is what `build`
1171
+ * writes here, computed by rigc's core (`headerBoundsOf` in `compile.ts`);
1172
+ * it used to copy the stage. All four together or none of them: a rig spec
1171
1173
  * that declares no stage (`skeleton.width`/`height` stated `null` — see
1172
1174
  * `RigSkeletonHeader`) emits a header without any of them, which is what an
1173
- * export of a skeleton whose stage was never set carries (issue #578).
1175
+ * export of a skeleton whose bounds were never set carries (issue #578).
1174
1176
  *
1175
1177
  * ⚠️ Optional here because the *runtime* leaves them `undefined` when they
1176
1178
  * are absent, not 0. `SkeletonData` declares `x = 0 … height = 0`
package/src/validate.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  type SpineGeneration,
69
69
  } from './generation.ts';
70
70
  import { posedNumbersOf } from './render.ts';
71
+ import { MODEL_DOCUMENT_SPEC } from './model.ts';
71
72
  import { BONE_INHERIT_KNOWN } from './rig.ts';
72
73
  import {
73
74
  CHANNELS_BY_KIND,
@@ -860,7 +861,7 @@ export function spineSkinEntries(data: ReturnType<SkeletonJson['readSkeletonData
860
861
  * line would hide a difference (an order nothing failed on, a name no line
861
862
  * printed); `validate()` itself builds them from its own round trip.
862
863
  */
863
- export function runtimeFacts(skeletonText: string, atlasText: string): {
864
+ export function runtimeFacts(skeletonText: string, atlasText: string, modelText?: string): {
864
865
  skinEntries: SkinEntryFacts;
865
866
  atlasPages: AtlasPageFacts;
866
867
  slotColour: SlotColourFacts;
@@ -890,7 +891,7 @@ export function runtimeFacts(skeletonText: string, atlasText: string): {
890
891
  skinMembers: data,
891
892
  regionJoins: spineRegionJoins(atlasText, raw),
892
893
  atlasRegions: { atlas },
893
- stage: spineStage(data),
894
+ stage: spineStage(data, modelText),
894
895
  skeletonRoster: rawSkeletonRoster(raw),
895
896
  boneTimelines: rawBoneTimelines(raw),
896
897
  eventKeys: rawEventKeys(raw),
@@ -1231,9 +1232,42 @@ export function spineAnimatedBones(raw: Json | null): AnimatedBoneFacts {
1231
1232
  };
1232
1233
  }
1233
1234
 
1234
- /** The runtime's supply of `StageFacts` (issue #1025, cut 4c-1): the loaded skeleton's `width` and `height`, as spine-core holds them — `undefined` where the header states none — or both absent with no skeleton. */
1235
- export function spineStage(data: ReturnType<SkeletonJson['readSkeletonData']> | null): StageFacts {
1236
- return { width: data?.width, height: data?.height };
1235
+ /**
1236
+ * The runtime's supply of `StageFacts` (issue #1025, cut 4c-1): the stage A14
1237
+ * and A19 measure against.
1238
+ *
1239
+ * 🔸 **A rigc build's stage is its model document's, not its header's**
1240
+ * (issue #907). Since then the header of a rigc build carries the setup-pose
1241
+ * bounding box — what the format says it is — and the stage, the working
1242
+ * area the art was painted in, is stated by the `rigc-compiled/3` document
1243
+ * written beside the pair (`stage`, issue #1026). So where the caller hands a
1244
+ * `/3` document (`modelText`), its `stage` is read: the two extents, or both
1245
+ * `undefined` where it states `null`. Everywhere else — an editor export, a
1246
+ * bare directory with no document, a `/2` or `/1` document, written when the
1247
+ * header still was the stage — it is the loaded skeleton's `width` and
1248
+ * `height`, as spine-core holds them (`undefined` where the header states
1249
+ * none), or both absent with no skeleton. An export carries no other box, so
1250
+ * on an export the rules measure against its bounding box, as they always
1251
+ * did.
1252
+ */
1253
+ export function spineStage(data: ReturnType<SkeletonJson['readSkeletonData']> | null, modelText?: string): StageFacts {
1254
+ return documentStage(modelText) ?? { width: data?.width, height: data?.height };
1255
+ }
1256
+
1257
+ /** The stage a `rigc-compiled/3` document states, or `undefined` where there is no such document (`spineStage`). */
1258
+ function documentStage(modelText: string | undefined): StageFacts | undefined {
1259
+ if (modelText === undefined) return undefined;
1260
+ let doc: unknown;
1261
+ try {
1262
+ doc = JSON.parse(modelText);
1263
+ } catch {
1264
+ return undefined;
1265
+ }
1266
+ if (!isObj(doc) || doc.spec !== MODEL_DOCUMENT_SPEC || !('stage' in doc)) return undefined;
1267
+ const stage = doc.stage;
1268
+ if (stage === null) return { width: undefined, height: undefined };
1269
+ if (isObj(stage) && typeof stage.width === 'number' && typeof stage.height === 'number') return { width: stage.width, height: stage.height };
1270
+ return undefined;
1237
1271
  }
1238
1272
 
1239
1273
  /**
@@ -2556,7 +2590,7 @@ export function validate(input: ValidateInput): ValidateReport {
2556
2590
  // somebody else's canvas. A rig that declares no budget has nothing to be
2557
2591
  // measured against, and the assertion says so instead of inventing a wall.
2558
2592
  check('A13_MESH_BUDGET', () => a13MeshBudget(verdicts, loadedMeshFacts(), input));
2559
- check('A14_NO_FULL_FRAME_MESH', () => a14NoFullFrameMesh(verdicts, loadedMeshFacts(), spineStage(data)));
2593
+ check('A14_NO_FULL_FRAME_MESH', () => a14NoFullFrameMesh(verdicts, loadedMeshFacts(), spineStage(data, input.modelText)));
2560
2594
 
2561
2595
  // --- A15: idle must not key a mesh-driving bone (dirty-skip lever) -----
2562
2596
  //
@@ -3200,7 +3234,7 @@ export function validate(input: ValidateInput): ValidateReport {
3200
3234
  // fires, and it now has to name the profile it belongs to as well as the one
3201
3235
  // that does not ask — the reader opted in, and the message is where they find
3202
3236
  // out what they opted into.
3203
- check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => a19OverlayPngsHaveAlpha(verdicts, { atlas }, spineStage(skeletonData), { regionAttachments }, input));
3237
+ check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => a19OverlayPngsHaveAlpha(verdicts, { atlas }, spineStage(skeletonData, input.modelText), { regionAttachments }, input));
3204
3238
  // Two regions on one page whose rectangles overlap and whose footprints do
3205
3239
  // too — a mesh's hull, else the rectangle (issue #1099). The clause A06 held
3206
3240
  // about two rectangles over the same texels, read over what each region
@@ -623,7 +623,7 @@ export function diffSummaryLines(reportPath: string): string[] {
623
623
  }
624
624
  if (headerMeasures.length === 0) {
625
625
  lines.push(
626
- ' the report carries no `skeleton` header block, so the stage was never compared — the `rigc` that wrote ' +
626
+ ' the report carries no `skeleton` header block, so the header\'s box was never compared — the `rigc` that wrote ' +
627
627
  'it predates the header measures (issue #578), and an absent block must not read here like one that is ' +
628
628
  'all 1.000',
629
629
  );