@rig-c/parts 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -228,7 +228,7 @@ than queueing behind someone else's job.
228
228
  ## The loop, for an agent
229
229
 
230
230
  ```sh
231
- rig-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png
231
+ rig-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png; the config needs assemble.rig_scale
232
232
  # See-through on st_input_full.png (external, or `rig-parts comfy seethrough`) -> layers/full
233
233
  rig-parts layers layers/full # every layer: box, opaque px, depth, plausibility figures; WARN lines
234
234
  rig-parts propose --head-box --full layers/full --canvas 1664x2432
@@ -323,9 +323,13 @@ These are limits of the approach, stated so nobody reads more into a green run:
323
323
  existence proof, not a rate.
324
324
  - **`loop` writes GIF and APNG (lossless and indexed), not WebP**: an animated WebP needs a VP8/VP8L
325
325
  encoder, which this package does not carry.
326
- - **The proposer assumes a standing figure unless it is told the pose.** Its ratios
327
- put the hip below the chest and hang the sleeves from the shoulders. A seated or
328
- reclining figure needs `propose --keypoints`, a file of its joints from any pose
326
+ - **The proposer reads the torso by screen y unless it is told the pose.** Its ratios
327
+ put the hip below `bottomwear`'s top edge and the chest above it, and hang the sleeves
328
+ from the shoulders. A figure whose joints are not there — reclining, an arm raised, or
329
+ seated in a skirt that does not start at the hip — needs `propose --keypoints`; sitting
330
+ alone does not, but a clean LINT does not show the hip is right (it asks only that the
331
+ hip be below the chest and the shoulders), so read the overlay ([AUTHORING §3](docs/AUTHORING.md)). The
332
+ file of joints comes from any pose
329
333
  estimator or a hand; this package does not estimate a pose itself, and its
330
334
  keypoint support has been measured on generated figures and on files read off the
331
335
  examples' own configs, never on an estimator's output.
package/cli.ts CHANGED
@@ -96,8 +96,13 @@ usage:
96
96
  link of a fringe chain (hung from head on a head-run front hair mesh, its tip
97
97
  no lower than the chin) that drifts more than ${FRINGE_STEP_PX} px plus a pixel's
98
98
  rounding across the head's down axis (the eye line's perpendicular), for a
99
- hip that is not below the chest, and for a hip above ${HIP_MIN_FRACTION} of the figure's
100
- height (the shoulders); the fringe's three chains stand in the head's frame,
99
+ hip that is not below the chest, for a hip above ${HIP_MIN_FRACTION} of the figure's
100
+ height (the shoulders), and for two keyed chains on one lattice mesh whose
101
+ own swing, every other bone held, takes an edge of the rig stage's lattice
102
+ past check's TEXTURE_STRETCH bar (with each chain alone, the tips'
103
+ distance, and each remedy's own figure: one sign for opposed amplitudes,
104
+ dropping a chain; a contour or automatic mesh, or rule "heat", is said in
105
+ a "squeeze not read" note); the fringe's three chains stand in the head's frame,
101
106
  rooted on the part's art, and a note says they are a template, not its
102
107
  locks; a headwear/earwear layer with hanging strands
103
108
  gets one pendulum chain per strand and a note with each strand's x, rows
@@ -273,8 +278,11 @@ usage:
273
278
  than its lattice, and the report records each part's triangle and
274
279
  overdraw difference. A part that passes is drafted, its note saying
275
280
  what it was measured against; one that fails keeps its entry, and the
276
- report names each failed reading and by how much. Written only when the
277
- drafted config's check is PASS. --requirements, --idle-keys,
281
+ report names each failed reading and by how much. An auto or contour
282
+ entry is neither drafted nor compared with anything: a NOT COMPARED line
283
+ and the report's not_compared name each, so build it once on a lattice
284
+ and compare the rig stage's mesh lines (AUTHORING §3, issue #307).
285
+ Written only when the drafted config's check is PASS. --requirements, --idle-keys,
278
286
  --page-edges and --pack-shape are as for rig and check.
279
287
 
280
288
  rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
@@ -880,6 +888,7 @@ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes:
880
888
  for (const f of res.findings) console.log(lintLine(f));
881
889
  for (const m of res.unknownMeshes) console.log(`note: mesh ${JSON.stringify(m)} names no part in parts.json, so it was not linted`);
882
890
  for (const w of res.lockUnread) console.log(`note: off-lock not read: ${w}`);
891
+ for (const w of res.squeezeUnread) console.log(`note: squeeze not read: ${w}`);
883
892
  for (const b of res.missingTorsoBones) {
884
893
  console.log(
885
894
  res.basis?.torso.read === true
package/docs/AUTHORING.md CHANGED
@@ -187,10 +187,58 @@ template the proposer wrote before issue #249 fires it on any head tilted more t
187
187
  degrees (the trailer character, 23.7 degrees: all six links; `reference-demo`, 3.2
188
188
  degrees, and `reference-sample`, -4.9 degrees: one link each).
189
189
 
190
- **A posed figure: `propose --keypoints`.** The proposer's ratios assume a
191
- standing figure: the hip below the chest, sleeves hanging from the shoulders. A
192
- seated, reclining or otherwise posed figure is told where its joints are with one
193
- explicit file (issue #75, `src/keypoints.ts`). A pose file named by
190
+ **Two chains on one lattice (issue #314).** `lint` swings every pair of chains a lattice
191
+ mesh's `segments` both name, both keyed by a chain track, on the rig stage's own lattice and
192
+ weights (`src/squeeze.ts`: the part padded as the rig stage pads it, `latticeMesh`, and
193
+ `influences` under the mesh's `r`, `exponent`, `influences` and `stray`, rounded as the
194
+ lattice rounds — so its vertex and triangle numbers are the ones `rig.json` and `check`
195
+ use). Each link turns as the idle's sine states it at every frame `check` renders, every
196
+ other bone held at the setup pose, and each edge is read by `check`'s own reader. A pair
197
+ whose worst edge is past `TEXTURE_STRETCH`'s bar (max(ratio, 1/ratio) above 1.926544)
198
+ prints
199
+ `LINT <a> and <b> on mesh '<part>' squeeze it: swung together, every other bone held, edge <i>-<j> of triangle <t> closes to <r> times its rest length at idle frame <f> (max(ratio, 1/ratio) <s>; <= 1.926544 is required, check's TEXTURE_STRETCH bar) — vertex <i> rides <bone> <w>, vertex <j> rides <bone> <w>; alone, <a> reads <x> and <b> <y>. Their tips are <d> px apart and …`
200
+ (`stretch it` and `opens` when the edge grows). The end names the amplitudes and the
201
+ remedies whose own figure passes the bar: with the two chains' amplitudes opposed link for
202
+ link, `give both the same sign (then <s'>)` — `<s'>` the same reading with the second
203
+ chain's signs turned; `drop one chain` when each alone passes, or `drop <b>, which alone is
204
+ past the bar too` when only one does; and, when none passes,
205
+ `lower the amplitudes`. A contour or automatic mesh, or `rule: "heat"`, is not rebuilt here:
206
+ a `note: squeeze not read: …` line names the mesh and its pairs. What it predicts is
207
+ `check`'s measurement without the other bones' motion. Measured: on `fixtures/squeeze.ts`
208
+ (two chains 39 px apart at their tips, the proposer's mirrored back-hair amplitudes) it
209
+ names the triangle, edge and frame `check` fails, 2.494 predicted against 2.488 measured,
210
+ and one sign passes both (1.054); on the four public examples it prints nothing; on
211
+ `reference-demo` the back-hair pair reads 1.393 at `hair_back` edge 43-42, frame 26 — the
212
+ edge and frame `check` reports as that example's worst, 1.388, the figure the bar is the
213
+ square of — and 1.075 with one sign; on `reference-sample` the sleeve pair reads 1.230 at
214
+ the edge and frame `check` reads 1.227 at.
215
+
216
+ **Why the back hair mirrors.** The proposer drafts `hairback_r` with amplitudes
217
+ `[0.6, 1.4, 2.4]` and `hairback_l` with their negatives, while the fringe, lock and skirt chains share one sign (the two sleeve chains are mirrored
218
+ the same way). The mirror is the reference proposer's draft,
219
+ ported as it was (`src/propose.ts`, `SIDES`); no reason is recorded with it. Opposed, the
220
+ two tips swing towards each other and apart on the same beat. On two hanks far apart that
221
+ costs nothing a bar reads (`demo`, tips 371 px apart: 1.050 for the pair, as for either
222
+ alone), and on one mass with the two chains close it squeezes the lattice between them
223
+ (issue #314: tips 39 px apart, an edge at 0.065 of its rest length). Whether the hair is one
224
+ mass is not something the proposer can read off the opaque shape — both chains always hang
225
+ on the one `back hair` part — so the draft is unchanged and the squeeze is measured instead,
226
+ by the lint above.
227
+
228
+ **A posed figure: `propose --keypoints`.** Without joints the proposer reads the
229
+ torso by screen y: the hip 0.14 face heights below `bottomwear`'s top edge (or below
230
+ the waist: *The hip*, below), the chest half way up to the neck, sleeves hanging from the
231
+ shoulders. A figure whose joints are not where those rules put them — a reclining
232
+ figure, an arm raised or crossed, or a seated figure whose skirt does not start at
233
+ the hip — is told where its joints are with one explicit file (issue #75,
234
+ `src/keypoints.ts`). Sitting alone does not call for it: a seated figure whose skirt
235
+ starts at the waist has its hip there (issue #314: a seated character proposed without
236
+ joints, its hip placed and `lint` clean, built green once its back-hair chains were
237
+ fixed). But a clean `lint` does not say the hip is right: the screen-y rules ask only
238
+ that the hip be below the chest and below 0.25 of the figure's height, and a hip at a
239
+ seated skirt's top passes both (selftest `PR56`: the rule's hip 28 px above where the
240
+ thighs leave the torso, no LINT line) — read the overlay, and where the hip or a sleeve
241
+ is off its joint, give the joints. A pose file named by
194
242
  `generation.control.skeleton` is not that file and is not offered as its default:
195
243
  the control skeleton is what was asked of the model, not where the joints ended up
196
244
  (a consumer measured a generated picture not following the control's raised hand),
@@ -450,6 +498,48 @@ contour region is (each share to 5 places, a 0 dropped, the remainder to the hea
450
498
  the cut it is rounded as the lattice always was. In the contour mode a region's bone counts
451
499
  toward the cap like any other (*The vertex's weights* in `src/localweights.ts`).
452
500
 
501
+ ### Which mode comes out smaller (issue #307)
502
+
503
+ The names suggest that `auto`, the mode that reduces a mesh, is the smallest. Measured on one
504
+ figure, it is not always: a reduction held to a large declared bend keeps many vertices to stay
505
+ near its dense source, and on such a part the outline `mesh-draft` drafts over a coarse grid
506
+ came out two to three times lighter. On parts that bend little, `auto` was far the smallest.
507
+
508
+ The figure is the trailer's (`rig-c/trailer`, 1108 px tall). Triangles per part, as the rig
509
+ stage's `mesh` lines print them: the kept modes from commit 45becfb; the `auto` counts of the
510
+ three parts that left it from the commit before, 24bd8ec (each `auto` with the film's bends
511
+ declared as `auto.stimulus` and `auto.motion.maxLocalDeformation` 3 px); the lattice column each
512
+ part at `"grid": 30`, the spacing `propose` drafts for this figure, in 45becfb's config
513
+ (`topwear` left on `auto`); and `mesh-draft` run over that lattice config. The bends are the
514
+ largest turns the trailer's `WORKING.md` states for its film, not re-measured here.
515
+
516
+ | part | the film bends it | `auto`, bends declared, 3 px | `mesh-draft` | lattice at `"grid": 30` |
517
+ | --- | --- | --- | --- | --- |
518
+ | `bottomwear` | 24° | 276 | 132 | 140 |
519
+ | `handwear_r` | 33° | 148 | 49 | 52 |
520
+ | `hairpin` | 95° (refused by `auto`; `contour` at its own spacing 13: 120) | — | 47, kept on its lattice (below) | 46 |
521
+ | `legwear` | 12° | 77 | — | 176 |
522
+ | `hair_back` | a few degrees | 28 | — | 120 |
523
+ | `handwear_l` | 4° | 31 | — | 72 |
524
+ | `neck` | — | 7 | — | 12 |
525
+ | `hair_front` | 11° | 112 | — | 114 |
526
+
527
+ Every count reproduces with `@rig-c/parts` 1.11.0 and rig-c 2.47.0 (the trailer's pins at
528
+ 45becfb, both configs over the same assembled parts), and the lattice and `mesh-draft` columns
529
+ again with this package on rig-c 2.40.0. `mesh-draft` kept the hairpin on its lattice: its
530
+ candidate, 47 triangles, sat 1.055 rig px from the lattice over the idle against a line of 1, and
531
+ had one triangle more than the lattice's 46 (with the hairpin's lattice declaring
532
+ `influences: {floor: "continuous"}`, as 45becfb's contour entry does, the idle reads 0.822 and
533
+ passes, and the triangle line alone keeps it). The trailer's 47 is that candidate, written by
534
+ hand. The film's two cuts were compared at their most different frame, enlarged; no difference
535
+ showed, and every `check` bar passed on both.
536
+
537
+ So, before keeping an `auto` entry on a part a chain or physics bends far: build the part once on
538
+ a lattice, run `mesh-draft` (§4, step 12), and compare the two `mesh` lines. Nothing else reports
539
+ the difference; `mesh-draft` names every `auto` or `contour` entry as not compared, because it
540
+ drafts lattice entries only. This is one figure, and the two examples here were not measured this
541
+ way.
542
+
453
543
  ### A contour mesh (`meshes.<part>.contour`, issue #84)
454
544
 
455
545
  The lattice keeps every grid cell that holds an art pixel, so its boundary and its interior
@@ -1425,7 +1515,10 @@ full loader before its assemble stage writes anything.
1425
1515
  The two divisors are calibrated on three figures of similar size (1106 to
1426
1516
  1185 px), and the bend set is a calibration, not your animation: a chain
1427
1517
  your animations or physics bend further than 15° a link was not measured
1428
- there. An `auto` or `contour` entry is yours and is left as it is. A
1518
+ there. An `auto` or `contour` entry is yours and is left as it is, and it
1519
+ is compared with nothing: a `NOT COMPARED` line and the report's
1520
+ `not_compared` name each such part, since which mode comes out smaller is
1521
+ measured, not named (§3, *Which mode comes out smaller*; issue #307). A
1429
1522
  relative `motion.animations_from` or `generation.control.skeleton` keeps its
1430
1523
  text, so it resolves against `draft/`; the command says so.
1431
1524
 
@@ -1439,7 +1532,7 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
1439
1532
  | --- | --- | --- | --- |
1440
1533
  | `layers` | the table, then one `author-made: layer "author:<label>" — …` line per layer the author made (group `author` in the table; §5, *A layer the author made*), then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
1441
1534
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
1442
- | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…), "<holder>" drawn in front of "<part>"` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order — that layer is not what the painting shows there (none on the five public paintings); and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (reference-demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
1535
+ | `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. Then the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…), "<holder>" drawn in front of "<part>"` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order — that layer is not what the painting shows there (none on the five public paintings); and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (reference-demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
1443
1536
  | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `reference-sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — cut the part with `assemble.cuts` to separate them; `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
1444
1537
  | `compare` | one line per pair (origin, parent, tip, length, direction — or SKIP and why), then the per-row summary, the unmapped bones of each side, `required:`, each side's roles and the `controls:` line | proposal against the config you corrected: the rows you changed and no others. Config against the `rig.json` `build` writes for it: measured on both examples, every origin within 6.19e-7 px (the rig's offsets are written to 6 places) and every chain link's tip and length within 4.99e-4 px (`length` is written to 3), printed to three places as `0.000` (a signed figure as `+0.000` or `-0.000`); `parent … is the right's ancestor at depth 2, <bone>_ctl between` for every keyed mesh bone, no `DIFFERENT` and no `NOT MAPPED` | `parent DIFFERENT: left a, right b` — a bone hangs from another parent than the other side's; `frames not related` — a rig with no stage (`skeleton.width`/`height`), two configs at different `rig_scale`: the distance rows SKIP, so declare the frame in a `--map` file if the author knows it; `FAIL STRUCTURE_REQUIRED_PRESENT` — a bone the map requires is missing through its pairs |
1445
1538
  | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`); in `rig.json`, each chain link's (or its `_ctl`'s) `length` and `rotation` | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages); every link's `length` the distance to the next link and `rotation` its direction (Spine degrees, counter-clockwise, y up, local to the parent), every offset under it — a child bone's `x, y`, a weight's bind `x, y`, a region's `x, y` — in that turned frame, and a region on a link carrying `rotation` that turns it back upright. Nothing moved: `flattenRig` (`src/rig.ts`) turns every offset back and gives the unturned numbers | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
