spine-rigc 1.2.0 β†’ 1.2.2

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 CHANGED
@@ -692,6 +692,7 @@ letting `A17` blame the editor for the harness's own doing.
692
692
  | πŸ“‹ [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) Β· [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) Β· [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
693
693
  | πŸ”¬ [SURVEY_2026-08-22.md](https://github.com/firejune/rigc/blob/main/docs/SURVEY_2026-08-22.md) | the survey the ladder was climbed by: rigc and the official examples as measured on 2026-08-22, and the gap list ordered by rung. A dated record, not kept current. Repository material |
694
694
  | 🧬 [GENERATIONS.md](https://github.com/firejune/rigc/blob/main/docs/GENERATIONS.md) | **Spine data from another generation.** Why a 3.8–4.2 file read as 4.3 fails in silence, the policy that follows (detect from `skeleton.spine`, never guess, play on the matching runtime), what `A16` and `ingest` do with such a file, and the editor as the migration path. Repository material |
695
+ | πŸ““ [CASES.md](https://github.com/firejune/rigc/blob/main/docs/CASES.md) | **converting a Live2D model to Spine, and verifying the conversion.** The method β€” the source's own player headless, parts from its texture along the drawables, motion baked as deform keys, reference frames first, `check` β€” run once end to end on two Live2D sample models with no editor and no converter, and verified against the source's renderer and Spine's own runtime: the three instruments, the two rigc defects verifying it led to and v1.2.1 fixed, and where the method stops. Figures only β€” the models' licence keeps every asset out. Repository material |
695
696
  | πŸ—ΊοΈ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 had to mean before the number was claimed, and what it was claimed on β€” conditions rather than a feature list, because direction here comes from what users hit |
696
697
  | πŸ“ [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** β€” why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
697
698
 
package/docs/AUTHORING.md CHANGED
@@ -141,7 +141,12 @@ result. There is no `--no-validate`, and there will not be one.
141
141
  Green from `build` means the file is valid; it says nothing at all about whether
142
142
  the animation is the one in the frames, and there is no assertion that could β€” see
143
143
  Β§9. The two run in that order because `check` needs artifacts on disk and `build`
144
- only writes them when the gate is green.
144
+ only writes them when the gate is green. Read its report from the top, because the
145
+ framing block says whether the figures under it are about placement or motion
146
+ (Β§9.2, *Read the framing block first*), and read a figure as where the loop starts,
147
+ not where it ends: keep the first green build's figure, check every later build
148
+ against the same frames, and stop when the figure stops moving rather than when it
149
+ exists β€” against a floor measured the way Β§9.2 says.
145
150
 
146
151
  🎞️ **When the source is a foreign player β€” a Live2D model, a Unity scene, a video β€”
147
152
  make the reference frames first, from the source.** rigc reads none of those
@@ -152,17 +157,25 @@ one directory of `f0000.png`, `f0001.png`… named after the candidate animation
152
157
  shows (or pair the two with `--as`): `check` accepts a set with no `frames.json`
153
158
  and takes its rate from `--fps` (Β§9). What that set cannot carry is its
154
159
  **background**, which the sidecar would have recorded β€” so render it onto an
155
- **opaque** background of the colour `check`'s no-`frames.json` note names
156
- (`232, 232, 232, 255` in this release). The content box is found against that
157
- colour and alpha is not read: measured on the 24 frames of `gallery/look`'s `turn`,
158
- the same drawn pixels read MAE mean **2.25** over that grey, **5.19** over white and
159
- **20.37** over a transparent background, where the fit took the whole 234Γ—256
160
- frame for the figure β€” all three at exit 0 with the same notes. A port with no
160
+ **opaque** background of the colour `check`'s no-`frames.json` note names, the one
161
+ after *"the background is this build's default,"*. Running `check` once on any
162
+ frame set with no sidecar prints it, so read it there before rendering rather than
163
+ from a copy here. The content box is found against that colour and alpha is not
164
+ read, so a reference that is not opaque is **refused**, naming the frames and the
165
+ first pixel whose alpha is not 255 (Β§9). A background that is opaque but another
166
+ colour is not refused and costs you: measured on the 24 frames of `gallery/look`'s
167
+ `turn`, the same drawn pixels read MAE mean **2.25** over the note's colour and
168
+ **5.19** over white, at exit 0 with the same notes. A port with no
161
169
  reference frames is **unmeasured, not finished**: green from `build` says the file
162
170
  is valid and nothing about whether it is the source's picture. And the parts are
163
171
  the source's own texture cut along its drawables, **never a screenshot of it** β€” a
164
172
  screenshot is the composed result, so a part cut from it carries every part under
165
173
  it, which is what the first of the three questions under *LOOK* below finds.
174
+ Where a part goes is the drawable's own geometry β€” its vertices in model space, its
175
+ UVs and its triangles β€” which is a mesh attachment with those three (Β§3.4, *Mesh
176
+ attachment*, where `uvs` are fractions of the region you cut rather than of the
177
+ source's page); a region at the drawable's bounding-box centre keeps only the box,
178
+ so no vertex the source moves has anywhere to go.
166
179
 
167
180
  🚨 **Read `check`'s per-frame column before its MAE.** The table's headline figures
168
181
  are the MAE and the slot drift, and a reader who came for those will skip the
@@ -214,7 +227,12 @@ What the flags mean:
214
227
 
215
228
  `render` also takes `--fps <n>` (the rate it samples at, default 12 β€” the same
216
229
  protocol rate the reference frames use) and `--max <px>` (the long side of a
217
- frame, default 256). Three commands take `--out`: a directory for `render`
230
+ frame, default 256). A clipping attachment is applied as the runtime applies it β€”
231
+ spine-core's own `SkeletonClipping`, in spine-webgl's call order β€” so a slot from
232
+ the clip's through its `end` slot draws only what falls inside the polygon, in
233
+ `render`, `check` and every other picture rigc's own rasteriser draws (`preview`
234
+ plays in Spine's player, which always clipped); the framing box still counts
235
+ what the clip removes, so adding or keying a mask moves no pixel it leaves drawn. Three commands take `--out`: a directory for `render`
218
236
  (default `render/`), the `.html` file for `preview` (default `preview.html`) and
219
237
  for `vote` (default `ballot.html`).
220
238
 
@@ -6173,6 +6191,31 @@ sampled at, and without it the 12 fps protocol rate is assumed and the report sa
6173
6191
  so rather than letting the assumption look like a measurement. Passing `--fps` with
6174
6192
  a value the sidecar contradicts is an error, not an override.
6175
6193
 
6194
+ A set with no sidecar is one of two things, and the `⚠️ no frames.json` note says
6195
+ which advice goes with which: **a rigc render older than the sidecar** is fixed by
6196
+ rendering it again with `rigc render`, and **a foreign source** β€” a Live2D, Unity or
6197
+ video player, whose frames predate nothing β€” needs the three things the sidecar
6198
+ would have said, supplied by whoever made them: an opaque background of the colour
6199
+ the note names, `--fps` at the rate it was rendered, and a directory named after
6200
+ the candidate animation it shows or `--as <that animation>`. Two of those are
6201
+ refused by name when they are wrong, because a figure would come out of either:
6202
+
6203
+ ```
6204
+ rigc check error: --frames frames/turn has no frames.json, and 24 of its 24 compared reference frame(s) are not opaque (f0000.png, f0001.png, f0002.png, and 21 more): f0000.png has alpha 0 at (0, 0), where every pixel must be 255. Without a sidecar the frames are read against the background colour 232, 232, 232, 255 with alpha unread, so a pixel that is not opaque counts by its colour bytes alone: a transparent background counts as drawn and puts the figure over the whole frame, a number about the transparent area and not the rig, which --viewport does not change. Render the frames onto an opaque background of 232, 232, 232, 255.
6205
+ rigc check error: no reference frame could be compared, so there is nothing to frame against: the frames were matched to a candidate animation by name, and the candidate has no animation called "idle" (the directory's own name) β€” it declares [sweep, tilt, turn]. Pass --as <name> with the one these frames show, or name the directory after it
6206
+ ```
6207
+
6208
+ The test is every pixel's alpha, not the border's: a pixel that is not opaque is
6209
+ not yet a colour β€” what it shows depends on what it is composited over β€” so one
6210
+ pixel of alpha 254 in one frame is refused too, naming that frame and that pixel.
6211
+ The first is a refusal rather than a note because pinning does not rescue it:
6212
+ `--viewport` set to the box the frames were drawn in reads MAE **0.00** on the
6213
+ opaque set and **32.90** on the same set made transparent, on every one of its 24
6214
+ frames β€” the transparent area times its distance from the background colour, which
6215
+ is a figure about the background and not about the rig. `--as` takes **one**
6216
+ candidate animation name; the `<candidate>=<reference>` pair is `diff`'s spelling,
6217
+ and `check` refuses it as a name the candidate does not declare.
6218
+
6176
6219
  `--viewport <x>,<y>,<width>,<height>` pins your candidate's world box instead of
6177
6220
  fitting it. Two uses:
6178
6221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Rig compiler for Spine β€” declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,7 +62,12 @@ skill the package ships there, and `rigc skills --help` says what it refuses.
62
62
  `--slot <slot,…>`), which draws the frame again without that part on the same
63
63
  grid, so the two frames say which part a pixel is. AUTHORING Β§0 holds the three.
64
64
  5. `rigc check --candidate <out> --frames <dir>` when you have reference pictures
65
- (`--out <dir>` writes the picture each of its numbers came from β€” open the worst);
65
+ (`--out <dir>` writes the picture each of its numbers came from β€” open the worst).
66
+ Read its output from the top: the framing block says whether the figures under
67
+ it are about placement or motion. A figure is where the loop starts, not where it
68
+ ends β€” keep the first green build's figure, check every later build against the
69
+ same frames, and stop when the figure stops moving, not when it exists; AUTHORING
70
+ Β§9.2 says how to read the framing block and the floor to read a figure against.
66
71
  `rigc vote --candidate <a> --candidate <b>` when several candidates are green and
67
72
  only a person can choose between them.
68
73
  6. `rigc validate <out>` re-gates artifacts already on disk, and
@@ -81,7 +86,10 @@ directory of `f0000.png`, `f0001.png`…, and `rigc check --frames <that dir> --
81
86
  reads them with no `frames.json`. A port with no reference frames is unmeasured,
82
87
  not finished. The parts are the source's own texture cut along its drawables, never
83
88
  a screenshot: a screenshot is the composed result, and a part cut from it carries
84
- every part under it. rigc reads none of those formats β€” FACE Β§11, the paragraph
89
+ every part under it. Where a part goes is its drawable's geometry β€” its vertices in
90
+ model space, its UVs, its triangles β€” which is a mesh attachment with those three
91
+ (AUTHORING Β§3.4, *Mesh attachment*); a region at the drawable's bounding-box centre
92
+ keeps only the box. rigc reads none of those formats β€” FACE Β§11, the paragraph
85
93
  that opens *No Live2D file is read or written*
86
94
  ([FACE.md](https://github.com/firejune/rigc/blob/main/docs/FACE.md#11-non-goals--stated-so-nobody-proposes-them-as-gaps)) β€”
87
95
  only the pictures they produce. The rule, the background those frames need and
package/src/check.ts CHANGED
@@ -1169,11 +1169,20 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1169
1169
  duration: disk[disk.length - 1].index / fps,
1170
1170
  },
1171
1171
  ];
1172
+ // Two sources write a set like this, and they need opposite advice (issue
1173
+ // #842): a rigc render older than the sidecar is fixed by rendering it again,
1174
+ // while a foreign player's frames predate nothing and no rigc tool renders
1175
+ // their source β€” what they need is the three things the sidecar would have
1176
+ // said, supplied by whoever made them.
1172
1177
  notes.push(
1173
- `no ${FRAMES_SIDECAR} at ${located.root} or beside it β€” this frame set predates the sidecar. The rate is ` +
1178
+ `no ${FRAMES_SIDECAR} at ${located.root} or beside it. The rate is ` +
1174
1179
  `--fps ${fps}${options.fps === undefined ? ' (the protocol default, not a measurement of these frames)' : ''} ` +
1175
- `and the background is this build's default (${BACKGROUND.join(', ')}). Re-render the set with ` +
1176
- 'bench/render_reference.ts and both become facts about the frames.',
1180
+ `and the background is this build's default, ${BACKGROUND.join(', ')}; neither is read from the frames. ` +
1181
+ 'A set with no sidecar is one of two things. A rigc render older than the sidecar: re-render it with ' +
1182
+ '`rigc render` (bench/render_reference.ts for an editor export) and both become facts about the frames. ' +
1183
+ `A foreign source β€” a Live2D, Unity or video player: render it onto an opaque background of ` +
1184
+ `${BACKGROUND.join(', ')}, pass --fps at the rate it was rendered, and name the directory after the ` +
1185
+ 'candidate animation it shows, or pass --as <that animation>.',
1177
1186
  );
1178
1187
  // The set root is the animation directory itself here, so reads resolve
1179
1188
  // against its parent the way a sidecar layout does.
@@ -1226,8 +1235,36 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1226
1235
  // `EDGE_FRACTION`. A handful of frames is enough: the level is a property of the
1227
1236
  // palette, not of a pose, and reading every frame twice to learn it is waste.
1228
1237
  const level = pairs.length === 0 ? BACKGROUND_TOLERANCE : edgeLevelOf(located.root, pairs, background);
1238
+ // Without a sidecar the background is an assumption, and it is an assumption
1239
+ // about a COLOUR: the box and the union alpha are found against it with alpha
1240
+ // unread. So a reference that is not opaque is refused rather than scored
1241
+ // (issue #842). Measured on gallery/look's `turn` with its background made
1242
+ // transparent and the candidate pinned by --viewport to the box the frames were
1243
+ // drawn in β€” where the same set over the assumed grey reads MAE 0.00 exactly β€”
1244
+ // it read 32.90 on every one of the 24 frames, which is the transparent area's
1245
+ // share of the frame times its distance from the grey: a figure about the
1246
+ // background, not the rig, and no framing moves it. A frame set WITH a sidecar
1247
+ // is `render`'s, which composites onto the colour it records, so it is not asked.
1248
+ const translucent: TranslucentFrame[] | null = located.sidecar ? null : [];
1229
1249
  const referenceBoxes =
1230
- pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
1250
+ pairs.length === 0
1251
+ ? []
1252
+ : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight, translucent);
1253
+ if (translucent !== null && translucent.length > 0) {
1254
+ const first = translucent[0];
1255
+ const shown = translucent.slice(0, TRANSLUCENT_NAMED).map((t) => basename(t.file));
1256
+ throw new CheckError(
1257
+ `--frames ${options.framesDir} has no ${FRAMES_SIDECAR}, and ${translucent.length} of its ${pairs.length} ` +
1258
+ `compared reference frame(s) are not opaque (${shown.join(', ')}${
1259
+ translucent.length > shown.length ? `, and ${translucent.length - shown.length} more` : ''
1260
+ }): ${basename(first.file)} has alpha ${first.alpha} at (${first.x}, ${first.y}), where every pixel must ` +
1261
+ `be 255. Without a sidecar the frames are read against the background colour ${BACKGROUND.join(', ')} ` +
1262
+ 'with alpha unread, so a pixel that is not opaque counts by its colour bytes alone: a transparent ' +
1263
+ 'background counts as drawn and puts the figure over the whole frame, a number about the transparent ' +
1264
+ 'area and not the rig, which --viewport does not change. Render the frames onto an opaque background of ' +
1265
+ `${BACKGROUND.join(', ')}.`,
1266
+ );
1267
+ }
1231
1268
 
1232
1269
  const scope: FramingScope = options.framing ?? 'per-shot';
1233
1270
  const slices = sliceBySet(prepared, referenceBoxes);
@@ -1287,7 +1324,14 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1287
1324
  topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
1288
1325
  }
1289
1326
  } else if (referenceBoxes.every((b) => b === null)) {
1290
- throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
1327
+ throw new CheckError(
1328
+ `no reference frame could be compared, so there is nothing to frame against${nothingToFrameWhy(
1329
+ prepared,
1330
+ posable.data.animations.map((a) => a.name),
1331
+ options.as,
1332
+ located.sidecar !== null,
1333
+ )}`,
1334
+ );
1291
1335
  } else if (scope === 'shared' || prepared.length === 1) {
1292
1336
  const framed = frameCandidate(
1293
1337
  prepared,
@@ -1725,7 +1769,38 @@ function edgeLevelOf(root: string, pairs: FramePair[], background: RGBA): number
1725
1769
  return histogram.level();
1726
1770
  }
1727
1771
 
1728
- /** Each reference frame's own content box, and a check that they are one grid. */
1772
+ /** How many translucent reference frames the refusal names before it counts the rest. */
1773
+ const TRANSLUCENT_NAMED = 3;
1774
+
1775
+ /** The first pixel of a reference frame that is not fully opaque β€” see `referenceContentBoxes`. */
1776
+ interface TranslucentFrame {
1777
+ file: string;
1778
+ x: number;
1779
+ y: number;
1780
+ alpha: number;
1781
+ }
1782
+
1783
+ /** The first pixel whose alpha is not 255, in row order, or null when the frame is opaque. */
1784
+ function firstTranslucentPixel(plate: Plate): { x: number; y: number; alpha: number } | null {
1785
+ for (let i = 3; i < plate.data.length; i += 4) {
1786
+ if (plate.data[i] !== 255) {
1787
+ const at = (i - 3) / 4;
1788
+ return { x: at % plate.width, y: Math.floor(at / plate.width), alpha: plate.data[i] };
1789
+ }
1790
+ }
1791
+ return null;
1792
+ }
1793
+
1794
+ /**
1795
+ * Each reference frame's own content box, and a check that they are one grid.
1796
+ *
1797
+ * `translucent`, when given, collects every frame that is not fully opaque. The
1798
+ * content box and the union alpha are found against the background COLOUR and
1799
+ * alpha is never read (`backgroundDistance`), so a transparent pixel is drawn to
1800
+ * both of them whatever its colour bytes say. It is collected here rather than in
1801
+ * a pass of its own because this is where every compared frame is already
1802
+ * decoded, and a frame that is not compared cannot move a figure.
1803
+ */
1729
1804
  function referenceContentBoxes(
1730
1805
  root: string,
1731
1806
  pairs: FramePair[],
@@ -1733,6 +1808,7 @@ function referenceContentBoxes(
1733
1808
  level: number,
1734
1809
  pixelWidth: number,
1735
1810
  pixelHeight: number,
1811
+ translucent: TranslucentFrame[] | null,
1736
1812
  ): Array<ContentBox | null> {
1737
1813
  return pairs.map((pair) => {
1738
1814
  const plate = readPlateFrom(root, pair.file);
@@ -1742,6 +1818,10 @@ function referenceContentBoxes(
1742
1818
  'the frames and the sidecar disagree about their own size',
1743
1819
  );
1744
1820
  }
1821
+ if (translucent !== null) {
1822
+ const at = firstTranslucentPixel(plate);
1823
+ if (at !== null) translucent.push({ file: pair.file, ...at });
1824
+ }
1745
1825
  return contentBoxOfPlate(plate, background, level);
1746
1826
  });
1747
1827
  }
@@ -2398,6 +2478,46 @@ function prepareSet(
2398
2478
  };
2399
2479
  }
