spine-rigc 0.22.2 → 0.24.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/atlas.ts CHANGED
@@ -115,9 +115,10 @@ export interface AtlasRegion {
115
115
  *
116
116
  * ⚠️ Not the page footprint. At `rotate: 90` / `270` the rectangle on the page
117
117
  * is `height x width`; `TextureAtlas` transposes for `u2/v2` at 90 and not at
118
- * 270, which is a bug in the runtime and the reason `windowOf` in
119
- * [`src/render.ts`](render.ts) derives the rectangle rather than reading those
120
- * two numbers. This field is the atlas's own meaning of `bounds`, untouched.
118
+ * 270, which is a bug in the runtime and the reason every reader here derives
119
+ * the rectangle rather than reading those two numbers. `pageFootprint` below
120
+ * is that derivation, spelled once. This field is the atlas's own meaning of
121
+ * `bounds`, untouched.
121
122
  */
122
123
  width: number;
123
124
  height: number;
@@ -174,6 +175,61 @@ export interface ParsedAtlas {
174
175
  lines: string[];
175
176
  }
176
177
 
178
+ /**
179
+ * The rectangle a region occupies **on its page** — which is not the rectangle
180
+ * its `bounds:` line states.
181
+ *
182
+ * `bounds:` is the kept rectangle in the DRAWING's orientation, so a packer that
183
+ * turned the drawing a quarter turn to fit it wrote `width x height` for a
184
+ * region that covers `height x width` of the page. 180 turns nothing: the
185
+ * footprint is the drawing's own way round at 0 and at 180, and transposed at 90
186
+ * and at 270.
187
+ *
188
+ * ## Why this is one function and was four (issue #579)
189
+ *
190
+ * Four readers in this tree want exactly this rectangle — `windowOf` in
191
+ * [`src/render.ts`](render.ts) fences a substituted piece with it,
192
+ * `resolveFromAtlas` in [`src/compile.ts`](compile.ts) refuses a region that
193
+ * runs off its page by it, and `A06` and `A19` in [`src/validate.ts`](validate.ts)
194
+ * measure a shared page's tiling and open one region's own texels with it — and
195
+ * two of the four transposed at 90 **only**, under a comment that read *"spine-core
196
+ * transposes a region's extent at 90 and not at 270 when it derives the UVs, so
197
+ * the rectangle ON THE PAGE follows the same rule"*.
198
+ *
199
+ * The premise is true and the conclusion does not follow, because the two are
200
+ * different quantities:
201
+ *
202
+ * * what `TextureAtlas` transposes at 90 and not at 270 is `u2`/`v2`
203
+ * (spine-core 4.3.13, `dist/TextureAtlas.js:162-171`) — so at 270 those two
204
+ * numbers do describe a rectangle the page does not have;
205
+ * * but `MeshAttachment.computeUVs` (`dist/attachments/MeshAttachment.js:118-162`)
206
+ * never reads `u2`/`v2` for an atlas region. It branches on `degrees` and
207
+ * derives the span from `originalWidth`/`originalHeight`, transposed at 90
208
+ * **and** at 270 alike. That is the routine that says where a region's texels
209
+ * are, and `extractRegion` below is its inverse.
210
+ *
211
+ * ⚠️ The consequence was not confined to a printed number, which is what the
212
+ * card assumed. A19 opens a shared page and scans one region's own rectangle for
213
+ * a transparent texel: at 270 it scanned `width x height` where the drawing
214
+ * occupies `height x width`, ran off the part into the transparent gutter, found
215
+ * its texel there and **named nothing**. Two fully opaque parts on one turned
216
+ * page — the exact defect A19 exists for — were measured green at `rotate: 270`
217
+ * and red at 0, 90 and 180.
218
+ *
219
+ * Structurally typed rather than taking `AtlasRegion`, because two of the four
220
+ * callers hold spine-core's `TextureAtlasRegion` instead and this file
221
+ * deliberately does not link the runtime.
222
+ */
223
+ export function pageFootprint(region: { width: number; height: number; degrees: number }): {
224
+ width: number;
225
+ height: number;
226
+ } {
227
+ const turned = region.degrees === 90 || region.degrees === 270;
228
+ return turned
229
+ ? { width: region.height, height: region.width }
230
+ : { width: region.width, height: region.height };
231
+ }
232
+
177
233
  /**
178
234
  * `TextureAtlasReader.readEntry`, to the value.
179
235
  *
@@ -413,8 +469,27 @@ export const PACK_NO_ROTATE = 0;
413
469
  * The unpacked default goes through here too (`buildAtlasText` builds one page
414
470
  * per image and calls this), which is what makes "the defaults change nothing" a
415
471
  * property of one function instead of a promise made by two.
472
+ *
473
+ * ⭐ **No pages is the empty FILE, not a blank line** (issue #608). A compile that
474
+ * measured no art — a rig whose skins fill no slot with anything that needs a
475
+ * page — used to come out of here as `"\n"`, because `[].join('\n')` is `''` and
476
+ * the trailing newline was appended unconditionally. That one byte contradicts
477
+ * the paragraph above it: a blank line is the separator that sits BETWEEN page
478
+ * blocks, so a file consisting of one is a separator with nothing on either side.
479
+ * `A07_ATLAS_TEXT_SHAPE` then read it as a malformed page block and refused the
480
+ * compiler's own output, which is how a rig with nothing to draw became a red
481
+ * gate on a file nobody had written wrong.
482
+ *
483
+ * The runtime cannot tell the two apart — `new TextureAtlas('')`,
484
+ * `new TextureAtlas('\n')` and `new TextureAtlas('\n\n')` all come back with
485
+ * `pages.length === 0` and `regions.length === 0`, and the constructor
486
+ * (`TextureAtlas.js:97-174`) has no `throw` in it at all — so the runtime is no
487
+ * help in choosing, and the choice is made on what the text SAYS. Zero bytes has
488
+ * exactly one reading; a blank line has two, and the wrong one is the one A07 was
489
+ * built to catch.
416
490
  */
417
491
  export function writeAtlasText(pages: EmitPage[]): string {
492
+ if (pages.length === 0) return '';
418
493
  const lines: string[] = [];
419
494
  pages.forEach((page, i) => {
420
495
  if (i > 0) lines.push(''); // exactly one blank line BETWEEN pages
@@ -919,25 +994,69 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
919
994
  * a plate's rows run downwards, so the kept rectangle's top row is
920
995
  * `originalHeight - offsetY - height`.
921
996
  *
922
- * ⛔ A rotated region is refused rather than guessed. `TextureAtlas` transposes
923
- * `u2/v2` at 90 and not at 270, and `RegionAttachment.computeUVs` assigns a
924
- * different corner order at 90 — there are already three opinions in the runtime
925
- * about that mapping and this file is not going to be a fourth. rigc's own packer
926
- * never rotates (`PACK_NO_ROTATE`), so only a foreign atlas can reach this.
997
+ * ## A rotated region is TRANSCRIBED, not guessed at (issue #570)
998
+ *
999
+ * This refused a rotated region until 2026-09-17, on the argument that the
1000
+ * runtime holds "three opinions" about the mapping. Measurement refutes the
1001
+ * argument: the three are not three readings of one mapping, they are one
1002
+ * mapping and two places that do not implement it.
1003
+ *
1004
+ * * `MeshAttachment.computeUVs` (spine-core 4.3.13,
1005
+ * `dist/attachments/MeshAttachment.js:126-162`) is the one routine that
1006
+ * states where a region's texels are for **all four** `degrees`, and it is
1007
+ * the routine `substituteTexture` in [`src/render.ts`](render.ts) already
1008
+ * goes through. The loop below is its inverse, term for term;
1009
+ * * `TextureAtlas`'s `u2`/`v2` (`dist/TextureAtlas.js:164-171`) transpose the
1010
+ * rectangle at 90 and not at 270, so at 270 they describe a rectangle the
1011
+ * page does not have — but `MeshAttachment.computeUVs` never reads them for
1012
+ * an atlas region, and neither does this;
1013
+ * * `RegionAttachment.computeUVs` (`dist/attachments/RegionAttachment.js:156-167`)
1014
+ * assigns the turned corner order at 90 and at nothing else, which is a
1015
+ * region-attachment rendering defect (issue #199) and not a statement about
1016
+ * where the drawing sits.
1017
+ *
1018
+ * Inverting the runtime's own expression on texel centres puts kept-rectangle
1019
+ * pixel `(x, y)` — `x` from the drawing's left, `y` down from `top` — at page
1020
+ * pixel `(X + x, Y + y)` unturned, `(X + y, Y + width - 1 - x)` at 90,
1021
+ * `(X + width - 1 - x, Y + height - 1 - y)` at 180 and
1022
+ * `(X + height - 1 - y, Y + x)` at 270, writing `X`/`Y` for the region's own
1023
+ * `x`/`y`; the packed footprint is `height x width` for the two quarter turns
1024
+ * and `width x height` for the other two. `repackRotatedTrimmed` in
1025
+ * `selftest.ts` derived the same 270 mapping for issue #199's fixture, and had
1026
+ * been shipping it green, while this comment claimed the mapping was unknowable.
1027
+ *
1028
+ * ⚠️ Any other `degrees` takes the unturned branch, because that is what the
1029
+ * runtime does with it: `regionFields.rotate` (`dist/TextureAtlas.js:87-93`)
1030
+ * `parseInt`s the value without checking it, and `computeUVs` falls to
1031
+ * `default:` for everything that is not 90, 180 or 270. Reading such a region
1032
+ * unturned is not a guess, it is agreement with the thing that will draw it.
1033
+ *
1034
+ * rigc's own packer still never rotates (`PACK_NO_ROTATE`), so only a foreign
1035
+ * atlas reaches any branch but the first.
927
1036
  */
928
1037
  export function extractRegion(page: Plate, region: AtlasRegion): Plate {
929
- if (region.degrees !== 0) {
930
- throw new CompileError(
931
- `region "${region.name.trim()}" is packed rotate: ${region.degrees}; reading a drawing back off a rotated ` +
932
- 'region is not implemented — rigc\'s own packer never rotates, so this is a foreign pack. Supply the loose ' +
933
- 'PNG instead of --atlas-in for the part that needs measuring.',
934
- );
935
- }
936
1038
  const out = new Plate(region.originalWidth, region.originalHeight);
937
1039
  const top = region.originalHeight - region.offsetY - region.height;
1040
+ const { degrees } = region;
938
1041
  for (let y = 0; y < region.height; y++) {
939
1042
  for (let x = 0; x < region.width; x++) {
940
- out.set(region.offsetX + x, top + y, page.get(region.x + x, region.y + y));
1043
+ const px =
1044
+ degrees === 90
1045
+ ? region.x + y
1046
+ : degrees === 180
1047
+ ? region.x + region.width - 1 - x
1048
+ : degrees === 270
1049
+ ? region.x + region.height - 1 - y
1050
+ : region.x + x;
1051
+ const py =
1052
+ degrees === 90
1053
+ ? region.y + region.width - 1 - x
1054
+ : degrees === 180
1055
+ ? region.y + region.height - 1 - y
1056
+ : degrees === 270
1057
+ ? region.y + x
1058
+ : region.y + y;
1059
+ out.set(region.offsetX + x, top + y, page.get(px, py));
941
1060
  }
942
1061
  }
943
1062
  return out;
package/src/check.ts CHANGED
@@ -863,6 +863,24 @@ export interface CheckReport {
863
863
  candidate: { skeleton: string; atlas: string };
864
864
  framesDir: string;
865
865
  framesRoot: string;
866
+ /**
867
+ * The skin the CANDIDATE was posed under, or `null` for no skin at all.
868
+ *
869
+ * ⭐ In the report rather than only in the run's arguments because a figure is
870
+ * only readable beside what produced it: on a multi-skin rig the same
871
+ * candidate and the same frames give a different number per skin, and a
872
+ * `check.json` that did not say which one it was is a number with no subject
873
+ * (issue #571).
874
+ */
875
+ skin: string | null;
876
+ /**
877
+ * The skin `frames.json` records for the reference frames, or `null`.
878
+ *
879
+ * `null` covers two facts that are the same on disk — the frames set no skin,
880
+ * and the frames were rendered before the field existed — which is why a
881
+ * mismatch against it is refused and an absence is only noted. See `notes`.
882
+ */
883
+ referenceSkin: string | null;
866
884
  /** One framing per set, or one across every set — see `FramingScope`. */
867
885
  framingScope: FramingScope;
868
886
  /**
@@ -947,6 +965,17 @@ export interface CheckOptions {
947
965
  viewport?: { x: number; y: number; width: number; height: number };
948
966
  /** Play this candidate animation against the frames, when the names differ. */
949
967
  as?: string;
968
+ /**
969
+ * Pose the candidate under this skin — see `PoseOptions.skin` (issue #571).
970
+ *
971
+ * Absent sets no skin, which resolves every slot through the default skin
972
+ * alone: on a multi-skin rig that draws none of the art the named skins carry,
973
+ * and a `check` of it compares blank against blank and reads 0.0000. A name
974
+ * the candidate does not declare is refused with the ones it does, and the
975
+ * reference frames' own recorded skin is checked against this — see the
976
+ * `referenceSkin` field of `CheckReport` for what an absent record means.
977
+ */
978
+ skin?: string;
950
979
  /**
951
980
  * Fit one framing per frame set, or one across every set compared.
952
981
  *
@@ -1026,7 +1055,20 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1026
1055
  const substitution = options.textureFrom
1027
1056
  ? textureSubstitutionFromText(options.textureFrom.atlasText, options.textureFrom.atlasDir)
1028
1057
  : null;
1029
- const poseOptions: PoseOptions | undefined = substitution ? { texture: true } : undefined;
1058
+ // The skin is refused here rather than deeper in the sampler, for the reason
1059
+ // every miss in this project is refused where the names are: the skeleton is
1060
+ // open on this line and the alternatives can be listed.
1061
+ if (options.skin !== undefined && !posable.data.skins.some((s) => s.name === options.skin)) {
1062
+ throw new CheckError(
1063
+ `the candidate declares no skin ${JSON.stringify(options.skin)}; it declares [${
1064
+ posable.data.skins.map((s) => s.name).join(', ') || 'none'
1065
+ }]`,
1066
+ );
1067
+ }
1068
+ const poseOptions: PoseOptions | undefined =
1069
+ substitution || options.skin !== undefined
1070
+ ? { ...(substitution ? { texture: true } : {}), ...(options.skin === undefined ? {} : { skin: options.skin }) }
1071
+ : undefined;
1030
1072
  let background: RGBA;
1031
1073
  let sets: FrameSet[];
1032
1074
  let pixelWidth: number;
@@ -1086,6 +1128,35 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1086
1128
 
1087
1129
  if (sets.length === 0) throw new CheckError(`no frame set to compare in ${options.framesDir}`);
1088
1130
 
1131
+ // --- the skin the frames were rendered under, against the one asked for ----
1132
+ //
1133
+ // ⭐ The asymmetry is the honest part (issue #571). A sidecar that RECORDS a
1134
+ // skin is a claim, and a claim that disagrees is refused by name; a sidecar
1135
+ // that records none is making no claim at all — it either set no skin or was
1136
+ // written before the field existed, and those are the same bytes — so the run
1137
+ // proceeds and says out loud that nothing checked it. Inventing a refusal out
1138
+ // of an absent field would refuse every frame set in this repository.
1139
+ const referenceSkin = located.sidecar?.skin ?? null;
1140
+ if (referenceSkin !== null && referenceSkin !== options.skin) {
1141
+ throw new CheckError(
1142
+ `${FRAMES_SIDECAR} records that these frames were rendered under skin ${JSON.stringify(referenceSkin)}, and ` +
1143
+ `this run poses the candidate ${
1144
+ options.skin === undefined
1145
+ ? 'under no skin at all (the default skin alone)'
1146
+ : `under skin ${JSON.stringify(options.skin)}`
1147
+ }. Two skins are two different pictures of one rig, so the comparison would be a number about the ` +
1148
+ `difference between them. Pass --skin ${JSON.stringify(referenceSkin)}, or render the reference frames ` +
1149
+ `${options.skin === undefined ? 'with no --skin' : `with --skin ${JSON.stringify(options.skin)}`}.`,
1150
+ );
1151
+ }
1152
+ if (referenceSkin === null && options.skin !== undefined) {
1153
+ notes.push(
1154
+ `the candidate is posed under skin ${JSON.stringify(options.skin)} and the reference frames record no skin ` +
1155
+ `at all, so nothing here could check that they are the same picture. A frame set rendered by \`rigc ` +
1156
+ `render --skin\` since #571 carries the name in ${FRAMES_SIDECAR} and this run would have compared it.`,
1157
+ );
1158
+ }
1159
+
1089
1160
  // Pose every set once. Its frames are wanted twice — to frame the candidate and
1090
1161
  // to compare it — and posing twice is both slower and a chance for the framing
1091
1162
  // and the comparison to disagree about what they measured.
@@ -1347,6 +1418,8 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1347
1418
  },
1348
1419
  framesDir: resolve(options.framesDir),
1349
1420
  framesRoot: located.root,
1421
+ skin: options.skin ?? null,
1422
+ referenceSkin,
1350
1423
  framingScope: scope,
1351
1424
  framing: topHow,
1352
1425
  viewport: topViewport === null ? null : framingOfViewport(topViewport),
@@ -3117,6 +3190,15 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
3117
3190
  lines.push(` candidate ${report.candidate.skeleton}`);
3118
3191
  lines.push(` atlas ${report.candidate.atlas}`);
3119
3192
  lines.push(` frames ${report.framesDir}`);
3193
+ // Always printed, on both sides, because the reading a reader has to be able
3194
+ // to make is "which picture of this rig is this" — and a line that appears
3195
+ // only when a skin was named cannot say that the run used none (issue #571).
3196
+ lines.push(
3197
+ ` skin candidate ${report.skin === null ? 'no skin set (the default skin alone)' : report.skin} ` +
3198
+ `frames ${
3199
+ report.referenceSkin === null ? `no skin recorded in ${FRAMES_SIDECAR}` : report.referenceSkin
3200
+ }`,
3201
+ );
3120
3202
  lines.push(
3121
3203
  ` scope ${
3122
3204
  report.framingScope === 'per-shot'