@@ -2030,7 +2123,7 @@ exits 0, and `mesh_draft.json` and the console name the reading and the amount.
2030
2123
  | `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above — or, in a chain that carries a part the scene file declares `rests`, more than the link above (issue #245; the object names the part and the file) | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip; non-increasing on a chain declared resting) |
2031
2124
  | `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's art in the lower half of its box travels less than 1.4725 times as far as in the upper half, measured from the idle's posed geometry (rig px quoted) | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them), or its mode: on `scarf`'s short skirt the proposal's lattice read 1.207 and the same part in `contour` mode 1.738 with the same segments and idle (issue #118) — both carried the `chest` segment #262 removed, after which the lattice reads 3.487. When the detail says `the root travels with the breath (x px of y px): it is carried by \`<bone>\`` (issue #262) the chain is not the cause: a bone an idle `translate` track moves (the breath) moves the root more than the tip, and with that translate held still (the part posed again without it, in proportion to each vertex's weight on the bone and those under it) the ratio passes — the row adds `carried_by`, `breath_rig_px`, `root_without_breath_rig_px` and `tip_without_breath_rig_px`; take that bone's segment off the mesh, or bind the root to the bone the part hangs from. A part the scene file declares `rests` (issue #245) is held the other way, its tip half's travel at most its root half's (ratio ≤ 1); the detail then reads `the scene file's rests declares its tip at rest, so a ratio <= 1 is required`, and for it the fix is the chain's `amps` (shrinking toward the tip) or the mesh's tip weighted to a bone the idle does not swing |
2032
2125
  | `CHECK_STILL_REGIONS_DARK` | a slot of the face's still set (the `face` slots and every slot drawn over the face that the head bone alone carries) moves relative to the bone the face's slot rides, or a `footwear` slot moves on the screen, by more than the arithmetic's own rounding — measured from the idle's posed geometry (the slot, its largest displacement in rig px, the idle frame and the tolerance quoted) | on the face: a face mesh with a weight on a bone other than the head (`segments`), or one deformed by another bone; on the feet: a mesh weighted to a swinging bone, or a region on the wrong bone. A bang swinging over the face is not this line's failure: its depth is reported (`crossing`), with no bar |
2033
- | `CHECK_TEXTURE_STRETCH` | a mesh triangle's edge, in some idle frame, is more than 1.926544 times its rest length or less than 1/1.926544 of it (the mesh, triangle, its three vertices, the edge and the frame are named); or a rest edge has length 0 | the chain tracks' `amps` on the bones that mesh is weighted to (the stretch grows with them), or the mesh's `segments` — a vertex blending two bones that swing against each other |
2126
+ | `CHECK_TEXTURE_STRETCH` | a mesh triangle's edge, in some idle frame, is more than 1.926544 times its rest length or less than 1/1.926544 of it (the mesh, triangle, its three vertices, the edge and the frame are named, and — issue #314 — the bones the edge's two vertices hang on: each vertex's two largest weights from `rig.json`, `vertex <i> is weighted to "<bone>" <w>, …`, then `on the idle's chains, the chain from "<first link>" carries "<bone>", …` — each chain `CHAIN_LAG` reads named once, by its first link — with a bone on no chain said, or `none of these bones hangs on a chain of the idle`); or a rest edge has length 0 | the chain tracks' `amps` on the bones that mesh is weighted to (the stretch grows with them), or the mesh's `segments` — a vertex blending two bones that swing against each other. Two chains named, with opposed amplitudes: give both one sign, or drop one — `propose --from-config` measures the pair before a build (§3, *Two chains on one lattice*) |
2034
2127
  | `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR`, `CHECK_GEOMETRY_FILE` | rigc's render is not what its `frames.json` says, or its `geometry.json` is not a whole `rigc-geometry/1` export of the frames beside it (a frame count, a mesh whose vertex count differs from its rest entry; for the face half, another viewport than `frames.json`'s, other frame indices, the head bone or a feature's bone missing from a frame, a non-finite transform or time) | a rigc problem; report it |
2035
2128
  | `LOOP_ENCODE` | the loop encoder refused a frame (translucent pixel in a GIF, a size change) | the frames; for a translucent frame write the lossless or the indexed APNG, which keep alpha |
2036
2129
  | `LOOP_LEAD_IN_IDLE` | `build --lead-in`: the idle cannot be repeated without inventing a value (a track not keyed from 0 to the duration, or ending where it does not start; a field besides `duration`, `loop`, `note`, `tracks`), the motion already has an `idle_lead_in`, or the render did not write whole periods | the refusal names the track or field; the demo's idle, as the rig stage writes it, repeats as written |
package/docs/STABILITY.md CHANGED
@@ -175,7 +175,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
175
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 |
176
176
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
177
177
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
178
- | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate`, `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note` |
178
+ | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate`, `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
179
179
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
180
180
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
181
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.12.0",
3
+ "version": "1.13.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": {
@@ -82,10 +82,12 @@ stage writes are the whole interface.
82
82
  keeps under the piece's edge; 1 px closed the seam on scarf) — and give the piece its own
83
83
  `meshes` entry; the `cut:` line gives the split. At overlap 0 a cut through
84
84
  opaque art leaves a seam `check` reads (AUTHORING §5).
85
- A seated or reclining figure: add `--keypoints <file>` (body-18 joints in
86
- painting px, each observed, occluded or missing; AUTHORING §3, *A posed
87
- figure*) so the neck, hip, chest and sleeves follow the pose and LINT reads
88
- the joints, not screen y. Never write a joint you did not get from an
85
+ A figure whose joints are not where the screen-y rules put them — reclining,
86
+ an arm raised, seated in a skirt that does not start at the hip: add
87
+ `--keypoints <file>` (body-18 joints in painting px, each observed, occluded
88
+ or missing; AUTHORING §3, *A posed figure*) so the neck, hip, chest and
89
+ sleeves follow the pose and LINT reads the joints, not screen y. A clean
90
+ LINT does not show a seated hip is right; the overlay does. Never write a joint you did not get from an
89
91
  estimator or the painting: say `missing` and let the rule stand in.
90
92
  6. Correct against `render/landmarks.png` and `note:` lines; a
91
93
  `coverage … not checked` line is a bone no check read (the line says why),
@@ -109,7 +111,8 @@ stage writes are the whole interface.
109
111
  8. Read `out/check/check.json` — the six judgement lines the same way as the seam
110
112
  and the loop: a FAIL names the field to change (AUTHORING §6);
111
113
  `TEXTURE_STRETCH` names the worst mesh triangle (slot, triangle, vertices, edge,
112
- frame), so quote it even on a PASS. Quote
114
+ frame), so quote it even on a PASS; its FAIL line also names the bones the
115
+ edge's two vertices are weighted to and the idle chains they hang on. Quote
113
116
  `RECOMPOSITE_HOLES` (uncovered px, hole count, the largest hole's box) in the
114
117
  report whatever `PASS` says. The artifact is the three paths the build prints
115
118
  last: `check/build/skeleton.json`, `skeleton.atlas` and the packed page. Report
package/src/build.ts CHANGED
@@ -227,7 +227,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
227
227
  log(
228
228
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
229
229
  `op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
230
- `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} merged=${p.merged_px} seam=${p.seam_override_px}`,
230
+ `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
231
231
  );
232
232
  }
