spine-rigc 2.46.0 → 2.47.1

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
@@ -414,10 +414,10 @@ and over it any of `bones` (origin to tip, a bone of length 0 marked as one),
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
416
  `folds` (each mesh triangle turned over against the setup pose, filled — `render --folds`
417
- names them per animation and frame), `slots` (the draw order at the pose), `grid` (in world units) and `declared`
417
+ names them per animation and frame, with where and how big), `slots` (the draw order at the pose), `grid` (in world units) and `declared`
418
418
  (each `invariants.axes` line and the bones held to it), at the setup pose, at
419
419
  `--animation <name> --at <s[,s…]>` — one picture, or a sheet — or zoomed with
420
- `--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
421
421
  bones by glob, `--overlay-slot`/`--hide-overlay-slot` the slots whose mesh, hull,
422
422
  weights, density, folds and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
423
423
  transform, to see what a change moved. Beside the PNG it writes a legend:
@@ -601,7 +601,7 @@ commands take it and what its default is.
601
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)) |
602
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 |
603
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 |
604
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles |
604
+ | `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 |
605
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` |
606
606
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
607
607
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -243,9 +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. 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
+ | `--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** |
249
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 |
250
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` |
251
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 |
@@ -7297,9 +7297,25 @@ and nothing in a report says so. `--folds` reads it off the frames `render` draw
7297
7297
  bun cli.ts render --candidate path/to/spine --folds [--animation …] [--skin …] [--fps 12]
7298
7298
  # rigc render
7299
7299
  # .. show 321 frame(s), 16.000s + contact.png + folds.json (2 of 11 mesh(es) fold) -> …/show@20fps
7300
- # .. folds "show": mesh "hair_long" (slot "hair_long") folds on 88 frame(s) [33-54, 113-134, 194-215, 273-294] at 20 fps; worst frame 40 (t=2.0000s): 3 of 459 triangle(s) [137, 139, 140]; 3 triangle(s) fold on some frame [137, 139, 140]
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
7301
  ```
7302
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
+
7303
7319
  **What folded means.** A mesh triangle is folded at a frame when its orientation
7304
7320
  there — the sign of its signed area in world space — is the opposite of its
7305
7321
  orientation at the setup pose. The setup pose is `rest`'s (§8.2): the setup pose's
@@ -7329,9 +7345,13 @@ on a `setup` line the first time a set shows its mesh, and never counted:
7329
7345
  `skin` (absent when none was set), `fps`, `reading` (these definitions, stated in
7330
7346
  the file), and `meshes[]` — every mesh any frame of the set shows, in `rest`'s
7331
7347
  order — each with `slot`, `attachment`, `triangleCount`, `setup` (`degenerate` and
7332
- `inverted`, triangle indices), `frames[]` (only the frames with a folded triangle:
7333
- `index`, `time`, `triangles`), `worst` (the frame with the most, the earliest on a
7334
- tie; `null` when it never folds) and `folded` (every triangle folded on some frame).
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`.
7335
7355
  A triangle is named by its index `i` in `rest`'s `triangles`: vertices
7336
7356
  `triangles[3i]`, `triangles[3i+1]`, `triangles[3i+2]`. A mesh that never folds is in
7337
7357
  the file and on no printed line.
@@ -7346,9 +7366,11 @@ reading derived from it is computed from it — `foldsOf` over `geometry.json`'s
7346
7366
  shows.
7347
7367
 
7348
7368
  **A reading, not a gate.** Whether a fold is acceptable is yours: a tassel that swings
7349
- past 90° folds honestly. To look at one, draw the worst frame:
7350
- `inspect --show art,folds,mesh --animation <name> --at <worst time>` fills the folded
7351
- triangles at that pose and lists them in its legend (§13).
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).
7352
7374
 
7353
7375
  ---
7354
7376
 
@@ -10176,7 +10198,7 @@ pixel (`IN09`).
10176
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 |
10177
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 |
10178
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 |
10179
- | Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --dim --animation <name> --at <the worst frame's time>` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles. The view fills those triangles at the pose drawn — every pixel whose centre a folded triangle holds — in the colour the legend's `folds` states, and the legend's `folds[]` names, per mesh, the folded triangles by index and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across: `--viewport` around the vertices the legend's `meshes[]` gives is how it becomes visible. A reading, not a verdict |
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 |
10180
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 |
10181
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 |
10182
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 |
@@ -10199,7 +10221,16 @@ pixel (`IN09`).
10199
10221
  vertices over every time drawn, padded by `<pad>` world units on every side
10200
10222
  (default a quarter of the box's longer side). A name that is both a bone and a slot
10201
10223
  is refused until it is written `bone:<name>` or `slot:<name>`; a bone of length 0
10202
- 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`.
10203
10234
  - **Two builds** — `--compare <dir | skeleton.json>`. The second build is loaded as
10204
10235
  the first is (its own poser, chosen by what it carries, on a `poser B:` line),
10205
10236
  posed at the same times, and drawn beside it: one time is two halves side by side,
@@ -10276,7 +10307,10 @@ pixel (`IN09`).
10276
10307
  `why`; it is not painted. With `folds`, `folds[]`: every kept mesh with a
10277
10308
  triangle folded at this tile's pose or a setup fault, in draw order — `slot`,
10278
10309
  `attachment`, `triangleCount`, `folded` (the triangles filled, by index),
10279
- `degenerate` and `inverted` (the setup pose's, never counted); empty when none.
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.
10280
10314
  With `mesh`, `hull`, `weights`, `density` or `folds`,
10281
10315
  `meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
10282
10316
  `vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
@@ -10287,8 +10321,8 @@ pixel (`IN09`).
10287
10321
  `build` — `A` or `B`.
10288
10322
  - `colours` — every colour the picture draws, as RGBA, with what it means.
10289
10323
  - `poser`, `show`, `dim`, `skin`, `animation`, `fps` and `framing` — what was drawn
10290
- and how it was framed (`shot`, `zoom` with the bone or slot, the pad and whether it
10291
- 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`).
10292
10326
  - `weights` — with `weights:<bone>`: the bone, the ramp's stops as RGBA, the floor
10293
10327
  below which nothing is painted, and how a pixel's weight is read.
10294
10328
  - `density` — with `density`: the ramp's stops (`ratio`, RGBA), the `scale` it is
