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/README.md +80 -1
- package/cli.ts +263 -13
- package/docs/AUTHORING.md +554 -75
- package/docs/INGEST.md +238 -44
- package/docs/SPEC_COVERAGE.md +14 -3
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +33 -11
- package/src/atlas.ts +135 -16
- package/src/check.ts +83 -1
- package/src/compile.ts +402 -74
- package/src/deformmeasure.ts +322 -151
- package/src/diff.ts +125 -2
- package/src/ingest.ts +1137 -0
- package/src/render.ts +113 -17
- package/src/rig.ts +92 -8
- package/src/timelines.ts +163 -0
- package/src/types.ts +74 -9
- package/src/validate.ts +765 -92
- package/tools/editor_roundtrip.ts +247 -36
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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'
|