@rig-c/core 2.40.1 → 2.42.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 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, mesh edges and hulls, one bone's weights, the draw order, 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, or beside a second build on one transform — 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,26 @@ 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` (one label per pixel), `axes` (each bone's world x and y), `mesh` and `hull`
414
+ (each mesh's edges and outline), `weights:<bone>` (what that bone moves, as a heat),
415
+ `slots` (the draw order at the pose), `grid` (in world units) and `declared`
416
+ (each `invariants.axes` line and the bones held to it), at the setup pose, at
417
+ `--animation <name> --at <s[,s…]>` — one picture, or a sheet — or zoomed with
418
+ `--zoom <bone|slot>[:pad]` or `--viewport x,y,w,h`; `--bone`/`--hide-bone` pick the
419
+ bones by glob, and `--compare <dir>` draws a second build beside the first on one
420
+ transform, to see what a change moved. Beside the PNG it writes a legend:
421
+ the world-to-pixel transform both ways, what each colour means, and every bone's
422
+ origin and tip in world units and in pixels, so a position read off the picture goes
423
+ straight back into the spec. [AUTHORING §13](docs/AUTHORING.md#13-asking-the-picture-a-question--rigc-inspect)
424
+ maps each question to its view.
425
+
407
426
  **`render`** samples every animation at 12 fps and writes `render/<animation>/f0000.png…`
408
427
  with a `contact.png` beside them — every frame of the shot as one labelled grid,
409
428
  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,163 @@ 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
+ | What does this bone move? | `--show art,mesh,weights:<bone> --dim` | the bone's weight over every region and mesh it moves, as a heat: a pixel whose centre lies in a triangle is painted the ramp's colour at the blend of its corners' weights — blue near 0, yellow at 0.5, red at 1 (the stops are in the legend) — and a weight of 0 is not painted, so the art shows where the bone does not reach. A region and an unweighted mesh are moved whole by their slot's bone. The weights are read off `skeleton.json`, the file both posers skin from; a bone that weights nothing paints nothing and the run says so on a line of its own |
10097
+ | How is this part triangulated? | `--show art,mesh,hull --zoom slot:<slot>` | each mesh's triangle edges, and over them its hull — vertex 0 to 1 to … to `hull − 1` and back. Regions are not drawn: a region is not a mesh. The legend lists every vertex in world units and in pixels |
10098
+ | Which is drawn over which? | `--show art,slots` | each slot that shows a region or mesh numbered by its place in the draw order **at the pose drawn** (0 is drawn first), on a white box at the centre of its attachment's box — so a `drawOrder` key (§4.7) shows from the first frame `render` draws it on |
10099
+ | Did this change move anything? | `--compare <dir>` beside any view | the same view of a second build beside the first, at the same times, on **one** world box and one transform, each half labelled `A`/`B` with the poser that posed it. A mark on a different pixel in the two halves is at a different place in the two rigs |
10100
+
10101
+ ### 13.2 Where it looks
10102
+
10103
+ - **The setup pose** by default.
10104
+ - **One time, or several** — `--animation <name> --at <s[,s…]>`. A time is a frame
10105
+ of the grid `render --fps <n>` draws the animation on (default 12): the pose at
10106
+ `--at 0.5` is `render`'s `f0006.png`, the same continuous trajectory stepped the
10107
+ same way, physics included. A time off that grid is refused naming the frames
10108
+ either side (`--at 0.3 is not on the 12 fps frame grid … the frames either side
10109
+ are 3 (0.2500s) and 4 (0.3333s)`); pass `--fps` for another grid. A time past the
10110
+ end is refused naming the animation's length. Several times are a sheet: tiles
10111
+ left to right, eight to a row, a one-pixel rule between them, all on one world box
10112
+ so a mark that moves between two tiles moved in the rig.
10113
+ - **The whole rig**, framed as `render` frames it; **a box** — `--viewport x,y,w,h`
10114
+ in world units, y up, `x,y` its bottom-left corner; or **one bone or slot** —
10115
+ `--zoom <name>[:<pad>]`, the bone's origin and tip or the slot's attachment
10116
+ vertices over every time drawn, padded by `<pad>` world units on every side
10117
+ (default a quarter of the box's longer side). A name that is both a bone and a slot
10118
+ is refused until it is written `bone:<name>` or `slot:<name>`; a bone of length 0
10119
+ with no pad is one point, and is refused asking for one.
10120
+ - **Two builds** — `--compare <dir | skeleton.json>`. The second build is loaded as
10121
+ the first is (its own poser, chosen by what it carries, on a `poser B:` line),
10122
+ posed at the same times, and drawn beside it: one time is two halves side by side,
10123
+ several are pairs, four to a row. The box is the one `--viewport` names, or the
10124
+ union of what `--zoom` names in **both** builds, or — with neither — the union of
10125
+ `render`'s framing of each, so a half is `render`'s frame of its build only where
10126
+ the two framings agree. A name either build lacks is refused naming that build.
10127
+ - `--max` is each tile's longer side in pixels (default 256), as on `render`.
10128
+ - `--dim` fades the art toward white (each channel keeps 0.4 of its distance from
10129
+ white) so what is drawn over it reads.
10130
+
10131
+ ### 13.3 Labels, and which bones are drawn
10132
+
10133
+ - **One label per pixel.** Bones whose origins land on one pixel — a `_ctl` bone and
10134
+ its identity child, a chain's joint — are one label: the first by declaration
10135
+ order, then `+N` for the rest (`leg0_ctl +2`). The pixel is the unit because it is
10136
+ what overprints: two origins a third of a pixel apart print their names on each
10137
+ other as surely as two that coincide. The legend's `labels` lists every bone of
10138
+ each label, and `bones` keeps every bone.
10139
+ - **No label over another.** Labels are placed in declaration order (slot numbers
10140
+ first); one that would touch an earlier label moves down a text line at a time
10141
+ until it is clear, and a grey leader joins it to its origin. After 16 lines it
10142
+ stays where it began and the legend marks it `overlaps`.
10143
+ - **`--bone <glob[,glob…]>`** draws only the bones a pattern matches, and
10144
+ **`--hide-bone <glob[,glob…]>`** none of the ones it matches, applied after
10145
+ `--bone` — so `--bone 'bang_*' --hide-bone '*_ctl,*_px'` is the fringe chains
10146
+ without their control bones. A pattern is a glob — `*` any run of characters, `?`
10147
+ one, matched against the whole name — rather than a regular expression: bone names
10148
+ are identifiers, the patterns are comma-separated, and a glob needs no escaping in
10149
+ the shell. The filter is on `bones`, `axes` and `names`; every bone stays in the
10150
+ legend, and a left-out one says which flag left it out in `undrawn`. A pattern
10151
+ that matches no bone is refused naming the bones there are.
10152
+
10153
+ ### 13.4 The legend
10154
+
10155
+ `<out>.json` beside `<out>.png`, `rigc-inspect/1`. What it states:
10156
+
10157
+ - `coordinates` — the two spaces and the transform, in both directions, as text:
10158
+ `px = x0 + (x - minX) * scale; py = y0 + (maxY - y) * scale` and
10159
+ `x = minX + (px - x0) / scale; y = maxY - (py - y0) / scale`. World is Spine's, y up;
10160
+ the picture is y down from its top-left corner, and pixel (i, j) covers
10161
+ `[i, i+1) x [j, j+1)` — a position's pixel is its floor.
10162
+ - `viewport` — the world box every tile is a picture of and its size in pixels;
10163
+ `size` — the whole picture's.
10164
+ - `tiles[]` — per tile: `animation`, `frame` and `time` (`frame / fps`), its `box` in
10165
+ the picture, its own `transform` (`minX`, `maxY`, `scale`, and `x0`, `y0`, its
10166
+ top-left pixel), and `bones[]` — every bone in declaration order with its
10167
+ `parent`, `length`, `zeroLength`, `origin` and `tip` each as `{ world, pixel }`,
10168
+ `undrawn` (why it is not drawn — its world matrix is 0 at this time, unposed by the
10169
+ skin or scaled to 0, or a `--bone`/`--hide-bone` pattern left it out — or `null`),
10170
+ and with `axes` shown, the two axis ends. With `grid`, the step and every line's
10171
+ value; with `declared`, each entry's line in degrees, its two points, and each held
10172
+ bone's angle and how far off the line it is. With `names`, `labels[]`: each label's
10173
+ `text`, the `bones` it stands for, its `anchor` pixel, the `box` it is drawn in,
10174
+ and whether it was `moved` or `overlaps`. With `mesh`, `hull` or `weights`,
10175
+ `meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
10176
+ `vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
10177
+ triangle `edges` as vertex pairs, every posed vertex in `world` and `pixel`, and
10178
+ with `weights` the bone's weight at each vertex. With `slots`, `slots[]`: each
10179
+ slot's `order`, `slot`, `attachment`, `bone`, its `anchor` and number's `box`.
10180
+ On a sheet or a compare, `label` — the tile's label text and box; with `--compare`,
10181
+ `build` — `A` or `B`.
10182
+ - `colours` — every colour the picture draws, as RGBA, with what it means.
10183
+ - `poser`, `show`, `dim`, `skin`, `animation`, `fps` and `framing` — what was drawn
10184
+ and how it was framed (`shot`, `zoom` with the bone or slot, the pad and whether it
10185
+ was stated, or `viewport`).
10186
+ - `weights` — with `weights:<bone>`: the bone, the ramp's stops as RGBA, the floor
10187
+ below which nothing is painted, and how a pixel's weight is read.
10188
+ - `boneFilter` — `--bone` and `--hide-bone` as given.
10189
+ - `builds` — with `--compare`: each build (`A`, `B`), the poser that posed it, and
10190
+ its `skeleton.json`'s sha256. `poser` is build `A`'s.
10191
+
10192
+ Every number is `JSON.stringify`'s, the double the pose produced, not a rounding of
10193
+ it. The same build and flags write the same bytes (`IN10`).
10194
+
10195
+ ### 13.5 What it refuses
10196
+
10197
+ Each of these exits 2, writes nothing, and names what exists: a bone or slot the
10198
+ build does not have (both rosters listed), an animation or skin it does not declare,
10199
+ a `--show` word it does not draw, a flag it does not take, a time off the frame grid
10200
+ or past the end, `--at` without `--animation` and the reverse, `--zoom` beside
10201
+ `--viewport`, `--show declared` on a skeleton with no `skeleton.model.json`
10202
+ beside it whose `spine.sha256` is its own — a Spine export states no rig invariants —
10203
+ `weights` with no bone or with a bone the build lacks (the bones listed), a
10204
+ `--bone`/`--hide-bone` pattern that matches no bone (the bones listed), and, with
10205
+ `--compare`, any of these that holds of the second build, said as `the --compare
10206
+ build: …`.
10207
+ A rig that declares no `invariants.axes` is not refused: the picture draws nothing
10208
+ for `declared` and the run says so on a line of its own.
10209
+
10210
+ ### 13.6 What it is not
10211
+
10212
+ - **Not a score.** Nothing in it is a pass bar; the picture is read, and what is read
10213
+ is acted on in the spec.
10214
+ - **Not a player.** It is one still, or a sheet of stills. A person watches
10215
+ `@rig-c/play` or `rigc preview`.
10216
+ - **Not a second poser.** Everything it draws is a pose `render` would have drawn at
10217
+ 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.40.1",
3
+ "version": "2.42.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": {
@@ -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
@@ -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 InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, 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,118 @@ 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, weightsBone } = readShow(flags.show);
680
+ const include = readBonePatterns('bone', flags.bone);
681
+ const exclude = readBonePatterns('hide-bone', flags['hide-bone']);
682
+ const boneFilter = include === undefined && exclude === undefined ? undefined : { ...(include === undefined ? {} : { include }), ...(exclude === undefined ? {} : { exclude }) };
683
+ const times = readTimes(flags.at);
684
+ const fps = readPositiveNumber(flags, 'fps', INSPECT_DEFAULT_FPS, 1);
685
+ const maxSide = readPositiveNumber(flags, 'max', 256, 16);
686
+ let frame: InspectFrame = { kind: 'shot' };
687
+ if (flags.zoom !== undefined) frame = { kind: 'zoom', ...readZoom(flags.zoom) };
688
+ if (flags.viewport !== undefined) {
689
+ const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
690
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
691
+ throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
692
+ }
693
+ if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
694
+ frame = { kind: 'viewport', x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
695
+ }
696
+ const target = resolve(flags.out ?? DEFAULT_INSPECT_OUT);
697
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_INSPECT_OUT) : target;
698
+ 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)`);
699
+ const legendPath = `${out.slice(0, -'.png'.length)}.json`;
700
+
701
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
702
+ const skeletonText = readSkeletonText(skeletonPath);
703
+ console.log('rigc inspect');
704
+ console.log(` .. skeleton ${skeletonPath}`);
705
+ console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
706
+ // `render`'s loading, in `render`'s order (issue #1014): the poser is chosen before anything is read off the candidate.
707
+ const candidate = loadCandidate(
708
+ { skeletonText, atlasText, atlasDir: dirname(atlasPath), label: skeletonPath },
709
+ { skeleton: skeletonPath, atlas: atlasPath },
710
+ posersAsked(flags),
711
+ );
712
+ const animation = readAnimationFlag(flags, [...candidate.facts.animations]) ?? null;
713
+ readPoserFlag(flags);
714
+ refuseUnchosen(candidate.choice);
715
+ // `--compare`: the second build, loaded the way the first was — its own poser chosen by what it carries, and said.
716
+ let compare: InspectCompare | undefined;
717
+ if (flags.compare !== undefined) {
718
+ const other = resolveDrawable(flags.compare, undefined);
719
+ const otherText = readSkeletonText(other.skeletonPath);
720
+ console.log(` .. compare ${other.skeletonPath}`);
721
+ const otherCandidate = loadCandidate(
722
+ { skeletonText: otherText, atlasText: other.atlasText, atlasDir: dirname(other.atlasPath), label: other.skeletonPath },
723
+ { skeleton: other.skeletonPath, atlas: other.atlasPath },
724
+ posersAsked(flags),
725
+ );
726
+ refuseUnchosen(otherCandidate.choice);
727
+ compare = { candidate: otherCandidate, texts: { skeletonText: otherText, modelText: modelTextBeside(other.skeletonPath, otherText) } };
728
+ }
729
+ const result = inspectCandidate(
730
+ candidate,
731
+ { skeletonText, modelText: modelTextBeside(skeletonPath, skeletonText) },
732
+ {
733
+ show,
734
+ ...(weightsBone === undefined ? {} : { weightsBone }),
735
+ ...(boneFilter === undefined ? {} : { boneFilter }),
736
+ dim: flags.dim !== undefined,
737
+ animation,
738
+ times,
739
+ fps,
740
+ skin: flags.skin,
741
+ frame,
742
+ maxSide,
743
+ },
744
+ {},
745
+ compare,
746
+ );
747
+ const { legend } = result;
748
+ console.log(` .. poser ${compare === undefined ? '' : 'A: '}${result.poserNote}`);
749
+ if (result.comparePoserNote !== undefined) console.log(` .. poser B: ${result.comparePoserNote}`);
750
+ const when = animation === null ? 'the setup pose' : `${animation} at ${legend.tiles.filter((t) => t.build !== 'B').map((t) => `${t.time.toFixed(4)}s (frame ${t.frame})`).join(', ')}, ${fps} fps grid`;
751
+ console.log(` .. view ${legend.show.join(',')}${legend.dim ? ' over dimmed art' : ''} — ${when}`);
752
+ const v = legend.viewport;
753
+ // Four places for reading; the legend carries every number exactly.
754
+ const f = (n: number): string => String(Number(n.toFixed(4)));
755
+ 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)`);
756
+ for (const note of result.notes) console.log(` .. ${note}`);
757
+ mkdirSync(dirname(out), { recursive: true });
758
+ result.plate.writePng(out);
759
+ writeFileSync(legendPath, legendText(legend));
760
+ console.log(`rigc: wrote ${out}`);
761
+ console.log(`rigc: wrote ${legendPath}`);
762
+ }
763
+
651
764
  /** `--scale 0.5,2` / `--rotation -30,30` — a pair of numbers, low first. */