233
233
  // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
@@ -253,6 +253,16 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
253
253
  return result;
254
254
  }
255
255
 
256
+ /**
257
+ * The scale the per-part line prints `drift=` with (issue #314): `refused_drift_px` counts the part's projection
258
+ * candidates (`projected_core_px`, the `src=` column's second figure) refused because See-through's colour sits more
259
+ * than the drift limit off the painting's, so it is read against them — `(<p>% of core)`, one decimal; a part with no
260
+ * candidate has nothing to refuse and says so. No bar reads either figure (AUTHORING §5, `assemble`).
261
+ */
262
+ export function driftShare(refused: number, core: number): string {
263
+ return core === 0 ? '(no core)' : `(${((100 * refused) / core).toFixed(1)}% of core)`;
264
+ }
265
+
256
266
  // ---------------------------------------------------------------------------
257
267
  // rig
258
268
  // ---------------------------------------------------------------------------
package/src/check.ts CHANGED
@@ -1948,7 +1948,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1948
1948
  // One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
1949
1949
  const geometryPath = join(idle.dir, GEOMETRY_FILE);
1950
1950
  const geometry = readGeometry(geometryPath, idle.written);
1951
- stretch = stretchLine(geometry, geometryPath, problems);
1951
+ stretch = stretchLine(geometry, geometryPath, problems, inp);
1952
1952
  if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
