spine-rigc 1.2.0 → 1.2.1
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/docs/AUTHORING.md +40 -7
- package/package.json +1 -1
- package/src/check.ts +126 -6
- package/src/render.ts +170 -50
package/docs/AUTHORING.md
CHANGED
|
@@ -152,12 +152,15 @@ one directory of `f0000.png`, `f0001.png`… named after the candidate animation
|
|
|
152
152
|
shows (or pair the two with `--as`): `check` accepts a set with no `frames.json`
|
|
153
153
|
and takes its rate from `--fps` (§9). What that set cannot carry is its
|
|
154
154
|
**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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
**opaque** background of the colour `check`'s no-`frames.json` note names, the one
|
|
156
|
+
after *"the background is this build's default,"*. Running `check` once on any
|
|
157
|
+
frame set with no sidecar prints it, so read it there before rendering rather than
|
|
158
|
+
from a copy here. The content box is found against that colour and alpha is not
|
|
159
|
+
read, so a reference that is not opaque is **refused**, naming the frames and the
|
|
160
|
+
first pixel whose alpha is not 255 (§9). A background that is opaque but another
|
|
161
|
+
colour is not refused and costs you: measured on the 24 frames of `gallery/look`'s
|
|
162
|
+
`turn`, the same drawn pixels read MAE mean **2.25** over the note's colour and
|
|
163
|
+
**5.19** over white, at exit 0 with the same notes. A port with no
|
|
161
164
|
reference frames is **unmeasured, not finished**: green from `build` says the file
|
|
162
165
|
is valid and nothing about whether it is the source's picture. And the parts are
|
|
163
166
|
the source's own texture cut along its drawables, **never a screenshot of it** — a
|
|
@@ -214,7 +217,12 @@ What the flags mean:
|
|
|
214
217
|
|
|
215
218
|
`render` also takes `--fps <n>` (the rate it samples at, default 12 — the same
|
|
216
219
|
protocol rate the reference frames use) and `--max <px>` (the long side of a
|
|
217
|
-
frame, default 256).
|
|
220
|
+
frame, default 256). A clipping attachment is applied as the runtime applies it —
|
|
221
|
+
spine-core's own `SkeletonClipping`, in spine-webgl's call order — so a slot from
|
|
222
|
+
the clip's through its `end` slot draws only what falls inside the polygon, in
|
|
223
|
+
`render`, `check` and every other picture rigc's own rasteriser draws (`preview`
|
|
224
|
+
plays in Spine's player, which always clipped); the framing box still counts
|
|
225
|
+
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
226
|
(default `render/`), the `.html` file for `preview` (default `preview.html`) and
|
|
219
227
|
for `vote` (default `ballot.html`).
|
|
220
228
|
|
|
@@ -6173,6 +6181,31 @@ sampled at, and without it the 12 fps protocol rate is assumed and the report sa
|
|
|
6173
6181
|
so rather than letting the assumption look like a measurement. Passing `--fps` with
|
|
6174
6182
|
a value the sidecar contradicts is an error, not an override.
|
|
6175
6183
|
|
|
6184
|
+
A set with no sidecar is one of two things, and the `⚠️ no frames.json` note says
|
|
6185
|
+
which advice goes with which: **a rigc render older than the sidecar** is fixed by
|
|
6186
|
+
rendering it again with `rigc render`, and **a foreign source** — a Live2D, Unity or
|
|
6187
|
+
video player, whose frames predate nothing — needs the three things the sidecar
|
|
6188
|
+
would have said, supplied by whoever made them: an opaque background of the colour
|
|
6189
|
+
the note names, `--fps` at the rate it was rendered, and a directory named after
|
|
6190
|
+
the candidate animation it shows or `--as <that animation>`. Two of those are
|
|
6191
|
+
refused by name when they are wrong, because a figure would come out of either:
|
|
6192
|
+
|
|
6193
|
+
```
|
|
6194
|
+
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.
|
|
6195
|
+
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
|
|
6196
|
+
```
|
|
6197
|
+
|
|
6198
|
+
The test is every pixel's alpha, not the border's: a pixel that is not opaque is
|
|
6199
|
+
not yet a colour — what it shows depends on what it is composited over — so one
|
|
6200
|
+
pixel of alpha 254 in one frame is refused too, naming that frame and that pixel.
|
|
6201
|
+
The first is a refusal rather than a note because pinning does not rescue it:
|
|
6202
|
+
`--viewport` set to the box the frames were drawn in reads MAE **0.00** on the
|
|
6203
|
+
opaque set and **32.90** on the same set made transparent, on every one of its 24
|
|
6204
|
+
frames — the transparent area times its distance from the background colour, which
|
|
6205
|
+
is a figure about the background and not about the rig. `--as` takes **one**
|
|
6206
|
+
candidate animation name; the `<candidate>=<reference>` pair is `diff`'s spelling,
|
|
6207
|
+
and `check` refuses it as a name the candidate does not declare.
|
|
6208
|
+
|
|
6176
6209
|
`--viewport <x>,<y>,<width>,<height>` pins your candidate's world box instead of
|
|
6177
6210
|
fitting it. Two uses:
|
|
6178
6211
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
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": {
|
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
|
|
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
|
|
1176
|
-
'
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
912
|
+
triangles = QUAD_TRIANGLES;
|
|
913
|
+
piece = { kind: 'region', ...common, texture, world, uvs };
|
|
834
914
|
}
|
|
835
|
-
return
|
|
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 =
|
|
1351
|
+
const framed: PoseOptions = { ...whole, unclipped: true };
|
|
1232
1352
|
const sets =
|
|
1233
1353
|
data.animations.length === 0
|
|
1234
1354
|
? [sampleSetupPose(data, framed)]
|