@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 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.&lt;part&gt;** — 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.&lt;part&gt;** — 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.&lt;part&gt;.influences** — optional, at least one: `maxInfluences` (a whole number, 1 or more, or null for no cap), `floor` ("cut" or "continuous")
128
129
  - **meshes.&lt;part&gt;.ribs.&lt;chain&gt;** — required: `stations`
129
130
  - **meshes.&lt;part&gt;.contour** — required: `tolerance`, `margin`, `spacing`; optional: `maxEdge`, `budget`, `stray`, `regions`
130
131
  - **meshes.&lt;part&gt;.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.10.0",
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 `rule` as the lattice entry states them.
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 { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
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 (each to 5 places, the last `1 − sum(others)`),
313
- * one a region reaches by {@link roundShares}.
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
- const shares = li.influences.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
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, DEFAULT_LIMITS, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], DEFAULT_LIMITS));
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
- for (const [vx0, vy0] of lm.vertices) {
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 ent: WeightEntry[] = (heat === null ? influences([wx, wy], segs, mesh.r, undefined, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], DEFAULT_LIMITS)).map(({ bone, weight }) => {
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: pyRound(weight, 5) };
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
- /** At most this many bones pull one vertex — Spine's usual budget and the reference's `[:4]`. */
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
- /** A normalised weight under this is dropped before the final normalisation — the reference's `>= 0.03`. */
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 always run at {@link DEFAULT_LIMITS} (the reference's `[:4]` and
80
- * `>= 0.03`); the automatic mode (`src/automesh.ts`, issue #126) passes the
81
- * author's numbers instead, because rig-c's reduction is handed explicit
82
- * influence limits on every weighted call and the 0.03 floor is not one the
83
- * author wrote (P19). The arithmetic is the same either way, so with the
84
- * defaults every byte the two older modes write is unchanged.
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, those under `minWeight` dropped, the rest normalised again. The distance rule
114
- * above and bone heat (`src/heat.ts`, issue #161) both end here, so the two rules share one cap and one floor.
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 = top.map(([bone, v]) => ({ bone, weight: v / s })).filter((e) => e.weight >= limits.minWeight);
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 }));