@@ -10296,7 +10330,8 @@ pixel (`IN09`).
10296
10330
  the figure's `totals` and the `ratio` are and how a pixel is painted.
10297
10331
  - `folds` — with `folds`: the colour a folded triangle is filled with, as RGBA, and
10298
10332
  what it means; `degenerateShare` (2⁻³⁰, §8.3); and in words what `folded`,
10299
- `degenerate` and `inverted` are and how a triangle is named by `index`. Its colour
10333
+ `degenerate` and `inverted` are, how a triangle is named by `index`, and what
10334
+ `box` and `area` are. Its colour
10300
10335
  is stated here rather than in `colours`, which every legend lists whole, so a legend
10301
10336
  drawn without `folds` is the bytes it was before the view existed.
10302
10337
  - `boneFilter` — `--bone` and `--hide-bone` as given.
@@ -10319,9 +10354,11 @@ beside it whose `spine.sha256` is its own — a Spine export states no rig invar
10319
10354
  `weights` with no bone or with a bone the build lacks (the bones listed), a
10320
10355
  `--bone`/`--hide-bone` pattern that matches no bone (the bones listed), an
10321
10356
  `--overlay-slot`/`--hide-overlay-slot` pattern that matches no slot (the slots
10322
- 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
10323
10359
  `--compare`, any of these that holds of the second build, said as `the --compare
10324
- 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.
10325
10362
  A rig that declares no `invariants.axes` is not refused: the picture draws nothing
10326
10363
  for `declared` and the run says so on a line of its own.
10327
10364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.46.0",
3
+ "version": "2.47.1",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/shared.ts CHANGED
@@ -999,7 +999,8 @@ const FLAG_MEANINGS: Record<string, string> = {
999
999
  'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
1000
1000
  folds:
1001
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. ' +
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). ' +
1003
1004
  'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
1004
1005
  'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
1005
1006
  poser:
@@ -1089,7 +1090,9 @@ const FLAG_MEANINGS: Record<string, string> = {
1089
1090
  zoom:
1090
1091
  'frame the picture to one bone (origin to tip) or one slot (its attachment\'s vertices) over every time drawn, padded ' +
1091
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 ' +
1092
- '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',
1093
1096
  bone:
1094
1097
  "draw only the bones these globs match (`*` any run of characters, `?` one, the whole name; comma-separated) — on `inspect`, " +
1095
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 " +
@@ -1209,7 +1212,7 @@ const FLAG_VALUES: Record<string, string> = {
1209
1212
  max: '<px>',
1210
1213
  show: '<view[,view…]>',
1211
1214
  at: '<s[,s…]>',
1212
- zoom: '<name>[:<pad>]',
1215
+ zoom: '<name|folds>[:<pad>]',
1213
1216
  bone: '<glob[,glob…]>',
1214
1217
  'hide-bone': '<glob[,glob…]>',
1215
1218
  'overlay-slot': '<glob[,glob…]>',
@@ -1600,7 +1603,7 @@ export const COMMANDS: CommandDoc[] = [
1600
1603
  usage: [
1601
1604
  'rigc inspect --candidate <dir | skeleton.json> [--show art,bones,…] [--dim] [--max 256] [--poser core|spine] [--out inspect.png] (the setup pose)',
1602
1605
  'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
1603
- '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)',
1604
1607
  "rigc inspect … --bone 'bang_*' --hide-bone '*_ctl' (which bones bones, axes and names draw)",
1605
1608
  'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights, density, folds and slots draw; the art stays whole)',
1606
1609
  'rigc inspect … --compare <dir | skeleton.json> (a second build beside the first, on one transform)',
package/src/core/raw.ts CHANGED
@@ -496,13 +496,71 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
496
496
  * A refusal is thrown from the pose it is met at, after `visit` has seen the
497
497
  * poses before it. `clock`, when given, is what each step moves the physics
498
498
  * clock by in place of the step itself — the render's offline schedule
499
- * (`physicsClockStep` in `src/render_core.ts`, issue #1380); the track time and
499
+ * (`physicsClockStep` below, issue #1380); the track time and
500
500
  * the animation time are the steps' running sum either way.
501
501
  */
502
502
  export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number): void {
503
503
  walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit);
504
504
  }
505
505
 
506
+ /**
507
+ * How much faster than the frames a render's physics clock runs — and, since issue
508
+ * #1389, a mesh comparison's (`src/meshcompare.ts`, `comparisonWalk`): each frame
509
+ * moves the skeleton's clock — the time a physics constraint integrates — by
510
+ * `physicsClockStep(1 / fps)`, the frame's step and `2⁻³⁰` of it more, while
511
+ * the animation is applied at the running sum of the frame steps as before
512
+ * (issue #1380).
513
+ *
514
+ * ⭐ **Why a render's physics clock is not the frame clock.** A physics
515
+ * constraint keeps the time it has not yet integrated (`remaining`), takes
516
+ * whole steps of `1 / fps` (its own `fps`, 60 by default) while at least one
517
+ * step remains, and shows `offset − lag·(1 − remaining / step)`, `lag` being
518
+ * everything this update's steps moved (`src/core/constraints_physics.ts`,
519
+ * *The step*). Whenever a frame lasts a whole number of physics steps — 12,
520
+ * 20, 30 and 60 fps against 60 Hz, and every second frame at 24 — the time
521
+ * left at the frame's end is zero in exact arithmetic, and in doubles it is
522
+ * either a few ulps above zero or a few ulps short of one whole step. Which of
523
+ * the two is decided by rounding, not by the motion: the shown weight is 1 or
524
+ * 0, so the spring is drawn one frame late or on time, and it changes regime
525
+ * on a single frame. Measured on the public trailer (`show`, 20 fps, 321
526
+ * frames): every spring jumps at frames 5, 12, 45 and 188 — the four frames
527
+ * that arithmetic alone predicts — and the loop's start (frame 80, on time)
528
+ * and end (frame 320, a frame late, which is what exact arithmetic draws)
529
+ * differ by 31.70 units of vertex and 10.37 of bone
530
+ * while every keyed bone agrees to 1.3e-12. spine-core 4.3.13 stepped the
531
+ * same way draws the same frames to the bit: this is the runtime under a
532
+ * fixed offline step, not a defect of the core's port, which is why the fix
533
+ * is the render's schedule and the physics is untouched.
534
+ *
535
+ * Running the clock `2⁻³⁰` fast puts the time left a little ABOVE every
536
+ * whole number of steps instead of on it, by `2⁻³⁰ · t` after `t` seconds:
537
+ * 1.5e-8 s at 16 s, against a rounding error of at most a few ulps of the
538
+ * clock (3.6e-15 s at 16 s) — so every tie resolves the way exact arithmetic
539
+ * resolves it, a step taken, and the shown weight sits at 1 − 9e-7 rather
540
+ * than flipping. It stays below the smallest gap a step boundary can fall
541
+ * into for 7.8e5 s even at 23 fps against 60 Hz (a gap of 1/1380 s), and a
542
+ * physics `reset` key, which restarts the time left from zero, is resolved
543
+ * the same way on every frame after it, because the lead is taken on every
544
+ * frame rather than once (read off the clock arithmetic with a reset at frame
545
+ * 100, 16 s at the five rates: no tie; no tree probe keys a reset under the
546
+ * render's clock). A pose stays a function of its frame
547
+ * index alone: the lead does not depend on how many frames are asked for.
548
+ *
549
+ * Rejected, measured over the same arithmetic (16 s at 12, 20, 24, 25, 30 and
550
+ * 60 fps): the clock as `i / fps` rather than a running sum (90 frames still
551
+ * flipped at 20 fps); a one-time lead of half a step (114 flipped at 24 fps,
552
+ * where half a step lands on the other tie); a one-time lead of any size,
553
+ * which a `reset` key cancels; and snapping `remaining` inside the physics,
554
+ * which changes the runtime's physics rather than the render's schedule and
555
+ * which spine-core's poser could not follow.
556
+ */
557
+ export const PHYSICS_CLOCK_LEAD = 2 ** -30;
558
+
559
+ /** What one frame of `step` seconds moves a render's physics clock by — `PHYSICS_CLOCK_LEAD`; both render posers and the mesh comparison step with it. */
560
+ export function physicsClockStep(step: number): number {
561
+ return step + step * PHYSICS_CLOCK_LEAD;
562
+ }
563
+
506
564
  /**
507
565
  * The animation time a looping track applies its animation at (`./walk.ts`,
508
566
  * *The time*): the track time wrapped by the duration, and 0 over a duration
package/src/folds.ts CHANGED
@@ -36,6 +36,12 @@
36
36
  * A mesh mirrored whole by a negative scale reads every triangle as folded: that
37
37
  * is what "opposite to the setup pose" is, and the count says which it was.
38
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
+ *
39
45
  * Pure: no clock, no randomness, no runtime. The posed vertices are handed in by
40
46
  * whichever poser drew the frames (rigc's core for a rigc build).
41
47
  */
@@ -98,8 +104,21 @@ export interface SetupFaults {
98
104
  inverted: number[];
99
105
  }
100
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
+
101
120
  /** One frame on which a mesh has a folded triangle. */
102
- export interface FoldedFrame {
121
+ export interface FoldedFrame extends FoldExtent {
103
122
  index: number;
104
123
  time: number;
105
124
  /** The folded triangles, by index (`triangles[3i..3i+2]` of the rest table), ascending. */
@@ -112,6 +131,8 @@ export interface MeshFolds {
112
131
  attachment: string;
113
132
  /** How many triangles the mesh has. */
114
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;
115
136
  setup: SetupFaults;
116
137
  /** Every frame with a folded triangle, in frame order; empty when the mesh never folds. */
117
138
  frames: FoldedFrame[];
@@ -129,7 +150,7 @@ export interface FoldsFile {
129
150
  skin?: string;
130
151
  fps: number;
131
152
  /** What the numbers mean, stated in the file so a reader does not need this module. */
132
- reading: { folded: string; degenerate: string; inverted: string; index: string };
153
+ reading: { folded: string; degenerate: string; inverted: string; index: string; box: string; area: string };
133
154
  /** Every mesh any frame of the set shows, in the order the rest table lists them. */
134
155
  meshes: MeshFolds[];
135
156
  }
@@ -145,6 +166,12 @@ export interface FoldPlant {
145
166
  againstPreviousFrame?: boolean;
146
167
  /** Count the setup pose's degenerate and inverted triangles like any other. */
147
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
+ /** Read degeneracy against this share of the largest coordinate rather than `DEGENERATE_SHARE` — 0 drops the line (issue #1390). */
174
+ degenerateShare?: number;
148
175
  }
149
176
 
150
177
  /** Twice the signed area of triangle `t` in `xy` (`x, y` per vertex). */
@@ -176,12 +203,13 @@ function magnitude(xy: readonly number[]): number {
176
203
 
177
204
  /**
178
205
  * The sign of triangle `t`'s area in `xy`: +1, −1, or 0 when it is degenerate
179
- * — its height over its longest edge at most `DEGENERATE_SHARE` of `scale`.
206
+ * — its height over its longest edge at most `share` (`DEGENERATE_SHARE`
207
+ * unplanted) of `scale`.
180
208
  */
181
- function orientation(xy: readonly number[], triangles: readonly number[], t: number, scale: number): -1 | 0 | 1 {
209
+ function orientation(xy: readonly number[], triangles: readonly number[], t: number, scale: number, share: number): -1 | 0 | 1 {
182
210
  const twice = cross2(xy, triangles, t);
183
211
  const edge = longestEdge(xy, triangles, t);
184
- if (edge === 0 || Math.abs(twice) / edge <= DEGENERATE_SHARE * scale) return 0;
212
+ if (edge === 0 || Math.abs(twice) / edge <= share * scale) return 0;
185
213
  return twice > 0 ? 1 : -1;
186
214
  }
187
215
 
@@ -195,6 +223,7 @@ export interface SetupReading {
195
223
  /** The setup reading of one mesh — see the module comment for what degenerate and inverted mean. */
196
224
  export function setupReading(rest: FoldRest, plant: FoldPlant = {}): SetupReading {
197
225
  const count = Math.floor(rest.triangles.length / 3);
226
+ const share = plant.degenerateShare ?? DEGENERATE_SHARE;
198
227
  const signs = new Int8Array(count);
199
228
  const world: number[] = [];
200
229
  const uvSigns: number[] = [];
@@ -204,8 +233,8 @@ export function setupReading(rest: FoldRest, plant: FoldPlant = {}): SetupReadin
204
233
  let sense = 0;
205
234
  const degenerate: number[] = [];
206
235
  for (let t = 0; t < count; t++) {
207
- const w = orientation(rest.vertices, rest.triangles, t, worldScale);
208
- const u = uvs.length === rest.vertices.length ? orientation(uvs, rest.triangles, t, uvScale) : 1;
236
+ const w = orientation(rest.vertices, rest.triangles, t, worldScale, share);
237
+ const u = uvs.length === rest.vertices.length ? orientation(uvs, rest.triangles, t, uvScale, share) : 1;
209
238
  world.push(w);
210
239
  uvSigns.push(u);
211
240
  if (w === 0 || u === 0) {
@@ -238,17 +267,61 @@ export function foldedAt(rest: FoldRest, setup: SetupReading, vertices: readonly
238
267
  );
239
268
  }
240
269
  const scale = magnitude(vertices);
270
+ const share = plant.degenerateShare ?? DEGENERATE_SHARE;
241
271
  const out: number[] = [];
242
272
  for (let t = 0; t < setup.signs.length; t++) {
243
273
  const before = setup.signs[t];
244
274
  if (before === 0) continue;
245
- const now = orientation(vertices, rest.triangles, t, scale);
275
+ const now = orientation(vertices, rest.triangles, t, scale, share);
246
276
  if (now === 0) continue;
247
277
  if (plant.signFlipped ? now === before : now === -before) out.push(t);
248
278
  }
249
279
  return out;
250
280
  }
251
281
 
282
+ /** A mesh's area at the setup pose: every triangle's unsigned area in the rest table, summed in index order. */
283
+ export function setupArea(rest: FoldRest): number {
284
+ let area = 0;
285
+ for (let t = 0; t < Math.floor(rest.triangles.length / 3); t++) area += Math.abs(cross2(rest.vertices, rest.triangles, t)) / 2;
286
+ return area;
287
+ }
288
+
289
+ /**
290
+ * Where `triangles` of one mesh are at one pose and how big they are: the world
291
+ * box of their posed vertices, and their summed setup-pose area, alone and as a
292
+ * share of `meshArea` (`setupArea(rest)`). `null` for no triangle.
293
+ *
294
+ * ⭐ The box is the pose's and the area is the setup pose's, on purpose. The box
295
+ * answers "where is it in this frame" — it is what a picture is framed to. The
296
+ * area answers "how much of the art is turned over", and a folded triangle's
297
+ * posed area cannot: it passes through 0 at the frame the triangle turns, so one
298
+ * triangle reads a different size on every frame it is folded and none of them
299
+ * says how much art it carries.
300
+ */
301
+ export function foldExtent(
302
+ rest: FoldRest,
303
+ vertices: readonly number[],
304
+ triangles: readonly number[],
305
+ meshArea: number,
306
+ plant: FoldPlant = {},
307
+ ): FoldExtent | null {
308
+ if (triangles.length === 0) return null;
309
+ const box: FoldBox = [Infinity, Infinity, -Infinity, -Infinity];
310
+ const take = (v: number): void => {
311
+ box[0] = Math.min(box[0], vertices[2 * v]);
312
+ box[1] = Math.min(box[1], vertices[2 * v + 1]);
313
+ box[2] = Math.max(box[2], vertices[2 * v]);
314
+ box[3] = Math.max(box[3], vertices[2 * v + 1]);
315
+ };
316
+ if (plant.boxWholeMesh) for (let v = 0; v < vertices.length / 2; v++) take(v);
317
+ let area = 0;
318
+ for (const t of triangles) {
319
+ if (!plant.boxWholeMesh) for (let k = 0; k < 3; k++) take(rest.triangles[3 * t + k]);
320
+ area += Math.abs(cross2(plant.areaPosed ? vertices : rest.vertices, rest.triangles, t)) / 2;
321
+ }
322
+ return { box, area, share: meshArea > 0 ? area / meshArea : 0 };
323
+ }
324
+
252
325
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
253
326
 
254
327
  /**
@@ -264,6 +337,7 @@ export function foldsOf(rest: readonly FoldRest[], frames: readonly FoldFrame[],
264
337
  for (const r of rest) {
265
338
  if (r.kind !== 'mesh' || !shown.has(restKey(r.slot, r.attachment))) continue;
266
339
  let setup = setupReading(r, plant);
340
+ const area = setupArea(r);
267
341
  const folded: FoldedFrame[] = [];
268
342
  const union = new Set<number>();
269
343
  for (const frame of frames) {
@@ -274,9 +348,10 @@ export function foldsOf(rest: readonly FoldRest[], frames: readonly FoldFrame[],
274
348
  const signs = Int8Array.from(setup.signs, (sign, t) => (triangles.includes(t) ? -sign : sign));
275
349
  setup = { ...setup, signs };
276
350
  }
277
- if (triangles.length === 0) continue;
351
+ const extent = foldExtent(r, posed.vertices, triangles, area, plant);
352
+ if (extent === null) continue;
278
353
  for (const t of triangles) union.add(t);
279
- folded.push({ index: frame.index, time: frame.time, triangles });
354
+ folded.push({ index: frame.index, time: frame.time, triangles, ...extent });
280
355
  }
281
356
  let worst: FoldedFrame | null = null;
282
357
  for (const f of folded) if (worst === null || f.triangles.length > worst.triangles.length) worst = f;
@@ -284,6 +359,7 @@ export function foldsOf(rest: readonly FoldRest[], frames: readonly FoldFrame[],
284
359
  slot: r.slot,
285
360
  attachment: r.attachment,
286
361
  triangleCount: setup.signs.length,
362
+ area,
287
363
  setup: setup.faults,
288
364
  frames: folded,
289
365
  worst,
@@ -301,6 +377,9 @@ export const FOLDS_READING: FoldsFile['reading'] = {
301
377
  inverted:
302
378
  "a triangle drawn mirrored against the rest of its mesh at the setup pose: the sign of world area x UV area is the opposite of the mesh's own sense (that product summed over its triangles, weighted by world area). Setup faults are listed once and never counted as folds",
303
379
  index: 'a triangle is named by its index i in the rest table: vertices triangles[3i], triangles[3i+1], triangles[3i+2]',
380
+ 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",
381
+ area:
382
+ "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",
304
383
  };
305
384
 
306
385
  /** The file for one frame set. */
@@ -325,10 +404,27 @@ export function indexRuns(indices: readonly number[]): string {
325
404
  return runs.join(', ');
326
405
  }
327
406
 
407
+ /** `x` rounded to hundredths away from the box's inside, so the printed box holds every vertex the exact one does. */
408
+ const outward = (x: number, up: boolean): string => ((up ? Math.ceil(x * 100) : Math.floor(x * 100)) / 100).toFixed(2);
409
+
410
+ /**
411
+ * Where and how big, as the fold line and the inspect note print it:
412
+ * `in world box x [x0, x1] y [y0, y1] (w × h), setup-pose area A units² = S% of the mesh's`.
413
+ * The box is rounded outward to hundredths; the file states it exactly.
414
+ */
415
+ export function extentText(extent: FoldExtent): string {
416
+ const [x0, y0, x1, y1] = extent.box;
417
+ return (
418
+ `in world box x [${outward(x0, false)}, ${outward(x1, true)}] y [${outward(y0, false)}, ${outward(y1, true)}] ` +
419
+ `(${(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`
420
+ );
421
+ }
422
+
328
423
  /**
329
424
  * The printed line for a mesh that folds, or `null` for one that never does.
330
425
  * It names the animation, the mesh and its slot, the frames, the worst frame's
331
- * count and its triangles, and every triangle folded on any frame.
426
+ * count, its triangles, where they are and how big, and every triangle folded
427
+ * on any frame.
332
428
  */
333
429
  export function foldLine(animation: string | null, fps: number, mesh: MeshFolds): string | null {
334
430
  const worst = mesh.worst;
@@ -337,7 +433,8 @@ export function foldLine(animation: string | null, fps: number, mesh: MeshFolds)
337
433
  const frames = mesh.frames.map((f) => f.index);
338
434
  return (
339
435
  `${where}: mesh "${mesh.attachment}" (slot "${mesh.slot}") folds on ${frames.length} frame(s) [${indexRuns(frames)}] at ${fps} fps; ` +
340
- `worst frame ${worst.index} (t=${worst.time.toFixed(4)}s): ${worst.triangles.length} of ${mesh.triangleCount} triangle(s) [${worst.triangles.join(', ')}]; ` +
436
+ `worst frame ${worst.index} (t=${worst.time.toFixed(4)}s): ${worst.triangles.length} of ${mesh.triangleCount} triangle(s) [${worst.triangles.join(', ')}] ` +
437
+ `${extentText(worst)}; ` +
341
438
  `${mesh.folded.length} triangle(s) fold on some frame [${mesh.folded.join(', ')}]`
342
439
  );
343
440
  }
package/src/inspect.ts CHANGED
@@ -45,7 +45,8 @@
45
45
  * ⭐ **A fold is read, never judged** (issue #1383). `folds` fills each mesh
46
46
  * triangle that is turned over at the pose drawn — its world orientation the
47
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.
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).
49
50
  *
50
51
  * Links nothing from the runtime: the poser is handed in.
51
52
  */
@@ -76,7 +77,7 @@ import {
76
77
  viewportFor,
77
78
  } from './render_shared.ts';
78
79
  import { hypot2 } from './numeric.ts';
79
- import { DEGENERATE_SHARE, foldedAt, FOLDS_READING, setupReading } from './folds.ts';
80
+ import { DEGENERATE_SHARE, extentText, type FoldBox, foldedAt, foldExtent, type FoldPlant, FOLDS_READING, foldsOf, indexRuns, setupArea, setupReading } from './folds.ts';
80
81
 
81
82
  /** The legend's format tag. */
82
83
  export const INSPECT_SPEC = 'rigc-inspect/1';
@@ -313,6 +314,8 @@ export interface InspectPlant {
313
314
  densityTotalsFiltered?: boolean;
314
315
  /** Folds: fill every triangle of every kept mesh, folded or not. */
315
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;
316
319
  }
317
320
 
318
321
  /** A point in both spaces. */
@@ -458,6 +461,12 @@ export interface LegendFold {
458
461
  degenerate: number[];
459
462
  /** Drawn mirrored against the rest of the mesh at the setup pose, never counted — by index. */
460
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;
461
470
  }
462
471
 
463
472
  export interface LegendTile {
@@ -497,7 +506,7 @@ export interface InspectLegend {
497
506
  fps: number;
498
507
  framing:
499
508
  | { kind: 'shot'; note: string }
500
- | { 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' }
501
510
  | { kind: 'viewport' };
502
511
  /** `--bone` / `--hide-bone` as given — each bone they leave out says so in its `undrawn`. */
503
512
  boneFilter?: { include: string[] | null; exclude: string[] | null };
@@ -508,7 +517,7 @@ export interface InspectLegend {
508
517
  /** With `density`: the ramp, its scale, and how area, totals and ratio are read. */
509
518
  density?: { ramp: Array<{ ratio: number; rgba: number[] }>; scale: string; area: string; totals: string; ratio: string; reading: string };
510
519
  /** With `folds`: the colour a folded triangle is filled with, the degenerate share, and what folded, degenerate and inverted mean. */
511
- folds?: { rgba: number[]; meaning: string; degenerateShare: number; folded: string; degenerate: string; inverted: string; index: string };
520
+ folds?: { rgba: number[]; meaning: string; degenerateShare: number; folded: string; degenerate: string; inverted: string; index: string; box: string; area: string };
512
521
  /** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
513
522
  builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
514
523
  coordinates: { world: string; image: string; toPixel: string; toWorld: string };
@@ -598,7 +607,7 @@ export function readTimes(raw: string | undefined): number[] {
598
607
  return times;
599
608
  }
600
609
 
601
- /** `--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. */
602
611
  export function readZoom(raw: string): { target: string; pad: number | null } {
603
612
  const colon = raw.lastIndexOf(':');
604
613
  if (colon > 0) {
@@ -609,7 +618,7 @@ export function readZoom(raw: string): { target: string; pad: number | null } {
609
618
  return { target: raw.slice(0, colon), pad };
610
619
  }
611
620
  }
612
- 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>]');
613
622
  return { target: raw, pad: null };
614
623
  }
615
624
 
@@ -1075,10 +1084,17 @@ function slotsLeftOut(slots: ReadonlyArray<{ name: string }>, filter: SlotFilter
1075
1084
  // the view
1076
1085
  // ---------------------------------------------------------------------------
1077
1086
 
1078
- /** The poses one picture is of: the frames, in the order the request named their times. */
1079
- 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[] } {
1080
1093
  const opts: PoseOptions = { bones: true, ...(needAttachments ? { geometry: true } : {}), ...(request.skin === undefined ? {} : { skin: request.skin }) };
1081
- 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
+ }
1082
1098
  const animation = poser.animations.find((a) => a.name === request.animation);
1083
1099
  if (animation === undefined) {
1084
1100
  throw new InspectError(`no animation ${JSON.stringify(request.animation)} in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`);
@@ -1090,11 +1106,67 @@ function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean
1090
1106
  seen.add(index);
1091
1107
  }
1092
1108
  const frames = sampleAnimation(poser, animation.name, request.fps, opts);
1093
- return indices.map((index) => ({ frame: frames[index], index }));
1109
+ return { poses: indices.map((index) => ({ frame: frames[index], index })), grid: frames };
1094
1110
  }
1095
1111
 
1096
1112
  type WorldBox = [number, number, number, number];
1097
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
+
1098
1170
  /** The world box of what `--zoom` names, over every pose of the picture. */
1099
1171
  function zoomBox(
1100
1172
  poser: Poser,
@@ -1167,7 +1239,9 @@ interface Gathered {
1167
1239
  poses: Array<{ frame: Frame; index: number }>;
1168
1240
  /** `render`'s framing of the build — taken only when the picture is framed as a shot. */
1169
1241
  shot: Viewport | null;
1170
- 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;
1171
1245
  /** The rest table's topology for every attachment a pose shows, by `slot\0attachment` — taken only when a mesh view is drawn. */
1172
1246
  rest: Map<string, AttachmentRest> | null;
1173
1247
  pages: Map<string, Plate>;
@@ -1202,18 +1276,35 @@ function gather(tag: 'A' | 'B', candidate: Candidate, texts: InspectTexts, reque
1202
1276
  const lengths = boneLengths(texts.skeletonText);
1203
1277
  const meshViews = request.show.some((w) => MESH_VIEWS.includes(w));
1204
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;
1205
1280
  const posed = throughPoser(candidate.choice, (poser, roster) => {
1206
- 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);
1207
1289
  const shot =
1208
1290
  request.frame.kind === 'shot' ? framingViewport(poser, request.maxSide, request.skin === undefined ? undefined : { skin: request.skin }, roster) : null;
1209
1291
  if (request.frame.kind === 'shot' && shot === null) {
1210
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');
1211
1293
  }
1212
- 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;
1213
1304
  const rest = meshViews
1214
1305
  ? new Map(poser.rest(request.skin, poses.map((p) => p.frame.attachments ?? [])).map((r) => [restKey(r.slot, r.attachment), r]))
1215
1306
  : null;
1216
- return { poses, shot, zoom, rest, bones: poser.bones, slots: poser.slots };
1307
+ return { poses, shot, zoom, foldsMiss, rest, bones: poser.bones, slots: poser.slots };
1217
1308
  });
1218
1309
  return {
1219
1310
  tag,
@@ -1275,6 +1366,11 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
1275
1366
  framing = { kind: 'viewport' };
1276
1367
  } else if (request.frame.kind === 'zoom') {
1277
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
+ }
1278
1374
  const [x0, y0, x1, y1] = found.reduce<WorldBox>(
1279
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])],
1280
1376
  [Infinity, Infinity, -Infinity, -Infinity],
@@ -1289,7 +1385,14 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
1289
1385
  }
1290
1386
  viewport = viewportFor(x0 - pad, y0 - pad, x1 + pad, y1 + pad, request.maxSide);
1291
1387
  const first = found[0];
1292
- 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
+ };
1293
1396
  } else {
1294
1397
  const shots = builds.map((b) => b.shot).filter((v): v is Viewport => v !== null);
1295
1398
  if (!compare) {
@@ -1587,7 +1690,18 @@ function drawTile(
1587
1690
  };
1588
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);
1589
1692
  if (folded.length === 0 && degenerate.length === 0 && inverted.length === 0) continue;
1590
- legendTile.folds.push({ slot: shown.slot, attachment: shown.attachment, triangleCount: rest.triangles.length / 3, folded, degenerate, inverted });
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
+ });
1591
1705
  }