2400
2480
 
2481
+ /**
2482
+ * What the "nothing to frame against" refusal adds when the sets can say why.
2483
+ *
2484
+ * The one reason a whole run has no reference box that a reader can act on is a
2485
+ * name that matched no candidate animation, and `prepareSet` already composed
2486
+ * that per set β€” then the refusal fired before any set was reported, so the one
2487
+ * sentence that named the fix never reached the reader (issue #842). This says
2488
+ * where each name came from, because the fix differs: a directory name is
2489
+ * renamed or overridden, an `--as` is corrected. Empty when every set matched,
2490
+ * so the refusal's other causes keep the sentence they had.
2491
+ */
2492
+ function nothingToFrameWhy(
2493
+ prepared: PreparedSet[],
2494
+ have: string[],
2495
+ as: string | undefined,
2496
+ sidecar: boolean,
2497
+ ): string {
2498
+ const unmatched = prepared.filter((p) => p.missing !== null);
2499
+ if (unmatched.length === 0) return '';
2500
+ const declared = `it declares [${have.join(', ') || 'none'}]`;
2501
+ if (as !== undefined) {
2502
+ return (
2503
+ `: --as ${JSON.stringify(as)} names no animation of the candidate β€” ${declared}. --as takes one candidate ` +
2504
+ 'animation name, the one these frames show'
2505
+ );
2506
+ }
2507
+ const tried = unmatched.map((p) => {
2508
+ const name = JSON.stringify(p.set.animation);
2509
+ if (sidecar) return `${name} (what ${FRAMES_SIDECAR} records for set ${JSON.stringify(p.set.dir)})`;
2510
+ return p.set.dir === p.set.animation
2511
+ ? `${name} (the directory's own name)`
2512
+ : `${name} (the directory ${JSON.stringify(p.set.dir)}, its @fps suffix dropped)`;
2513
+ });
2514
+ return (
2515
+ `: the frames were matched to a candidate animation by name, and the candidate has no animation called ` +
2516
+ `${tried.join(', ')} β€” ${declared}. Pass --as <name> with the one these frames show, or name the ` +
2517
+ 'directory after it'
2518
+ );
2519
+ }
2520
+
2401
2521
  function checkOneSet(
2402
2522
  root: string,
2403
2523
  prepared: PreparedSet,
package/src/render.ts CHANGED
@@ -23,6 +23,13 @@
23
23
  * real runtime would. Each triangle is then filled with barycentric UV
24
24
  * interpolation.
25
25
  *
26
+ * A **clipping attachment** draws no pixel of its own and removes the pixels of
27
+ * every slot from the one carrying it through its `end` slot. `piecesOf` runs
28
+ * spine-core's own `SkeletonClipping` beside the draw-order walk, in the call
29
+ * sequence spine-webgl's `SkeletonRenderer.draw` runs, so a slot inside a clip
30
+ * reaches the rasteriser as the geometry the runtime draws rather than as the
31
+ * attachment's whole (issue #844).
32
+ *
26
33
  * ⭐ **Sampling is bilinear on both paths, and the source is straight alpha β€”
27
34
  * so the interpolation is premultiplied.** One filter rather than two is not a
28
35
  * detail: `check` measures a candidate against reference frames, and a mesh
@@ -80,14 +87,17 @@ import {
80
87
  AnimationState,
81
88
  AnimationStateData,
82
89
  AtlasAttachmentLoader,
90
+ ClippingAttachment,
83
91
  MeshAttachment,
84
92
  Physics,
85
93
  RegionAttachment,
86
94
  Skeleton,
95
+ SkeletonClipping,
87
96
  SkeletonJson,
88
97
  TextureAtlas,
89
98
  TextureAtlasRegion,
90
99
  type SkeletonData,
100
+ type Slot,
91
101
  } from '@esotericsoftware/spine-core';
92
102
  import { readFileSync } from 'node:fs';
93
103
  import { join } from 'node:path';
@@ -361,6 +371,19 @@ export interface PoseOptions {
361
371
  slots?: string[];
362
372
  /** Draw every slot but these β€” see `slots`. */
363
373
  hidden?: string[];
374
+ /**
375
+ * Pose every attachment whole, with no clipping attachment applied β€” set by
376
+ * `framingViewport` and by nothing that draws.
377
+ *
378
+ * ⭐ **The framing box counts what a clip removes**, for the reason it counts
379
+ * what `--slot`/`--hide` leave out: the box is a property of the shot, and a
380
+ * clip is a statement about which pixels of it are drawn. Framed on the
381
+ * clipped geometry, a rig's viewport would move the moment a clip is added or
382
+ * keyed, and every frame set already on disk for it β€” `frames.json`'s world
383
+ * box, the grid `check` compares on β€” would stop describing the frames a
384
+ * second render writes.
385
+ */
386
+ unclipped?: boolean;
364
387
  }
365
388
 
366
389
  /**
@@ -758,6 +781,35 @@ export function sampleAll(data: SkeletonData, fps: number, opts?: PoseOptions):
758
781
  * type that is neither is skipped rather than refused: a bounding box, a point
759
782
  * and a clipping attachment are all things a rig legitimately carries and none
760
783
  * of them draws a pixel.
784
+ *
785
+ * ## A clipping attachment is skipped as a piece and applied as a mask
786
+ *
787
+ * It draws nothing, and it removes what every slot from its own through its
788
+ * `end` slot draws outside its polygon. Skipping it outright drew those pixels β€”
789
+ * measured on a port whose eye masks clip the irises: the blink frame read MAE
790
+ * 1.07 against 0.53 at an open eye, with both irises drawn over closed lids,
791
+ * where spine-webgl reads 0.16 (issue #844). So spine-core's own
792
+ * `SkeletonClipping` runs beside the walk, and the call sequence is
793
+ * spine-webgl's `SkeletonRenderer.draw` (branch `4.3`) step for step: at a
794
+ * clipping attachment `clipEnd(slot)` then `clipStart(skeleton, slot, clip)` and
795
+ * nothing drawn; at every other slot, drawn or not, `clipEnd(slot)` after it,
796
+ * which is what ends a clip AT its end slot rather than before it; `clipEnd()`
797
+ * after the walk. The polygon, its convex decomposition, `inverse` and `convex`
798
+ * are the clipper's, so there is no second opinion about any of them here.
799
+ *
800
+ * A slot inside a clip hands its world vertices, its triangles β€” a region's are
801
+ * the runtime's own `0 1 2 2 3 0` β€” and its UVs to `clipTrianglesUnpacked`, and
802
+ * the piece carries what comes back. ⚠️ **Only when the clipper says it clipped**,
803
+ * exactly as spine-webgl uses the result only when `clipTriangles` returns
804
+ * true: an attachment wholly inside the polygon draws its own geometry, so a
805
+ * region there is still a `Quad` and its pixels are the unclipped ones to the
806
+ * bit. One that is cut becomes a `Mesh` β€” the clipper's output is a triangle
807
+ * list β€” and one wholly outside becomes a mesh with no triangle, which keeps the
808
+ * slot in the frame, undrawn, rather than absent.
809
+ *
810
+ * The clip is applied whatever `slots`/`hidden` draw: a hidden clip still masks
811
+ * what is shown, so a subset frame is the whole frame's pixels for those slots.
812
+ * `unclipped` turns it off for the framing box alone β€” see its note.
761
813
  */
762
814
  export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
763
815
  // A skin is chosen before a skeleton is posed, and this one is already posed β€”
@@ -776,63 +828,127 @@ export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
776
828
  // application point is, rather than matching no piece in silence.
777
829
  const subset = slotSubsetOf(skeleton.data, opts, skeleton.skin?.name);
778
830
  const named = subset === undefined ? undefined : new Set(subset.names);
831
+ const clipper = opts?.unclipped === true ? null : new SkeletonClipping();
779
832
  const pieces: Piece[] = [];
780
833
  for (const slot of skeleton.drawOrder.appliedPose) {
781
- if (subset !== undefined && named !== undefined && named.has(slot.data.name) !== (subset.mode === 'slots')) continue;
782
- const pose = slot.appliedPose;
783
- const attachment = pose.attachment;
784
- if (!attachment) continue;
785
- const isMesh = attachment instanceof MeshAttachment;
786
- if (!isMesh && !(attachment instanceof RegionAttachment)) continue;
787
-
788
- const index = attachment.sequence.resolveIndex(pose);
789
- const region = attachment.sequence.regions[index];
790
- if (!(region instanceof TextureAtlasRegion)) {
791
- throw new Error(
792
- `slot "${slot.data.name}" attachment "${attachment.name}" resolved to no atlas region; ` +
793
- 'the attachment names a region the atlas does not have',
794
- );
795
- }
796
- const colour = pose.color;
797
- const own = attachment.color;
798
- const tint: [number, number, number, number] = [
799
- colour.r * own.r,
800
- colour.g * own.g,
801
- colour.b * own.b,
802
- colour.a * own.a,
803
- ];
804
- // The dark colour is the SLOT's alone β€” an attachment has a `color` and no
805
- // dark one, so there is nothing to multiply it by. Read off `appliedPose`
806
- // like the light colour, so an `rgba2` timeline reaches the picture.
807
- const darkPose = pose.darkColor;
808
- const dark: [number, number, number] | undefined =
809
- darkPose === null ? undefined : [darkPose.r, darkPose.g, darkPose.b];
810
- const common = { tint, dark, slot: slot.data.name, page: region.page.name };
811
- const texture = opts?.texture !== true ? undefined : artUvsOf(attachment, region);
812
-
813
- if (isMesh) {
814
- // `worldVerticesLength` is 2 per vertex whether or not the mesh is
815
- // weighted β€” the weight runs live in `vertices`, not here β€” so this is the
816
- // full output length and the whole mesh is computed in one call. Deform
817
- // offsets, if the pose carries any, are applied inside it.
818
- const world = new Array<number>(attachment.worldVerticesLength).fill(0);
819
- attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
820
- pieces.push({
821
- kind: 'mesh',
822
- ...common,
823
- texture,
824
- world,
825
- uvs: attachment.sequence.getUVs(index),
826
- triangles: attachment.triangles,
827
- });
834
+ const attachment = slot.appliedPose.attachment;
835
+ if (attachment instanceof ClippingAttachment) {
836
+ if (clipper !== null) {
837
+ clipper.clipEnd(slot);
838
+ // spine-webgl ends the clip at a slot whose bone is inactive and starts
839
+ // none there; the order of the two calls is the same either way.
840
+ if (slot.bone.active) clipper.clipStart(skeleton, slot, attachment);
841
+ }
828
842
  continue;
829
843
  }
844
+ const drawn = subset === undefined || named === undefined || named.has(slot.data.name) === (subset.mode === 'slots');
845
+ const piece = drawn ? pieceOf(skeleton, slot, opts, clipper) : null;
846
+ if (piece !== null) pieces.push(piece);
847
+ clipper?.clipEnd(slot);
848
+ }
849
+ clipper?.clipEnd();
850
+ return pieces;
851
+ }
852
+
853
+ /** The runtime's own triangulation of a region's quad β€” spine-webgl's `QUAD_TRIANGLES`. */
854
+ const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
830
855
 
856
+ /**
857
+ * One slot's posed drawable, or `null` for an attachment that draws nothing β€”
858
+ * clipped by `clipper` when a clip is active over it (see `piecesOf`).
859
+ */
860
+ function pieceOf(
861
+ skeleton: Skeleton,
862
+ slot: Slot,
863
+ opts: PoseOptions | undefined,
864
+ clipper: SkeletonClipping | null,
865
+ ): Piece | null {
866
+ const pose = slot.appliedPose;
867
+ const attachment = pose.attachment;
868
+ if (!attachment) return null;
869
+ const isMesh = attachment instanceof MeshAttachment;
870
+ if (!isMesh && !(attachment instanceof RegionAttachment)) return null;
871
+
872
+ const index = attachment.sequence.resolveIndex(pose);
873
+ const region = attachment.sequence.regions[index];
874
+ if (!(region instanceof TextureAtlasRegion)) {
875
+ throw new Error(
876
+ `slot "${slot.data.name}" attachment "${attachment.name}" resolved to no atlas region; ` +
877
+ 'the attachment names a region the atlas does not have',
878
+ );
879
+ }
880
+ const colour = pose.color;
881
+ const own = attachment.color;
882
+ const tint: [number, number, number, number] = [
883
+ colour.r * own.r,
884
+ colour.g * own.g,
885
+ colour.b * own.b,
886
+ colour.a * own.a,
887
+ ];
888
+ // The dark colour is the SLOT's alone β€” an attachment has a `color` and no
889
+ // dark one, so there is nothing to multiply it by. Read off `appliedPose`
890
+ // like the light colour, so an `rgba2` timeline reaches the picture.
891
+ const darkPose = pose.darkColor;
892
+ const dark: [number, number, number] | undefined =
893
+ darkPose === null ? undefined : [darkPose.r, darkPose.g, darkPose.b];
894
+ const common = { tint, dark, slot: slot.data.name, page: region.page.name };
895
+ const texture = opts?.texture !== true ? undefined : artUvsOf(attachment, region);
896
+ const uvs = attachment.sequence.getUVs(index);
897
+
898
+ let piece: Piece;
899
+ let triangles: number[];
900
+ if (isMesh) {
901
+ // `worldVerticesLength` is 2 per vertex whether or not the mesh is
902
+ // weighted β€” the weight runs live in `vertices`, not here β€” so this is the
903
+ // full output length and the whole mesh is computed in one call. Deform
904
+ // offsets, if the pose carries any, are applied inside it.
905
+ const world = new Array<number>(attachment.worldVerticesLength).fill(0);
906
+ attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
907
+ triangles = attachment.triangles;
908
+ piece = { kind: 'mesh', ...common, texture, world, uvs, triangles };
909
+ } else {
831
910
  const world = new Array<number>(8).fill(0);
832
911
  attachment.computeWorldVertices(slot, attachment.getOffsets(pose), world, 0, 2);
833
- pieces.push({ kind: 'region', ...common, texture, world, uvs: attachment.sequence.getUVs(index) });
912
+ triangles = QUAD_TRIANGLES;
913
+ piece = { kind: 'region', ...common, texture, world, uvs };
834
914
  }
835
- return pieces;
915
+ if (clipper === null || !clipper.isClipping()) return piece;
916
+ return clippedPiece(piece, triangles, uvs, clipper);
917
+ }
918
+
919
+ /**
920
+ * `piece` as the active clip leaves it, or `piece` itself when the clipper cut
921
+ * nothing β€” see `piecesOf`.
922
+ *
923
+ * The page UVs and the original-art UVs (`PieceTexture`, when the piece carries
924
+ * them) each go through their own `clipTrianglesUnpacked` call over the same
925
+ * vertices and triangles. The clipper's geometry depends on the positions alone
926
+ * and it interpolates a UV set barycentrically inside each source triangle, so
927
+ * the two calls cut the same polygons and each UV set lands on them β€” which is
928
+ * what lets `substituteTexture` re-seat a clipped piece exactly as it re-seats a
929
+ * whole one, through the drawing's own coordinates. The vertex count is compared
930
+ * all the same, because a clipped piece whose two UV sets disagreed on it would
931
+ * sample the wrong texels and say nothing.
932
+ */
933
+ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clipper: SkeletonClipping): Piece {
934
+ if (!clipper.clipTrianglesUnpacked(piece.world, 0, triangles, triangles.length, uvs, 2)) return piece;
935
+ const world = Array.from(clipper.clippedVerticesTyped);
936
+ const clippedUvs = Array.from(clipper.clippedUVsTyped);
937
+ const clipped = Array.from(clipper.clippedTrianglesTyped);
938
+ let texture = piece.texture;
939
+ if (texture !== undefined) {
940
+ clipper.clipTrianglesUnpacked(piece.world, 0, triangles, triangles.length, texture.artUvs, 2);
941
+ const artUvs = Array.from(clipper.clippedUVsTyped);
942
+ if (artUvs.length !== clippedUvs.length) {
943
+ throw new Error(
944
+ `slot "${piece.slot}": the clip cut ${clippedUvs.length / 2} vertices for the page UVs and ` +
945
+ `${artUvs.length / 2} for the original-art UVs over the same geometry`,
946
+ );
947
+ }
948
+ texture = { region: texture.region, artUvs };
949
+ }
950
+ const { tint, dark, slot, page } = piece;
951
+ return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped };
836
952
  }
837
953
 
838
954
  // ---------------------------------------------------------------------------
@@ -1227,8 +1343,12 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1227
1343
  // `--slot`/`--hide` leave out still counts toward the box, so a frame with a
1228
1344
  // part hidden lands on the pixel grid of the frame with it and the two overlay.
1229
1345
  // A subset framed to its own extent would move every pixel it kept.
1346
+ //
1347
+ // A clip is taken off for the same reason (issue #844): what it removes still
1348
+ // counts toward the box, so adding or keying a mask moves no pixel it leaves
1349
+ // drawn β€” see `PoseOptions.unclipped`.
1230
1350
  const { slots: _drawn, hidden: _hidden, ...whole } = opts ?? {};
1231
- const framed = opts === undefined ? undefined : whole;
1351
+ const framed: PoseOptions = { ...whole, unclipped: true };
1232
1352
  const sets =
1233
1353
  data.animations.length === 0
1234
1354
  ? [sampleSetupPose(data, framed)]