spine-rigc 2.45.2 → 2.46.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,7 +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
+ | 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 triangles folded over, 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 |
45
45
  | a compiled rig | `rigc preview` | one self-contained `.html` that plays it in Spine's own web player |
46
46
  | two to four compiled rigs | `rigc vote` | one ballot page a human picks from, and the answer checked into a ledger |
47
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)) |
@@ -413,12 +413,13 @@ and over it any of `bones` (origin to tip, a bone of length 0 marked as one),
413
413
  `names` (one label per pixel), `axes` (each bone's world x and y), `mesh` and `hull`
414
414
  (each mesh's edges and outline), `weights:<bone>` (what that bone moves, as a heat),
415
415
  `density` (each part's triangles per area against the figure's, on a log ramp),
416
- `slots` (the draw order at the pose), `grid` (in world units) and `declared`
416
+ `folds` (each mesh triangle turned over against the setup pose, filled — `render --folds`
417
+ names them per animation and frame), `slots` (the draw order at the pose), `grid` (in world units) and `declared`
417
418
  (each `invariants.axes` line and the bones held to it), at the setup pose, at
418
419
  `--animation <name> --at <s[,s…]>` — one picture, or a sheet — or zoomed with
419
420
  `--zoom <bone|slot>[:pad]` or `--viewport x,y,w,h`; `--bone`/`--hide-bone` pick the
420
421
  bones by glob, `--overlay-slot`/`--hide-overlay-slot` the slots whose mesh, hull,
421
- weights, density and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
422
+ weights, density, folds and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
422
423
  transform, to see what a change moved. Beside the PNG it writes a legend:
423
424
  the world-to-pixel transform both ways, what each colour means, and every bone's
424
425
  origin and tip in world units and in pixels, so a position read off the picture goes
@@ -600,7 +601,7 @@ commands take it and what its default is.
600
601
  | `repack <dir> --out <dir> --keep-pages` | **`skeleton.model.json` alone, for the pages already in place** — for a player that reads the document beside an atlas and pages that already shipped. The same read and stage rules, then `build --atlas-in` over the input atlas in place of the lift and the pack; it writes the document, and nothing else, only after its `pages` are shown to state the atlas as it stands, the rebuilt skeleton byte-identical to the one on disk (so `spine.sha256` names it — no acceptance flag here) and the gate green. `--out` is usually the build's own directory and must not already hold a `skeleton.model.json`; a page or region line the document has no field for is refused by name ([AUTHORING §0.4](docs/AUTHORING.md)) |
601
602
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
602
603
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
603
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid |
604
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles |
604
605
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
605
606
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
606
607
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -245,6 +245,7 @@ What the flags mean:
245
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
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 |
247
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** |
248
+ | `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
248
249
  | `--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 |
249
250
  | `--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` |
250
251
  | `--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 |
@@ -1108,6 +1109,7 @@ bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
1108
1109
  bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
1109
1110
  bun cli.ts render --candidate path/to/spine --slot <name,…> | --hide <name,…> # part of the rig, same grid
1110
1111
  bun cli.ts render --candidate path/to/spine --geometry # + geometry.json per set: the numbers the frames were drawn from
1112
+ bun cli.ts render --candidate path/to/spine --folds # + folds.json per set: which mesh triangles turn over, and when
1111
1113
  bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
1112
1114
  bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
1113
1115
  bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
@@ -7285,6 +7287,69 @@ still-in-frame residual over every region of `spineboy-pro` is 4.7e-12 world uni
7285
7287
  Topology and UVs sit in `rest` rather than on every frame because they do not
7286
7288
  change: repeating them per frame would add 35 % to `spineboy-pro`'s files.
7287
7289
 
7290
+ ### 8.3 Which triangles fold — `render --folds`
7291
+
7292
+ A mesh can **fold** — a triangle turning over under a bend — on a build that passes
7293
+ every assertion and every check. The art still draws, mirrored, over its neighbour,
7294
+ and nothing in a report says so. `--folds` reads it off the frames `render` draws:
7295
+
7296
+ ```bash
7297
+ bun cli.ts render --candidate path/to/spine --folds [--animation …] [--skin …] [--fps 12]
7298
+ # rigc render
7299
+ # .. show 321 frame(s), 16.000s + contact.png + folds.json (2 of 11 mesh(es) fold) -> …/show@20fps
7300
+ # .. folds "show": mesh "hair_long" (slot "hair_long") folds on 88 frame(s) [33-54, 113-134, 194-215, 273-294] at 20 fps; worst frame 40 (t=2.0000s): 3 of 459 triangle(s) [137, 139, 140]; 3 triangle(s) fold on some frame [137, 139, 140]
7301
+ ```
7302
+
7303
+ **What folded means.** A mesh triangle is folded at a frame when its orientation
7304
+ there — the sign of its signed area in world space — is the opposite of its
7305
+ orientation at the setup pose. The setup pose is `rest`'s (§8.2): the setup pose's
7306
+ bones with no deform, so an attachment a slot only swaps to later is read too.
7307
+ Regions are not read: a region is one quad and cannot fold. A mesh mirrored whole by
7308
+ a negative scale reads every triangle as folded — that is what the definition says,
7309
+ and the count tells the two apart.
7310
+
7311
+ **What is never counted.** A triangle already wrong at the setup pose is said once,
7312
+ on a `setup` line the first time a set shows its mesh, and never counted:
7313
+
7314
+ - **degenerate** — its height over its longest edge is at most 2⁻³⁰ of the largest
7315
+ coordinate its attachment holds at that pose, in world space or in its UVs. That
7316
+ is eight million ulps above the rounding a chain of bone transforms leaves, and a
7317
+ hundred-thousandth of a pixel on a rig 10⁴ units across drawn at one pixel per
7318
+ unit — thinner than the posed numbers resolve, and thinner than anything drawn.
7319
+ A triangle degenerate *at a frame* is not counted at that frame either: the sign of
7320
+ a zero area is the rounding's;
7321
+ - **inverted** — its texture is drawn mirrored against the rest of its mesh: the sign
7322
+ of world area × UV area is the opposite of the mesh's own sense, that product
7323
+ summed over its triangles, weighted by world area. Read through the UVs and not the
7324
+ index order, because the format fixes no winding — a mesh whose triangles are
7325
+ listed in mixed orders draws correctly, and a winding test would call half of it
7326
+ folded.
7327
+
7328
+ **The file.** `folds.json`, `rigc-folds/1`, beside each set's frames: `animation`,
7329
+ `skin` (absent when none was set), `fps`, `reading` (these definitions, stated in
7330
+ the file), and `meshes[]` — every mesh any frame of the set shows, in `rest`'s
7331
+ order — each with `slot`, `attachment`, `triangleCount`, `setup` (`degenerate` and
7332
+ `inverted`, triangle indices), `frames[]` (only the frames with a folded triangle:
7333
+ `index`, `time`, `triangles`), `worst` (the frame with the most, the earliest on a
7334
+ tie; `null` when it never folds) and `folded` (every triangle folded on some frame).
7335
+ A triangle is named by its index `i` in `rest`'s `triangles`: vertices
7336
+ `triangles[3i]`, `triangles[3i+1]`, `triangles[3i+2]`. A mesh that never folds is in
7337
+ the file and on no printed line.
7338
+
7339
+ **Nothing else moves.** The PNGs, `contact.png`, `frames.json` and, with
7340
+ `--geometry` beside it, `geometry.json` are byte for byte what the render writes
7341
+ without the flag. The reading is its own file rather than a field of `geometry.json`
7342
+ because that file is the pose, read by consumers that have it on disk already; a
7343
+ reading derived from it is computed from it — `foldsOf` over `geometry.json`'s
7344
+ `rest` and `frames` is `folds.json` exactly (`FO04`). ⛔ **Not with
7345
+ `--slot`/`--hide`**, for `--geometry`'s reason: the reading is of every mesh the pose
7346
+ shows.
7347
+
7348
+ **A reading, not a gate.** Whether a fold is acceptable is yours: a tassel that swings
7349
+ past 90° folds honestly. To look at one, draw the worst frame:
7350
+ `inspect --show art,folds,mesh --animation <name> --at <worst time>` fills the folded
7351
+ triangles at that pose and lists them in its legend (§13).
7352
+
7288
7353
  ---
7289
7354
 
7290
7355
  ## 9. Checking against the frames — `rigc check`
@@ -10111,6 +10176,7 @@ pixel (`IN09`).
10111
10176
  | 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 |
10112
10177
  | Does this part's edge follow that bone? | `--show art,weights:<bone>,mesh --overlay-slot <part> --zoom slot:<part>` | the part's own triangles and the bone's weight over them alone, over the whole painting. Where slots stack — a neck under the face, the hair, a collar — every mesh crossing the box is drawn and the heat is the union of every slot the bone moves, so the part's edge has no pixel of its own until the others are left out. The legend's `meshes[]` gives the part's weight for the bone at every vertex, so "does the top row follow the head" is read as numbers as well as colour |
10113
10178
  | Is this rig's density where its motion is? | `--show art,density --dim` | each region and mesh filled by its **ratio** — its triangles per world unit² over the figure's — on one ramp, log-scaled and centred on 1×: grey is as dense as the figure, pink to magenta denser (4×, 16×), light to dark green sparser (1/4×, 1/16×), clamped past the end stops (the stops are in the legend). A part's area is its posed mesh's own, the sum of its triangles' areas at the pose drawn, not the image's box; a region is two triangles over its quad. Where parts overlap the pixel is the part drawn last, as in the art, so a part under others is read with `--overlay-slot <part>`. The figure's totals are always over every slot the pose shows — the filter picks what is drawn and listed, never what the figure is — and the legend's `density` gives each slot's attachment, kind, vertices, triangles, hull, area, triangles per area, ratio and the bones it is weighted to, in draw order. A measurement, not a verdict: no bar is drawn, and `A13_MESH_BUDGET` stays the one gate on counts |
10179
+ | Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --dim --animation <name> --at <the worst frame's time>` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles. The view fills those triangles at the pose drawn — every pixel whose centre a folded triangle holds — in the colour the legend's `folds` states, and the legend's `folds[]` names, per mesh, the folded triangles by index and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across: `--viewport` around the vertices the legend's `meshes[]` gives is how it becomes visible. A reading, not a verdict |
10114
10180
  | 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 |
10115
10181
  | 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 |
10116
10182
  | 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 |
@@ -10167,7 +10233,7 @@ pixel (`IN09`).
10167
10233
  legend, and a left-out one says which flag left it out in `undrawn`. A pattern
10168
10234
  that matches no bone is refused naming the bones there are.
10169
10235
  - **`--overlay-slot <glob[,glob…]>`** draws the per-slot views — `mesh`, `hull`,
10170
- `weights:<bone>`, `density` and `slots` — only for the slots a pattern matches, and
10236
+ `weights:<bone>`, `density`, `folds` and `slots` — only for the slots a pattern matches, and
10171
10237
  **`--hide-overlay-slot <glob[,glob…]>`** for none of the ones it matches, applied
10172
10238
  after `--overlay-slot`; the patterns are globs, as `--bone`'s are. The **art is not
10173
10239
  filtered**: the painting is what the edge is read against, and `--dim` already
@@ -10207,7 +10273,11 @@ pixel (`IN09`).
10207
10273
  `ratio`, the `rgba` it is painted, `bones` (how many bones it is weighted to —
10208
10274
  a region's or an unweighted mesh's is its slot's one) and `boneNames`. A part
10209
10275
  whose posed area is 0 has `trianglesPerArea` and `ratio` `null` and says why in
10210
- `why`; it is not painted. With `mesh`, `hull`, `weights` or `density`,
10276
+ `why`; it is not painted. With `folds`, `folds[]`: every kept mesh with a
10277
+ triangle folded at this tile's pose or a setup fault, in draw order — `slot`,
10278
+ `attachment`, `triangleCount`, `folded` (the triangles filled, by index),
10279
+ `degenerate` and `inverted` (the setup pose's, never counted); empty when none.
10280
+ With `mesh`, `hull`, `weights`, `density` or `folds`,
10211
10281
  `meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
10212
10282
  `vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
10213
10283
  triangle `edges` as vertex pairs, every posed vertex in `world` and `pixel`, and
@@ -10224,6 +10294,11 @@ pixel (`IN09`).
10224
10294
  - `density` — with `density`: the ramp's stops (`ratio`, RGBA), the `scale` it is
10225
10295
  read on (log₂ of the ratio, clamped at the end stops), and in words what `area`,
10226
10296
  the figure's `totals` and the `ratio` are and how a pixel is painted.
10297
+ - `folds` — with `folds`: the colour a folded triangle is filled with, as RGBA, and
10298
+ what it means; `degenerateShare` (2⁻³⁰, §8.3); and in words what `folded`,
10299
+ `degenerate` and `inverted` are and how a triangle is named by `index`. Its colour
10300
+ is stated here rather than in `colours`, which every legend lists whole, so a legend
10301
+ drawn without `folds` is the bytes it was before the view existed.
10227
10302
  - `boneFilter` — `--bone` and `--hide-bone` as given.
10228
10303
  - `slotFilter` — `--overlay-slot` and `--hide-overlay-slot` as given; with it,
10229
10304
  `meshes[]` and `slots[]` list only the slots it keeps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.45.2",
3
+ "version": "2.46.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": {
@@ -31,6 +31,7 @@ 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
33
  import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
34
+ import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
34
35
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
35
36
  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';
36
37
  import { type CompileResult } from '../types.ts';
@@ -488,6 +489,10 @@ export function cmdRender(flags: Record<string, string>): void {
488
489
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
489
490
  const outRoot = resolve(flags.out ?? 'render');
490
491
  const geometry = flags.geometry !== undefined;
492
+ // The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
493
+ // neither, or for --geometry alone, gets the bytes it always got: geometry.json is the pose, and a reading derived
494
+ // from it is a second file beside it, written only when asked for.
495
+ const folds = flags.folds !== undefined;
491
496
  // Refused together rather than one of them ignored (issue #864). The export
492
497
  // records every slot's whole geometry, because a subset is a statement about
493
498
  // which pixels are DRAWN and the pose is the same whatever a picture leaves
@@ -505,6 +510,18 @@ export function cmdRender(flags: Record<string, string>): void {
505
510
  }
506
511
  }
507
512
  }
513
+ // The same refusal for the same reason: the reading is of every mesh the pose shows, whatever a picture leaves out.
514
+ if (folds) {
515
+ for (const flag of ['slot', 'hide'] as const) {
516
+ if (flags[flag] !== undefined) {
517
+ throw new UsageError(
518
+ `--folds takes no --${flag}: it reads every mesh the pose shows, which a subset of the drawn slots does not ` +
519
+ `change. Run \`rigc render --folds\` on the whole rig for the reading, and --${flag} ${flags[flag]} ` +
520
+ 'without --folds for the pictures — both land on the same grid',
521
+ );
522
+ }
523
+ }
524
+ }
508
525
 
509
526
  console.log('rigc render');
510
527
  console.log(` .. skeleton ${skeletonPath}`);
@@ -569,7 +586,7 @@ export function cmdRender(flags: Record<string, string>): void {
569
586
  // `--geometry` rides on the SAME call (issue #864): the bones and the whole
570
587
  // attachments are read off the skeleton at the step that drew each frame, so
571
588
  // the export's grid is this frame set's by construction.
572
- const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
589
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : folds ? { ...pose, geometry: true } : pose;
573
590
  const sampled: Map<string, Frame[]> =
574
591
  only === undefined
575
592
  ? sampleAll(poser, fps, sampling)
@@ -583,15 +600,28 @@ export function cmdRender(flags: Record<string, string>): void {
583
600
  geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
584
601
  }
585
602
  }
586
- return { viewport, sampled, geometryTexts };
603
+ // The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
604
+ // export writes — one per set, every mesh any of its frames shows.
605
+ const foldReadings = new Map<string, ReturnType<typeof foldsFileOf>>();
606
+ if (folds) {
607
+ for (const [name, frames] of sampled) {
608
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
609
+ const shown = frames.map((frame) => frame.attachments ?? []);
610
+ const read = frames.map((frame) => ({ index: frame.index, time: frame.time, attachments: frame.attachments ?? [] }));
611
+ foldReadings.set(name, foldsFileOf(animation, fps, skin, foldsOf(poser.rest(skin, shown), read)));
612
+ }
613
+ }
614
+ return { viewport, sampled, geometryTexts, foldReadings };
587
615
  });
588
- const { viewport, sampled, geometryTexts } = posed.value;
616
+ const { viewport, sampled, geometryTexts, foldReadings } = posed.value;
589
617
  console.log(` .. poser ${posed.note}`);
590
618
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
591
619
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
592
620
 
593
621
  mkdirSync(outRoot, { recursive: true });
594
622
  const sets: FrameSet[] = [];
623
+ // A mesh's setup faults are a property of the mesh, not of an animation: said once, the first time a set shows it.
624
+ const faultsSaid = new Set<string>();
595
625
  for (const [name, frames] of sampled) {
596
626
  // Same naming as a reference render: the protocol rate says nothing, any
597
627
  // other rate says itself, so two rates of one animation sit side by side.
@@ -611,6 +641,8 @@ export function cmdRender(flags: Record<string, string>): void {
611
641
  if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
612
642
  const geometryOut = geometryTexts.get(name);
613
643
  if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
644
+ const foldReading = foldReadings.get(name);
645
+ if (foldReading !== undefined) writeFileSync(join(dir, FOLDS_FILE), foldsText(foldReading));
614
646
  const duration = frames[frames.length - 1].time;
615
647
  sets.push({
616
648
  dir: dirName,
@@ -622,8 +654,18 @@ export function cmdRender(flags: Record<string, string>): void {
622
654
  duration,
623
655
  });
624
656
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
625
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
657
+ const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
658
+ const foldNote = foldReading === undefined ? '' : ` + ${FOLDS_FILE} (${folding} of ${foldReading.meshes.length} mesh(es) fold)`;
659
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}`;
626
660
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
661
+ for (const mesh of foldReading?.meshes ?? []) {
662
+ const key = `${mesh.slot}\u0000${mesh.attachment}`;
663
+ const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
664
+ faultsSaid.add(key);
665
+ if (fault !== null) console.log(` .. setup ${fault}`);
666
+ const line = foldLine(foldReading?.animation ?? null, fps, mesh);
667
+ if (line !== null) console.log(` .. folds ${line}`);
668
+ }
627
669
  }
628
670
 
629
671
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
package/src/cli/shared.ts CHANGED
@@ -36,6 +36,7 @@ import { ANCHOR_MAX_RESIDUAL, ANCHOR_MAX_UNEXPLAINED, DEFAULT_HINGE_MAX, DEFAULT
36
36
  import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport } from '../check.ts';
37
37
  import { compile, CompileError, droppedStateReason, headerBoundsOf, type CompileOptions } from '../compile.ts';
38
38
  import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
39
+ import { FOLDS_FILE } from '../folds.ts';
39
40
  import { parseJsonWithPosition } from '../json-position.ts';
40
41
  import { RUNG_IDS } from '../ladder.ts';
41
42
  import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
@@ -126,7 +127,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
126
127
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
127
128
  * halves together by reading `--help` rather than by naming a flag.
128
129
  */
129
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences', 'keep-pages', 'dim']);
130
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'accept-skeleton-differences', 'keep-pages', 'dim']);
130
131
 
131
132
  /**
132
133
  * The flags a command is allowed to spell more than once.
@@ -996,6 +997,11 @@ const FLAG_MEANINGS: Record<string, string> = {
996
997
  `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
997
998
  "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
998
999
  'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
1000
+ folds:
1001
+ `also read which mesh triangles fold — turn over against the setup pose — and write ${FOLDS_FILE} into each frame ` +
1002
+ 'directory: per mesh, the frames with a folded triangle, the worst frame\'s count and which triangles, by index. ' +
1003
+ 'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
1004
+ 'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
999
1005
  poser:
1000
1006
  "`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
1001
1007
  'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
@@ -1067,7 +1073,8 @@ const FLAG_MEANINGS: Record<string, string> = {
1067
1073
  "build's own frame; grid = lines at whole multiples of a step in world units, labelled; density = each region and mesh " +
1068
1074
  "filled by its triangles per world unit² over the figure's, on a log ramp centred on 1x that the legend states, with the " +
1069
1075
  "figure's totals over every slot and each slot's row in the legend; weights:<bone> = that bone's " +
1070
- 'weight over every region and mesh it moves, as a heat on a ramp the legend states; mesh = each mesh\'s triangle edges; ' +
1076
+ 'weight over every region and mesh it moves, as a heat on a ramp the legend states; folds = each mesh triangle turned ' +
1077
+ "over against the setup pose at the pose drawn, filled, and named by index in the legend (render --folds reads the same); mesh = each mesh's triangle edges; " +
1071
1078
  "hull = each mesh's hull outline; declared = each invariants.axes " +
1072
1079
  "line through its two bones' origins, its held bones drawn in their own colour; bones = each bone from origin to tip, " +
1073
1080
  '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 ' +
@@ -1577,10 +1584,10 @@ export const COMMANDS: CommandDoc[] = [
1577
1584
  runtime: false,
1578
1585
  spineFormat: false,
1579
1586
  usage: [
1580
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
1587
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
1581
1588
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1582
1589
  ],
1583
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
1590
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
1584
1591
  overrides: {
1585
1592
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1586
1593
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -1595,7 +1602,7 @@ export const COMMANDS: CommandDoc[] = [
1595
1602
  'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
1596
1603
  'rigc inspect … --zoom <bone|slot>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
1597
1604
  "rigc inspect … --bone 'bang_*' --hide-bone '*_ctl' (which bones bones, axes and names draw)",
1598
- 'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights and slots draw; the art stays whole)',
1605
+ 'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights, density, folds and slots draw; the art stays whole)',
1599
1606
  'rigc inspect … --compare <dir | skeleton.json> (a second build beside the first, on one transform)',
1600
1607
  ],
1601
1608
  flags: ['candidate', 'atlas', 'show', 'dim', 'animation', 'at', 'fps', 'skin', 'zoom', 'viewport', 'bone', 'hide-bone', 'overlay-slot', 'hide-overlay-slot', 'compare', 'max', 'poser', 'out'],
package/src/folds.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Which triangles of a posed mesh are turned over — the fold reading behind
3
+ * `rigc render --folds` and `rigc inspect --show folds` (issue #1383).
4
+ *
5
+ * ⭐ **A reading, not a gate.** A mesh can fold — a triangle flipping over under
6
+ * a bend — on a build that passes every assertion and every check, and until
7
+ * this it was found by a person looking at the right frame. Whether a fold is
8
+ * acceptable is the consumer's: a tassel that swings past 90° folds honestly.
9
+ * So this names the triangles and the frames and decides nothing.
10
+ *
11
+ * ## What "folded" means here
12
+ *
13
+ * A triangle is folded at a frame when its orientation there — the sign of its
14
+ * signed area in world space — is the opposite of its orientation at the setup
15
+ * pose. The setup pose is the rest table's (`AttachmentRest.vertices`: the setup
16
+ * pose's bones with no deform, the table `render --geometry` writes as `rest`),
17
+ * so an attachment a slot only swaps to later has a reference too.
18
+ *
19
+ * A triangle that is already wrong at the setup pose is reported once as that
20
+ * and never counted as folding, because "opposite to the setup pose" says
21
+ * nothing about it:
22
+ *
23
+ * - **degenerate** — thinner than the posed numbers resolve (`DEGENERATE_SHARE`
24
+ * below), in world space or in its UVs, so it has no orientation to keep;
25
+ * - **inverted** — its texture is drawn mirrored against the rest of its mesh:
26
+ * the sign of world area × UV area is the opposite of the mesh's own sense,
27
+ * which is the sign of that product summed over its triangles, weighted by
28
+ * world area. Read through the UVs rather than the index order because the
29
+ * format does not fix a winding: a mesh whose triangles are listed in mixed
30
+ * orders is drawn correctly, and a winding test would call half of it folded.
31
+ *
32
+ * A triangle that is degenerate at a frame — collapsed to a line by the pose —
33
+ * is not counted at that frame either: the sign of a zero area is the rounding's,
34
+ * not the pose's.
35
+ *
36
+ * A mesh mirrored whole by a negative scale reads every triangle as folded: that
37
+ * is what "opposite to the setup pose" is, and the count says which it was.
38
+ *
39
+ * Pure: no clock, no randomness, no runtime. The posed vertices are handed in by
40
+ * whichever poser drew the frames (rigc's core for a rigc build).
41
+ */
42
+
43
+ /** The fold file's name inside an animation's frame directory, and its format tag. */
44
+ export const FOLDS_FILE = 'folds.json';
45
+ export const FOLDS_SPEC = 'rigc-folds/1';
46
+
47
+ /**
48
+ * A triangle is degenerate when its height over its longest edge is at most
49
+ * this share of the largest coordinate magnitude its attachment holds at that
50
+ * pose (world units, or UV units for the UV triangle).
51
+ *
52
+ * 2⁻³⁰ ≈ 9.3e-10, chosen between two bounds that are both stated rather than
53
+ * tuned:
54
+ *
55
+ * - **above the rounding**: a posed coordinate is a chain of bone transforms in
56
+ * doubles, each step rounding by half an ulp (2⁻⁵³) of numbers no larger than
57
+ * a few times the coordinate. 2⁻³⁰ is 2²³ ulps — eight million steps of
58
+ * headroom — so a sign this flags is never one a different rounding could
59
+ * have given the other way;
60
+ * - **below anything drawn**: on a rig 10⁴ world units across it is 1e-5 world
61
+ * units, a hundred-thousandth of a pixel at one pixel per unit, so no triangle
62
+ * a picture can show is called degenerate.
63
+ *
64
+ * Relative to the attachment's own magnitude because the rounding is: a mesh at
65
+ * 3,200 units from its origin carries 3,200 times the absolute noise of one at 1.
66
+ */
67
+ export const DEGENERATE_SHARE = 2 ** -30;
68
+
69
+ /** The rest geometry the reading needs: the triangles, the UVs and the setup pose's vertices. */
70
+ export interface FoldRest {
71
+ slot: string;
72
+ attachment: string;
73
+ kind: 'region' | 'mesh';
74
+ vertices: readonly number[];
75
+ triangles: readonly number[];
76
+ uvs?: readonly number[];
77
+ }
78
+
79
+ /** One posed attachment: its slot, its name, its world vertices `x, y` per vertex. */
80
+ export interface FoldPose {
81
+ slot: string;
82
+ attachment: string;
83
+ vertices: readonly number[];
84
+ }
85
+
86
+ /** One sampled frame: its index on the grid, its time and every attachment it shows. */
87
+ export interface FoldFrame {
88
+ index: number;
89
+ time: number;
90
+ attachments: readonly FoldPose[];
91
+ }
92
+
93
+ /** A mesh's triangles that are wrong at the setup pose — reported once, never counted. */
94
+ export interface SetupFaults {
95
+ /** Thinner than the posed numbers resolve, in world space or in the UVs — by triangle index. */
96
+ degenerate: number[];
97
+ /** Drawn mirrored against the rest of its mesh at the setup pose — by triangle index. */
98
+ inverted: number[];
99
+ }
100
+
101
+ /** One frame on which a mesh has a folded triangle. */
102
+ export interface FoldedFrame {
103
+ index: number;
104
+ time: number;
105
+ /** The folded triangles, by index (`triangles[3i..3i+2]` of the rest table), ascending. */
106
+ triangles: number[];
107
+ }
108
+
109
+ /** One mesh's reading over one frame set. */
110
+ export interface MeshFolds {
111
+ slot: string;
112
+ attachment: string;
113
+ /** How many triangles the mesh has. */
114
+ triangleCount: number;
115
+ setup: SetupFaults;
116
+ /** Every frame with a folded triangle, in frame order; empty when the mesh never folds. */
117
+ frames: FoldedFrame[];
118
+ /** The frame with the most folded triangles — the earliest, on a tie — or `null` when it never folds. */
119
+ worst: FoldedFrame | null;
120
+ /** Every triangle folded on some frame, ascending. */
121
+ folded: number[];
122
+ }
123
+
124
+ /** `folds.json`: one frame set's reading, every mesh any frame shows. */
125
+ export interface FoldsFile {
126
+ spec: typeof FOLDS_SPEC;
127
+ /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
128
+ animation: string | null;
129
+ skin?: string;
130
+ fps: number;
131
+ /** What the numbers mean, stated in the file so a reader does not need this module. */
132
+ reading: { folded: string; degenerate: string; inverted: string; index: string };
133
+ /** Every mesh any frame of the set shows, in the order the rest table lists them. */
134
+ meshes: MeshFolds[];
135
+ }
136
+
137
+ /**
138
+ * A planted defect, for the suite's mutants only (`FO` controls in
139
+ * `selftest.ts`). Production passes none.
140
+ */
141
+ export interface FoldPlant {
142
+ /** Count a triangle whose sign AGREES with the setup pose's, rather than the one opposite it. */
143
+ signFlipped?: boolean;
144
+ /** Read each frame against the frame before it — a flip detector — rather than against the setup pose. */
145
+ againstPreviousFrame?: boolean;
146
+ /** Count the setup pose's degenerate and inverted triangles like any other. */
147
+ countSetupFaults?: boolean;
148
+ }
149
+
150
+ /** Twice the signed area of triangle `t` in `xy` (`x, y` per vertex). */
151
+ function cross2(xy: readonly number[], triangles: readonly number[], t: number): number {
152
+ const a = triangles[3 * t] * 2;
153
+ const b = triangles[3 * t + 1] * 2;
154
+ const c = triangles[3 * t + 2] * 2;
155
+ return (xy[b] - xy[a]) * (xy[c + 1] - xy[a + 1]) - (xy[c] - xy[a]) * (xy[b + 1] - xy[a + 1]);
156
+ }
157
+
158
+ /** The longest edge of triangle `t` in `xy`. */
159
+ function longestEdge(xy: readonly number[], triangles: readonly number[], t: number): number {
160
+ const ids = [triangles[3 * t] * 2, triangles[3 * t + 1] * 2, triangles[3 * t + 2] * 2];
161
+ let most = 0;
162
+ for (const [i, j] of [[0, 1], [1, 2], [2, 0]] as const) {
163
+ const dx = xy[ids[j]] - xy[ids[i]];
164
+ const dy = xy[ids[j] + 1] - xy[ids[i] + 1];
165
+ most = Math.max(most, Math.sqrt(dx * dx + dy * dy));
166
+ }
167
+ return most;
168
+ }
169
+
170
+ /** The largest coordinate magnitude in `xy` — what the rounding of every number in it is relative to. */
171
+ function magnitude(xy: readonly number[]): number {
172
+ let most = 0;
173
+ for (const v of xy) most = Math.max(most, Math.abs(v));
174
+ return most;
175
+ }
176
+
177
+ /**
178
+ * The sign of triangle `t`'s area in `xy`: +1, −1, or 0 when it is degenerate
179
+ * — its height over its longest edge at most `DEGENERATE_SHARE` of `scale`.
180
+ */
181
+ function orientation(xy: readonly number[], triangles: readonly number[], t: number, scale: number): -1 | 0 | 1 {
182
+ const twice = cross2(xy, triangles, t);
183
+ const edge = longestEdge(xy, triangles, t);
184
+ if (edge === 0 || Math.abs(twice) / edge <= DEGENERATE_SHARE * scale) return 0;
185
+ return twice > 0 ? 1 : -1;
186
+ }
187
+
188
+ /** A mesh's setup reading: each triangle's world sign at the setup pose, and the triangles reported as setup faults. */
189
+ export interface SetupReading {
190
+ /** Per triangle: +1 or −1, its world sign at the setup pose; 0 for a setup fault (never counted). */
191
+ signs: Int8Array;
192
+ faults: SetupFaults;
193
+ }
194
+
195
+ /** The setup reading of one mesh — see the module comment for what degenerate and inverted mean. */
196
+ export function setupReading(rest: FoldRest, plant: FoldPlant = {}): SetupReading {
197
+ const count = Math.floor(rest.triangles.length / 3);
198
+ const signs = new Int8Array(count);
199
+ const world: number[] = [];
200
+ const uvSigns: number[] = [];
201
+ const worldScale = magnitude(rest.vertices);
202
+ const uvs = rest.uvs ?? [];
203
+ const uvScale = magnitude(uvs);
204
+ let sense = 0;
205
+ const degenerate: number[] = [];
206
+ for (let t = 0; t < count; t++) {
207
+ const w = orientation(rest.vertices, rest.triangles, t, worldScale);
208
+ const u = uvs.length === rest.vertices.length ? orientation(uvs, rest.triangles, t, uvScale) : 1;
209
+ world.push(w);
210
+ uvSigns.push(u);
211
+ if (w === 0 || u === 0) {
212
+ degenerate.push(t);
213
+ continue;
214
+ }
215
+ sense += w * u * Math.abs(cross2(rest.vertices, rest.triangles, t));
216
+ }
217
+ const inverted: number[] = [];
218
+ for (let t = 0; t < count; t++) {
219
+ if (world[t] === 0 || uvSigns[t] === 0) continue;
220
+ // A mesh whose two senses weigh exactly the same has no rest to be inverted against: none is flagged.
221
+ if (sense !== 0 && Math.sign(world[t] * uvSigns[t]) !== Math.sign(sense)) {
222
+ inverted.push(t);
223
+ continue;
224
+ }
225
+ signs[t] = world[t];
226
+ }
227
+ if (plant.countSetupFaults) {
228
+ for (let t = 0; t < count; t++) signs[t] = world[t] === 0 ? 1 : world[t];
229
+ }
230
+ return { signs, faults: { degenerate, inverted } };
231
+ }
232
+
233
+ /** The triangles of one mesh folded at one pose, ascending — `vertices` posed, `setup` its setup reading. */
234
+ export function foldedAt(rest: FoldRest, setup: SetupReading, vertices: readonly number[], plant: FoldPlant = {}): number[] {
235
+ if (vertices.length !== rest.vertices.length) {
236
+ throw new Error(
237
+ `slot "${rest.slot}" mesh "${rest.attachment}": the rest table states ${rest.vertices.length / 2} vertices, the pose ${vertices.length / 2} — the poser disagrees with itself`,
238
+ );
239
+ }
240
+ const scale = magnitude(vertices);
241
+ const out: number[] = [];
242
+ for (let t = 0; t < setup.signs.length; t++) {
243
+ const before = setup.signs[t];
244
+ if (before === 0) continue;
245
+ const now = orientation(vertices, rest.triangles, t, scale);
246
+ if (now === 0) continue;
247
+ if (plant.signFlipped ? now === before : now === -before) out.push(t);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
253
+
254
+ /**
255
+ * Every mesh's reading over one frame set: `rest` the rest table (regions are
256
+ * skipped — a region is a quad and cannot fold), `frames` the frames as the
257
+ * sampler returned them. A mesh any frame shows is read; one no frame shows is
258
+ * not listed. Order is the rest table's, so one build reads in one order.
259
+ */
260
+ export function foldsOf(rest: readonly FoldRest[], frames: readonly FoldFrame[], plant: FoldPlant = {}): MeshFolds[] {
261
+ const shown = new Set<string>();
262
+ for (const frame of frames) for (const a of frame.attachments) shown.add(restKey(a.slot, a.attachment));
263
+ const out: MeshFolds[] = [];
264
+ for (const r of rest) {
265
+ if (r.kind !== 'mesh' || !shown.has(restKey(r.slot, r.attachment))) continue;
266
+ let setup = setupReading(r, plant);
267
+ const folded: FoldedFrame[] = [];
268
+ const union = new Set<number>();
269
+ for (const frame of frames) {
270
+ const posed = frame.attachments.find((a) => a.slot === r.slot && a.attachment === r.attachment);
271
+ if (posed === undefined) continue;
272
+ const triangles = foldedAt(r, setup, posed.vertices, plant);
273
+ if (plant.againstPreviousFrame) {
274
+ const signs = Int8Array.from(setup.signs, (sign, t) => (triangles.includes(t) ? -sign : sign));
275
+ setup = { ...setup, signs };
276
+ }
277
+ if (triangles.length === 0) continue;
278
+ for (const t of triangles) union.add(t);
279
+ folded.push({ index: frame.index, time: frame.time, triangles });
280
+ }
281
+ let worst: FoldedFrame | null = null;
282
+ for (const f of folded) if (worst === null || f.triangles.length > worst.triangles.length) worst = f;
283
+ out.push({
284
+ slot: r.slot,
285
+ attachment: r.attachment,
286
+ triangleCount: setup.signs.length,
287
+ setup: setup.faults,
288
+ frames: folded,
289
+ worst,
290
+ folded: [...union].sort((a, b) => a - b),
291
+ });
292
+ }
293
+ return out;
294
+ }
295
+
296
+ /** What `folds.json` states its numbers mean — the one wording the file and the docs share. */
297
+ export const FOLDS_READING: FoldsFile['reading'] = {
298
+ folded:
299
+ "a triangle whose world orientation (the sign of its signed area) at the frame is the opposite of its orientation at the setup pose — the rest table's vertices, the setup pose's bones with no deform. A triangle degenerate at the frame is not counted there",
300
+ degenerate: `a triangle whose height over its longest edge is at most ${DEGENERATE_SHARE} of the largest coordinate magnitude its attachment holds at that pose, in world space or in its UVs — thinner than the posed numbers resolve`,
301
+ inverted:
302
+ "a triangle drawn mirrored against the rest of its mesh at the setup pose: the sign of world area x UV area is the opposite of the mesh's own sense (that product summed over its triangles, weighted by world area). Setup faults are listed once and never counted as folds",
303
+ index: 'a triangle is named by its index i in the rest table: vertices triangles[3i], triangles[3i+1], triangles[3i+2]',
304
+ };
305
+
306
+ /** The file for one frame set. */
307
+ export function foldsFileOf(animation: string | null, fps: number, skin: string | undefined, meshes: MeshFolds[]): FoldsFile {
308
+ return { spec: FOLDS_SPEC, animation, ...(skin === undefined ? {} : { skin }), fps, reading: FOLDS_READING, meshes };
309
+ }
310
+
311
+ /** The file's text: two-space JSON and a newline, so one build and one request are one byte sequence. */
312
+ export function foldsText(file: FoldsFile): string {
313
+ return `${JSON.stringify(file, null, 2)}\n`;
314
+ }
315
+
316
+ /** Ascending indices as runs: `[3, 4, 5, 9]` → `3-5, 9`. */
317
+ export function indexRuns(indices: readonly number[]): string {
318
+ const runs: string[] = [];
319
+ for (let i = 0; i < indices.length; ) {
320
+ let j = i;
321
+ while (j + 1 < indices.length && indices[j + 1] === indices[j] + 1) j++;
322
+ runs.push(j === i ? String(indices[i]) : `${indices[i]}-${indices[j]}`);
323
+ i = j + 1;
324
+ }
325
+ return runs.join(', ');
326
+ }
327
+
328
+ /**
329
+ * The printed line for a mesh that folds, or `null` for one that never does.
330
+ * It names the animation, the mesh and its slot, the frames, the worst frame's
331
+ * count and its triangles, and every triangle folded on any frame.
332
+ */
333
+ export function foldLine(animation: string | null, fps: number, mesh: MeshFolds): string | null {
334
+ const worst = mesh.worst;
335
+ if (worst === null) return null;
336
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
337
+ const frames = mesh.frames.map((f) => f.index);
338
+ return (
339
+ `${where}: mesh "${mesh.attachment}" (slot "${mesh.slot}") folds on ${frames.length} frame(s) [${indexRuns(frames)}] at ${fps} fps; ` +
340
+ `worst frame ${worst.index} (t=${worst.time.toFixed(4)}s): ${worst.triangles.length} of ${mesh.triangleCount} triangle(s) [${worst.triangles.join(', ')}]; ` +
341
+ `${mesh.folded.length} triangle(s) fold on some frame [${mesh.folded.join(', ')}]`
342
+ );
343
+ }
344
+
345
+ /** The printed line for a mesh with setup faults, or `null` for one with none — printed once per run, not per animation. */
346
+ export function setupFaultLine(mesh: Pick<MeshFolds, 'slot' | 'attachment' | 'setup'>): string | null {
347
+ const { degenerate, inverted } = mesh.setup;
348
+ if (degenerate.length === 0 && inverted.length === 0) return null;
349
+ const parts = [
350
+ ...(degenerate.length === 0 ? [] : [`${degenerate.length} degenerate [${degenerate.join(', ')}]`]),
351
+ ...(inverted.length === 0 ? [] : [`${inverted.length} inverted against the rest of the mesh [${inverted.join(', ')}]`]),
352
+ ];
353
+ return `mesh "${mesh.attachment}" (slot "${mesh.slot}") at the setup pose: triangle(s) ${parts.join('; ')} — reported here once and never counted as folding`;
354
+ }
package/src/inspect.ts CHANGED
@@ -42,6 +42,11 @@
42
42
  * budget: `A13_MESH_BUDGET` is the one gate on counts, and this only says where
43
43
  * the triangles are.
44
44
  *
45
+ * ⭐ **A fold is read, never judged** (issue #1383). `folds` fills each mesh
46
+ * triangle that is turned over at the pose drawn — its world orientation the
47
+ * opposite of the setup pose's — by `./folds.ts`, the reading `render --folds`
48
+ * prints, so the picture and that line name the same triangles.
49
+ *
45
50
  * Links nothing from the runtime: the poser is handed in.
46
51
  */
47
52
  import { createHash } from 'node:crypto';
@@ -71,12 +76,13 @@ import {
71
76
  viewportFor,
72
77
  } from './render_shared.ts';
73
78
  import { hypot2 } from './numeric.ts';
79
+ import { DEGENERATE_SHARE, foldedAt, FOLDS_READING, setupReading } from './folds.ts';
74
80
 
75
81
  /** The legend's format tag. */
76
82
  export const INSPECT_SPEC = 'rigc-inspect/1';
77
83
 
78
84
  /** Every word `--show` takes, in the order the picture draws them (bottom first). `weights` is spelled `weights:<bone>`. */
79
- export const SHOW_WORDS = ['art', 'grid', 'density', 'weights', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
85
+ export const SHOW_WORDS = ['art', 'grid', 'density', 'weights', 'folds', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
80
86
  export type ShowWord = (typeof SHOW_WORDS)[number];
81
87
 
82
88
  /** The words as a refusal lists them — `weights` with the bone it needs. */
@@ -183,6 +189,19 @@ export const INSPECT_COLOURS = {
183
189
  rule: { rgba: [...SHEET_RULE], meaning: 'the gap between two tiles' },
184
190
  } as const satisfies Record<string, { rgba: readonly number[]; meaning: string }>;
185
191
 
192
+ /**
193
+ * The `folds` view's colour (issue #1383). Kept out of `INSPECT_COLOURS`, which
194
+ * every legend lists whole: a colour added there would move the bytes of every
195
+ * legend written without `folds`. It is stated in the legend's `folds` block
196
+ * instead, only when the view is drawn — as the weights and density ramps are.
197
+ * Pure red at full alpha: neither ramp reaches it (the weights ramp's red end is
198
+ * `[215, 20, 20]`), and the mesh edges drawn over a fill stay readable.
199
+ */
200
+ export const FOLD_COLOUR = {
201
+ rgba: [255, 0, 0, 255],
202
+ meaning: 'a mesh triangle folded over at this pose — its world orientation the opposite of the setup pose\'s: every pixel whose centre it holds',
203
+ } as const;
204
+
186
205
  type ColourName = keyof typeof INSPECT_COLOURS;
187
206
  const colour = (name: ColourName): RGBA => [...INSPECT_COLOURS[name].rgba] as RGBA;
188
207
 
@@ -292,6 +311,8 @@ export interface InspectPlant {
292
311
  densityLinearRamp?: boolean;
293
312
  /** Density: take the figure's totals over the slots the filter keeps, not over every slot. */
294
313
  densityTotalsFiltered?: boolean;
314
+ /** Folds: fill every triangle of every kept mesh, folded or not. */
315
+ foldsFillAll?: boolean;
295
316
  }
296
317
 
297
318
  /** A point in both spaces. */
@@ -425,6 +446,20 @@ export interface LegendDensity {
425
446
  slots: LegendDensitySlot[];
426
447
  }
427
448
 
449
+ /** One kept mesh with a folded triangle, or a setup fault, at one tile's pose (issue #1383). */
450
+ export interface LegendFold {
451
+ slot: string;
452
+ attachment: string;
453
+ /** How many triangles the mesh has. */
454
+ triangleCount: number;
455
+ /** The triangles folded at this pose, by index (`triangles[3i..3i+2]` of the rest table), ascending — the ones filled. */
456
+ folded: number[];
457
+ /** Thinner than the posed numbers resolve at the setup pose, never counted — by index. */
458
+ degenerate: number[];
459
+ /** Drawn mirrored against the rest of the mesh at the setup pose, never counted — by index. */
460
+ inverted: number[];
461
+ }
462
+
428
463
  export interface LegendTile {
429
464
  /** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
430
465
  build?: 'A' | 'B';
@@ -445,6 +480,8 @@ export interface LegendTile {
445
480
  slots?: LegendSlot[];
446
481
  /** With `density`: the figure's totals and every kept slot's row. */
447
482
  density?: LegendDensity;
483
+ /** With `folds`: every kept mesh with a triangle folded at this pose or a setup fault, in draw order; empty when none. */
484
+ folds?: LegendFold[];
448
485
  declared?: LegendDeclared[];
449
486
  grid?: { step: number; x: number[]; y: number[] };
450
487
  }
@@ -470,6 +507,8 @@ export interface InspectLegend {
470
507
  weights?: { bone: string; ramp: Array<{ weight: number; rgba: number[] }>; floor: number; reading: string };
471
508
  /** With `density`: the ramp, its scale, and how area, totals and ratio are read. */
472
509
  density?: { ramp: Array<{ ratio: number; rgba: number[] }>; scale: string; area: string; totals: string; ratio: string; reading: string };
510
+ /** With `folds`: the colour a folded triangle is filled with, the degenerate share, and what folded, degenerate and inverted mean. */
511
+ folds?: { rgba: number[]; meaning: string; degenerateShare: number; folded: string; degenerate: string; inverted: string; index: string };
473
512
  /** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
474
513
  builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
475
514
  coordinates: { world: string; image: string; toPixel: string; toWorld: string };
@@ -1138,7 +1177,7 @@ interface Gathered {
1138
1177
  sha: string;
1139
1178
  }
1140
1179
 
1141
- const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density'];
1180
+ const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density', 'folds'];
1142
1181
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
1143
1182
 
1144
1183
  /** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
@@ -1357,6 +1396,16 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
1357
1396
  },
1358
1397
  }
1359
1398
  : {}),
1399
+ ...(request.show.includes('folds')
1400
+ ? {
1401
+ folds: {
1402
+ rgba: [...FOLD_COLOUR.rgba],
1403
+ meaning: FOLD_COLOUR.meaning,
1404
+ degenerateShare: DEGENERATE_SHARE,
1405
+ ...FOLDS_READING,
1406
+ },
1407
+ }
1408
+ : {}),
1360
1409
  ...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
1361
1410
  coordinates: {
1362
1411
  world: 'spine world: y up, world units',
@@ -1521,6 +1570,37 @@ function drawTile(
1521
1570
  }
1522
1571
  legendTile.meshes.push(entry);
1523
1572
  }
1573
+ // ── folds: each kept mesh's turned-over triangles filled, over the heat and under the edges (issue #1383) ──
1574
+ if (request.show.includes('folds')) {
1575
+ legendTile.folds = [];
1576
+ for (const shown of frame.attachments ?? []) {
1577
+ if (slotsOut.has(shown.slot)) continue;
1578
+ const rest = build.rest.get(restKey(shown.slot, shown.attachment));
1579
+ if (rest === undefined || rest.kind !== 'mesh') continue;
1580
+ const setup = setupReading(rest);
1581
+ const folded = foldedAt(rest, setup, shown.vertices);
1582
+ const { degenerate, inverted } = setup.faults;
1583
+ const fill = plant.foldsFillAll ? Array.from({ length: rest.triangles.length / 3 }, (_, i) => i) : folded;
1584
+ const at = (v: number): [number, number] => {
1585
+ const [px, py] = toPixel(shown.vertices[2 * v], shown.vertices[2 * v + 1]);
1586
+ return [tx(px), ty(py)];
1587
+ };
1588
+ for (const t of fill) fillTriangle(tile, [at(rest.triangles[3 * t]), at(rest.triangles[3 * t + 1]), at(rest.triangles[3 * t + 2])], [...FOLD_COLOUR.rgba] as RGBA);
1589
+ if (folded.length === 0 && degenerate.length === 0 && inverted.length === 0) continue;
1590
+ legendTile.folds.push({ slot: shown.slot, attachment: shown.attachment, triangleCount: rest.triangles.length / 3, folded, degenerate, inverted });
1591
+ }
1592
+ if (first) {
1593
+ const count = legendTile.folds.reduce((n, f) => n + f.folded.length, 0);
1594
+ const meshes = legendTile.folds.filter((f) => f.folded.length > 0);
1595
+ notes.push(
1596
+ count === 0
1597
+ ? `folds: no mesh${request.slotFilter === undefined ? '' : ' the filter keeps'} has a triangle folded over at this pose — nothing filled`
1598
+ : `folds: ${count} triangle(s) folded over across ${meshes.length} mesh(es) — ${meshes.map((f) => `"${f.slot}" [${f.folded.join(', ')}]`).join(', ')} — every one is in the legend`,
1599
+ );
1600
+ const faulted = legendTile.folds.filter((f) => f.degenerate.length > 0 || f.inverted.length > 0);
1601
+ if (faulted.length > 0) notes.push(`folds: setup faults, never counted — ${faulted.map((f) => `"${f.slot}" degenerate [${f.degenerate.join(', ')}] inverted [${f.inverted.join(', ')}]`).join(', ')}`);
1602
+ }
1603
+ }
1524
1604
  for (const entry of legendTile.meshes) {
1525
1605
  if (entry.kind !== 'mesh') continue;
1526
1606
  anyMesh = true;