1953
1953
  }
1954
1954
 
@@ -3300,15 +3300,76 @@ function stretchAtText(s: { triangle: number; vertices: readonly number[]; edge:
3300
3300
  return `triangle ${s.triangle} (vertices ${s.vertices.join(' ')}), edge ${s.edge.join('-')}, idle frame ${s.frame}`;
3301
3301
  }
3302
3302
 
3303
+ /**
3304
+ * Issue #314: what a failing edge's two vertices hang on, read off rig.json and motion.json — so the cause reads off
3305
+ * the line rather than off a hand decoding of the weight runs. Each vertex's bones from the default skin's weights,
3306
+ * the largest two by weight (equal weights in rig.json's order), with how many it has; then every bone named, grouped
3307
+ * by the idle chain it hangs on — the nearest link at or above it of a chain `CHAIN_LAG` reads
3308
+ * ({@link chainFigures}: two or more rotate-keyed links at one period), each chain named once, by its first link — and
3309
+ * the bones on none. A mesh with no weights rides its slot's bone, and an attachment the skin does not hold, or a
3310
+ * vertex its weights do not reach, is said rather than guessed.
3311
+ */
3312
+ export function edgeBonesText(rig: Record<string, unknown>, motion: Record<string, unknown>, slot: string, attachment: string, edge: readonly number[]): string {
3313
+ const skins = isRecord(rig.skins) ? rig.skins : {};
3314
+ const skin = isRecord(skins.default) ? skins.default : {};
3315
+ const onSlot = isRecord(skin[slot]) ? skin[slot] : {};
3316
+ const att = onSlot[attachment];
3317
+ if (!isRecord(att)) return `its weights are not read: rig.json's default skin holds no attachment "${attachment}" on slot "${slot}"`;
3318
+ if (!Array.isArray(att.weights)) {
3319
+ const sl = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((x) => x.name === slot);
3320
+ return `the mesh carries no weights, so vertex ${edge.join(' and vertex ')} ride slot "${slot}"'s bone ${sl !== undefined && typeof sl.bone === 'string' ? `"${sl.bone}"` : '(rig.json names none)'}`;
3321
+ }
3322
+ const weights = att.weights;
3323
+ const missing = edge.filter((v) => !Array.isArray(weights[v]));
3324
+ if (missing.length > 0) return `its weights are not read: rig.json's mesh "${attachment}" weights ${weights.length} vertices, and vertex ${missing.join(' and ')} is not among them`;
3325
+ const named: string[] = [];
3326
+ const parts = edge.map((v, i) => {
3327
+ const list = (weights[v] as unknown[]).filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') as Array<{ bone: string; weight: number }>;
3328
+ const top = [...list].sort((a, b) => b.weight - a.weight).slice(0, 2);
3329
+ for (const w of top) if (!named.includes(w.bone)) named.push(w.bone);
3330
+ const shown = top.map((w) => `"${w.bone}" ${w.weight}`).join(', ');
3331
+ return `${i === 0 ? `vertex ${v} is weighted to` : `vertex ${v} to`} ${shown}${list.length > 2 ? ` (the largest 2 of ${list.length})` : ''}`;
3332
+ });
3333
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
3334
+ const parent = new Map(bones.filter((b) => typeof b.name === 'string').map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
3335
+ const firstLink = new Map<string, string>();
3336
+ for (const c of chainFigures(rig, motion).chains) if (c.length >= 2) for (const l of c) firstLink.set(l.bone, c[0].bone);
3337
+ const carries = new Map<string, string[]>();
3338
+ const none: string[] = [];
3339
+ for (const b of named) {
3340
+ let chain: string | null = null;
3341
+ const seen = new Set<string>();
3342
+ for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
3343
+ const f = firstLink.get(x);
3344
+ if (f !== undefined) {
3345
+ chain = f;
3346
+ break;
3347
+ }
3348
+ seen.add(x);
3349
+ }
3350
+ if (chain === null) none.push(b);
3351
+ else carries.set(chain, [...(carries.get(chain) ?? []), b]);
3352
+ }
3353
+ const quoted = (l: readonly string[]): string => l.map((n) => `"${n}"`).join(', ');
3354
+ const chains =
3355
+ carries.size === 0
3356
+ ? 'none of these bones hangs on a chain of the idle'
3357
+ : `on the idle's chains, ${[...carries].map(([c, l]) => `the chain from "${c}" carries ${quoted(l)}`).join(' and ')}${none.length === 0 ? '' : `, and ${quoted(none)} ${none.length === 1 ? 'hangs' : 'hang'} on none`}`;
3358
+ return `${parts.join(' and ')}; ${chains}`;
3359
+ }
3360
+
3303
3361
  /**
3304
3362
  * (6) No visible texture stretch: for every mesh triangle, each edge's length
3305
3363
  * in each idle frame over its rest length (the setup pose's bones, no deform —
3306
3364
  * which on both examples is the art's own proportions, the attachment's `uvs`
3307
3365
  * times its size, to 2.4e-5). The figure is the rig's worst max(ratio,
3308
3366
  * 1/ratio); each mesh's largest and smallest ratio, and where, are the detail.
3309
- * SKIP when the render wrote no geometry file or the rig draws no mesh.
3367
+ * SKIP when the render wrote no geometry file or the rig draws no mesh. With
3368
+ * `bound`, the rig and motion `check` read, a failing edge's line also names the
3369
+ * bones its two vertices hang on and their chains ({@link edgeBonesText}, issue
3370
+ * #314).
3310
3371
  */
3311
- export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[]): JudgementLine {
3372
+ export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[], bound?: Pick<CheckInputs, 'rig' | 'motion'>): JudgementLine {
3312
3373
  if (geo === null) return skip(`${path} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the skinned mesh vertices are read from it`);
3313
3374
  if (geo.meshes.length === 0) return skip('the rig draws no mesh attachment (every slot is a region), so there is no triangle to stretch');
3314
3375
  const figures = stretchFigures(geo.meshes, geo.frames);
@@ -3343,7 +3404,7 @@ export function stretchLine(geo: IdleGeometry | null, path: string, problems: Pr
3343
3404
  problems.push({
3344
3405
  code: 'CHECK_TEXTURE_STRETCH',
3345
3406
  object: `mesh "${m.slot}" ${stretchAtText(at)}`,
3346
- detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
3407
+ detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required${bound === undefined ? '' : `; ${edgeBonesText(bound.rig, bound.motion, m.slot, m.attachment, at.edge)}`} — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
3347
3408
  });
3348
3409
  }
3349
3410
  }
