spine-rigc 2.45.1 → 2.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/docs/AUTHORING.md +92 -2
- package/package.json +1 -1
- package/src/cli/core_commands.ts +46 -4
- package/src/cli/shared.ts +12 -5
- package/src/core/raw.ts +7 -4
- package/src/folds.ts +354 -0
- package/src/inspect.ts +82 -2
- package/src/render.ts +7 -4
- package/src/render_core.ts +64 -3
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
|
-
`
|
|
416
|
+
`folds` (each mesh triangle turned over against the setup pose, filled — `render --folds`
|
|
417
|
+
names them per animation and frame), `slots` (the draw order at the pose), `grid` (in world units) and `declared`
|
|
417
418
|
(each `invariants.axes` line and the bones held to it), at the setup pose, at
|
|
418
419
|
`--animation <name> --at <s[,s…]>` — one picture, or a sheet — or zoomed with
|
|
419
420
|
`--zoom <bone|slot>[:pad]` or `--viewport x,y,w,h`; `--bone`/`--hide-bone` pick the
|
|
420
421
|
bones by glob, `--overlay-slot`/`--hide-overlay-slot` the slots whose mesh, hull,
|
|
421
|
-
weights, density and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
|
|
422
|
+
weights, density, folds and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
|
|
422
423
|
transform, to see what a change moved. Beside the PNG it writes a legend:
|
|
423
424
|
the world-to-pixel transform both ways, what each colour means, and every bone's
|
|
424
425
|
origin and tip in world units and in pixels, so a position read off the picture goes
|
|
@@ -600,7 +601,7 @@ commands take it and what its default is.
|
|
|
600
601
|
| `repack <dir> --out <dir> --keep-pages` | **`skeleton.model.json` alone, for the pages already in place** — for a player that reads the document beside an atlas and pages that already shipped. The same read and stage rules, then `build --atlas-in` over the input atlas in place of the lift and the pack; it writes the document, and nothing else, only after its `pages` are shown to state the atlas as it stands, the rebuilt skeleton byte-identical to the one on disk (so `spine.sha256` names it — no acceptance flag here) and the gate green. `--out` is usually the build's own directory and must not already hold a `skeleton.model.json`; a page or region line the document has no field for is refused by name ([AUTHORING §0.4](docs/AUTHORING.md)) |
|
|
601
602
|
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
|
|
602
603
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
603
|
-
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid |
|
|
604
|
+
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles |
|
|
604
605
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
|
|
605
606
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
|
|
606
607
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
|
package/docs/AUTHORING.md
CHANGED
|
@@ -245,6 +245,7 @@ What the flags mean:
|
|
|
245
245
|
| `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
|
|
246
246
|
| `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone or slot it frames to; §13 says each, with the refusals |
|
|
247
247
|
| `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
|
|
248
|
+
| `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
|
|
248
249
|
| `--poser` | `render`, `inspect`, `check` and `explain`: `core` or `spine` — on `render`, `inspect` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`, `RC34`). On the entry that links none of spine-core — the installed `rigc` without the runtime beside it, `bun cli_core.ts` in a clone — that refusal ends on the route to the entry that does (``the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts` ``); a runtime that is installed and fails to load is refused without it, since installing it is not what that reader has to do. On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1), ending on the same route where the entry links none of it |
|
|
249
250
|
| `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
|
|
250
251
|
| `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
|
|
@@ -1108,6 +1109,7 @@ bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
|
|
|
1108
1109
|
bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
|
|
1109
1110
|
bun cli.ts render --candidate path/to/spine --slot <name,…> | --hide <name,…> # part of the rig, same grid
|
|
1110
1111
|
bun cli.ts render --candidate path/to/spine --geometry # + geometry.json per set: the numbers the frames were drawn from
|
|
1112
|
+
bun cli.ts render --candidate path/to/spine --folds # + folds.json per set: which mesh triangles turn over, and when
|
|
1111
1113
|
bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
|
|
1112
1114
|
bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
|
|
1113
1115
|
bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
|
|
@@ -4906,6 +4908,21 @@ file can be read; it is a **key** outside its bound that `build` refuses (§4.4)
|
|
|
4906
4908
|
number: a non-number would reach the file as `null`, which the runtime reads as
|
|
4907
4909
|
**zero**, so `"mass": "heavy"` would ship a constraint that never settles.
|
|
4908
4910
|
|
|
4911
|
+
⚠️ **What `rigc render` draws a spring with is not a player's clock to the bit.** A
|
|
4912
|
+
constraint integrates whole steps of `1 / fps` and shows the rest as a lag weight, so
|
|
4913
|
+
a frame lasting a whole number of steps (12, 20, 30 or 60 fps against the default
|
|
4914
|
+
60, every second frame at 24) leaves zero in exact arithmetic and a few ulps either
|
|
4915
|
+
side of it in doubles — and spine-core stepped by `1/fps` per frame lets the rounding
|
|
4916
|
+
decide, drawing every spring a frame late or on time and switching between the two on
|
|
4917
|
+
single frames (issue #1380: frames 5, 12, 45 and 188 of a 20 fps render, and a loop
|
|
4918
|
+
whose two ends sat in different regimes, 31.7 units apart). `render`, and everything
|
|
4919
|
+
that poses through its posers (`check`, `bonedist`, `inspect`, `--geometry`), runs
|
|
4920
|
+
the physics clock `2⁻³⁰` faster than the frames, so every such frame takes the steps
|
|
4921
|
+
exact arithmetic takes; the animation time is unchanged. A spring that has converged
|
|
4922
|
+
by the loop point therefore closes, which the loop-seam suite's `LS06` holds at all
|
|
4923
|
+
five rates. A player with a real clock is a different question and rigc does not
|
|
4924
|
+
answer it.
|
|
4925
|
+
|
|
4909
4926
|
`mix` is a player-side `AnimationStateData` config and is **not** emitted into
|
|
4910
4927
|
skeleton JSON, so the parser is the only check it gets: it is
|
|
4911
4928
|
`{ "default": <seconds>, "pairs"?: [["<from>", "<to>", <seconds>], …] }`, and a
|
|
@@ -7270,6 +7287,69 @@ still-in-frame residual over every region of `spineboy-pro` is 4.7e-12 world uni
|
|
|
7270
7287
|
Topology and UVs sit in `rest` rather than on every frame because they do not
|
|
7271
7288
|
change: repeating them per frame would add 35 % to `spineboy-pro`'s files.
|
|
7272
7289
|
|
|
7290
|
+
### 8.3 Which triangles fold — `render --folds`
|
|
7291
|
+
|
|
7292
|
+
A mesh can **fold** — a triangle turning over under a bend — on a build that passes
|
|
7293
|
+
every assertion and every check. The art still draws, mirrored, over its neighbour,
|
|
7294
|
+
and nothing in a report says so. `--folds` reads it off the frames `render` draws:
|
|
7295
|
+
|
|
7296
|
+
```bash
|
|
7297
|
+
bun cli.ts render --candidate path/to/spine --folds [--animation …] [--skin …] [--fps 12]
|
|
7298
|
+
# rigc render
|
|
7299
|
+
# .. show 321 frame(s), 16.000s + contact.png + folds.json (2 of 11 mesh(es) fold) -> …/show@20fps
|
|
7300
|
+
# .. folds "show": mesh "hair_long" (slot "hair_long") folds on 88 frame(s) [33-54, 113-134, 194-215, 273-294] at 20 fps; worst frame 40 (t=2.0000s): 3 of 459 triangle(s) [137, 139, 140]; 3 triangle(s) fold on some frame [137, 139, 140]
|
|
7301
|
+
```
|
|
7302
|
+
|
|
7303
|
+
**What folded means.** A mesh triangle is folded at a frame when its orientation
|
|
7304
|
+
there — the sign of its signed area in world space — is the opposite of its
|
|
7305
|
+
orientation at the setup pose. The setup pose is `rest`'s (§8.2): the setup pose's
|
|
7306
|
+
bones with no deform, so an attachment a slot only swaps to later is read too.
|
|
7307
|
+
Regions are not read: a region is one quad and cannot fold. A mesh mirrored whole by
|
|
7308
|
+
a negative scale reads every triangle as folded — that is what the definition says,
|
|
7309
|
+
and the count tells the two apart.
|
|
7310
|
+
|
|
7311
|
+
**What is never counted.** A triangle already wrong at the setup pose is said once,
|
|
7312
|
+
on a `setup` line the first time a set shows its mesh, and never counted:
|
|
7313
|
+
|
|
7314
|
+
- **degenerate** — its height over its longest edge is at most 2⁻³⁰ of the largest
|
|
7315
|
+
coordinate its attachment holds at that pose, in world space or in its UVs. That
|
|
7316
|
+
is eight million ulps above the rounding a chain of bone transforms leaves, and a
|
|
7317
|
+
hundred-thousandth of a pixel on a rig 10⁴ units across drawn at one pixel per
|
|
7318
|
+
unit — thinner than the posed numbers resolve, and thinner than anything drawn.
|
|
7319
|
+
A triangle degenerate *at a frame* is not counted at that frame either: the sign of
|
|
7320
|
+
a zero area is the rounding's;
|
|
7321
|
+
- **inverted** — its texture is drawn mirrored against the rest of its mesh: the sign
|
|
7322
|
+
of world area × UV area is the opposite of the mesh's own sense, that product
|
|
7323
|
+
summed over its triangles, weighted by world area. Read through the UVs and not the
|
|
7324
|
+
index order, because the format fixes no winding — a mesh whose triangles are
|
|
7325
|
+
listed in mixed orders draws correctly, and a winding test would call half of it
|
|
7326
|
+
folded.
|
|
7327
|
+
|
|
7328
|
+
**The file.** `folds.json`, `rigc-folds/1`, beside each set's frames: `animation`,
|
|
7329
|
+
`skin` (absent when none was set), `fps`, `reading` (these definitions, stated in
|
|
7330
|
+
the file), and `meshes[]` — every mesh any frame of the set shows, in `rest`'s
|
|
7331
|
+
order — each with `slot`, `attachment`, `triangleCount`, `setup` (`degenerate` and
|
|
7332
|
+
`inverted`, triangle indices), `frames[]` (only the frames with a folded triangle:
|
|
7333
|
+
`index`, `time`, `triangles`), `worst` (the frame with the most, the earliest on a
|
|
7334
|
+
tie; `null` when it never folds) and `folded` (every triangle folded on some frame).
|
|
7335
|
+
A triangle is named by its index `i` in `rest`'s `triangles`: vertices
|
|
7336
|
+
`triangles[3i]`, `triangles[3i+1]`, `triangles[3i+2]`. A mesh that never folds is in
|
|
7337
|
+
the file and on no printed line.
|
|
7338
|
+
|
|
7339
|
+
**Nothing else moves.** The PNGs, `contact.png`, `frames.json` and, with
|
|
7340
|
+
`--geometry` beside it, `geometry.json` are byte for byte what the render writes
|
|
7341
|
+
without the flag. The reading is its own file rather than a field of `geometry.json`
|
|
7342
|
+
because that file is the pose, read by consumers that have it on disk already; a
|
|
7343
|
+
reading derived from it is computed from it — `foldsOf` over `geometry.json`'s
|
|
7344
|
+
`rest` and `frames` is `folds.json` exactly (`FO04`). ⛔ **Not with
|
|
7345
|
+
`--slot`/`--hide`**, for `--geometry`'s reason: the reading is of every mesh the pose
|
|
7346
|
+
shows.
|
|
7347
|
+
|
|
7348
|
+
**A reading, not a gate.** Whether a fold is acceptable is yours: a tassel that swings
|
|
7349
|
+
past 90° folds honestly. To look at one, draw the worst frame:
|
|
7350
|
+
`inspect --show art,folds,mesh --animation <name> --at <worst time>` fills the folded
|
|
7351
|
+
triangles at that pose and lists them in its legend (§13).
|
|
7352
|
+
|
|
7273
7353
|
---
|
|
7274
7354
|
|
|
7275
7355
|
## 9. Checking against the frames — `rigc check`
|
|
@@ -10096,6 +10176,7 @@ pixel (`IN09`).
|
|
|
10096
10176
|
| What does this bone move? | `--show art,mesh,weights:<bone> --dim` | the bone's weight over every region and mesh it moves, as a heat: a pixel whose centre lies in a triangle is painted the ramp's colour at the blend of its corners' weights — blue near 0, yellow at 0.5, red at 1 (the stops are in the legend) — and a weight of 0 is not painted, so the art shows where the bone does not reach. A region and an unweighted mesh are moved whole by their slot's bone. The weights are read off `skeleton.json`, the file both posers skin from; a bone that weights nothing paints nothing and the run says so on a line of its own |
|
|
10097
10177
|
| Does this part's edge follow that bone? | `--show art,weights:<bone>,mesh --overlay-slot <part> --zoom slot:<part>` | the part's own triangles and the bone's weight over them alone, over the whole painting. Where slots stack — a neck under the face, the hair, a collar — every mesh crossing the box is drawn and the heat is the union of every slot the bone moves, so the part's edge has no pixel of its own until the others are left out. The legend's `meshes[]` gives the part's weight for the bone at every vertex, so "does the top row follow the head" is read as numbers as well as colour |
|
|
10098
10178
|
| Is this rig's density where its motion is? | `--show art,density --dim` | each region and mesh filled by its **ratio** — its triangles per world unit² over the figure's — on one ramp, log-scaled and centred on 1×: grey is as dense as the figure, pink to magenta denser (4×, 16×), light to dark green sparser (1/4×, 1/16×), clamped past the end stops (the stops are in the legend). A part's area is its posed mesh's own, the sum of its triangles' areas at the pose drawn, not the image's box; a region is two triangles over its quad. Where parts overlap the pixel is the part drawn last, as in the art, so a part under others is read with `--overlay-slot <part>`. The figure's totals are always over every slot the pose shows — the filter picks what is drawn and listed, never what the figure is — and the legend's `density` gives each slot's attachment, kind, vertices, triangles, hull, area, triangles per area, ratio and the bones it is weighted to, in draw order. A measurement, not a verdict: no bar is drawn, and `A13_MESH_BUDGET` stays the one gate on counts |
|
|
10179
|
+
| Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --dim --animation <name> --at <the worst frame's time>` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles. The view fills those triangles at the pose drawn — every pixel whose centre a folded triangle holds — in the colour the legend's `folds` states, and the legend's `folds[]` names, per mesh, the folded triangles by index and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across: `--viewport` around the vertices the legend's `meshes[]` gives is how it becomes visible. A reading, not a verdict |
|
|
10099
10180
|
| How is this part triangulated? | `--show art,mesh,hull --zoom slot:<slot>` | each mesh's triangle edges, and over them its hull — vertex 0 to 1 to … to `hull − 1` and back. Regions are not drawn: a region is not a mesh. The legend lists every vertex in world units and in pixels |
|
|
10100
10181
|
| Which is drawn over which? | `--show art,slots` | each slot that shows a region or mesh numbered by its place in the draw order **at the pose drawn** (0 is drawn first), on a white box at the centre of its attachment's box — so a `drawOrder` key (§4.7) shows from the first frame `render` draws it on |
|
|
10101
10182
|
| Did this change move anything? | `--compare <dir>` beside any view | the same view of a second build beside the first, at the same times, on **one** world box and one transform, each half labelled `A`/`B` with the poser that posed it. A mark on a different pixel in the two halves is at a different place in the two rigs |
|
|
@@ -10152,7 +10233,7 @@ pixel (`IN09`).
|
|
|
10152
10233
|
legend, and a left-out one says which flag left it out in `undrawn`. A pattern
|
|
10153
10234
|
that matches no bone is refused naming the bones there are.
|
|
10154
10235
|
- **`--overlay-slot <glob[,glob…]>`** draws the per-slot views — `mesh`, `hull`,
|
|
10155
|
-
`weights:<bone>`, `density` and `slots` — only for the slots a pattern matches, and
|
|
10236
|
+
`weights:<bone>`, `density`, `folds` and `slots` — only for the slots a pattern matches, and
|
|
10156
10237
|
**`--hide-overlay-slot <glob[,glob…]>`** for none of the ones it matches, applied
|
|
10157
10238
|
after `--overlay-slot`; the patterns are globs, as `--bone`'s are. The **art is not
|
|
10158
10239
|
filtered**: the painting is what the edge is read against, and `--dim` already
|
|
@@ -10192,7 +10273,11 @@ pixel (`IN09`).
|
|
|
10192
10273
|
`ratio`, the `rgba` it is painted, `bones` (how many bones it is weighted to —
|
|
10193
10274
|
a region's or an unweighted mesh's is its slot's one) and `boneNames`. A part
|
|
10194
10275
|
whose posed area is 0 has `trianglesPerArea` and `ratio` `null` and says why in
|
|
10195
|
-
`why`; it is not painted. With `
|
|
10276
|
+
`why`; it is not painted. With `folds`, `folds[]`: every kept mesh with a
|
|
10277
|
+
triangle folded at this tile's pose or a setup fault, in draw order — `slot`,
|
|
10278
|
+
`attachment`, `triangleCount`, `folded` (the triangles filled, by index),
|
|
10279
|
+
`degenerate` and `inverted` (the setup pose's, never counted); empty when none.
|
|
10280
|
+
With `mesh`, `hull`, `weights`, `density` or `folds`,
|
|
10196
10281
|
`meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
|
|
10197
10282
|
`vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
|
|
10198
10283
|
triangle `edges` as vertex pairs, every posed vertex in `world` and `pixel`, and
|
|
@@ -10209,6 +10294,11 @@ pixel (`IN09`).
|
|
|
10209
10294
|
- `density` — with `density`: the ramp's stops (`ratio`, RGBA), the `scale` it is
|
|
10210
10295
|
read on (log₂ of the ratio, clamped at the end stops), and in words what `area`,
|
|
10211
10296
|
the figure's `totals` and the `ratio` are and how a pixel is painted.
|
|
10297
|
+
- `folds` — with `folds`: the colour a folded triangle is filled with, as RGBA, and
|
|
10298
|
+
what it means; `degenerateShare` (2⁻³⁰, §8.3); and in words what `folded`,
|
|
10299
|
+
`degenerate` and `inverted` are and how a triangle is named by `index`. Its colour
|
|
10300
|
+
is stated here rather than in `colours`, which every legend lists whole, so a legend
|
|
10301
|
+
drawn without `folds` is the bytes it was before the view existed.
|
|
10212
10302
|
- `boneFilter` — `--bone` and `--hide-bone` as given.
|
|
10213
10303
|
- `slotFilter` — `--overlay-slot` and `--hide-overlay-slot` as given; with it,
|
|
10214
10304
|
`meshes[]` and `slots[]` list only the slots it keeps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.46.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
657
|
+
const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
|
|
658
|
+
const foldNote = foldReading === undefined ? '' : ` + ${FOLDS_FILE} (${folding} of ${foldReading.meshes.length} mesh(es) fold)`;
|
|
659
|
+
const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}`;
|
|
626
660
|
console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
|
|
661
|
+
for (const mesh of foldReading?.meshes ?? []) {
|
|
662
|
+
const key = `${mesh.slot}\u0000${mesh.attachment}`;
|
|
663
|
+
const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
|
|
664
|
+
faultsSaid.add(key);
|
|
665
|
+
if (fault !== null) console.log(` .. setup ${fault}`);
|
|
666
|
+
const line = foldLine(foldReading?.animation ?? null, fps, mesh);
|
|
667
|
+
if (line !== null) console.log(` .. folds ${line}`);
|
|
668
|
+
}
|
|
627
669
|
}
|
|
628
670
|
|
|
629
671
|
// The sidecar is what makes this a frame SET rather than a pile of pictures:
|
package/src/cli/shared.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { ANCHOR_MAX_RESIDUAL, ANCHOR_MAX_UNEXPLAINED, DEFAULT_HINGE_MAX, DEFAULT
|
|
|
36
36
|
import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport } from '../check.ts';
|
|
37
37
|
import { compile, CompileError, droppedStateReason, headerBoundsOf, type CompileOptions } from '../compile.ts';
|
|
38
38
|
import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
|
|
39
|
+
import { FOLDS_FILE } from '../folds.ts';
|
|
39
40
|
import { parseJsonWithPosition } from '../json-position.ts';
|
|
40
41
|
import { RUNG_IDS } from '../ladder.ts';
|
|
41
42
|
import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
|
|
@@ -126,7 +127,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
|
126
127
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
127
128
|
* halves together by reading `--help` rather than by naming a flag.
|
|
128
129
|
*/
|
|
129
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
130
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
130
131
|
|
|
131
132
|
/**
|
|
132
133
|
* The flags a command is allowed to spell more than once.
|
|
@@ -996,6 +997,11 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
996
997
|
`also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
|
|
997
998
|
"slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
|
|
998
999
|
'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
|
|
1000
|
+
folds:
|
|
1001
|
+
`also read which mesh triangles fold — turn over against the setup pose — and write ${FOLDS_FILE} into each frame ` +
|
|
1002
|
+
'directory: per mesh, the frames with a folded triangle, the worst frame\'s count and which triangles, by index. ' +
|
|
1003
|
+
'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
|
|
1004
|
+
'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
|
|
999
1005
|
poser:
|
|
1000
1006
|
"`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1001
1007
|
'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
|
|
@@ -1067,7 +1073,8 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1067
1073
|
"build's own frame; grid = lines at whole multiples of a step in world units, labelled; density = each region and mesh " +
|
|
1068
1074
|
"filled by its triangles per world unit² over the figure's, on a log ramp centred on 1x that the legend states, with the " +
|
|
1069
1075
|
"figure's totals over every slot and each slot's row in the legend; weights:<bone> = that bone's " +
|
|
1070
|
-
'weight over every region and mesh it moves, as a heat on a ramp the legend states;
|
|
1076
|
+
'weight over every region and mesh it moves, as a heat on a ramp the legend states; folds = each mesh triangle turned ' +
|
|
1077
|
+
"over against the setup pose at the pose drawn, filled, and named by index in the legend (render --folds reads the same); mesh = each mesh's triangle edges; " +
|
|
1071
1078
|
"hull = each mesh's hull outline; declared = each invariants.axes " +
|
|
1072
1079
|
"line through its two bones' origins, its held bones drawn in their own colour; bones = each bone from origin to tip, " +
|
|
1073
1080
|
'a bone of length 0 marked as one; axes = each bone\'s world x and y axes; slots = each slot\'s place in the posed ' +
|
|
@@ -1577,10 +1584,10 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1577
1584
|
runtime: false,
|
|
1578
1585
|
spineFormat: false,
|
|
1579
1586
|
usage: [
|
|
1580
|
-
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
|
|
1587
|
+
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
|
|
1581
1588
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1582
1589
|
],
|
|
1583
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
|
|
1590
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
|
|
1584
1591
|
overrides: {
|
|
1585
1592
|
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
1586
1593
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
|
@@ -1595,7 +1602,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1595
1602
|
'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
|
|
1596
1603
|
'rigc inspect … --zoom <bone|slot>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
|
|
1597
1604
|
"rigc inspect … --bone 'bang_*' --hide-bone '*_ctl' (which bones bones, axes and names draw)",
|
|
1598
|
-
'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights and slots draw; the art stays whole)',
|
|
1605
|
+
'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights, density, folds and slots draw; the art stays whole)',
|
|
1599
1606
|
'rigc inspect … --compare <dir | skeleton.json> (a second build beside the first, on one transform)',
|
|
1600
1607
|
],
|
|
1601
1608
|
flags: ['candidate', 'atlas', 'show', 'dim', 'animation', 'at', 'fps', 'skin', 'zoom', 'viewport', 'bone', 'hide-bone', 'overlay-slot', 'hide-overlay-slot', 'compare', 'max', 'poser', 'out'],
|
package/src/core/raw.ts
CHANGED
|
@@ -257,7 +257,7 @@ export interface WalkMode {
|
|
|
257
257
|
keep: boolean;
|
|
258
258
|
/** A plant only (the core suite's `CO26`): the animation time a looping step applies at, from its track time, the duration and the track time before it — `loopedTime` unless given. */
|
|
259
259
|
time?: (trackTime: number, duration: number, before: number) => number;
|
|
260
|
-
/**
|
|
260
|
+
/** What the physics clock moves by, from the step and how far the animation time moved — the step itself unless given: a plant (`CO26`), or the render's offline clock passed to `poseRawAnimationEach` (issue #1380). */
|
|
261
261
|
clock?: (dt: number, moved: number) => number;
|
|
262
262
|
/** A plant only (`CO26`): `false` fires no `reset` key across the wrap — the reading the walk was measured against and rejected. */
|
|
263
263
|
wrapResets?: boolean;
|
|
@@ -494,10 +494,13 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
|
|
|
494
494
|
* animation's series — on the production rig the core poser's render peaked
|
|
495
495
|
* highest on, the largest animation's series was 153 MiB of retained heap.
|
|
496
496
|
* A refusal is thrown from the pose it is met at, after `visit` has seen the
|
|
497
|
-
* poses before it.
|
|
497
|
+
* poses before it. `clock`, when given, is what each step moves the physics
|
|
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
|
|
500
|
+
* the animation time are the steps' running sum either way.
|
|
498
501
|
*/
|
|
499
|
-
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void): void {
|
|
500
|
-
walkEach(doc, animation, steps, plant, reset, RAW_MODE, RAW_SHAPE, visit);
|
|
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
|
+
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit);
|
|
501
504
|
}
|
|
502
505
|
|
|
503
506
|
/**
|
package/src/folds.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which triangles of a posed mesh are turned over — the fold reading behind
|
|
3
|
+
* `rigc render --folds` and `rigc inspect --show folds` (issue #1383).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **A reading, not a gate.** A mesh can fold — a triangle flipping over under
|
|
6
|
+
* a bend — on a build that passes every assertion and every check, and until
|
|
7
|
+
* this it was found by a person looking at the right frame. Whether a fold is
|
|
8
|
+
* acceptable is the consumer's: a tassel that swings past 90° folds honestly.
|
|
9
|
+
* So this names the triangles and the frames and decides nothing.
|
|
10
|
+
*
|
|
11
|
+
* ## What "folded" means here
|
|
12
|
+
*
|
|
13
|
+
* A triangle is folded at a frame when its orientation there — the sign of its
|
|
14
|
+
* signed area in world space — is the opposite of its orientation at the setup
|
|
15
|
+
* pose. The setup pose is the rest table's (`AttachmentRest.vertices`: the setup
|
|
16
|
+
* pose's bones with no deform, the table `render --geometry` writes as `rest`),
|
|
17
|
+
* so an attachment a slot only swaps to later has a reference too.
|
|
18
|
+
*
|
|
19
|
+
* A triangle that is already wrong at the setup pose is reported once as that
|
|
20
|
+
* and never counted as folding, because "opposite to the setup pose" says
|
|
21
|
+
* nothing about it:
|
|
22
|
+
*
|
|
23
|
+
* - **degenerate** — thinner than the posed numbers resolve (`DEGENERATE_SHARE`
|
|
24
|
+
* below), in world space or in its UVs, so it has no orientation to keep;
|
|
25
|
+
* - **inverted** — its texture is drawn mirrored against the rest of its mesh:
|
|
26
|
+
* the sign of world area × UV area is the opposite of the mesh's own sense,
|
|
27
|
+
* which is the sign of that product summed over its triangles, weighted by
|
|
28
|
+
* world area. Read through the UVs rather than the index order because the
|
|
29
|
+
* format does not fix a winding: a mesh whose triangles are listed in mixed
|
|
30
|
+
* orders is drawn correctly, and a winding test would call half of it folded.
|
|
31
|
+
*
|
|
32
|
+
* A triangle that is degenerate at a frame — collapsed to a line by the pose —
|
|
33
|
+
* is not counted at that frame either: the sign of a zero area is the rounding's,
|
|
34
|
+
* not the pose's.
|
|
35
|
+
*
|
|
36
|
+
* A mesh mirrored whole by a negative scale reads every triangle as folded: that
|
|
37
|
+
* is what "opposite to the setup pose" is, and the count says which it was.
|
|
38
|
+
*
|
|
39
|
+
* Pure: no clock, no randomness, no runtime. The posed vertices are handed in by
|
|
40
|
+
* whichever poser drew the frames (rigc's core for a rigc build).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** The fold file's name inside an animation's frame directory, and its format tag. */
|
|
44
|
+
export const FOLDS_FILE = 'folds.json';
|
|
45
|
+
export const FOLDS_SPEC = 'rigc-folds/1';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A triangle is degenerate when its height over its longest edge is at most
|
|
49
|
+
* this share of the largest coordinate magnitude its attachment holds at that
|
|
50
|
+
* pose (world units, or UV units for the UV triangle).
|
|
51
|
+
*
|
|
52
|
+
* 2⁻³⁰ ≈ 9.3e-10, chosen between two bounds that are both stated rather than
|
|
53
|
+
* tuned:
|
|
54
|
+
*
|
|
55
|
+
* - **above the rounding**: a posed coordinate is a chain of bone transforms in
|
|
56
|
+
* doubles, each step rounding by half an ulp (2⁻⁵³) of numbers no larger than
|
|
57
|
+
* a few times the coordinate. 2⁻³⁰ is 2²³ ulps — eight million steps of
|
|
58
|
+
* headroom — so a sign this flags is never one a different rounding could
|
|
59
|
+
* have given the other way;
|
|
60
|
+
* - **below anything drawn**: on a rig 10⁴ world units across it is 1e-5 world
|
|
61
|
+
* units, a hundred-thousandth of a pixel at one pixel per unit, so no triangle
|
|
62
|
+
* a picture can show is called degenerate.
|
|
63
|
+
*
|
|
64
|
+
* Relative to the attachment's own magnitude because the rounding is: a mesh at
|
|
65
|
+
* 3,200 units from its origin carries 3,200 times the absolute noise of one at 1.
|
|
66
|
+
*/
|
|
67
|
+
export const DEGENERATE_SHARE = 2 ** -30;
|
|
68
|
+
|
|
69
|
+
/** The rest geometry the reading needs: the triangles, the UVs and the setup pose's vertices. */
|
|
70
|
+
export interface FoldRest {
|
|
71
|
+
slot: string;
|
|
72
|
+
attachment: string;
|
|
73
|
+
kind: 'region' | 'mesh';
|
|
74
|
+
vertices: readonly number[];
|
|
75
|
+
triangles: readonly number[];
|
|
76
|
+
uvs?: readonly number[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One posed attachment: its slot, its name, its world vertices `x, y` per vertex. */
|
|
80
|
+
export interface FoldPose {
|
|
81
|
+
slot: string;
|
|
82
|
+
attachment: string;
|
|
83
|
+
vertices: readonly number[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One sampled frame: its index on the grid, its time and every attachment it shows. */
|
|
87
|
+
export interface FoldFrame {
|
|
88
|
+
index: number;
|
|
89
|
+
time: number;
|
|
90
|
+
attachments: readonly FoldPose[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A mesh's triangles that are wrong at the setup pose — reported once, never counted. */
|
|
94
|
+
export interface SetupFaults {
|
|
95
|
+
/** Thinner than the posed numbers resolve, in world space or in the UVs — by triangle index. */
|
|
96
|
+
degenerate: number[];
|
|
97
|
+
/** Drawn mirrored against the rest of its mesh at the setup pose — by triangle index. */
|
|
98
|
+
inverted: number[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One frame on which a mesh has a folded triangle. */
|
|
102
|
+
export interface FoldedFrame {
|
|
103
|
+
index: number;
|
|
104
|
+
time: number;
|
|
105
|
+
/** The folded triangles, by index (`triangles[3i..3i+2]` of the rest table), ascending. */
|
|
106
|
+
triangles: number[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One mesh's reading over one frame set. */
|
|
110
|
+
export interface MeshFolds {
|
|
111
|
+
slot: string;
|
|
112
|
+
attachment: string;
|
|
113
|
+
/** How many triangles the mesh has. */
|
|
114
|
+
triangleCount: number;
|
|
115
|
+
setup: SetupFaults;
|
|
116
|
+
/** Every frame with a folded triangle, in frame order; empty when the mesh never folds. */
|
|
117
|
+
frames: FoldedFrame[];
|
|
118
|
+
/** The frame with the most folded triangles — the earliest, on a tie — or `null` when it never folds. */
|
|
119
|
+
worst: FoldedFrame | null;
|
|
120
|
+
/** Every triangle folded on some frame, ascending. */
|
|
121
|
+
folded: number[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `folds.json`: one frame set's reading, every mesh any frame shows. */
|
|
125
|
+
export interface FoldsFile {
|
|
126
|
+
spec: typeof FOLDS_SPEC;
|
|
127
|
+
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
128
|
+
animation: string | null;
|
|
129
|
+
skin?: string;
|
|
130
|
+
fps: number;
|
|
131
|
+
/** What the numbers mean, stated in the file so a reader does not need this module. */
|
|
132
|
+
reading: { folded: string; degenerate: string; inverted: string; index: string };
|
|
133
|
+
/** Every mesh any frame of the set shows, in the order the rest table lists them. */
|
|
134
|
+
meshes: MeshFolds[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A planted defect, for the suite's mutants only (`FO` controls in
|
|
139
|
+
* `selftest.ts`). Production passes none.
|
|
140
|
+
*/
|
|
141
|
+
export interface FoldPlant {
|
|
142
|
+
/** Count a triangle whose sign AGREES with the setup pose's, rather than the one opposite it. */
|
|
143
|
+
signFlipped?: boolean;
|
|
144
|
+
/** Read each frame against the frame before it — a flip detector — rather than against the setup pose. */
|
|
145
|
+
againstPreviousFrame?: boolean;
|
|
146
|
+
/** Count the setup pose's degenerate and inverted triangles like any other. */
|
|
147
|
+
countSetupFaults?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Twice the signed area of triangle `t` in `xy` (`x, y` per vertex). */
|
|
151
|
+
function cross2(xy: readonly number[], triangles: readonly number[], t: number): number {
|
|
152
|
+
const a = triangles[3 * t] * 2;
|
|
153
|
+
const b = triangles[3 * t + 1] * 2;
|
|
154
|
+
const c = triangles[3 * t + 2] * 2;
|
|
155
|
+
return (xy[b] - xy[a]) * (xy[c + 1] - xy[a + 1]) - (xy[c] - xy[a]) * (xy[b + 1] - xy[a + 1]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The longest edge of triangle `t` in `xy`. */
|
|
159
|
+
function longestEdge(xy: readonly number[], triangles: readonly number[], t: number): number {
|
|
160
|
+
const ids = [triangles[3 * t] * 2, triangles[3 * t + 1] * 2, triangles[3 * t + 2] * 2];
|
|
161
|
+
let most = 0;
|
|
162
|
+
for (const [i, j] of [[0, 1], [1, 2], [2, 0]] as const) {
|
|
163
|
+
const dx = xy[ids[j]] - xy[ids[i]];
|
|
164
|
+
const dy = xy[ids[j] + 1] - xy[ids[i] + 1];
|
|
165
|
+
most = Math.max(most, Math.sqrt(dx * dx + dy * dy));
|
|
166
|
+
}
|
|
167
|
+
return most;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The largest coordinate magnitude in `xy` — what the rounding of every number in it is relative to. */
|
|
171
|
+
function magnitude(xy: readonly number[]): number {
|
|
172
|
+
let most = 0;
|
|
173
|
+
for (const v of xy) most = Math.max(most, Math.abs(v));
|
|
174
|
+
return most;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The sign of triangle `t`'s area in `xy`: +1, −1, or 0 when it is degenerate
|
|
179
|
+
* — its height over its longest edge at most `DEGENERATE_SHARE` of `scale`.
|
|
180
|
+
*/
|
|
181
|
+
function orientation(xy: readonly number[], triangles: readonly number[], t: number, scale: number): -1 | 0 | 1 {
|
|
182
|
+
const twice = cross2(xy, triangles, t);
|
|
183
|
+
const edge = longestEdge(xy, triangles, t);
|
|
184
|
+
if (edge === 0 || Math.abs(twice) / edge <= DEGENERATE_SHARE * scale) return 0;
|
|
185
|
+
return twice > 0 ? 1 : -1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A mesh's setup reading: each triangle's world sign at the setup pose, and the triangles reported as setup faults. */
|
|
189
|
+
export interface SetupReading {
|
|
190
|
+
/** Per triangle: +1 or −1, its world sign at the setup pose; 0 for a setup fault (never counted). */
|
|
191
|
+
signs: Int8Array;
|
|
192
|
+
faults: SetupFaults;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The setup reading of one mesh — see the module comment for what degenerate and inverted mean. */
|
|
196
|
+
export function setupReading(rest: FoldRest, plant: FoldPlant = {}): SetupReading {
|
|
197
|
+
const count = Math.floor(rest.triangles.length / 3);
|
|
198
|
+
const signs = new Int8Array(count);
|
|
199
|
+
const world: number[] = [];
|
|
200
|
+
const uvSigns: number[] = [];
|
|
201
|
+
const worldScale = magnitude(rest.vertices);
|
|
202
|
+
const uvs = rest.uvs ?? [];
|
|
203
|
+
const uvScale = magnitude(uvs);
|
|
204
|
+
let sense = 0;
|
|
205
|
+
const degenerate: number[] = [];
|
|
206
|
+
for (let t = 0; t < count; t++) {
|
|
207
|
+
const w = orientation(rest.vertices, rest.triangles, t, worldScale);
|
|
208
|
+
const u = uvs.length === rest.vertices.length ? orientation(uvs, rest.triangles, t, uvScale) : 1;
|
|
209
|
+
world.push(w);
|
|
210
|
+
uvSigns.push(u);
|
|
211
|
+
if (w === 0 || u === 0) {
|
|
212
|
+
degenerate.push(t);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
sense += w * u * Math.abs(cross2(rest.vertices, rest.triangles, t));
|
|
216
|
+
}
|
|
217
|
+
const inverted: number[] = [];
|
|
218
|
+
for (let t = 0; t < count; t++) {
|
|
219
|
+
if (world[t] === 0 || uvSigns[t] === 0) continue;
|
|
220
|
+
// A mesh whose two senses weigh exactly the same has no rest to be inverted against: none is flagged.
|
|
221
|
+
if (sense !== 0 && Math.sign(world[t] * uvSigns[t]) !== Math.sign(sense)) {
|
|
222
|
+
inverted.push(t);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
signs[t] = world[t];
|
|
226
|
+
}
|
|
227
|
+
if (plant.countSetupFaults) {
|
|
228
|
+
for (let t = 0; t < count; t++) signs[t] = world[t] === 0 ? 1 : world[t];
|
|
229
|
+
}
|
|
230
|
+
return { signs, faults: { degenerate, inverted } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The triangles of one mesh folded at one pose, ascending — `vertices` posed, `setup` its setup reading. */
|
|
234
|
+
export function foldedAt(rest: FoldRest, setup: SetupReading, vertices: readonly number[], plant: FoldPlant = {}): number[] {
|
|
235
|
+
if (vertices.length !== rest.vertices.length) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`slot "${rest.slot}" mesh "${rest.attachment}": the rest table states ${rest.vertices.length / 2} vertices, the pose ${vertices.length / 2} — the poser disagrees with itself`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const scale = magnitude(vertices);
|
|
241
|
+
const out: number[] = [];
|
|
242
|
+
for (let t = 0; t < setup.signs.length; t++) {
|
|
243
|
+
const before = setup.signs[t];
|
|
244
|
+
if (before === 0) continue;
|
|
245
|
+
const now = orientation(vertices, rest.triangles, t, scale);
|
|
246
|
+
if (now === 0) continue;
|
|
247
|
+
if (plant.signFlipped ? now === before : now === -before) out.push(t);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Every mesh's reading over one frame set: `rest` the rest table (regions are
|
|
256
|
+
* skipped — a region is a quad and cannot fold), `frames` the frames as the
|
|
257
|
+
* sampler returned them. A mesh any frame shows is read; one no frame shows is
|
|
258
|
+
* not listed. Order is the rest table's, so one build reads in one order.
|
|
259
|
+
*/
|
|
260
|
+
export function foldsOf(rest: readonly FoldRest[], frames: readonly FoldFrame[], plant: FoldPlant = {}): MeshFolds[] {
|
|
261
|
+
const shown = new Set<string>();
|
|
262
|
+
for (const frame of frames) for (const a of frame.attachments) shown.add(restKey(a.slot, a.attachment));
|
|
263
|
+
const out: MeshFolds[] = [];
|
|
264
|
+
for (const r of rest) {
|
|
265
|
+
if (r.kind !== 'mesh' || !shown.has(restKey(r.slot, r.attachment))) continue;
|
|
266
|
+
let setup = setupReading(r, plant);
|
|
267
|
+
const folded: FoldedFrame[] = [];
|
|
268
|
+
const union = new Set<number>();
|
|
269
|
+
for (const frame of frames) {
|
|
270
|
+
const posed = frame.attachments.find((a) => a.slot === r.slot && a.attachment === r.attachment);
|
|
271
|
+
if (posed === undefined) continue;
|
|
272
|
+
const triangles = foldedAt(r, setup, posed.vertices, plant);
|
|
273
|
+
if (plant.againstPreviousFrame) {
|
|
274
|
+
const signs = Int8Array.from(setup.signs, (sign, t) => (triangles.includes(t) ? -sign : sign));
|
|
275
|
+
setup = { ...setup, signs };
|
|
276
|
+
}
|
|
277
|
+
if (triangles.length === 0) continue;
|
|
278
|
+
for (const t of triangles) union.add(t);
|
|
279
|
+
folded.push({ index: frame.index, time: frame.time, triangles });
|
|
280
|
+
}
|
|
281
|
+
let worst: FoldedFrame | null = null;
|
|
282
|
+
for (const f of folded) if (worst === null || f.triangles.length > worst.triangles.length) worst = f;
|
|
283
|
+
out.push({
|
|
284
|
+
slot: r.slot,
|
|
285
|
+
attachment: r.attachment,
|
|
286
|
+
triangleCount: setup.signs.length,
|
|
287
|
+
setup: setup.faults,
|
|
288
|
+
frames: folded,
|
|
289
|
+
worst,
|
|
290
|
+
folded: [...union].sort((a, b) => a - b),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** What `folds.json` states its numbers mean — the one wording the file and the docs share. */
|
|
297
|
+
export const FOLDS_READING: FoldsFile['reading'] = {
|
|
298
|
+
folded:
|
|
299
|
+
"a triangle whose world orientation (the sign of its signed area) at the frame is the opposite of its orientation at the setup pose — the rest table's vertices, the setup pose's bones with no deform. A triangle degenerate at the frame is not counted there",
|
|
300
|
+
degenerate: `a triangle whose height over its longest edge is at most ${DEGENERATE_SHARE} of the largest coordinate magnitude its attachment holds at that pose, in world space or in its UVs — thinner than the posed numbers resolve`,
|
|
301
|
+
inverted:
|
|
302
|
+
"a triangle drawn mirrored against the rest of its mesh at the setup pose: the sign of world area x UV area is the opposite of the mesh's own sense (that product summed over its triangles, weighted by world area). Setup faults are listed once and never counted as folds",
|
|
303
|
+
index: 'a triangle is named by its index i in the rest table: vertices triangles[3i], triangles[3i+1], triangles[3i+2]',
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** The file for one frame set. */
|
|
307
|
+
export function foldsFileOf(animation: string | null, fps: number, skin: string | undefined, meshes: MeshFolds[]): FoldsFile {
|
|
308
|
+
return { spec: FOLDS_SPEC, animation, ...(skin === undefined ? {} : { skin }), fps, reading: FOLDS_READING, meshes };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The file's text: two-space JSON and a newline, so one build and one request are one byte sequence. */
|
|
312
|
+
export function foldsText(file: FoldsFile): string {
|
|
313
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Ascending indices as runs: `[3, 4, 5, 9]` → `3-5, 9`. */
|
|
317
|
+
export function indexRuns(indices: readonly number[]): string {
|
|
318
|
+
const runs: string[] = [];
|
|
319
|
+
for (let i = 0; i < indices.length; ) {
|
|
320
|
+
let j = i;
|
|
321
|
+
while (j + 1 < indices.length && indices[j + 1] === indices[j] + 1) j++;
|
|
322
|
+
runs.push(j === i ? String(indices[i]) : `${indices[i]}-${indices[j]}`);
|
|
323
|
+
i = j + 1;
|
|
324
|
+
}
|
|
325
|
+
return runs.join(', ');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The printed line for a mesh that folds, or `null` for one that never does.
|
|
330
|
+
* It names the animation, the mesh and its slot, the frames, the worst frame's
|
|
331
|
+
* count and its triangles, and every triangle folded on any frame.
|
|
332
|
+
*/
|
|
333
|
+
export function foldLine(animation: string | null, fps: number, mesh: MeshFolds): string | null {
|
|
334
|
+
const worst = mesh.worst;
|
|
335
|
+
if (worst === null) return null;
|
|
336
|
+
const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
|
|
337
|
+
const frames = mesh.frames.map((f) => f.index);
|
|
338
|
+
return (
|
|
339
|
+
`${where}: mesh "${mesh.attachment}" (slot "${mesh.slot}") folds on ${frames.length} frame(s) [${indexRuns(frames)}] at ${fps} fps; ` +
|
|
340
|
+
`worst frame ${worst.index} (t=${worst.time.toFixed(4)}s): ${worst.triangles.length} of ${mesh.triangleCount} triangle(s) [${worst.triangles.join(', ')}]; ` +
|
|
341
|
+
`${mesh.folded.length} triangle(s) fold on some frame [${mesh.folded.join(', ')}]`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The printed line for a mesh with setup faults, or `null` for one with none — printed once per run, not per animation. */
|
|
346
|
+
export function setupFaultLine(mesh: Pick<MeshFolds, 'slot' | 'attachment' | 'setup'>): string | null {
|
|
347
|
+
const { degenerate, inverted } = mesh.setup;
|
|
348
|
+
if (degenerate.length === 0 && inverted.length === 0) return null;
|
|
349
|
+
const parts = [
|
|
350
|
+
...(degenerate.length === 0 ? [] : [`${degenerate.length} degenerate [${degenerate.join(', ')}]`]),
|
|
351
|
+
...(inverted.length === 0 ? [] : [`${inverted.length} inverted against the rest of the mesh [${inverted.join(', ')}]`]),
|
|
352
|
+
];
|
|
353
|
+
return `mesh "${mesh.attachment}" (slot "${mesh.slot}") at the setup pose: triangle(s) ${parts.join('; ')} — reported here once and never counted as folding`;
|
|
354
|
+
}
|
package/src/inspect.ts
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
* budget: `A13_MESH_BUDGET` is the one gate on counts, and this only says where
|
|
43
43
|
* the triangles are.
|
|
44
44
|
*
|
|
45
|
+
* ⭐ **A fold is read, never judged** (issue #1383). `folds` fills each mesh
|
|
46
|
+
* triangle that is turned over at the pose drawn — its world orientation the
|
|
47
|
+
* opposite of the setup pose's — by `./folds.ts`, the reading `render --folds`
|
|
48
|
+
* prints, so the picture and that line name the same triangles.
|
|
49
|
+
*
|
|
45
50
|
* Links nothing from the runtime: the poser is handed in.
|
|
46
51
|
*/
|
|
47
52
|
import { createHash } from 'node:crypto';
|
|
@@ -71,12 +76,13 @@ import {
|
|
|
71
76
|
viewportFor,
|
|
72
77
|
} from './render_shared.ts';
|
|
73
78
|
import { hypot2 } from './numeric.ts';
|
|
79
|
+
import { DEGENERATE_SHARE, foldedAt, FOLDS_READING, setupReading } from './folds.ts';
|
|
74
80
|
|
|
75
81
|
/** The legend's format tag. */
|
|
76
82
|
export const INSPECT_SPEC = 'rigc-inspect/1';
|
|
77
83
|
|
|
78
84
|
/** Every word `--show` takes, in the order the picture draws them (bottom first). `weights` is spelled `weights:<bone>`. */
|
|
79
|
-
export const SHOW_WORDS = ['art', 'grid', 'density', 'weights', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
|
|
85
|
+
export const SHOW_WORDS = ['art', 'grid', 'density', 'weights', 'folds', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
|
|
80
86
|
export type ShowWord = (typeof SHOW_WORDS)[number];
|
|
81
87
|
|
|
82
88
|
/** The words as a refusal lists them — `weights` with the bone it needs. */
|
|
@@ -183,6 +189,19 @@ export const INSPECT_COLOURS = {
|
|
|
183
189
|
rule: { rgba: [...SHEET_RULE], meaning: 'the gap between two tiles' },
|
|
184
190
|
} as const satisfies Record<string, { rgba: readonly number[]; meaning: string }>;
|
|
185
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The `folds` view's colour (issue #1383). Kept out of `INSPECT_COLOURS`, which
|
|
194
|
+
* every legend lists whole: a colour added there would move the bytes of every
|
|
195
|
+
* legend written without `folds`. It is stated in the legend's `folds` block
|
|
196
|
+
* instead, only when the view is drawn — as the weights and density ramps are.
|
|
197
|
+
* Pure red at full alpha: neither ramp reaches it (the weights ramp's red end is
|
|
198
|
+
* `[215, 20, 20]`), and the mesh edges drawn over a fill stay readable.
|
|
199
|
+
*/
|
|
200
|
+
export const FOLD_COLOUR = {
|
|
201
|
+
rgba: [255, 0, 0, 255],
|
|
202
|
+
meaning: 'a mesh triangle folded over at this pose — its world orientation the opposite of the setup pose\'s: every pixel whose centre it holds',
|
|
203
|
+
} as const;
|
|
204
|
+
|
|
186
205
|
type ColourName = keyof typeof INSPECT_COLOURS;
|
|
187
206
|
const colour = (name: ColourName): RGBA => [...INSPECT_COLOURS[name].rgba] as RGBA;
|
|
188
207
|
|
|
@@ -292,6 +311,8 @@ export interface InspectPlant {
|
|
|
292
311
|
densityLinearRamp?: boolean;
|
|
293
312
|
/** Density: take the figure's totals over the slots the filter keeps, not over every slot. */
|
|
294
313
|
densityTotalsFiltered?: boolean;
|
|
314
|
+
/** Folds: fill every triangle of every kept mesh, folded or not. */
|
|
315
|
+
foldsFillAll?: boolean;
|
|
295
316
|
}
|
|
296
317
|
|
|
297
318
|
/** A point in both spaces. */
|
|
@@ -425,6 +446,20 @@ export interface LegendDensity {
|
|
|
425
446
|
slots: LegendDensitySlot[];
|
|
426
447
|
}
|
|
427
448
|
|
|
449
|
+
/** One kept mesh with a folded triangle, or a setup fault, at one tile's pose (issue #1383). */
|
|
450
|
+
export interface LegendFold {
|
|
451
|
+
slot: string;
|
|
452
|
+
attachment: string;
|
|
453
|
+
/** How many triangles the mesh has. */
|
|
454
|
+
triangleCount: number;
|
|
455
|
+
/** The triangles folded at this pose, by index (`triangles[3i..3i+2]` of the rest table), ascending — the ones filled. */
|
|
456
|
+
folded: number[];
|
|
457
|
+
/** Thinner than the posed numbers resolve at the setup pose, never counted — by index. */
|
|
458
|
+
degenerate: number[];
|
|
459
|
+
/** Drawn mirrored against the rest of the mesh at the setup pose, never counted — by index. */
|
|
460
|
+
inverted: number[];
|
|
461
|
+
}
|
|
462
|
+
|
|
428
463
|
export interface LegendTile {
|
|
429
464
|
/** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
|
|
430
465
|
build?: 'A' | 'B';
|
|
@@ -445,6 +480,8 @@ export interface LegendTile {
|
|
|
445
480
|
slots?: LegendSlot[];
|
|
446
481
|
/** With `density`: the figure's totals and every kept slot's row. */
|
|
447
482
|
density?: LegendDensity;
|
|
483
|
+
/** With `folds`: every kept mesh with a triangle folded at this pose or a setup fault, in draw order; empty when none. */
|
|
484
|
+
folds?: LegendFold[];
|
|
448
485
|
declared?: LegendDeclared[];
|
|
449
486
|
grid?: { step: number; x: number[]; y: number[] };
|
|
450
487
|
}
|
|
@@ -470,6 +507,8 @@ export interface InspectLegend {
|
|
|
470
507
|
weights?: { bone: string; ramp: Array<{ weight: number; rgba: number[] }>; floor: number; reading: string };
|
|
471
508
|
/** With `density`: the ramp, its scale, and how area, totals and ratio are read. */
|
|
472
509
|
density?: { ramp: Array<{ ratio: number; rgba: number[] }>; scale: string; area: string; totals: string; ratio: string; reading: string };
|
|
510
|
+
/** With `folds`: the colour a folded triangle is filled with, the degenerate share, and what folded, degenerate and inverted mean. */
|
|
511
|
+
folds?: { rgba: number[]; meaning: string; degenerateShare: number; folded: string; degenerate: string; inverted: string; index: string };
|
|
473
512
|
/** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
|
|
474
513
|
builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
|
|
475
514
|
coordinates: { world: string; image: string; toPixel: string; toWorld: string };
|
|
@@ -1138,7 +1177,7 @@ interface Gathered {
|
|
|
1138
1177
|
sha: string;
|
|
1139
1178
|
}
|
|
1140
1179
|
|
|
1141
|
-
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density'];
|
|
1180
|
+
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density', 'folds'];
|
|
1142
1181
|
const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
|
|
1143
1182
|
|
|
1144
1183
|
/** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
|
|
@@ -1357,6 +1396,16 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
|
|
|
1357
1396
|
},
|
|
1358
1397
|
}
|
|
1359
1398
|
: {}),
|
|
1399
|
+
...(request.show.includes('folds')
|
|
1400
|
+
? {
|
|
1401
|
+
folds: {
|
|
1402
|
+
rgba: [...FOLD_COLOUR.rgba],
|
|
1403
|
+
meaning: FOLD_COLOUR.meaning,
|
|
1404
|
+
degenerateShare: DEGENERATE_SHARE,
|
|
1405
|
+
...FOLDS_READING,
|
|
1406
|
+
},
|
|
1407
|
+
}
|
|
1408
|
+
: {}),
|
|
1360
1409
|
...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
|
|
1361
1410
|
coordinates: {
|
|
1362
1411
|
world: 'spine world: y up, world units',
|
|
@@ -1521,6 +1570,37 @@ function drawTile(
|
|
|
1521
1570
|
}
|
|
1522
1571
|
legendTile.meshes.push(entry);
|
|
1523
1572
|
}
|
|
1573
|
+
// ── folds: each kept mesh's turned-over triangles filled, over the heat and under the edges (issue #1383) ──
|
|
1574
|
+
if (request.show.includes('folds')) {
|
|
1575
|
+
legendTile.folds = [];
|
|
1576
|
+
for (const shown of frame.attachments ?? []) {
|
|
1577
|
+
if (slotsOut.has(shown.slot)) continue;
|
|
1578
|
+
const rest = build.rest.get(restKey(shown.slot, shown.attachment));
|
|
1579
|
+
if (rest === undefined || rest.kind !== 'mesh') continue;
|
|
1580
|
+
const setup = setupReading(rest);
|
|
1581
|
+
const folded = foldedAt(rest, setup, shown.vertices);
|
|
1582
|
+
const { degenerate, inverted } = setup.faults;
|
|
1583
|
+
const fill = plant.foldsFillAll ? Array.from({ length: rest.triangles.length / 3 }, (_, i) => i) : folded;
|
|
1584
|
+
const at = (v: number): [number, number] => {
|
|
1585
|
+
const [px, py] = toPixel(shown.vertices[2 * v], shown.vertices[2 * v + 1]);
|
|
1586
|
+
return [tx(px), ty(py)];
|
|
1587
|
+
};
|
|
1588
|
+
for (const t of fill) fillTriangle(tile, [at(rest.triangles[3 * t]), at(rest.triangles[3 * t + 1]), at(rest.triangles[3 * t + 2])], [...FOLD_COLOUR.rgba] as RGBA);
|
|
1589
|
+
if (folded.length === 0 && degenerate.length === 0 && inverted.length === 0) continue;
|
|
1590
|
+
legendTile.folds.push({ slot: shown.slot, attachment: shown.attachment, triangleCount: rest.triangles.length / 3, folded, degenerate, inverted });
|
|
1591
|
+
}
|
|
1592
|
+
if (first) {
|
|
1593
|
+
const count = legendTile.folds.reduce((n, f) => n + f.folded.length, 0);
|
|
1594
|
+
const meshes = legendTile.folds.filter((f) => f.folded.length > 0);
|
|
1595
|
+
notes.push(
|
|
1596
|
+
count === 0
|
|
1597
|
+
? `folds: no mesh${request.slotFilter === undefined ? '' : ' the filter keeps'} has a triangle folded over at this pose — nothing filled`
|
|
1598
|
+
: `folds: ${count} triangle(s) folded over across ${meshes.length} mesh(es) — ${meshes.map((f) => `"${f.slot}" [${f.folded.join(', ')}]`).join(', ')} — every one is in the legend`,
|
|
1599
|
+
);
|
|
1600
|
+
const faulted = legendTile.folds.filter((f) => f.degenerate.length > 0 || f.inverted.length > 0);
|
|
1601
|
+
if (faulted.length > 0) notes.push(`folds: setup faults, never counted — ${faulted.map((f) => `"${f.slot}" degenerate [${f.degenerate.join(', ')}] inverted [${f.inverted.join(', ')}]`).join(', ')}`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1524
1604
|
for (const entry of legendTile.meshes) {
|
|
1525
1605
|
if (entry.kind !== 'mesh') continue;
|
|
1526
1606
|
anyMesh = true;
|
package/src/render.ts
CHANGED
|
@@ -128,7 +128,7 @@ import { readFileSync } from 'node:fs';
|
|
|
128
128
|
// `loadPosedSkeleton`, and kept, the plant reads red for its own reason rather than for a missing name.
|
|
129
129
|
import { dirname, join } from 'node:path';
|
|
130
130
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
131
|
-
import { clipSourceOf, corePoser, inactiveBoneSnapshot, subsetOver, unposedBones } from './render_core.ts';
|
|
131
|
+
import { clipSourceOf, corePoser, inactiveBoneSnapshot, physicsClockStep, subsetOver, unposedBones } from './render_core.ts';
|
|
132
132
|
import { type PosedVertices, type WorldTransform } from './nonfinite.ts';
|
|
133
133
|
import {
|
|
134
134
|
atlasScales,
|
|
@@ -488,9 +488,10 @@ export type PoseSource = Poser | SkeletonData;
|
|
|
488
488
|
* because that is the path a runtime actually takes, and 4.3's
|
|
489
489
|
* `Animation.apply` takes a `MixFrom` that only the state machine has any
|
|
490
490
|
* business choosing. Frame 0 applies, updates by 0 and resets physics; every
|
|
491
|
-
* later frame is one `state.update(1/fps)`, apply, `skeleton.update
|
|
491
|
+
* later frame is one `state.update(1/fps)`, apply, `skeleton.update` and
|
|
492
492
|
* `Physics.update` — one continuous trajectory, so the track time of frame `i`
|
|
493
|
-
* is the sum of `i` steps.
|
|
493
|
+
* is the sum of `i` steps. The skeleton's clock, which only the physics reads,
|
|
494
|
+
* moves by `physicsClockStep(1/fps)` (`PHYSICS_CLOCK_LEAD`, issue #1380).
|
|
494
495
|
*/
|
|
495
496
|
export function spinePoser(data: SkeletonData): Poser {
|
|
496
497
|
return {
|
|
@@ -507,12 +508,14 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
507
508
|
state.setAnimation(0, name, false);
|
|
508
509
|
skeleton.setupPose();
|
|
509
510
|
const step = 1 / fps;
|
|
511
|
+
// The physics clock runs `PHYSICS_CLOCK_LEAD` fast, so no frame ends on a physics step's tie (issue #1380).
|
|
512
|
+
const clock = physicsClockStep(step);
|
|
510
513
|
const posed = spinePosed(skeleton);
|
|
511
514
|
for (let i = 0; i <= count; i++) {
|
|
512
515
|
if (i > 0) {
|
|
513
516
|
state.update(step);
|
|
514
517
|
state.apply(skeleton);
|
|
515
|
-
skeleton.update(
|
|
518
|
+
skeleton.update(clock);
|
|
516
519
|
skeleton.updateWorldTransform(Physics.update);
|
|
517
520
|
} else {
|
|
518
521
|
state.apply(skeleton);
|
package/src/render_core.ts
CHANGED
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
* frame of the nineteen tree rows by the core suite's `CR03`. The setup pose
|
|
29
29
|
* is `poseRawSetup`. Since issue #1180 the walk is `poseRawAnimationEach`,
|
|
30
30
|
* the same walk handing each pose over as it is posed (`CORE_RAW_WALK`), so
|
|
31
|
-
* one pose is held at a time.
|
|
31
|
+
* one pose is held at a time. Since issue #1380 the physics clock — and
|
|
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
|
|
34
|
+
* under the step itself, and the loop-seam suite's `LS06` holds the two
|
|
35
|
+
* posers equal under the render's clock.
|
|
32
36
|
* - **Vertices, triangles, bones, slot colours, draw order** are the raw
|
|
33
37
|
* pose's, unchanged: the same doubles `computeWorldVertices`,
|
|
34
38
|
* `getWorldRotationX` and the slot pose hold (`CR03`).
|
|
@@ -361,8 +365,65 @@ export function keptPieceArrays(): PieceArrays {
|
|
|
361
365
|
*/
|
|
362
366
|
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void) => void;
|
|
363
367
|
|
|
364
|
-
/** The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next. */
|
|
365
|
-
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit);
|
|
368
|
+
/** The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next, the physics clock moved by `physicsClockStep` (issue #1380). */
|
|
369
|
+
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep);
|
|
370
|
+
|
|
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.
|
|
420
|
+
*/
|
|
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
|
+
}
|
|
366
427
|
|
|
367
428
|
/** What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
|
|
368
429
|
export interface CorePoserPlants {
|