1592
1706
  if (first) {
1593
1707
  const count = legendTile.folds.reduce((n, f) => n + f.folded.length, 0);
@@ -1595,7 +1709,9 @@ function drawTile(
1595
1709
  notes.push(
1596
1710
  count === 0
1597
1711
  ? `folds: no mesh${request.slotFilter === undefined ? '' : ' the filter keeps'} has a triangle folded over at this pose — nothing filled`
1598
- : `folds: ${count} triangle(s) folded over across ${meshes.length} mesh(es) — ${meshes.map((f) => `"${f.slot}" [${f.folded.join(', ')}]`).join(', ')} — every one is in the legend`,
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`,
1599
1715
  );
1600
1716
  const faulted = legendTile.folds.filter((f) => f.degenerate.length > 0 || f.inverted.length > 0);
1601
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(', ')}`);
@@ -66,7 +66,11 @@
66
66
  * on every walk and stepped at the declared `dt` to each frame (the oracle's
67
67
  * `stepSchedule` rule: from the last frame, steps of `dt` while before the
68
68
  * next, then to it), the same steps for the reference and every candidate. A
69
- * nonzero `warmupSteps` is refused, never run as zero.
69
+ * nonzero `warmupSteps` is refused, never run as zero. The physics clock
70
+ * moves by `physicsClockStep` of each step, the render's clock
71
+ * (`PHYSICS_CLOCK_LEAD` in `src/core/raw.ts`, issue #1380), so a step
72
+ * lasting a whole number of a constraint's steps takes them as exact
73
+ * arithmetic does (`comparisonWalk`, issue #1389).
70
74
  *
71
75
  * ## What it never does
72
76
  *
@@ -106,8 +110,8 @@ import {
106
110
  import { areaBand, stretchSingularValues, triangleAreas } from './areaband.ts';
107
111
  import { uvCarriers, uvCarriersWith, type Carrier, type CarrierPlant } from './meshcarriers.ts';
108
112
  import { CoreInputError, readModel, underNoSkin, underSkin, type CompiledDocument } from './core/index.ts';
109
- import { poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts';
110
- import { sampleTime } from './core/animation.ts';
113
+ import { physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts';
114
+ import { sampleTime, type TimelinePlant } from './core/animation.ts';
111
115
  import { CORE_DEFAULT_SKIN } from './core/skins.ts';
112
116
  import { readVersion } from './package_meta.ts';
113
117
  import { hypot2 } from './numeric.ts';
@@ -187,7 +191,9 @@ export interface MotionComparisonInput {
187
191
  * Issue #1291's faults in carrying `motionAmplitude` into the setup measurement, for the `mesh-compare` suite's
188
192
  * negative controls: the amplitude not handed over; `null` handed over as the field left out; the field not validated
189
193
  * before the builds are read; `null` echoed for a field left out; and the load counted as a required row, so the
190
- * amplitude moves a verdict. `compareMeshesInMotion` plants none. Issue #1323 adds `CarrierPlant`
194
+ * amplitude moves a verdict. `compareMeshesInMotion` plants none. Issue #1389 adds `'frame-physics-clock'`: every
195
+ * build posed with the physics clock moved by the schedule's step itself, the walk before that issue
196
+ * (`comparisonWalk`). Issue #1323 adds `CarrierPlant`
191
197
  * (`src/meshcarriers.ts`): a fault in resolving a sample's several carriers, planted in every `uvCarriers` the
192
198
  * comparison reads.
193
199
  */
@@ -197,6 +203,7 @@ export type ComparePlant =
197
203
  | 'amplitude-unvalidated'
198
204
  | 'echo-when-unset'
199
205
  | 'amplitude-gates-acceptance'
206
+ | 'frame-physics-clock'
200
207
  | OverBoundPlant
201
208
  | CarrierPlant;
202
209
 
@@ -594,7 +601,37 @@ function drawnOf(pose: RawPose, ref: AttachmentRef): number[] | null {
594
601
  return row === undefined ? null : row.vertices;
595
602
  }
596
603
 
597
- function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames: readonly Planned[], walks: readonly Walk[]): Posed | { refused: string } {
604
+ /**
605
+ * The walk every build of a comparison is posed on (issue #1389): `poseRawAnimationEach`, reset at the animation, the
606
+ * physics clock moved by `physicsClockStep` of each step — the render's clock (`src/core/raw.ts`). A walk time that
607
+ * ends on a whole number of a physics constraint's steps leaves zero time over in exact arithmetic and a few ulps
608
+ * either side of zero in doubles, so with the step itself as the clock the constraint took one step too few or one
609
+ * too many by rounding. Measured over the clock arithmetic on 300 durations as the core reads them (float32, as Spine
610
+ * does: n/12 s to 20 s and tenths to 8 s): at 12 fps and `dt` 1/12, the rate `@rig-c/parts` walks, 11,248 of 44,486
611
+ * grid updates sit on a tie against a 60 Hz constraint and the frame clock happens to resolve every one as exact
612
+ * arithmetic does, while against 30, 24 and 50 Hz it mis-steps 3,274, 3,350 and 3,160 of them; at 4 fps and `dt` 1/60
613
+ * it mis-steps 19,876 grid and 594 irr updates against 60 Hz. The render's clock mis-steps none of any, so no time
614
+ * that is not a tie came within its lead of a whole step. `clock: 'frame'` is that walk, the step itself — `ComparePlant`
615
+ * `'frame-physics-clock'` and `MQ178`'s planted red. With no physics constraint the clock is read by nothing, so every
616
+ * pose is the same either way (`MQ179`); with one, the lead moves the shown lag weight by about `2⁻³⁰` of the time
617
+ * walked even where no step moves: on `MQ179`'s spring at parts' rate and 60 Hz, one unit of a row's sixth decimal,
618
+ * which can move the worst sample to a neighbour that nearly ties it.
619
+ */
620
+ export function comparisonWalk(doc: CompiledDocument, animation: string, deltas: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, clock: 'render' | 'frame' = 'render'): void {
621
+ poseRawAnimationEach(doc, animation, deltas, plant, 'animation', visit, clock === 'render' ? physicsClockStep : undefined);
622
+ }
623
+
624
+ /**
625
+ * The walks `schedule` plans over the model document `model`, in walk order, each with the steps every build is posed
626
+ * on — `planSchedule`'s, read for the `mesh-compare` suite's `MQ178`. Internal: merely exported, so not promised; it
627
+ * reads the schedule without the input checks `compareMeshesInMotion` runs first.
628
+ */
629
+ export function comparisonWalks(schedule: MotionSchedule, model: string): Array<{ animation: string; phase: 'grid' | 'irr' | null; deltas: number[] }> {
630
+ const doc = readModel(model, 'the model document');
631
+ return planSchedule(schedule, doc, doc.constraints.filter((c) => c.kind === 'physics').length).walks.map((w) => ({ animation: w.animation, phase: w.phase, deltas: [...w.deltas] }));
632
+ }
633
+
634
+ function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames: readonly Planned[], walks: readonly Walk[], clock: 'render' | 'frame'): Posed | { refused: string } {
598
635
  try {
599
636
  const view = viewOf(doc, refs[0].skin);
600
637
  const setupPose = poseRawSetup(view);
@@ -612,12 +649,19 @@ function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames
612
649
  list.push(i);
613
650
  wanted.set(f.pose, list);
614
651
  });
615
- poseRawAnimationEach(view, w.animation, w.deltas, {}, 'animation', (pose, index) => {
616
- const at = wanted.get(index);
617
- if (at === undefined) return;
618
- const drawn = refs.map((r) => drawnOf(pose, r));
619
- for (const i of at) out[i] = drawn;
620
- });
652
+ comparisonWalk(
653
+ view,
654
+ w.animation,
655
+ w.deltas,
656
+ {},
657
+ (pose, index) => {
658
+ const at = wanted.get(index);
659
+ if (at === undefined) return;
660
+ const drawn = refs.map((r) => drawnOf(pose, r));
661
+ for (const i of at) out[i] = drawn;
662
+ },
663
+ clock,
664
+ );
621
665
  });
622
666
  return { setup, frames: out, setupBones };
623
667
  } catch (err) {
@@ -1003,11 +1047,12 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1003
1047
  const carriersOf = (uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> =>
1004
1048
  uvCarriersWith(uvs, triangles, samples, who, carrierPlant);
1005
1049
  const refCarriers = order.map((i, k) => carriersOf(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
1006
- const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks);
1050
+ const clock = plant === 'frame-physics-clock' ? 'frame' : 'render';
1051
+ const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks, clock);
1007
1052
 
1008
1053
  motionOf = (b: Build, isReference: boolean) => {
1009
1054
  const carriers = isReference ? refCarriers : order.map((i, k) => carriersOf(b.meshes[i].uvs, b.meshes[i].triangles, samplesOf[k].samples, `candidate "${b.id}", attachment ${nameOf(refs[i])}`));
1010
- const posed = isReference ? posedRef : poseBuild(b.doc, order.map((i) => refs[i]), frames, walks);
1055
+ const posed = isReference ? posedRef : poseBuild(b.doc, order.map((i) => refs[i]), frames, walks, clock);
1011
1056
  const built: Built[] = [];
1012
1057
  const perFrame: NonNullable<CandidateReport['perFrame']> = [];
1013
1058
  // Issue #1315: kept for a candidate only, and only when asked — a call without the field does none of this.
@@ -30,7 +30,7 @@
30
30
  * the same walk handing each pose over as it is posed (`CORE_RAW_WALK`), so
31
31
  * one pose is held at a time. Since issue #1380 the physics clock — and
32
32
  * nothing else — moves by `physicsClockStep(1/fps)` rather than by the step
33
- * (`PHYSICS_CLOCK_LEAD` below), in both posers: `CR03` still holds the walk
33
+ * (`PHYSICS_CLOCK_LEAD`, `./core/raw.ts`), in both posers: `CR03` still holds the walk
34
34
  * under the step itself, and the loop-seam suite's `LS06` holds the two
35
35
  * posers equal under the render's clock.
36
36
  * - **Vertices, triangles, bones, slot colours, draw order** are the raw
@@ -100,7 +100,7 @@ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
100
100
  import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
101
101
  import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
102
102
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
103
- import { poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
103
+ import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
104
104
  import type { TimelinePlant } from './core/animation.ts';
105
105
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
106
106
  import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
@@ -369,61 +369,10 @@ export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: read
369
369
  export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep);
370
370
 
371
371
  /**
372
- * How much faster than the frames a render's physics clock runs: each frame
373
- * moves the skeleton's clock — the time a physics constraint integrates — by
374
- * `physicsClockStep(1 / fps)`, the frame's step and `2⁻³⁰` of it more, while
375
- * the animation is applied at the running sum of the frame steps as before
376
- * (issue #1380).
377
- *
378
- * ⭐ **Why a render's physics clock is not the frame clock.** A physics
379
- * constraint keeps the time it has not yet integrated (`remaining`), takes
380
- * whole steps of `1 / fps` (its own `fps`, 60 by default) while at least one
381
- * step remains, and shows `offset − lag·(1 − remaining / step)`, `lag` being
382
- * everything this update's steps moved (`src/core/constraints_physics.ts`,
383
- * *The step*). Whenever a frame lasts a whole number of physics steps — 12,
384
- * 20, 30 and 60 fps against 60 Hz, and every second frame at 24 — the time
385
- * left at the frame's end is zero in exact arithmetic, and in doubles it is
386
- * either a few ulps above zero or a few ulps short of one whole step. Which of
387
- * the two is decided by rounding, not by the motion: the shown weight is 1 or
388
- * 0, so the spring is drawn one frame late or on time, and it changes regime
389
- * on a single frame. Measured on the public trailer (`show`, 20 fps, 321
390
- * frames): every spring jumps at frames 5, 12, 45 and 188 — the four frames
391
- * that arithmetic alone predicts — and the loop's start (frame 80, on time)
392
- * and end (frame 320, a frame late, which is what exact arithmetic draws)
393
- * differ by 31.70 units of vertex and 10.37 of bone
394
- * while every keyed bone agrees to 1.3e-12. spine-core 4.3.13 stepped the
395
- * same way draws the same frames to the bit: this is the runtime under a
396
- * fixed offline step, not a defect of the core's port, which is why the fix
397
- * is the render's schedule and the physics is untouched.
398
- *
399
- * Running the clock `2⁻³⁰` fast puts the time left a little ABOVE every
400
- * whole number of steps instead of on it, by `2⁻³⁰ · t` after `t` seconds:
401
- * 1.5e-8 s at 16 s, against a rounding error of at most a few ulps of the
402
- * clock (3.6e-15 s at 16 s) — so every tie resolves the way exact arithmetic
403
- * resolves it, a step taken, and the shown weight sits at 1 − 9e-7 rather
404
- * than flipping. It stays below the smallest gap a step boundary can fall
405
- * into for 7.8e5 s even at 23 fps against 60 Hz (a gap of 1/1380 s), and a
406
- * physics `reset` key, which restarts the time left from zero, is resolved
407
- * the same way on every frame after it, because the lead is taken on every
408
- * frame rather than once (read off the clock arithmetic with a reset at frame
409
- * 100, 16 s at the five rates: no tie; no tree probe keys a reset under the
410
- * render's clock). A pose stays a function of its frame
411
- * index alone: the lead does not depend on how many frames are asked for.
412
- *
413
- * Rejected, measured over the same arithmetic (16 s at 12, 20, 24, 25, 30 and
414
- * 60 fps): the clock as `i / fps` rather than a running sum (90 frames still
415
- * flipped at 20 fps); a one-time lead of half a step (114 flipped at 24 fps,
416
- * where half a step lands on the other tie); a one-time lead of any size,
417
- * which a `reset` key cancels; and snapping `remaining` inside the physics,
418
- * which changes the runtime's physics rather than the render's schedule and
419
- * which spine-core's poser could not follow.
372
+ * The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
373
+ * comparison (`./meshcompare.ts`, issue #1389) reads the same clock without this module's closure.
420
374
  */
421
- export const PHYSICS_CLOCK_LEAD = 2 ** -30;
422
-
423
- /** What one frame of `step` seconds moves a render's physics clock by — `PHYSICS_CLOCK_LEAD`; both posers step with it. */
424
- export function physicsClockStep(step: number): number {
425
- return step + step * PHYSICS_CLOCK_LEAD;
426
- }
375
+ export { PHYSICS_CLOCK_LEAD, physicsClockStep };
427
376
 
428
377
  /** What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
429
378
  export interface CorePoserPlants {