package/src/meshdraft.ts CHANGED
@@ -118,6 +118,33 @@ export const MORE_TRIANGLES_FLAG = '--more-triangles';
118
118
  */
119
119
  export const RELATIVE_SENTENCE = 'the gate is a judgment relative to this config\'s lattice, not an absolute quality: when the lattice coarsens, the reference moves with it';
120
120
 
121
+ /**
122
+ * What `mesh-draft` says of a part whose config entry is not a lattice (`contour` or `auto`, issue #307): it drafts
123
+ * lattice entries only, so such a part was neither drafted nor compared. Said because the answer to "which mode comes
124
+ * out smaller" is not the one the names suggest: on one figure (rig-c/trailer 45becfb) an `auto` reduction held to a
125
+ * large declared bend kept two to three times the triangles of the outline this command drafts (AUTHORING §3, *Which
126
+ * mode comes out smaller*).
127
+ */
128
+ export const NOT_COMPARED_SENTENCE = 'mesh-draft drafts lattice entries only, so it drafted nothing for this part and compared it with nothing; whether a lattice, or the outline mesh-draft drafts over one, comes out smaller is not known from this run — build the part once on a lattice, run mesh-draft, and compare the rig stage\'s mesh lines before keeping this entry';
129
+
130
+ /** A config entry `mesh-draft` did not draft because it is not a lattice (issue #307), in config order. */
131
+ export interface NotCompared {
132
+ part: string;
133
+ mode: 'contour' | 'auto';
134
+ note: string;
135
+ }
136
+
137
+ /** The entries of `meshes` that are not lattices, in config order, each with its note ({@link NOT_COMPARED_SENTENCE}). */
138
+ export function notCompared(meshes: CharacterConfig['meshes']): NotCompared[] {
139
+ const out: NotCompared[] = [];
140
+ for (const [part, spec] of Object.entries(meshes)) {
141
+ if ('grid' in spec) continue;
142
+ const mode = 'auto' in spec ? 'auto' : 'contour';
143
+ out.push({ part, mode, note: `its entry is "${mode}", not a lattice: ${NOT_COMPARED_SENTENCE}` });
144
+ }
145
+ return out;
146
+ }
147
+
121
148
  /** The five readings, in the order the gate reads them. */
122
149
  export const READINGS = ['bars', 'idle', 'bend inverted', 'bend worst', 'triangles'] as const;
123
150
  export type Reading = (typeof READINGS)[number];
@@ -534,6 +561,8 @@ export interface PartRow {
534
561
  export interface MeshDraftResult {
535
562
  rules: FigureRules;
536
563
  rows: PartRow[];
564
+ /** The entries not drafted because they are not lattices (issue #307), in config order. */
565
+ notCompared: NotCompared[];
537
566
  written: string[];
538
567
  }
539
568
 
@@ -810,6 +839,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
810
839
  }
811
840
  const configText = splicedConfig(text, drafted);
812
841
  const rowsOut = [...rows.values()];
842
+ const others = notCompared(cfg.meshes);
813
843
  const report = {
814
844
  spec: MESH_DRAFT_SPEC,
815
845
  rules: {
@@ -834,6 +864,9 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
834
864
  gate: r.gate,
835
865
  note: r.note,
836
866
  })),
867
+ // Issue #307: present only when the config holds an entry that is not a lattice, so a config of lattices alone
868
+ // writes the bytes it wrote before the key existed.
869
+ ...(others.length === 0 ? {} : { not_compared: others }),
837
870
  };
838
871
  mkdirSync(input.out, { recursive: true });
839
872
  const written = [join(input.out, 'config.json'), join(input.out, MESH_DRAFT_REPORT)];
@@ -845,14 +878,15 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
845
878
  for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
846
879
  if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
847
880
  }
881
+ for (const o of others) log(` NOT COMPARED ${o.part}: ${o.note}`);
848
882
  const nDrafted = rowsOut.filter((r) => r.mode === 'contour').length;
849
883
  const tri = (pick: (r: PartRow) => number): number => rowsOut.reduce((a, r) => a + pick(r), 0);
850
- log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
884
+ log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}${others.length === 0 ? '' : `; ${others.length} part(s) not a lattice, not drafted and not compared: ${others.map((o) => `${o.part} (${o.mode})`).join(', ')}`}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
851
885
  if (resolve(configDir) !== resolve(input.out)) {
852
886
  for (const r of relativePaths(raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
853
887
  }
854
888
  log(`rig-parts mesh-draft: wrote ${written.join(' and ')}`);
855
- return { rules, rows: rowsOut, written };
889
+ return { rules, rows: rowsOut, notCompared: others, written };
856
890
  }
857
891
 
858
892
  /**
package/src/propose.ts CHANGED
@@ -136,6 +136,8 @@ import { configRoles, type RoleOf } from './structure.ts';
136
136
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
137
137
  import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
138
138
  import { edgeLength } from './length.ts';
139
+ import { chainSqueezes, type SqueezeFinding } from './squeeze.ts';
140
+ import { TEXTURE_STRETCH_CEILING } from './check.ts';
139
141
 
140
142
  // ---------------------------------------------------------------------------
141
143
  // the output shape — config-shaped, key order as the reference writes it
@@ -2905,7 +2907,10 @@ export interface ChainNotAdvancingFinding {
2905
2907
  toJoint: KeypointName;
2906
2908
  }
2907
2909
 
2908
- export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
2910
+ /** Issue #314: two chains on one lattice whose own swing closes or opens an edge between them past check's TEXTURE_STRETCH bar (`src/squeeze.ts`). */
2911
+ export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
2912
+
2913
+ export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding | SqueezeLintFinding;
2909
2914
 
2910
2915
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
2911
2916
  export interface LintBasis {
@@ -2923,6 +2928,8 @@ export interface LintResult {
2923
2928
  lockUnread: string[];
2924
2929
  /** Single bones the torso checks read (`hip`, `chest`) that the bones do not hold: those checks did not run. */
2925
2930
  missingTorsoBones: string[];
2931
+ /** Issue #314: the pairs of keyed chains on one mesh the squeeze check could not build a lattice for, and why. Empty when it read every pair or had none. */
2932
+ squeezeUnread: string[];
2926
2933
  /** Present only under `--keypoints`: the rule set that read the torso and the chains. */
2927
2934
  basis?: LintBasis;
2928
2935
  /** One record per bone of the spec, in its bone order (chain links as `<chain><i>`): which checks read it, or why none did (issue #86). */
@@ -3130,8 +3137,21 @@ export function lint(
3130
3137
  }
3131
3138
  }
3132
3139
  }
3140
+ // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
3141
+ // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
3142
+ const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }])));
3143
+ for (const { mesh, a, b } of sq.read) {
3144
+ for (const [c, other] of [[a, b], [b, a]]) {
3145
+ const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
3146
+ if (e !== undefined && 'chain' in e) e.points.forEach((_, i) => readBy(`${c}${i}`, `squeeze with ${other} on mesh ${pyRepr(mesh)}`));
3147
+ }
3148
+ }
3149
+ for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
3150
+ const squeezeUnread = sq.unread;
3133
3151
  const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
