@rig-c/core 2.40.1 → 2.41.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 +15 -0
- package/docs/AUTHORING.md +123 -2
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +5 -0
- package/src/cli/core_commands.ts +85 -2
- package/src/cli/shared.ts +58 -2
- package/src/inspect.ts +751 -0
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ installed.
|
|
|
41
41
|
| the same, and one texture instead of many | `rigc build --pack` | the parts arranged onto shared atlas pages, written beside the skeleton — losslessly, so the picture is the picture |
|
|
42
42
|
| a pack somebody already made | `rigc build --atlas-in` | the same skeleton, with every part resolved to a region of that atlas — or a named refusal, never a part that silently does not draw |
|
|
43
43
|
| a compiled rig | `rigc render` | every animation as PNG frames, plus one labelled contact sheet of the whole shot |
|
|
44
|
+
| a compiled rig and a question about its geometry | `rigc inspect` | one picture of its bones, axes, a world grid or a declared axis over the art — at the setup pose, one time or a sheet of several, zoomed to a bone or a slot — and a legend that puts every mark back in world units |
|
|
44
45
|
| a compiled rig | `rigc preview` | one self-contained `.html` that plays it in Spine's own web player |
|
|
45
46
|
| two to four compiled rigs | `rigc vote` | one ballot page a human picks from, and the answer checked into a ledger |
|
|
46
47
|
| a picture of a key pose | `rigc pose` | where each loose part PNG sits in it, in spec coordinates — the movement between two poses is then yours to key ([docs/MOTION.md](docs/MOTION.md)) |
|
|
@@ -402,8 +403,22 @@ second package, no server.
|
|
|
402
403
|
```bash
|
|
403
404
|
rigc render --candidate spine # PNG frames + a contact sheet, in render/
|
|
404
405
|
rigc preview --candidate spine # one .html file that plays it: preview.html
|
|
406
|
+
rigc inspect --candidate spine --show art,bones,names --dim # inspect.png + inspect.json
|
|
405
407
|
```
|
|
406
408
|
|
|
409
|
+
**`inspect`** answers a question about geometry rather than appearance — *is this
|
|
410
|
+
bone where the art is*, *which way does it squash*, *where in rig units is this
|
|
411
|
+
point*, *is this axis held*. It draws the build's art, framed as `render` frames it,
|
|
412
|
+
and over it any of `bones` (origin to tip, a bone of length 0 marked as one),
|
|
413
|
+
`names`, `axes` (each bone's world x and y), `grid` (in world units) and `declared`
|
|
414
|
+
(each `invariants.axes` line and the bones held to it), at the setup pose, at
|
|
415
|
+
`--animation <name> --at <s[,s…]>` — one picture, or a sheet — or zoomed with
|
|
416
|
+
`--zoom <bone|slot>[:pad]` or `--viewport x,y,w,h`. Beside the PNG it writes a legend:
|
|
417
|
+
the world-to-pixel transform both ways, what each colour means, and every bone's
|
|
418
|
+
origin and tip in world units and in pixels, so a position read off the picture goes
|
|
419
|
+
straight back into the spec. [AUTHORING §13](docs/AUTHORING.md#13-asking-the-picture-a-question--rigc-inspect)
|
|
420
|
+
maps each question to its view.
|
|
421
|
+
|
|
407
422
|
**`render`** samples every animation at 12 fps and writes `render/<animation>/f0000.png…`
|
|
408
423
|
with a `contact.png` beside them — every frame of the shot as one labelled grid,
|
|
409
424
|
which is the picture to open first, because spacing is a comparison *across*
|
package/docs/AUTHORING.md
CHANGED
|
@@ -126,6 +126,12 @@ bun cli.ts preview --candidate path/to/spine # one .html that plays it in Spi
|
|
|
126
126
|
# ↳ a green build ends by naming this command for its own --out; repeat
|
|
127
127
|
# --candidate for a pane per candidate on one page
|
|
128
128
|
|
|
129
|
+
# …and when the question is about geometry — is this bone where the art is, which
|
|
130
|
+
# way does it squash — draw the bones over the art and read the legend beside it:
|
|
131
|
+
bun cli.ts inspect --candidate path/to/spine --show art,bones,names --dim --out look.png
|
|
132
|
+
# ↳ look.png and look.json: every bone's origin and tip in world units and in
|
|
133
|
+
# pixels, and the transform between them — §13
|
|
134
|
+
|
|
129
135
|
# …and where you have several green candidates and no instrument that separates
|
|
130
136
|
# them, ask a human — the one loop step this toolchain cannot run for you:
|
|
131
137
|
bun cli.ts vote --candidate path/to/spine-a --candidate path/to/spine-b # -> ballot.html
|
|
@@ -235,10 +241,11 @@ What the flags mean:
|
|
|
235
241
|
| `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
|
|
236
242
|
| `--profile` | `spine` = the 35 validity rules (**the default**) · `spine-html` = all 51, opt-in |
|
|
237
243
|
| `--report` | `build` and `repack`: also write the gate's report as a JSON document (`build-report/1`) to this file — every gate's `PASS`, `SKIP` and `FAIL` rows, its summary figures and stats line, which supplier judged it, and every `pack:` line's figures. Written when the command writes `--out` and when a gate is red, before the exit; a file already at the path is removed first, so a compile error or a refused repack leaves none. Never inside `--out`, refused by name. The printed lines are byte-identical with it and without it — **§5.3** |
|
|
238
|
-
| `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`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 |
|
|
244
|
+
| `--candidate` | `check`, `bench`, `render`, `inspect`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`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 |
|
|
239
245
|
| `--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 |
|
|
246
|
+
| `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone or slot it frames to; §13 says each, with the refusals |
|
|
240
247
|
| `--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** |
|
|
241
|
-
| `--poser` | `render`, `check` and `explain`: `core` or `spine` — on `render` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`, `RC34`). On the entry that links none of spine-core — the installed `rigc` without the runtime beside it, `bun cli_core.ts` in a clone — that refusal ends on the route to the entry that does (``the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts` ``); a runtime that is installed and fails to load is refused without it, since installing it is not what that reader has to do. On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1), ending on the same route where the entry links none of it |
|
|
248
|
+
| `--poser` | `render`, `inspect`, `check` and `explain`: `core` or `spine` — on `render`, `inspect` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`, `RC34`). On the entry that links none of spine-core — the installed `rigc` without the runtime beside it, `bun cli_core.ts` in a clone — that refusal ends on the route to the entry that does (``the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts` ``); a runtime that is installed and fails to load is refused without it, since installing it is not what that reader has to do. On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1), ending on the same route where the entry links none of it |
|
|
242
249
|
| `--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` |
|
|
243
250
|
| `--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 |
|
|
244
251
|
| `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
|
|
@@ -10048,3 +10055,117 @@ simply unused; a name the directory lacks is refused `no-part-image` by name.
|
|
|
10048
10055
|
is a given condition and once your spec states it there is nothing left to be
|
|
10049
10056
|
close to. Every threshold in the report is a reporting threshold. There is no pass
|
|
10050
10057
|
bar and `caveats` says so in the file.
|
|
10058
|
+
|
|
10059
|
+
## 13. Asking the picture a question — `rigc inspect`
|
|
10060
|
+
|
|
10061
|
+
The gate answers everything a number can say, and a geometric judgement is not
|
|
10062
|
+
one of those: *the bones around the head look odd* is a sentence about a picture.
|
|
10063
|
+
`inspect` draws that picture of a green build — the art, and over it the rig's own
|
|
10064
|
+
geometry at one moment — and writes a **legend** beside it that turns every mark
|
|
10065
|
+
back into world units. The picture is for reading; the legend is for acting on what
|
|
10066
|
+
was read, because a position taken off it goes straight into a spec.
|
|
10067
|
+
|
|
10068
|
+
```bash
|
|
10069
|
+
bun cli.ts inspect --candidate gallery/look/build --show art,bones,names --dim --max 640 --out look.png
|
|
10070
|
+
# rigc inspect
|
|
10071
|
+
# .. skeleton …/gallery/look/build/skeleton.json
|
|
10072
|
+
# .. atlas …/gallery/look/build/skeleton.atlas
|
|
10073
|
+
# .. poser rigc core — …/skeleton.model.json
|
|
10074
|
+
# .. view art,bones,names over dimmed art — the setup pose
|
|
10075
|
+
# .. box world x -35.2..835.2, y -35.2..915.2 at 0.6734 px per unit — 1 tile(s) of 586x640px (exact in the legend)
|
|
10076
|
+
# rigc: wrote …/look.png
|
|
10077
|
+
# rigc: wrote …/look.json
|
|
10078
|
+
```
|
|
10079
|
+
|
|
10080
|
+
It poses through the poser `render` would pose the candidate with, and the `poser`
|
|
10081
|
+
line says which — rigc's core for a build with its `skeleton.model.json`, spine-core
|
|
10082
|
+
for an export or a named fallback (the `--poser` row in §0). Without `--zoom` or
|
|
10083
|
+
`--viewport` the picture is framed exactly as `render` frames the build at the same
|
|
10084
|
+
`--max`, so with nothing drawn over it the art **is** `render`'s frame, pixel for
|
|
10085
|
+
pixel (`IN09`).
|
|
10086
|
+
|
|
10087
|
+
### 13.1 Which question, which view
|
|
10088
|
+
|
|
10089
|
+
| The question | The view | What to read |
|
|
10090
|
+
| --- | --- | --- |
|
|
10091
|
+
| Is this bone where the art is? | `--show art,bones,names --dim` | each bone as a line from its origin to its tip, the origin a filled square, the tip a hollow one; a bone of length 0 is a hollow 5x5 square around its origin, so a joint nobody gave a length is not mistaken for a point on another bone. Three fringe chains drawn as evenly spaced page verticals is the kind of thing this shows at a glance |
|
|
10092
|
+
| Which way does this bone squash? | `--show art,axes --zoom <bone>` | the bone's world **x** axis (red) and **y** axis (green), each 24 px long at a world scale of 1 — longer or shorter as the bone scales. A `scaley` key squashes along the green one; if that is the page's vertical on a face painted at an angle, the blink squashes along the page |
|
|
10093
|
+
| Where in rig units is this point? | `--show art,grid --zoom <bone or slot>` or `--viewport x,y,w,h` | grid lines at whole multiples of a step in **world units**, each labelled with its value, the lines through 0 darker. The legend's transform converts any pixel exactly; the grid is for reading by eye |
|
|
10094
|
+
| Is this axis declared, and held? | `--show art,declared,bones` | each `invariants.axes` line (§3.7) through its two bones' origins, drawn across the whole tile, and the bones it holds drawn in their own colour; the legend states each held bone's world x axis and how far off the line it lies, as `A51` measures it, at the pose drawn |
|
|
10095
|
+
| Did it break during the move? | any of the above `--animation <name> --at <s,s,…>` | one tile per time, left to right on one grid, each labelled with its time and frame |
|
|
10096
|
+
|
|
10097
|
+
Two questions are not this cut's: **what does this bone move** (its weights over
|
|
10098
|
+
the meshes it skins, the mesh edges and hulls, the slots' draw order) and **did this
|
|
10099
|
+
change move anything** (two builds side by side on one grid). `--show mesh`,
|
|
10100
|
+
`hull`, `weights:<bone>`, `slots` and `--compare` are refused as any unknown word
|
|
10101
|
+
or flag is, naming what this version draws.
|
|
10102
|
+
|
|
10103
|
+
### 13.2 Where it looks
|
|
10104
|
+
|
|
10105
|
+
- **The setup pose** by default.
|
|
10106
|
+
- **One time, or several** — `--animation <name> --at <s[,s…]>`. A time is a frame
|
|
10107
|
+
of the grid `render --fps <n>` draws the animation on (default 12): the pose at
|
|
10108
|
+
`--at 0.5` is `render`'s `f0006.png`, the same continuous trajectory stepped the
|
|
10109
|
+
same way, physics included. A time off that grid is refused naming the frames
|
|
10110
|
+
either side (`--at 0.3 is not on the 12 fps frame grid … the frames either side
|
|
10111
|
+
are 3 (0.2500s) and 4 (0.3333s)`); pass `--fps` for another grid. A time past the
|
|
10112
|
+
end is refused naming the animation's length. Several times are a sheet: tiles
|
|
10113
|
+
left to right, eight to a row, a one-pixel rule between them, all on one world box
|
|
10114
|
+
so a mark that moves between two tiles moved in the rig.
|
|
10115
|
+
- **The whole rig**, framed as `render` frames it; **a box** — `--viewport x,y,w,h`
|
|
10116
|
+
in world units, y up, `x,y` its bottom-left corner; or **one bone or slot** —
|
|
10117
|
+
`--zoom <name>[:<pad>]`, the bone's origin and tip or the slot's attachment
|
|
10118
|
+
vertices over every time drawn, padded by `<pad>` world units on every side
|
|
10119
|
+
(default a quarter of the box's longer side). A name that is both a bone and a slot
|
|
10120
|
+
is refused until it is written `bone:<name>` or `slot:<name>`; a bone of length 0
|
|
10121
|
+
with no pad is one point, and is refused asking for one.
|
|
10122
|
+
- `--max` is each tile's longer side in pixels (default 256), as on `render`.
|
|
10123
|
+
- `--dim` fades the art toward white (each channel keeps 0.4 of its distance from
|
|
10124
|
+
white) so what is drawn over it reads.
|
|
10125
|
+
|
|
10126
|
+
### 13.3 The legend
|
|
10127
|
+
|
|
10128
|
+
`<out>.json` beside `<out>.png`, `rigc-inspect/1`. What it states:
|
|
10129
|
+
|
|
10130
|
+
- `coordinates` — the two spaces and the transform, in both directions, as text:
|
|
10131
|
+
`px = x0 + (x - minX) * scale; py = y0 + (maxY - y) * scale` and
|
|
10132
|
+
`x = minX + (px - x0) / scale; y = maxY - (py - y0) / scale`. World is Spine's, y up;
|
|
10133
|
+
the picture is y down from its top-left corner, and pixel (i, j) covers
|
|
10134
|
+
`[i, i+1) x [j, j+1)` — a position's pixel is its floor.
|
|
10135
|
+
- `viewport` — the world box every tile is a picture of and its size in pixels;
|
|
10136
|
+
`size` — the whole picture's.
|
|
10137
|
+
- `tiles[]` — per tile: `animation`, `frame` and `time` (`frame / fps`), its `box` in
|
|
10138
|
+
the picture, its own `transform` (`minX`, `maxY`, `scale`, and `x0`, `y0`, its
|
|
10139
|
+
top-left pixel), and `bones[]` — every bone in declaration order with its
|
|
10140
|
+
`parent`, `length`, `zeroLength`, `origin` and `tip` each as `{ world, pixel }`,
|
|
10141
|
+
`undrawn` (why it is not drawn — its world matrix is 0 at this time, unposed by the
|
|
10142
|
+
skin or scaled to 0 — or `null`), and with `axes` shown, the two axis ends. With
|
|
10143
|
+
`grid`, the step and every line's value; with `declared`, each entry's line in
|
|
10144
|
+
degrees, its two points, and each held bone's angle and how far off the line it is.
|
|
10145
|
+
- `colours` — every colour the picture draws, as RGBA, with what it means.
|
|
10146
|
+
- `poser`, `show`, `dim`, `skin`, `animation`, `fps` and `framing` — what was drawn
|
|
10147
|
+
and how it was framed (`shot`, `zoom` with the bone or slot, the pad and whether it
|
|
10148
|
+
was stated, or `viewport`).
|
|
10149
|
+
|
|
10150
|
+
Every number is `JSON.stringify`'s, the double the pose produced, not a rounding of
|
|
10151
|
+
it. The same build and flags write the same bytes (`IN10`).
|
|
10152
|
+
|
|
10153
|
+
### 13.4 What it refuses
|
|
10154
|
+
|
|
10155
|
+
Each of these exits 2, writes nothing, and names what exists: a bone or slot the
|
|
10156
|
+
build does not have (both rosters listed), an animation or skin it does not declare,
|
|
10157
|
+
a `--show` word it does not draw, a flag it does not take, a time off the frame grid
|
|
10158
|
+
or past the end, `--at` without `--animation` and the reverse, `--zoom` beside
|
|
10159
|
+
`--viewport`, and `--show declared` on a skeleton with no `skeleton.model.json`
|
|
10160
|
+
beside it whose `spine.sha256` is its own — a Spine export states no rig invariants.
|
|
10161
|
+
A rig that declares no `invariants.axes` is not refused: the picture draws nothing
|
|
10162
|
+
for `declared` and the run says so on a line of its own.
|
|
10163
|
+
|
|
10164
|
+
### 13.5 What it is not
|
|
10165
|
+
|
|
10166
|
+
- **Not a score.** Nothing in it is a pass bar; the picture is read, and what is read
|
|
10167
|
+
is acted on in the spec.
|
|
10168
|
+
- **Not a player.** It is one still, or a sheet of stills. A person watches
|
|
10169
|
+
`@rig-c/play` or `rigc preview`.
|
|
10170
|
+
- **Not a second poser.** Everything it draws is a pose `render` would have drawn at
|
|
10171
|
+
the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.41.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -74,6 +74,11 @@ row for each.
|
|
|
74
74
|
cover a feature the art shows — and answer each with `render --hide <slot>` (or
|
|
75
75
|
`--slot <slot,…>`), which draws the frame again without that part on the same
|
|
76
76
|
grid, so the two frames say which part a pixel is. AUTHORING §0 holds the three.
|
|
77
|
+
When the question is geometry — is this bone where the art is, which way does it
|
|
78
|
+
squash, where in rig units is this point — `rigc inspect --candidate <out> --show
|
|
79
|
+
art,bones,names --dim` draws the bones over the art and writes a legend beside the
|
|
80
|
+
picture that puts every mark back in world units; AUTHORING §13 maps each
|
|
81
|
+
question to its view.
|
|
77
82
|
5. `rigc check --candidate <out> --frames <dir>` when you have reference pictures
|
|
78
83
|
(`--out <dir>` writes the picture each of its numbers came from — open the worst).
|
|
79
84
|
Read its output from the top: the framing block says whether the figures under
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bodies of the commands that reach nothing of spine-core — `explain`,
|
|
3
|
-
* `ingest`, `diff`, `check`, `render`, `pose`, `chainfit` and `skills` —
|
|
3
|
+
* `ingest`, `diff`, `check`, `render`, `inspect`, `pose`, `chainfit` and `skills` —
|
|
4
4
|
* moved here unchanged from `cli.ts` (issue #1052, step 4e of #380), with the
|
|
5
5
|
* helpers only they call. Both entries register them (`CORE_COMMAND_RUNS`):
|
|
6
6
|
* `cli.ts` beside the runtime's, `cli_core.ts` alone. And the body
|
|
@@ -30,12 +30,13 @@ import { ingest, INGEST_GUTTERS, type IngestFinding, IngestSpecRefused, type Ing
|
|
|
30
30
|
import { PARSER_DEFAULTS, parserReading } from '../keyorder.ts';
|
|
31
31
|
import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
|
|
32
32
|
import { parseMotionSpec } from '../motion.ts';
|
|
33
|
+
import { type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readShowWords, readTimes, readZoom } from '../inspect.ts';
|
|
33
34
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
34
35
|
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
|
|
35
36
|
import { type CompileResult } from '../types.ts';
|
|
36
37
|
import { attachmentRegionJoins } from '../region_joins.ts';
|
|
37
38
|
import { cmdRepack } from './repack.ts';
|
|
38
|
-
import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
|
|
39
|
+
import { ATLAS_ABSENT, COMMANDS, type CommandRun, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
|
|
39
40
|
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
40
41
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
41
42
|
|
|
@@ -648,6 +649,87 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
648
649
|
console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
|
|
649
650
|
}
|
|
650
651
|
|
|
652
|
+
/** Where `inspect` writes when `--out` is not given, and the name it takes inside a directory `--out` names. */
|
|
653
|
+
export const DEFAULT_INSPECT_OUT = 'inspect.png';
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* inspect — one moment of a build drawn for an agent to read, and the legend
|
|
657
|
+
* that turns its pixels back into world units (issue #1357). The view is
|
|
658
|
+
* `src/inspect.ts`'s; this reads the flags, loads the candidate the way
|
|
659
|
+
* `render` loads it and writes the two files.
|
|
660
|
+
*
|
|
661
|
+
* ⚠️ A flag this command does not take is refused by name here rather than
|
|
662
|
+
* ignored: the parser accepts any `--word value`, and a view asked for with a
|
|
663
|
+
* flag that does nothing would be a picture of something other than what the
|
|
664
|
+
* caller asked about.
|
|
665
|
+
*/
|
|
666
|
+
export function cmdInspect(flags: Record<string, string>): void {
|
|
667
|
+
const doc = COMMANDS.find((c) => c.name === 'inspect');
|
|
668
|
+
const accepted = [...(doc?.flags ?? []), 'help'];
|
|
669
|
+
const stray = Object.keys(flags).filter((name) => !accepted.includes(name));
|
|
670
|
+
if (stray.length > 0) {
|
|
671
|
+
throw new InspectError(`inspect takes no --${stray.join(', --')}; it takes --${accepted.filter((f) => f !== 'help').join(', --')}`);
|
|
672
|
+
}
|
|
673
|
+
if (flags.candidate === undefined) {
|
|
674
|
+
throw new UsageError('inspect needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
|
|
675
|
+
}
|
|
676
|
+
if (flags.zoom !== undefined && flags.viewport !== undefined) {
|
|
677
|
+
throw new InspectError('--zoom and --viewport both choose the box the picture is of; give one of them');
|
|
678
|
+
}
|
|
679
|
+
const show = readShowWords(flags.show);
|
|
680
|
+
const times = readTimes(flags.at);
|
|
681
|
+
const fps = readPositiveNumber(flags, 'fps', INSPECT_DEFAULT_FPS, 1);
|
|
682
|
+
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
683
|
+
let frame: InspectFrame = { kind: 'shot' };
|
|
684
|
+
if (flags.zoom !== undefined) frame = { kind: 'zoom', ...readZoom(flags.zoom) };
|
|
685
|
+
if (flags.viewport !== undefined) {
|
|
686
|
+
const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
|
|
687
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
688
|
+
throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
|
|
689
|
+
}
|
|
690
|
+
if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
|
|
691
|
+
frame = { kind: 'viewport', x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
692
|
+
}
|
|
693
|
+
const target = resolve(flags.out ?? DEFAULT_INSPECT_OUT);
|
|
694
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_INSPECT_OUT) : target;
|
|
695
|
+
if (!out.endsWith('.png')) throw new InspectError(`--out ${flags.out}: the picture is a PNG, so --out names a .png file (its legend is written beside it as .json)`);
|
|
696
|
+
const legendPath = `${out.slice(0, -'.png'.length)}.json`;
|
|
697
|
+
|
|
698
|
+
const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
|
|
699
|
+
const skeletonText = readSkeletonText(skeletonPath);
|
|
700
|
+
console.log('rigc inspect');
|
|
701
|
+
console.log(` .. skeleton ${skeletonPath}`);
|
|
702
|
+
console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
|
|
703
|
+
// `render`'s loading, in `render`'s order (issue #1014): the poser is chosen before anything is read off the candidate.
|
|
704
|
+
const candidate = loadCandidate(
|
|
705
|
+
{ skeletonText, atlasText, atlasDir: dirname(atlasPath), label: skeletonPath },
|
|
706
|
+
{ skeleton: skeletonPath, atlas: atlasPath },
|
|
707
|
+
posersAsked(flags),
|
|
708
|
+
);
|
|
709
|
+
const animation = readAnimationFlag(flags, [...candidate.facts.animations]) ?? null;
|
|
710
|
+
readPoserFlag(flags);
|
|
711
|
+
refuseUnchosen(candidate.choice);
|
|
712
|
+
const result = inspectCandidate(
|
|
713
|
+
candidate,
|
|
714
|
+
{ skeletonText, modelText: modelTextBeside(skeletonPath, skeletonText) },
|
|
715
|
+
{ show, dim: flags.dim !== undefined, animation, times, fps, skin: flags.skin, frame, maxSide },
|
|
716
|
+
);
|
|
717
|
+
const { legend } = result;
|
|
718
|
+
console.log(` .. poser ${result.poserNote}`);
|
|
719
|
+
const when = animation === null ? 'the setup pose' : `${animation} at ${legend.tiles.map((t) => `${t.time.toFixed(4)}s (frame ${t.frame})`).join(', ')}, ${fps} fps grid`;
|
|
720
|
+
console.log(` .. view ${legend.show.join(',')}${legend.dim ? ' over dimmed art' : ''} — ${when}`);
|
|
721
|
+
const v = legend.viewport;
|
|
722
|
+
// Four places for reading; the legend carries every number exactly.
|
|
723
|
+
const f = (n: number): string => String(Number(n.toFixed(4)));
|
|
724
|
+
console.log(` .. box world x ${f(v.minX)}..${f(v.maxX)}, y ${f(v.minY)}..${f(v.maxY)} at ${f(v.scale)} px per unit — ${legend.tiles.length} tile(s) of ${v.width}x${v.height}px (exact in the legend)`);
|
|
725
|
+
for (const note of result.notes) console.log(` .. ${note}`);
|
|
726
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
727
|
+
result.plate.writePng(out);
|
|
728
|
+
writeFileSync(legendPath, legendText(legend));
|
|
729
|
+
console.log(`rigc: wrote ${out}`);
|
|
730
|
+
console.log(`rigc: wrote ${legendPath}`);
|
|
731
|
+
}
|
|
732
|
+
|
|
651
733
|
/** `--scale 0.5,2` / `--rotation -30,30` — a pair of numbers, low first. */
|
|
652
734
|
function readRange(flags: Record<string, string>, key: string): { low: number; high: number } | undefined {
|
|
653
735
|
const raw = flags[key];
|
|
@@ -1621,6 +1703,7 @@ export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
|
1621
1703
|
diff: ({ flags, lists, positional }) => cmdDiff(flags, lists, positional),
|
|
1622
1704
|
check: ({ flags }) => cmdCheck(flags),
|
|
1623
1705
|
render: ({ flags }) => cmdRender(flags),
|
|
1706
|
+
inspect: ({ flags }) => cmdInspect(flags),
|
|
1624
1707
|
pose: ({ flags }) => cmdPose(flags),
|
|
1625
1708
|
chainfit: ({ flags }) => cmdChainFit(flags),
|
|
1626
1709
|
skills: ({ flags, positional }) => cmdSkills(flags, positional),
|
package/src/cli/shared.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { BallotError } from '../ballot.ts';
|
|
|
56
56
|
import { ChainFitError } from '../chainfit.ts';
|
|
57
57
|
import { CheckError } from '../check.ts';
|
|
58
58
|
import { IngestError, type IngestStage } from '../ingest.ts';
|
|
59
|
+
import { InspectError, DEFAULT_SHOW, DIM_KEEP, INSPECT_DEFAULT_FPS, ON_GRID_FRAMES, SHOW_WORDS } from '../inspect.ts';
|
|
59
60
|
import { NotAPngError } from '../png.ts';
|
|
60
61
|
import { PoseError } from '../pose.ts';
|
|
61
62
|
import { RepackError } from '../repack.ts';
|
|
@@ -125,7 +126,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
|
125
126
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
126
127
|
* halves together by reading `--help` rather than by naming a flag.
|
|
127
128
|
*/
|
|
128
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences', 'keep-pages']);
|
|
129
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
132
|
* The flags a command is allowed to spell more than once.
|
|
@@ -996,7 +997,7 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
996
997
|
"slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
|
|
997
998
|
'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
|
|
998
999
|
poser:
|
|
999
|
-
"`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1000
|
+
"`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1000
1001
|
'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
|
|
1001
1002
|
"beside the skeleton and the skeleton is the one the document records (spine.sha256), `spine` otherwise and wherever the core refuses the input by name; the `poser` " +
|
|
1002
1003
|
'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
|
|
@@ -1061,6 +1062,22 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1061
1062
|
'draw every slot but these, comma-separated — `--slot` the other way round, on the same grid, recorded and ' +
|
|
1062
1063
|
'refused the same way. Not with `--slot`: the two are one statement',
|
|
1063
1064
|
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
1065
|
+
show:
|
|
1066
|
+
`what \`inspect\` draws, comma-separated, from [${SHOW_WORDS.join(', ')}] (default \`${DEFAULT_SHOW.join(',')}\`): art = the ` +
|
|
1067
|
+
"build's own frame; grid = lines at whole multiples of a step in world units, labelled; declared = each invariants.axes " +
|
|
1068
|
+
"line through its two bones' origins, its held bones drawn in their own colour; bones = each bone from origin to tip, " +
|
|
1069
|
+
'a bone of length 0 marked as one; axes = each bone\'s world x and y axes; names = each bone\'s name. Any other word ' +
|
|
1070
|
+
'is refused naming these',
|
|
1071
|
+
dim: `fade the art toward white (each channel keeps ${DIM_KEEP} of its distance from white), so what is drawn over it reads`,
|
|
1072
|
+
at:
|
|
1073
|
+
`the times of --animation to draw, in seconds, comma-separated — one is a picture, several a sheet of tiles on one ` +
|
|
1074
|
+
`grid in the order given. Each must be a frame of the --fps grid render draws the animation on (within ` +
|
|
1075
|
+
`${ON_GRID_FRAMES} of a frame), and is refused naming the frames either side otherwise; without --animation the ` +
|
|
1076
|
+
'picture is the setup pose and --at is refused',
|
|
1077
|
+
zoom:
|
|
1078
|
+
'frame the picture to one bone (origin to tip) or one slot (its attachment\'s vertices) over every time drawn, padded ' +
|
|
1079
|
+
'by <pad> world units on every side (default: a quarter of the box\'s longer side). A name that is both a bone and a ' +
|
|
1080
|
+
'slot is refused until written bone:<name> or slot:<name>; a name that is neither lists both rosters. Not with --viewport',
|
|
1064
1081
|
record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
|
|
1065
1082
|
ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
|
|
1066
1083
|
ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
|
|
@@ -1158,6 +1175,9 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1158
1175
|
slot: '<name[,name…]>',
|
|
1159
1176
|
hide: '<name[,name…]>',
|
|
1160
1177
|
max: '<px>',
|
|
1178
|
+
show: '<view[,view…]>',
|
|
1179
|
+
at: '<s[,s…]>',
|
|
1180
|
+
zoom: '<name>[:<pad>]',
|
|
1161
1181
|
record: '<result.json>',
|
|
1162
1182
|
ballot: '<ballot.html>',
|
|
1163
1183
|
ledger: '<votes.jsonl>',
|
|
@@ -1536,6 +1556,36 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1536
1556
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
|
1537
1557
|
},
|
|
1538
1558
|
},
|
|
1559
|
+
{
|
|
1560
|
+
name: 'inspect',
|
|
1561
|
+
runtime: false,
|
|
1562
|
+
spineFormat: false,
|
|
1563
|
+
usage: [
|
|
1564
|
+
'rigc inspect --candidate <dir | skeleton.json> [--show art,bones,…] [--dim] [--max 256] [--poser core|spine] [--out inspect.png] (the setup pose)',
|
|
1565
|
+
'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
|
|
1566
|
+
'rigc inspect … --zoom <bone|slot>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
|
|
1567
|
+
],
|
|
1568
|
+
flags: ['candidate', 'atlas', 'show', 'dim', 'animation', 'at', 'fps', 'skin', 'zoom', 'viewport', 'max', 'poser', 'out'],
|
|
1569
|
+
overrides: {
|
|
1570
|
+
animation: { meaning: 'the animation --at reads its times from (default: the setup pose, with no --at)' },
|
|
1571
|
+
fps: { meaning: `the frame grid --at is read on, which is the grid \`render --fps\` draws (default ${INSPECT_DEFAULT_FPS})` },
|
|
1572
|
+
viewport: { meaning: 'frame the picture to this world box, y up — x,y its bottom-left corner — instead of render\'s framing of the whole rig' },
|
|
1573
|
+
out: {
|
|
1574
|
+
value: '<file.png>',
|
|
1575
|
+
meaning:
|
|
1576
|
+
'the picture to write (default `inspect.png`; a directory means that name in it), and beside it the legend, the same ' +
|
|
1577
|
+
'path with .json for .png: the world-to-pixel transform both ways, what each colour means, and every bone\'s origin ' +
|
|
1578
|
+
'and tip in world units and in pixels',
|
|
1579
|
+
},
|
|
1580
|
+
},
|
|
1581
|
+
notes: [
|
|
1582
|
+
'inspect draws one moment of the build for an agent to read, and its legend says',
|
|
1583
|
+
'where every mark is in world units and in pixels. It poses through the poser',
|
|
1584
|
+
'render would pose the candidate with, and the poser line says which. Without',
|
|
1585
|
+
'--zoom or --viewport the picture is framed as render frames the build, so its',
|
|
1586
|
+
'art is render\'s frame at the same time and --max. Nothing in it is a score.',
|
|
1587
|
+
],
|
|
1588
|
+
},
|
|
1539
1589
|
{
|
|
1540
1590
|
name: 'preview',
|
|
1541
1591
|
runtime: { for: "its gate line is the round trip's, and it names the pages it embeds through spine-core's atlas reader" },
|
|
@@ -2704,6 +2754,12 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
|
|
|
2704
2754
|
// Same kind as a PoseError, and printed the same way for the same reason: the
|
|
2705
2755
|
// messages name a path, a bone or an attachment, and reprinting the whole
|
|
2706
2756
|
// usage under them buries the one line that says what to change.
|
|
2757
|
+
// A view refused before anything was drawn (issue #1357): a name the build does not have, a time off render's
|
|
2758
|
+
// frame grid, a box with no size. The message lists what exists; the usage under it would bury that.
|
|
2759
|
+
if (err instanceof InspectError) {
|
|
2760
|
+
console.error(`rigc inspect: ${err.message}`);
|
|
2761
|
+
process.exit(2);
|
|
2762
|
+
}
|
|
2707
2763
|
if (err instanceof ChainFitError) {
|
|
2708
2764
|
console.error(`rigc chainfit: ${err.message}`);
|
|
2709
2765
|
process.exit(2);
|
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigc inspect` — a picture of a compiled build made for an agent to read,
|
|
3
|
+
* and the legend that turns a pixel of it back into a number (issue #1357).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **The picture is a question about geometry, and the legend is its answer
|
|
6
|
+
* key.** `render` draws what the rig looks like; this draws what the rig IS at
|
|
7
|
+
* one moment — where each bone starts and ends, which way its axes point, where
|
|
8
|
+
* the world's units fall, and the line a rig declares some bones to lie along —
|
|
9
|
+
* over the same art on the same pixel grid. Without the legend the picture is
|
|
10
|
+
* for a person: an agent that reads "the elbow is at pixel 412, 180" needs the
|
|
11
|
+
* transform to say where that is in the rig spec, and every drawn bone's pixel
|
|
12
|
+
* origin and tip to say which mark is which. So the legend states the transform
|
|
13
|
+
* exactly, in both directions, and every mark the picture carries.
|
|
14
|
+
*
|
|
15
|
+
* 🔒 **It poses through what `render` poses the candidate with, and says
|
|
16
|
+
* which.** The caller hands over the candidate `loadCandidate` read and runs
|
|
17
|
+
* this through `throughPoser`, the seam `render` runs — so a rigc build is posed
|
|
18
|
+
* by rigc's own core and an export by spine-core, a fallback is named, and the
|
|
19
|
+
* art of a picture with nothing drawn over it is `render`'s frame at the same
|
|
20
|
+
* time and the same `--max`, pixel for pixel (`IN09`).
|
|
21
|
+
*
|
|
22
|
+
* ⭐ **A time is a frame of `render`'s grid.** The poser steps one continuous
|
|
23
|
+
* trajectory at `1/fps` (physics included), so the pose "at t" is the one
|
|
24
|
+
* `render --fps <fps>` wrote as frame `t·fps`, and a time off that grid is
|
|
25
|
+
* refused naming the two grid times either side rather than posed some other
|
|
26
|
+
* way — a second recipe for one moment would be a second opinion about the pose.
|
|
27
|
+
*
|
|
28
|
+
* Links nothing from the runtime: the poser is handed in.
|
|
29
|
+
*/
|
|
30
|
+
import { drawText, GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
31
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
32
|
+
import { lineGap, worldAxisDegrees } from './assertions/bodies/a51.ts';
|
|
33
|
+
import {
|
|
34
|
+
BACKGROUND,
|
|
35
|
+
type BoneSnapshot,
|
|
36
|
+
type Candidate,
|
|
37
|
+
type Frame,
|
|
38
|
+
framingViewport,
|
|
39
|
+
type Poser,
|
|
40
|
+
type PoseOptions,
|
|
41
|
+
renderFrame,
|
|
42
|
+
sampleAnimation,
|
|
43
|
+
sampleSetupPose,
|
|
44
|
+
SHEET_COLUMNS,
|
|
45
|
+
SHEET_GAP,
|
|
46
|
+
SHEET_LABEL,
|
|
47
|
+
SHEET_RULE,
|
|
48
|
+
type SkinRoster,
|
|
49
|
+
throughPoser,
|
|
50
|
+
type Viewport,
|
|
51
|
+
viewportFor,
|
|
52
|
+
} from './render_shared.ts';
|
|
53
|
+
|
|
54
|
+
/** The legend's format tag. */
|
|
55
|
+
export const INSPECT_SPEC = 'rigc-inspect/1';
|
|
56
|
+
|
|
57
|
+
/** Every word `--show` takes, in the order the picture draws them (bottom first). */
|
|
58
|
+
export const SHOW_WORDS = ['art', 'grid', 'declared', 'bones', 'axes', 'names'] as const;
|
|
59
|
+
export type ShowWord = (typeof SHOW_WORDS)[number];
|
|
60
|
+
|
|
61
|
+
/** What `--show` draws when it is not given. */
|
|
62
|
+
export const DEFAULT_SHOW: readonly ShowWord[] = ['art', 'bones'];
|
|
63
|
+
|
|
64
|
+
/** The frame rate a time is read on when `--fps` is not given — `render`'s own default, so the two grids are one. */
|
|
65
|
+
export const INSPECT_DEFAULT_FPS = 12;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How far from a whole frame `t·fps` may be and still name that frame, in
|
|
69
|
+
* frames. Not a tolerance on the pose: it absorbs the binary spelling of a
|
|
70
|
+
* decimal time (`0.1 · 12` is `1.2000000000000002`), and the frame posed is the
|
|
71
|
+
* whole one, filed under `index / fps` in the legend.
|
|
72
|
+
*/
|
|
73
|
+
export const ON_GRID_FRAMES = 1e-6;
|
|
74
|
+
|
|
75
|
+
/** A zoom with no pad stated is padded by this share of the zoomed box's longer side, on every side. */
|
|
76
|
+
export const DEFAULT_ZOOM_PAD_SHARE = 0.25;
|
|
77
|
+
|
|
78
|
+
/** The grid's step is the smallest 1, 2 or 5 × 10ⁿ world units at least this many pixels apart. */
|
|
79
|
+
export const GRID_MIN_PIXELS = 32;
|
|
80
|
+
|
|
81
|
+
/** An axis is drawn this many pixels long for a bone whose world scale on it is 1 — longer or shorter as it scales. */
|
|
82
|
+
export const AXIS_PIXELS = 24;
|
|
83
|
+
|
|
84
|
+
/** `--dim`: every art pixel is moved toward white, keeping this share of its distance from white. */
|
|
85
|
+
export const DIM_KEEP = 0.4;
|
|
86
|
+
|
|
87
|
+
/** What each colour the picture draws means — the legend repeats this table. */
|
|
88
|
+
export const INSPECT_COLOURS = {
|
|
89
|
+
bone: { rgba: [0, 110, 255, 255], meaning: "a bone's line, from its origin to its tip (origin + length along its world x axis)" },
|
|
90
|
+
origin: { rgba: [235, 0, 190, 255], meaning: "a bone's origin: a filled 3x3 square centred on the origin's pixel" },
|
|
91
|
+
tip: { rgba: [255, 140, 0, 255], meaning: "a bone's tip: a hollow 3x3 square centred on the tip's pixel" },
|
|
92
|
+
zero: { rgba: [255, 210, 0, 255], meaning: 'a bone of length 0: a hollow 5x5 square around its origin, which is also its tip' },
|
|
93
|
+
name: { rgba: [25, 25, 25, 255], meaning: "a bone's name, starting 4 px right of and 3 px below its origin's pixel; bones sharing that pixel list their names one per line under it, in declaration order" },
|
|
94
|
+
axisX: { rgba: [215, 30, 30, 255], meaning: `a bone's world x axis from its origin, ${AXIS_PIXELS} px long at a world scale of 1` },
|
|
95
|
+
axisY: { rgba: [20, 160, 40, 255], meaning: `a bone's world y axis from its origin, ${AXIS_PIXELS} px long at a world scale of 1` },
|
|
96
|
+
grid: { rgba: [150, 150, 150, 255], meaning: 'a grid line at a whole multiple of the step, in world units, with its value at the edge' },
|
|
97
|
+
gridZero: { rgba: [70, 70, 70, 255], meaning: 'the grid line at world x = 0 or y = 0' },
|
|
98
|
+
declared: { rgba: [0, 190, 190, 255], meaning: 'an invariants.axes line, through its two bones\' origins, across the whole tile' },
|
|
99
|
+
held: { rgba: [0, 95, 95, 255], meaning: 'the line of a bone an invariants.axes entry holds to its line, in place of the bone colour' },
|
|
100
|
+
label: { rgba: [...SHEET_LABEL], meaning: "a sheet tile's time and frame, at its bottom-left corner" },
|
|
101
|
+
rule: { rgba: [...SHEET_RULE], meaning: 'the gap between two tiles of a sheet' },
|
|
102
|
+
} as const satisfies Record<string, { rgba: readonly number[]; meaning: string }>;
|
|
103
|
+
|
|
104
|
+
type ColourName = keyof typeof INSPECT_COLOURS;
|
|
105
|
+
const colour = (name: ColourName): RGBA => [...INSPECT_COLOURS[name].rgba] as RGBA;
|
|
106
|
+
|
|
107
|
+
/** A refusal of the invocation: a name the build does not have, a time off the grid, a box that cannot be drawn. Exit 2, nothing written. */
|
|
108
|
+
export class InspectError extends Error {}
|
|
109
|
+
|
|
110
|
+
/** What to frame a picture to. */
|
|
111
|
+
export type InspectFrame =
|
|
112
|
+
| { kind: 'shot' }
|
|
113
|
+
| { kind: 'zoom'; target: string; pad: number | null }
|
|
114
|
+
| { kind: 'viewport'; x: number; y: number; width: number; height: number };
|
|
115
|
+
|
|
116
|
+
/** One `rigc inspect` invocation, its flags read but not yet checked against the build. */
|
|
117
|
+
export interface InspectRequest {
|
|
118
|
+
show: readonly ShowWord[];
|
|
119
|
+
dim: boolean;
|
|
120
|
+
/** `null` is the setup pose. */
|
|
121
|
+
animation: string | null;
|
|
122
|
+
/** Seconds, in the order given; empty with no animation. */
|
|
123
|
+
times: readonly number[];
|
|
124
|
+
fps: number;
|
|
125
|
+
skin: string | undefined;
|
|
126
|
+
frame: InspectFrame;
|
|
127
|
+
maxSide: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What the picture is made from besides the poser: the build's own texts. */
|
|
131
|
+
export interface InspectTexts {
|
|
132
|
+
/** `skeleton.json` — read for each bone's `length`, which no pose carries. */
|
|
133
|
+
skeletonText: string;
|
|
134
|
+
/** The `skeleton.model.json` that is this skeleton's, or `undefined` — what `declared` reads `rig.axes` from. */
|
|
135
|
+
modelText: string | undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A planted defect, for the suite's mutants only (`IN` controls in
|
|
140
|
+
* `selftest.ts`). Each one is a way the picture or the legend can be wrong that
|
|
141
|
+
* a control has to catch by name; production passes none.
|
|
142
|
+
*/
|
|
143
|
+
export interface InspectPlant {
|
|
144
|
+
/** Draw every bone mark this many pixels off where the legend says it is. */
|
|
145
|
+
overlayShift?: [number, number];
|
|
146
|
+
/** State every bone mark this many pixels off where the picture draws it. */
|
|
147
|
+
legendShift?: [number, number];
|
|
148
|
+
/** Read the grid step as pixels rather than world units. */
|
|
149
|
+
gridInPixels?: boolean;
|
|
150
|
+
/** Map world y to image y without turning it over. */
|
|
151
|
+
unflipped?: boolean;
|
|
152
|
+
/** Zoom to this bone, whatever the request named. */
|
|
153
|
+
zoomBone?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A point in both spaces. */
|
|
157
|
+
export interface LegendPoint {
|
|
158
|
+
/** Spine world, y up, world units. */
|
|
159
|
+
world: [number, number];
|
|
160
|
+
/** The picture's pixels, y down, from its top-left corner; pixel (i, j) covers [i, i+1) x [j, j+1). */
|
|
161
|
+
pixel: [number, number];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One bone in one tile. */
|
|
165
|
+
export interface LegendBone {
|
|
166
|
+
name: string;
|
|
167
|
+
parent: string | null;
|
|
168
|
+
/** The skeleton's `length` for it, world units (0 where the file states none). */
|
|
169
|
+
length: number;
|
|
170
|
+
/** `length` is 0: origin and tip are one point, marked by the `zero` square. */
|
|
171
|
+
zeroLength: boolean;
|
|
172
|
+
/** `null` when the bone is drawn; otherwise why it is not (its world matrix is 0). */
|
|
173
|
+
undrawn: string | null;
|
|
174
|
+
origin: LegendPoint;
|
|
175
|
+
tip: LegendPoint;
|
|
176
|
+
/** The ends of its drawn world x and y axes — present when `axes` is shown and the bone is drawn. */
|
|
177
|
+
axes?: { x: LegendPoint; y: LegendPoint };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One `invariants.axes` entry in one tile. */
|
|
181
|
+
export interface LegendDeclared {
|
|
182
|
+
name: string;
|
|
183
|
+
through: [string, string];
|
|
184
|
+
tolerance: number;
|
|
185
|
+
/** The line's direction in world degrees (CCW from +x), or `null` when the two origins coincide (`why`). */
|
|
186
|
+
degrees: number | null;
|
|
187
|
+
why: string | null;
|
|
188
|
+
from: LegendPoint | null;
|
|
189
|
+
to: LegendPoint | null;
|
|
190
|
+
/** Each held bone's world x axis, and how far off the line it lies, as A51 measures it, at this tile's pose. */
|
|
191
|
+
bones: Array<{ name: string; degrees: number | null; off: number | null }>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface LegendTile {
|
|
195
|
+
/** `null` for the setup pose. */
|
|
196
|
+
animation: string | null;
|
|
197
|
+
/** The frame of `render --fps <fps>` this pose is, and its time `frame / fps`. */
|
|
198
|
+
frame: number;
|
|
199
|
+
time: number;
|
|
200
|
+
/** Where the tile sits in the picture: its top-left pixel and its size. */
|
|
201
|
+
box: { x: number; y: number; width: number; height: number };
|
|
202
|
+
/** The tile's transform. `px = x0 + (x - minX)·scale`, `py = y0 + (maxY - y)·scale`; back: `x = minX + (px - x0)/scale`, `y = maxY - (py - y0)/scale`. */
|
|
203
|
+
transform: { minX: number; maxY: number; scale: number; x0: number; y0: number };
|
|
204
|
+
bones: LegendBone[];
|
|
205
|
+
declared?: LegendDeclared[];
|
|
206
|
+
grid?: { step: number; x: number[]; y: number[] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface InspectLegend {
|
|
210
|
+
spec: typeof INSPECT_SPEC;
|
|
211
|
+
poser: 'core' | 'spine';
|
|
212
|
+
show: ShowWord[];
|
|
213
|
+
dim: boolean;
|
|
214
|
+
skin?: string;
|
|
215
|
+
animation: string | null;
|
|
216
|
+
fps: number;
|
|
217
|
+
framing:
|
|
218
|
+
| { kind: 'shot'; note: string }
|
|
219
|
+
| { kind: 'zoom'; bone?: string; slot?: string; pad: number; padFrom: 'stated' | 'default' }
|
|
220
|
+
| { kind: 'viewport' };
|
|
221
|
+
coordinates: { world: string; image: string; toPixel: string; toWorld: string };
|
|
222
|
+
/** The world box every tile is a picture of, and its pixels — one for the whole picture. */
|
|
223
|
+
viewport: { minX: number; minY: number; maxX: number; maxY: number; scale: number; width: number; height: number };
|
|
224
|
+
size: { width: number; height: number };
|
|
225
|
+
colours: Record<ColourName, { rgba: number[]; meaning: string }>;
|
|
226
|
+
tiles: LegendTile[];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The picture and its legend, and which poser posed it and why. */
|
|
230
|
+
export interface InspectResult {
|
|
231
|
+
plate: Plate;
|
|
232
|
+
legend: InspectLegend;
|
|
233
|
+
poserNote: string;
|
|
234
|
+
/** Lines worth printing beside the picture: what a view drew nothing for, and why. */
|
|
235
|
+
notes: string[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// reading the request against the build
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** `--show`'s words, refused by name — a word this command does not draw lists every one it does. */
|
|
243
|
+
export function readShowWords(raw: string | undefined): ShowWord[] {
|
|
244
|
+
if (raw === undefined) return [...DEFAULT_SHOW];
|
|
245
|
+
const words = raw.split(',').map((w) => w.trim()).filter((w) => w !== '');
|
|
246
|
+
if (words.length === 0) throw new InspectError(`--show ${JSON.stringify(raw)} names no view; it takes [${SHOW_WORDS.join(', ')}]`);
|
|
247
|
+
const out: ShowWord[] = [];
|
|
248
|
+
for (const word of words) {
|
|
249
|
+
const known = SHOW_WORDS.find((w) => w === word);
|
|
250
|
+
if (known === undefined) throw new InspectError(`--show ${JSON.stringify(word)} is not a view inspect draws; it draws [${SHOW_WORDS.join(', ')}]`);
|
|
251
|
+
if (out.includes(known)) throw new InspectError(`--show names ${JSON.stringify(word)} twice`);
|
|
252
|
+
out.push(known);
|
|
253
|
+
}
|
|
254
|
+
// In the drawing order, so two spellings of one set are one picture and one legend.
|
|
255
|
+
return SHOW_WORDS.filter((w) => out.includes(w));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `--at`'s seconds. Their grid frames are checked against the animation later, where its length is known. */
|
|
259
|
+
export function readTimes(raw: string | undefined): number[] {
|
|
260
|
+
if (raw === undefined) return [];
|
|
261
|
+
const parts = raw.split(',').map((s) => s.trim());
|
|
262
|
+
const times = parts.map(Number);
|
|
263
|
+
const bad = parts.find((s, i) => s === '' || !Number.isFinite(times[i]) || times[i] < 0);
|
|
264
|
+
if (bad !== undefined) throw new InspectError(`--at ${JSON.stringify(raw)}: ${JSON.stringify(bad)} is not a time in seconds of at least 0`);
|
|
265
|
+
return times;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** `--zoom <name>[:<pad>]`: the name, and the pad in world units when one is stated. */
|
|
269
|
+
export function readZoom(raw: string): { target: string; pad: number | null } {
|
|
270
|
+
const colon = raw.lastIndexOf(':');
|
|
271
|
+
if (colon > 0) {
|
|
272
|
+
const tail = raw.slice(colon + 1);
|
|
273
|
+
const pad = Number(tail);
|
|
274
|
+
if (tail.trim() !== '' && Number.isFinite(pad)) {
|
|
275
|
+
if (pad < 0) throw new InspectError(`--zoom ${JSON.stringify(raw)}: the pad ${tail} is below 0; it is world units on every side`);
|
|
276
|
+
return { target: raw.slice(0, colon), pad };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (raw === '') throw new InspectError('--zoom needs a bone or slot name: --zoom <name>[:<pad>]');
|
|
280
|
+
return { target: raw, pad: null };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The grid frame `t` names at `fps`, or the refusal naming the two either side. */
|
|
284
|
+
function gridFrame(t: number, fps: number, duration: number, animation: string): number {
|
|
285
|
+
const exact = t * fps;
|
|
286
|
+
const frame = Math.round(exact);
|
|
287
|
+
const last = Math.round(duration * fps);
|
|
288
|
+
if (Math.abs(exact - frame) > ON_GRID_FRAMES) {
|
|
289
|
+
const below = Math.floor(exact);
|
|
290
|
+
throw new InspectError(
|
|
291
|
+
`--at ${t} is not on the ${fps} fps frame grid render draws "${animation}" on: the frames either side are ` +
|
|
292
|
+
`${below} (${(below / fps).toFixed(4)}s) and ${below + 1} (${((below + 1) / fps).toFixed(4)}s). Name one of them, or pass --fps for another grid`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (frame > last) {
|
|
296
|
+
throw new InspectError(
|
|
297
|
+
`--at ${t} is past the end of "${animation}": it lasts ${duration}s, frame ${last} (${(last / fps).toFixed(4)}s) at ${fps} fps`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return frame;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Each bone's `length` off the skeleton file — Spine's default is 0, which is what a file that states none means. */
|
|
304
|
+
export function boneLengths(skeletonText: string): Map<string, number> {
|
|
305
|
+
const parsed = JSON.parse(skeletonText) as { bones?: Array<{ name?: unknown; length?: unknown }> };
|
|
306
|
+
const out = new Map<string, number>();
|
|
307
|
+
for (const bone of parsed.bones ?? []) {
|
|
308
|
+
if (typeof bone.name !== 'string') continue;
|
|
309
|
+
out.set(bone.name, typeof bone.length === 'number' ? bone.length : 0);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** One `invariants.axes` entry, as the model document's `rig.axes` spells it. */
|
|
315
|
+
export interface DeclaredAxis {
|
|
316
|
+
name: string;
|
|
317
|
+
through: [string, string];
|
|
318
|
+
bones: string[];
|
|
319
|
+
tolerance: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** `rig.axes` off the build's model document, or the refusal saying why `declared` has nothing to read. */
|
|
323
|
+
export function declaredAxes(modelText: string | undefined): DeclaredAxis[] {
|
|
324
|
+
if (modelText === undefined) {
|
|
325
|
+
throw new InspectError(
|
|
326
|
+
'--show declared reads invariants.axes from the skeleton.model.json `build` writes beside a skeleton, and there is none ' +
|
|
327
|
+
'here whose spine.sha256 is this skeleton\'s — a Spine export states no rig invariants',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const doc = JSON.parse(modelText) as { rig?: { axes?: unknown } };
|
|
331
|
+
const axes = doc.rig?.axes;
|
|
332
|
+
if (!Array.isArray(axes)) {
|
|
333
|
+
throw new InspectError(
|
|
334
|
+
'--show declared reads rig.axes from the skeleton.model.json beside this skeleton, and that document states no rig.axes — ' +
|
|
335
|
+
'it was written by a rigc older than invariants.axes; build it again',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return axes as DeclaredAxis[];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// drawing
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
/** One pixel, set outright; off the tile is nothing. */
|
|
346
|
+
function put(plate: Plate, x: number, y: number, rgba: RGBA): void {
|
|
347
|
+
if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) return;
|
|
348
|
+
const i = (y * plate.width + x) * 4;
|
|
349
|
+
plate.data[i] = rgba[0];
|
|
350
|
+
plate.data[i + 1] = rgba[1];
|
|
351
|
+
plate.data[i + 2] = rgba[2];
|
|
352
|
+
plate.data[i + 3] = rgba[3];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A one-pixel line between two continuous pixel positions: every pixel whose square a dense walk along it enters. */
|
|
356
|
+
function line(plate: Plate, x0: number, y0: number, x1: number, y1: number, rgba: RGBA): void {
|
|
357
|
+
const steps = Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) * 2) + 1;
|
|
358
|
+
for (let s = 0; s <= steps; s++) {
|
|
359
|
+
const u = s / steps;
|
|
360
|
+
put(plate, Math.floor(x0 + (x1 - x0) * u), Math.floor(y0 + (y1 - y0) * u), rgba);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A square of side `2r + 1` centred on pixel (cx, cy): filled, or its border only. */
|
|
365
|
+
function square(plate: Plate, cx: number, cy: number, r: number, hollow: boolean, rgba: RGBA): void {
|
|
366
|
+
for (let dy = -r; dy <= r; dy++) {
|
|
367
|
+
for (let dx = -r; dx <= r; dx++) {
|
|
368
|
+
if (hollow && Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
|
|
369
|
+
put(plate, cx + dx, cy + dy, rgba);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function text(plate: Plate, value: string, x: number, y: number, rgba: RGBA): void {
|
|
375
|
+
drawText(value, x, y, 1, (px, py) => put(plate, px, py, rgba));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The grid step: the smallest 1, 2 or 5 × 10ⁿ world units whose lines land at least `GRID_MIN_PIXELS` apart. */
|
|
379
|
+
export function gridStep(scale: number): number {
|
|
380
|
+
const least = GRID_MIN_PIXELS / scale;
|
|
381
|
+
let power = 10 ** Math.floor(Math.log10(least));
|
|
382
|
+
for (;;) {
|
|
383
|
+
for (const m of [1, 2, 5]) if (m * power >= least) return m * power;
|
|
384
|
+
power *= 10;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A grid value as its label prints it: as many decimals as the step has, no more. */
|
|
389
|
+
function gridLabel(value: number, step: number): string {
|
|
390
|
+
const decimals = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
|
|
391
|
+
const shown = value.toFixed(decimals);
|
|
392
|
+
return /^-0(\.0*)?$/.test(shown) ? shown.slice(1) : shown;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Whole multiples of `step` from `lo` to `hi`, inclusive. */
|
|
396
|
+
function multiples(lo: number, hi: number, step: number): number[] {
|
|
397
|
+
const out: number[] = [];
|
|
398
|
+
for (let k = Math.ceil(lo / step); k * step <= hi; k++) out.push(k * step === 0 ? 0 : k * step);
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// the view
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/** The poses one picture is of: the frames, in the order the request named their times. */
|
|
407
|
+
function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean): Array<{ frame: Frame; index: number }> {
|
|
408
|
+
const opts: PoseOptions = { bones: true, ...(needAttachments ? { geometry: true } : {}), ...(request.skin === undefined ? {} : { skin: request.skin }) };
|
|
409
|
+
if (request.animation === null) return [{ frame: sampleSetupPose(poser, opts)[0], index: 0 }];
|
|
410
|
+
const animation = poser.animations.find((a) => a.name === request.animation);
|
|
411
|
+
if (animation === undefined) {
|
|
412
|
+
throw new InspectError(`no animation ${JSON.stringify(request.animation)} in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`);
|
|
413
|
+
}
|
|
414
|
+
const indices = request.times.map((t) => gridFrame(t, request.fps, animation.duration, animation.name));
|
|
415
|
+
const seen = new Set<number>();
|
|
416
|
+
for (const [i, index] of indices.entries()) {
|
|
417
|
+
if (seen.has(index)) throw new InspectError(`--at names frame ${index} (${(index / request.fps).toFixed(4)}s) twice — at ${request.times[i]}`);
|
|
418
|
+
seen.add(index);
|
|
419
|
+
}
|
|
420
|
+
const frames = sampleAnimation(poser, animation.name, request.fps, opts);
|
|
421
|
+
return indices.map((index) => ({ frame: frames[index], index }));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The world box of what `--zoom` names, over every pose of the picture. */
|
|
425
|
+
function zoomBox(
|
|
426
|
+
poser: Poser,
|
|
427
|
+
poses: ReadonlyArray<{ frame: Frame }>,
|
|
428
|
+
target: string,
|
|
429
|
+
lengths: Map<string, number>,
|
|
430
|
+
): { box: [number, number, number, number]; bone?: string; slot?: string } {
|
|
431
|
+
const isBone = poser.bones.some((b) => b.name === target);
|
|
432
|
+
const isSlot = poser.slots.some((s) => s.name === target);
|
|
433
|
+
if (isBone && isSlot) {
|
|
434
|
+
// A rig names a slot after its bone often; which box is meant is the caller's to say, not a guess.
|
|
435
|
+
throw new InspectError(
|
|
436
|
+
`--zoom ${JSON.stringify(target)} names both a bone and a slot; say which with --zoom bone:${target} or --zoom slot:${target}`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
let kind: 'bone' | 'slot' | null = isBone ? 'bone' : isSlot ? 'slot' : null;
|
|
440
|
+
let name = target;
|
|
441
|
+
for (const prefix of ['bone', 'slot'] as const) {
|
|
442
|
+
if (kind === null && target.startsWith(`${prefix}:`)) {
|
|
443
|
+
name = target.slice(prefix.length + 1);
|
|
444
|
+
const has = prefix === 'bone' ? poser.bones.some((b) => b.name === name) : poser.slots.some((s) => s.name === name);
|
|
445
|
+
if (has) kind = prefix;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (kind === null) {
|
|
449
|
+
throw new InspectError(
|
|
450
|
+
`--zoom ${JSON.stringify(target)}: no bone or slot of that name. Bones: [${poser.bones.map((b) => b.name).join(', ')}]; ` +
|
|
451
|
+
`slots: [${poser.slots.map((s) => s.name).join(', ')}]`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const box: [number, number, number, number] = [Infinity, Infinity, -Infinity, -Infinity];
|
|
455
|
+
const take = (x: number, y: number): void => {
|
|
456
|
+
box[0] = Math.min(box[0], x);
|
|
457
|
+
box[1] = Math.min(box[1], y);
|
|
458
|
+
box[2] = Math.max(box[2], x);
|
|
459
|
+
box[3] = Math.max(box[3], y);
|
|
460
|
+
};
|
|
461
|
+
for (const { frame } of poses) {
|
|
462
|
+
if (kind === 'bone') {
|
|
463
|
+
const b = frame.bones?.find((s) => s.name === name);
|
|
464
|
+
if (b === undefined) continue;
|
|
465
|
+
if (b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0) continue;
|
|
466
|
+
const length = lengths.get(name) ?? 0;
|
|
467
|
+
take(b.worldX, b.worldY);
|
|
468
|
+
take(b.worldX + length * b.a, b.worldY + length * b.c);
|
|
469
|
+
} else {
|
|
470
|
+
for (const shown of frame.attachments ?? []) {
|
|
471
|
+
if (shown.slot !== name) continue;
|
|
472
|
+
for (let i = 0; i + 1 < shown.vertices.length; i += 2) take(shown.vertices[i], shown.vertices[i + 1]);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (!box.every(Number.isFinite)) {
|
|
477
|
+
throw new InspectError(
|
|
478
|
+
kind === 'bone'
|
|
479
|
+
? `--zoom ${JSON.stringify(target)}: bone "${name}" is not posed at any time of this picture (its world matrix is 0 — unposed by the skin, or a scale of 0)`
|
|
480
|
+
: `--zoom ${JSON.stringify(target)}: slot "${name}" shows no region or mesh at any time of this picture`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return kind === 'bone' ? { box, bone: name } : { box, slot: name };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The picture and its legend, posed through `candidate`'s chosen poser — the
|
|
488
|
+
* one `render` would pose it with (`throughPoser`), named in the result.
|
|
489
|
+
*/
|
|
490
|
+
export function inspectCandidate(candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant = {}): InspectResult {
|
|
491
|
+
const { facts, pages } = candidate;
|
|
492
|
+
if (request.skin !== undefined && !facts.skins.includes(request.skin)) {
|
|
493
|
+
throw new InspectError(`no skin ${JSON.stringify(request.skin)} in this skeleton; it declares [${facts.skins.join(', ') || 'none'}]`);
|
|
494
|
+
}
|
|
495
|
+
if (request.animation === null && request.times.length > 0) {
|
|
496
|
+
throw new InspectError('--at needs --animation <name>: the setup pose has no time. Without --at the picture is the setup pose');
|
|
497
|
+
}
|
|
498
|
+
if (request.animation !== null && request.times.length === 0) {
|
|
499
|
+
throw new InspectError(`--animation ${request.animation} needs --at <s[,s…]>: the times of it to draw, on the ${request.fps} fps grid render draws it on`);
|
|
500
|
+
}
|
|
501
|
+
const axes = request.show.includes('declared') ? declaredAxes(texts.modelText) : [];
|
|
502
|
+
const lengths = boneLengths(texts.skeletonText);
|
|
503
|
+
const posed = throughPoser(candidate.choice, (poser, roster) => view(poser, roster, pages, lengths, axes, request, plant));
|
|
504
|
+
return { ...posed.value, legend: { ...posed.value.legend, poser: posed.poser }, poserNote: posed.note };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function view(
|
|
508
|
+
poser: Poser,
|
|
509
|
+
roster: SkinRoster,
|
|
510
|
+
pages: Map<string, Plate>,
|
|
511
|
+
lengths: Map<string, number>,
|
|
512
|
+
axes: readonly DeclaredAxis[],
|
|
513
|
+
request: InspectRequest,
|
|
514
|
+
plant: InspectPlant,
|
|
515
|
+
): Omit<InspectResult, 'poserNote'> {
|
|
516
|
+
const notes: string[] = [];
|
|
517
|
+
const zooming = request.frame.kind === 'zoom';
|
|
518
|
+
const poses = posesOf(poser, request, zooming);
|
|
519
|
+
// The world box and its pixels: one for every tile, so a sheet's tiles overlay.
|
|
520
|
+
let viewport: Viewport;
|
|
521
|
+
let framing: InspectLegend['framing'];
|
|
522
|
+
if (request.frame.kind === 'viewport') {
|
|
523
|
+
const f = request.frame;
|
|
524
|
+
viewport = viewportFor(f.x, f.y, f.x + f.width, f.y + f.height, request.maxSide);
|
|
525
|
+
framing = { kind: 'viewport' };
|
|
526
|
+
} else if (request.frame.kind === 'zoom') {
|
|
527
|
+
const target = plant.zoomBone ?? request.frame.target;
|
|
528
|
+
const found = zoomBox(poser, poses, target, lengths);
|
|
529
|
+
const [x0, y0, x1, y1] = found.box;
|
|
530
|
+
const longer = Math.max(x1 - x0, y1 - y0);
|
|
531
|
+
const pad = request.frame.pad ?? longer * DEFAULT_ZOOM_PAD_SHARE;
|
|
532
|
+
if (longer + 2 * pad === 0) {
|
|
533
|
+
throw new InspectError(
|
|
534
|
+
`--zoom ${JSON.stringify(request.frame.target)}: what it names is one point, (${x0}, ${y0}), at every time of this picture, so ` +
|
|
535
|
+
`the box has no size — state a pad in world units: --zoom ${request.frame.target}:<pad>`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
viewport = viewportFor(x0 - pad, y0 - pad, x1 + pad, y1 + pad, request.maxSide);
|
|
539
|
+
framing = { kind: 'zoom', ...(found.bone === undefined ? {} : { bone: found.bone }), ...(found.slot === undefined ? {} : { slot: found.slot }), pad, padFrom: request.frame.pad === null ? 'default' : 'stated' };
|
|
540
|
+
} else {
|
|
541
|
+
const shot = framingViewport(poser, request.maxSide, request.skin === undefined ? undefined : { skin: request.skin }, roster);
|
|
542
|
+
if (shot === null) throw new InspectError('this skeleton poses no drawable attachment in any animation or in its setup pose — there is nothing to frame; name a box with --viewport');
|
|
543
|
+
viewport = shot;
|
|
544
|
+
framing = { kind: 'shot', note: "render's own framing: every animation's posed box at 60 fps, padded — the grid render draws this build on at the same --max" };
|
|
545
|
+
}
|
|
546
|
+
const { width, height, scale } = viewport;
|
|
547
|
+
const sheet = poses.length > 1;
|
|
548
|
+
const columns = sheet ? Math.min(SHEET_COLUMNS, poses.length) : 1;
|
|
549
|
+
const rows = Math.ceil(poses.length / columns);
|
|
550
|
+
const gap = sheet ? SHEET_GAP : 0;
|
|
551
|
+
const plate = new Plate(columns * (width + gap) + gap, rows * (height + gap) + gap);
|
|
552
|
+
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(colour('rule'), i);
|
|
553
|
+
const [shiftX, shiftY] = plant.overlayShift ?? [0, 0];
|
|
554
|
+
const [lieX, lieY] = plant.legendShift ?? [0, 0];
|
|
555
|
+
const tiles: LegendTile[] = [];
|
|
556
|
+
for (const [i, { frame, index }] of poses.entries()) {
|
|
557
|
+
const x0 = gap + (i % columns) * (width + gap);
|
|
558
|
+
const y0 = gap + Math.floor(i / columns) * (height + gap);
|
|
559
|
+
// ⭐ The one projection both the picture and the legend read: continuous pixels in the whole picture.
|
|
560
|
+
const toPixel = (wx: number, wy: number): [number, number] => [
|
|
561
|
+
x0 + (wx - viewport.minX) * scale,
|
|
562
|
+
y0 + (plant.unflipped ? wy - viewport.minY : viewport.maxY - wy) * scale,
|
|
563
|
+
];
|
|
564
|
+
const point = (wx: number, wy: number): LegendPoint => {
|
|
565
|
+
const [px, py] = toPixel(wx, wy);
|
|
566
|
+
return { world: [wx, wy], pixel: [px + lieX, py + lieY] };
|
|
567
|
+
};
|
|
568
|
+
const tile = request.show.includes('art') ? renderFrame(frame, pages, viewport, BACKGROUND) : filled(width, height, BACKGROUND);
|
|
569
|
+
if (request.dim) {
|
|
570
|
+
for (let p = 0; p < tile.data.length; p += 4) {
|
|
571
|
+
for (let c = 0; c < 3; c++) tile.data[p + c] = Math.round(255 - (255 - tile.data[p + c]) * DIM_KEEP);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// Drawn on the tile in tile pixels; `tx`/`ty` take a picture pixel back to the tile's.
|
|
575
|
+
const tx = (px: number): number => px - x0;
|
|
576
|
+
const ty = (py: number): number => py - y0;
|
|
577
|
+
const legendTile: LegendTile = {
|
|
578
|
+
animation: request.animation,
|
|
579
|
+
frame: index,
|
|
580
|
+
time: request.animation === null ? 0 : index / request.fps,
|
|
581
|
+
box: { x: x0, y: y0, width, height },
|
|
582
|
+
transform: { minX: viewport.minX, maxY: viewport.maxY, scale, x0, y0 },
|
|
583
|
+
bones: [],
|
|
584
|
+
};
|
|
585
|
+
if (request.show.includes('grid')) {
|
|
586
|
+
const step = plant.gridInPixels ? gridStep(scale) / scale : gridStep(scale);
|
|
587
|
+
const xs = multiples(viewport.minX, viewport.maxX, step);
|
|
588
|
+
const ys = multiples(viewport.minY, viewport.maxY, step);
|
|
589
|
+
for (const gx of xs) {
|
|
590
|
+
const col = Math.floor(tx(toPixel(gx, 0)[0]));
|
|
591
|
+
for (let y = 0; y < height; y++) put(tile, col, y, colour(gx === 0 ? 'gridZero' : 'grid'));
|
|
592
|
+
}
|
|
593
|
+
for (const gy of ys) {
|
|
594
|
+
const row = Math.floor(ty(toPixel(0, gy)[1]));
|
|
595
|
+
for (let x = 0; x < width; x++) put(tile, x, row, colour(gy === 0 ? 'gridZero' : 'grid'));
|
|
596
|
+
}
|
|
597
|
+
for (const gx of xs) text(tile, gridLabel(gx, step), Math.floor(tx(toPixel(gx, 0)[0])) + 2, 2, colour('gridZero'));
|
|
598
|
+
for (const gy of ys) text(tile, gridLabel(gy, step), 2, Math.floor(ty(toPixel(0, gy)[1])) + 2, colour('gridZero'));
|
|
599
|
+
legendTile.grid = { step, x: xs, y: ys };
|
|
600
|
+
}
|
|
601
|
+
const snapshots = new Map<string, BoneSnapshot>((frame.bones ?? []).map((b) => [b.name, b]));
|
|
602
|
+
const isPosed = (b: BoneSnapshot | undefined): b is BoneSnapshot => b !== undefined && !(b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0);
|
|
603
|
+
const held = new Set<string>();
|
|
604
|
+
if (request.show.includes('declared')) {
|
|
605
|
+
legendTile.declared = [];
|
|
606
|
+
for (const axis of axes) {
|
|
607
|
+
const a = snapshots.get(axis.through[0]);
|
|
608
|
+
const b = snapshots.get(axis.through[1]);
|
|
609
|
+
const entry: LegendDeclared = { name: axis.name, through: axis.through, tolerance: axis.tolerance, degrees: null, why: null, from: null, to: null, bones: [] };
|
|
610
|
+
if (!isPosed(a) || !isPosed(b)) {
|
|
611
|
+
entry.why = `${!isPosed(a) ? axis.through[0] : axis.through[1]} is not posed at this time (its world matrix is 0), so the line has no point there`;
|
|
612
|
+
} else if (a.worldX === b.worldX && a.worldY === b.worldY) {
|
|
613
|
+
entry.why = `"${axis.through[0]}" and "${axis.through[1]}" share the world origin (${a.worldX}, ${a.worldY}), so they draw no line`;
|
|
614
|
+
} else {
|
|
615
|
+
const degrees = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
|
|
616
|
+
entry.degrees = degrees;
|
|
617
|
+
entry.from = point(a.worldX, a.worldY);
|
|
618
|
+
entry.to = point(b.worldX, b.worldY);
|
|
619
|
+
// Across the whole tile: extend the line past both ends by the tile's diagonal, in world units.
|
|
620
|
+
const reach = Math.hypot(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY);
|
|
621
|
+
const ux = (b.worldX - a.worldX) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
622
|
+
const uy = (b.worldY - a.worldY) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
623
|
+
const [p0x, p0y] = toPixel(a.worldX - ux * reach, a.worldY - uy * reach);
|
|
624
|
+
const [p1x, p1y] = toPixel(a.worldX + ux * reach, a.worldY + uy * reach);
|
|
625
|
+
line(tile, tx(p0x) + shiftX, ty(p0y) + shiftY, tx(p1x) + shiftX, ty(p1y) + shiftY, colour('declared'));
|
|
626
|
+
for (const name of axis.bones) {
|
|
627
|
+
const s = snapshots.get(name);
|
|
628
|
+
if (!isPosed(s) || (s.a === 0 && s.c === 0)) {
|
|
629
|
+
entry.bones.push({ name, degrees: null, off: null });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const found = worldAxisDegrees(s);
|
|
633
|
+
entry.bones.push({ name, degrees: found, off: lineGap(found, degrees) });
|
|
634
|
+
held.add(name);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (entry.why !== null && i === 0) notes.push(`declared: axis "${axis.name}" — ${entry.why}`);
|
|
638
|
+
legendTile.declared.push(entry);
|
|
639
|
+
}
|
|
640
|
+
if (axes.length === 0 && i === 0) notes.push('declared: the rig declares no invariants.axes — nothing drawn for it');
|
|
641
|
+
}
|
|
642
|
+
// Every bone, in declaration order — drawn or not, so the legend names each.
|
|
643
|
+
const drawn: Array<{ bone: LegendBone; o: [number, number]; t: [number, number] }> = [];
|
|
644
|
+
for (const declared of poser.bones) {
|
|
645
|
+
const s = snapshots.get(declared.name);
|
|
646
|
+
const length = lengths.get(declared.name) ?? 0;
|
|
647
|
+
const posedBone = isPosed(s);
|
|
648
|
+
const ox = s?.worldX ?? 0;
|
|
649
|
+
const oy = s?.worldY ?? 0;
|
|
650
|
+
const bone: LegendBone = {
|
|
651
|
+
name: declared.name,
|
|
652
|
+
parent: declared.parent,
|
|
653
|
+
length,
|
|
654
|
+
zeroLength: length === 0,
|
|
655
|
+
undrawn: posedBone ? null : 'its world matrix is 0 at this time — unposed by the skin, or a scale of 0',
|
|
656
|
+
origin: point(ox, oy),
|
|
657
|
+
tip: point(ox + length * (s?.a ?? 0), oy + length * (s?.c ?? 0)),
|
|
658
|
+
};
|
|
659
|
+
if (posedBone && request.show.includes('axes')) {
|
|
660
|
+
const unit = AXIS_PIXELS / scale;
|
|
661
|
+
bone.axes = { x: point(ox + s.a * unit, oy + s.c * unit), y: point(ox + s.b * unit, oy + s.d * unit) };
|
|
662
|
+
}
|
|
663
|
+
legendTile.bones.push(bone);
|
|
664
|
+
if (posedBone) drawn.push({ bone, o: toPixel(ox, oy), t: toPixel(bone.tip.world[0], bone.tip.world[1]) });
|
|
665
|
+
}
|
|
666
|
+
// Lines, then axes, then names, then the marks — so a mark is never covered.
|
|
667
|
+
const at = (p: [number, number]): [number, number] => [tx(p[0]) + shiftX, ty(p[1]) + shiftY];
|
|
668
|
+
if (request.show.includes('bones')) {
|
|
669
|
+
for (const { bone, o, t } of drawn) {
|
|
670
|
+
if (bone.zeroLength) continue;
|
|
671
|
+
const [ax, ay] = at(o);
|
|
672
|
+
const [bx, by] = at(t);
|
|
673
|
+
line(tile, ax, ay, bx, by, colour(held.has(bone.name) ? 'held' : 'bone'));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (request.show.includes('axes')) {
|
|
677
|
+
for (const { bone, o } of drawn) {
|
|
678
|
+
if (bone.axes === undefined) continue;
|
|
679
|
+
const [ax, ay] = at(o);
|
|
680
|
+
for (const [end, name] of [[bone.axes.y, 'axisY'], [bone.axes.x, 'axisX']] as const) {
|
|
681
|
+
const [ex, ey] = at(toPixel(end.world[0], end.world[1]));
|
|
682
|
+
line(tile, ax, ay, ex, ey, colour(name));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (request.show.includes('names')) {
|
|
687
|
+
// Bones whose origins share a pixel — a `_ctl` parent and its child, a chain's joint — list their names one per
|
|
688
|
+
// line under it, in declaration order, rather than printing them over each other.
|
|
689
|
+
const below = new Map<string, number>();
|
|
690
|
+
for (const { bone, o } of drawn) {
|
|
691
|
+
const [ax, ay] = at(o);
|
|
692
|
+
const key = `${Math.floor(ax)},${Math.floor(ay)}`;
|
|
693
|
+
const line = below.get(key) ?? 0;
|
|
694
|
+
below.set(key, line + 1);
|
|
695
|
+
text(tile, bone.name, Math.floor(ax) + 4, Math.floor(ay) + 3 + line * (GLYPH_H + 2), colour('name'));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (request.show.includes('bones')) {
|
|
699
|
+
for (const { bone, t } of drawn) {
|
|
700
|
+
if (bone.zeroLength) continue;
|
|
701
|
+
const [bx, by] = at(t);
|
|
702
|
+
square(tile, Math.floor(bx), Math.floor(by), 1, true, colour('tip'));
|
|
703
|
+
}
|
|
704
|
+
for (const { bone, o } of drawn) {
|
|
705
|
+
const [ax, ay] = at(o);
|
|
706
|
+
if (bone.zeroLength) square(tile, Math.floor(ax), Math.floor(ay), 2, true, colour('zero'));
|
|
707
|
+
square(tile, Math.floor(ax), Math.floor(ay), 1, false, colour('origin'));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (sheet) {
|
|
711
|
+
const label = request.animation === null ? 'setup' : `t=${legendTile.time.toFixed(3)} f${index}`;
|
|
712
|
+
const ly = height - GLYPH_H - 2;
|
|
713
|
+
for (let y = ly - 1; y < ly + GLYPH_H + 1; y++) for (let x = 1; x < textWidth(label, 1) + 3; x++) put(tile, x, y, BACKGROUND);
|
|
714
|
+
text(tile, label, 2, ly, colour('label'));
|
|
715
|
+
}
|
|
716
|
+
for (let y = 0; y < height; y++) plate.data.set(tile.data.subarray(y * width * 4, (y + 1) * width * 4), ((y0 + y) * plate.width + x0) * 4);
|
|
717
|
+
tiles.push(legendTile);
|
|
718
|
+
}
|
|
719
|
+
const legend: InspectLegend = {
|
|
720
|
+
spec: INSPECT_SPEC,
|
|
721
|
+
poser: 'core',
|
|
722
|
+
show: [...request.show],
|
|
723
|
+
dim: request.dim,
|
|
724
|
+
...(request.skin === undefined ? {} : { skin: request.skin }),
|
|
725
|
+
animation: request.animation,
|
|
726
|
+
fps: request.fps,
|
|
727
|
+
framing,
|
|
728
|
+
coordinates: {
|
|
729
|
+
world: 'spine world: y up, world units',
|
|
730
|
+
image: 'the picture: y down, from its top-left corner; pixel (i, j) covers [i, i+1) x [j, j+1), so a position\'s pixel is its floor',
|
|
731
|
+
toPixel: 'px = x0 + (x - minX) * scale; py = y0 + (maxY - y) * scale — each tile\'s own transform',
|
|
732
|
+
toWorld: 'x = minX + (px - x0) / scale; y = maxY - (py - y0) / scale',
|
|
733
|
+
},
|
|
734
|
+
viewport: { minX: viewport.minX, minY: viewport.minY, maxX: viewport.maxX, maxY: viewport.maxY, scale, width, height },
|
|
735
|
+
size: { width: plate.width, height: plate.height },
|
|
736
|
+
colours: Object.fromEntries(Object.entries(INSPECT_COLOURS).map(([k, v]) => [k, { rgba: [...v.rgba], meaning: v.meaning }])) as InspectLegend['colours'],
|
|
737
|
+
tiles,
|
|
738
|
+
};
|
|
739
|
+
return { plate, legend, notes };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function filled(width: number, height: number, rgba: RGBA): Plate {
|
|
743
|
+
const plate = new Plate(width, height);
|
|
744
|
+
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(rgba, i);
|
|
745
|
+
return plate;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** The legend as its file holds it: two-space JSON and a newline, so two runs over one build are one byte sequence. */
|
|
749
|
+
export function legendText(legend: InspectLegend): string {
|
|
750
|
+
return `${JSON.stringify(legend, null, 2)}\n`;
|
|
751
|
+
}
|