spine-rigc 2.45.2 → 2.47.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, with where and how big), `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
- `--zoom <bone|slot>[:pad]` or `--viewport x,y,w,h`; `--bone`/`--hide-bone` pick the
420
+ `--zoom <bone|slot|folds>[: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, their world box and their setup-pose area |
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
@@ -243,8 +243,9 @@ What the flags mean:
243
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** |
244
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 |
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
- | `--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 |
246
+ | `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone, slot or folds 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, with their world box and setup-pose area. 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,91 @@ 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] in world box x [112.04, 133.28] y [323.01, 339.19] (21.22 × 16.16), setup-pose area 122.00 units² = 0.0686% of the mesh's; 3 triangle(s) fold on some frame [137, 139, 140]
7301
+ ```
7302
+
7303
+ **Where and how big.** The line says, for the worst frame, where the folded
7304
+ triangles are — the **world box** of their vertices as posed at that frame, every
7305
+ vertex of each and nothing else, printed rounded *outward* to hundredths so the
7306
+ printed box still holds every one (the file states it exactly) — and how big they
7307
+ are: their summed **setup-pose area**, each triangle's unsigned area in `rest`, in
7308
+ world units², and that as a share of the mesh's own setup-pose area (every
7309
+ triangle's, summed). "3 of 459 triangles" reads the same for three slivers at a
7310
+ hair tip and three panels of a skirt; the share does not.
7311
+
7312
+ ⭐ The box is the frame's and the area is the setup pose's, on purpose. The box is
7313
+ where to look, so it is where the triangles are drawn. The area is how much of the
7314
+ art is turned over, and a folded triangle's *posed* area cannot say that: it passes
7315
+ through 0 at the frame the triangle turns, so one triangle reads a different size on
7316
+ every frame it is folded — on the film above, `hair_long`'s three range from 0.09 to
7317
+ 24.2 units² posed across their 88 frames against 122.00 at the setup pose.
7318
+
7319
+ **What folded means.** A mesh triangle is folded at a frame when its orientation
7320
+ there — the sign of its signed area in world space — is the opposite of its
7321
+ orientation at the setup pose. The setup pose is `rest`'s (§8.2): the setup pose's
7322
+ bones with no deform, so an attachment a slot only swaps to later is read too.
7323
+ Regions are not read: a region is one quad and cannot fold. A mesh mirrored whole by
7324
+ a negative scale reads every triangle as folded — that is what the definition says,
7325
+ and the count tells the two apart.
7326
+
7327
+ **What is never counted.** A triangle already wrong at the setup pose is said once,
7328
+ on a `setup` line the first time a set shows its mesh, and never counted:
7329
+
7330
+ - **degenerate** — its height over its longest edge is at most 2⁻³⁰ of the largest
7331
+ coordinate its attachment holds at that pose, in world space or in its UVs. That
7332
+ is eight million ulps above the rounding a chain of bone transforms leaves, and a
7333
+ hundred-thousandth of a pixel on a rig 10⁴ units across drawn at one pixel per
7334
+ unit — thinner than the posed numbers resolve, and thinner than anything drawn.
7335
+ A triangle degenerate *at a frame* is not counted at that frame either: the sign of
7336
+ a zero area is the rounding's;
7337
+ - **inverted** — its texture is drawn mirrored against the rest of its mesh: the sign
7338
+ of world area × UV area is the opposite of the mesh's own sense, that product
7339
+ summed over its triangles, weighted by world area. Read through the UVs and not the
7340
+ index order, because the format fixes no winding — a mesh whose triangles are
7341
+ listed in mixed orders draws correctly, and a winding test would call half of it
7342
+ folded.
7343
+
7344
+ **The file.** `folds.json`, `rigc-folds/1`, beside each set's frames: `animation`,
7345
+ `skin` (absent when none was set), `fps`, `reading` (these definitions, stated in
7346
+ the file), and `meshes[]` — every mesh any frame of the set shows, in `rest`'s
7347
+ order — each with `slot`, `attachment`, `triangleCount`, `setup` (`degenerate` and
7348
+ `inverted`, triangle indices), `area` (the mesh's setup-pose area, world units²),
7349
+ `frames[]` (only the frames with a folded triangle: `index`, `time`, `triangles`,
7350
+ and that frame's `box` — `[minX, minY, maxX, maxY]`, world units, y up — `area` and
7351
+ `share`, as above), `worst` (the frame with the most, the earliest on a tie, with the
7352
+ same fields; `null` when it never folds) and `folded` (every triangle folded on some
7353
+ frame). `area`, `box` and `share` arrived in 2.47 (issue #1386) as added fields:
7354
+ every field 2.46 wrote means what it meant, so the tag is still `rigc-folds/1`.
7355
+ A triangle is named by its index `i` in `rest`'s `triangles`: vertices
7356
+ `triangles[3i]`, `triangles[3i+1]`, `triangles[3i+2]`. A mesh that never folds is in
7357
+ the file and on no printed line.
7358
+
7359
+ **Nothing else moves.** The PNGs, `contact.png`, `frames.json` and, with
7360
+ `--geometry` beside it, `geometry.json` are byte for byte what the render writes
7361
+ without the flag. The reading is its own file rather than a field of `geometry.json`
7362
+ because that file is the pose, read by consumers that have it on disk already; a
7363
+ reading derived from it is computed from it — `foldsOf` over `geometry.json`'s
7364
+ `rest` and `frames` is `folds.json` exactly (`FO04`). ⛔ **Not with
7365
+ `--slot`/`--hide`**, for `--geometry`'s reason: the reading is of every mesh the pose
7366
+ shows.
7367
+
7368
+ **A reading, not a gate.** Whether a fold is acceptable is yours: a tassel that swings
7369
+ past 90° folds honestly, and no size is too small to print. To look at one, draw the
7370
+ worst frame framed to it:
7371
+ `inspect --show art,folds,mesh --animation <name> --at <worst time> --zoom folds`
7372
+ fills the folded triangles at that pose, frames the picture to their box, and lists
7373
+ them with the same box and area in its legend (§13).
7374
+
7288
7375
  ---
7289
7376
 
7290
7377
  ## 9. Checking against the frames — `rigc check`
@@ -10111,6 +10198,7 @@ pixel (`IN09`).
10111
10198
  | 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
10199
  | 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
10200
  | 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 |
10201
+ | Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --animation <name> --at <the worst frame's time> --zoom folds` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count, which triangles, their world box at that frame and their setup-pose area. 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 with the same box and area, and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across, invisible in a picture of the whole mesh: `--zoom folds` frames the picture to the folded triangles' box. A thin sliver still needs pixels — raise `--max` when the fill is a line. A reading, not a verdict |
10114
10202
  | 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
10203
  | 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
10204
  | 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 |
@@ -10133,7 +10221,16 @@ pixel (`IN09`).
10133
10221
  vertices over every time drawn, padded by `<pad>` world units on every side