3134
- return basis === undefined ? { findings, unknownMeshes, lockUnread, missingTorsoBones, coverage } : { findings, unknownMeshes, lockUnread, missingTorsoBones, basis, coverage };
3152
+ return basis === undefined
3153
+ ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
3154
+ : { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
3135
3155
  }
3136
3156
 
3137
3157
  /** The bones a finding names: the off-art link; hip and chest for a relation between them; a chain's last link for its tip. */
@@ -3148,6 +3168,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
3148
3168
  return ['hip'];
3149
3169
  case 'chest-not-between':
3150
3170
  return ['chest'];
3171
+ case 'squeeze':
3172
+ return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
3151
3173
  case 'chain-not-advancing': {
3152
3174
  if (!f.point.endsWith(' tip')) return [f.point];
3153
3175
  const chain = f.point.slice(0, -' tip'.length);
@@ -3313,11 +3335,42 @@ export function lintLine(f: LintFinding): string {
3313
3335
  return `LINT hip at ${pyIntList(f.hip)} is not past chest at ${pyIntList(f.chest)} on the way from the neck joint to the hip joints (hip at ${pyFixed(f.tHip, 2)}, chest at ${pyFixed(f.tChest, 2)} of the way): breathing and the skirt hang from the wrong end of the torso`;
3314
3336
  case 'hip-nearer-neck':
3315
3337
  return `LINT hip at ${pyIntList(f.hip)} is nearer the neck joint ${fmtAt(f.neck)} (${pyFixed(f.dNeck, 1)} px) than the midpoint of the hip joints ${fmtAt(f.hips)} (${pyFixed(f.dHips, 1)} px): a hip at the shoulders`;
3338
+ case 'squeeze':
3339
+ return squeezeLine(f);
3316
3340
  case 'chain-not-advancing':
3317
3341
  return `LINT ${f.point} at ${pyIntList(f.at)} does not advance from ${f.fromJoint} toward ${f.toJoint}: it falls ${pyFixed(f.t, 1)} px along that line and ${f.previous} ${pyFixed(f.tPrevious, 1)} px, and each must fall further than the one before`;
3318
3342
  }
3319
3343
  }
3320
3344
 