652
765
  function readRange(flags: Record<string, string>, key: string): { low: number; high: number } | undefined {
653
766
  const raw = flags[key];
@@ -1621,6 +1734,7 @@ export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
1621
1734
  diff: ({ flags, lists, positional }) => cmdDiff(flags, lists, positional),
1622
1735
  check: ({ flags }) => cmdCheck(flags),
1623
1736
  render: ({ flags }) => cmdRender(flags),
1737
+ inspect: ({ flags }) => cmdInspect(flags),
1624
1738
  pose: ({ flags }) => cmdPose(flags),
1625
1739
  chainfit: ({ flags }) => cmdChainFit(flags),
1626
1740
  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_SPELLINGS } 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,36 @@ 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_SPELLINGS.join(', ')}] (default \`${DEFAULT_SHOW.join(',')}\`): art = the ` +
1067
+ "build's own frame; grid = lines at whole multiples of a step in world units, labelled; weights:<bone> = that bone's " +
1068
+ 'weight over every region and mesh it moves, as a heat on a ramp the legend states; mesh = each mesh\'s triangle edges; ' +
1069
+ "hull = each mesh's hull outline; declared = each invariants.axes " +
1070
+ "line through its two bones' origins, its held bones drawn in their own colour; bones = each bone from origin to tip, " +
1071
+ 'a bone of length 0 marked as one; axes = each bone\'s world x and y axes; slots = each slot\'s place in the posed ' +
1072
+ 'draw order; names = the bones\' names, one label per pixel (bones sharing an origin pixel are "first +N"). Any other word ' +
1073
+ 'is refused naming these',
1074
+ dim: `fade the art toward white (each channel keeps ${DIM_KEEP} of its distance from white), so what is drawn over it reads`,
1075
+ at:
1076
+ `the times of --animation to draw, in seconds, comma-separated — one is a picture, several a sheet of tiles on one ` +
1077
+ `grid in the order given. Each must be a frame of the --fps grid render draws the animation on (within ` +
1078
+ `${ON_GRID_FRAMES} of a frame), and is refused naming the frames either side otherwise; without --animation the ` +
1079
+ 'picture is the setup pose and --at is refused',
1080
+ zoom:
1081
+ 'frame the picture to one bone (origin to tip) or one slot (its attachment\'s vertices) over every time drawn, padded ' +
1082
+ '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 ' +
1083
+ 'slot is refused until written bone:<name> or slot:<name>; a name that is neither lists both rosters. Not with --viewport',
1084
+ bone:
1085
+ "draw only the bones these globs match (`*` any run of characters, `?` one, the whole name; comma-separated) — on `inspect`, " +
1086
+ "for bones, axes and names; every bone stays in the legend, a left-out one saying why in `undrawn`. A pattern that matches no " +
1087
+ 'bone is refused naming the bones there are',
1088
+ 'hide-bone':
1089
+ "draw none of the bones these globs match — `--bone` the other way round, applied after it, so `--bone 'bang_*' " +
1090
+ "--hide-bone '*_ctl'` is the fringe chains without their control bones",
1091
+ compare:
1092
+ 'a second build to draw beside --candidate, the same view at the same times on one world box and one transform — the union ' +
1093
+ 'of both framings unless --zoom or --viewport pins it — each half labelled with its build and the poser that posed it. A ' +
1094
+ 'name, animation or skin the second build lacks is refused naming it',
1064
1095
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
1065
1096
  ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
1066
1097
  ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
@@ -1158,6 +1189,12 @@ const FLAG_VALUES: Record<string, string> = {
1158
1189
  slot: '<name[,name…]>',
1159
1190
  hide: '<name[,name…]>',
1160
1191
  max: '<px>',
1192
+ show: '<view[,view…]>',
1193
+ at: '<s[,s…]>',
1194
+ zoom: '<name>[:<pad>]',
1195
+ bone: '<glob[,glob…]>',
1196
+ 'hide-bone': '<glob[,glob…]>',
1197
+ compare: '<dir|skeleton.json>',
1161
1198
  record: '<result.json>',
1162
1199
  ballot: '<ballot.html>',
1163
1200
  ledger: '<votes.jsonl>',
@@ -1536,6 +1573,38 @@ export const COMMANDS: CommandDoc[] = [
1536
1573
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
1537
1574
  },
1538
1575
  },
1576
+ {
1577
+ name: 'inspect',
1578
+ runtime: false,
1579
+ spineFormat: false,
1580
+ usage: [
1581
+ 'rigc inspect --candidate <dir | skeleton.json> [--show art,bones,…] [--dim] [--max 256] [--poser core|spine] [--out inspect.png] (the setup pose)',
1582
+ 'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
1583
+ 'rigc inspect … --zoom <bone|slot>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
1584
+ "rigc inspect … --bone 'bang_*' --hide-bone '*_ctl' (which bones bones, axes and names draw)",
1585
+ 'rigc inspect … --compare <dir | skeleton.json> (a second build beside the first, on one transform)',
1586
+ ],
1587
+ flags: ['candidate', 'atlas', 'show', 'dim', 'animation', 'at', 'fps', 'skin', 'zoom', 'viewport', 'bone', 'hide-bone', 'compare', 'max', 'poser', 'out'],
1588
+ overrides: {
1589
+ animation: { meaning: 'the animation --at reads its times from (default: the setup pose, with no --at)' },
1590
+ fps: { meaning: `the frame grid --at is read on, which is the grid \`render --fps\` draws (default ${INSPECT_DEFAULT_FPS})` },
1591
+ 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' },
1592
+ out: {
1593
+ value: '<file.png>',
1594
+ meaning:
1595
+ 'the picture to write (default `inspect.png`; a directory means that name in it), and beside it the legend, the same ' +
1596
+ 'path with .json for .png: the world-to-pixel transform both ways, what each colour means, and every bone\'s origin ' +
1597
+ 'and tip in world units and in pixels',
1598
+ },
1599
+ },
1600
+ notes: [
1601
+ 'inspect draws one moment of the build for an agent to read, and its legend says',
1602
+ 'where every mark is in world units and in pixels. It poses through the poser',
1603
+ 'render would pose the candidate with, and the poser line says which. Without',
1604
+ '--zoom or --viewport the picture is framed as render frames the build, so its',
1605
+ 'art is render\'s frame at the same time and --max. Nothing in it is a score.',
1606
+ ],
1607
+ },
1539
1608
  {
1540
1609
  name: 'preview',
1541
1610
  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 +2773,12 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
2704
2773
  // Same kind as a PoseError, and printed the same way for the same reason: the
2705
2774
  // messages name a path, a bone or an attachment, and reprinting the whole
2706
2775
  // usage under them buries the one line that says what to change.
2776
+ // A view refused before anything was drawn (issue #1357): a name the build does not have, a time off render's
2777
+ // frame grid, a box with no size. The message lists what exists; the usage under it would bury that.
2778
+ if (err instanceof InspectError) {
2779
+ console.error(`rigc inspect: ${err.message}`);
2780
+ process.exit(2);
2781
+ }
2707
2782
  if (err instanceof ChainFitError) {
2708
2783
  console.error(`rigc chainfit: ${err.message}`);
2709
2784
  process.exit(2);