spine-rigc 1.3.0 → 1.4.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 +1 -1
- package/cli.ts +61 -14
- package/docs/AUTHORING.md +189 -28
- package/package.json +1 -1
- package/src/atlas.ts +31 -0
- package/src/pose.ts +88 -14
- package/src/render.ts +376 -21
package/README.md
CHANGED
|
@@ -535,7 +535,7 @@ commands take it and what its default is.
|
|
|
535
535
|
| `validate <dir>` | re-gates artifacts already on disk |
|
|
536
536
|
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
|
|
537
537
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
538
|
-
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference |
|
|
538
|
+
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid |
|
|
539
539
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
|
|
540
540
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
|
|
541
541
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
|
package/cli.ts
CHANGED
|
@@ -145,6 +145,10 @@ import {
|
|
|
145
145
|
FRAMES_SIDECAR,
|
|
146
146
|
FRAMES_SPEC,
|
|
147
147
|
framingViewport,
|
|
148
|
+
GEOMETRY_FILE,
|
|
149
|
+
GeometryError,
|
|
150
|
+
geometryFileOf,
|
|
151
|
+
geometryText,
|
|
148
152
|
loadPosable,
|
|
149
153
|
PROTOCOL_FPS,
|
|
150
154
|
renderFrame,
|
|
@@ -153,6 +157,7 @@ import {
|
|
|
153
157
|
SETUP_POSE_DIR,
|
|
154
158
|
SHEET_FILE,
|
|
155
159
|
SHEET_TILE,
|
|
160
|
+
sidecarViewport,
|
|
156
161
|
SlotSubsetError,
|
|
157
162
|
slotSubsetOf,
|
|
158
163
|
type Frame,
|
|
@@ -259,7 +264,7 @@ function repositoryUrl(): string {
|
|
|
259
264
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
260
265
|
* halves together by reading `--help` rather than by naming a flag.
|
|
261
266
|
*/
|
|
262
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
|
|
267
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
|
|
263
268
|
|
|
264
269
|
/**
|
|
265
270
|
* The flags a command is allowed to spell more than once.
|
|
@@ -2010,6 +2015,24 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
2010
2015
|
const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
|
|
2011
2016
|
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
2012
2017
|
const outRoot = resolve(flags.out ?? 'render');
|
|
2018
|
+
const geometry = flags.geometry !== undefined;
|
|
2019
|
+
// Refused together rather than one of them ignored (issue #864). The export
|
|
2020
|
+
// records every slot's whole geometry, because a subset is a statement about
|
|
2021
|
+
// which pixels are DRAWN and the pose is the same whatever a picture leaves
|
|
2022
|
+
// out — so a geometry.json beside a subset's frames would describe slots
|
|
2023
|
+
// those frames do not draw, and one that dropped them would stop being the
|
|
2024
|
+
// pose. The whole-rig run writes the same file either way.
|
|
2025
|
+
if (geometry) {
|
|
2026
|
+
for (const flag of ['slot', 'hide'] as const) {
|
|
2027
|
+
if (flags[flag] !== undefined) {
|
|
2028
|
+
throw new UsageError(
|
|
2029
|
+
`--geometry takes no --${flag}: it records every slot's whole geometry, which a subset of the drawn slots ` +
|
|
2030
|
+
`does not change. Run \`rigc render --geometry\` on the whole rig for the file, and --${flag} ` +
|
|
2031
|
+
`${flags[flag]} without --geometry for the pictures — both land on the same grid`,
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2013
2036
|
|
|
2014
2037
|
console.log('rigc render');
|
|
2015
2038
|
console.log(` .. skeleton ${skeletonPath}`);
|
|
@@ -2043,8 +2066,24 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
2043
2066
|
// `sampleAll` covers the skeleton with no animation at all, which files its one
|
|
2044
2067
|
// setup-pose frame under the reserved name. Narrowing to one animation reuses
|
|
2045
2068
|
// the same sampler rather than a second path through it.
|
|
2069
|
+
//
|
|
2070
|
+
// `--geometry` rides on the SAME call (issue #864): the bones and the whole
|
|
2071
|
+
// attachments are read off the skeleton at the step that drew each frame, so
|
|
2072
|
+
// the export's grid is this frame set's by construction.
|
|
2073
|
+
const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
|
|
2046
2074
|
const sampled: Map<string, Frame[]> =
|
|
2047
|
-
only === undefined
|
|
2075
|
+
only === undefined
|
|
2076
|
+
? sampleAll(data, fps, sampling)
|
|
2077
|
+
: new Map([[only, sampleAnimation(data, only, fps, sampling)]]);
|
|
2078
|
+
// Every file's text before the first write, so a refused number leaves the
|
|
2079
|
+
// output directory as it was rather than half of a frame set behind it.
|
|
2080
|
+
const geometryTexts = new Map<string, string>();
|
|
2081
|
+
if (geometry) {
|
|
2082
|
+
for (const [name, frames] of sampled) {
|
|
2083
|
+
const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
|
|
2084
|
+
geometryTexts.set(name, geometryText(geometryFileOf(data, animation, fps, frames, viewport, skin)));
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2048
2087
|
console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
|
|
2049
2088
|
if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
|
|
2050
2089
|
|
|
@@ -2067,6 +2106,8 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
2067
2106
|
// would be the same picture with a border and a "0" on it.
|
|
2068
2107
|
const sheet = frames.length > 1;
|
|
2069
2108
|
if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
|
|
2109
|
+
const geometryOut = geometryTexts.get(name);
|
|
2110
|
+
if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
|
|
2070
2111
|
const duration = frames[frames.length - 1].time;
|
|
2071
2112
|
sets.push({
|
|
2072
2113
|
dir: dirName,
|
|
@@ -2078,7 +2119,8 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
2078
2119
|
duration,
|
|
2079
2120
|
});
|
|
2080
2121
|
const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
|
|
2081
|
-
|
|
2122
|
+
const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
|
|
2123
|
+
console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
|
|
2082
2124
|
}
|
|
2083
2125
|
|
|
2084
2126
|
// The sidecar is what makes this a frame SET rather than a pile of pictures:
|
|
@@ -2095,15 +2137,9 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
2095
2137
|
// every slot says nothing and stays the bytes it always was (issue #835).
|
|
2096
2138
|
...subsetFields(subset),
|
|
2097
2139
|
background: BACKGROUND,
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
width: viewport.maxX - viewport.minX,
|
|
2102
|
-
height: viewport.maxY - viewport.minY,
|
|
2103
|
-
scale: viewport.scale,
|
|
2104
|
-
pixelWidth: viewport.width,
|
|
2105
|
-
pixelHeight: viewport.height,
|
|
2106
|
-
},
|
|
2140
|
+
// The one spelling `geometry.json` repeats, so the two files cannot state
|
|
2141
|
+
// two boxes for one grid.
|
|
2142
|
+
viewport: sidecarViewport(viewport),
|
|
2107
2143
|
sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
|
|
2108
2144
|
};
|
|
2109
2145
|
writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
@@ -3808,6 +3844,10 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
3808
3844
|
'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
|
|
3809
3845
|
'as a measurement',
|
|
3810
3846
|
'all-bones': 'print every bone pair, not just the worst by position',
|
|
3847
|
+
geometry:
|
|
3848
|
+
`also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
|
|
3849
|
+
"slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
|
|
3850
|
+
'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
|
|
3811
3851
|
'texture-from':
|
|
3812
3852
|
"also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
|
|
3813
3853
|
'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
|
|
@@ -4148,10 +4188,10 @@ const COMMANDS: CommandDoc[] = [
|
|
|
4148
4188
|
{
|
|
4149
4189
|
name: 'render',
|
|
4150
4190
|
usage: [
|
|
4151
|
-
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
|
|
4191
|
+
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--out render/]',
|
|
4152
4192
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
4153
4193
|
],
|
|
4154
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
|
|
4194
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'out'],
|
|
4155
4195
|
overrides: {
|
|
4156
4196
|
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
4157
4197
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
|
@@ -4499,6 +4539,13 @@ try {
|
|
|
4499
4539
|
// the one reader's and already names the file, what it is and what rigc
|
|
4500
4540
|
// reads; a stack under it is the tool describing its own internals instead.
|
|
4501
4541
|
// Exit 1, like a compile error: the invocation was fine, a file was not.
|
|
4542
|
+
// A pose the geometry export cannot write as numbers (issue #864): the
|
|
4543
|
+
// invocation was fine and the skeleton posed a NaN or an infinity, so exit 1
|
|
4544
|
+
// like a file that is not a PNG. Raised before the first file is written.
|
|
4545
|
+
if (err instanceof GeometryError) {
|
|
4546
|
+
console.error(`rigc render: ${err.message}`);
|
|
4547
|
+
process.exit(1);
|
|
4548
|
+
}
|
|
4502
4549
|
if (err instanceof NotAPngError) {
|
|
4503
4550
|
console.error(`rigc: ${err.message}`);
|
|
4504
4551
|
process.exit(1);
|
package/docs/AUTHORING.md
CHANGED
|
@@ -209,6 +209,7 @@ What the flags mean:
|
|
|
209
209
|
| `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
|
|
210
210
|
| `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
|
|
211
211
|
| `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
|
|
212
|
+
| `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
|
|
212
213
|
| `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
|
|
213
214
|
| `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
|
|
214
215
|
| `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
|
|
@@ -318,6 +319,31 @@ a gap:
|
|
|
318
319
|
regions `rotate: 90` lifted every region back byte for byte and drew its
|
|
319
320
|
region attachments with 0 differing pixels — and made neither page smaller,
|
|
320
321
|
because both were already on the smallest power-of-two page their area allows.
|
|
322
|
+
Measured again on twelve region sets (issue #866: those two rigs and ten
|
|
323
|
+
production rigs of 20 to 22 parts), packing each with the same search twice —
|
|
324
|
+
as shipped, and with every part also tried turned under the same placement
|
|
325
|
+
score:
|
|
326
|
+
|
|
327
|
+
| Set | `pot` page | turned | `free` page | turned |
|
|
328
|
+
| --- | --- | --- | --- | --- |
|
|
329
|
+
| 22-part painting | 1024x2048 | 0.00 % | 1888x697 | −1.14 % |
|
|
330
|
+
| 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
|
|
331
|
+
| P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
|
|
332
|
+
| P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
|
|
333
|
+
| P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
|
|
334
|
+
| P4 | 1024x2048 | 0.00 % | 864x1346 | −6.16 % |
|
|
335
|
+
| P5 | 2048x2048 | 0.00 % | 1344x1768 | −13.37 % |
|
|
336
|
+
| P6 | 1024x2048 | 0.00 % | 800x1934 | −1.62 % |
|
|
337
|
+
| P7 | 512x2048 | 0.00 % | 1024x928 | −0.05 % |
|
|
338
|
+
| P8 | 512x2048 | 0.00 % | 608x1250 | −4.96 % |
|
|
339
|
+
| P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
|
|
340
|
+
| P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
|
|
341
|
+
|
|
342
|
+
Under `pot` turning shrank no page. Under `free` the largest gain, 13.37 %, is
|
|
343
|
+
inside the search's own noise: the neighbouring width on the 32-pixel grid
|
|
344
|
+
moves that set's unturned page by 18.13 %, and trying every width instead
|
|
345
|
+
finds an unturned page 10.22 % smaller. With every width tried, no set gains
|
|
346
|
+
more than 5.05 % from turning — so the packer still never turns a region.
|
|
321
347
|
- **no re-ordering of anything the skeleton says.** `skeleton.json` from a packed
|
|
322
348
|
build is **byte-identical** to the unpacked one, because sizes are still
|
|
323
349
|
measured from the loose PNGs and packing is an output arrangement.
|
|
@@ -827,6 +853,7 @@ bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin
|
|
|
827
853
|
bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
|
|
828
854
|
bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
|
|
829
855
|
bun cli.ts render --candidate path/to/spine --slot <name,…> | --hide <name,…> # part of the rig, same grid
|
|
856
|
+
bun cli.ts render --candidate path/to/spine --geometry # + geometry.json per set: the numbers the frames were drawn from
|
|
830
857
|
bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
|
|
831
858
|
bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
|
|
832
859
|
bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
|
|
@@ -921,7 +948,11 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
|
|
|
921
948
|
in draw order; a slot whose art lives only under another skin is refused naming
|
|
922
949
|
that skin (pass `--skin`); the two flags together are refused. `frames.json`
|
|
923
950
|
records the subset as `slots` or `hidden`, and **`check` refuses such a set as
|
|
924
|
-
a reference** (§9). `
|
|
951
|
+
a reference** (§9). `--geometry` writes the numbers each frame was drawn from
|
|
952
|
+
beside it — every bone's world transform and every attachment's world vertices
|
|
953
|
+
after skinning, with their rest geometry — for a judgement the pixels cannot
|
|
954
|
+
make: a mesh triangle's stretch, a region read in its own bone's frame (§8.2).
|
|
955
|
+
`preview`
|
|
925
956
|
writes one self-contained `.html`: your skeleton, atlas and page PNGs are
|
|
926
957
|
embedded in it as data URIs and played by the official **Spine Web Player**, so
|
|
927
958
|
double-clicking it is also the interop proof — what plays there was played by
|
|
@@ -6262,6 +6293,119 @@ the loop with **§9**: the fit's own number says how near this pose is to this f
|
|
|
6262
6293
|
and only `check` says whether the shot is the shot. **§9.3** is the list of what even
|
|
6263
6294
|
that cannot see.
|
|
6264
6295
|
|
|
6296
|
+
### 8.2 The numbers the frames were drawn from — `render --geometry`
|
|
6297
|
+
|
|
6298
|
+
A frame set is pixels, and some judgements about a rig are not about pixels: how far
|
|
6299
|
+
a mesh triangle is stretched over its rest shape, or whether a region holds still in
|
|
6300
|
+
its own bone's frame. Both need the posed numbers, and both need them **on the frames'
|
|
6301
|
+
own grid** — a second tool re-sampling the animation would step the runtime its own
|
|
6302
|
+
way and drift from the pictures it is meant to explain. So `render --geometry` writes
|
|
6303
|
+
them beside the pictures, off the very frames it drew:
|
|
6304
|
+
|
|
6305
|
+
```bash
|
|
6306
|
+
bun cli.ts render --candidate path/to/spine --geometry [--animation …] [--skin …] [--fps 12]
|
|
6307
|
+
```
|
|
6308
|
+
|
|
6309
|
+
Each frame directory gains a `geometry.json` beside its `f####.png`. Nothing else
|
|
6310
|
+
changes: the PNGs, `contact.png` and `frames.json` are byte for byte what the same
|
|
6311
|
+
command writes without the flag. The export costs a small fraction of the render —
|
|
6312
|
+
measured on `spineboy-pro` at 12 fps, 190 frames: about 30 ms for the eleven files
|
|
6313
|
+
against about 610 ms for the rasterising alone — which is why there is no
|
|
6314
|
+
geometry-only mode: a consumer judging frames wants the frames, and skipping them
|
|
6315
|
+
would save little.
|
|
6316
|
+
|
|
6317
|
+
⛔ **Not with `--slot`/`--hide`.** The two are refused together by name, exit 2,
|
|
6318
|
+
nothing written. A subset is a statement about which pixels are *drawn*; the
|
|
6319
|
+
geometry is the whole pose whatever a picture leaves out, so beside a subset's frames
|
|
6320
|
+
it would describe slots those frames do not draw. The whole-rig run writes the same
|
|
6321
|
+
file either way.
|
|
6322
|
+
|
|
6323
|
+
**The file, field by field.** `spec` is `rigc-geometry/1`. The header fields sit one
|
|
6324
|
+
per line and each `rest` entry and each frame on a line of its own, so a diff of two
|
|
6325
|
+
exports names the frame that moved. Two exports of one skeleton are byte-identical.
|
|
6326
|
+
|
|
6327
|
+
| Field | What it holds |
|
|
6328
|
+
| --- | --- |
|
|
6329
|
+
| `coordinates` | the string `spine world, y up, world units` — every position below is in the skeleton's world, not frame pixels |
|
|
6330
|
+
| `animation` | the animation, or `null` for a skeleton with none (its one frame is the setup pose) |
|
|
6331
|
+
| `skin` | the skin these frames were posed under — absent when none was set, exactly as in `frames.json` |
|
|
6332
|
+
| `fps` | the rate the frames were sampled at |
|
|
6333
|
+
| `viewport` | `frames.json`'s own `viewport` object, the same numbers: world box `x`, `y`, `width`, `height`, `scale` (frame pixels per world unit), `pixelWidth`, `pixelHeight`. World to frame pixel is `px = (x − viewport.x) · scale`, `py = (viewport.y + viewport.height − y) · scale`, which is the projection the renderer used |
|
|
6334
|
+
| `bones` | every bone in declaration order: `{ name, parent }`, `parent` `null` for the root |
|
|
6335
|
+
| `slots` | every slot in declaration order: `{ name, bone }` — the bone the slot hangs from |
|
|
6336
|
+
| `rest` | one entry per `(slot, attachment)` any frame shows, in order of first appearance: `slot`, `attachment`, `kind` (`region` or `mesh`), `vertices` at rest, `triangles` (vertex index triplets — a region's are the runtime's `0 1 2 2 3 0`), and for a mesh `hull` (the number of hull vertices, which are the first ones) and `uvs` (the attachment's own, `u, v` per vertex over the untrimmed drawing, y down) |
|
|
6337
|
+
| `setup` | the setup pose, sampled the way a skeleton with no animation is rendered: `{ bones, attachments }` |
|
|
6338
|
+
| `frames` | one per PNG, in index order: `{ index, time, bones, attachments }`. `index` is the number in `f####.png`, `time` is `index / fps` in seconds |
|
|
6339
|
+
|
|
6340
|
+
Inside `setup` and each frame:
|
|
6341
|
+
|
|
6342
|
+
- `bones` — every bone in declaration order: `{ name, a, b, c, d, worldX, worldY }`,
|
|
6343
|
+
the world transform, so a point `(lx, ly)` in the bone's own frame lands at
|
|
6344
|
+
`(a·lx + b·ly + worldX, c·lx + d·ly + worldY)`. The matrix is complete — rotation,
|
|
6345
|
+
scale and shear all live in `a b c d`.
|
|
6346
|
+
- `attachments` — every slot showing a region or a mesh, in the frame's **draw
|
|
6347
|
+
order**: `{ slot, attachment, vertices, color }`. `vertices` are `x, y` per vertex
|
|
6348
|
+
in world units after skinning and deform — the runtime's own
|
|
6349
|
+
`computeWorldVertices` over the **whole** attachment. A region's four are its
|
|
6350
|
+
corners in the order bottom-left, top-left, top-right, bottom-right; a mesh's are
|
|
6351
|
+
in the attachment's own order, so vertex `i` is the same vertex in every frame and
|
|
6352
|
+
in `rest`. `color` is the slot colour times the attachment colour, straight RGBA
|
|
6353
|
+
`0..1` — the same tint the frame was drawn with.
|
|
6354
|
+
|
|
6355
|
+
What `rest` means, precisely: the **setup pose's bones with the slot's deform
|
|
6356
|
+
empty**, taken for every attachment the file shows — including one the setup pose
|
|
6357
|
+
does not show, which a slot only swaps to later. For an attachment the setup pose
|
|
6358
|
+
does show, its `rest` vertices and its `setup` vertices are the same numbers bit for
|
|
6359
|
+
bit, because they come off one posed skeleton.
|
|
6360
|
+
|
|
6361
|
+
⚠️ **What is deliberately not in it.** Slots `--slot`/`--hide` would leave out are in
|
|
6362
|
+
it (see above). A clip is not applied: a clipping attachment removes pixels, and the
|
|
6363
|
+
clipper's output is a new triangle list whose vertices are not the attachment's and
|
|
6364
|
+
are not numbered like them — so `vertices` are the attachment's own, and the frames
|
|
6365
|
+
show what the clip left of them. Bounding boxes, points, paths and clipping polygons
|
|
6366
|
+
draw nothing and are not listed. Numbers are printed the way every JSON file this
|
|
6367
|
+
tool writes prints them — the shortest decimal that reads back as the same double —
|
|
6368
|
+
so a vertex in the file *is* the runtime's vertex, not a rounding of it; a pose that
|
|
6369
|
+
is not finite is refused naming the frame, the slot, the attachment and the vertex,
|
|
6370
|
+
rather than written as `null`.
|
|
6371
|
+
|
|
6372
|
+
**The two judgements the file was designed for**, written once so a consumer
|
|
6373
|
+
implements what the file promises:
|
|
6374
|
+
|
|
6375
|
+
1. **Texture stretch of a mesh triangle.** For each `rest` entry of kind `mesh` and
|
|
6376
|
+
each triangle `(i, j, k)` in its `triangles`, over every frame whose `attachments`
|
|
6377
|
+
carry the same `slot` and `attachment`:
|
|
6378
|
+
|
|
6379
|
+
`stretch = max over frames, max over the edges (i,j) (j,k) (k,i) of |posed edge| / |rest edge|`
|
|
6380
|
+
|
|
6381
|
+
where an edge's length is `hypot(x_j − x_i, y_j − y_i)` over that frame's
|
|
6382
|
+
`vertices` and over `rest`'s. The worst triangle is named by its `slot`, its
|
|
6383
|
+
triangle index and its three vertex indices. A value of exactly `1` is a
|
|
6384
|
+
triangle no frame moved. It is measured against rest, so it counts what the
|
|
6385
|
+
bones do to a weighted mesh as well as what a deform key does — unlike the
|
|
6386
|
+
`DEFORM` block, which compares a key against the same bones with the deform
|
|
6387
|
+
cleared.
|
|
6388
|
+
2. **A region still in its bone's own frame.** Take the region's `slot`, look its
|
|
6389
|
+
`bone` up in `slots`, and for each frame carry its four corners into that bone's
|
|
6390
|
+
frame by the inverse of the bone's world transform:
|
|
6391
|
+
|
|
6392
|
+
`det = a·d − b·c`, `dx = x − worldX`, `dy = y − worldY`,
|
|
6393
|
+
`lx = (d·dx − b·dy) / det`, `ly = (−c·dx + a·dy) / det`
|
|
6394
|
+
|
|
6395
|
+
Against the same four carried from `setup`, the largest difference over frames is
|
|
6396
|
+
the region's motion *relative to its bone*: zero, to double rounding, for a region
|
|
6397
|
+
nothing but its bone moves. A face region measured against a head bone this way
|
|
6398
|
+
is the face with the head's own turn, translation and scale taken out.
|
|
6399
|
+
|
|
6400
|
+
**Measured on the example corpus** at 12 fps (these are one run's figures, not
|
|
6401
|
+
thresholds): `spineboy-pro`'s eleven files total 5.1 MB, from 72 kB (`aim`, one
|
|
6402
|
+
frame) to 1.46 MB (`death`, 60 frames), and `sack-pro`'s four 1.4 MB. The largest
|
|
6403
|
+
stretch is `sack-pro` `fall-in`, `cape-back` triangle 15, ×6.744; the largest
|
|
6404
|
+
still-in-frame residual over every region of `spineboy-pro` is 4.7e-12 world units
|
|
6405
|
+
(`portal-streaks2`, whose bone scales; `sack-pro` has no region).
|
|
6406
|
+
Topology and UVs sit in `rest` rather than on every frame because they do not
|
|
6407
|
+
change: repeating them per frame would add 35 % to `spineboy-pro`'s files.
|
|
6408
|
+
|
|
6265
6409
|
---
|
|
6266
6410
|
|
|
6267
6411
|
## 9. Checking against the frames — `rigc check`
|
|
@@ -8588,7 +8732,7 @@ ambiguous one ends with a sentence saying which reading its figures support:
|
|
|
8588
8732
|
|
|
8589
8733
|
- **below the floor** — a 40x28 part of one flat colour, cut from a plain block it fits anywhere inside:
|
|
8590
8734
|
|
|
8591
|
-
> part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0;
|
|
8735
|
+
> part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0; 14 candidate(s), best 0.0000, next 0.0000, spread 0.0000 — below the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up): pose placed 8% of the measured parts this plain, so this part is too plain for pose to tell its placements apart; that says nothing about whether the cut is right
|
|
8592
8736
|
|
|
8593
8737
|
- **above the floor** — a 200x150 black-and-white checker the frame does not hold:
|
|
8594
8738
|
|
|
@@ -8615,24 +8759,24 @@ Found within 2 px of the truth, out of 15 trials per cell (free search, default
|
|
|
8615
8759
|
|
|
8616
8760
|
| texture level (mean) | 24 px | 32 px | 48 px | 64 px | 96 px | 128 px | 192 px |
|
|
8617
8761
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
8618
|
-
| `native` (detail 3.49) |
|
|
8619
|
-
| `blur1` (detail 1.58) |
|
|
8620
|
-
| `blur2` (detail 0.59) |
|
|
8621
|
-
| `flat` (detail 0.00) | 0/15 (14 amb, 1 wrong) | 0/15 (14 amb, 1 wrong) | 0/15 (
|
|
8762
|
+
| `native` (detail 3.49) | 12/15 (3 amb) | 14/15 (1 amb) | 13/15 (2 amb) | 15/15 | 12/15 (2 amb, 1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
|
|
8763
|
+
| `blur1` (detail 1.58) | 10/15 (5 amb) | 12/15 (2 amb, 1 wrong) | 13/15 (2 amb) | 13/15 (2 amb) | 12/15 (1 amb, 2 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
|
|
8764
|
+
| `blur2` (detail 0.59) | 7/15 (7 amb, 1 wrong) | 8/15 (5 amb, 2 wrong) | 9/15 (4 amb, 2 wrong) | 8/15 (3 amb, 4 wrong) | 7/15 (3 amb, 5 wrong) | 6/15 (4 amb, 5 wrong) | 6/15 (2 amb, 7 wrong) |
|
|
8765
|
+
| `flat` (detail 0.00) | 0/15 (14 amb, 1 wrong) | 0/15 (14 amb, 1 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (11 amb, 4 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (12 amb, 3 wrong) |
|
|
8622
8766
|
|
|
8623
8767
|
The same trials, grouped by `detail`:
|
|
8624
8768
|
|
|
8625
8769
|
| detail | found | ambiguous | wrong | refused |
|
|
8626
8770
|
| --- | --- | --- | --- | --- |
|
|
8627
|
-
| under 0.25 | 8/119 |
|
|
8628
|
-
| 0.25–0.5 |
|
|
8629
|
-
| 0.5–0.75 |
|
|
8630
|
-
| 0.75–1 |
|
|
8631
|
-
| 1–1.5 |
|
|
8632
|
-
| 1.5–2 |
|
|
8633
|
-
| 2–3 |
|
|
8634
|
-
| 3–4 |
|
|
8635
|
-
| 4 and over |
|
|
8771
|
+
| under 0.25 | 8/119 | 89 | 22 | 0 |
|
|
8772
|
+
| 0.25–0.5 | 2/3 | 0 | 1 | 0 |
|
|
8773
|
+
| 0.5–0.75 | 44/81 | 16 | 21 | 0 |
|
|
8774
|
+
| 0.75–1 | 17/34 | 14 | 3 | 0 |
|
|
8775
|
+
| 1–1.5 | 24/25 | 1 | 0 | 0 |
|
|
8776
|
+
| 1.5–2 | 34/48 | 11 | 3 | 0 |
|
|
8777
|
+
| 2–3 | 36/44 | 5 | 3 | 0 |
|
|
8778
|
+
| 3–4 | 33/34 | 1 | 0 | 0 |
|
|
8779
|
+
| 4 and over | 29/32 | 3 | 0 | 0 |
|
|
8636
8780
|
|
|
8637
8781
|
**Method.** The truth set is `examples/spineboy`: its setup pose and one frame of
|
|
8638
8782
|
`walk` (a third of the way in). For every trial the figure is rendered twice with one
|
|
@@ -8654,25 +8798,42 @@ part has a low per-pixel gradient and still places — and size alone has no
|
|
|
8654
8798
|
measurable effect across 24–192 px at any level.
|
|
8655
8799
|
|
|
8656
8800
|
**The edge**, derived by `deriveFloor` as the largest rung of a fixed ladder under
|
|
8657
|
-
which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **
|
|
8658
|
-
of the trials were found (
|
|
8801
|
+
which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **8.2%**
|
|
8802
|
+
of the trials were found (10/122); at or over it, **72.8%** (217/298). A part is below
|
|
8659
8803
|
the floor when its `detail` is under 0.5, or when its own longest side is under 24 px,
|
|
8660
8804
|
the smallest size measured (there, nothing measured says `pose` can place it).
|
|
8661
8805
|
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8806
|
+
**What the search carries, and why.** The coarse pass scores the part at anchor
|
|
8807
|
+
cells an **eighth** of its own span apart, and every scale rung sends its **five**
|
|
8808
|
+
best distinct places down to the finer levels. Both were set on this grid (issue
|
|
8809
|
+
#865): of the 65 failures whose truth scored better than every answer reported, 38
|
|
8810
|
+
had a truth that beat its whole scale rung at the coarse level while the anchor cell
|
|
8811
|
+
holding it did not — at a quarter-span stride a thin gun or a gapped fist falls between
|
|
8812
|
+
two anchors — and only 3 had it ranked under the old cutoff of three. The finer grid
|
|
8813
|
+
took the grid from 185 to 227 trials found and those misses from 65 to 14, for about
|
|
8814
|
+
10–14% more CPU over the grid (615 s of user time before it, 678 s and 704 s on two runs after).
|
|
8815
|
+
|
|
8816
|
+
⚠️ **What this is and is not.** The grid is not a reliability line: four native cells
|
|
8817
|
+
place 14 or 15 of 15 and the trials at `detail` 3 and over place 62 of 66, but the
|
|
8818
|
+
goggles still place 10 of 42, and a threshold read off two dozen parts of one figure
|
|
8819
|
+
would be a claim about that figure. What the grid can separate is **whose** failure a
|
|
8820
|
+
failure is. `bun tools/pose_floor.ts` prints the residual at the truth for every
|
|
8821
|
+
failed trial and marks it **missed** when the truth scores better than everything
|
|
8822
|
+
reported and nothing reported is on it — a failure the search could fix. The other
|
|
8823
|
+
failures are the objective preferring another placement (a blurred part shrunk into
|
|
8824
|
+
its own region scores better than its truth) or the truth reported and tied, and no
|
|
8825
|
+
search can fix those. After the change, 14 of the 193 failed trials are misses, and 13
|
|
8826
|
+
of those sit within 7 px of the truth rather than on another hill. All this was
|
|
8827
|
+
measured on **ideal cuts**, the frame's own pixels, where a residual at the truth is
|
|
8828
|
+
near zero. A generated cut also disagrees with its picture, which can only make it
|
|
8829
|
+
harder to place. So the edge is where `pose` fails even on ideal cuts, and never a
|
|
8830
|
+
guarantee above it.
|
|
8671
8831
|
|
|
8672
8832
|
Re-run it with `bun tools/pose_floor.ts --textures <level> --json <out>` (one level per
|
|
8673
8833
|
process parallelises it), then `bun tools/pose_floor.ts --from <a.json>,<b.json>,…` to
|
|
8674
|
-
print the table
|
|
8675
|
-
its cells
|
|
8834
|
+
print the table, derive the edge and list the misses per cell. `PO22` in
|
|
8835
|
+
`bun run selftest` re-measures three of its cells, `PO24` holds those three cells to
|
|
8836
|
+
no miss at all, and `PO23` places the 48 px fist this change was made for.
|
|
8676
8837
|
|
|
8677
8838
|
## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
|
|
8678
8839
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, round-trips it through spine-core and named assertions, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/atlas.ts
CHANGED
|
@@ -518,6 +518,37 @@ export interface EmitPage {
|
|
|
518
518
|
* ([`src/validate.ts`](validate.ts)) and `artUvsOf`
|
|
519
519
|
* ([`src/render.ts`](render.ts)), which returns no art UVs for a turned region
|
|
520
520
|
* attachment, so `check`'s texture substitution would report it unmatched.
|
|
521
|
+
*
|
|
522
|
+
* 📏 **Measured again for issue #866, on twelve region sets, and held back again.**
|
|
523
|
+
* The sets are the two painting rigs above and ten production rigs of 20 to 22
|
|
524
|
+
* parts. Each was packed by this file's own search at padding 2 twice: as
|
|
525
|
+
* shipped, and with every cell also tried turned under the same BSSF score
|
|
526
|
+
* (ties go to the unturned cell). The shipped search reproduced the page every
|
|
527
|
+
* one of the twelve atlases was written at. Area change from turning:
|
|
528
|
+
*
|
|
529
|
+
* | set | `pot` | `pot` + turn | `free` | `free` + turn |
|
|
530
|
+
* | --- | --- | --- | --- | --- |
|
|
531
|
+
* | 22-part painting | 1024x2048 | 0.00 % | 1888x697 | −1.14 % |
|
|
532
|
+
* | 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
|
|
533
|
+
* | P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
|
|
534
|
+
* | P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
|
|
535
|
+
* | P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
|
|
536
|
+
* | P4 | 1024x2048 | 0.00 % | 864x1346 | −6.16 % |
|
|
537
|
+
* | P5 | 2048x2048 | 0.00 % | 1344x1768 | −13.37 % |
|
|
538
|
+
* | P6 | 1024x2048 | 0.00 % | 800x1934 | −1.62 % |
|
|
539
|
+
* | P7 | 512x2048 | 0.00 % | 1024x928 | −0.05 % |
|
|
540
|
+
* | P8 | 512x2048 | 0.00 % | 608x1250 | −4.96 % |
|
|
541
|
+
* | P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
|
|
542
|
+
* | P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
|
|
543
|
+
*
|
|
544
|
+
* Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
|
|
545
|
+
* it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
|
|
546
|
+
* noise: the nearest other width on the `FREE_EDGE_STEP` grid moves P5's
|
|
547
|
+
* unturned page by 18.13 %, and searching every width instead of every 32nd
|
|
548
|
+
* finds an unturned P5 page 10.22 % smaller with nothing turned. With every
|
|
549
|
+
* width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
|
|
550
|
+
* (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
|
|
551
|
+
* packer still never turns a region.
|
|
521
552
|
*/
|
|
522
553
|
export const PACK_NO_ROTATE = 0;
|
|
523
554
|
|
package/src/pose.ts
CHANGED
|
@@ -116,15 +116,63 @@ export const COARSE_LONG_SIDE = 40;
|
|
|
116
116
|
*/
|
|
117
117
|
export const COARSE_PART_SPAN = 10;
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Pixels the part's SHORT side must still span at the coarse level — the same
|
|
121
|
+
* argument as `COARSE_PART_SPAN`, applied to the axis that rule does not read.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ Found by the change that tightened the coarse grid (issue #865), not by the
|
|
124
|
+
* floor grid, whose parts are none of them thin enough to reach it. MOTION.md
|
|
125
|
+
* §6's 60x14 signal arm is 3.5 px thick at the 4x level its length chose; on the
|
|
126
|
+
* quarter-span grid an anchor cell happened to sit where refinement found its
|
|
127
|
+
* truth (residual 0.0305), and on the eighth-span grid none did, so pose B
|
|
128
|
+
* reported a placement 15 px down the arm at 0.1482. A part three pixels thick
|
|
129
|
+
* at the coarse level has no across-the-part signal for any grid to read. 4, 5
|
|
130
|
+
* and 6 were each measured to move that arm up one level and place it; 4 is the
|
|
131
|
+
* least, and at 4 no trial of the floor grid moves at all.
|
|
132
|
+
*/
|
|
133
|
+
const COARSE_PART_THICKNESS = 4;
|
|
134
|
+
|
|
119
135
|
/**
|
|
120
136
|
* Coarse anchor positions are stepped at a fraction of the part's own size.
|
|
121
137
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
138
|
+
* Scanning every pixel of the coarse level would make the scan's cost grow with
|
|
139
|
+
* the frame's area instead of with the number of places the part could plausibly
|
|
140
|
+
* be, so the anchors are strided. How far apart is the part's own business.
|
|
141
|
+
*
|
|
142
|
+
* ⚠️ An eighth, not the quarter it was, and the reason is measured rather than
|
|
143
|
+
* assumed (issue #865). "The objective varies over distances of order the part"
|
|
144
|
+
* is true of a solid part and false of a thin or holey one: a gun whose material
|
|
145
|
+
* is a third of its box, or a fist with gaps between the fingers, loses most of
|
|
146
|
+
* its fit a quarter-span off its truth. On `tools/pose_floor.ts`'s grid, 65
|
|
147
|
+
* failed trials had a truth that scored better than the answer reported and was
|
|
148
|
+
* missing from the final list; in 38 of them the truth, evaluated exactly at the
|
|
149
|
+
* coarse level, beat every cell of its scale rung, and the anchor CELL holding it
|
|
150
|
+
* did not — the basin fell between two anchors. A cutoff on how many minima
|
|
151
|
+
* survive (3 of the 65) was not what lost them. At an eighth the grid found
|
|
152
|
+
* 226 of 420 trials against 185 (with `MINIMA_PER_SCALE` still at three), and
|
|
153
|
+
* the search misses fell from 65 to 15, for 10–14% more CPU over the grid.
|
|
154
|
+
*
|
|
155
|
+
* 🔸 Not free of losses, and they are stated rather than averaged away: 9 trials
|
|
156
|
+
* that placed on the quarter grid do not on this one. Five of them are blurred
|
|
157
|
+
* parts that now come back ambiguous between two scales at the same spot, or
|
|
158
|
+
* 2.0 px off against a 2 px bar; three are a basin reached and then polished
|
|
159
|
+
* onto a worse placement beside it, and one a basin ranked out of the
|
|
160
|
+
* refinement's twelve — the refinement, not this grid.
|
|
161
|
+
*/
|
|
162
|
+
export const COARSE_STRIDE_FRACTION = 0.125;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Grid cells around a coarse cell that must not beat it for it to be a minimum,
|
|
166
|
+
* and the spacing kept between the minima one scale rung sends down.
|
|
167
|
+
*
|
|
168
|
+
* Four at an eighth-span stride is, nominally, the half-span the old two cells
|
|
169
|
+
* at a quarter held (the stride is rounded to whole level pixels, so only
|
|
170
|
+
* nominally). Measured on the grid: leaving it at two when the stride halved let one
|
|
171
|
+
* wrong hill fill a rung's `MINIMA_PER_SCALE` with its own neighbours — 223
|
|
172
|
+
* trials found rather than 226, and 12 that placed on the old grid lost rather
|
|
173
|
+
* than 9 (both at three minima per rung).
|
|
126
174
|
*/
|
|
127
|
-
|
|
175
|
+
const COARSE_SUPPRESSION_CELLS = 4;
|
|
128
176
|
|
|
129
177
|
/** How many scale rungs one octave gets in the coarse ladder. */
|
|
130
178
|
export const SCALE_STEPS_PER_OCTAVE = 3;
|
|
@@ -180,8 +228,20 @@ export const BACKGROUND_TOLERANCE = 10;
|
|
|
180
228
|
/** Share of the frame's border ring one colour must hold before it is called the background. */
|
|
181
229
|
export const BACKGROUND_BORDER_SHARE = 0.6;
|
|
182
230
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
231
|
+
/**
|
|
232
|
+
* How many distinct places each coarse scale rung sends down for refinement.
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ Five rather than three, and read off the grid rather than off a probe
|
|
235
|
+
* (issue #865). The card's own probe raised this to ten and recovered the fist it
|
|
236
|
+
* was chasing; on the full grid that bought 2 trials net (3 gained, 1 lost),
|
|
237
|
+
* because only 3 of the 65 trials the search missed had their truth's basin
|
|
238
|
+
* ranked under the cutoff — the rest had fallen between anchor cells, which is
|
|
239
|
+
* `COARSE_STRIDE_FRACTION`'s job. With the stride fixed, one miss of that kind was
|
|
240
|
+
* left (a 48 px fist, its basin fifth on its rung), and five is the count that
|
|
241
|
+
* carries it: 227 trials found against 226 at three, none lost, and a CPU cost
|
|
242
|
+
* inside the grid's run-to-run noise.
|
|
243
|
+
*/
|
|
244
|
+
const MINIMA_PER_SCALE = 5;
|
|
185
245
|
|
|
186
246
|
/** How many candidates survive each refinement level. */
|
|
187
247
|
const REFINE_CANDIDATES = 12;
|
|
@@ -224,8 +284,8 @@ export const POSE_FLOOR = {
|
|
|
224
284
|
span: 24,
|
|
225
285
|
detail: 0.5,
|
|
226
286
|
/** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
|
|
227
|
-
rateBelow: 0.
|
|
228
|
-
rateAbove: 0.
|
|
287
|
+
rateBelow: 0.082,
|
|
288
|
+
rateAbove: 0.728,
|
|
229
289
|
};
|
|
230
290
|
|
|
231
291
|
/** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
|
|
@@ -266,8 +326,13 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
|
|
|
266
326
|
// ⚠️ "Above the floor" is not "the search found the right hill". Measured on
|
|
267
327
|
// the floor's own material, a part above it came back ambiguous between two
|
|
268
328
|
// equally WRONG placements (residual 0.148 each, 173 px off) while the truth
|
|
269
|
-
// scored 0.035 — a basin the coarse pass never sent down.
|
|
270
|
-
//
|
|
329
|
+
// scored 0.035 — a basin the coarse pass never sent down. Issue #865 fixed
|
|
330
|
+
// that one (the coarse grid had stepped over it), and the reading still
|
|
331
|
+
// occurs: on the grid after it, 10 ambiguous trials above the floor had a
|
|
332
|
+
// truth scoring better than both answers — native 24 px gun and shin, 48 px
|
|
333
|
+
// arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
|
|
334
|
+
// up. Nine of the ten sat 2–6 px from the truth rather than on another hill. So the sentence names that reading too, with the one remedy that
|
|
335
|
+
// separates it: a window.
|
|
271
336
|
return (
|
|
272
337
|
`${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
|
|
273
338
|
(verdict === 'refused'
|
|
@@ -1623,9 +1688,18 @@ function placePart(
|
|
|
1623
1688
|
// only about the frame. The pyramid stops when the frame fits in
|
|
1624
1689
|
// COARSE_LONG_SIDE; this then walks back UP it until the part still spans
|
|
1625
1690
|
// COARSE_PART_SPAN pixels there, because a level that has reduced the part to
|
|
1626
|
-
// three pixels cannot say where the part is at any price
|
|
1691
|
+
// three pixels cannot say where the part is at any price — and until its short
|
|
1692
|
+
// side still spans COARSE_PART_THICKNESS, which is the same sentence about a
|
|
1693
|
+
// part that is long and thin.
|
|
1627
1694
|
let coarseIndex = levels.length - 1;
|
|
1628
|
-
|
|
1695
|
+
const thickness = Math.min(tw, th);
|
|
1696
|
+
while (
|
|
1697
|
+
coarseIndex > 0 &&
|
|
1698
|
+
((span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN ||
|
|
1699
|
+
(thickness * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_THICKNESS)
|
|
1700
|
+
) {
|
|
1701
|
+
coarseIndex--;
|
|
1702
|
+
}
|
|
1629
1703
|
const coarse = levels[coarseIndex];
|
|
1630
1704
|
const spanAtCoarse = (span * scaleReference) / coarse.reduction;
|
|
1631
1705
|
const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
|
|
@@ -1635,7 +1709,7 @@ function placePart(
|
|
|
1635
1709
|
for (const scale of scales) {
|
|
1636
1710
|
const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
|
|
1637
1711
|
grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
|
|
1638
|
-
candidates.push(...localMinima(field,
|
|
1712
|
+
candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
|
|
1639
1713
|
}
|
|
1640
1714
|
base.coarse = grid;
|
|
1641
1715
|
candidates.sort((a, b) => a.residual - b.residual);
|
package/src/render.ts
CHANGED
|
@@ -336,6 +336,17 @@ export interface PoseOptions {
|
|
|
336
336
|
* one instrument that does (`bonedist.ts`) needs it on every frame.
|
|
337
337
|
*/
|
|
338
338
|
bones?: boolean;
|
|
339
|
+
/**
|
|
340
|
+
* Also record every slot's attachment geometry, whole — see `AttachmentPose`
|
|
341
|
+
* and `Frame.attachments` (issue #864). Off by default for `bones`' reason:
|
|
342
|
+
* nothing that draws reads it, and `render --geometry` is the one caller.
|
|
343
|
+
*
|
|
344
|
+
* ⭐ **It is not filtered by `slots`/`hidden` and not cut by a clip.** Those
|
|
345
|
+
* are statements about which pixels are drawn; the geometry is a statement
|
|
346
|
+
* about where the pose put each attachment, and it is the same pose whatever
|
|
347
|
+
* a picture of it leaves out.
|
|
348
|
+
*/
|
|
349
|
+
geometry?: boolean;
|
|
339
350
|
/**
|
|
340
351
|
* Pose under this skin, by the name the skeleton declares for it.
|
|
341
352
|
*
|
|
@@ -635,6 +646,44 @@ export interface Frame {
|
|
|
635
646
|
* the two rigs.
|
|
636
647
|
*/
|
|
637
648
|
bones?: BoneSnapshot[];
|
|
649
|
+
/**
|
|
650
|
+
* Every slot's attachment as the pose left it, in draw order — present only
|
|
651
|
+
* when `PoseOptions.geometry` asked for it (issue #864).
|
|
652
|
+
*
|
|
653
|
+
* ⚠️ Not `pieces` again. A piece is what gets DRAWN: `--slot`/`--hide` remove
|
|
654
|
+
* pieces and a clip replaces one with the clipper's own triangle list, whose
|
|
655
|
+
* vertices are not the attachment's and are not numbered like them. A
|
|
656
|
+
* consumer comparing a triangle's edges across frames needs vertex `i` to be
|
|
657
|
+
* the same vertex in every frame, so these are the attachment's own vertices,
|
|
658
|
+
* whole, for every slot that shows a region or a mesh.
|
|
659
|
+
*
|
|
660
|
+
* Read off the same skeleton at the same step as `pieces` and `bones`, which
|
|
661
|
+
* is what puts it on render's frame grid by construction rather than by a
|
|
662
|
+
* second derivation of it.
|
|
663
|
+
*/
|
|
664
|
+
attachments?: AttachmentPose[];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* One slot's region or mesh attachment in one posed frame (issue #864).
|
|
669
|
+
*
|
|
670
|
+
* `vertices` come from the runtime's own `computeWorldVertices` over the whole
|
|
671
|
+
* attachment — the call `pieceOf` makes, through the one helper both share —
|
|
672
|
+
* so skinning and deform live in spine-core and nowhere here.
|
|
673
|
+
*/
|
|
674
|
+
export interface AttachmentPose {
|
|
675
|
+
slot: string;
|
|
676
|
+
/** The attachment's own name, which is what a deform or attachment timeline keys. */
|
|
677
|
+
attachment: string;
|
|
678
|
+
/**
|
|
679
|
+
* World positions, `x, y` per vertex, **y up**. A region's four corners are in
|
|
680
|
+
* spine-core's order — bottom-left, top-left, top-right, bottom-right — and a
|
|
681
|
+
* mesh's vertices in the attachment's own order, so index `i` names the same
|
|
682
|
+
* vertex in every frame.
|
|
683
|
+
*/
|
|
684
|
+
vertices: number[];
|
|
685
|
+
/** Slot colour x attachment colour, straight alpha, 0..1 — the piece's `tint`, by the same arithmetic. */
|
|
686
|
+
color: [number, number, number, number];
|
|
638
687
|
}
|
|
639
688
|
|
|
640
689
|
/** Where the world sits in a frame: the four world numbers plus the scale. */
|
|
@@ -732,6 +781,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
|
|
|
732
781
|
time: i * step,
|
|
733
782
|
pieces: piecesOf(skeleton, pieceOpts),
|
|
734
783
|
...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
|
|
784
|
+
...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
|
|
735
785
|
});
|
|
736
786
|
}
|
|
737
787
|
return frames;
|
|
@@ -746,20 +796,31 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
|
|
|
746
796
|
* whole content is the setup pose.
|
|
747
797
|
*/
|
|
748
798
|
export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[] {
|
|
749
|
-
const skeleton =
|
|
750
|
-
skeleton.setupPose();
|
|
751
|
-
skeleton.update(0);
|
|
752
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
799
|
+
const skeleton = setupPosed(data, opts?.skin);
|
|
753
800
|
return [
|
|
754
801
|
{
|
|
755
802
|
index: 0,
|
|
756
803
|
time: 0,
|
|
757
804
|
pieces: piecesOf(skeleton, piecesOptions(opts)),
|
|
758
805
|
...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
|
|
806
|
+
...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
|
|
759
807
|
},
|
|
760
808
|
];
|
|
761
809
|
}
|
|
762
810
|
|
|
811
|
+
/**
|
|
812
|
+
* A fresh skeleton under `skin`, stepped into its setup pose — the one recipe
|
|
813
|
+
* `sampleSetupPose` and the geometry export's `rest` table both pose from, so
|
|
814
|
+
* the two cannot come to describe different rest poses.
|
|
815
|
+
*/
|
|
816
|
+
function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
|
|
817
|
+
const skeleton = skeletonUnderSkin(data, skin);
|
|
818
|
+
skeleton.setupPose();
|
|
819
|
+
skeleton.update(0);
|
|
820
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
821
|
+
return skeleton;
|
|
822
|
+
}
|
|
823
|
+
|
|
763
824
|
/**
|
|
764
825
|
* Every animation of one skeleton at one rate, keyed by the name its frames are
|
|
765
826
|
* filed under. A skeleton with no animation at all contributes its setup pose
|
|
@@ -877,14 +938,7 @@ function pieceOf(
|
|
|
877
938
|
'the attachment names a region the atlas does not have',
|
|
878
939
|
);
|
|
879
940
|
}
|
|
880
|
-
const
|
|
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
|
-
];
|
|
941
|
+
const tint = tintOf(slot, attachment);
|
|
888
942
|
// The dark colour is the SLOT's alone — an attachment has a `color` and no
|
|
889
943
|
// dark one, so there is nothing to multiply it by. Read off `appliedPose`
|
|
890
944
|
// like the light colour, so an `rgba2` timeline reaches the picture.
|
|
@@ -897,18 +951,11 @@ function pieceOf(
|
|
|
897
951
|
|
|
898
952
|
let piece: Piece;
|
|
899
953
|
let triangles: number[];
|
|
954
|
+
const world = worldVerticesOf(skeleton, slot, attachment);
|
|
900
955
|
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
956
|
triangles = attachment.triangles;
|
|
908
957
|
piece = { kind: 'mesh', ...common, texture, world, uvs, triangles };
|
|
909
958
|
} else {
|
|
910
|
-
const world = new Array<number>(8).fill(0);
|
|
911
|
-
attachment.computeWorldVertices(slot, attachment.getOffsets(pose), world, 0, 2);
|
|
912
959
|
triangles = QUAD_TRIANGLES;
|
|
913
960
|
piece = { kind: 'region', ...common, texture, world, uvs };
|
|
914
961
|
}
|
|
@@ -916,6 +963,56 @@ function pieceOf(
|
|
|
916
963
|
return clippedPiece(piece, triangles, uvs, clipper);
|
|
917
964
|
}
|
|
918
965
|
|
|
966
|
+
/** Slot colour x attachment colour, straight alpha — what a piece is tinted by and a geometry entry records. */
|
|
967
|
+
function tintOf(slot: Slot, attachment: MeshAttachment | RegionAttachment): [number, number, number, number] {
|
|
968
|
+
const colour = slot.appliedPose.color;
|
|
969
|
+
const own = attachment.color;
|
|
970
|
+
return [colour.r * own.r, colour.g * own.g, colour.b * own.b, colour.a * own.a];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* One attachment's world vertices on `slot`, whole, by the runtime's own routine.
|
|
975
|
+
*
|
|
976
|
+
* The one place either kind is asked for them: `pieceOf` draws what this returns
|
|
977
|
+
* and `attachmentsOf` records it, so a drawn frame and its geometry export
|
|
978
|
+
* cannot disagree about where a vertex is.
|
|
979
|
+
*
|
|
980
|
+
* `worldVerticesLength` is 2 per vertex whether or not the mesh is weighted —
|
|
981
|
+
* the weight runs live in `vertices`, not here — so this is the full output
|
|
982
|
+
* length and the whole mesh is computed in one call. Deform offsets, if the
|
|
983
|
+
* slot's pose carries any, are applied inside it; a region's offsets are read
|
|
984
|
+
* for the sequence frame the slot's pose resolves.
|
|
985
|
+
*/
|
|
986
|
+
function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachment | RegionAttachment): number[] {
|
|
987
|
+
if (attachment instanceof MeshAttachment) {
|
|
988
|
+
const world = new Array<number>(attachment.worldVerticesLength).fill(0);
|
|
989
|
+
attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
|
|
990
|
+
return world;
|
|
991
|
+
}
|
|
992
|
+
const world = new Array<number>(8).fill(0);
|
|
993
|
+
attachment.computeWorldVertices(slot, attachment.getOffsets(slot.appliedPose), world, 0, 2);
|
|
994
|
+
return world;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Every slot's region or mesh attachment, whole, in the posed draw order — see
|
|
999
|
+
* `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
|
|
1000
|
+
*/
|
|
1001
|
+
function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
|
|
1002
|
+
const out: AttachmentPose[] = [];
|
|
1003
|
+
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
1004
|
+
const attachment = slot.appliedPose.attachment;
|
|
1005
|
+
if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
|
|
1006
|
+
out.push({
|
|
1007
|
+
slot: slot.data.name,
|
|
1008
|
+
attachment: attachment.name,
|
|
1009
|
+
vertices: worldVerticesOf(skeleton, slot, attachment),
|
|
1010
|
+
color: tintOf(slot, attachment),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
919
1016
|
/**
|
|
920
1017
|
* `piece` as the active clip leaves it, or `piece` itself when the clipper cut
|
|
921
1018
|
* nothing — see `piecesOf`.
|
|
@@ -951,6 +1048,262 @@ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clip
|
|
|
951
1048
|
return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped };
|
|
952
1049
|
}
|
|
953
1050
|
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
// the geometry export — `render --geometry` (issue #864)
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
//
|
|
1055
|
+
// ⭐ A frame set is pixels, and two judgements a consumer that does not link
|
|
1056
|
+
// spine-core wants to make are not about pixels: how far a mesh triangle is
|
|
1057
|
+
// stretched over its rest shape, and whether a region holds still in its own
|
|
1058
|
+
// bone's frame. Both need the numbers the pose was drawn FROM. So `render` writes
|
|
1059
|
+
// them beside the pictures, off the very `Frame`s it drew — one call, one frame
|
|
1060
|
+
// grid, one viewport — rather than a second command re-deriving any of the three.
|
|
1061
|
+
|
|
1062
|
+
/** The export's file name inside an animation's frame directory, and its format tag. */
|
|
1063
|
+
export const GEOMETRY_FILE = 'geometry.json';
|
|
1064
|
+
export const GEOMETRY_SPEC = 'rigc-geometry/1';
|
|
1065
|
+
/** Stated in the file, so a reader cannot take the numbers for frame pixels. */
|
|
1066
|
+
export const GEOMETRY_COORDINATES = 'spine world, y up, world units';
|
|
1067
|
+
|
|
1068
|
+
/** A geometry file's frame: `Frame` reduced to the numbers the export promises. */
|
|
1069
|
+
export interface GeometryFrame {
|
|
1070
|
+
index: number;
|
|
1071
|
+
time: number;
|
|
1072
|
+
bones: GeometryBone[];
|
|
1073
|
+
attachments: AttachmentPose[];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** One bone's world transform: `world = [a b; c d]·local + (worldX, worldY)`. */
|
|
1077
|
+
export interface GeometryBone {
|
|
1078
|
+
name: string;
|
|
1079
|
+
a: number;
|
|
1080
|
+
b: number;
|
|
1081
|
+
c: number;
|
|
1082
|
+
d: number;
|
|
1083
|
+
worldX: number;
|
|
1084
|
+
worldY: number;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* One attachment's rest geometry and its topology — the half of a stretch ratio
|
|
1089
|
+
* no frame carries.
|
|
1090
|
+
*
|
|
1091
|
+
* ⭐ **Rest is the setup pose's bones with no deform**, taken for every
|
|
1092
|
+
* attachment any frame of the file shows — including one the setup pose does not
|
|
1093
|
+
* show, which a slot only swaps to later. For an attachment the setup pose does
|
|
1094
|
+
* show, these vertices are the `setup` entry's own, bit for bit: both come off
|
|
1095
|
+
* one skeleton posed by `setupPosed`.
|
|
1096
|
+
*/
|
|
1097
|
+
export interface AttachmentRest {
|
|
1098
|
+
slot: string;
|
|
1099
|
+
attachment: string;
|
|
1100
|
+
kind: 'region' | 'mesh';
|
|
1101
|
+
vertices: number[];
|
|
1102
|
+
/** Vertex index triplets. A region's are the runtime's own `0 1 2 2 3 0`. */
|
|
1103
|
+
triangles: number[];
|
|
1104
|
+
/** A mesh's hull vertex count — the first `hull` vertices, as the format's `hull` field counts them. */
|
|
1105
|
+
hull?: number;
|
|
1106
|
+
/** A mesh's `uvs`, `u, v` per vertex over the untrimmed drawing, y down — the attachment's own, not a page's. */
|
|
1107
|
+
uvs?: number[];
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export interface GeometryFile {
|
|
1111
|
+
spec: string;
|
|
1112
|
+
coordinates: string;
|
|
1113
|
+
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
1114
|
+
animation: string | null;
|
|
1115
|
+
skin?: string;
|
|
1116
|
+
fps: number;
|
|
1117
|
+
/** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
|
|
1118
|
+
viewport: FramesSidecar['viewport'];
|
|
1119
|
+
/** Every bone in the skeleton's declaration order, and its parent's name. */
|
|
1120
|
+
bones: Array<{ name: string; parent: string | null }>;
|
|
1121
|
+
/**
|
|
1122
|
+
* Every slot in the skeleton's declaration order, and the bone it hangs from —
|
|
1123
|
+
* which is the bone whose frame "still in its own bone's frame" is read in.
|
|
1124
|
+
*/
|
|
1125
|
+
slots: Array<{ name: string; bone: string }>;
|
|
1126
|
+
rest: AttachmentRest[];
|
|
1127
|
+
/** The setup pose, sampled the way `sampleSetupPose` samples it. */
|
|
1128
|
+
setup: Omit<GeometryFrame, 'index' | 'time'>;
|
|
1129
|
+
frames: GeometryFrame[];
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* A number the export could not print as a number — refused rather than written,
|
|
1134
|
+
* because `JSON.stringify` writes `NaN` and `Infinity` as `null` and a consumer
|
|
1135
|
+
* would read a hole in the geometry as a vertex at nothing.
|
|
1136
|
+
*/
|
|
1137
|
+
export class GeometryError extends Error {}
|
|
1138
|
+
|
|
1139
|
+
/** `frames.json`'s viewport block for `v` — the one spelling both files use. */
|
|
1140
|
+
export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
|
|
1141
|
+
return {
|
|
1142
|
+
x: v.minX,
|
|
1143
|
+
y: v.minY,
|
|
1144
|
+
width: v.maxX - v.minX,
|
|
1145
|
+
height: v.maxY - v.minY,
|
|
1146
|
+
scale: v.scale,
|
|
1147
|
+
pixelWidth: v.width,
|
|
1148
|
+
pixelHeight: v.height,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The geometry file for one frame set — `frames` exactly as a sampler returned
|
|
1154
|
+
* them with `{ bones: true, geometry: true }`, which is what makes its grid the
|
|
1155
|
+
* frame set's own.
|
|
1156
|
+
*
|
|
1157
|
+
* Refused by `GeometryError`, naming the frame, the slot, the attachment and the
|
|
1158
|
+
* vertex (or the bone), when any number in it is not finite.
|
|
1159
|
+
*/
|
|
1160
|
+
export function geometryFileOf(
|
|
1161
|
+
data: SkeletonData,
|
|
1162
|
+
animation: string | null,
|
|
1163
|
+
fps: number,
|
|
1164
|
+
frames: Frame[],
|
|
1165
|
+
viewport: Viewport,
|
|
1166
|
+
skin: string | undefined,
|
|
1167
|
+
): GeometryFile {
|
|
1168
|
+
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1169
|
+
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
1170
|
+
throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
|
|
1174
|
+
attachments: frame.attachments,
|
|
1175
|
+
};
|
|
1176
|
+
};
|
|
1177
|
+
const posed = frames.map((frame) => ({
|
|
1178
|
+
index: frame.index,
|
|
1179
|
+
time: frame.time,
|
|
1180
|
+
...geometryFrame(frame, `frame ${frame.index}`),
|
|
1181
|
+
}));
|
|
1182
|
+
const setupFrame = sampleSetupPose(data, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
|
|
1183
|
+
const setup = geometryFrame(setupFrame, 'the setup pose');
|
|
1184
|
+
const file: GeometryFile = {
|
|
1185
|
+
spec: GEOMETRY_SPEC,
|
|
1186
|
+
coordinates: GEOMETRY_COORDINATES,
|
|
1187
|
+
animation,
|
|
1188
|
+
...(skin === undefined ? {} : { skin }),
|
|
1189
|
+
fps,
|
|
1190
|
+
viewport: sidecarViewport(viewport),
|
|
1191
|
+
bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
|
|
1192
|
+
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
1193
|
+
rest: restOf(data, skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
|
|
1194
|
+
setup,
|
|
1195
|
+
frames: posed,
|
|
1196
|
+
};
|
|
1197
|
+
refuseNonFinite(file);
|
|
1198
|
+
return file;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* The rest table: every (slot, attachment) the given frames show, in order of
|
|
1203
|
+
* first appearance, posed on one setup skeleton with the slot's deform empty.
|
|
1204
|
+
*
|
|
1205
|
+
* The attachment is resolved the way the pose resolved it — `Skeleton.getAttachment`,
|
|
1206
|
+
* the skin first and the default skin second — so a name means the object the
|
|
1207
|
+
* frames drew. A region's rest corners are read for the sequence frame the setup
|
|
1208
|
+
* pose resolves, which is the frame its setup pose draws.
|
|
1209
|
+
*/
|
|
1210
|
+
function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentPose[][]): AttachmentRest[] {
|
|
1211
|
+
const skeleton = setupPosed(data, skin);
|
|
1212
|
+
// Slot, then attachment: two maps rather than one joined key, so no pair of
|
|
1213
|
+
// names can fold into another's entry whatever characters they carry.
|
|
1214
|
+
const seen = new Map<string, Set<string>>();
|
|
1215
|
+
const out: AttachmentRest[] = [];
|
|
1216
|
+
for (const entries of shown) {
|
|
1217
|
+
for (const entry of entries) {
|
|
1218
|
+
const names = seen.get(entry.slot) ?? new Set<string>();
|
|
1219
|
+
if (names.has(entry.attachment)) continue;
|
|
1220
|
+
names.add(entry.attachment);
|
|
1221
|
+
seen.set(entry.slot, names);
|
|
1222
|
+
const slotIndex = data.findSlot(entry.slot)?.index ?? -1;
|
|
1223
|
+
const slot = skeleton.slots[slotIndex];
|
|
1224
|
+
const attachment = slot === undefined ? null : skeleton.getAttachment(slotIndex, entry.attachment);
|
|
1225
|
+
if (slot === undefined || !(attachment instanceof MeshAttachment || attachment instanceof RegionAttachment)) {
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
`slot ${JSON.stringify(entry.slot)} showed attachment ${JSON.stringify(entry.attachment)} in a frame, and ` +
|
|
1228
|
+
'the setup skeleton resolves no region or mesh of that name there',
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
// A mesh reads the slot's deform array; the setup pose leaves it empty,
|
|
1232
|
+
// and emptying it here says so rather than trusting that it is.
|
|
1233
|
+
slot.appliedPose.deform.length = 0;
|
|
1234
|
+
const vertices = worldVerticesOf(skeleton, slot, attachment);
|
|
1235
|
+
out.push(
|
|
1236
|
+
attachment instanceof MeshAttachment
|
|
1237
|
+
? {
|
|
1238
|
+
slot: entry.slot,
|
|
1239
|
+
attachment: entry.attachment,
|
|
1240
|
+
kind: 'mesh',
|
|
1241
|
+
vertices,
|
|
1242
|
+
triangles: Array.from(attachment.triangles),
|
|
1243
|
+
hull: attachment.hullLength / 2,
|
|
1244
|
+
uvs: Array.from(attachment.regionUVs),
|
|
1245
|
+
}
|
|
1246
|
+
: { slot: entry.slot, attachment: entry.attachment, kind: 'region', vertices, triangles: [...QUAD_TRIANGLES] },
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return out;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
|
|
1254
|
+
function refuseNonFinite(file: GeometryFile): void {
|
|
1255
|
+
const check = (where: string, entries: AttachmentPose[] | AttachmentRest[], bones: GeometryBone[]): void => {
|
|
1256
|
+
for (const bone of bones) {
|
|
1257
|
+
for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
|
|
1258
|
+
if (!Number.isFinite(bone[field])) {
|
|
1259
|
+
throw new GeometryError(`${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
for (const entry of entries) {
|
|
1264
|
+
const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
|
|
1265
|
+
if (bad === -1) continue;
|
|
1266
|
+
throw new GeometryError(
|
|
1267
|
+
`${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
|
|
1268
|
+
`${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`,
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
check('the rest table', file.rest, []);
|
|
1273
|
+
check('the setup pose', file.setup.attachments, file.setup.bones);
|
|
1274
|
+
for (const frame of file.frames) check(`frame ${frame.index}`, frame.attachments, frame.bones);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* The file's text: a JSON object whose header fields sit one per line and whose
|
|
1279
|
+
* `rest` entries and `frames` sit one per line each, so a diff of two exports
|
|
1280
|
+
* names the frame that moved.
|
|
1281
|
+
*
|
|
1282
|
+
* Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
|
|
1283
|
+
* them — the shortest decimal that reads back as the same double, fixed by the
|
|
1284
|
+
* language rather than a locale — so a vertex in the file IS the runtime's
|
|
1285
|
+
* vertex, not a rounding of it.
|
|
1286
|
+
*/
|
|
1287
|
+
export function geometryText(file: GeometryFile): string {
|
|
1288
|
+
const lines: string[] = [];
|
|
1289
|
+
const entries = Object.entries(file);
|
|
1290
|
+
entries.forEach(([key, value], i) => {
|
|
1291
|
+
const comma = i + 1 < entries.length ? ',' : '';
|
|
1292
|
+
if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
|
|
1293
|
+
if (value.length === 0) {
|
|
1294
|
+
lines.push(` ${JSON.stringify(key)}: []${comma}`);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
lines.push(` ${JSON.stringify(key)}: [`);
|
|
1298
|
+
value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
|
|
1299
|
+
lines.push(` ]${comma}`);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
|
|
1303
|
+
});
|
|
1304
|
+
return `{\n${lines.join('\n')}\n}\n`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
954
1307
|
// ---------------------------------------------------------------------------
|
|
955
1308
|
// texture-only substitution — see `substituteTexture`
|
|
956
1309
|
// ---------------------------------------------------------------------------
|
|
@@ -1347,7 +1700,9 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
|
|
|
1347
1700
|
// A clip is taken off for the same reason (issue #844): what it removes still
|
|
1348
1701
|
// counts toward the box, so adding or keying a mask moves no pixel it leaves
|
|
1349
1702
|
// drawn — see `PoseOptions.unclipped`.
|
|
1350
|
-
|
|
1703
|
+
// Neither the bone snapshots nor the geometry export frame anything, and
|
|
1704
|
+
// both would be taken at `FRAMING_FPS` for every animation only to be dropped.
|
|
1705
|
+
const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
|
|
1351
1706
|
const framed: PoseOptions = { ...whole, unclipped: true };
|
|
1352
1707
|
const sets =
|
|
1353
1708
|
data.animations.length === 0
|