@rig-c/parts 1.10.0 → 1.11.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/docs/AUTHORING.md +72 -0
- package/docs/STABILITY.md +3 -2
- package/package.json +1 -1
- package/src/config.ts +30 -1
- package/src/meshdraft.ts +3 -2
- package/src/rig.ts +82 -15
- package/src/weights.ts +69 -11
package/docs/AUTHORING.md
CHANGED
|
@@ -95,6 +95,7 @@ read by no CPU stage).
|
|
|
95
95
|
| `meshes.<part>` | rig | **proposed**, then corrected. Exactly one of `grid` (the lattice: cell size, px — what `propose` writes: by default one spacing for the figure, round(H / 37) rig px with H the height of every part's box together, a part whose tenth of its short side is already coarser keeping that, and under `propose --grid-rule part` the reference's tenth of the part's short side clamped to 5..36, issue #265), `contour` (the outline mode, authored; the next row and "A contour mesh" below) or `auto` (the automatic mode, authored; "An automatic mesh" below) — two, or none, is `CONFIG_MESH_MODE`. In every mode: `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`), and, **authored**, optional, `exponent` (issue #161): a number above 0 that replaces the 2 in that rule, `w = 1/(d + r)^exponent` — a larger one hands each vertex more fully to its nearest bone; absent is 2 and changes no byte, a value that is not a number above 0 is `CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`, and a declared one is echoed as `exponent` in the mesh's `mesh_report.json` row; the exponent is the distance rule's, so declared beside `rule: "heat"` it is refused (`CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's. Optional `rule` (issue #161): `"distance"` — the rule above, and what an absent `rule` means (every byte is the same) — or `"heat"`, bone heat over the part's own silhouette, in every mode: the lattice's art (alpha above 8) with holes filled, or for `contour` and `auto` the silhouette their outline is traced from (that mode's art — alpha above 8 for `contour`, alpha 1 and above for `auto` — less any island a declared `stray` left out, holes filled, grown by its `margin`); each bound bone's sources are the silhouette pixels whose centre lies within 0.75 px of its segment (a pixel two bones reach goes to the nearer, a tie to the first listed), held at 1 for that bone and 0 for the others; the field is solved by row-major Gauss–Seidel over-relaxation to a sweep change under 1e-7 within 20000 sweeps; a vertex reads the nearest silhouette pixel and takes the same cap and floor (and `regions`) as the distance rule; `r` is not read. The mesh's `mesh_report.json` row gains `heat` (the constants, each bone's sources and sweeps, the residual). Any other value is `CONFIG_FIELD_TYPE`; a bound bone with no source pixel is `RIG_HEAT_BONE_SOURCE`, an island no bone has a source in `RIG_HEAT_ISLAND_SOURCE`, a solve past its sweep bound `RIG_HEAT_CONVERGED` — none is weighted 0 |
|
|
96
96
|
| `meshes.<part>.contour` | rig | **authored**, optional (issue #84): `{tolerance, margin, spacing, maxEdge?, budget?, stray?, regions?}`. `tolerance` — rig-c's Douglas–Peucker tolerance on the traced outline, px, 0 or more; `margin` — how far the silhouette grows before it is traced, px: 0, or 1 or more (a pixel joins when its centre lies within `margin` of an art pixel's centre; a value above 0 and below 1 adds nothing and is refused, `CONFIG_FIELD_TYPE`); `spacing` — the background interior spacing, px, above 0; `maxEdge` (issue #265) — the longest outline edge, px, above 0: every simplified edge longer than it is cut into `ceil(length / maxEdge)` equal pieces, each cut point on the 1/256 px grid, before any region or rib touches the outline (absent: nothing is cut, and no byte moves; under 2/256 px the rig stage refuses it, `CONTOUR_PARAMETER`, because two cut points could snap together); the row echoes it in `params` and counts the points it added in `contour.outlineCut`, both only when declared; `budget` — the most vertices the mesh may have (refused above it, nothing thinned); `stray` — the largest island, in art pixels, that may be left out of the mesh (absent: none is); `regions` — local deformation regions, each `{name, shape, bone, spacing, band}` with `shape` `"circle"` (`cx, cy, r`) or `"polygon"` (`points`, 3 or more): `bone` is the region's control bone (any bone `bones` declares), `spacing` the interior spacing inside the region and its band, `band` the width the bone's weight falls across. Every length and position is **rig px**, like every other in the config; the part image is the rig less `(x − 4, y − 4)` (its box less the pad), a translation, so a length is the same number in both. A region's `cx`, `cy`, `r`, `band` and `points` are multiples of 1/256 px (`CONFIG_FIELD_TYPE` otherwise); region names are unique per mesh (`CONFIG_REGION_NAME_UNIQUE`). The alpha threshold is not a field: art is alpha above 8, the lattice's and `check`'s |
|
|
97
97
|
| `meshes.<part>.ribs` | rig | **authored**, optional (issue #188), in the `contour` and `auto` modes: `{<chain>: {stations}, …}`, each key a chain this mesh's `segments` name. Each link of the chain gets a row of vertices across the part at its origin and at `stations` points evenly between its origin and its end (`stations`, a whole number, 0 or more, required; a chain of L links carries L × (stations + 1) ribs), each rib a row of vertices across the part, kept as a row of locked edges; the rule is "Ribs along a chain" under "A contour mesh". Absent places none and changes no byte. Refused: a lattice mesh with ribs (`CONFIG_RIBS_MODE` — its vertices are its grid's corners), a key that is not one of the mesh's chains (`CONFIG_NAME_RESOLVES`), an object naming no chain or a `stations` that is not a whole number from 0 (`CONFIG_FIELD_TYPE`); at the rig stage `CONTOUR_RIB_ART`, `CONTOUR_RIB_LINK`, `CONTOUR_RIB_CROSSING`, `CONTOUR_RIB_EDGE` (the contour refusals). The mesh's `mesh_report.json` row gains `ribs` — the declaration, the ribs placed and the vertices they hold — and its contour report (the source's, in `auto`) lists each rib's vertices |
|
|
98
|
+
| `meshes.<part>.influences` | rig | **authored**, optional (issue #284), in the `grid` and `contour` modes: `{maxInfluences?, floor?}`, at least one. `maxInfluences` — the most bones that pull one vertex, a whole number 1 or more, or `null`, no cap (every bone the mesh's `segments` name; `null` is the config's spelling of a bound declared absent, as in `maxOvershoot` and `weightJump`); absent is 4. `floor` — `"cut"` (the capped shares normalised, any under 0.03 dropped, the rest normalised again; what an absent `floor` means) or `"continuous"` (0.03 subtracted from every capped share, those at or below 0 dropped, the rest normalised again, so a bone fades out of a vertex instead of leaving it in one step). Absent, the mesh is weighted at 4 and the cut and every byte is the one written before the field existed; declared, it applies under either `rule`, and is echoed as `influences` in the mesh's `mesh_report.json` row. `propose` never writes it, and `mesh-draft` carries a lattice's declaration to the contour candidate it drafts. A cap that is not a whole number 1 or more or `null` (the string `"none"` included), another `floor`, or an object naming neither is `CONFIG_FIELD_TYPE`; a vertex the declared cut leaves with no bone is `RIG_INFLUENCES_KEPT`; beside `auto` it is `CONFIG_INFLUENCES_MODE` (that mode's cap and floor are `auto.influences`). What each setting trades is "The weight cut" below |
|
|
98
99
|
| `regions.<part>` | rig | **proposed**: the bone a rigid part rides. Every plan part is exactly one of a mesh or a region (`CONFIG_PART_ATTACHED`) |
|
|
99
100
|
| `motion.duration` | rig; check (the loop is measured at this time) | proposed as 40 frames at 12 fps, `40/12` s, written `3.3333333333333335` (issue #252; the reference proposed 4 s, and every period it drafted is scaled with it so each track keeps its cycles per idle); a whole number of 1/12 s ticks, because `check` renders at 12 fps and compares frame 0 with the frame at this time — read within 10^-9 s (`TIME_SLACK`, issue #244), since a whole number of frames is often not exact in binary: rigc's 40th frame sits at `40 × (1/12)` = `3.333333333333333`. Half a frame off is `CHECK_LOOP_LAST_FRAME_AT_DURATION` |
|
|
100
101
|
| `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. The proposed breath (issues #252, #270) is `chest` `translatey` travelling 2.5 % of the torso's length peak to peak — the distance from the `neck` bone to the `hip` bone as the proposal writes them, the run the chest sits half way along, whichever rule placed them — (amplitude half of it to two decimals, `base` the amplitude, so the chest rises from the setup pose and returns); the `breath:` note prints the fraction, the length, the two bones and the pixels. Until #270 it was 2.5 % of the `topwear` part's height, and a sweater to the thighs (`scarf`, 690 px) breathed 2.9 times as deep as `demo`; on neck → hip `scarf` reads 431 px (10.78 px) and `demo` 192 px (4.80 px). Neck and hip on one point: no length to scale by, so the breath keeps the reference's amplitude 1.3 (2.6 px peak to peak) and the note says so — scale it to the figure. `chest` `scalex` and the `head` and `neck` rolls are a ratio and angles and are proposed the same on every figure. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
|
|
@@ -374,6 +375,75 @@ top edge instead would close the eye upward onto the upper lid and still move wh
|
|
|
374
375
|
above it by (1 − squash) × its height above the pivot, so the cut, not the pivot, is what
|
|
375
376
|
holds the crease.
|
|
376
377
|
|
|
378
|
+
### The weight cut (`meshes.<part>.influences`, issue #284)
|
|
379
|
+
|
|
380
|
+
The distance rule (and bone heat) shares a vertex among the bones its mesh's segments name,
|
|
381
|
+
then cuts the list: the 4 largest kept, any share under 0.03 dropped. Both numbers are the
|
|
382
|
+
reference implementation's (`[:4]` and `>= 0.03`), carried over as port constants. Neither
|
|
383
|
+
is a limit of the format — spine-core reads a weighted vertex as a bone count followed by
|
|
384
|
+
that many bindings, with no bound on the count — and rig-c neither refuses nor warns above
|
|
385
|
+
four: its one assertion on a mesh's weights, `A20_MESH_WEIGHTS_COHERENT`, checks that they
|
|
386
|
+
are coherent, not how many there are (`docs/evidence/weight-cap.md`, *Where four comes
|
|
387
|
+
from*, in the repository). The cut is where a vertex changes hands in one step, and in the
|
|
388
|
+
lattice and contour modes a mesh may declare it:
|
|
389
|
+
|
|
390
|
+
```json
|
|
391
|
+
"bottomwear": { "grid": 25, "r": 10, "segments": [["hip", [232, 425], [601, 425]], "skirt_r", "skirt_c", "skirt_l"], "influences": { "maxInfluences": 6, "floor": "continuous" } }
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**When to reach for it:** a part that folds where two chains meet, or at the end of a curl.
|
|
395
|
+
The default does not move; a fold is today found by looking at a build that passes every
|
|
396
|
+
bar, and this field is what there is to turn once one is seen.
|
|
397
|
+
|
|
398
|
+
**What it trades.** Every figure below is issue #284 Stage E's, verbatim from
|
|
399
|
+
[`docs/evidence/weight-cap.md`](evidence/weight-cap.md), on every lattice mesh of `demo`,
|
|
400
|
+
`scarf` and the trailer (`rig-c/trailer` at a99d976): the inverted triangles at the bend
|
|
401
|
+
sets (±15° on every chain link; on the trailer also ± the film's own bends, after the
|
|
402
|
+
slash), the largest idle displacement against today's rig in rig px (the part in the
|
|
403
|
+
survey's tables), and the cost — bindings (one per bone per vertex) in the whole
|
|
404
|
+
`rig.json`, `rig.json`'s length and the compiled `skeleton.json`'s, in bytes. Every
|
|
405
|
+
setting passed every `check` bar on all three examples, and `rigc render`'s time did not
|
|
406
|
+
separate them (the run's own spread was as large). "Cap none" is the survey's name for
|
|
407
|
+
`maxInfluences: null`.
|
|
408
|
+
|
|
409
|
+
| setting | inverted at the bends: demo / scarf / trailer ±15° / trailer film | idle vs today, rig px: demo / scarf / trailer | bindings: demo / scarf / trailer | `rig.json` bytes: demo / scarf / trailer | `skeleton.json` bytes: demo / scarf / trailer |
|
|
410
|
+
| --- | --- | --- | --- | --- | --- |
|
|
411
|
+
| cap 4, cut (today, absent) | 1 / 36 / 6 / 7 | — | 3008 / 4192 / 5014 | 471635 / 665178 / 773228 | 648296 / 886481 / 993045 |
|
|
412
|
+
| cap 4, continuous | 1 / 42 / 5 / 0 | 1.252 / 0.676 / 1.471 | 3008 / 4192 / 5014 | 471627 / 665230 / 773235 | 648282 / 886533 / 993052 |
|
|
413
|
+
| cap 5, cut | 1 / 35 / 5 / 1 | 1.853 / 1.291 / 1.875 | 3257 / 4546 / 5337 | 502053 / 707658 / 812385 | 670404 / 917493 / 1021528 |
|
|
414
|
+
| cap 5, continuous | 1 / 46 / 3 / 0 | 1.293 / 1.139 / 1.580 | 3257 / 4546 / 5337 | 502059 / 707681 / 812407 | 670410 / 917516 / 1021550 |
|
|
415
|
+
| cap 6, cut | 1 / 41 / 7 / 0 | 2.417 / 1.274 / 2.235 | 3423 / 4787 / 5562 | 522287 / 736622 / 839712 | 685243 / 938707 / 1041677 |
|
|
416
|
+
| cap 6, continuous | 1 / 45 / 3 / 0 | 1.329 / 1.132 / 1.750 | 3423 / 4787 / 5561 | 522272 / 736693 / 839600 | 685228 / 938778 / 1041595 |
|
|
417
|
+
| cap 8, cut | 1 / 43 / 20 / 3 | 3.350 / 1.274 / 3.106 | 3532 / 4969 / 5692 | 535476 / 758656 / 855632 | 694930 / 954712 / 1053604 |
|
|
418
|
+
| cap 8, continuous | 1 / 46 / 7 / 0 | 1.365 / 1.122 / 2.248 | 3532 / 4969 / 5692 | 535455 / 758684 / 855638 | 694909 / 954740 / 1053610 |
|
|
419
|
+
| cap none, cut | 1 / 43 / 22 / 3 | 3.350 / 1.291 / 3.179 | 3514 / 4987 / 5684 | 533305 / 760874 / 854648 | 693337 / 956381 / 1052864 |
|
|
420
|
+
| cap none, continuous | 1 / 47 / 7 / 0 | 1.365 / 1.114 / 2.197 | 3513 / 4987 / 5684 | 533169 / 760956 / 854631 | 693238 / 956463 / 1052847 |
|
|
421
|
+
| every bone, nothing dropped (the survey's reference; not a setting) | 0 / 36 / 0 / 0 | 3.350 / 1.336 / 3.286 | 5446 / 8969 / 9087 | 770980 / 1244841 / 1270495 | 866749 / 1314917 / 1358750 |
|
|
422
|
+
|
|
423
|
+
In the survey's words: cap 5 adds 6.4–8.4 % bindings, cap 6 10.9–14.2 %, cap 8 13.5–18.5 %,
|
|
424
|
+
no cap 13.4–19.0 %; `rig.json` grows 5.1–6.5 % at cap 5 and 10.5–14.4 % with no cap, the
|
|
425
|
+
compiled `skeleton.json` 2.9–3.5 % and 6.0–7.9 %; nothing dropped at all costs 81–114 % more
|
|
426
|
+
bindings. Read per part, where the folds are: on the trailer the skirt's hem corner at the
|
|
427
|
+
film's bends folds 5 today and none at caps 5 and 6 with either floor, and none at any cap
|
|
428
|
+
with the continuous floor; the long hair's curl tips fold 4 at ±15° today and none at caps
|
|
429
|
+
5 and 6 with the continuous floor, and more, not fewer, above six with the cut (17 at cap
|
|
430
|
+
8, 19 with no cap); demo's one fold, on the fringe, is there under every setting. The idle
|
|
431
|
+
moves under every setting that changes a weight: that column is the price of the change
|
|
432
|
+
itself, not a defect of the setting, and it is what to look at on the part before keeping
|
|
433
|
+
one. The survey's own pages hold the per-part tables and the pictures.
|
|
434
|
+
|
|
435
|
+
**What a declaration writes.** On `demo` and `scarf`, a config declaring each setting on
|
|
436
|
+
every lattice mesh writes, at every lattice vertex, the bones and weights the survey wrote
|
|
437
|
+
for that setting (measured when the field landed: 0 vertices differ in each of the twenty
|
|
438
|
+
builds), so the bindings and the largest count are the table's. `rig.json`'s length matches
|
|
439
|
+
the table at cap 4 and is a little shorter above it (demo at cap 5: 500517 bytes against
|
|
440
|
+
the survey's 502053), because the survey bound a bone new to a vertex at an offset read off
|
|
441
|
+
the rendered setup pose, and the rig stage writes that offset exactly, as it writes every
|
|
442
|
+
other. Under the continuous floor a share can round to 0, so such a mesh is rounded as a
|
|
443
|
+
contour region is (each share to 5 places, a 0 dropped, the remainder to the heaviest); under
|
|
444
|
+
the cut it is rounded as the lattice always was. In the contour mode a region's bone counts
|
|
445
|
+
toward the cap like any other (*The vertex's weights* in `src/localweights.ts`).
|
|
446
|
+
|
|
377
447
|
### A contour mesh (`meshes.<part>.contour`, issue #84)
|
|
378
448
|
|
|
379
449
|
The lattice keeps every grid cell that holds an art pixel, so its boundary and its interior
|
|
@@ -1773,6 +1843,7 @@ See-through with another seed.
|
|
|
1773
1843
|
| `CONFIG_BONE_UNIQUE` | a bone or chain declared twice, or `root` declared | `bones` |
|
|
1774
1844
|
| `CONFIG_PART_ATTACHED` | a plan part or a cut's piece with neither or both of a mesh and a region; a patch with a mesh, or with no region | `meshes` / `regions` |
|
|
1775
1845
|
| `CONFIG_RIBS_MODE` | `meshes.<part>.ribs` on a lattice (`grid`) mesh: its vertices are its grid's corners, which no row can move (issue #188) | put the part in `contour` or `auto`, or remove `ribs` |
|
|
1846
|
+
| `CONFIG_INFLUENCES_MODE` | `meshes.<part>.influences` on an automatic (`auto`) mesh: that mode's cap and floor are `auto.influences`, which rig-c's reduction is handed (issue #284) | declare them in `auto.influences`, or remove `influences` |
|
|
1776
1847
|
| `CONFIG_PATCH_BOX` | an `assemble.patches` box with `x1 <= x0` or `y1 <= y0` | that patch's `box` (`x1`, `y1` are exclusive) |
|
|
1777
1848
|
| `CONFIG_AMPS_MATCH_CHAIN` | a chain track's `amps` is not one per link | that track's `amps` |
|
|
1778
1849
|
| `CONFIG_PERIOD_DIVIDES_DURATION` | a period that is not a whole fraction of the idle — the loop could not close | that track's `period`, or `motion.duration` |
|
|
@@ -1852,6 +1923,7 @@ See-through with another seed.
|
|
|
1852
1923
|
| `RIG_PART_PRESENT`, `RIG_PART_ATTACHED` | a `meshes`/`regions` key that is not in `parts.json`, a part with neither, or a `painting:` part with a mesh | the config and the plan must name the same parts; a patch is a region |
|
|
1853
1924
|
| `RIG_PNG_PRESENT`, `RIG_PNG_MATCHES_BOX`, `RIG_PART_HAS_ART` | a part's PNG is missing, the wrong size, or has no art pixel | re-run assemble |
|
|
1854
1925
|
| `RIG_NAME_RESOLVES`, `RIG_SEGMENT_DEFINED`, `RIG_CHAIN_POINTS` | a segment names an undeclared bone, a bone with no `tip` and no next link, or a chain too short to make a link | that bone's `tip`, or write the segment out as `[bone, [x0,y0], [x1,y1]]` |
|
|
1926
|
+
| `RIG_INFLUENCES_KEPT` | a mesh's declared `influences` leaves a vertex with no bone: every capped share is under 0.03 (at or under it, for the continuous floor). The largest of k shares is at least 1/k, so this needs 34 or more kept bones; the default cap of 4 never reaches it (issue #284). The detail names the first vertex, how many more, and its largest share | declare `maxInfluences` 33 or less, or name fewer bones in `segments` |
|
|
1855
1927
|
| `RIG_LATTICE_ONE_LOOP` | the lattice over a part does not close into one outline even after the repair passes | that mesh's `grid` |
|
|
1856
1928
|
| `RIG_CONTROL_NAME_FREE` | a keyed, mesh-weighted bone needs `<bone>_ctl` and that name is taken | rename the declared bone |
|
|
1857
1929
|
| `RIG_TURN_NAME_FREE` | with `turn`: a twin (`turn_<part>_<bone>`), the control `turn` or a transform constraint `turn_<part>` the turn writes is a name the rig already holds | rename the declared bone or constraint |
|
package/docs/STABILITY.md
CHANGED
|
@@ -124,7 +124,8 @@ part name, `[i]` an entry of a list):
|
|
|
124
124
|
- **assemble.cuts[i].draw**, when an object — required: `before`
|
|
125
125
|
- **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`
|
|
126
126
|
- **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`
|
|
127
|
-
- **meshes.<part>** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`; optional in every mode: `exponent`, `rule`; optional with `contour` or `auto`: `ribs`
|
|
127
|
+
- **meshes.<part>** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`; optional in every mode: `exponent`, `rule`; optional with `contour` or `auto`: `ribs`; optional with `grid` or `contour`: `influences` (issue #284; beside `auto` it is refused, CONFIG_INFLUENCES_MODE)
|
|
128
|
+
- **meshes.<part>.influences** — optional, at least one: `maxInfluences` (a whole number, 1 or more, or null for no cap), `floor` ("cut" or "continuous")
|
|
128
129
|
- **meshes.<part>.ribs.<chain>** — required: `stations`
|
|
129
130
|
- **meshes.<part>.contour** — required: `tolerance`, `margin`, `spacing`; optional: `maxEdge`, `budget`, `stray`, `regions`
|
|
130
131
|
- **meshes.<part>.contour.regions[i]** — required: `name`, `shape`, `bone`, `spacing`, `band`, and `cx`, `cy`, `r` for a circle or `points` for a polygon
|
|
@@ -170,7 +171,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
|
|
|
170
171
|
| --- | --- | --- |
|
|
171
172
|
| `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242) |
|
|
172
173
|
| `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
|
|
173
|
-
| `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
|
|
174
|
+
| `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `influences`, as declared, when a lattice or contour mesh declares it (after `exponent`, or where `exponent` would be); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
|
|
174
175
|
| `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
|
|
175
176
|
| `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
|
|
176
177
|
| `basis.json` | `propose` | spec `spine-parts-basis/1` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.ts
CHANGED
|
@@ -92,6 +92,7 @@ import { type Problem, refuseIfAny } from './errors.ts';
|
|
|
92
92
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
93
93
|
import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
|
|
94
94
|
import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
95
|
+
import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
|
|
95
96
|
|
|
96
97
|
export type Point = [number, number];
|
|
97
98
|
|
|
@@ -275,6 +276,8 @@ export interface LatticeMeshSpec {
|
|
|
275
276
|
exponent?: number;
|
|
276
277
|
/** Absent: `"distance"`. */
|
|
277
278
|
rule?: WeightRule;
|
|
279
|
+
/** The cap and the floor's form (issue #284); absent is 4 and the cut at 0.03, the rule as it always was. */
|
|
280
|
+
influences?: MeshInfluences;
|
|
278
281
|
}
|
|
279
282
|
|
|
280
283
|
export interface ContourMeshSpec {
|
|
@@ -285,6 +288,8 @@ export interface ContourMeshSpec {
|
|
|
285
288
|
exponent?: number;
|
|
286
289
|
/** Absent: `"distance"`. */
|
|
287
290
|
rule?: WeightRule;
|
|
291
|
+
/** As the lattice's (issue #284). */
|
|
292
|
+
influences?: MeshInfluences;
|
|
288
293
|
/** Rows of vertices across the part along the chains named here (issue #188); absent places none. */
|
|
289
294
|
ribs?: RibsSpec;
|
|
290
295
|
}
|
|
@@ -1660,7 +1665,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1660
1665
|
const at = `config.meshes.${part}`;
|
|
1661
1666
|
// The known keys keep their old order (grid, r, segments) with contour after them, and `r` and `segments` are
|
|
1662
1667
|
// required in the place and words the object check uses, so a lattice mesh's refusals read as they did.
|
|
1663
|
-
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs']);
|
|
1668
|
+
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences']);
|
|
1664
1669
|
if (m === null) continue;
|
|
1665
1670
|
for (const key of ['r', 'segments']) if (!(key in m)) c.fail('CONFIG_FIELD_PRESENT', `${at}.${key}`, 'is absent and required');
|
|
1666
1671
|
const modes = ['grid', 'contour', 'auto'].filter((k) => k in m);
|
|
@@ -1701,6 +1706,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1701
1706
|
});
|
|
1702
1707
|
}
|
|
1703
1708
|
if ('ribs' in m) checkRibs(c, `${at}.ribs`, m, chains);
|
|
1709
|
+
if ('influences' in m) checkMeshInfluences(c, `${at}.influences`, m);
|
|
1704
1710
|
if ('auto' in m && typeof m.auto === 'object' && m.auto !== null && !Array.isArray(m.auto) && 'stimulus' in m.auto) {
|
|
1705
1711
|
checkStimulus(c, `${at}.auto.stimulus`, (m.auto as Record<string, Json>).stimulus, m, chains);
|
|
1706
1712
|
}
|
|
@@ -1746,6 +1752,29 @@ function checkStimulus(c: Check, at: string, v: Json, m: Record<string, Json>, c
|
|
|
1746
1752
|
});
|
|
1747
1753
|
}
|
|
1748
1754
|
|
|
1755
|
+
/**
|
|
1756
|
+
* `meshes.<part>.influences` (issue #284): the lattice and contour modes' cut, the author's per mesh —
|
|
1757
|
+
* `maxInfluences`, a whole number 1 or more or `null` (no cap), and `floor`, `"cut"` or `"continuous"` (`FloorForm`,
|
|
1758
|
+
* `src/weights.ts`). Either may be absent (4, and the cut); an object naming neither is refused, since it would
|
|
1759
|
+
* declare nothing. The automatic mode states its cap and floor in `auto.influences`, which rig-c's reduction is
|
|
1760
|
+
* handed, so this field beside `auto` is refused by name (`CONFIG_INFLUENCES_MODE`) rather than read by nothing.
|
|
1761
|
+
*/
|
|
1762
|
+
function checkMeshInfluences(c: Check, at: string, m: Record<string, Json>): void {
|
|
1763
|
+
if ('auto' in m) {
|
|
1764
|
+
c.fail('CONFIG_INFLUENCES_MODE', at, `is ${show(m.influences)} on an automatic mesh (auto); the automatic mode states its cap and floor in auto.influences (maxInfluences, minWeight), which rig-c's reduction is handed — declare them there, or remove influences`);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const l = c.object(at, m.influences, [], ['maxInfluences', 'floor']);
|
|
1768
|
+
if (l === null) return;
|
|
1769
|
+
if (!('maxInfluences' in l) && !('floor' in l)) c.fail('CONFIG_FIELD_TYPE', at, `is ${show(m.influences)}, which names neither maxInfluences nor floor; remove influences to keep the cap of 4 and the cut at 0.03`);
|
|
1770
|
+
if ('maxInfluences' in l && l.maxInfluences !== null && !(typeof l.maxInfluences === 'number' && Number.isInteger(l.maxInfluences) && l.maxInfluences >= 1)) {
|
|
1771
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.maxInfluences`, `is ${show(l.maxInfluences)}; an integer at or above 1, or null (no cap: every bone the mesh's segments name), is required`);
|
|
1772
|
+
}
|
|
1773
|
+
if ('floor' in l && !FLOOR_FORMS.includes(l.floor as FloorForm)) {
|
|
1774
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.floor`, `is ${show(l.floor)}; "cut" (shares under 0.03 dropped, what an absent floor means) or "continuous" (0.03 subtracted from every share, those at or below 0 dropped) is required`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1749
1778
|
/**
|
|
1750
1779
|
* `meshes.<part>.ribs` (issue #188): an object of chain name → `{ stations }`.
|
|
1751
1780
|
* Each key must be a chain this mesh's `segments` name (a rib is placed along
|
package/src/meshdraft.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - dust left out: an extra 4-connected island of the art (alpha above `ART_ALPHA`) of at most {@link DUST_MAX_PX} px
|
|
15
15
|
* and at most {@link DUST_MAX_SHARE} of the part's art is dust, and a `stray` equal to the largest such island leaves
|
|
16
16
|
* it out. A part with any other extra island holds a real piece, and keeps its lattice ({@link dustOf});
|
|
17
|
-
* - `r`, `segments`, `exponent` and `
|
|
17
|
+
* - `r`, `segments`, `exponent`, `rule` and `influences` as the lattice entry states them.
|
|
18
18
|
*
|
|
19
19
|
* The constants are the ones issue #265's stages measured (Stage A passed every bar at c = 37 on three examples; T 2
|
|
20
20
|
* at the trailer's 1108 px figure is Stage A2's point; the dust rule is Stage B's). They are calibrated on three
|
|
@@ -200,6 +200,7 @@ export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules,
|
|
|
200
200
|
segments: lattice.segments,
|
|
201
201
|
...(lattice.exponent === undefined ? {} : { exponent: lattice.exponent }),
|
|
202
202
|
...(lattice.rule === undefined ? {} : { rule: lattice.rule }),
|
|
203
|
+
...(lattice.influences === undefined ? {} : { influences: lattice.influences }),
|
|
203
204
|
};
|
|
204
205
|
}
|
|
205
206
|
|
|
@@ -692,7 +693,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
692
693
|
const denseMeshes = new Map<string, LatticeMeshSpec>();
|
|
693
694
|
for (const part of names) {
|
|
694
695
|
const l = cfg.meshes[part] as LatticeMeshSpec;
|
|
695
|
-
denseMeshes.set(part, { grid: denseGrid(artPixels(images.get(part) as Raster)), r: l.r, segments: l.segments, ...(l.exponent === undefined ? {} : { exponent: l.exponent }), ...(l.rule === undefined ? {} : { rule: l.rule }) });
|
|
696
|
+
denseMeshes.set(part, { grid: denseGrid(artPixels(images.get(part) as Raster)), r: l.r, segments: l.segments, ...(l.exponent === undefined ? {} : { exponent: l.exponent }), ...(l.rule === undefined ? {} : { rule: l.rule }), ...(l.influences === undefined ? {} : { influences: l.influences }) });
|
|
696
697
|
}
|
|
697
698
|
const denseRig = buildRig({ ...cfg, meshes: { ...cfg.meshes, ...Object.fromEntries(denseMeshes) } }, parts, images, undefined, input.idleKeys).rig;
|
|
698
699
|
const pngs: Array<[string, Uint8Array]> = readdirSync(join(draftedRig.rigDir, 'images')).sort().map((f) => [f, new Uint8Array(readFileSync(join(draftedRig.rigDir, 'images', f)))]);
|
package/src/rig.ts
CHANGED
|
@@ -161,7 +161,7 @@ import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
|
161
161
|
import { pyRound } from './round.ts';
|
|
162
162
|
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
163
|
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
164
|
-
import {
|
|
164
|
+
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
165
165
|
|
|
166
166
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
167
167
|
export const PAD = 4;
|
|
@@ -309,12 +309,47 @@ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>):
|
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
311
|
* The weights a contour vertex is written with: a vertex no region reaches is
|
|
312
|
-
* rounded as the lattice rounds (
|
|
313
|
-
*
|
|
312
|
+
* rounded as the lattice rounds ({@link latticeShares}), one a region reaches
|
|
313
|
+
* by {@link roundShares} — and so is every vertex of a mesh that declares the
|
|
314
|
+
* continuous floor (issue #284), whose shares, like a region's ramp, reach
|
|
315
|
+
* below 0.03 and may round to 0.
|
|
314
316
|
*/
|
|
315
|
-
export function writtenShares(li: LocalInfluence): Influence[] {
|
|
316
|
-
if (li.region >= 0) return roundShares(li.influences);
|
|
317
|
-
|
|
317
|
+
export function writtenShares(li: LocalInfluence, floor?: FloorForm): Influence[] {
|
|
318
|
+
if (li.region >= 0 || floor === 'continuous') return roundShares(li.influences);
|
|
319
|
+
return latticeShares(li.influences);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The refusal for a mesh some of whose vertices the cut leaves with no bone (issue #284): the first such vertex, with
|
|
324
|
+
* its capped shares before the floor, and how many more there are.
|
|
325
|
+
*
|
|
326
|
+
* When it can happen, by arithmetic: the capped shares of a vertex are normalised, so over k kept bones they sum to 1
|
|
327
|
+
* and the largest is at least 1/k. The cut keeps a share of at least 0.03 and the continuous floor one above 0.03, so a
|
|
328
|
+
* vertex keeps no bone only if 1/k ≤ 0.03, that is k ≥ 34 (1/33 = 0.0303… keeps its largest under both forms). At the
|
|
329
|
+
* default cap of 4 the largest share is at least 0.25, so a mesh that declares nothing never reaches this; only a
|
|
330
|
+
* declared cap of 34 or more, or `null`, over 34 or more bones whose shares are all at or below the floor does — and a
|
|
331
|
+
* share that is not a number (raw values summing to 0) lands here too rather than in the file. Rare, so refused by
|
|
332
|
+
* name rather than left to a crash in the rounding.
|
|
333
|
+
*/
|
|
334
|
+
export function nothingKeptProblem(part: string, floor: FloorForm | undefined, first: { vertex: number; at: Point; capped: readonly Influence[] }, more: number): Problem {
|
|
335
|
+
const largest = first.capped.length === 0 ? null : Math.max(...first.capped.map((e) => e.weight));
|
|
336
|
+
const form = floor === 'continuous' ? 'the continuous floor (0.03 subtracted from every share) keeps none at or below 0.03' : 'the cut drops every share below 0.03';
|
|
337
|
+
return {
|
|
338
|
+
code: 'RIG_INFLUENCES_KEPT',
|
|
339
|
+
object: `config.meshes.${part}.influences`,
|
|
340
|
+
detail: `vertex ${first.vertex} at rig (${first.at[0]}, ${first.at[1]})${more > 0 ? ` and ${more} more` : ''} keeps no bone: its ${first.capped.length} capped share(s) have the largest ${largest === null || !Number.isFinite(largest) ? String(largest) : pyRound(largest, 6)}, and ${form}; a largest share above 0.03 is required, which every vertex has when 33 or fewer bones are kept (the largest of k shares is at least 1/k) — declare maxInfluences 33 or less, or name fewer bones in segments`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The lattice's rounding: each weight to 5 places, the last entry written as
|
|
346
|
+
* `1 − sum(others)`. Safe because under the cut every kept entry is at least
|
|
347
|
+
* `MIN_WEIGHT`, so the last cannot be taken negative; a mesh that declares the
|
|
348
|
+
* continuous floor is rounded by {@link roundShares} instead (issue #284, as
|
|
349
|
+
* Stage E wrote it: `docs/evidence/weight-cap.md`).
|
|
350
|
+
*/
|
|
351
|
+
export function latticeShares(list: readonly Influence[]): Influence[] {
|
|
352
|
+
const shares = list.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
|
|
318
353
|
let others = 0;
|
|
319
354
|
for (let k = 0; k < shares.length - 1; k++) others += shares[k].weight;
|
|
320
355
|
shares[shares.length - 1].weight = pyRound(1 - others, 5);
|
|
@@ -613,6 +648,8 @@ export interface LatticeMeshReport {
|
|
|
613
648
|
grid: number;
|
|
614
649
|
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
615
650
|
exponent?: number;
|
|
651
|
+
/** The cap and floor form the mesh declared, as declared (issue #284); absent when it declared none and ran at 4 and the cut. */
|
|
652
|
+
influences?: MeshInfluences;
|
|
616
653
|
/** Only under `rule: "heat"` (issue #161): the rule and the solve it ran. Absent otherwise, so a distance mesh writes the row it always wrote. */
|
|
617
654
|
heat?: HeatRow;
|
|
618
655
|
}
|
|
@@ -636,6 +673,8 @@ export interface ContourMeshReport {
|
|
|
636
673
|
mode: 'contour';
|
|
637
674
|
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
638
675
|
exponent?: number;
|
|
676
|
+
/** As the lattice row's (issue #284). */
|
|
677
|
+
influences?: MeshInfluences;
|
|
639
678
|
/** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
|
|
640
679
|
ribs?: RibsRow;
|
|
641
680
|
/** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
|
|
@@ -1321,8 +1360,10 @@ export function buildRig(
|
|
|
1321
1360
|
segs: Segment[],
|
|
1322
1361
|
rule: WeightRule,
|
|
1323
1362
|
ribSpec: { segments: ReadonlyArray<string | readonly [string, Point, Point]>; ribs?: RibsSpec },
|
|
1363
|
+
declared: MeshInfluences | undefined,
|
|
1324
1364
|
out: Problem[],
|
|
1325
1365
|
): { attachment: MeshAttachment; report: ContourMeshReport } | null => {
|
|
1366
|
+
const limits = meshLimits(declared);
|
|
1326
1367
|
const ox = p.x - PAD;
|
|
1327
1368
|
const oy = p.y - PAD;
|
|
1328
1369
|
const w = img.width;
|
|
@@ -1354,10 +1395,12 @@ export function buildRig(
|
|
|
1354
1395
|
let infl = 0;
|
|
1355
1396
|
let maxInfl = 0;
|
|
1356
1397
|
let overlapped = false;
|
|
1398
|
+
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1399
|
+
let emptiedMore = 0;
|
|
1357
1400
|
cm.vertices.forEach(([vx, vy], vi) => {
|
|
1358
1401
|
const wx = vx + ox;
|
|
1359
1402
|
const wy = vy + oy;
|
|
1360
|
-
const li = localInfluences([wx, wy], segs, r, regions,
|
|
1403
|
+
const li = localInfluences([wx, wy], segs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
|
|
1361
1404
|
if ('first' in li) {
|
|
1362
1405
|
if (!overlapped) {
|
|
1363
1406
|
out.push({
|
|
@@ -1369,11 +1412,17 @@ export function buildRig(
|
|
|
1369
1412
|
overlapped = true;
|
|
1370
1413
|
return;
|
|
1371
1414
|
}
|
|
1415
|
+
if (li.influences.length === 0) {
|
|
1416
|
+
// issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be); a region's bone would be kept.
|
|
1417
|
+
if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
|
|
1418
|
+
else emptiedMore++;
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1372
1421
|
if (li.region >= 0) {
|
|
1373
1422
|
reached[li.region]++;
|
|
1374
1423
|
if (li.g >= 1) whole[li.region]++;
|
|
1375
1424
|
}
|
|
1376
|
-
const shares = writtenShares(li);
|
|
1425
|
+
const shares = writtenShares(li, limits.floor);
|
|
1377
1426
|
const ent: WeightEntry[] = shares.map(({ bone, weight }) => {
|
|
1378
1427
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1379
1428
|
return { bone, x: places(x), y: places(y), weight };
|
|
@@ -1383,6 +1432,10 @@ export function buildRig(
|
|
|
1383
1432
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1384
1433
|
});
|
|
1385
1434
|
if (overlapped) return null;
|
|
1435
|
+
if (emptied !== null) {
|
|
1436
|
+
out.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1386
1439
|
const uvs: number[] = [];
|
|
1387
1440
|
for (const [x, y] of cm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1388
1441
|
const bones = new Set(segs.map((s) => s.bone));
|
|
@@ -1401,6 +1454,7 @@ export function buildRig(
|
|
|
1401
1454
|
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
|
|
1402
1455
|
mode: 'contour',
|
|
1403
1456
|
...(exponent === undefined ? {} : { exponent }),
|
|
1457
|
+
...(declared === undefined ? {} : { influences: declared }),
|
|
1404
1458
|
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
|
|
1405
1459
|
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1406
1460
|
contour: rep,
|
|
@@ -1684,7 +1738,7 @@ export function buildRig(
|
|
|
1684
1738
|
continue;
|
|
1685
1739
|
}
|
|
1686
1740
|
if ('contour' in mesh) {
|
|
1687
|
-
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
|
|
1741
|
+
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, mesh.influences, problems);
|
|
1688
1742
|
if (row === null) continue;
|
|
1689
1743
|
skin[p.name] = { [p.name]: row.attachment };
|
|
1690
1744
|
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
@@ -1711,20 +1765,32 @@ export function buildRig(
|
|
|
1711
1765
|
const weights: WeightEntry[][] = [];
|
|
1712
1766
|
let infl = 0;
|
|
1713
1767
|
let maxInfl = 0;
|
|
1714
|
-
|
|
1768
|
+
// issue #284: the mesh's declared cap and floor form, or DEFAULT_LIMITS itself when it declares none.
|
|
1769
|
+
const limits: InfluenceLimits = meshLimits(mesh.influences);
|
|
1770
|
+
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1771
|
+
let emptiedMore = 0;
|
|
1772
|
+
for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
|
|
1715
1773
|
const wx = vx0 + ox;
|
|
1716
1774
|
const wy = vy0 + oy;
|
|
1717
|
-
const
|
|
1775
|
+
const shares = heat === null ? influences([wx, wy], segs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
|
|
1776
|
+
if (shares.length === 0) {
|
|
1777
|
+
// issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be).
|
|
1778
|
+
if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
|
|
1779
|
+
else emptiedMore++;
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
const ent: WeightEntry[] = (limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares)).map(({ bone, weight }) => {
|
|
1718
1783
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1719
|
-
return { bone, x: places(x), y: places(y), weight
|
|
1784
|
+
return { bone, x: places(x), y: places(y), weight };
|
|
1720
1785
|
});
|
|
1721
|
-
let others = 0;
|
|
1722
|
-
for (let k = 0; k < ent.length - 1; k++) others += ent[k].weight;
|
|
1723
|
-
ent[ent.length - 1].weight = pyRound(1 - others, 5);
|
|
1724
1786
|
weights.push(ent);
|
|
1725
1787
|
infl += ent.length;
|
|
1726
1788
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1727
1789
|
}
|
|
1790
|
+
if (emptied !== null) {
|
|
1791
|
+
problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1728
1794
|
const uvs: number[] = [];
|
|
1729
1795
|
for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1730
1796
|
skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
|
|
@@ -1740,6 +1806,7 @@ export function buildRig(
|
|
|
1740
1806
|
art_coverage: pyRound(artCoverage(lm, art), 5),
|
|
1741
1807
|
grid: mesh.grid,
|
|
1742
1808
|
...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
|
|
1809
|
+
...(mesh.influences === undefined ? {} : { influences: mesh.influences }),
|
|
1743
1810
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1744
1811
|
});
|
|
1745
1812
|
}
|
package/src/weights.ts
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* rounding. Ties in the sort keep the order the bones first appeared among the
|
|
19
19
|
* segments (Python's `sorted` is stable, and so is `Array.prototype.sort`).
|
|
20
20
|
*
|
|
21
|
+
* The cap and the floor's form are the author's per mesh in the lattice and
|
|
22
|
+
* contour modes (issue #284, `meshes.<part>.influences`): a declared
|
|
23
|
+
* `maxInfluences` (a whole number, or `null`: no cap) replaces the 4, and
|
|
24
|
+
* `floor: "continuous"` replaces the drop at 0.03 by the continuous floor
|
|
25
|
+
* ({@link FloorForm}). Absent, both are the constants above, and every byte is
|
|
26
|
+
* the one written before the field existed.
|
|
27
|
+
*
|
|
21
28
|
* Distances are the reference's arithmetic, operation for operation — the
|
|
22
29
|
* projection `t = clip(((p - a) . ab) / |ab|^2, 0, 1)` and `sqrt(dx^2 + dy^2)`,
|
|
23
30
|
* not `Math.hypot`, whose last bit differs — because a weight rounded at the
|
|
@@ -31,12 +38,36 @@
|
|
|
31
38
|
*/
|
|
32
39
|
import type { Point } from './config.ts';
|
|
33
40
|
|
|
34
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* At most this many bones pull one vertex where a mesh declares no cap: the reference implementation's `[:4]`,
|
|
43
|
+
* carried over as a port constant, and nothing more. The format sets no limit — spine-core 4.3.13 reads a weighted
|
|
44
|
+
* vertex as a bone count followed by that many bindings, with no bound on the count (`SkeletonJson.js`,
|
|
45
|
+
* `SkeletonBinary.js`, and `computeWorldVertices` in `attachments/Attachment.js`) — and rig-c 2.40.0 neither refuses
|
|
46
|
+
* nor warns above four: its one assertion on a mesh's weights, `A20_MESH_WEIGHTS_COHERENT`, checks that they are
|
|
47
|
+
* coherent (every vertex bound, every weight finite and non-negative, each vertex summing to 1), not how many there
|
|
48
|
+
* are. Read and cited in `docs/evidence/weight-cap.md` (issue #284, Stage E). A mesh may declare its own cap
|
|
49
|
+
* (`meshes.<part>.influences.maxInfluences`).
|
|
50
|
+
*/
|
|
35
51
|
export const MAX_INFLUENCES = 4;
|
|
36
52
|
|
|
37
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A normalised weight under this is dropped before the final normalisation — the reference's `>= 0.03`. Under the
|
|
55
|
+
* continuous floor ({@link FloorForm}) it is subtracted from every kept share instead.
|
|
56
|
+
*/
|
|
38
57
|
export const MIN_WEIGHT = 0.03;
|
|
39
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The floor's form (issue #284): `"cut"` — the reference's, and what an absent form means: the capped shares,
|
|
61
|
+
* normalised, any under the floor dropped, the rest normalised again — or `"continuous"`: the capped shares,
|
|
62
|
+
* normalised, the floor subtracted from every one, those at or below zero dropped, the rest normalised again. Under
|
|
63
|
+
* the continuous floor a share reaches zero exactly where the cut would drop it, so a bone fades out of a vertex
|
|
64
|
+
* instead of leaving it in one step; this is Stage A's "continuous floor" (`tools/tear_survey.ts`), the form Stage E
|
|
65
|
+
* swept at every cap (`docs/evidence/weight-cap.md`).
|
|
66
|
+
*/
|
|
67
|
+
export type FloorForm = 'cut' | 'continuous';
|
|
68
|
+
|
|
69
|
+
export const FLOOR_FORMS: readonly FloorForm[] = ['cut', 'continuous'];
|
|
70
|
+
|
|
40
71
|
/**
|
|
41
72
|
* The exponent of the distance rule where a mesh declares none: `1/(d + r)²`,
|
|
42
73
|
* the reference's rule. At this value the weight is computed as
|
|
@@ -76,20 +107,42 @@ export interface Influence {
|
|
|
76
107
|
|
|
77
108
|
/**
|
|
78
109
|
* The cap and the floor `influences()` applies. The lattice and the contour
|
|
79
|
-
* mode
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
110
|
+
* mode run at {@link DEFAULT_LIMITS} (the reference's `[:4]` and `>= 0.03`)
|
|
111
|
+
* unless the mesh declares `influences` (issue #284; {@link meshLimits}); the
|
|
112
|
+
* automatic mode (`src/automesh.ts`, issue #126) passes the author's numbers
|
|
113
|
+
* instead, because rig-c's reduction is handed explicit influence limits on
|
|
114
|
+
* every weighted call and the 0.03 floor is not one the author wrote (P19).
|
|
115
|
+
* The arithmetic is the same either way, so with the defaults every byte the
|
|
116
|
+
* two older modes write is unchanged.
|
|
85
117
|
*/
|
|
86
118
|
export interface InfluenceLimits {
|
|
87
119
|
maxInfluences: number;
|
|
88
120
|
minWeight: number;
|
|
121
|
+
/** The floor's form; absent is `"cut"`, the arithmetic as it always was. */
|
|
122
|
+
floor?: FloorForm;
|
|
89
123
|
}
|
|
90
124
|
|
|
91
125
|
export const DEFAULT_LIMITS: InfluenceLimits = { maxInfluences: MAX_INFLUENCES, minWeight: MIN_WEIGHT };
|
|
92
126
|
|
|
127
|
+
/** `meshes.<part>.influences` in the lattice and contour modes (issue #284): either field may be absent. */
|
|
128
|
+
export interface MeshInfluences {
|
|
129
|
+
/** A whole number, 1 or more, or `null`, no cap — every bone the mesh's segments name is kept (the config's spelling of a bound declared absent, as `maxOvershoot` and `weightJump`). Absent: {@link MAX_INFLUENCES}. */
|
|
130
|
+
maxInfluences?: number | null;
|
|
131
|
+
/** Absent: `"cut"`. */
|
|
132
|
+
floor?: FloorForm;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The limits a lattice or contour mesh runs at: {@link DEFAULT_LIMITS} itself when it declares no `influences`, so
|
|
137
|
+
* nothing about such a mesh changes; otherwise its declared cap (`null` is a count no vertex reaches) and floor form,
|
|
138
|
+
* with the floor's value {@link MIN_WEIGHT} either way — the declaration chooses the form of the cut, not where it falls.
|
|
139
|
+
*/
|
|
140
|
+
export function meshLimits(declared: MeshInfluences | undefined): InfluenceLimits {
|
|
141
|
+
if (declared === undefined) return DEFAULT_LIMITS;
|
|
142
|
+
const cap = declared.maxInfluences;
|
|
143
|
+
return { maxInfluences: cap === undefined ? MAX_INFLUENCES : cap === null ? Number.MAX_SAFE_INTEGER : cap, minWeight: MIN_WEIGHT, floor: declared.floor ?? 'cut' };
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
/**
|
|
94
147
|
* The influences on one vertex at `p` (rig pixels), before rounding, under
|
|
95
148
|
* `w = 1/(d + r)^exponent`. The rig stage passes every mesh's exponent
|
|
@@ -110,14 +163,19 @@ export function influences(p: Point, segments: readonly Segment[], r: number, li
|
|
|
110
163
|
|
|
111
164
|
/**
|
|
112
165
|
* The cap and the floor, on raw per-bone values in first-appearance order: sorted (stable), the top
|
|
113
|
-
* `maxInfluences` normalised,
|
|
114
|
-
*
|
|
166
|
+
* `maxInfluences` normalised, then the floor in its form ({@link FloorForm}) — under `"cut"` those under `minWeight`
|
|
167
|
+
* dropped, under `"continuous"` `minWeight` subtracted from each and those at or below zero dropped — and the rest
|
|
168
|
+
* normalised again. The distance rule above and bone heat (`src/heat.ts`, issue #161) both end here, so the two
|
|
169
|
+
* rules share one cap and one floor.
|
|
115
170
|
*/
|
|
116
171
|
export function capAndFloor(raw: ReadonlyArray<readonly [string, number]>, limits: InfluenceLimits): Influence[] {
|
|
117
172
|
const top = [...raw].sort((x, y) => y[1] - x[1]).slice(0, limits.maxInfluences);
|
|
118
173
|
let s = 0;
|
|
119
174
|
for (const [, v] of top) s += v;
|
|
120
|
-
const kept =
|
|
175
|
+
const kept =
|
|
176
|
+
limits.floor === 'continuous'
|
|
177
|
+
? top.map(([bone, v]) => ({ bone, weight: v / s - limits.minWeight })).filter((e) => e.weight > 0)
|
|
178
|
+
: top.map(([bone, v]) => ({ bone, weight: v / s })).filter((e) => e.weight >= limits.minWeight);
|
|
121
179
|
let s2 = 0;
|
|
122
180
|
for (const e of kept) s2 += e.weight;
|
|
123
181
|
return kept.map((e) => ({ bone: e.bone, weight: e.weight / s2 }));
|