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 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
- `both; free tries every width on a ${FREE_EDGE_STEP}px step, takes the height the placement needs and keeps ` +
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 the geometry export cannot write as numbers (issue #864): the
4543
- // invocation was fine and the skeleton posed a NaN or an infinity, so exit 1
4544
- // like a file that is not a PNG. Raised before the first file is written.
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 every width instead
345
- finds an unturned page 10.22 % smaller. With every width tried, no set gains
346
- more than 5.05 % from turning — so the packer still never turns a region.
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 the multiples of 32 from the widest padded part up to `--page-size`; at each
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 naming the frame, the slot, the attachment and the vertex,
6370
- rather than written as `null`.
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; 14 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 8% 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
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) | 12/15 (3 amb) | 14/15 (1 amb) | 13/15 (2 amb) | 15/15 | 12/15 (2 amb, 1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
8763
- | `blur1` (detail 1.58) | 10/15 (5 amb) | 12/15 (2 amb, 1 wrong) | 13/15 (2 amb) | 13/15 (2 amb) | 12/15 (1 amb, 2 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
8764
- | `blur2` (detail 0.59) | 7/15 (7 amb, 1 wrong) | 8/15 (5 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) | 6/15 (2 amb, 7 wrong) |
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 | 2/3 | 0 | 1 | 0 |
8773
- | 0.5–0.75 | 44/81 | 16 | 21 | 0 |
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 | 24/25 | 1 | 0 | 0 |
8776
- | 1.5–2 | 34/48 | 11 | 3 | 0 |
8777
- | 2–3 | 36/44 | 5 | 3 | 0 |
8778
- | 3–4 | 33/34 | 1 | 0 | 0 |
8779
- | 4 and over | 29/32 | 3 | 0 | 0 |
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, **8.2%**
8802
- of the trials were found (10/122); at or over it, **72.8%** (217/298). A part is below
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
- ⚠️ **What this is and is not.** The grid is not a reliability line: four native cells
8817
- place 14 or 15 of 15 and the trials at `detail` 3 and over place 62 of 66, but the
8818
- goggles still place 10 of 42, and a threshold read off two dozen parts of one figure
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 the change, 14 of the 193 failed trials are misses, and 13
8826
- of those sit within 7 px of the truth rather than on another hill. All this was
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 this change was made for.
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.4.0",
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 `FREE_EDGE_STEP` grid moves P5's
547
- * unturned page by 18.13 %, and searching every width instead of every 32nd
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 multiple of `FREE_EDGE_STEP` and the height
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
- /** The step a `free` page's width is tried on. See `packAtlas`, *Page size*. */
640
- export const FREE_EDGE_STEP = 32;
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 the multiples of `FREE_EDGE_STEP` from the widest
755
- * cell up to `maxEdge`, plus `maxEdge` itself (it is a power of two, so this
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
- for (const cell of cells) widest = Math.max(widest, cell.w);
776
- const widths: number[] = [];
777
- for (let w = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; w <= maxEdge; w += FREE_EDGE_STEP) widths.push(w);
778
- if (widths[widths.length - 1] !== maxEdge && widest <= maxEdge) widths.push(maxEdge);
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 (const width of widths) {
781
- const attempt = packOnePage(cells, width, maxEdge);
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
- return best;
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(cells: Array<{ w: number; h: number }>, pageW: number, pageH: number): Array<Rect | null> {
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`: widths on a `FREE_EDGE_STEP` grid, the height
985
- * the placement needs, least area first, then squarer, then narrower. Everything
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 went from 1024x2048 to 1888x697 (2,097,152 to 1,315,936 texels,
992
- * -37.3 %) and from 512x2048 to 480x1166 (1,048,576 to 559,680, -46.6 %). The cost is the exactness the paragraph above describes:
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 do not on this one. Five of them are blurred
157
- * parts that now come back ambiguous between two scales at the same spot, or
158
- * 2.0 px off against a 2 px bar; three are a basin reached and then polished
159
- * onto a worse placement beside it, and one a basin ranked out of the
160
- * refinement's twelve — the refinement, not this grid.
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
- /** How many candidates survive each refinement level. */
247
- const REFINE_CANDIDATES = 12;
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.082,
288
- rateAbove: 0.728,
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. So the sentence names that reading too, with the one remedy that
335
- // separates it: a window.
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) break;
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
- candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds));
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 number the export could not print as a number — refused rather than written,
1134
- * because `JSON.stringify` writes `NaN` and `Infinity` as `null` and a consumer
1135
- * would read a hole in the geometry as a vertex at nothing.
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
- /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
1254
- function refuseNonFinite(file: GeometryFile): void {
1255
- const check = (where: string, entries: AttachmentPose[] | AttachmentRest[], bones: GeometryBone[]): void => {
1256
- for (const bone of bones) {
1257
- for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
1258
- if (!Number.isFinite(bone[field])) {
1259
- throw new GeometryError(`${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`);
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
- for (const entry of entries) {
1264
- const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
1265
- if (bad === -1) continue;
1266
- throw new GeometryError(
1267
- `${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
1268
- `${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`,
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
- check('the rest table', file.rest, []);
1273
- check('the setup pose', file.setup.attachments, file.setup.bones);
1274
- for (const frame of file.frames) check(`frame ${frame.index}`, frame.attachments, frame.bones);
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(box.minX)) return null;
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
  }