10134
10222
  (default a quarter of the box's longer side). A name that is both a bone and a slot
10135
10223
  is refused until it is written `bone:<name>` or `slot:<name>`; a bone of length 0
10136
- with no pad is one point, and is refused asking for one.
10224
+ with no pad is one point, and is refused asking for one. **The folds** —
10225
+ `--zoom folds[:<pad>]`: the box of every triangle folded over (§8.3) at the times
10226
+ drawn, in every mesh `--overlay-slot`/`--hide-overlay-slot` keep — the union of
10227
+ the legend's `folds[].box` over the tiles — padded as above. With nothing folded at
10228
+ those times it is refused, naming the meshes that fold on the animation's grid and
10229
+ their frames (`--zoom folds: nothing to frame — no mesh has a triangle folded over
10230
+ at t=1s of "turn" — at 12 fps these do: "hair_lock_l" on frame(s) [22-23], worst
10231
+ frame 22 (t=1.8333s); …`); at the setup pose — what a fold is read against —
10232
+ nothing folds, and it says so. A rig with a bone or slot named `folds` is refused
10233
+ until the name is written `bone:folds` or `slot:folds`.
10137
10234
  - **Two builds** — `--compare <dir | skeleton.json>`. The second build is loaded as
10138
10235
  the first is (its own poser, chosen by what it carries, on a `poser B:` line),
10139
10236
  posed at the same times, and drawn beside it: one time is two halves side by side,
@@ -10167,7 +10264,7 @@ pixel (`IN09`).
10167
10264
  legend, and a left-out one says which flag left it out in `undrawn`. A pattern
10168
10265
  that matches no bone is refused naming the bones there are.
10169
10266
  - **`--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
10267
+ `weights:<bone>`, `density`, `folds` and `slots` — only for the slots a pattern matches, and
10171
10268
  **`--hide-overlay-slot <glob[,glob…]>`** for none of the ones it matches, applied
10172
10269
  after `--overlay-slot`; the patterns are globs, as `--bone`'s are. The **art is not
10173
10270
  filtered**: the painting is what the edge is read against, and `--dim` already
@@ -10207,7 +10304,14 @@ pixel (`IN09`).
10207
10304
  `ratio`, the `rgba` it is painted, `bones` (how many bones it is weighted to —
10208
10305
  a region's or an unweighted mesh's is its slot's one) and `boneNames`. A part
10209
10306
  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`,
10307
+ `why`; it is not painted. With `folds`, `folds[]`: every kept mesh with a
10308
+ triangle folded at this tile's pose or a setup fault, in draw order — `slot`,
10309
+ `attachment`, `triangleCount`, `folded` (the triangles filled, by index),
10310
+ `degenerate` and `inverted` (the setup pose's, never counted), `box` (the folded
10311
+ triangles' world box at this pose, `[minX, minY, maxX, maxY]`; `null` when none is
10312
+ folded), `area` (their setup-pose area, world units²) and `share` (that over the
10313
+ mesh's setup-pose area) — §8.3's numbers; empty when none.
10314
+ With `mesh`, `hull`, `weights`, `density` or `folds`,
10211
10315
  `meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
10212
10316
  `vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
10213
10317
  triangle `edges` as vertex pairs, every posed vertex in `world` and `pixel`, and
@@ -10217,13 +10321,19 @@ pixel (`IN09`).
10217
10321
  `build` — `A` or `B`.
10218
10322
  - `colours` — every colour the picture draws, as RGBA, with what it means.
10219
10323
  - `poser`, `show`, `dim`, `skin`, `animation`, `fps` and `framing` — what was drawn
10220
- and how it was framed (`shot`, `zoom` with the bone or slot, the pad and whether it
10221
- was stated, or `viewport`).
10324
+ and how it was framed (`shot`, `zoom` with the bone or slot — or with `folds`, what
10325
+ the box is of — the pad and whether it was stated, or `viewport`).
10222
10326
  - `weights` — with `weights:<bone>`: the bone, the ramp's stops as RGBA, the floor
10223
10327
  below which nothing is painted, and how a pixel's weight is read.
10224
10328
  - `density` — with `density`: the ramp's stops (`ratio`, RGBA), the `scale` it is
10225
10329
  read on (log₂ of the ratio, clamped at the end stops), and in words what `area`,
10226
10330
  the figure's `totals` and the `ratio` are and how a pixel is painted.
10331
+ - `folds` — with `folds`: the colour a folded triangle is filled with, as RGBA, and
10332
+ what it means; `degenerateShare` (2⁻³⁰, §8.3); and in words what `folded`,
10333
+ `degenerate` and `inverted` are, how a triangle is named by `index`, and what
10334
+ `box` and `area` are. Its colour
10335
+ is stated here rather than in `colours`, which every legend lists whole, so a legend
10336
+ drawn without `folds` is the bytes it was before the view existed.
10227
10337
  - `boneFilter` — `--bone` and `--hide-bone` as given.
10228
10338
  - `slotFilter` — `--overlay-slot` and `--hide-overlay-slot` as given; with it,
10229
10339
  `meshes[]` and `slots[]` list only the slots it keeps.
@@ -10244,9 +10354,11 @@ beside it whose `spine.sha256` is its own — a Spine export states no rig invar
10244
10354
  `weights` with no bone or with a bone the build lacks (the bones listed), a
10245
10355
  `--bone`/`--hide-bone` pattern that matches no bone (the bones listed), an
10246
10356
  `--overlay-slot`/`--hide-overlay-slot` pattern that matches no slot (the slots
10247
- listed), and, with
10357
+ listed), `--zoom folds` with no triangle folded over at the times drawn (the meshes
10358
+ that fold on the grid and their frames listed), and, with
10248
10359
  `--compare`, any of these that holds of the second build, said as `the --compare
10249
- build: …`.
10360
+ build: …` — except `--zoom folds`, which frames to whichever build folds and is
10361
+ refused only when neither does, each build's frames named.
10250
10362
  A rig that declares no `invariants.axes` is not refused: the picture draws nothing
10251
10363
  for `declared` and the run says so on a line of its own.
10252
10364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.45.2",
3
+ "version": "2.47.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,12 @@ 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
+ 'with where they are (the world box of their posed vertices) and how big (their setup-pose area, and its share of the mesh\'s). ' +
1004
+ 'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
1005
+ 'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
999
1006
  poser:
1000
1007
  "`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
1008
  'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
@@ -1067,7 +1074,8 @@ const FLAG_MEANINGS: Record<string, string> = {
1067
1074
  "build's own frame; grid = lines at whole multiples of a step in world units, labelled; density = each region and mesh " +
1068
1075
  "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
1076
  "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; ' +
1077
+ 'weight over every region and mesh it moves, as a heat on a ramp the legend states; folds = each mesh triangle turned ' +
1078
+ "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
1079
  "hull = each mesh's hull outline; declared = each invariants.axes " +
1072
1080
  "line through its two bones' origins, its held bones drawn in their own colour; bones = each bone from origin to tip, " +
1073
1081
  '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 ' +
@@ -1082,7 +1090,9 @@ const FLAG_MEANINGS: Record<string, string> = {
1082
1090
  zoom:
1083
1091
  'frame the picture to one bone (origin to tip) or one slot (its attachment\'s vertices) over every time drawn, padded ' +
1084
1092
  '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 ' +
1085
- 'slot is refused until written bone:<name> or slot:<name>; a name that is neither lists both rosters. Not with --viewport',
1093
+ 'slot is refused until written bone:<name> or slot:<name>; a name that is neither lists both rosters. `folds` frames it to ' +
1094
+ 'every folded triangle of every mesh --overlay-slot keeps, at the times drawn — refused, naming the frames that do fold, ' +
1095
+ 'when none is folded there. Not with --viewport',
1086
1096
  bone:
1087
1097
  "draw only the bones these globs match (`*` any run of characters, `?` one, the whole name; comma-separated) — on `inspect`, " +
1088
1098
  "for bones, axes and names; every bone stays in the legend, a left-out one saying why in `undrawn`. A pattern that matches no " +
@@ -1202,7 +1212,7 @@ const FLAG_VALUES: Record<string, string> = {
1202
1212
  max: '<px>',
1203
1213
  show: '<view[,view…]>',
1204
1214
  at: '<s[,s…]>',
1205
- zoom: '<name>[:<pad>]',
1215
+ zoom: '<name|folds>[:<pad>]',
1206
1216
  bone: '<glob[,glob…]>',
1207
1217
  'hide-bone': '<glob[,glob…]>',
1208
1218
  'overlay-slot': '<glob[,glob…]>',
@@ -1577,10 +1587,10 @@ export const COMMANDS: CommandDoc[] = [
1577
1587
  runtime: false,
1578
1588
  spineFormat: false,
1579
1589
  usage: [
1580
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
1590
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
1581
1591
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1582
1592
  ],
1583
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
1593
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
1584
1594
  overrides: {
1585
1595
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1586
1596
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -1593,9 +1603,9 @@ export const COMMANDS: CommandDoc[] = [
1593
1603
  usage: [
1594
1604
  'rigc inspect --candidate <dir | skeleton.json> [--show art,bones,…] [--dim] [--max 256] [--poser core|spine] [--out inspect.png] (the setup pose)',
1595
1605
  'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
1596
- 'rigc inspect … --zoom <bone|slot>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
1606
+ 'rigc inspect … --zoom <bone|slot|folds>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
1597
1607
  "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)',
1608
+ 'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights, density, folds and slots draw; the art stays whole)',
1599
1609
  'rigc inspect … --compare <dir | skeleton.json> (a second build beside the first, on one transform)',
1600
1610
  ],
1601
1611
  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,446 @@
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
+ * ## Where and how big (issue #1386)
40
+ *
41
+ * Each folded frame also carries the world box of its folded triangles' posed
42
+ * vertices and their summed setup-pose area, alone and as a share of the mesh's
43
+ * — `foldExtent` says why the box is the frame's and the area the setup pose's.
44
+ *
45
+ * Pure: no clock, no randomness, no runtime. The posed vertices are handed in by
46
+ * whichever poser drew the frames (rigc's core for a rigc build).
47
+ */
48
+
49
+ /** The fold file's name inside an animation's frame directory, and its format tag. */
50
+ export const FOLDS_FILE = 'folds.json';
51
+ export const FOLDS_SPEC = 'rigc-folds/1';
52
+
53
+ /**
54
+ * A triangle is degenerate when its height over its longest edge is at most
55
+ * this share of the largest coordinate magnitude its attachment holds at that
56
+ * pose (world units, or UV units for the UV triangle).
57
+ *
58
+ * 2⁻³⁰ ≈ 9.3e-10, chosen between two bounds that are both stated rather than
59
+ * tuned:
60
+ *
61
+ * - **above the rounding**: a posed coordinate is a chain of bone transforms in
62
+ * doubles, each step rounding by half an ulp (2⁻⁵³) of numbers no larger than
63
+ * a few times the coordinate. 2⁻³⁰ is 2²³ ulps — eight million steps of
64
+ * headroom — so a sign this flags is never one a different rounding could
65
+ * have given the other way;
66
+ * - **below anything drawn**: on a rig 10⁴ world units across it is 1e-5 world
67
+ * units, a hundred-thousandth of a pixel at one pixel per unit, so no triangle
68
+ * a picture can show is called degenerate.
69
+ *
70
+ * Relative to the attachment's own magnitude because the rounding is: a mesh at
71
+ * 3,200 units from its origin carries 3,200 times the absolute noise of one at 1.
72
+ */
73
+ export const DEGENERATE_SHARE = 2 ** -30;
74
+
75
+ /** The rest geometry the reading needs: the triangles, the UVs and the setup pose's vertices. */
76
+ export interface FoldRest {
77
+ slot: string;
78
+ attachment: string;
79
+ kind: 'region' | 'mesh';
80
+ vertices: readonly number[];
81
+ triangles: readonly number[];
82
+ uvs?: readonly number[];
83
+ }
84
+
85
+ /** One posed attachment: its slot, its name, its world vertices `x, y` per vertex. */
86
+ export interface FoldPose {
87
+ slot: string;
88
+ attachment: string;
89
+ vertices: readonly number[];
90
+ }
91
+
92
+ /** One sampled frame: its index on the grid, its time and every attachment it shows. */
93
+ export interface FoldFrame {
94
+ index: number;
95
+ time: number;
96
+ attachments: readonly FoldPose[];
97
+ }
98
+
99
+ /** A mesh's triangles that are wrong at the setup pose — reported once, never counted. */
100
+ export interface SetupFaults {
101
+ /** Thinner than the posed numbers resolve, in world space or in the UVs — by triangle index. */
102
+ degenerate: number[];
103
+ /** Drawn mirrored against the rest of its mesh at the setup pose — by triangle index. */
104
+ inverted: number[];
105
+ }
106
+
107
+ /** A world box `[minX, minY, maxX, maxY]`, world units, y up. */
108
+ export type FoldBox = [number, number, number, number];
109
+
110
+ /** Where a set of folded triangles is at one pose, and how big they are (issue #1386). */
111
+ export interface FoldExtent {
112
+ /** The world box of the folded triangles' vertices as posed at this frame — every vertex of each, nothing else. */
113
+ box: FoldBox;
114
+ /** Their summed area at the setup pose, world units² — each triangle's unsigned area in the rest table. */
115
+ area: number;
116
+ /** `area` over the mesh's own setup-pose area (every triangle's, unsigned): the share of the mesh's art that is turned over. */
117
+ share: number;
118
+ }
119
+
120
+ /** One frame on which a mesh has a folded triangle. */
121
+ export interface FoldedFrame extends FoldExtent {
122
+ index: number;
123
+ time: number;
124
+ /** The folded triangles, by index (`triangles[3i..3i+2]` of the rest table), ascending. */
125
+ triangles: number[];
126
+ }
127
+
128
+ /** One mesh's reading over one frame set. */
129
+ export interface MeshFolds {
130
+ slot: string;
131
+ attachment: string;
132
+ /** How many triangles the mesh has. */
133
+ triangleCount: number;
134
+ /** The mesh's area at the setup pose, world units²: every triangle's unsigned area in the rest table, summed — what `share` is over. */
135
+ area: number;
136
+ setup: SetupFaults;
137
+ /** Every frame with a folded triangle, in frame order; empty when the mesh never folds. */
138
+ frames: FoldedFrame[];
139
+ /** The frame with the most folded triangles — the earliest, on a tie — or `null` when it never folds. */
140
+ worst: FoldedFrame | null;
141
+ /** Every triangle folded on some frame, ascending. */
142
+ folded: number[];
143
+ }
144
+
145
+ /** `folds.json`: one frame set's reading, every mesh any frame shows. */
146
+ export interface FoldsFile {
147
+ spec: typeof FOLDS_SPEC;
148
+ /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
149
+ animation: string | null;
150
+ skin?: string;
151
+ fps: number;
152
+ /** What the numbers mean, stated in the file so a reader does not need this module. */
153
+ reading: { folded: string; degenerate: string; inverted: string; index: string; box: string; area: string };
154
+ /** Every mesh any frame of the set shows, in the order the rest table lists them. */
155
+ meshes: MeshFolds[];
156
+ }
157
+
158
+ /**
159
+ * A planted defect, for the suite's mutants only (`FO` controls in
160
+ * `selftest.ts`). Production passes none.
161
+ */
162
+ export interface FoldPlant {
163
+ /** Count a triangle whose sign AGREES with the setup pose's, rather than the one opposite it. */
164
+ signFlipped?: boolean;
165
+ /** Read each frame against the frame before it — a flip detector — rather than against the setup pose. */
166
+ againstPreviousFrame?: boolean;
167
+ /** Count the setup pose's degenerate and inverted triangles like any other. */
168
+ countSetupFaults?: boolean;
169
+ /** Box every vertex of the mesh at the frame, rather than the folded triangles' vertices. */
170
+ boxWholeMesh?: boolean;
171
+ /** Size the folded triangles by their unsigned area as posed at the frame, rather than at the setup pose. */
172
+ areaPosed?: boolean;
173
+ }
174
+
175
+ /** Twice the signed area of triangle `t` in `xy` (`x, y` per vertex). */
176
+ function cross2(xy: readonly number[], triangles: readonly number[], t: number): number {
177
+ const a = triangles[3 * t] * 2;
178
+ const b = triangles[3 * t + 1] * 2;
179
+ const c = triangles[3 * t + 2] * 2;
180
+ return (xy[b] - xy[a]) * (xy[c + 1] - xy[a + 1]) - (xy[c] - xy[a]) * (xy[b + 1] - xy[a + 1]);
181
+ }
182
+
183
+ /** The longest edge of triangle `t` in `xy`. */
184
+ function longestEdge(xy: readonly number[], triangles: readonly number[], t: number): number {
185
+ const ids = [triangles[3 * t] * 2, triangles[3 * t + 1] * 2, triangles[3 * t + 2] * 2];
186
+ let most = 0;
187
+ for (const [i, j] of [[0, 1], [1, 2], [2, 0]] as const) {
188
+ const dx = xy[ids[j]] - xy[ids[i]];
189
+ const dy = xy[ids[j] + 1] - xy[ids[i] + 1];
190
+ most = Math.max(most, Math.sqrt(dx * dx + dy * dy));
191
+ }
192
+ return most;
193
+ }
194
+
195
+ /** The largest coordinate magnitude in `xy` — what the rounding of every number in it is relative to. */
196
+ function magnitude(xy: readonly number[]): number {
197
+ let most = 0;
198
+ for (const v of xy) most = Math.max(most, Math.abs(v));
199
+ return most;
200
+ }
201
+
202
+ /**
203
+ * The sign of triangle `t`'s area in `xy`: +1, −1, or 0 when it is degenerate
204
+ * — its height over its longest edge at most `DEGENERATE_SHARE` of `scale`.
205
+ */
206
+ function orientation(xy: readonly number[], triangles: readonly number[], t: number, scale: number): -1 | 0 | 1 {
207
+ const twice = cross2(xy, triangles, t);
208
+ const edge = longestEdge(xy, triangles, t);
209
+ if (edge === 0 || Math.abs(twice) / edge <= DEGENERATE_SHARE * scale) return 0;
210
+ return twice > 0 ? 1 : -1;
211
+ }
212
+
213
+ /** A mesh's setup reading: each triangle's world sign at the setup pose, and the triangles reported as setup faults. */
214
+ export interface SetupReading {
215
+ /** Per triangle: +1 or −1, its world sign at the setup pose; 0 for a setup fault (never counted). */
216
+ signs: Int8Array;
217
+ faults: SetupFaults;
218
+ }
219
+
220
+ /** The setup reading of one mesh — see the module comment for what degenerate and inverted mean. */
221
+ export function setupReading(rest: FoldRest, plant: FoldPlant = {}): SetupReading {
222
+ const count = Math.floor(rest.triangles.length / 3);
223
+ const signs = new Int8Array(count);
224
+ const world: number[] = [];
225
+ const uvSigns: number[] = [];
226
+ const worldScale = magnitude(rest.vertices);
227
+ const uvs = rest.uvs ?? [];
228
+ const uvScale = magnitude(uvs);
229
+ let sense = 0;
230
+ const degenerate: number[] = [];
231
+ for (let t = 0; t < count; t++) {
232
+ const w = orientation(rest.vertices, rest.triangles, t, worldScale);
233
+ const u = uvs.length === rest.vertices.length ? orientation(uvs, rest.triangles, t, uvScale) : 1;
234
+ world.push(w);
235
+ uvSigns.push(u);
236
+ if (w === 0 || u === 0) {
237
+ degenerate.push(t);
238
+ continue;
239
+ }
240
+ sense += w * u * Math.abs(cross2(rest.vertices, rest.triangles, t));
241
+ }
242
+ const inverted: number[] = [];
243
+ for (let t = 0; t < count; t++) {
244
+ if (world[t] === 0 || uvSigns[t] === 0) continue;
245
+ // A mesh whose two senses weigh exactly the same has no rest to be inverted against: none is flagged.
246
+ if (sense !== 0 && Math.sign(world[t] * uvSigns[t]) !== Math.sign(sense)) {
247
+ inverted.push(t);
248
+ continue;
249
+ }
250
+ signs[t] = world[t];
251
+ }
252
+ if (plant.countSetupFaults) {
253
+ for (let t = 0; t < count; t++) signs[t] = world[t] === 0 ? 1 : world[t];
254
+ }
255
+ return { signs, faults: { degenerate, inverted } };
256
+ }
257
+
258
+ /** The triangles of one mesh folded at one pose, ascending — `vertices` posed, `setup` its setup reading. */
259
+ export function foldedAt(rest: FoldRest, setup: SetupReading, vertices: readonly number[], plant: FoldPlant = {}): number[] {
260
+ if (vertices.length !== rest.vertices.length) {
261
+ throw new Error(
262
+ `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`,
263
+ );
264
+ }
265
+ const scale = magnitude(vertices);
266
+ const out: number[] = [];
267
+ for (let t = 0; t < setup.signs.length; t++) {
268
+ const before = setup.signs[t];
269
+ if (before === 0) continue;
270
+ const now = orientation(vertices, rest.triangles, t, scale);
271
+ if (now === 0) continue;
272
+ if (plant.signFlipped ? now === before : now === -before) out.push(t);
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /** A mesh's area at the setup pose: every triangle's unsigned area in the rest table, summed in index order. */
278
+ export function setupArea(rest: FoldRest): number {
279
+ let area = 0;
280
+ for (let t = 0; t < Math.floor(rest.triangles.length / 3); t++) area += Math.abs(cross2(rest.vertices, rest.triangles, t)) / 2;
281
+ return area;
282
+ }
283
+
284
+ /**
285
+ * Where `triangles` of one mesh are at one pose and how big they are: the world
286
+ * box of their posed vertices, and their summed setup-pose area, alone and as a
287
+ * share of `meshArea` (`setupArea(rest)`). `null` for no triangle.
288
+ *
289
+ * ⭐ The box is the pose's and the area is the setup pose's, on purpose. The box
290
+ * answers "where is it in this frame" — it is what a picture is framed to. The
291
+ * area answers "how much of the art is turned over", and a folded triangle's
292
+ * posed area cannot: it passes through 0 at the frame the triangle turns, so one
293
+ * triangle reads a different size on every frame it is folded and none of them
294
+ * says how much art it carries.
295
+ */
296
+ export function foldExtent(
297
+ rest: FoldRest,
298
+ vertices: readonly number[],
299
+ triangles: readonly number[],
300
+ meshArea: number,
301
+ plant: FoldPlant = {},
302
+ ): FoldExtent | null {
303
+ if (triangles.length === 0) return null;
304
+ const box: FoldBox = [Infinity, Infinity, -Infinity, -Infinity];
305
+ const take = (v: number): void => {
306
+ box[0] = Math.min(box[0], vertices[2 * v]);
307
+ box[1] = Math.min(box[1], vertices[2 * v + 1]);
308
+ box[2] = Math.max(box[2], vertices[2 * v]);
309
+ box[3] = Math.max(box[3], vertices[2 * v + 1]);
310
+ };
311
+ if (plant.boxWholeMesh) for (let v = 0; v < vertices.length / 2; v++) take(v);
312
+ let area = 0;
313
+ for (const t of triangles) {
314
+ if (!plant.boxWholeMesh) for (let k = 0; k < 3; k++) take(rest.triangles[3 * t + k]);
315
+ area += Math.abs(cross2(plant.areaPosed ? vertices : rest.vertices, rest.triangles, t)) / 2;
316
+ }
317
+ return { box, area, share: meshArea > 0 ? area / meshArea : 0 };
318
+ }
319
+
320
+ const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
321
+
322
+ /**
323
+ * Every mesh's reading over one frame set: `rest` the rest table (regions are
324
+ * skipped — a region is a quad and cannot fold), `frames` the frames as the
325
+ * sampler returned them. A mesh any frame shows is read; one no frame shows is
326
+ * not listed. Order is the rest table's, so one build reads in one order.
327
+ */
328
+ export function foldsOf(rest: readonly FoldRest[], frames: readonly FoldFrame[], plant: FoldPlant = {}): MeshFolds[] {
329
+ const shown = new Set<string>();
330
+ for (const frame of frames) for (const a of frame.attachments) shown.add(restKey(a.slot, a.attachment));
331
+ const out: MeshFolds[] = [];
332
+ for (const r of rest) {
333
+ if (r.kind !== 'mesh' || !shown.has(restKey(r.slot, r.attachment))) continue;
334
+ let setup = setupReading(r, plant);
335
+ const area = setupArea(r);
336
+ const folded: FoldedFrame[] = [];
337
+ const union = new Set<number>();
338
+ for (const frame of frames) {
339
+ const posed = frame.attachments.find((a) => a.slot === r.slot && a.attachment === r.attachment);
340
+ if (posed === undefined) continue;
341
+ const triangles = foldedAt(r, setup, posed.vertices, plant);
342
+ if (plant.againstPreviousFrame) {
343
+ const signs = Int8Array.from(setup.signs, (sign, t) => (triangles.includes(t) ? -sign : sign));
344
+ setup = { ...setup, signs };
345
+ }
346
+ const extent = foldExtent(r, posed.vertices, triangles, area, plant);
347
+ if (extent === null) continue;
348
+ for (const t of triangles) union.add(t);
349
+ folded.push({ index: frame.index, time: frame.time, triangles, ...extent });
350
+ }
351
+ let worst: FoldedFrame | null = null;
352
+ for (const f of folded) if (worst === null || f.triangles.length > worst.triangles.length) worst = f;
353
+ out.push({
354
+ slot: r.slot,
355
+ attachment: r.attachment,
356
+ triangleCount: setup.signs.length,
357
+ area,
358
+ setup: setup.faults,
359
+ frames: folded,
360
+ worst,
361
+ folded: [...union].sort((a, b) => a - b),
362
+ });
363
+ }
364
+ return out;
365
+ }
366
+
367
+ /** What `folds.json` states its numbers mean — the one wording the file and the docs share. */
368
+ export const FOLDS_READING: FoldsFile['reading'] = {
369
+ folded:
370
+ "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",
371
+ 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`,
372
+ inverted:
373
+ "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",
374
+ index: 'a triangle is named by its index i in the rest table: vertices triangles[3i], triangles[3i+1], triangles[3i+2]',
375
+ box: "a frame's box is [minX, minY, maxX, maxY] in world units (y up) over every vertex of that frame's folded triangles as posed there, and nothing else",
376
+ area:
377
+ "a frame's area is its folded triangles' unsigned areas at the setup pose (the rest table), summed, in world units²; share is that over the mesh's area, every triangle's unsigned setup-pose area summed. The setup pose's, not the frame's: a folded triangle's posed area passes through 0 as it turns, so it says how far the turn has gone rather than how much art is turned over",
378
+ };
379
+
380
+ /** The file for one frame set. */
381
+ export function foldsFileOf(animation: string | null, fps: number, skin: string | undefined, meshes: MeshFolds[]): FoldsFile {
382
+ return { spec: FOLDS_SPEC, animation, ...(skin === undefined ? {} : { skin }), fps, reading: FOLDS_READING, meshes };
383
+ }
384
+
385
+ /** The file's text: two-space JSON and a newline, so one build and one request are one byte sequence. */
386
+ export function foldsText(file: FoldsFile): string {
387
+ return `${JSON.stringify(file, null, 2)}\n`;
388
+ }
389
+
390
+ /** Ascending indices as runs: `[3, 4, 5, 9]` → `3-5, 9`. */
391
+ export function indexRuns(indices: readonly number[]): string {
392
+ const runs: string[] = [];
393
+ for (let i = 0; i < indices.length; ) {
394
+ let j = i;
395
+ while (j + 1 < indices.length && indices[j + 1] === indices[j] + 1) j++;
396
+ runs.push(j === i ? String(indices[i]) : `${indices[i]}-${indices[j]}`);
397
+ i = j + 1;
398
+ }
399
+ return runs.join(', ');
400
+ }
401
+
402
+ /** `x` rounded to hundredths away from the box's inside, so the printed box holds every vertex the exact one does. */
403
+ const outward = (x: number, up: boolean): string => ((up ? Math.ceil(x * 100) : Math.floor(x * 100)) / 100).toFixed(2);
404
+
405
+ /**
406
+ * Where and how big, as the fold line and the inspect note print it:
407
+ * `in world box x [x0, x1] y [y0, y1] (w × h), setup-pose area A units² = S% of the mesh's`.
408
+ * The box is rounded outward to hundredths; the file states it exactly.
409
+ */
410
+ export function extentText(extent: FoldExtent): string {
411
+ const [x0, y0, x1, y1] = extent.box;
412
+ return (
413
+ `in world box x [${outward(x0, false)}, ${outward(x1, true)}] y [${outward(y0, false)}, ${outward(y1, true)}] ` +
414
+ `(${(x1 - x0).toFixed(2)} × ${(y1 - y0).toFixed(2)}), setup-pose area ${extent.area.toFixed(2)} units² = ${(100 * extent.share).toPrecision(3)}% of the mesh's`
415
+ );
416
+ }
417
+
418
+ /**
419
+ * The printed line for a mesh that folds, or `null` for one that never does.
420
+ * It names the animation, the mesh and its slot, the frames, the worst frame's
421
+ * count, its triangles, where they are and how big, and every triangle folded
422
+ * on any frame.
423
+ */
424
+ export function foldLine(animation: string | null, fps: number, mesh: MeshFolds): string | null {
425
+ const worst = mesh.worst;
426
+ if (worst === null) return null;
427
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
428
+ const frames = mesh.frames.map((f) => f.index);
429
+ return (
430
+ `${where}: mesh "${mesh.attachment}" (slot "${mesh.slot}") folds on ${frames.length} frame(s) [${indexRuns(frames)}] at ${fps} fps; ` +
431
+ `worst frame ${worst.index} (t=${worst.time.toFixed(4)}s): ${worst.triangles.length} of ${mesh.triangleCount} triangle(s) [${worst.triangles.join(', ')}] ` +
432
+ `${extentText(worst)}; ` +
433
+ `${mesh.folded.length} triangle(s) fold on some frame [${mesh.folded.join(', ')}]`
434
+ );
435
+ }
436
+
437
+ /** The printed line for a mesh with setup faults, or `null` for one with none — printed once per run, not per animation. */
438
+ export function setupFaultLine(mesh: Pick<MeshFolds, 'slot' | 'attachment' | 'setup'>): string | null {
439
+ const { degenerate, inverted } = mesh.setup;
440
+ if (degenerate.length === 0 && inverted.length === 0) return null;
441
+ const parts = [
442
+ ...(degenerate.length === 0 ? [] : [`${degenerate.length} degenerate [${degenerate.join(', ')}]`]),
443
+ ...(inverted.length === 0 ? [] : [`${inverted.length} inverted against the rest of the mesh [${inverted.join(', ')}]`]),
444
+ ];
445
+ return `mesh "${mesh.attachment}" (slot "${mesh.slot}") at the setup pose: triangle(s) ${parts.join('; ')} — reported here once and never counted as folding`;
446
+ }
package/src/inspect.ts CHANGED
@@ -42,6 +42,12 @@
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 — and the same
49
+ * box and area, which `--zoom folds` frames the picture to (issue #1386).
50
+ *
45
51
  * Links nothing from the runtime: the poser is handed in.
46
52
  */
47
53
  import { createHash } from 'node:crypto';
@@ -71,12 +77,13 @@ import {
71
77
  viewportFor,
72
78
  } from './render_shared.ts';
73
79
  import { hypot2 } from './numeric.ts';
80
+ import { DEGENERATE_SHARE, extentText, type FoldBox, foldedAt, foldExtent, type FoldPlant, FOLDS_READING, foldsOf, indexRuns, setupArea, setupReading } from './folds.ts';
74
81
 
75
82
  /** The legend's format tag. */
76
83
  export const INSPECT_SPEC = 'rigc-inspect/1';
77
84
 
78
85
  /** 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;
86
+ export const SHOW_WORDS = ['art', 'grid', 'density', 'weights', 'folds', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
80
87
  export type ShowWord = (typeof SHOW_WORDS)[number];
81
88
 
82
89
  /** The words as a refusal lists them — `weights` with the bone it needs. */
@@ -183,6 +190,19 @@ export const INSPECT_COLOURS = {
183
190
  rule: { rgba: [...SHEET_RULE], meaning: 'the gap between two tiles' },
184
191
  } as const satisfies Record<string, { rgba: readonly number[]; meaning: string }>;
185
192
 
193
+ /**
194
+ * The `folds` view's colour (issue #1383). Kept out of `INSPECT_COLOURS`, which
195
+ * every legend lists whole: a colour added there would move the bytes of every
196
+ * legend written without `folds`. It is stated in the legend's `folds` block
197
+ * instead, only when the view is drawn — as the weights and density ramps are.
198
+ * Pure red at full alpha: neither ramp reaches it (the weights ramp's red end is
199
+ * `[215, 20, 20]`), and the mesh edges drawn over a fill stay readable.
200
+ */
201
+ export const FOLD_COLOUR = {
202
+ rgba: [255, 0, 0, 255],
203
+ 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',
204
+ } as const;
205
+
186
206
  type ColourName = keyof typeof INSPECT_COLOURS;
187
207
  const colour = (name: ColourName): RGBA => [...INSPECT_COLOURS[name].rgba] as RGBA;
188
208
 
@@ -292,6 +312,10 @@ export interface InspectPlant {
292
312
  densityLinearRamp?: boolean;
293
313
  /** Density: take the figure's totals over the slots the filter keeps, not over every slot. */
294
314
  densityTotalsFiltered?: boolean;
315
+ /** Folds: fill every triangle of every kept mesh, folded or not. */
316
+ foldsFillAll?: boolean;
317
+ /** Folds: a planted fold reading (`./folds.ts`) for the box and area the legend states and `--zoom folds` frames to. */
318
+ foldPlant?: FoldPlant;
295
319
  }
296
320
 
297
321
  /** A point in both spaces. */
@@ -425,6 +449,26 @@ export interface LegendDensity {
425
449
  slots: LegendDensitySlot[];
426
450
  }
427
451
 
452
+ /** One kept mesh with a folded triangle, or a setup fault, at one tile's pose (issue #1383). */
453
+ export interface LegendFold {
454
+ slot: string;
455
+ attachment: string;
456
+ /** How many triangles the mesh has. */
457
+ triangleCount: number;
458
+ /** The triangles folded at this pose, by index (`triangles[3i..3i+2]` of the rest table), ascending — the ones filled. */
459
+ folded: number[];
460
+ /** Thinner than the posed numbers resolve at the setup pose, never counted — by index. */
461
+ degenerate: number[];
462
+ /** Drawn mirrored against the rest of the mesh at the setup pose, never counted — by index. */
463
+ inverted: number[];
464
+ /** The world box of the folded triangles' vertices at this pose, `[minX, minY, maxX, maxY]`; `null` when none is folded (issue #1386). */
465
+ box: FoldBox | null;
466
+ /** The folded triangles' summed setup-pose area, world units² — 0 when none is folded. */
467
+ area: number;
468
+ /** `area` over the mesh's setup-pose area. */
469
+ share: number;
470
+ }
471
+
428
472
  export interface LegendTile {
429
473
  /** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
430
474
  build?: 'A' | 'B';
@@ -445,6 +489,8 @@ export interface LegendTile {
445
489
  slots?: LegendSlot[];
446
490
  /** With `density`: the figure's totals and every kept slot's row. */
447
491
  density?: LegendDensity;
492
+ /** With `folds`: every kept mesh with a triangle folded at this pose or a setup fault, in draw order; empty when none. */
493
+ folds?: LegendFold[];
448
494
  declared?: LegendDeclared[];
449
495
  grid?: { step: number; x: number[]; y: number[] };
450
496
  }
@@ -460,7 +506,7 @@ export interface InspectLegend {
460
506
  fps: number;
461
507
  framing:
462
508
  | { kind: 'shot'; note: string }
463
- | { kind: 'zoom'; bone?: string; slot?: string; pad: number; padFrom: 'stated' | 'default' }
509
+ | { kind: 'zoom'; bone?: string; slot?: string; folds?: string; pad: number; padFrom: 'stated' | 'default' }
464
510
  | { kind: 'viewport' };
465
511
  /** `--bone` / `--hide-bone` as given — each bone they leave out says so in its `undrawn`. */
466
512
  boneFilter?: { include: string[] | null; exclude: string[] | null };
@@ -470,6 +516,8 @@ export interface InspectLegend {
470
516
  weights?: { bone: string; ramp: Array<{ weight: number; rgba: number[] }>; floor: number; reading: string };
471
517
  /** With `density`: the ramp, its scale, and how area, totals and ratio are read. */
472
518
  density?: { ramp: Array<{ ratio: number; rgba: number[] }>; scale: string; area: string; totals: string; ratio: string; reading: string };
519
+ /** With `folds`: the colour a folded triangle is filled with, the degenerate share, and what folded, degenerate and inverted mean. */
520
+ folds?: { rgba: number[]; meaning: string; degenerateShare: number; folded: string; degenerate: string; inverted: string; index: string; box: string; area: string };
473
521
  /** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
474
522
  builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
475
523
  coordinates: { world: string; image: string; toPixel: string; toWorld: string };
@@ -559,7 +607,7 @@ export function readTimes(raw: string | undefined): number[] {
559
607
  return times;
560
608
  }
561
609
 
562
- /** `--zoom <name>[:<pad>]`: the name, and the pad in world units when one is stated. */
610
+ /** `--zoom <name>[:<pad>]`: the name — a bone, a slot or `folds` — and the pad in world units when one is stated. */
563
611
  export function readZoom(raw: string): { target: string; pad: number | null } {
564
612
  const colon = raw.lastIndexOf(':');
565
613
  if (colon > 0) {
@@ -570,7 +618,7 @@ export function readZoom(raw: string): { target: string; pad: number | null } {
570
618
  return { target: raw.slice(0, colon), pad };
571
619
  }
572
620
  }
573
- if (raw === '') throw new InspectError('--zoom needs a bone or slot name: --zoom <name>[:<pad>]');
621
+ if (raw === '') throw new InspectError('--zoom needs a bone or slot name, or folds: --zoom <name|folds>[:<pad>]');
574
622
  return { target: raw, pad: null };
575
623
  }
576
624
 
@@ -1036,10 +1084,17 @@ function slotsLeftOut(slots: ReadonlyArray<{ name: string }>, filter: SlotFilter
1036
1084
  // the view
1037
1085
  // ---------------------------------------------------------------------------
1038
1086
 
1039
- /** The poses one picture is of: the frames, in the order the request named their times. */
1040
- function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean): Array<{ frame: Frame; index: number }> {
1087
+ /**
1088
+ * The poses one picture is of: the frames, in the order the request named their
1089
+ * times — and every frame of the grid they were taken from, which `--zoom folds`
1090
+ * reads when nothing is folded at the times drawn.
1091
+ */
1092
+ function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean): { poses: Array<{ frame: Frame; index: number }>; grid: Frame[] } {
1041
1093
  const opts: PoseOptions = { bones: true, ...(needAttachments ? { geometry: true } : {}), ...(request.skin === undefined ? {} : { skin: request.skin }) };
1042
- if (request.animation === null) return [{ frame: sampleSetupPose(poser, opts)[0], index: 0 }];
1094
+ if (request.animation === null) {
1095
+ const grid = sampleSetupPose(poser, opts);
1096
+ return { poses: [{ frame: grid[0], index: 0 }], grid };
1097
+ }
1043
1098
  const animation = poser.animations.find((a) => a.name === request.animation);
1044
1099
  if (animation === undefined) {
1045
1100
  throw new InspectError(`no animation ${JSON.stringify(request.animation)} in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`);
@@ -1051,11 +1106,67 @@ function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean
1051
1106
  seen.add(index);
1052
1107
  }
1053
1108
  const frames = sampleAnimation(poser, animation.name, request.fps, opts);
1054
- return indices.map((index) => ({ frame: frames[index], index }));
1109
+ return { poses: indices.map((index) => ({ frame: frames[index], index })), grid: frames };
1055
1110
  }
1056
1111
 
1057
1112
  type WorldBox = [number, number, number, number];
1058
1113
 
1114
+ /** The word `--zoom` takes for the fold reading's box rather than a bone's or a slot's (issue #1386). */
1115
+ export const ZOOM_FOLDS = 'folds';
1116
+
1117
+ /**
1118
+ * `--zoom folds`: the world box of every folded triangle of every kept mesh at
1119
+ * the times drawn — the box the legend's `folds[]` states per tile, unioned —
1120
+ * or, when none is folded there, `null` and the sentence that says which frames
1121
+ * of the grid do fold, so the refusal points at them.
1122
+ */
1123
+ function foldsZoomBox(
1124
+ poser: Poser,
1125
+ poses: ReadonlyArray<{ frame: Frame }>,
1126
+ grid: readonly Frame[],
1127
+ slotsOut: ReadonlySet<string>,
1128
+ request: InspectRequest,
1129
+ plant: InspectPlant,
1130
+ ): { box: WorldBox; folds: string } | { miss: string } {
1131
+ const rests = new Map<string, AttachmentRest>();
1132
+ const restOf = (frames: ReadonlyArray<{ frame: Frame }>): void => {
1133
+ for (const r of poser.rest(request.skin, frames.map((p) => p.frame.attachments ?? []))) rests.set(restKey(r.slot, r.attachment), r);
1134
+ };
1135
+ restOf(poses);
1136
+ const box: WorldBox = [Infinity, Infinity, -Infinity, -Infinity];
1137
+ for (const { frame } of poses) {
1138
+ for (const shown of frame.attachments ?? []) {
1139
+ if (slotsOut.has(shown.slot)) continue;
1140
+ const rest = rests.get(restKey(shown.slot, shown.attachment));
1141
+ if (rest === undefined || rest.kind !== 'mesh') continue;
1142
+ const extent = foldExtent(rest, shown.vertices, foldedAt(rest, setupReading(rest), shown.vertices), setupArea(rest), plant.foldPlant);
1143
+ if (extent === null) continue;
1144
+ box[0] = Math.min(box[0], extent.box[0]);
1145
+ box[1] = Math.min(box[1], extent.box[1]);
1146
+ box[2] = Math.max(box[2], extent.box[2]);
1147
+ box[3] = Math.max(box[3], extent.box[3]);
1148
+ }
1149
+ }
1150
+ const kept = request.slotFilter === undefined ? 'mesh' : 'mesh the filter keeps';
1151
+ if (box.every(Number.isFinite)) return { box, folds: `every triangle of every ${kept} folded over at the time(s) drawn` };
1152
+ const at = request.animation === null ? 'the setup pose' : `t=${request.times.map((t) => `${t}s`).join(', ')}`;
1153
+ if (request.animation === null) {
1154
+ return { miss: `no ${kept} has a triangle folded over at ${at} — the setup pose is what a fold is read against, so nothing folds there; name --animation <name> --at <s>` };
1155
+ }
1156
+ // Where the grid does fold: the reading `render --folds` prints, over every frame of it, for the kept meshes.
1157
+ restOf(grid.map((frame) => ({ frame })));
1158
+ const keptRest = [...rests.values()].filter((r) => !slotsOut.has(r.slot));
1159
+ const read = grid.map((frame, index) => ({ index, time: frame.time, attachments: (frame.attachments ?? []).filter((a) => !slotsOut.has(a.slot)) }));
1160
+ const folding = foldsOf(keptRest, read).filter((m) => m.worst !== null);
1161
+ const where =
1162
+ folding.length === 0
1163
+ ? `no ${kept} folds on any frame of ${JSON.stringify(request.animation)} at ${request.fps} fps`
1164
+ : `at ${request.fps} fps these do: ${folding
1165
+ .map((m) => `"${m.slot}" on frame(s) [${indexRuns(m.frames.map((f) => f.index))}], worst frame ${m.worst?.index} (t=${m.worst?.time.toFixed(4)}s)`)
1166
+ .join('; ')}`;
1167
+ return { miss: `no ${kept} has a triangle folded over at ${at} of ${JSON.stringify(request.animation)} — ${where}` };
1168
+ }
1169
+
1059
1170
  /** The world box of what `--zoom` names, over every pose of the picture. */
1060
1171
  function zoomBox(
1061
1172
  poser: Poser,
@@ -1128,7 +1239,9 @@ interface Gathered {
1128
1239
  poses: Array<{ frame: Frame; index: number }>;
1129
1240
  /** `render`'s framing of the build — taken only when the picture is framed as a shot. */
1130
1241
  shot: Viewport | null;
1131
- zoom: { box: WorldBox; bone?: string; slot?: string } | null;
1242
+ zoom: { box: WorldBox; bone?: string; slot?: string; folds?: string } | null;
1243
+ /** `--zoom folds` with nothing folded at the times drawn: which frames do — the refusal's words, when no build has a box. */
1244
+ foldsMiss: string | null;
1132
1245
  /** The rest table's topology for every attachment a pose shows, by `slot\0attachment` — taken only when a mesh view is drawn. */
1133
1246
  rest: Map<string, AttachmentRest> | null;
1134
1247
  pages: Map<string, Plate>;
@@ -1138,7 +1251,7 @@ interface Gathered {
1138
1251
  sha: string;
1139
1252
  }
1140
1253
 
1141
- const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density'];
1254
+ const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density', 'folds'];
1142
1255
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
1143
1256
 
1144
1257
  /** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
@@ -1163,18 +1276,35 @@ function gather(tag: 'A' | 'B', candidate: Candidate, texts: InspectTexts, reque
1163
1276
  const lengths = boneLengths(texts.skeletonText);
1164
1277
  const meshViews = request.show.some((w) => MESH_VIEWS.includes(w));
1165
1278
  const needAttachments = request.frame.kind === 'zoom' || meshViews || request.show.includes('slots');
1279
+ const zoomFolds = request.frame.kind === 'zoom' && request.frame.target === ZOOM_FOLDS && plant.zoomBone === undefined;
1166
1280
  const posed = throughPoser(candidate.choice, (poser, roster) => {
1167
- const poses = posesOf(poser, request, needAttachments);
1281
+ if (zoomFolds && (poser.bones.some((b) => b.name === ZOOM_FOLDS) || poser.slots.some((s) => s.name === ZOOM_FOLDS))) {
1282
+ // The word and a name in the rig would mean two boxes; which is meant is the caller's to say, as with a bone and a slot.
1283
+ throw new InspectError(
1284
+ `--zoom ${ZOOM_FOLDS} is the fold reading's box, and this skeleton also has a ${poser.bones.some((b) => b.name === ZOOM_FOLDS) ? 'bone' : 'slot'} named "${ZOOM_FOLDS}"; ` +
1285
+ `for that one write --zoom bone:${ZOOM_FOLDS} or --zoom slot:${ZOOM_FOLDS}, and for the folds a --viewport around the box render --folds prints`,
1286
+ );
1287
+ }
1288
+ const { poses, grid } = posesOf(poser, request, needAttachments);
1168
1289
  const shot =
1169
1290
  request.frame.kind === 'shot' ? framingViewport(poser, request.maxSide, request.skin === undefined ? undefined : { skin: request.skin }, roster) : null;
1170
1291
  if (request.frame.kind === 'shot' && shot === null) {
1171
1292
  throw new InspectError('this skeleton poses no drawable attachment in any animation or in its setup pose — there is nothing to frame; name a box with --viewport');
1172
1293
  }
1173
- const zoom = request.frame.kind === 'zoom' ? zoomBox(poser, poses, plant.zoomBone ?? request.frame.target, lengths) : null;
1294
+ const folds = zoomFolds ? foldsZoomBox(poser, poses, grid, slotsLeftOut(poser.slots, request.slotFilter, plant.ignoreSlotFilter !== true), request, plant) : null;
1295
+ const zoom =
1296
+ folds !== null
1297
+ ? 'box' in folds
1298
+ ? folds
1299
+ : null
1300
+ : request.frame.kind === 'zoom'
1301
+ ? zoomBox(poser, poses, plant.zoomBone ?? request.frame.target, lengths)
1302
+ : null;
1303
+ const foldsMiss = folds !== null && 'miss' in folds ? folds.miss : null;
1174
1304
  const rest = meshViews
1175
1305
  ? new Map(poser.rest(request.skin, poses.map((p) => p.frame.attachments ?? [])).map((r) => [restKey(r.slot, r.attachment), r]))
1176
1306
  : null;
1177
- return { poses, shot, zoom, rest, bones: poser.bones, slots: poser.slots };
1307
+ return { poses, shot, zoom, foldsMiss, rest, bones: poser.bones, slots: poser.slots };
1178
1308
  });
1179
1309
  return {
1180
1310
  tag,
@@ -1236,6 +1366,11 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
1236
1366
  framing = { kind: 'viewport' };
1237
1367
  } else if (request.frame.kind === 'zoom') {
1238
1368
  const found = builds.map((b) => b.zoom).filter((z): z is NonNullable<Gathered['zoom']> => z !== null);
1369
+ if (found.length === 0) {
1370
+ // Only `--zoom folds` finds nothing without having refused already: a bone or slot that names nothing is refused while posing.
1371
+ const misses = builds.map((b) => (compare ? `${b.tag === 'A' ? 'the --candidate build' : 'the --compare build'}: ${b.foldsMiss ?? ''}` : (b.foldsMiss ?? '')));
1372
+ throw new InspectError(`--zoom ${request.frame.target}: nothing to frame — ${misses.join('; ')}`);
1373
+ }
1239
1374
  const [x0, y0, x1, y1] = found.reduce<WorldBox>(
1240
1375
  (u, z) => [Math.min(u[0], z.box[0]), Math.min(u[1], z.box[1]), Math.max(u[2], z.box[2]), Math.max(u[3], z.box[3])],
1241
1376
  [Infinity, Infinity, -Infinity, -Infinity],
@@ -1250,7 +1385,14 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
1250
1385
  }
1251
1386
  viewport = viewportFor(x0 - pad, y0 - pad, x1 + pad, y1 + pad, request.maxSide);
1252
1387
  const first = found[0];
1253
- framing = { kind: 'zoom', ...(first?.bone === undefined ? {} : { bone: first.bone }), ...(first?.slot === undefined ? {} : { slot: first.slot }), pad, padFrom: request.frame.pad === null ? 'default' : 'stated' };
1388
+ framing = {
1389
+ kind: 'zoom',
1390
+ ...(first?.bone === undefined ? {} : { bone: first.bone }),
1391
+ ...(first?.slot === undefined ? {} : { slot: first.slot }),
1392
+ ...(first?.folds === undefined ? {} : { folds: first.folds }),
1393
+ pad,
1394
+ padFrom: request.frame.pad === null ? 'default' : 'stated',
1395
+ };
1254
1396
  } else {
1255
1397
  const shots = builds.map((b) => b.shot).filter((v): v is Viewport => v !== null);
1256
1398
  if (!compare) {
@@ -1357,6 +1499,16 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
1357
1499
  },
1358
1500
  }
1359
1501
  : {}),
1502
+ ...(request.show.includes('folds')
1503
+ ? {
1504
+ folds: {
1505
+ rgba: [...FOLD_COLOUR.rgba],
1506
+ meaning: FOLD_COLOUR.meaning,
1507
+ degenerateShare: DEGENERATE_SHARE,
1508
+ ...FOLDS_READING,
1509
+ },
1510
+ }
1511
+ : {}),
1360
1512
  ...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
1361
1513
  coordinates: {
1362
1514
  world: 'spine world: y up, world units',
@@ -1521,6 +1673,50 @@ function drawTile(
1521
1673
  }
1522
1674
  legendTile.meshes.push(entry);
1523
1675
  }
1676
+ // ── folds: each kept mesh's turned-over triangles filled, over the heat and under the edges (issue #1383) ──
1677
+ if (request.show.includes('folds')) {
1678
+ legendTile.folds = [];
1679
+ for (const shown of frame.attachments ?? []) {
1680
+ if (slotsOut.has(shown.slot)) continue;
1681
+ const rest = build.rest.get(restKey(shown.slot, shown.attachment));
1682
+ if (rest === undefined || rest.kind !== 'mesh') continue;
1683
+ const setup = setupReading(rest);
1684
+ const folded = foldedAt(rest, setup, shown.vertices);
1685
+ const { degenerate, inverted } = setup.faults;
1686
+ const fill = plant.foldsFillAll ? Array.from({ length: rest.triangles.length / 3 }, (_, i) => i) : folded;
1687
+ const at = (v: number): [number, number] => {
1688
+ const [px, py] = toPixel(shown.vertices[2 * v], shown.vertices[2 * v + 1]);
1689
+ return [tx(px), ty(py)];
1690
+ };
1691
+ 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);
1692
+ if (folded.length === 0 && degenerate.length === 0 && inverted.length === 0) continue;
1693
+ const extent = foldExtent(rest, shown.vertices, folded, setupArea(rest), plant.foldPlant);
1694
+ legendTile.folds.push({
1695
+ slot: shown.slot,
1696
+ attachment: shown.attachment,
1697
+ triangleCount: rest.triangles.length / 3,
1698
+ folded,
1699
+ degenerate,
1700
+ inverted,
1701
+ box: extent === null ? null : extent.box,
1702
+ area: extent === null ? 0 : extent.area,
1703
+ share: extent === null ? 0 : extent.share,
1704
+ });
1705
+ }
1706
+ if (first) {
1707
+ const count = legendTile.folds.reduce((n, f) => n + f.folded.length, 0);
1708
+ const meshes = legendTile.folds.filter((f) => f.folded.length > 0);
1709
+ notes.push(
1710
+ count === 0
1711
+ ? `folds: no mesh${request.slotFilter === undefined ? '' : ' the filter keeps'} has a triangle folded over at this pose — nothing filled`
1712
+ : `folds: ${count} triangle(s) folded over across ${meshes.length} mesh(es) — ${meshes
1713
+ .map((f) => `"${f.slot}" [${f.folded.join(', ')}]${f.box === null ? '' : ` ${extentText({ box: f.box, area: f.area, share: f.share })}`}`)
1714
+ .join(', ')} — every one is in the legend`,
1715
+ );
1716
+ const faulted = legendTile.folds.filter((f) => f.degenerate.length > 0 || f.inverted.length > 0);
1717
+ 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(', ')}`);
1718
+ }
1719
+ }
1524
1720
  for (const entry of legendTile.meshes) {
1525
1721
  if (entry.kind !== 'mesh') continue;
1526
1722
  anyMesh = true;