3345
+ /**
3346
+ * The squeeze LINT line (issue #314): the pair and the mesh, the worst edge with both swinging and the bone each of
3347
+ * its two vertices rides most, the bar it is held to (check's), each chain swung alone, the tips' distance and the
3348
+ * amplitudes, and the remedies whose figure passes the bar — one sign when the amplitudes are opposed, dropping a
3349
+ * chain when the other alone passes — or, when none does, that the amplitudes are too large for this lattice.
3350
+ */
3351
+ function squeezeLine(f: SqueezeLintFinding): string {
3352
+ const e = f.both.at;
3353
+ const C = TEXTURE_STRETCH_CEILING;
3354
+ const rides = f.vertices.map((v) => (v.weights.length === 0 ? `vertex ${v.vertex} rides no bone` : `vertex ${v.vertex} rides ${v.weights[0].bone} ${v.weights[0].weight}`)).join(', ');
3355
+ const amps = (c: { amps: readonly number[] }): string => `[${c.amps.join(', ')}]`;
3356
+ const remedies: string[] = [];
3357
+ if (f.turned !== null && f.turned <= C) remedies.push(`give both the same sign (then ${pyFixed(f.turned, 3)})`);
3358
+ if (f.aloneA <= C && f.aloneB <= C) remedies.push('drop one chain');
3359
+ else if (f.aloneA <= C) remedies.push(`drop ${f.b.chain}, which alone is past the bar too`);
3360
+ else if (f.aloneB <= C) remedies.push(`drop ${f.a.chain}, which alone is past the bar too`);
3361
+ const said =
3362
+ f.turned !== null
3363
+ ? `their amplitudes are opposed (${f.a.chain} ${amps(f.a)}, ${f.b.chain} ${amps(f.b)}), so their tips swing towards each other and apart`
3364
+ : `their amplitudes are ${f.a.chain} ${amps(f.a)} and ${f.b.chain} ${amps(f.b)}`;
3365
+ const remedy = remedies.length > 0 ? remedies.join(', or ') : 'neither one sign nor either chain alone passes the bar: lower the amplitudes';
3366
+ return (
3367
+ `LINT ${f.a.chain} and ${f.b.chain} on mesh ${pyRepr(f.mesh)} ${e.ratio >= 1 ? 'stretch' : 'squeeze'} it: swung together, every other bone held, ` +
3368
+ `edge ${e.edge.join('-')} of triangle ${e.triangle} ${e.ratio >= 1 ? 'opens' : 'closes'} to ${pyFixed(e.ratio, 3)} times its rest length at idle frame ${e.frame} (max(ratio, 1/ratio) ${pyFixed(f.both.severity, 3)}; ` +
3369
+ `<= ${C} is required, check's TEXTURE_STRETCH bar) — ${rides}; alone, ${f.a.chain} reads ${pyFixed(f.aloneA, 3)} and ${f.b.chain} ${pyFixed(f.aloneB, 3)}. ` +
3370
+ `Their tips are ${pyFixed(f.tipGap, 1)} px apart and ${said}: ${remedy}`
3371
+ );
3372
+ }
3373
+
3321
3374
  export interface Comparison {
3322
3375
  rows: Array<[string, number]>;
3323
3376
  onlyConfig: string[];
package/src/squeeze.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Two chains on one lattice that the idle swings into each other (issue #314).
3
+ *
4
+ * A pair of chains a mesh's `segments` both name is a squeeze when their own
5
+ * swing, every other bone held at the setup pose, closes (or opens) a lattice
6
+ * edge between the art each one carries past the bar `check`'s
7
+ * `TEXTURE_STRETCH` holds every edge to ({@link TEXTURE_STRETCH_CEILING}, on
8
+ * max(ratio, 1/ratio)). Read here from what `propose` and `--from-config` hold —
9
+ * the parts, the bones, the mesh's spec and the idle's chain tracks — so the
10
+ * squeeze is named before anything is built.
11
+ *
12
+ * What is built is the rig stage's own lattice and weights, by the rig stage's
13
+ * own functions: the part's image padded by {@link PAD}, its art (alpha above
14
+ * {@link ART_ALPHA}, less what a declared `stray` leaves out) cut into the
15
+ * lattice by `latticeMesh`, and each vertex weighted by `influences` under the
16
+ * mesh's `r`, `exponent` and `influences`, rounded as the lattice rounds — so
17
+ * the vertex and triangle numbers are the ones `rig.json` and `check` use. Only
18
+ * the lattice mode under the distance rule is read; a contour or automatic mesh,
19
+ * or `rule: "heat"`, is said not read, by name.
20
+ *
21
+ * Each chain's links are posed as the idle's sine states them —
22
+ * `amps[i] * sin(2 pi (t / period - (phase + lag * i)))` degrees on link `i`,
23
+ * `src/motion.ts` — turned down the chain (a link carries every link below it),
24
+ * at the frames `check` renders: `t = f / IDLE_FPS` for every whole frame up to
25
+ * the idle's duration. Every other bone stays where the setup pose has it, so a
26
+ * figure is the pair's own: the head's roll or the breath carry both chains
27
+ * alike and are not in it. A vertex moves as linear blend skinning moves it,
28
+ * each weight times where that bone's motion takes the vertex. The edge ratios
29
+ * are `check`'s own reader on these poses ({@link stretchFigures}).
30
+ *
31
+ * The pair is named when both swinging together pass the bar, with each
32
+ * chain swung alone beside it — so the remedy "drop one chain" carries the
33
+ * figure the other is left with, and a chain past the bar on its own reads as
34
+ * that. With the two chains' amplitudes opposed link for link (every `amps[i]`
35
+ * of one the other sign of the other's), the same reading is taken with the
36
+ * second chain's signs turned, so the remedy "give both the same sign" carries
37
+ * its own figure too. A remedy whose figure is past the bar is not offered.
38
+ */
39
+ import { TEXTURE_STRETCH_CEILING } from './check.ts';
40
+ import type { BoneEntry, MeshSpec, Motion, Point } from './config.ts';
41
+ import { stretchFigures, stretchSeverity, type StretchAt } from './instruments.ts';
42
+ import { edgeLength } from './length.ts';
43
+ import { ART_ALPHA, latticeMesh, latticeStray } from './mesh.ts';
44
+ import { IDLE_FPS, TIME_SLACK } from './motion.ts';
45
+ import { pad } from './raster/composite.ts';
46
+ import { alphaAbove, type Raster } from './raster/types.ts';
47
+ import { latticeShares, PAD, roundShares } from './rig.ts';
48
+ import { DISTANCE_EXPONENT, influences, meshLimits, type Segment } from './weights.ts';
49
+
50
+ /** One chain of the pair as the finding prints it: its name, its `amps` as written, and its tip. */
51
+ export interface SqueezeChain {
52
+ chain: string;
53
+ amps: number[];
54
+ tip: Point;
55
+ }
56
+
57
+ /** The worst edge of one reading: where, its ratio, and max(ratio, 1/ratio). */
58
+ export interface SqueezeEdge {
59
+ at: StretchAt;
60
+ severity: number;
61
+ }
62
+
63
+ /** A pair named: both chains, the mesh, the worst edge with both swinging, and each alone (and turned, when opposed). */
64
+ export interface SqueezeFinding {
65
+ mesh: string;
66
+ a: SqueezeChain;
67
+ b: SqueezeChain;
68
+ /** The two tips' distance at the setup pose, rig px. */
69
+ tipGap: number;
70
+ both: SqueezeEdge;
71
+ /** The two vertices of `both`'s edge, each with its two largest weights (bone, weight as rig.json writes it), and how many it has. */
72
+ vertices: Array<{ vertex: number; weights: Array<{ bone: string; weight: number }>; of: number }>;
73
+ /** The worst edge with the first chain swung alone, and with the second. */
74
+ aloneA: number;
75
+ aloneB: number;
76
+ /** Both opposed link for link: the worst edge with the second chain's signs turned. Null when they are not opposed. */
77
+ turned: number | null;
78
+ }
79
+
80
+ export interface SqueezeResult {
81
+ findings: SqueezeFinding[];
82
+ /** Pairs on one mesh this did not read, and why. */
83
+ unread: string[];
84
+ /** Every pair read: the mesh and the two chains. */
85
+ read: Array<{ mesh: string; a: string; b: string }>;
86
+ }
87
+
88
+ type ChainTrackLike = { chain: string; amps: number[]; period: number; phase: number; lag: number };
89
+
90
+ /** Each frame's time, as `check` renders them: every whole frame at {@link IDLE_FPS} up to the idle's duration. */
91
+ function frameTimes(duration: number): number[] {
92
+ const out: number[] = [];
93
+ for (let f = 0; f / IDLE_FPS <= duration + TIME_SLACK; f++) out.push(f / IDLE_FPS);
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * A chain's posed links at time `t`: per link, its setup origin, its posed origin, and the turn it carries (degrees,
99
+ * the sum of its own and every link above it). Positive is Spine's counter-clockwise (y up), which on the crop's y-down
100
+ * page turns a vector (dx, dy) to (dx cos + dy sin, -dx sin + dy cos).
101
+ */
102
+ function posedLinks(pts: readonly Point[], track: ChainTrackLike, sign: 1 | -1, t: number): Array<{ rest: Point; at: [number, number]; turn: number }> {
103
+ const out: Array<{ rest: Point; at: [number, number]; turn: number }> = [];
104
+ let at: [number, number] = [pts[0][0], pts[0][1]];
105
+ let turn = 0;
106
+ for (let i = 0; i + 1 < pts.length; i++) {
107
+ const amp = i < track.amps.length ? track.amps[i] : 0;
108
+ turn += sign * amp * Math.sin(2 * Math.PI * (t / track.period - (track.phase + track.lag * i)));
109
+ out.push({ rest: pts[i], at, turn });
110
+ const rad = (turn * Math.PI) / 180;
111
+ const dx = pts[i + 1][0] - pts[i][0];
112
+ const dy = pts[i + 1][1] - pts[i][1];
113
+ at = [at[0] + dx * Math.cos(rad) + dy * Math.sin(rad), at[1] - dx * Math.sin(rad) + dy * Math.cos(rad)];
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Where a bone's motion takes the point `v`: a link turns it about its origin and carries it to the link's posed origin. */
119
+ function moved(link: { rest: Point; at: [number, number]; turn: number }, v: readonly [number, number]): [number, number] {
120
+ const rad = (link.turn * Math.PI) / 180;
121
+ const dx = v[0] - link.rest[0];
122
+ const dy = v[1] - link.rest[1];
123
+ return [link.at[0] + dx * Math.cos(rad) + dy * Math.sin(rad), link.at[1] - dx * Math.sin(rad) + dy * Math.cos(rad)];
124
+ }
125
+
126
+ /**
127
+ * Every pair of chains a lattice mesh's `segments` name, both keyed by a chain track, read as the module doc says.
128
+ * `images` holds each part's PNG by name, its `box` its parts.json box (rig px).
129
+ */
130
+ export function chainSqueezes(
131
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; motion?: Motion },
132
+ parts: ReadonlyMap<string, { x: number; y: number; image: Raster }>,
133
+ ): SqueezeResult {
134
+ const findings: SqueezeFinding[] = [];
135
+ const unread: string[] = [];
136
+ const read: SqueezeResult['read'] = [];
137
+ const motion = spec.motion;
138
+ if (motion === undefined) return { findings, unread, read };
139
+ const tracks = new Map<string, ChainTrackLike>();
140
+ for (const t of motion.tracks) if ('chain' in t) tracks.set(t.chain, t);
141
+ const chains = new Map<string, { points: Point[]; tip: Point; parent: string }>();
142
+ for (const e of spec.bones) if ('chain' in e) chains.set(e.chain, { points: e.points, tip: e.tip, parent: e.parent });
143
+ // Every bone's parent, chain links as <chain><i>, so a bone under a link is carried by it.
144
+ const parent = new Map<string, string>();
145
+ for (const e of spec.bones) {
146
+ if ('chain' in e) e.points.forEach((_, i) => parent.set(`${e.chain}${i}`, i === 0 ? e.parent : `${e.chain}${i - 1}`));
147
+ else parent.set(e.name, e.parent);
148
+ }
149
+ const linkOf = new Map<string, [string, number]>();
150
+ for (const [c, e] of chains) e.points.forEach((_, i) => linkOf.set(`${c}${i}`, [c, i]));
151
+ // The nearest link at or above a bone, among the chains in `moving`.
152
+ const carrier = (bone: string, moving: ReadonlySet<string>): [string, number] | null => {
153
+ const seen = new Set<string>();
154
+ for (let b: string | undefined = bone; b !== undefined && !seen.has(b); b = parent.get(b)) {
155
+ const l = linkOf.get(b);
156
+ if (l !== undefined && moving.has(l[0])) return l;
157
+ seen.add(b);
158
+ }
159
+ return null;
160
+ };
161
+ const times = frameTimes(motion.duration);
162
+
163
+ for (const [mname, m] of Object.entries(spec.meshes)) {
164
+ const keyed = m.segments.filter((s): s is string => typeof s === 'string' && chains.has(s) && tracks.has(s));
165
+ if (keyed.length < 2) continue;
166
+ const pairs: Array<[string, string]> = [];
167
+ for (let i = 0; i < keyed.length; i++) for (let j = i + 1; j < keyed.length; j++) pairs.push([keyed[i], keyed[j]]);
168
+ const names = pairs.map(([a, b]) => `${a} and ${b}`).join(', ');
169
+ if ('contour' in m || 'auto' in m) {
170
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}) is a ${'contour' in m ? 'contour' : 'automatic'} mesh; only a lattice is built here`);
171
+ continue;
172
+ }
173
+ if (m.rule === 'heat') {
174
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}) is weighted by rule "heat"; only the distance rule is read here`);
175
+ continue;
176
+ }
177
+ const part = parts.get(mname);
178
+ if (part === undefined) continue;
179
+ // The rig stage's segments: a chain is its links, each from its origin to the next link or the tip.
180
+ const segs: Segment[] = [];
181
+ let unresolved: string | null = null;
182
+ for (const sp of m.segments) {
183
+ if (typeof sp !== 'string') {
184
+ segs.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
185
+ continue;
186
+ }
187
+ const c = chains.get(sp);
188
+ if (c !== undefined) {
189
+ const line = [...c.points, c.tip];
190
+ c.points.forEach((q, i) => segs.push({ bone: `${sp}${i}`, a: [q[0], q[1]], b: [line[i + 1][0], line[i + 1][1]] }));
191
+ continue;
192
+ }
193
+ const single = spec.bones.find((e) => 'name' in e && e.name === sp);
194
+ if (single === undefined || !('name' in single) || single.tip === undefined) {
195
+ unresolved = `its segment "${sp}" is not a chain or a single bone with a tip`;
196
+ break;
197
+ }
198
+ segs.push({ bone: sp, a: [single.at[0], single.at[1]], b: [single.tip[0], single.tip[1]] });
199
+ }
200
+ if (unresolved !== null) {
201
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}): ${unresolved}, so it has no weights to read`);
202
+ continue;
203
+ }
204
+ const img = pad(part.image, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
205
+ const allArt = alphaAbove(img, ART_ALPHA);
206
+ const art = m.stray === undefined ? allArt : latticeStray(allArt, m.stray).art;
207
+ const lm = latticeMesh(mname, art, m.grid);
208
+ if ('code' in lm) {
209
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}): its lattice is refused (${lm.code}), so there is no triangle to read`);
210
+ continue;
211
+ }
212
+ const ox = part.x - PAD;
213
+ const oy = part.y - PAD;
214
+ const limits = meshLimits(m.influences);
215
+ const rest: Array<[number, number]> = lm.vertices.map(([x, y]) => [x + ox, y + oy]);
216
+ const weights = rest.map((p) => {
217
+ const shares = influences(p, segs, m.r, limits, m.exponent ?? DISTANCE_EXPONENT);
218
+ return shares.length === 0 ? [] : limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares);
219
+ });
220
+ const restFlat = rest.flatMap(([x, y]) => [x, y]);
221
+ const worst = (moving: ReadonlyMap<string, 1 | -1>): SqueezeEdge | null => {
222
+ const set = new Set(moving.keys());
223
+ const frames = times.map((t, index) => {
224
+ const posed = new Map<string, ReturnType<typeof posedLinks>>();
225
+ for (const [c, sign] of moving) {
226
+ const e = chains.get(c) as { points: Point[]; tip: Point };
227
+ posed.set(c, posedLinks([...e.points, e.tip], tracks.get(c) as ChainTrackLike, sign, t));
228
+ }
229
+ const vertices: number[] = [];
230
+ rest.forEach((v, vi) => {
231
+ let x = 0;
232
+ let y = 0;
233
+ for (const { bone, weight } of weights[vi]) {
234
+ const l = carrier(bone, set);
235
+ const to = l === null ? v : moved((posed.get(l[0]) as ReturnType<typeof posedLinks>)[l[1]], v);
236
+ x += weight * to[0];
237
+ y += weight * to[1];
238
+ }
239
+ vertices.push(x, y);
240
+ });
241
+ return { index, attachments: [{ slot: mname, attachment: mname, vertices }] };
242
+ });
243
+ const [fig] = stretchFigures([{ slot: mname, attachment: mname, vertices: restFlat, triangles: lm.triangles }], frames);
244
+ if (fig.max === null || fig.min === null) return null;
245
+ const sMax = stretchSeverity(fig.max.ratio);
246
+ const sMin = stretchSeverity(fig.min.ratio);
247
+ return sMin > sMax ? { at: fig.min, severity: sMin } : { at: fig.max, severity: sMax };
248
+ };
249
+ for (const [a, b] of pairs) {
250
+ read.push({ mesh: mname, a, b });
251
+ const both = worst(new Map([[a, 1], [b, 1]]));
252
+ if (both === null || both.severity <= TEXTURE_STRETCH_CEILING) continue;
253
+ const aloneA = (worst(new Map([[a, 1]])) as SqueezeEdge).severity;
254
+ const aloneB = (worst(new Map([[b, 1]])) as SqueezeEdge).severity;
255
+ const ta = tracks.get(a) as ChainTrackLike;
256
+ const tb = tracks.get(b) as ChainTrackLike;
257
+ const opposed = ta.amps.length === tb.amps.length && ta.amps.every((v, i) => v * tb.amps[i] < 0);
258
+ const ca = chains.get(a) as { tip: Point };
259
+ const cb = chains.get(b) as { tip: Point };
260
+ findings.push({
261
+ mesh: mname,
262
+ a: { chain: a, amps: [...ta.amps], tip: ca.tip },
263
+ b: { chain: b, amps: [...tb.amps], tip: cb.tip },
264
+ tipGap: edgeLength(ca.tip[0] - cb.tip[0], ca.tip[1] - cb.tip[1]),
265
+ both,
266
+ vertices: both.at.edge.map((vertex) => ({ vertex, weights: [...weights[vertex]].sort((p, q) => q.weight - p.weight).slice(0, 2), of: weights[vertex].length })),
267
+ aloneA,
268
+ aloneB,
269
+ turned: opposed ? (worst(new Map<string, 1 | -1>([[a, 1], [b, -1]])) as SqueezeEdge).severity : null,
270
+ });
271
+ }
272
+ }
273
+ return { findings, unread, read };
274
+ }