spine-rigc 1.4.0 → 1.5.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/cli.ts +8 -5
- package/docs/AUTHORING.md +128 -26
- package/package.json +1 -1
- package/src/atlas.ts +145 -24
- package/src/check.ts +22 -0
- package/src/pose.ts +235 -13
- package/src/render.ts +160 -22
package/cli.ts
CHANGED
|
@@ -103,7 +103,6 @@ import {
|
|
|
103
103
|
DEFAULT_PADDING,
|
|
104
104
|
DEFAULT_PAGE_EDGES,
|
|
105
105
|
DEFAULT_PAGE_SIZE,
|
|
106
|
-
FREE_EDGE_STEP,
|
|
107
106
|
PAGE_EDGES,
|
|
108
107
|
packAtlas,
|
|
109
108
|
type PageEdges,
|
|
@@ -2054,6 +2053,9 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
2054
2053
|
if (skin !== undefined) console.log(` .. skin ${skin}`);
|
|
2055
2054
|
if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
|
|
2056
2055
|
|
|
2056
|
+
// `null` is a skeleton that posed no vertex at all. One that posed a vertex
|
|
2057
|
+
// it cannot frame — Infinity or NaN — is thrown from the framing as a
|
|
2058
|
+
// `GeometryError` naming the number (issue #873), and never reaches this.
|
|
2057
2059
|
const viewport = framingViewport(data, maxSide, pose);
|
|
2058
2060
|
if (!viewport) {
|
|
2059
2061
|
throw new UsageError(
|
|
@@ -3825,7 +3827,7 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
3825
3827
|
padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
|
|
3826
3828
|
"extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
|
|
3827
3829
|
'page-edges': `what a page's edges may be, --pack only (default ${DEFAULT_PAGE_EDGES}): pot is a power of two on ` +
|
|
3828
|
-
|
|
3830
|
+
'both; free tries every width from the widest part up, takes the height the placement needs and keeps ' +
|
|
3829
3831
|
'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
|
|
3830
3832
|
'build rather than exactly',
|
|
3831
3833
|
'atlas-in':
|
|
@@ -4539,9 +4541,10 @@ try {
|
|
|
4539
4541
|
// the one reader's and already names the file, what it is and what rigc
|
|
4540
4542
|
// reads; a stack under it is the tool describing its own internals instead.
|
|
4541
4543
|
// Exit 1, like a compile error: the invocation was fine, a file was not.
|
|
4542
|
-
// A pose
|
|
4543
|
-
//
|
|
4544
|
-
//
|
|
4544
|
+
// A pose that is not finite (issues #864, #873): the invocation was fine and
|
|
4545
|
+
// the skeleton posed a NaN or an infinity, so exit 1 like a file that is not
|
|
4546
|
+
// a PNG. Raised by the geometry export and by the framing — the one sentence
|
|
4547
|
+
// naming the bone or vertex and its value — before the first file is written.
|
|
4545
4548
|
if (err instanceof GeometryError) {
|
|
4546
4549
|
console.error(`rigc render: ${err.message}`);
|
|
4547
4550
|
process.exit(1);
|
package/docs/AUTHORING.md
CHANGED
|
@@ -341,9 +341,12 @@ a gap:
|
|
|
341
341
|
|
|
342
342
|
Under `pot` turning shrank no page. Under `free` the largest gain, 13.37 %, is
|
|
343
343
|
inside the search's own noise: the neighbouring width on the 32-pixel grid
|
|
344
|
-
moves that set's unturned page by 18.13 %, and trying
|
|
345
|
-
finds an unturned page 10.22 % smaller. With every width
|
|
346
|
-
more than 5.05 % from turning — so the packer still never
|
|
344
|
+
`free` then searched moves that set's unturned page by 18.13 %, and trying
|
|
345
|
+
every width instead finds an unturned page 10.22 % smaller. With every width
|
|
346
|
+
tried, no set gains more than 5.05 % from turning — so the packer still never
|
|
347
|
+
turns a region. (The `free` column is that day's 32-pixel grid. `free` has
|
|
348
|
+
tried every width since issue #872 — below — so the 5.05 % is the turn gain on
|
|
349
|
+
the search that ships.)
|
|
347
350
|
- **no re-ordering of anything the skeleton says.** `skeleton.json` from a packed
|
|
348
351
|
build is **byte-identical** to the unpacked one, because sizes are still
|
|
349
352
|
measured from the loose PNGs and packing is an output arrangement.
|
|
@@ -383,7 +386,7 @@ every power-of-two pair up to `--page-size` in order of area and writes the firs
|
|
|
383
386
|
the pack fits — it never doubles past a smaller page that would do. So when the
|
|
384
387
|
parts' own area already exceeds the next power-of-two page down, no `pot` packer
|
|
385
388
|
can do better, and `free` is the lever left. Under `free` the candidate widths
|
|
386
|
-
are
|
|
389
|
+
are **every width** from the widest padded part up to `--page-size`; at each
|
|
387
390
|
width the parts are placed by the same MaxRects pass on a page `--page-size`
|
|
388
391
|
tall, and the height is the bottom edge of the lowest part. The page with the
|
|
389
392
|
least area wins, then the squarer one (smaller |width − height|), then the
|
|
@@ -391,13 +394,65 @@ narrower one — every candidate has a different width, so the order is total an
|
|
|
391
394
|
two builds of the same parts write the same bytes. Spilling, the packing order
|
|
392
395
|
and `--page-size` as the ceiling are the same as under `pot`.
|
|
393
396
|
|
|
397
|
+
Every width is affordable because of two bounds, and neither can change the
|
|
398
|
+
page: a width is not tried when the parts' area cannot fit it or when its
|
|
399
|
+
tallest part alone already makes it larger than the best page so far, and a
|
|
400
|
+
placement stops as soon as its lowest part makes it larger than that best page.
|
|
401
|
+
Both are strict, so a width that could tie the best is always placed to the end.
|
|
402
|
+
|
|
403
|
+
**Why every width, measured (issue #872).** Until then the widths were the
|
|
404
|
+
multiples of 32. On twelve region sets — the two painting rigs below and ten
|
|
405
|
+
production rigs of 20 to 22 parts, the sets of the rotation table above — and,
|
|
406
|
+
as a held-out check, on the ten atlases of the fetched `examples/` corpus, each
|
|
407
|
+
search was run against the every-width page (padding 2, `--page-size` 2048).
|
|
408
|
+
Area change against the 32-pixel grid:
|
|
409
|
+
|
|
410
|
+
| Set | 32 px (before) | step 8 | step 4 | coarse 32 → ±31 px of the best 2 | coarse 12 → ±11 px of the best | every width (now) |
|
|
411
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
412
|
+
| 22-part painting | 1888x697 | −1.58 % | −1.58 % | −1.68 % | −1.68 % | 967x1338, −1.68 % |
|
|
413
|
+
| 20-part painting | 480x1166 | 0.00 % | 0.00 % | −0.21 % | −0.21 % | 479x1166, −0.21 % |
|
|
414
|
+
| P1 | 416x1633 | −1.92 % | −2.88 % | −2.88 % | −2.88 % | 404x1633, −2.88 % |
|
|
415
|
+
| P2 | 1184x810 | 0.00 % | 0.00 % | −0.08 % | −0.08 % | 1183x810, −0.08 % |
|
|
416
|
+
| P3 | 480x1000 | 0.00 % | 0.00 % | 0.00 % | 0.00 % | 480x1000, 0.00 % |
|
|
417
|
+
| P4 | 864x1346 | −3.07 % | −3.07 % | −2.66 % | −3.15 % | 1231x915, −3.15 % |
|
|
418
|
+
| P5 | 1344x1768 | −9.90 % | −10.16 % | −10.22 % | −10.22 % | 1427x1495, −10.22 % |
|
|
419
|
+
| P6 | 800x1934 | −1.00 % | −1.50 % | −1.50 % | −1.50 % | 788x1934, −1.50 % |
|
|
420
|
+
| P7 | 1024x928 | −0.78 % | −0.78 % | −0.88 % | −0.88 % | 1015x928, −0.88 % |
|
|
421
|
+
| P8 | 608x1250 | 0.00 % | 0.00 % | −0.16 % | −0.16 % | 607x1250, −0.16 % |
|
|
422
|
+
| P9 | 1024x2037 | −2.34 % | −2.73 % | −2.93 % | −2.93 % | 994x2037, −2.93 % |
|
|
423
|
+
| P10 | 352x1433 | 0.00 % | −1.14 % | −1.70 % | −1.70 % | 346x1433, −1.70 % |
|
|
424
|
+
| worst miss of the every-width page, twelve sets | 11.38 % | 1.73 % | 0.58 % | 0.50 % | 0.00 % | 0.00 % |
|
|
425
|
+
| worst miss, the ten `examples/` atlases | 4.12 % | 2.67 % | 0.77 % | 0.82 % | 0.82 % | 0.00 % |
|
|
426
|
+
| cell placements, twelve sets | 12,361 | 49,063 | 98,021 | 40,230 | 38,371 | 47,978 |
|
|
427
|
+
|
|
428
|
+
The rule was the cheapest search that lands on the every-width page. Flipping
|
|
429
|
+
the packer's own last tie-break (leftmost before topmost) changes no page on any
|
|
430
|
+
of the 22 sets, so there is no tie-break noise to land "within": the target is
|
|
431
|
+
the page itself. The coarse-to-fine search that met it on all twelve sets missed
|
|
432
|
+
it on the first held-out atlas, and its failures do not follow its grid — on
|
|
433
|
+
the twelve sets, refining around the best width of a 9-pixel grid misses where
|
|
434
|
+
10 to 12 do not, and a 14-pixel grid misses again —
|
|
435
|
+
because the area as a function of the width has minima one pixel wide (a 4-pixel
|
|
436
|
+
grid refined around its best four widths still misses one). Every width is exact
|
|
437
|
+
by construction. Its placement count overstates its cost, because an abandoned
|
|
438
|
+
placement stops early: all twelve sets take 53 ms in the measuring harness,
|
|
439
|
+
against 22 ms for the 32-pixel grid and 810 ms for every width unbounded, and a
|
|
440
|
+
whole `build --pack --page-edges free` of the 22-part rig takes the same wall
|
|
441
|
+
time either way. ⚠️ The cost grows with the part count faster than the grid's:
|
|
442
|
+
on generated sets of 100 and 300 small parts, where no bound prunes much, the
|
|
443
|
+
search places 15 times the grid's cells.
|
|
444
|
+
|
|
394
445
|
The default stays `pot` because every runtime accepts a power-of-two page and
|
|
395
446
|
the editor's own packer writes one by default: 9 of the 10 atlases in the
|
|
396
447
|
fetched `examples/` corpus are power-of-two on both edges. What `free` costs is
|
|
397
448
|
the exactness above. `x / pageWidth` is no longer exact, so a **region**
|
|
398
449
|
attachment's samples join a mesh's at one least significant bit against the
|
|
399
450
|
loose build instead of 0. No region's bytes change: every one lifts back byte for
|
|
400
|
-
byte, as under `pot`. Observed on two painting rigs of 22 and 20 parts
|
|
451
|
+
byte, as under `pot`. Observed on two painting rigs of 22 and 20 parts, on the
|
|
452
|
+
32-pixel grid `free` shipped with (issue #860); every width now packs them at
|
|
453
|
+
967x1338 = 1,293,846 (−38.3 %, 91.2 % covered, 59.7 % opaque) and 479x1166 =
|
|
454
|
+
558,514 (−46.7 %, 93.4 % covered, 53.9 % opaque), and the render columns are
|
|
455
|
+
held to their bound of one by `PK72` on every build rather than re-measured here:
|
|
401
456
|
|
|
402
457
|
| Rig | `pot` page | `free` page | Area | Covered | Opaque (alpha > 0) | Render vs loose, region attachments only | Render vs loose, whole rig |
|
|
403
458
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
@@ -1443,6 +1498,20 @@ renderers and reports its step 5 as a **SKIP** naming that, and the round trip
|
|
|
1443
1498
|
comes back green on the strength of `validate` and `diff`. A skin only **one** side
|
|
1444
1499
|
can draw is the other case entirely, and stays red.
|
|
1445
1500
|
|
|
1501
|
+
🔢 **"Nothing to draw" is said only of a skeleton that posed no vertex at all.** One
|
|
1502
|
+
whose attachment is there but posed to `Infinity` or `NaN` is refused by the bone or
|
|
1503
|
+
the vertex that holds it and the value — `rigc render: the setup pose: bone "head" has
|
|
1504
|
+
worldX Infinity; a world transform is finite`, or `… animation "turn" frame 31 at 60
|
|
1505
|
+
fps (t=0.5167s): bone "head" has a NaN; …` for a key, or `… slot "x" attachment "y"
|
|
1506
|
+
vertex 3 has x NaN; a posed vertex is finite` — exit 1, before anything is written.
|
|
1507
|
+
It is the sentence `render --geometry` refuses on (§8.2), from the same code, and
|
|
1508
|
+
`rigc check` prints it after `the candidate is posed to a number that is not finite,
|
|
1509
|
+
so no frame of it can be compared:`. The bone is named before its vertices because
|
|
1510
|
+
it is the cause; look for its value in the skeleton file — a setup `x`/`y`/`rotation`,
|
|
1511
|
+
or a timeline key at the time given — and, for a rig spec you wrote, in `rigc explain`,
|
|
1512
|
+
which prints every bone's local transform. A frame's time is the framing pass's own
|
|
1513
|
+
(every animation at 60 fps); the key that overflowed is at or just before it.
|
|
1514
|
+
|
|
1446
1515
|
**Region attachment** ([Spine: region attachments](http://esotericsoftware.com/spine-regions)),
|
|
1447
1516
|
the default `type`:
|
|
1448
1517
|
|
|
@@ -5359,6 +5428,13 @@ rather than linked, like the references at the top of this page: that page cites
|
|
|
5359
5428
|
corpus inventory, and a ladder run reads this guide without following its references
|
|
5360
5429
|
out of it.)
|
|
5361
5430
|
|
|
5431
|
+
🔢 **A fourth surface is a pose that is not finite**, which `render` and `check`
|
|
5432
|
+
refuse before drawing anything, naming the bone or vertex and its value. `validate`
|
|
5433
|
+
does not always get there first: a bone `rotation` of `1e309`, in the setup pose or
|
|
5434
|
+
in a key, was measured leaving `A10_NO_NAN_AFTER_STEPPING` green, because the bone's
|
|
5435
|
+
world position stays finite while its matrix is `NaN`. The sentence, and where to
|
|
5436
|
+
look for the number, is under *nothing to draw* in §3.4.
|
|
5437
|
+
|
|
5362
5438
|
### 5.1 Compile errors — before the gate
|
|
5363
5439
|
|
|
5364
5440
|
A `CompileError` names the object and the field, and nothing is written.
|
|
@@ -6366,8 +6442,13 @@ show what the clip left of them. Bounding boxes, points, paths and clipping poly
|
|
|
6366
6442
|
draw nothing and are not listed. Numbers are printed the way every JSON file this
|
|
6367
6443
|
tool writes prints them — the shortest decimal that reads back as the same double —
|
|
6368
6444
|
so a vertex in the file *is* the runtime's vertex, not a rounding of it; a pose that
|
|
6369
|
-
is not finite is refused
|
|
6370
|
-
|
|
6445
|
+
is not finite is refused rather than written as `null`, naming where — the setup pose,
|
|
6446
|
+
`animation "a" frame i at F fps (t=…s)`, or the rest table — and then the bone and
|
|
6447
|
+
its world-transform field, or the slot, the attachment and the vertex, with the
|
|
6448
|
+
value. Bones come first, so a bone that overflowed is named as itself rather than as
|
|
6449
|
+
its first vertex. Since issue #873 plain `render` refuses the same pose in the same
|
|
6450
|
+
sentence while framing it (§3.4's note under *nothing to draw*), so the flag is not
|
|
6451
|
+
needed to find out which number it was.
|
|
6371
6452
|
|
|
6372
6453
|
**The two judgements the file was designed for**, written once so a consumer
|
|
6373
6454
|
implements what the file promises:
|
|
@@ -8732,7 +8813,7 @@ ambiguous one ends with a sentence saying which reading its figures support:
|
|
|
8732
8813
|
|
|
8733
8814
|
- **below the floor** — a 40x28 part of one flat colour, cut from a plain block it fits anywhere inside:
|
|
8734
8815
|
|
|
8735
|
-
> part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0;
|
|
8816
|
+
> part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0; 17 candidate(s), best 0.0000, next 0.0000, spread 0.0000 — below the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up): pose placed 7% of the measured parts this plain, so this part is too plain for pose to tell its placements apart; that says nothing about whether the cut is right
|
|
8736
8817
|
|
|
8737
8818
|
- **above the floor** — a 200x150 black-and-white checker the frame does not hold:
|
|
8738
8819
|
|
|
@@ -8759,9 +8840,9 @@ Found within 2 px of the truth, out of 15 trials per cell (free search, default
|
|
|
8759
8840
|
|
|
8760
8841
|
| texture level (mean) | 24 px | 32 px | 48 px | 64 px | 96 px | 128 px | 192 px |
|
|
8761
8842
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
8762
|
-
| `native` (detail 3.49) |
|
|
8763
|
-
| `blur1` (detail 1.58) |
|
|
8764
|
-
| `blur2` (detail 0.59) |
|
|
8843
|
+
| `native` (detail 3.49) | 13/15 (2 amb) | 15/15 | 15/15 | 15/15 | 13/15 (1 amb, 1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
|
|
8844
|
+
| `blur1` (detail 1.58) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 13/15 (2 amb) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
|
|
8845
|
+
| `blur2` (detail 0.59) | 9/15 (5 amb, 1 wrong) | 9/15 (4 amb, 2 wrong) | 9/15 (4 amb, 2 wrong) | 8/15 (3 amb, 4 wrong) | 7/15 (3 amb, 5 wrong) | 6/15 (4 amb, 5 wrong) | 7/15 (3 amb, 5 wrong) |
|
|
8765
8846
|
| `flat` (detail 0.00) | 0/15 (14 amb, 1 wrong) | 0/15 (14 amb, 1 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (11 amb, 4 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (12 amb, 3 wrong) |
|
|
8766
8847
|
|
|
8767
8848
|
The same trials, grouped by `detail`:
|
|
@@ -8769,14 +8850,14 @@ The same trials, grouped by `detail`:
|
|
|
8769
8850
|
| detail | found | ambiguous | wrong | refused |
|
|
8770
8851
|
| --- | --- | --- | --- | --- |
|
|
8771
8852
|
| under 0.25 | 8/119 | 89 | 22 | 0 |
|
|
8772
|
-
| 0.25–0.5 |
|
|
8773
|
-
| 0.5–0.75 |
|
|
8853
|
+
| 0.25–0.5 | 1/3 | 1 | 1 | 0 |
|
|
8854
|
+
| 0.5–0.75 | 48/81 | 14 | 19 | 0 |
|
|
8774
8855
|
| 0.75–1 | 17/34 | 14 | 3 | 0 |
|
|
8775
|
-
| 1–1.5 |
|
|
8776
|
-
| 1.5–2 | 34/48 |
|
|
8777
|
-
| 2–3 |
|
|
8778
|
-
| 3–4 |
|
|
8779
|
-
| 4 and over |
|
|
8856
|
+
| 1–1.5 | 25/25 | 0 | 0 | 0 |
|
|
8857
|
+
| 1.5–2 | 34/48 | 12 | 2 | 0 |
|
|
8858
|
+
| 2–3 | 40/44 | 1 | 3 | 0 |
|
|
8859
|
+
| 3–4 | 34/34 | 0 | 0 | 0 |
|
|
8860
|
+
| 4 and over | 30/32 | 2 | 0 | 0 |
|
|
8780
8861
|
|
|
8781
8862
|
**Method.** The truth set is `examples/spineboy`: its setup pose and one frame of
|
|
8782
8863
|
`walk` (a third of the way in). For every trial the figure is rendered twice with one
|
|
@@ -8798,8 +8879,8 @@ part has a low per-pixel gradient and still places — and size alone has no
|
|
|
8798
8879
|
measurable effect across 24–192 px at any level.
|
|
8799
8880
|
|
|
8800
8881
|
**The edge**, derived by `deriveFloor` as the largest rung of a fixed ladder under
|
|
8801
|
-
which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **
|
|
8802
|
-
of the trials were found (
|
|
8882
|
+
which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **7.4%**
|
|
8883
|
+
of the trials were found (9/122); at or over it, **76.5%** (228/298). A part is below
|
|
8803
8884
|
the floor when its `detail` is under 0.5, or when its own longest side is under 24 px,
|
|
8804
8885
|
the smallest size measured (there, nothing measured says `pose` can place it).
|
|
8805
8886
|
|
|
@@ -8813,17 +8894,35 @@ two anchors — and only 3 had it ranked under the old cutoff of three. The fine
|
|
|
8813
8894
|
took the grid from 185 to 227 trials found and those misses from 65 to 14, for about
|
|
8814
8895
|
10–14% more CPU over the grid (615 s of user time before it, 678 s and 704 s on two runs after).
|
|
8815
8896
|
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8897
|
+
The refinement then polishes each place down the pyramid, and at full resolution a
|
|
8898
|
+
polish that has stopped tries its scale **up by half a coarse rung** (2^(1/6)), re-fits
|
|
8899
|
+
the position there, and restarts if that scores better; fifteen places survive each
|
|
8900
|
+
level. Both were set on this grid too (issue #877): of the 14 misses left after #865,
|
|
8901
|
+
12 reached full resolution with the truth's place ranked first or second and the
|
|
8902
|
+
full-resolution objective preferring the truth, and stopped 2–7 px off it at a shrunk
|
|
8903
|
+
scale — the objective couples scale to position along a diagonal, so every
|
|
8904
|
+
one-axis step from there is worse while a joint one is better. None of them had
|
|
8905
|
+
accepted a worse residual. One more was the 24 px shin, its truth's place ranked
|
|
8906
|
+
15th at the 2x level and cut at twelve. Up only because the objective charges part
|
|
8907
|
+
pixels that land off the figure and nothing for figure left uncovered, so the
|
|
8908
|
+
reduced levels err small: every stuck point sat below scale 1. It took the grid from
|
|
8909
|
+
227 to 237 found and the misses from 14 to 8 (12 trials gained, 2 lost — each a
|
|
8910
|
+
blurred part now reported with a second placement at the same spot and another
|
|
8911
|
+
scale, one of them with the truth itself as its best answer), for 43% more
|
|
8912
|
+
refinement samples and the coarse pass untouched.
|
|
8913
|
+
|
|
8914
|
+
⚠️ **What this is and is not.** The grid is not a reliability line: five native cells
|
|
8915
|
+
place 14 or 15 of 15 and the trials at `detail` 3 and over place 64 of 66, but the
|
|
8916
|
+
goggles still place 12 of 42, and a threshold read off two dozen parts of one figure
|
|
8819
8917
|
would be a claim about that figure. What the grid can separate is **whose** failure a
|
|
8820
8918
|
failure is. `bun tools/pose_floor.ts` prints the residual at the truth for every
|
|
8821
8919
|
failed trial and marks it **missed** when the truth scores better than everything
|
|
8822
8920
|
reported and nothing reported is on it — a failure the search could fix. The other
|
|
8823
8921
|
failures are the objective preferring another placement (a blurred part shrunk into
|
|
8824
8922
|
its own region scores better than its truth) or the truth reported and tied, and no
|
|
8825
|
-
search can fix those. After
|
|
8826
|
-
|
|
8923
|
+
search can fix those. After #877, 8 of the 183 failed trials are misses, and 7 of
|
|
8924
|
+
those sit within 3.1 px of the truth rather than on another hill; the `--from`
|
|
8925
|
+
listing prints, for each, where the refinement left the truth level by level. All this was
|
|
8827
8926
|
measured on **ideal cuts**, the frame's own pixels, where a residual at the truth is
|
|
8828
8927
|
near zero. A generated cut also disagrees with its picture, which can only make it
|
|
8829
8928
|
harder to place. So the edge is where `pose` fails even on ideal cuts, and never a
|
|
@@ -8833,7 +8932,10 @@ Re-run it with `bun tools/pose_floor.ts --textures <level> --json <out>` (one le
|
|
|
8833
8932
|
process parallelises it), then `bun tools/pose_floor.ts --from <a.json>,<b.json>,…` to
|
|
8834
8933
|
print the table, derive the edge and list the misses per cell. `PO22` in
|
|
8835
8934
|
`bun run selftest` re-measures three of its cells, `PO24` holds those three cells to
|
|
8836
|
-
no miss at all, and `PO23` places the 48 px fist
|
|
8935
|
+
no miss at all, and `PO23` places the 48 px fist #865 was made for; `PO28` and
|
|
8936
|
+
`PO29` place the four trials #877 was made for and read where the shin survived the
|
|
8937
|
+
cutoff, and `PO26` holds every polish to strictly descending residuals, read through
|
|
8938
|
+
the same trace.
|
|
8837
8939
|
|
|
8838
8940
|
## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
|
|
8839
8941
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, round-trips it through spine-core and named assertions, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/atlas.ts
CHANGED
|
@@ -543,12 +543,18 @@ export interface EmitPage {
|
|
|
543
543
|
*
|
|
544
544
|
* Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
|
|
545
545
|
* it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
|
|
546
|
-
* noise: the nearest other width on the
|
|
547
|
-
* unturned page by 18.13 %, and searching every width instead of every
|
|
548
|
-
* finds an unturned P5 page 10.22 % smaller with nothing turned. With every
|
|
546
|
+
* noise: the nearest other width on the 32-px grid the search then used moves
|
|
547
|
+
* P5's unturned page by 18.13 %, and searching every width instead of every
|
|
548
|
+
* 32nd finds an unturned P5 page 10.22 % smaller with nothing turned. With every
|
|
549
549
|
* width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
|
|
550
550
|
* (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
|
|
551
551
|
* packer still never turns a region.
|
|
552
|
+
*
|
|
553
|
+
* ⚠️ The `free` column and the 18.13 % are measurements of the search as it was
|
|
554
|
+
* on that day, a 32-px width grid. Issue #872 replaced it with every width (see
|
|
555
|
+
* `FREE_EDGE_STEP`), so today's `free` pages for these sets are the every-width
|
|
556
|
+
* ones the paragraph above compares against, and the 5.05 % is the turn gain
|
|
557
|
+
* measured on the search that ships.
|
|
552
558
|
*/
|
|
553
559
|
export const PACK_NO_ROTATE = 0;
|
|
554
560
|
|
|
@@ -628,7 +634,7 @@ export const DEFAULT_PADDING = 2;
|
|
|
628
634
|
* What a packed page's edges may be — `--page-edges`. See `packAtlas`, *Page size*.
|
|
629
635
|
*
|
|
630
636
|
* `pot` is the default and the only value until issue #860: both edges are powers
|
|
631
|
-
* of two. `free` lets the width be any
|
|
637
|
+
* of two. `free` lets the width be any whole number of pixels and the height
|
|
632
638
|
* whatever the placement needs. What `free` costs is measured rather than
|
|
633
639
|
* conceded: a region attachment's sampling coordinate stops being exact and moves
|
|
634
640
|
* to `PK05`'s bound of one least significant bit, the bound a mesh already had.
|
|
@@ -636,8 +642,28 @@ export const DEFAULT_PADDING = 2;
|
|
|
636
642
|
export const PAGE_EDGES = ['pot', 'free'] as const;
|
|
637
643
|
export type PageEdges = (typeof PAGE_EDGES)[number];
|
|
638
644
|
export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
|
|
639
|
-
/**
|
|
640
|
-
|
|
645
|
+
/**
|
|
646
|
+
* The step a `free` page's width is tried on: **1, every width** (issue #872).
|
|
647
|
+
* See `smallestFreePageFor` for the search and `packAtlas`, *Page size*, for
|
|
648
|
+
* what it buys.
|
|
649
|
+
*
|
|
650
|
+
* It was 32 until #872, and the grid was the cost. On twelve region sets (two
|
|
651
|
+
* painting rigs and ten production rigs of 20 to 22 parts) the 32-px grid's page
|
|
652
|
+
* was larger than the every-width page on eleven, by up to 11.38 % (P5: 1344x1768
|
|
653
|
+
* against 1427x1495). Finer fixed steps and coarse-to-fine searches were measured
|
|
654
|
+
* against the every-width page too, and none of them is safe: the area as a
|
|
655
|
+
* function of the width has minima one pixel wide, so a step-4 grid refined
|
|
656
|
+
* over ±3 px of its best four widths still misses one (the fetched
|
|
657
|
+
* `5-squash-and-stretch` atlas: 867x415 against 863x415), and the coarse-to-fine
|
|
658
|
+
* search that was exact on all twelve sets missed by 0.82 % on the first
|
|
659
|
+
* held-out atlas it met. Every width is exact by construction; what keeps it
|
|
660
|
+
* cheap is the two bounds `smallestFreePageFor` prunes with, and they are exact
|
|
661
|
+
* too.
|
|
662
|
+
*
|
|
663
|
+
* Kept as a named constant rather than deleted because it is importable, and a
|
|
664
|
+
* reader of the old value should find the new one rather than a missing name.
|
|
665
|
+
*/
|
|
666
|
+
export const FREE_EDGE_STEP = 1;
|
|
641
667
|
|
|
642
668
|
/** The part a page is packed from: its region name and its pixels. */
|
|
643
669
|
export interface PackInput {
|
|
@@ -751,9 +777,8 @@ function smallestPageFor(
|
|
|
751
777
|
*
|
|
752
778
|
* The rule, which `docs/AUTHORING.md` §0.1 states in the same words:
|
|
753
779
|
*
|
|
754
|
-
* * the candidate WIDTHS are
|
|
755
|
-
*
|
|
756
|
-
* only adds anything when it is smaller than the step);
|
|
780
|
+
* * the candidate WIDTHS are every whole width from the widest cell up to
|
|
781
|
+
* `maxEdge` (`FREE_EDGE_STEP` is 1);
|
|
757
782
|
* * at each width the cells are placed by the same MaxRects pass as a `pot`
|
|
758
783
|
* page, on a page `maxEdge` tall, and the HEIGHT is the bottom edge of the
|
|
759
784
|
* lowest cell — what that placement needs, not rounded;
|
|
@@ -766,26 +791,84 @@ function smallestPageFor(
|
|
|
766
791
|
* in the page height (a shorter page changes which free rectangle scores best),
|
|
767
792
|
* so "the least height that fits" is not a quantity a bisection could find, and a
|
|
768
793
|
* definition that could not be computed exactly would not be deterministic.
|
|
794
|
+
*
|
|
795
|
+
* ⭐ **Two bounds make every width cheap, and neither can change the answer**
|
|
796
|
+
* (issue #872). Both compare against the best page found so far, and both are
|
|
797
|
+
* strict, so a width that could still TIE the best — and win on the squarer or
|
|
798
|
+
* narrower key — is always placed to the end:
|
|
799
|
+
*
|
|
800
|
+
* * a width is not placed at all when the cells' own area exceeds
|
|
801
|
+
* `width x maxEdge` (it cannot fit), or when `width x` the tallest cell
|
|
802
|
+
* already exceeds the best area (no page at that width is shorter than its
|
|
803
|
+
* tallest cell);
|
|
804
|
+
* * a placement is abandoned the moment its lowest cell's bottom edge makes
|
|
805
|
+
* `width x bottom` exceed the best area. The bottom edge only grows as cells
|
|
806
|
+
* are added, so the page that placement would have finished is larger still.
|
|
807
|
+
*
|
|
808
|
+
* Neither changes a placement that runs to the end — the pass is the `pot` pass,
|
|
809
|
+
* on the same page, and abandoning it only stops it early — so the winner is the
|
|
810
|
+
* every-width winner, and `PK75` compares the two on every set it builds. What
|
|
811
|
+
* they save, measured on the twelve sets: 391,854 cell placements for an
|
|
812
|
+
* unbounded every-width search, 47,978 bounded, against 12,361 for the retired
|
|
813
|
+
* 32-px grid.
|
|
769
814
|
*/
|
|
770
815
|
function smallestFreePageFor(
|
|
771
816
|
cells: Array<{ w: number; h: number }>,
|
|
772
817
|
maxEdge: number,
|
|
773
818
|
): { width: number; height: number; rects: Rect[] } | null {
|
|
819
|
+
return freePageSearch(cells, maxEdge).page;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** What one `free` page search did: the page it chose, and what each width cost. */
|
|
823
|
+
export interface FreePageSearch {
|
|
824
|
+
page: { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null;
|
|
825
|
+
/** Candidate widths, from the widest cell to `maxEdge`. */
|
|
826
|
+
widths: number;
|
|
827
|
+
/** Widths whose placement ran to the end. */
|
|
828
|
+
completed: number;
|
|
829
|
+
/** Widths whose placement stopped once its lowest cell made the page larger than the best so far. */
|
|
830
|
+
abandoned: number;
|
|
831
|
+
/** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
|
|
832
|
+
skipped: number;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* `smallestFreePageFor`'s search with its bookkeeping — exported so the
|
|
837
|
+
* selftest can compare its page against an unbounded every-width reference and
|
|
838
|
+
* see that the bounds did prune (`PK75`). The page is the whole of what the
|
|
839
|
+
* packer uses; the counts are for the control.
|
|
840
|
+
*/
|
|
841
|
+
export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number): FreePageSearch {
|
|
774
842
|
let widest = 0;
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
for (
|
|
778
|
-
|
|
843
|
+
let tallest = 0;
|
|
844
|
+
let cellArea = 0;
|
|
845
|
+
for (const cell of cells) {
|
|
846
|
+
widest = Math.max(widest, cell.w);
|
|
847
|
+
tallest = Math.max(tallest, cell.h);
|
|
848
|
+
cellArea += cell.w * cell.h;
|
|
849
|
+
}
|
|
850
|
+
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
|
|
779
851
|
let best: { width: number; height: number; rects: Rect[] } | null = null;
|
|
780
|
-
for (
|
|
781
|
-
|
|
852
|
+
for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
|
|
853
|
+
out.widths++;
|
|
854
|
+
const bestArea = best === null ? Infinity : best.width * best.height;
|
|
855
|
+
if (cellArea > width * maxEdge || width * tallest > bestArea) {
|
|
856
|
+
out.skipped++;
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
|
|
860
|
+
const attempt = packOnePage(cells, width, maxEdge, maxBottom);
|
|
861
|
+
let height = 0;
|
|
862
|
+
for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
|
|
863
|
+
if (height > maxBottom) {
|
|
864
|
+
out.abandoned++;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
out.completed++;
|
|
782
868
|
if (attempt.some((r) => r === null)) continue;
|
|
783
869
|
const rects = attempt as Rect[];
|
|
784
|
-
let height = 0;
|
|
785
|
-
for (const r of rects) height = Math.max(height, r.y + r.h);
|
|
786
870
|
if (best !== null) {
|
|
787
871
|
const area = width * height;
|
|
788
|
-
const bestArea = best.width * best.height;
|
|
789
872
|
if (area > bestArea) continue;
|
|
790
873
|
if (area === bestArea) {
|
|
791
874
|
const square = Math.abs(width - height);
|
|
@@ -796,7 +879,28 @@ function smallestFreePageFor(
|
|
|
796
879
|
}
|
|
797
880
|
best = { width, height, rects };
|
|
798
881
|
}
|
|
799
|
-
|
|
882
|
+
out.page = best;
|
|
883
|
+
return out;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* One `free` candidate, unbounded: the cells placed at `width` on a page
|
|
888
|
+
* `maxEdge` tall, and the height that placement needs — or `null` when they do
|
|
889
|
+
* not all fit. It is the pass `freePageSearch` runs at each width with no
|
|
890
|
+
* bound, exported so the selftest can rebuild a reference search from the same
|
|
891
|
+
* placement (`PK74`'s 32-px grid, `PK75`'s every width, `PK76`'s `pot` search).
|
|
892
|
+
*/
|
|
893
|
+
export function placeAtWidth(
|
|
894
|
+
cells: Array<{ w: number; h: number }>,
|
|
895
|
+
width: number,
|
|
896
|
+
height: number,
|
|
897
|
+
): { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null {
|
|
898
|
+
const attempt = packOnePage(cells, width, height);
|
|
899
|
+
if (attempt.some((r) => r === null)) return null;
|
|
900
|
+
const rects = attempt as Rect[];
|
|
901
|
+
let bottom = 0;
|
|
902
|
+
for (const r of rects) bottom = Math.max(bottom, r.y + r.h);
|
|
903
|
+
return { width, height: bottom, rects };
|
|
800
904
|
}
|
|
801
905
|
|
|
802
906
|
/**
|
|
@@ -813,8 +917,18 @@ function smallestFreePageFor(
|
|
|
813
917
|
* to be pushed. So the tie-break is spelled out and total — smallest long-side
|
|
814
918
|
* leftover, then topmost, then leftmost — and the caller sorts its input before
|
|
815
919
|
* calling. Same inputs, byte-identical page.
|
|
920
|
+
*
|
|
921
|
+
* `maxBottom` is the `free` search's abandon bound (see `freePageSearch`): once a
|
|
922
|
+
* placed cell's bottom edge passes it the pass stops, and the cells not placed
|
|
923
|
+
* are `null`. The default, `Infinity`, never stops a pass — which is every `pot`
|
|
924
|
+
* call, so a `pot` page is placed exactly as it was before the bound existed.
|
|
816
925
|
*/
|
|
817
|
-
function packOnePage(
|
|
926
|
+
function packOnePage(
|
|
927
|
+
cells: Array<{ w: number; h: number }>,
|
|
928
|
+
pageW: number,
|
|
929
|
+
pageH: number,
|
|
930
|
+
maxBottom = Infinity,
|
|
931
|
+
): Array<Rect | null> {
|
|
818
932
|
const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
|
|
819
933
|
const placed: Array<Rect | null> = [];
|
|
820
934
|
|
|
@@ -848,6 +962,10 @@ function packOnePage(cells: Array<{ w: number; h: number }>, pageW: number, page
|
|
|
848
962
|
}
|
|
849
963
|
const put: Rect = { x: best.x, y: best.y, w: cell.w, h: cell.h };
|
|
850
964
|
placed.push(put);
|
|
965
|
+
if (put.y + put.h > maxBottom) {
|
|
966
|
+
while (placed.length < cells.length) placed.push(null);
|
|
967
|
+
return placed;
|
|
968
|
+
}
|
|
851
969
|
|
|
852
970
|
// Split every free rectangle the placement overlaps, then prune.
|
|
853
971
|
const next: Rect[] = [];
|
|
@@ -981,15 +1099,18 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
981
1099
|
* Opt-in, and the default stays `pot`: every runtime accepts a power-of-two page
|
|
982
1100
|
* and the editor's own packer writes one by default — 9 of the 10 atlases in the
|
|
983
1101
|
* fetched `examples/` corpus are power-of-two on both edges. Under `free` the
|
|
984
|
-
* search is `smallestFreePageFor`:
|
|
985
|
-
*
|
|
1102
|
+
* search is `smallestFreePageFor`: every width from the widest cell up (issue
|
|
1103
|
+
* #872; a 32-px grid until then), the height the placement needs, least area
|
|
1104
|
+
* first, then squarer, then narrower, with two exact bounds that keep it cheap. Everything
|
|
986
1105
|
* else is shared — the MaxRects pass, the packing order, the spill rule (which
|
|
987
1106
|
* parts share a page is still decided at `maxEdge x maxEdge`) and `--page-size`
|
|
988
1107
|
* as the ceiling on both edges, floored to a power of two exactly as under `pot`.
|
|
989
1108
|
*
|
|
990
1109
|
* What it buys and what it costs, observed on two 20- and 22-part painting rigs:
|
|
991
|
-
* the page
|
|
992
|
-
* -
|
|
1110
|
+
* the page goes from 1024x2048 to 967x1338 (2,097,152 to 1,293,846 texels,
|
|
1111
|
+
* -38.3 %) and from 512x2048 to 479x1166 (1,048,576 to 558,514, -46.7 %). Under
|
|
1112
|
+
* the 32-px grid #860 shipped with they were 1888x697 (-37.3 %) and 480x1166
|
|
1113
|
+
* (-46.6 %). The cost is the exactness the paragraph above describes:
|
|
993
1114
|
* `x / pageWidth` is no longer exact, so a REGION attachment's sampling
|
|
994
1115
|
* coordinate joins a mesh's at `PK05`'s bound of one least significant bit
|
|
995
1116
|
* against the loose build. It does not go past it, and no region's bytes change
|
package/src/check.ts
CHANGED
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
PAD,
|
|
81
81
|
FRAMES_SIDECAR,
|
|
82
82
|
FRAMES_SPEC,
|
|
83
|
+
nonFinitePoseOf,
|
|
83
84
|
SHEET_COLUMNS,
|
|
84
85
|
SHEET_FILE,
|
|
85
86
|
SHEET_GAP,
|
|
@@ -1225,6 +1226,27 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
1225
1226
|
// to compare it — and posing twice is both slower and a chance for the framing
|
|
1226
1227
|
// and the comparison to disagree about what they measured.
|
|
1227
1228
|
const prepared = sets.map((set) => prepareSet(located.root, set, posable, options.as, poseOptions));
|
|
1229
|
+
// 🔒 A pose that is not finite is refused before anything measures it (issue
|
|
1230
|
+
// #873), in the words `render` and the geometry export use for it. Measured
|
|
1231
|
+
// on a planted bone at x=1e309 before this: an overflowing root read "posed no
|
|
1232
|
+
// drawable attachment", one at -1e309 "drew no pixel", and one at +1e309 or a
|
|
1233
|
+
// rotation at 1e309 exited 0 with MAE 1.00 — the part missing from every
|
|
1234
|
+
// frame, and nothing said why.
|
|
1235
|
+
const posed = prepared.filter((p) => p.missing === null);
|
|
1236
|
+
const overflow = posed.some((p) =>
|
|
1237
|
+
p.frames.some((frame) => frame.pieces.some((piece) => piece.world.some((value) => !Number.isFinite(value)))),
|
|
1238
|
+
);
|
|
1239
|
+
if (overflow) {
|
|
1240
|
+
const found = nonFinitePoseOf(
|
|
1241
|
+
posable.data,
|
|
1242
|
+
options.skin,
|
|
1243
|
+
posed.map((p) => ({ animation: p.candidateAnimation, fps: p.set.fps })),
|
|
1244
|
+
);
|
|
1245
|
+
if (found === null) {
|
|
1246
|
+
throw new Error('a drawn piece of the candidate is not finite and no bone or vertex of its pose is — the two were posed differently');
|
|
1247
|
+
}
|
|
1248
|
+
throw new CheckError(`the candidate is posed to a number that is not finite, so no frame of it can be compared: ${found}`);
|
|
1249
|
+
}
|
|
1228
1250
|
const pairs = prepared.flatMap((p) => p.pairs);
|
|
1229
1251
|
if (pairs.length === 0) {
|
|
1230
1252
|
notes.push('no reference frame has a candidate frame at the same index — nothing below was measured');
|
package/src/pose.ts
CHANGED
|
@@ -153,11 +153,23 @@ const COARSE_PART_THICKNESS = 4;
|
|
|
153
153
|
* the search misses fell from 65 to 15, for 10–14% more CPU over the grid.
|
|
154
154
|
*
|
|
155
155
|
* 🔸 Not free of losses, and they are stated rather than averaged away: 9 trials
|
|
156
|
-
* that placed on the quarter grid
|
|
157
|
-
* parts that
|
|
158
|
-
* 2.0 px off against a 2 px bar
|
|
159
|
-
*
|
|
160
|
-
*
|
|
156
|
+
* that placed on the quarter grid did not on this one when it shipped. Five are
|
|
157
|
+
* blurred parts at 24–96 px that came back ambiguous between two scales at the
|
|
158
|
+
* same spot, or 2.0 px off against a 2 px bar — the objective's, not this
|
|
159
|
+
* grid's. The other four were the refinement's, and since issue #877 all four
|
|
160
|
+
* place at their truth again: the native 48 px arm (walk) at 0.07 px, 48 px
|
|
161
|
+
* goggles (walk) 0.04, 96 px goggles (setup) 0.03 — three polishes stopped in a
|
|
162
|
+
* scale–position valley, now left by `POLISH_SCALE_ESCAPE` — and the 24 px shin
|
|
163
|
+
* (setup) 0.03, ranked 15th into full resolution, now carried by
|
|
164
|
+
* `REFINE_CANDIDATES`. Of the five blurred ones, the blur2 32 px shin (setup)
|
|
165
|
+
* and the blur1 24 px mouth are found again too.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ #877 lost two of its own, both inside the ambiguity margin rather than
|
|
168
|
+
* off the truth: the blur2 24 px front shin (walk) now reports the truth as its
|
|
169
|
+
* BEST (0.03 px, 0.00989) with the old 0.57 px answer 0.0094 above it, and the
|
|
170
|
+
* blur1 64 px mouth (walk) keeps its best at 0.93 px and gains a second scale
|
|
171
|
+
* at the same spot 0.0031 above it. Each is two readings of one place the
|
|
172
|
+
* 0.01 absolute margin will not pick between — the verdict it exists to give.
|
|
161
173
|
*/
|
|
162
174
|
export const COARSE_STRIDE_FRACTION = 0.125;
|
|
163
175
|
|
|
@@ -243,8 +255,59 @@ export const BACKGROUND_BORDER_SHARE = 0.6;
|
|
|
243
255
|
*/
|
|
244
256
|
const MINIMA_PER_SCALE = 5;
|
|
245
257
|
|
|
246
|
-
/**
|
|
247
|
-
|
|
258
|
+
/**
|
|
259
|
+
* How many candidates survive each refinement level.
|
|
260
|
+
*
|
|
261
|
+
* ⚠️ Fifteen rather than twelve, and it is the smallest count the grid named
|
|
262
|
+
* (issue #877). Of the 14 trials the search missed after #865, one was lost by
|
|
263
|
+
* this cutoff rather than by any polish: the native 24 px front shin, whose
|
|
264
|
+
* truth's candidate ranked 15th (index 14) of 28 at the 2x level — that part's
|
|
265
|
+
* coarse level — and was cut on the way into full resolution, where the truth
|
|
266
|
+
* scores 0.0199 against the 0.1188 reported. Fifteen is the least count that
|
|
267
|
+
* carries index 14; sixteen alone was measured to place it at 0.03 px and move
|
|
268
|
+
* no other trial (228 found, none lost). A band of the level's best was the
|
|
269
|
+
* alternative, and it was rejected on its number: to carry that candidate it
|
|
270
|
+
* would have had to reach 1.73x the best, and a band that wide carried up to 27
|
|
271
|
+
* candidates (14 of 108 sampled refinement levels over twelve) — a cost set by
|
|
272
|
+
* one trial and paid on all of them.
|
|
273
|
+
*/
|
|
274
|
+
const REFINE_CANDIDATES = 15;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The factor a converged full-resolution polish tries its scale up by before it
|
|
278
|
+
* stops — half a coarse scale rung, the resolution the coarse ladder itself had.
|
|
279
|
+
*
|
|
280
|
+
* 🔍 What the pattern search cannot do, and the trace table of issue #877 is
|
|
281
|
+
* how it was seen: every probe moves ONE degree of freedom, and the objective
|
|
282
|
+
* couples scale to position along a diagonal valley — a part shrunk a little
|
|
283
|
+
* fits best a pixel or two off its truth, so from there every single-axis probe
|
|
284
|
+
* is worse and every joint move is better. In 12 of the 14 missed trials the
|
|
285
|
+
* truth's candidate reached full resolution ranked first or second, the
|
|
286
|
+
* full-resolution objective preferred the truth to everything kept, and the
|
|
287
|
+
* polish stopped 2–7 px off it at a scale of 0.64–0.96. Not one polish ever
|
|
288
|
+
* accepted a worse residual — a polish moves only on a strictly lower probe of
|
|
289
|
+
* its own level's objective, and `PO26` holds that — so the rise from one level
|
|
290
|
+
* to the next is a change of objective, never a step taken uphill.
|
|
291
|
+
*
|
|
292
|
+
* ⭐ So the escape re-fits position at the new scale before comparing — the
|
|
293
|
+
* joint move, taken one axis at a time — and a better point restarts the polish
|
|
294
|
+
* there. UP only, and that is derived rather than tuned: the objective charges a
|
|
295
|
+
* part pixel that lands off the figure and charges nothing for figure left
|
|
296
|
+
* uncovered, so a shrunk placement is the cheap error the reduced levels make —
|
|
297
|
+
* every stuck point in the table sat below scale 1, none above.
|
|
298
|
+
*
|
|
299
|
+
* 📏 Measured on the grid with `REFINE_CANDIDATES` at fifteen: 227 → 237 trials
|
|
300
|
+
* found, 12 gained, 2 lost, search misses 14 → 8, for 43% more refinement
|
|
301
|
+
* samples (the coarse pass is untouched). Both ways at twelve found 236 (12
|
|
302
|
+
* gained, 3 lost) for 38% more against up only's 21%; a second step each way,
|
|
303
|
+
* 237 (15, 5) for 76%. ⚠️ The two it loses are one shape, a scale twin at the
|
|
304
|
+
* same spot: the blurred 64 px mouth (walk) keeps its best at 0.9 px and now
|
|
305
|
+
* reports a second placement 1.8 px off at scale 0.825 within the ambiguity
|
|
306
|
+
* margin, and the blurred 24 px front shin (walk) now places AT its truth
|
|
307
|
+
* (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 — the old answer is
|
|
308
|
+
* still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
|
|
309
|
+
*/
|
|
310
|
+
const POLISH_SCALE_ESCAPE = 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE));
|
|
248
311
|
|
|
249
312
|
/** Sample budgets per stage. The reported residual uses every pixel regardless. */
|
|
250
313
|
const COARSE_SAMPLES = 96;
|
|
@@ -284,8 +347,8 @@ export const POSE_FLOOR = {
|
|
|
284
347
|
span: 24,
|
|
285
348
|
detail: 0.5,
|
|
286
349
|
/** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
|
|
287
|
-
rateBelow: 0.
|
|
288
|
-
rateAbove: 0.
|
|
350
|
+
rateBelow: 0.074,
|
|
351
|
+
rateAbove: 0.765,
|
|
289
352
|
};
|
|
290
353
|
|
|
291
354
|
/** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
|
|
@@ -331,8 +394,11 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
|
|
|
331
394
|
// occurs: on the grid after it, 10 ambiguous trials above the floor had a
|
|
332
395
|
// truth scoring better than both answers — native 24 px gun and shin, 48 px
|
|
333
396
|
// arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
|
|
334
|
-
// up. Nine of the ten sat 2–6 px from the truth rather than on another hill.
|
|
335
|
-
//
|
|
397
|
+
// up. Nine of the ten sat 2–6 px from the truth rather than on another hill.
|
|
398
|
+
// Issue #877's polish took that to 6 — native 24 px gun, 96 px goggles, and
|
|
399
|
+
// blurred goggles and gun from 128 px and rear shin at 48 px — five of them
|
|
400
|
+
// 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
|
|
401
|
+
// too, with the one remedy that separates it: a window.
|
|
336
402
|
return (
|
|
337
403
|
`${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
|
|
338
404
|
(verdict === 'refused'
|
|
@@ -567,6 +633,67 @@ export interface PoseOptions {
|
|
|
567
633
|
scale?: { min: number; max: number };
|
|
568
634
|
rotation?: { minDeg: number; maxDeg: number };
|
|
569
635
|
maxResidual?: number;
|
|
636
|
+
/**
|
|
637
|
+
* An instrument's sink, never read by the search itself: when present, every
|
|
638
|
+
* level of every part's refinement is appended to it. See `PoseTrace`.
|
|
639
|
+
*/
|
|
640
|
+
trace?: PoseTrace;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
|
|
644
|
+
export interface PoseTraceCandidate {
|
|
645
|
+
x: number;
|
|
646
|
+
y: number;
|
|
647
|
+
rotationDeg: number;
|
|
648
|
+
scale: number;
|
|
649
|
+
/** The level's own sampled objective — not the reported, every-pixel residual. */
|
|
650
|
+
residual: number;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** One accepted move of a polish: the residual it moved to and the steps it moved with. */
|
|
654
|
+
export interface PoseTraceStep {
|
|
655
|
+
residual: number;
|
|
656
|
+
translate: number;
|
|
657
|
+
rotate: number;
|
|
658
|
+
scale: number;
|
|
659
|
+
/** The accepted probe's scale or rotation was held by its window rather than taken as stepped. */
|
|
660
|
+
clamped: boolean;
|
|
661
|
+
/** A restart from a converged point to a different scale, rather than a step of the pattern. */
|
|
662
|
+
escape: boolean;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export interface PoseTraceLevel {
|
|
666
|
+
part: string;
|
|
667
|
+
/** Pyramid index; 0 is full resolution. */
|
|
668
|
+
level: number;
|
|
669
|
+
reduction: number;
|
|
670
|
+
/** How many of the candidates handed down this level took, before any rotation branching. */
|
|
671
|
+
keep: number;
|
|
672
|
+
/** The seeds this level polished, in the order they were ranked going in. */
|
|
673
|
+
seeds: PoseTraceCandidate[];
|
|
674
|
+
/** Per seed, this level's objective at the seed — where its polish started from. */
|
|
675
|
+
starts: number[];
|
|
676
|
+
/** Per seed, the moves its polish accepted, in order. */
|
|
677
|
+
paths: PoseTraceStep[][];
|
|
678
|
+
/** Per seed, where its polish ended. */
|
|
679
|
+
polished: PoseTraceCandidate[];
|
|
680
|
+
/** The candidates this level hands on, ranked and deduplicated. */
|
|
681
|
+
out: PoseTraceCandidate[];
|
|
682
|
+
/** This level's objective at `PoseTrace.probe`, when one was given. */
|
|
683
|
+
probeResidual: number | null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The refinement written down level by level, for an instrument that knows
|
|
688
|
+
* where the truth is and needs to say where the search left it (issue #877).
|
|
689
|
+
* Pure bookkeeping: a search with a trace returns the same report as one without.
|
|
690
|
+
*/
|
|
691
|
+
export interface PoseTrace {
|
|
692
|
+
/** A placement, in the report's own space, to score at every level alongside the candidates. */
|
|
693
|
+
probe?: { x: number; y: number; rotationDeg: number; scale: number };
|
|
694
|
+
levels: PoseTraceLevel[];
|
|
695
|
+
/** Per part, every candidate that reached the report, with its every-pixel residual, best first. */
|
|
696
|
+
measured: { part: string; candidates: PoseTraceCandidate[] }[];
|
|
570
697
|
}
|
|
571
698
|
|
|
572
699
|
// ---------------------------------------------------------------------------
|
|
@@ -1095,16 +1222,47 @@ function polish(
|
|
|
1095
1222
|
* default run moves.
|
|
1096
1223
|
*/
|
|
1097
1224
|
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1225
|
+
/** Scale factors tried from the point the steps converged on; a better one restarts the polish there. */
|
|
1226
|
+
escapes: readonly number[],
|
|
1227
|
+
/** An instrument's record of where the polish started and the moves it accepted; the search never reads it. */
|
|
1228
|
+
path?: { start: number; steps: PoseTraceStep[] },
|
|
1098
1229
|
): Candidate {
|
|
1099
1230
|
const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
|
|
1100
1231
|
const hold = (v: number): number =>
|
|
1101
1232
|
rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
|
|
1102
1233
|
let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
|
|
1234
|
+
if (path !== undefined) path.start = cur.residual;
|
|
1103
1235
|
let dt = step.translate;
|
|
1104
1236
|
let dr = step.rotate;
|
|
1105
1237
|
let ds = step.scale;
|
|
1106
1238
|
for (let guard = 0; guard < 200; guard++) {
|
|
1107
|
-
if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale)
|
|
1239
|
+
if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) {
|
|
1240
|
+
let jump = cur;
|
|
1241
|
+
for (const f of escapes) {
|
|
1242
|
+
const scale = clamp(cur.scale * f);
|
|
1243
|
+
if (scale === cur.scale) continue;
|
|
1244
|
+
const refit = polish(
|
|
1245
|
+
level,
|
|
1246
|
+
plate,
|
|
1247
|
+
s,
|
|
1248
|
+
{ ...cur, scale },
|
|
1249
|
+
{ translate: step.translate, rotate: 0, scale: 0 },
|
|
1250
|
+
{ translate: floor.translate, rotate: 0, scale: 0 },
|
|
1251
|
+
smooth,
|
|
1252
|
+
bounds,
|
|
1253
|
+
rotationBounds,
|
|
1254
|
+
[],
|
|
1255
|
+
);
|
|
1256
|
+
if (refit.residual < jump.residual) jump = refit;
|
|
1257
|
+
}
|
|
1258
|
+
if (jump === cur) break;
|
|
1259
|
+
path?.steps.push({ residual: jump.residual, translate: dt, rotate: dr, scale: jump.scale / cur.scale - 1, clamped: false, escape: true });
|
|
1260
|
+
cur = jump;
|
|
1261
|
+
dt = step.translate;
|
|
1262
|
+
dr = step.rotate;
|
|
1263
|
+
ds = step.scale;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1108
1266
|
const probes: Candidate[] = [];
|
|
1109
1267
|
const push = (cand: Omit<Candidate, 'residual'>): void => {
|
|
1110
1268
|
probes.push({ ...cand, residual: residualAt(level, plate, s, { ...cand, residual: 0 }, smooth) });
|
|
@@ -1139,6 +1297,12 @@ function polish(
|
|
|
1139
1297
|
ds /= 2;
|
|
1140
1298
|
continue;
|
|
1141
1299
|
}
|
|
1300
|
+
if (path !== undefined) {
|
|
1301
|
+
const clamped =
|
|
1302
|
+
(best.scale !== cur.scale && best.scale !== cur.scale * (1 + ds) && best.scale !== cur.scale * (1 - ds)) ||
|
|
1303
|
+
(best.rotDeg !== cur.rotDeg && best.rotDeg !== cur.rotDeg + dr && best.rotDeg !== cur.rotDeg - dr);
|
|
1304
|
+
path.steps.push({ residual: best.residual, translate: dt, rotate: dr, scale: ds, clamped, escape: false });
|
|
1305
|
+
}
|
|
1142
1306
|
cur = best;
|
|
1143
1307
|
}
|
|
1144
1308
|
return cur;
|
|
@@ -1553,6 +1717,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1553
1717
|
maxResidual,
|
|
1554
1718
|
{ min: scaleMin, max: scaleMax },
|
|
1555
1719
|
rotationBounds,
|
|
1720
|
+
options.trace,
|
|
1556
1721
|
),
|
|
1557
1722
|
);
|
|
1558
1723
|
}
|
|
@@ -1569,6 +1734,7 @@ function placePart(
|
|
|
1569
1734
|
maxResidual: number,
|
|
1570
1735
|
scaleBounds: { min: number; max: number },
|
|
1571
1736
|
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1737
|
+
trace?: PoseTrace,
|
|
1572
1738
|
): PosePart {
|
|
1573
1739
|
const scaleMin = scaleBounds.min;
|
|
1574
1740
|
/** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
|
|
@@ -1766,7 +1932,11 @@ function placePart(
|
|
|
1766
1932
|
}
|
|
1767
1933
|
seeds.push(start);
|
|
1768
1934
|
}
|
|
1769
|
-
|
|
1935
|
+
const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
|
|
1936
|
+
candidates = seeds.map((seed, i) =>
|
|
1937
|
+
polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? [POLISH_SCALE_ESCAPE] : [], trace === undefined ? undefined : paths[i]),
|
|
1938
|
+
);
|
|
1939
|
+
const polished = candidates.slice();
|
|
1770
1940
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
1771
1941
|
// ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
|
|
1772
1942
|
// and the radius has to scale with the PART rather than be a pixel count.
|
|
@@ -1775,6 +1945,47 @@ function placePart(
|
|
|
1775
1945
|
// instrument exists to keep: with a fixed one-pixel radius the fixture lost
|
|
1776
1946
|
// one of its two identical arms.
|
|
1777
1947
|
candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
|
|
1948
|
+
if (trace !== undefined) {
|
|
1949
|
+
const ou = part.width / 2 - anchorX;
|
|
1950
|
+
const ov = part.height / 2 - anchorY;
|
|
1951
|
+
const said = (c: Candidate): PoseTraceCandidate => {
|
|
1952
|
+
const cos = Math.cos(c.rotDeg * DEG) * c.scale;
|
|
1953
|
+
const sin = Math.sin(c.rotDeg * DEG) * c.scale;
|
|
1954
|
+
return {
|
|
1955
|
+
x: c.cx * level.reduction + ou * cos - ov * sin,
|
|
1956
|
+
y: c.cy * level.reduction + ou * sin + ov * cos,
|
|
1957
|
+
rotationDeg: normaliseDegrees(c.rotDeg),
|
|
1958
|
+
scale: c.scale,
|
|
1959
|
+
residual: c.residual,
|
|
1960
|
+
};
|
|
1961
|
+
};
|
|
1962
|
+
let probeResidual: number | null = null;
|
|
1963
|
+
if (trace.probe !== undefined) {
|
|
1964
|
+
const q = trace.probe;
|
|
1965
|
+
const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
|
|
1966
|
+
const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
|
|
1967
|
+
const at: Candidate = {
|
|
1968
|
+
cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
|
|
1969
|
+
cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
|
|
1970
|
+
rotDeg: q.rotationDeg,
|
|
1971
|
+
scale: q.scale,
|
|
1972
|
+
residual: 0,
|
|
1973
|
+
};
|
|
1974
|
+
probeResidual = residualAt(level, plate, s, at, smooth);
|
|
1975
|
+
}
|
|
1976
|
+
trace.levels.push({
|
|
1977
|
+
part: name,
|
|
1978
|
+
level: li,
|
|
1979
|
+
reduction: level.reduction,
|
|
1980
|
+
keep,
|
|
1981
|
+
seeds: seeds.map(said),
|
|
1982
|
+
starts: paths.map((p) => p.start),
|
|
1983
|
+
paths: paths.map((p) => p.steps),
|
|
1984
|
+
polished: polished.map(said),
|
|
1985
|
+
out: candidates.map(said),
|
|
1986
|
+
probeResidual,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1778
1989
|
if (li > 0) {
|
|
1779
1990
|
candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
|
|
1780
1991
|
// A rotation-free part keeps its step at zero all the way down, so the
|
|
@@ -1812,6 +2023,7 @@ function placePart(
|
|
|
1812
2023
|
true,
|
|
1813
2024
|
scaleBounds,
|
|
1814
2025
|
rotationBounds,
|
|
2026
|
+
[],
|
|
1815
2027
|
),
|
|
1816
2028
|
);
|
|
1817
2029
|
}
|
|
@@ -1822,6 +2034,16 @@ function placePart(
|
|
|
1822
2034
|
// candidates that walked to the same optimum are one answer, not two.
|
|
1823
2035
|
const measured = candidates.map((cand) => ({ cand, placement: toPlacement(part, anchorX, anchorY, cand, measure(levels[0], plates[0], part, anchorX, anchorY, cand)) }));
|
|
1824
2036
|
measured.sort((a, b) => a.placement.residual - b.placement.residual || b.placement.footprint - a.placement.footprint);
|
|
2037
|
+
trace?.measured.push({
|
|
2038
|
+
part: name,
|
|
2039
|
+
candidates: measured.map((m) => ({
|
|
2040
|
+
x: m.placement.x,
|
|
2041
|
+
y: m.placement.y,
|
|
2042
|
+
rotationDeg: m.placement.rotationDeg,
|
|
2043
|
+
scale: m.placement.scale,
|
|
2044
|
+
residual: m.placement.residual,
|
|
2045
|
+
})),
|
|
2046
|
+
});
|
|
1825
2047
|
const distinct: typeof measured = [];
|
|
1826
2048
|
for (const m of measured) {
|
|
1827
2049
|
const same = distinct.some(
|
package/src/render.ts
CHANGED
|
@@ -1130,9 +1130,17 @@ export interface GeometryFile {
|
|
|
1130
1130
|
}
|
|
1131
1131
|
|
|
1132
1132
|
/**
|
|
1133
|
-
* A
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1133
|
+
* A pose holding a number that is not finite — refused by the bone or the vertex
|
|
1134
|
+
* that holds it, and the value, rather than drawn, framed or written.
|
|
1135
|
+
*
|
|
1136
|
+
* Two callers throw it, with one sentence between them (`nonFiniteSentence`):
|
|
1137
|
+
* the geometry export, because `JSON.stringify` writes `NaN` and `Infinity` as
|
|
1138
|
+
* `null` and a consumer would read a hole in the geometry as a vertex at
|
|
1139
|
+
* nothing; and `framingViewport`, because a box over an infinite vertex is no
|
|
1140
|
+
* box at all (issue #873). Before that second caller the framing answered `null`
|
|
1141
|
+
* for it, which `render` prints as "posed no drawable attachment" — true of a
|
|
1142
|
+
* skeleton that draws nothing and false of this one, whose attachment is there
|
|
1143
|
+
* and posed to a number no picture can hold.
|
|
1136
1144
|
*/
|
|
1137
1145
|
export class GeometryError extends Error {}
|
|
1138
1146
|
|
|
@@ -1250,28 +1258,127 @@ function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentP
|
|
|
1250
1258
|
return out;
|
|
1251
1259
|
}
|
|
1252
1260
|
|
|
1253
|
-
/**
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
+
/** The bone fields a world transform is made of — what `firstNonFinite` reads off a bone. */
|
|
1262
|
+
type WorldTransform = Pick<GeometryBone, 'name' | 'a' | 'b' | 'c' | 'd' | 'worldX' | 'worldY'>;
|
|
1263
|
+
|
|
1264
|
+
/** The fields of an attachment entry `firstNonFinite` reads — a frame's `AttachmentPose` or a rest entry. */
|
|
1265
|
+
type PosedVertices = Pick<AttachmentPose, 'slot' | 'attachment' | 'vertices'>;
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* The sentence for the first number at `where` that is not finite — bones before
|
|
1269
|
+
* vertices, since a bone that overflowed is the cause and its vertices the
|
|
1270
|
+
* symptom — or `null` when every number there is finite.
|
|
1271
|
+
*/
|
|
1272
|
+
function firstNonFinite(where: string, entries: readonly PosedVertices[], bones: readonly WorldTransform[]): string | null {
|
|
1273
|
+
for (const bone of bones) {
|
|
1274
|
+
for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
|
|
1275
|
+
if (!Number.isFinite(bone[field])) {
|
|
1276
|
+
return `${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`;
|
|
1261
1277
|
}
|
|
1262
1278
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
);
|
|
1279
|
+
}
|
|
1280
|
+
for (const entry of entries) {
|
|
1281
|
+
const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
|
|
1282
|
+
if (bad === -1) continue;
|
|
1283
|
+
return (
|
|
1284
|
+
`${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
|
|
1285
|
+
`${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* How a sampled frame is named in that sentence: the animation, the frame's
|
|
1293
|
+
* index at the rate it was sampled and its time — or the bare index for the one
|
|
1294
|
+
* frame of a skeleton with no animation, whose setup pose is checked first.
|
|
1295
|
+
*/
|
|
1296
|
+
function frameWhere(animation: string | null, index: number, time: number, fps: number): string {
|
|
1297
|
+
if (animation === null) return `frame ${index}`;
|
|
1298
|
+
return `animation ${JSON.stringify(animation)} frame ${index} at ${fps} fps (t=${time.toFixed(4)}s)`;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** One sampled frame's numbers, under the name the sentence gives it. */
|
|
1302
|
+
interface NamedPose {
|
|
1303
|
+
where: string;
|
|
1304
|
+
attachments: readonly PosedVertices[];
|
|
1305
|
+
bones: readonly WorldTransform[];
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* ⭐ **The one derivation of the non-finite sentence** (issue #873): the setup
|
|
1310
|
+
* pose, then every frame in order, then the rest table. The export and the
|
|
1311
|
+
* framing both reach it, so `render` and `render --geometry` refuse one planted
|
|
1312
|
+
* overflow in the same words.
|
|
1313
|
+
*
|
|
1314
|
+
* The setup pose first because a setup bone that overflowed is named there as
|
|
1315
|
+
* the BONE, and the rest table — the setup's bones with no deform — would name
|
|
1316
|
+
* the same fault one step on, as a vertex. Rest is still checked, last: it holds
|
|
1317
|
+
* attachments the setup pose does not show.
|
|
1318
|
+
*/
|
|
1319
|
+
function nonFiniteSentence(
|
|
1320
|
+
setup: Pick<NamedPose, 'attachments' | 'bones'>,
|
|
1321
|
+
frames: readonly NamedPose[],
|
|
1322
|
+
rest: readonly PosedVertices[],
|
|
1323
|
+
): string | null {
|
|
1324
|
+
const first = firstNonFinite('the setup pose', setup.attachments, setup.bones);
|
|
1325
|
+
if (first !== null) return first;
|
|
1326
|
+
for (const frame of frames) {
|
|
1327
|
+
const found = firstNonFinite(frame.where, frame.attachments, frame.bones);
|
|
1328
|
+
if (found !== null) return found;
|
|
1329
|
+
}
|
|
1330
|
+
return firstNonFinite('the rest table', rest, []);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
|
|
1334
|
+
function refuseNonFinite(file: GeometryFile): void {
|
|
1335
|
+
const sentence = nonFiniteSentence(
|
|
1336
|
+
file.setup,
|
|
1337
|
+
file.frames.map((frame) => ({ ...frame, where: frameWhere(file.animation, frame.index, frame.time, file.fps) })),
|
|
1338
|
+
file.rest,
|
|
1339
|
+
);
|
|
1340
|
+
if (sentence !== null) throw new GeometryError(sentence);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Where a skeleton's pose is not finite, as the sentence the geometry export
|
|
1345
|
+
* refuses on — or `null` when every bone and vertex of it is finite.
|
|
1346
|
+
*
|
|
1347
|
+
* Each set is sampled at its own rate, with its bones and whole attachments —
|
|
1348
|
+
* `animation: null` is the setup pose alone — so the frame it names is a frame of
|
|
1349
|
+
* the caller's own grid. It samples again rather than taking the caller's
|
|
1350
|
+
* frames, because a caller that only draws sampled no bones; it is run only
|
|
1351
|
+
* once the caller has found a number that is not finite, so a finite pose never
|
|
1352
|
+
* pays for it.
|
|
1353
|
+
*/
|
|
1354
|
+
export function nonFinitePoseOf(
|
|
1355
|
+
data: SkeletonData,
|
|
1356
|
+
skin: string | undefined,
|
|
1357
|
+
sets: ReadonlyArray<{ animation: string | null; fps: number }>,
|
|
1358
|
+
): string | null {
|
|
1359
|
+
const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
|
|
1360
|
+
// Every attachment list the frames showed, for the rest table's roster.
|
|
1361
|
+
const shown: AttachmentPose[][] = [];
|
|
1362
|
+
const named = (frame: Frame, where: string): NamedPose => {
|
|
1363
|
+
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
1364
|
+
throw new Error(`${where} was sampled without { bones: true, geometry: true }`);
|
|
1270
1365
|
}
|
|
1366
|
+
shown.push(frame.attachments);
|
|
1367
|
+
return { where, attachments: frame.attachments, bones: frame.bones };
|
|
1271
1368
|
};
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1369
|
+
const setup = named(sampleSetupPose(data, opts)[0], 'the setup pose');
|
|
1370
|
+
const frames = sets.flatMap(({ animation, fps }) =>
|
|
1371
|
+
animation === null
|
|
1372
|
+
? []
|
|
1373
|
+
: sampleAnimation(data, animation, fps, opts).map((frame) =>
|
|
1374
|
+
named(frame, frameWhere(animation, frame.index, frame.time, fps)),
|
|
1375
|
+
),
|
|
1376
|
+
);
|
|
1377
|
+
// Not read unless the setup pose and every frame are finite: `restOf` poses
|
|
1378
|
+
// what they show, and would be posing the same overflow a third time.
|
|
1379
|
+
const found = nonFiniteSentence(setup, frames, []);
|
|
1380
|
+
if (found !== null) return found;
|
|
1381
|
+
return nonFiniteSentence({ attachments: [], bones: [] }, [], restOf(data, skin, shown));
|
|
1275
1382
|
}
|
|
1276
1383
|
|
|
1277
1384
|
/**
|
|
@@ -1685,6 +1792,10 @@ export function trimmedUnionBounds(
|
|
|
1685
1792
|
*
|
|
1686
1793
|
* Measuring the box densely and once makes the framing a property of the SHOT,
|
|
1687
1794
|
* so every rate of one skeleton lands on the same pixels.
|
|
1795
|
+
*
|
|
1796
|
+
* `null` means the skeleton posed no vertex at all — nothing to draw. A pose
|
|
1797
|
+
* holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
|
|
1798
|
+
* bone or vertex and its value (issue #873), never answered `null`.
|
|
1688
1799
|
*/
|
|
1689
1800
|
export function framingViewport(data: SkeletonData, maxSide: number, opts?: PoseOptions): Viewport | null {
|
|
1690
1801
|
// The skin belongs here as much as in the frames: the union box is over the
|
|
@@ -1708,8 +1819,35 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
|
|
|
1708
1819
|
data.animations.length === 0
|
|
1709
1820
|
? [sampleSetupPose(data, framed)]
|
|
1710
1821
|
: data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, framed));
|
|
1822
|
+
// ⚠️ Two different reasons a box is not finite, told apart here and nowhere
|
|
1823
|
+
// else (issue #873). A skeleton that posed no vertex at all has nothing to
|
|
1824
|
+
// draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
|
|
1825
|
+
// drawable attachment in a place no box can hold — so it is refused by the
|
|
1826
|
+
// bone or vertex and its value, in the geometry export's own sentence. Before
|
|
1827
|
+
// this, the first case's `null` covered both, and a single overflowing bone
|
|
1828
|
+
// among finite ones reached neither: its box was finite on one side, and
|
|
1829
|
+
// `render` wrote a NaN-by-NaN frame set with exit 0.
|
|
1830
|
+
const posedAny = sets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
|
|
1831
|
+
if (!posedAny) return null;
|
|
1711
1832
|
const box = unionBounds(sets);
|
|
1712
|
-
if (!Number.isFinite
|
|
1833
|
+
if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
|
|
1834
|
+
const found = nonFinitePoseOf(
|
|
1835
|
+
data,
|
|
1836
|
+
framed.skin,
|
|
1837
|
+
data.animations.length === 0
|
|
1838
|
+
? [{ animation: null, fps: FRAMING_FPS }]
|
|
1839
|
+
: data.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
|
|
1840
|
+
);
|
|
1841
|
+
// Reaching here with nothing found would mean the pieces and the whole
|
|
1842
|
+
// attachments disagree about one pose — a defect here, said as one.
|
|
1843
|
+
if (found === null) {
|
|
1844
|
+
throw new Error(
|
|
1845
|
+
`the framing box is not finite (${box.minX}, ${box.minY}, ${box.maxX}, ${box.maxY}) and no bone or ` +
|
|
1846
|
+
'vertex of the pose is — the drawn pieces and the attachments were posed differently',
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
throw new GeometryError(found);
|
|
1850
|
+
}
|
|
1713
1851
|
const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
|
|
1714
1852
|
return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
|
|
1715
1853
|
}
|