@rig-c/parts 1.31.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -335,6 +335,7 @@ usage:
335
335
  rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
336
336
  [--page-edges pot|free] [--pack-shape rect|polygon]
337
337
  [--requirements <file.json>] [--seam-pairs] [--keep-stills]
338
+ [--set <name> | --all-sets]
338
339
  Build, gate, render and measure a rig through rig-c's CLI (the rigc at
339
340
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json:
340
341
  a rig spec, which rigc build compiles, not a compiled skeleton.json.
@@ -567,7 +568,7 @@ usage:
567
568
  [--project core|visible] [--page-edges pot|free]
568
569
  [--pack-shape rect|polygon] [--loop] [--requirements <file.json>]
569
570
  [--idle-keys ctl|direct] [--lead-in <periods>] [--seam-pairs] [--keep-stills]
570
- [--alt <name>=<source>:<full>[:<head>]]
571
+ [--alt <name>=<source>:<full>[:<head>]] [--set <name> | --all-sets]
571
572
  assemble, then rig, then check, in one process, each stage's own lines
572
573
  printed under [assemble], [rig] and [check]; the first stage that refuses
573
574
  stops the build with its own FAIL lines. The config must already carry
@@ -785,13 +786,13 @@ const TAKES = {
785
786
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
786
787
  propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap', '--grid-rule', '--cloth'], switches: ['--head-box', '--no-back-hair'] },
787
788
  compare: { valued: ['--left', '--right', '--map'], switches: [] },
788
- check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements'], switches: ['--seam-pairs', '--keep-stills'] },
789
+ check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements', '--set'], switches: ['--seam-pairs', '--keep-stills', '--all-sets'] },
789
790
  loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
790
791
  assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt'], switches: ['--propose-plan'] },
791
792
  'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll'], switches: ['--offload', '--lama', '--nf4'] },
792
793
  'comfy paint': { valued: ['--config', '--out', '--seeds', '--seed0', '--host', '--wait', '--timeout', '--poll'], switches: [] },
793
794
  inputs: { valued: ['--source', '--config', '--out'], switches: [] },
794
- build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in', '--alt'], switches: ['--loop', '--seam-pairs', '--keep-stills'] },
795
+ build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in', '--alt', '--set'], switches: ['--loop', '--seam-pairs', '--keep-stills', '--all-sets'] },
795
796
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
796
797
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
797
798
  painting: { valued: ['--keypoints', '--person'], switches: [] },
@@ -1037,17 +1038,20 @@ function flags(args: string[], command: Command, required: readonly string[]): M
1037
1038
  }
1038
1039
 
1039
1040
  function cmdCheck(args: string[]): number {
1040
- // The two switches check takes (issues #202, #209): they carry no value, so they are taken out before the valued flags are read.
1041
+ // The switches check takes (issues #202, #209, #454): they carry no value, so they are taken out before the valued flags are read.
1041
1042
  const seamPairs = args.filter((a) => a === '--seam-pairs').length;
1042
1043
  if (seamPairs > 1) return usage('--seam-pairs is given twice');
1043
1044
  const keepStills = args.filter((a) => a === '--keep-stills').length;
1044
1045
  if (keepStills > 1) return usage('--keep-stills is given twice');
1046
+ const allSets = args.filter((a) => a === '--all-sets').length;
1047
+ if (allSets > 1) return usage('--all-sets is given twice');
1045
1048
  const f = flags(
1046
- args.filter((a) => a !== '--seam-pairs' && a !== '--keep-stills'),
1049
+ args.filter((a) => a !== '--seam-pairs' && a !== '--keep-stills' && a !== '--all-sets'),
1047
1050
  'check',
1048
1051
  ['--rig', '--out'],
1049
1052
  );
1050
1053
  if (typeof f === 'string') return usage(f);
1054
+ if (allSets === 1 && f.has('--set')) return usage('--set and --all-sets are two modes; give one');
1051
1055
  const rig = f.get('--rig') as string;
1052
1056
  const out = f.get('--out') as string;
1053
1057
  const edges = pageEdgesOf(f.get('--page-edges'));
@@ -1057,7 +1061,7 @@ function cmdCheck(args: string[]): number {
1057
1061
  try {
1058
1062
  const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
1059
1063
  const r = checkStage(
1060
- { rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape, requirements: f.get('--requirements'), seamPairs: seamPairs === 1, keepStills: keepStills === 1 },
1064
+ { rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape, requirements: f.get('--requirements'), seamPairs: seamPairs === 1, keepStills: keepStills === 1, ...(f.has('--set') ? { set: f.get('--set') as string } : {}), allSets: allSets === 1 },
1061
1065
  rigcRunner(bin),
1062
1066
  bin,
1063
1067
  console.log,
@@ -1503,6 +1507,7 @@ function cmdBuild(args: string[]): number {
1503
1507
  }
1504
1508
  // --head is optional (issue #359), as for assemble.
1505
1509
  for (const f of ['--config', '--source', '--full', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1510
+ if (on.has('--all-sets') && flags.has('--set')) return usage('--set and --all-sets are two modes; give one');
1506
1511
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1507
1512
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1508
1513
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
@@ -1528,7 +1533,7 @@ function cmdBuild(args: string[]): number {
1528
1533
  const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-build-'));
1529
1534
  try {
1530
1535
  const r = build(
1531
- { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), ...(alts.length === 0 ? {} : { alts }) },
1536
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), ...(alts.length === 0 ? {} : { alts }), ...(flags.has('--set') ? { set: flags.get('--set') as string } : {}), allSets: on.has('--all-sets') },
1532
1537
  { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
1533
1538
  console.log,
1534
1539
  );
package/docs/AUTHORING.md CHANGED
@@ -108,6 +108,7 @@ read by no CPU stage).
108
108
  | `assemble.extend_below_crop` | assemble | **proposed** with the plan: `{part, run, tag}` — a head-run part that reaches the bottom of the head crop is continued from a full-run layer, whole connected components |
109
109
  | `assemble.patches` | assemble; `meshes`/`regions` keys may name them too | **authored**, optional: `[{name, box, alpha, draw}]` — an extra part cut from the **painting** itself, for figure no See-through layer holds (a hem both runs dropped). `box` is `[x0, y0, x1, y1]` in **rig** pixels, `x1`/`y1` exclusive — the space of `parts.json` and `recomposite_rig.png`, where the hole is found; `alpha` is `"silhouette"` (the painting's figure silhouette inside the box, the one `--seam silhouette` uses) or `"box"` (the whole box); `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`. A patch is always a region: its bone is `regions.<name>`, in the one place every region's bone lives, and a `meshes` entry for it is refused. See §5 for how to place one |
110
110
  | `assemble.cuts` | assemble; `meshes`/`regions` keys may name the new parts too, and must (each is a part like any other) | **authored**, optional (issue #170): `[{from, into, polygon, draw, overlap, under, under_fill}]`, `under` and `under_fill` optional — a plan part's layer cut by a polygon the author draws: the pixels of plan part `from` whose **centre** lies inside `polygon` (even-odd; a centre exactly on an edge is outside) leave `from` and make the new part `into`, colour and alpha, so every pixel of the layer ends in exactly one of the two. `polygon` is `[[x, y], …]`, at least three points, non-negative integer **rig** pixels — the space of `parts.json` and of the patches' boxes. The file that shows the layer in those coordinates is `assemble`'s own `rig/parts/<from>.png`, placed at its `parts.json` `x`, `y` (the PNG's pixel `(i, j)` is rig pixel `(x + i, y + j)`), or `render/recomposite_rig.png`, the whole rig 1:1; the contact sheets (`sheet`) are scaled and carry no coordinates. `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`, as for a patch (to keep the setup pose, draw the piece immediately in front of `from` — `{"before": "<the plan part after from>"}`, or `"front"` when `from` is the last plan part; `{"before": "<from>"}` draws it behind `from`, which `under` refuses as `ASSEMBLE_UNDER_DRAW` because the fill would hide it). `from` must be a plan part (`CONFIG_NAME_RESOLVES`; a patch or another cut's piece is refused), `into` a new name (`CONFIG_PART_UNIQUE`), the polygon three or more integer points (`CONFIG_FIELD_TYPE`). The piece's `parts.json` record carries `from`'s `<run>:<tag>` and its own counts, each split from the layer's by where the pixel lies; it is a mesh or a region under its own name, and the coverage rule holds it like a plan part. What the config cannot know the stage refuses: a polygon point past the rig (`ASSEMBLE_CUT_INSIDE`), two cuts of one part sharing a pixel (`ASSEMBLE_CUT_OVERLAP`), a cut that takes none of the layer's opaque pixels or leaves `from` none (`ASSEMBLE_CUT_PIXELS`). `overlap` is required, a non-negative integer of rig pixels with no default (`CONFIG_FIELD_TYPE`): the **band** — the polygon's pixels within `overlap` px of its edge by Chebyshev distance (the polygon's mask less its erosion by a `2 * overlap + 1` square) — stays in `from` as well as going to the piece, so the piece's resampled edge has art under it; the counts stay a strict partition and the band is the base's extra copy, printed on the `cut:` line. At `0` the two abut and `check`'s seam bar judges the edge between them (`CHECK_SEAM_WITHIN_BAR`, §5). A band only covers the edge at rest: a piece that swings well clear of where it was painted shows the hole it leaves, and a thin piece's band shows a ghost of the piece. `under` (issue #193), optional, is the author's image of what lies under the piece: a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE` unless a non-empty string), in rig pixels over the polygon's **bounding box** — the least and greatest polygon x and y, so the image is `max x − min x` by `max y − min y` and its pixel `(i, j)` is rig pixel `(min x + i, min y + j)`. Where the cut removed pixels from `from` — the piece's pixels (alpha above 0) less the band — `from` takes the image's pixel, colour and alpha wherever the image's alpha is at least the alpha the cut removed there (**filled**); elsewhere `from` stays empty there (**left empty**): a transparent pixel is the author's statement that nothing lies under it (the background, or a part drawn behind), and a less opaque one is not half-filled. The image is read nowhere else, so its pixels over the band, over the box outside the polygon or where the layer had no art never change `from`. The `cut:` line then ends `; under <n> px filled, <m> px left empty` (`n + m` is every removed pixel), so an unintended hole is a number in the log, not a guess; the filled pixels count in `from`'s `opaque_px` and `occluded_px` (the piece is drawn over them), never as visible or projected. The stage refuses: a file that is not there (`ASSEMBLE_UNDER_PRESENT`) or not a PNG (`ASSEMBLE_UNDER_DECODES`), an image not exactly the box's size (`ASSEMBLE_UNDER_SIZE`, both sizes given), an image that fills none of the removed pixels — all transparent there, or another file (`ASSEMBLE_UNDER_EMPTY`; to leave the whole hole, drop `under`), and a piece drawn behind `from` (`ASSEMBLE_UNDER_DRAW`: the fill would hide it; draw it `"front"` or `{"before": "<the plan part after from>"}`). Producing the image is the author's, by whatever means; absent, `from` keeps only its band. `under_fill` (issue #360), optional, is the one value `"colour"` (`CONFIG_FIELD_TYPE` on any other): the same pixels are filled and left empty, and a filled pixel takes the image's **colour** at the alpha the cut removed there instead of the image's alpha, so `from`'s alpha is the uncut layer's wherever the image fills and its silhouette does not grow — an opaque image over a soft edge leaves the edge soft. It applies to the part's hidden-art layer as well as to a file; a cut with neither is refused (`ASSEMBLE_UNDER_FILL_SOURCE`). The `cut:` line's filled count then says how many took the colour only: `; under 190 px filled (190 colour only, at the alpha the cut removed), 0 px left empty`. Absent, a filled pixel takes the image's colour and alpha, as above |
111
+ | `assemble.alternates` | assemble, rig, check (`--set`, `--all-sets`) | **authored**, optional (issues #454, #465): `[{name, source?, full?, head?, head_box?, parts?, hides?, cuts?, skeleton?, drawn?}]` — alternate painting sets assembled into `<out>/rig/parts/<set>_<part>.png` (`parts.json` `alternates`), attached on each covered slot in `rig.json` as `<set>_<part>` beside the base attachment, and indexed in `<out>/rig/sets.json` (`rig-parts-sets/1`: `sets.<set>.slots`, `.hides`, and `.drawn` when present). `source`, `full` and `head` may instead be given on the CLI with `--alt <name>=<source>:<full>[:<head>]`. `parts` lists the subset of parts the set replaces (default: every part not in `hides`); `hides` lists slots the set hides (`null` in `sets.json`). `skeleton` (a pose file path relative to the config's directory, `{"width": W, "height": H, "points": {…}}` with all 18 body-18 keypoints on the canvas, validated by the same rules as `generation.control.skeleton`: `CONFIG_FILE_PRESENT`, `CONFIG_IS_JSON`, `CONFIG_FIELD_TYPE`, `CONFIG_KEY_KNOWN`, `CONFIG_KEY_UNIQUE`, `CONFIG_POSE_CANVAS`, `CONFIG_POSE_KEYPOINTS`, `CONFIG_POSE_IN_CANVAS`) or `drawn` (`{<bone>: <worldDegrees>}`, each bone resolved against `bones`, `CONFIG_NAME_RESOLVES`) declares the posed angles the alternate was painted at (issue #465); when `skeleton` is given, `assemble` derives `drawn` from its 18 keypoints (with `drawn` overriding any bone named in both), records both `skeleton` (scaled by `assemble.rig_scale` to rig px) and `drawn` in `parts.json` `alternates`, and the `rig` stage writes `<set>_*` region and mesh attachments in the bone-local frame of `poseRigBonesForDrawn(bones, drawn)` (weighting limb segments directly from `skeleton`'s joint endpoints when `skeleton` is present) and emits `drawn` in `sets.json` so `rig-pose` and `check --set <name>` / `--all-sets` pose the bones at `drawn` without rotating the already-posed art a second time |
111
112
  | `limbs` | assemble (the cut and the caps), rig (through `regions`), check (`LIMB_ONE_BONE`) | **proposed** by `propose --keypoints` (issue #349), then checked: `{<limb>: {segments: [{part, from, bone}, {part, from, bone, joint, range?, cap?, edge?, cap_shape?, cut_band?}, …]}}`, the upper segment first. The rule is *Limbs: cut at their joints* below the table. `part` is a new part (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` against the plan, patches, cuts and every other limb part); `from` a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`) — the layer the segment's pixels come from, which is cut into the limb parts and is not itself made: a `meshes` or `regions` entry for it is `CONFIG_NAME_RESOLVES`, and the segments of one limb that take one part are consecutive (`CONFIG_LIMB_FROM_RUN`); `bone` a declared bone, each segment's the child of the one before (`CONFIG_LIMB_BONES_CHAIN`); the upper segment names no `joint`, `range`, `cap`, `edge`, `cap_shape` or `cut_band` (`CONFIG_KEY_KNOWN`), every other names its `joint` (`CONFIG_FIELD_PRESENT`) — the joint is its bone's origin; `range` is `[min, max]` degrees from rest with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`), which `assemble` writes into the lower part's `parts.json` record and `check` poses the joint at — its two ends and its middle — reading the seam there (`SEAM_OVER_RANGE`, issue #360; rig-c needs no rotation limit); `cap` a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE`); `edge` `"feather"` and `cap_shape` `"disc"` (issue #360), each the one value its field takes (`CONFIG_FIELD_TYPE` on any other) — the cut's geometry at that joint, *Limbs: cut at their joints* below; absent, the cut is hard and the cap the layer's half-disc of half the limb's width; `cut_band` a whole number of px, 1 or more (`CONFIG_FIELD_TYPE`), on a segment that takes the segment above's part — between two layers nothing is cut, so a band there is `CONFIG_KEY_KNOWN` — places the cut within that many px of the joint line (issue #360, *Limbs: cut at their joints*); absent, the cut is the joint line. Each limb part is attached like any part — `regions.<part>`, which must be the segment's `bone` (`CONFIG_LIMB_BONE`), or a mesh, which `check` holds to that bone alone (`LIMB_ONE_BONE`). With `limbs`, `assemble` reads `bones` too (`CONFIG_FIELD_PRESENT` without it). Absent, nothing is cut at a joint and no byte of any output moves |
112
113
  | `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip, link_inherit?}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone with a `tip` is turned the same way, towards its tip, with that distance as its length (issue #239); a tip on the bone's own origin is refused (`RIG_TIP_DISTINCT`). The five face bones `eye_r`, `eye_l`, `brow_r`, `brow_l` and `mouth` (by name, when one has no tip of its own) are turned to the eye line — the line through the origins of `eye_r` and `eye_l` as `bones` writes them, read image-left to image-right, drawn when parts.json holds both an `eyewhite-r` and an `eyewhite-l` part — so the blink's `scaley` squashes across the eye line on a tilted head; move an eye bone and the line moves with it. With one eyewhite or none, or an eye bone missing, the rig stage's `face axis:` line says so and they keep the page's axes. A bone under a face bone keeps the face's axis. With the line, `rig.json` also declares `invariants.axes` — one entry, `"face"`, through `eye_r` and `eye_l`, holding the turned face bones (and their controls) — so rig-c's gate holds the build to the line; the line is the one rigc draws through the same two bones, so the tolerance is only the float floor — the largest gap the emitted (float32) bones have to it, rounded up to 6 places plus one 6-place unit — and the `face axis:` line prints it. With the line the rig stage also adds `head_up` (issue #250): a slotless bone under `head`, at head's origin, turned to the eye line + 90 degrees and as long as the eye midpoint lies from head's origin along that axis — the head's frame, which a rule that needs it reads; `head` keeps its own frame, so no key on it or under it changes meaning, and a config bone of that name is refused (`RIG_HEAD_UP_NAME_FREE`). A face bone with its own `tip` is held too (issue #258), in a second entry, `"face_tips"`, when its tip lies along the line within whole-pixel rounding — a tip written in whole pixels sits at most sqrt(2)/2 px off the point of the line it stands for, so its direction may sit up to asin((sqrt(2)/2) / length) degrees off — with its tolerance measured the same way; a tip further off is the bone's own axis, and the `face axis:` line names it with its gap and that bound. A failure is rig-c's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` line, which names the bone and the fix (rig-c's `docs/AUTHORING.md` §3.7 and §5.2); with no line no entry is written and A51 reports SKIP. Every region on a turned bone is turned back, so the setup pose does not move. Any other single bone keeps world rotation 0; one whose parent is a link is turned back upright. A single bone may declare `inherit` (issue #348): what it takes from its parent's transform, one of rig-c's own mode names exactly as rig-c's table spells them — `normal`, `onlyTranslation`, `noRotationOrReflection`, `noScale`, `noScaleOrReflection` (rig-c's `RigBone.inherit`) — written to that bone in `rig.json` as declared; any other spelling, `"NoScale"` included (the runtime would fold its first letter; nothing here folds or maps), is refused (`CONFIG_BONE_INHERIT`), and a chain takes no `inherit` (`CONFIG_KEY_KNOWN`). A chain may instead declare `link_inherit` (issue #369), an object mapping its own expanded link names to those same modes, for example `"link_inherit": {"leg_r1": "noScale"}`. Only the named link changes; omitted links inherit normally. A key outside this chain is `CONFIG_NAME_RESOLVES`, with this chain's valid link names in the detail; a non-object or non-string mode is `CONFIG_FIELD_TYPE`, and another mode spelling is `CONFIG_BONE_INHERIT`. The mode is forwarded to the expanded link, or to its control under `--idle-keys ctl`, by the same rule as a single bone. This is an authored opt-in: `propose` does not add it. Its setup rotation is the turn from where rig-c's own poser puts the bone with no rotation of its own under that mode, so it still stands upright (or along its tip) at the setup pose. Where the idle gives the bone a `<bone>_ctl`, the mode goes on the control: the control is the bone the parent's rotation reaches, and the bone under it inherits normally, so the idle's keys moved onto the control still turn it. A foot that does not take its shin's rotation (`noRotationOrReflection` or `onlyTranslation`) keeps its shoe level while the leg is posed (`FEET_STAY_LEVEL`, §7); which mode is the author's. `noScale` keeps a shin or shoe's shape under a scaled parent but still takes the parent's rotation: a shoe that must also stay level needs a separate rotation constraint, for example a transform constraint from an unrotated bone. This does not change the proposed feet's existing inheritance or `FEET_STAY_LEVEL` default. An automatic mesh weighted through a bone with a mode other than `normal` gets no derived amplitude (its row names the bone) and no residual envelope (rig-c's `SKINNING_RANGE_UNSUPPORTED`). Absent, `rig.json` carries no `inherit` and no byte moves |
113
114
  | `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, less any island a declared `stray` left out) 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. The rule is chosen per mesh, so `RIG_HEAT_BONE_SOURCE` names three remedies: move the segment onto the art, take the bone out of `segments`, or keep this part on the distance rule — the one to take when the bone anchors the part from outside its art (a torso bone under a neck), since the first moves a bone another part placed and the second drops the anchor (issue #303). On a lattice, `RIG_HEAT_ISLAND_SOURCE` on an island that is dust — at most 4 px and at most 0.1 % of the part's art px, `mesh-draft`'s line — names `meshes.<part>.stray` and the figure that leaves it out, with the value declared; an island above that line keeps the segment remedy, a real piece being a real authoring question |
@@ -2729,6 +2730,7 @@ says why each requirement exists and what breaks without it.
2729
2730
  | <a id="ASSEMBLE_HIDDEN_ONE_SOURCE"></a>`ASSEMBLE_HIDDEN_ONE_SOURCE` | a cut's `under`, or a limb segment's `cap`, names a file, and the part it reads also has a hidden-art layer (`<layer>:hidden`, issue #359): reading either would be a guess | remove the file from the config, or the hidden-art layer from the input |
2730
2731
  | <a id="ASSEMBLE_ALT_INPUT_PRESENT"></a>`ASSEMBLE_ALT_INPUT_PRESENT` | an alternate painting set (`assemble.alternates` or `--alt`, issue #454) is missing `source` or `full`, names a file or directory that is not on disk, or lacks a covered part's layer in its runs | give `source` and `full` on the alternate set, or list an occluded part in `hides` |
2731
2732
  | <a id="ASSEMBLE_ALT_CANVAS_SIZE"></a>`ASSEMBLE_ALT_CANVAS_SIZE` | an alternate painting's canvas size differs from the primary painting's (issue #454; the detail gives both sizes) | the alternate painting: the primary painting's canvas size, so its parts land in the same rig frame |
2733
+ | <a id="ASSEMBLE_ALT_SKELETON"></a>`ASSEMBLE_ALT_SKELETON` | an alternate set's `skeleton` file (`assemble.alternates[i].skeleton`, issue #465) is not JSON or does not hold `{"width": W, "height": H, "points": {…the 18 body-18 keypoints}}` | that set's `skeleton` file |
2732
2734
  | <a id="ASSEMBLE_COUNTS_ADD_UP"></a>`ASSEMBLE_COUNTS_ADD_UP` | a part's visible and occluded counts do not add up to its opaque pixels, or a projected pixel is not visible, or a cut part's pieces do not add up to it — an assembler bug, not an input problem | report it with the part named; nothing was written |
2733
2735
 
2734
2736
  ### propose
@@ -2829,7 +2831,7 @@ documents do not ship). Each is an input this package built; none is an author's
2829
2831
  | --- | --- | --- |
2830
2832
  | <a id="CHECK_RIGC_PRESENT"></a>`CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
2831
2833
  | <a id="CHECK_RIGC_PACKAGE"></a>`CHECK_RIGC_PACKAGE` | `node_modules/.bin/rigc` is missing under the package root named: this lookup (the selftest's, issue #337) takes this package's own rigc and no other — no directory above, no `PATH` | `bun install` in that root |
2832
- | <a id="CHECK_INPUT_PRESENT"></a><a id="CHECK_INPUT_IS_JSON"></a><a id="CHECK_PART_PNG_PRESENT"></a><a id="CHECK_PART_PNG_MATCHES_BOX"></a><a id="CHECK_PART_SLOT_PRESENT"></a><a id="CHECK_RIG_STAGE_PRESENT"></a><a id="CHECK_RIG_STAGE_IS_THE_CANVAS"></a><a id="CHECK_RIG_ROOT_BONE"></a><a id="CHECK_IDLE_PRESENT"></a>`CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by). `motion.json` is required even with no animation in it, because `rigc build` takes `--motion` (a spec with `"animations": {}` builds green); `CHECK_IDLE_PRESENT` is an `idle` that is there with no positive `duration` — an absent `idle` is measured around (§7). `CHECK_INPUT_PRESENT` on `…/parts.json`: `--parts` named a directory without `parts.json` — most often `parts/` itself — or, without `--parts`, the rig directory has none while the directory above it does (build's layout, `<out>/rig` under `<out>/parts.json`). The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json`. With neither (no `--parts`, and no `parts.json` beside `rig.json` or above it), the rig is measured without parts and nothing is refused (§7) | re-run rig (`build` does both); for `…/parts.json`, `--parts` — the directory above `parts/` |
2834
+ | <a id="CHECK_INPUT_PRESENT"></a><a id="CHECK_INPUT_IS_JSON"></a><a id="CHECK_PART_PNG_PRESENT"></a><a id="CHECK_PART_PNG_MATCHES_BOX"></a><a id="CHECK_PART_SLOT_PRESENT"></a><a id="CHECK_RIG_STAGE_PRESENT"></a><a id="CHECK_RIG_STAGE_IS_THE_CANVAS"></a><a id="CHECK_RIG_ROOT_BONE"></a><a id="CHECK_IDLE_PRESENT"></a>`CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by; `CHECK_INPUT_PRESENT` on `--set` / `--all-sets`: no `parts.json`, no `alternates`, or the named set is not in `alternates`). `motion.json` is required even with no animation in it, because `rigc build` takes `--motion` (a spec with `"animations": {}` builds green); `CHECK_IDLE_PRESENT` is an `idle` that is there with no positive `duration` — an absent `idle` is measured around (§7). `CHECK_INPUT_PRESENT` on `…/parts.json`: `--parts` named a directory without `parts.json` — most often `parts/` itself — or, without `--parts`, the rig directory has none while the directory above it does (build's layout, `<out>/rig` under `<out>/parts.json`). The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json`. With neither (no `--parts`, and no `parts.json` beside `rig.json` or above it), the rig is measured without parts and nothing is refused (§7) | re-run rig (`build` does both); for `…/parts.json`, `--parts` — the directory above `parts/` |
2833
2835
  | <a id="CHECK_SOURCE_SIZE"></a>`CHECK_SOURCE_SIZE` | the painting `--source` names is not this rig's: no single `rig_scale` takes its width and height to the stage's (assemble makes the canvas `trunc(w × rig_scale)` x `trunc(h × rig_scale)`), or, with `parts.json` read, its `scale_rig_per_source` does not; or the stage is not whole pixels. Refused before anything is built | the painting the rig was made from — the stage's own size, or the painting `assemble` was given |
2834
2836
  | <a id="CHECK_SOURCE_PNG"></a>`CHECK_SOURCE_PNG` | `--source` names a file that does not read as a PNG | a PNG |
2835
2837
  | <a id="CHECK_SOURCE_GRID"></a>`CHECK_SOURCE_GRID` | the setup-pose still and its black-tinted twin (the coverage `SETUP_POSE_VS_SOURCE` reads) did not come back on one grid over one opaque background | a rigc problem; report it |
@@ -2838,7 +2840,7 @@ documents do not ship). Each is an input this package built; none is an author's
2838
2840
  | <a id="CHECK_RIGC_GREEN"></a>`CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
2839
2841
  | <a id="CHECK_LOOP_LAST_FRAME_AT_DURATION"></a>`CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
2840
2842
  | <a id="CHECK_LOOP_CLOSES"></a>`CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted); with a physics constraint, the frames of the rig with every physics constraint left out, and the object says so (§7, *loop*) | a track whose last key is not its first |
2841
- | <a id="CHECK_SEAM_WITHIN_BAR"></a>`CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | read the `seam pair` lines and `check/seam/` pictures (§7; magenta = a counted pixel, cyan = the box round them): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png`. A pair of one layer cut in two with `differing_copies_px` is the cut line: move the polygon's edge, not `overlap` |
2843
+ | <a id="CHECK_SEAM_WITHIN_BAR"></a>`CHECK_SEAM_WITHIN_BAR` | the setup pose (or, under `--set <name>` / `--all-sets`, an alternate set's setup pose posed at its `drawn` bone angles with its `<set>_*` attachments and `hides` applied, issue #465) does not reproduce the flat stack of parts (`recomposite_rig.png`, or `recomposite_rig_<set>.png`) | read the `seam pair` lines and `check/seam/` (`check/seam_<set>/`) pictures (§7; magenta = a counted pixel, cyan = the box round them): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png` (`recomposite_rig_<set>.png`). A pair of one layer cut in two with `differing_copies_px` is the cut line: move the polygon's edge, not `overlap` |
2842
2844
  | <a id="CHECK_BREATH_VISIBLE"></a>`CHECK_BREATH_VISIBLE` | the breath alone barely moves the torso (`topwear`, rendered alone, the idle with and without its breath tracks — issue #411; the whole idle's figure is printed beside it, and motion that is not the breath's is not counted), or the idle has no breath track at all — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
2843
2845
  | <a id="CHECK_BLINK_NO_HOLE"></a>`CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
2844
2846
  | <a id="CHECK_CHAIN_LAG"></a>`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) |
package/docs/STABILITY.md CHANGED
@@ -56,9 +56,9 @@ defaults are the help's too, and none is guessed from the art.
56
56
  | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`), `--cloth` (a config whose `cloth` block is the recipe, issue #364; not with `--from-config`) |
57
57
  | `compare` | `--left`, `--right`; optional `--map` |
58
58
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
59
- | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills` |
59
+ | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills`, `--set` or `--all-sets` (issue #454) |
60
60
  | `loop` | `--frames`, `--out`; optional `--palette` |
61
- | `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills`, `--alt` (as for `assemble`, issue #454) |
61
+ | `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills`, `--alt` (as for `assemble`, issue #454), `--set` or `--all-sets` (as for `check`, issue #454) |
62
62
  | `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
63
63
  | `mesh-draft` | `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon), `--more-triangles` |
64
64
  | `painting` | one positional path, the painting; `--keypoints`; optional `--person` |
@@ -121,7 +121,7 @@ part name, `[i]` an entry of a list):
121
121
  - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
122
122
  - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
123
123
  - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`
124
- - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts` (issue #454)
124
+ - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `drawn` (issues #454, #465)
125
125
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
126
126
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
127
127
  - **assemble.patches[i].draw**, when an object — required: `before`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.31.0",
3
+ "version": "1.32.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/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measureRecomposite, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -83,6 +83,7 @@ import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
85
  import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, SETS_FILE, type StrayCleared, type TurnRow } from './rig.ts';
86
+ import { KEYPOINT_NAMES, type KeypointName, type PosePoints } from './skeleton.ts';
86
87
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
87
88
  import { TURN_BONE } from './turn.ts';
88
89
  import { pyRound } from './round.ts';
@@ -273,6 +274,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
273
274
  hides?: string[];
274
275
  head_box?: [number, number, number, number];
275
276
  cuts?: Cut[];
277
+ skeleton?: string;
278
+ drawn?: Record<string, number>;
276
279
  }> = cfgAlts.map((a, i) => {
277
280
  const cli = cliByName.get(a.name);
278
281
  if (cli !== undefined) cliByName.delete(a.name);
@@ -286,6 +289,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
286
289
  hides: a.hides,
287
290
  head_box: a.head_box,
288
291
  cuts: a.cuts,
292
+ skeleton: a.skeleton === undefined ? undefined : resolve(configDir, a.skeleton),
293
+ drawn: a.drawn,
289
294
  };
290
295
  });
291
296
  for (const cli of cliByName.values()) {
@@ -334,6 +339,48 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
334
339
  });
335
340
  continue;
336
341
  }
342
+ let altSkeleton: PosePoints | undefined;
343
+ if (alt.skeleton !== undefined) {
344
+ if (!existsSync(alt.skeleton) || !statSync(alt.skeleton).isFile()) {
345
+ altProblems.push({
346
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
347
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
348
+ detail: 'no such file; the alternate pose skeleton is required when named',
349
+ });
350
+ continue;
351
+ }
352
+ try {
353
+ const rawSkel: unknown = JSON.parse(readFileSync(alt.skeleton, 'utf8'));
354
+ const skObj = typeof rawSkel === 'object' && rawSkel !== null && !Array.isArray(rawSkel) ? (rawSkel as Record<string, unknown>) : null;
355
+ const sw = skObj !== null && typeof skObj.width === 'number' && skObj.width > 0 ? skObj.width : null;
356
+ const sh = skObj !== null && typeof skObj.height === 'number' && skObj.height > 0 ? skObj.height : null;
357
+ const spts = skObj !== null && typeof skObj.points === 'object' && skObj.points !== null && !Array.isArray(skObj.points) ? (skObj.points as Record<string, unknown>) : null;
358
+ if (sw === null || sh === null || spts === null || KEYPOINT_NAMES.some((k) => !Array.isArray(spts[k]) || (spts[k] as unknown[]).length !== 2)) {
359
+ altProblems.push({
360
+ code: 'ASSEMBLE_ALT_SKELETON',
361
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
362
+ detail: 'must hold {"width": W, "height": H, "points": {…the 18 body-18 keypoints}}',
363
+ });
364
+ continue;
365
+ }
366
+ const [rW, rH] = result.parts.rig_size;
367
+ const sx = rW / sw;
368
+ const sy = rH / sh;
369
+ const scaled = {} as Record<KeypointName, readonly [number, number]>;
370
+ for (const k of KEYPOINT_NAMES) {
371
+ const [px, py] = spts[k] as [number, number];
372
+ scaled[k] = [pyRound(px * sx, 6), pyRound(py * sy, 6)];
373
+ }
374
+ altSkeleton = scaled;
375
+ } catch (err) {
376
+ altProblems.push({
377
+ code: 'ASSEMBLE_ALT_SKELETON',
378
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
379
+ detail: `does not parse as JSON: ${(err as Error).message}`,
380
+ });
381
+ continue;
382
+ }
383
+ }
337
384
  const altSrc = readSource(alt.source);
338
385
  if (altSrc.width !== src.width || altSrc.height !== src.height) {
339
386
  altProblems.push({
@@ -421,6 +468,17 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
421
468
  const keptRecords = altRes.parts.parts.filter((p) => coveredSet.has(p.name));
422
469
  const slots: Record<string, string> = {};
423
470
  for (const r of keptRecords) slots[r.name] = `${alt.name}_${r.name}`;
471
+ let altRecomposite = altRes.parts.recomposite as RecompositeRecord;
472
+ if (alt.parts !== undefined) {
473
+ const [rW, rH] = result.parts.rig_size;
474
+ const altByName = new Map(altRes.images.map((x) => [x.record.name, x]));
475
+ const fullSetPlaced = result.images
476
+ .filter(({ record }) => !hidesSet.has(record.name))
477
+ .map((baseItem) => altByName.get(baseItem.record.name) ?? baseItem);
478
+ const altSrcRig = sourceInRig(altSrc, rW, rH);
479
+ const altCan = recomposite(fullSetPlaced, rW, rH);
480
+ altRecomposite = recompositeRecord(measureRecomposite(altCan, altSrcRig, fullSetPlaced));
481
+ }
424
482
  altBuilt.push({
425
483
  record: {
426
484
  name: alt.name,
@@ -429,9 +487,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
429
487
  slots,
430
488
  records: keptRecords,
431
489
  ghost_px: altRes.parts.ghost_px,
432
- recomposite: altRes.parts.recomposite as RecompositeRecord,
490
+ recomposite: altRecomposite,
433
491
  ...(altRes.parts.scene === undefined ? {} : { scene: altRes.parts.scene }),
434
492
  ...(altRes.parts.single_run_pad === undefined ? {} : { single_run_pad: altRes.parts.single_run_pad }),
493
+ ...(altSkeleton === undefined ? {} : { skeleton: altSkeleton }),
494
+ ...(alt.drawn === undefined ? {} : { drawn: { ...alt.drawn } }),
435
495
  },
436
496
  images: keptImages,
437
497
  });
@@ -1210,6 +1270,10 @@ export interface CheckStageInput {
1210
1270
  seamPairs?: boolean;
1211
1271
  /** `check --keep-stills` (issue #209): leave the setup-pose still and its `frames.json` under {@link STILL_DIR}. */
1212
1272
  keepStills?: boolean;
1273
+ /** `check --set <name>` (issue #454): measure one declared alternate set's setup-pose seam and recomposite figures. */
1274
+ set?: string;
1275
+ /** `check --all-sets` (issue #454): measure every declared alternate set's setup-pose seam and recomposite figures. */
1276
+ allSets?: boolean;
1213
1277
  }
1214
1278
 
1215
1279
  /**
@@ -1389,7 +1453,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1389
1453
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
1390
1454
  log(` ${rigcIdentity(bin, v)}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1391
1455
  const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
1392
- const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true);
1456
+ const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true, input.set, input.allSets === true);
1393
1457
  log(` gate rig-play (rigc ${packedBuildLabel(mode)}), verbatim:`);
1394
1458
  for (const l of r.gateHtml) log(l);
1395
1459
  for (const l of packLines(r)) log(` ${l}`);
@@ -1407,6 +1471,16 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1407
1471
  );
1408
1472
  const pairs = fig.seam_pairs ?? [];
1409
1473
  pairs.forEach((row, i) => log(` seam pair ${i + 1} of ${pairs.length}: ${seamPairText(row)}`));
1474
+ if (fig.sets !== undefined) {
1475
+ for (const [setName, setFig] of Object.entries(fig.sets)) {
1476
+ log(
1477
+ ` seam (${setName})${setFig.seam_border === undefined ? '' : ` (config.scene: ${setFig.seam_border.frame_px_left_out} frame px on the painting's border rows and columns left out, ${setFig.seam_border.px_over_40_left_out} of them over ${SEAM_PX_LEVEL})`}: setup pose at ${setFig.render_px}, scale ${setFig.seam_frame_scale.toFixed(4)}: mean |d| ${setFig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
1478
+ `${setFig.seam_px_over_40} px over ${SEAM_PX_LEVEL}, ${seamRigPxText(setFig.seam_px_over_40, setFig.seam_frame_scale)} rig px (the count / s²; <= ${SEAM_PX_BAR}), ${setFig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
1479
+ );
1480
+ const setPairs = setFig.seam_pairs ?? [];
1481
+ setPairs.forEach((row, i) => log(` seam pair (${setName}) ${i + 1} of ${setPairs.length}: ${seamPairText(row)}`));
1482
+ }
1483
+ }
1410
1484
  }
1411
1485
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
1412
1486
  const folds = fig[FOLD_LINE];
@@ -1420,6 +1494,11 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1420
1494
  const sweep = fig[SWEEP_LINE];
1421
1495
  if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1422
1496
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1497
+ if (fig.sets !== undefined) {
1498
+ for (const [setName, setFig] of Object.entries(fig.sets)) {
1499
+ log(` ${judgementLine(`RECOMPOSITE_HOLES (${setName})`, setFig.RECOMPOSITE_HOLES)}`);
1500
+ }
1501
+ }
1423
1502
  const vsSource = fig[SOURCE_LINE];
1424
1503
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
1425
1504
  if (r.requirements !== null) {
@@ -1707,6 +1786,10 @@ export interface BuildInput {
1707
1786
  keepStills?: boolean;
1708
1787
  /** `--alt`, forwarded to the assemble stage as `assemble` takes it (issue #454). */
1709
1788
  alts?: readonly AlternateCliInput[];
1789
+ /** `--set <name>`, forwarded to the check as `check` takes it (issue #454). */
1790
+ set?: string;
1791
+ /** `--all-sets`, forwarded to the check as `check` takes it (issue #454). */
1792
+ allSets?: boolean;
1710
1793
  }
1711
1794
 
1712
1795
  /** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value itself. */
@@ -1729,6 +1812,8 @@ export const CHECK_FLAGS_IN_BUILD: Readonly<Record<string, CheckFlagInBuild>> =
1729
1812
  '--page-edges': { as: 'forwarded' },
1730
1813
  '--pack-shape': { as: 'forwarded' },
1731
1814
  '--requirements': { as: 'forwarded' },
1815
+ '--set': { as: 'forwarded' },
1816
+ '--all-sets': { as: 'forwarded' },
1732
1817
  '--seam-pairs': { as: 'forwarded' },
1733
1818
  '--keep-stills': { as: 'forwarded' },
1734
1819
  };
@@ -1841,7 +1926,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1841
1926
 
1842
1927
  let report: CheckReport;
1843
1928
  try {
1844
- report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true }, run.check, run.checkBin, prefixed('check'));
1929
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true, ...(input.set === undefined ? {} : { set: input.set }), allSets: input.allSets === true }, run.check, run.checkBin, prefixed('check'));
1845
1930
  } catch (err) {
1846
1931
  return refused('check', err);
1847
1932
  }
package/src/check.ts CHANGED
@@ -125,7 +125,8 @@ import {
125
125
  type Toward,
126
126
  type WorldBox,
127
127
  } from './instruments.ts';
128
- import { type PartRecord, type PartsFile, readParts } from './parts.ts';
128
+ import { type AlternatePartsRecord, type PartRecord, type PartsFile, readParts } from './parts.ts';
129
+ import { poseRigBonesForDrawn, type RigBone, SETS_FILE } from './rig.ts';
129
130
  import { turnTwin } from './turn.ts';
130
131
  import {
131
132
  aimLine,
@@ -622,7 +623,7 @@ function readJson(path: string, problems: Problem[]): Record<string, unknown> |
622
623
  * duration. `sourcePath` is `--source`, read and held to the stage here, so a
623
624
  * painting that is not this rig's is refused before anything is built.
624
625
  */
625
- export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string): CheckInputs {
626
+ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string, set?: string, allSets = false): CheckInputs {
626
627
  const dir = resolve(rigDir);
627
628
  const named = partsHome !== undefined;
628
629
  const home = resolve(partsHome ?? rigDir);
@@ -674,6 +675,43 @@ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?:
674
675
  problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `part "${p.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${p.w}x${p.h}` });
675
676
  }
676
677
  }
678
+ for (const alt of parts.alternates ?? []) {
679
+ for (const r of alt.records) {
680
+ const png = join(partsDir, `${alt.name}_${r.name}.png`);
681
+ if (!existsSync(png)) {
682
+ problems.push({ code: 'CHECK_PART_PNG_PRESENT', object: `set "${alt.name}" part "${r.name}"`, detail: `${png} does not exist; parts.json lists it` });
683
+ continue;
684
+ }
685
+ const im = readPng(png);
686
+ if (im.width !== r.w || im.height !== r.h) {
687
+ problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `set "${alt.name}" part "${r.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${r.w}x${r.h}` });
688
+ }
689
+ }
690
+ }
691
+ }
692
+ if (set !== undefined || allSets) {
693
+ if (parts === null) {
694
+ problems.push({
695
+ code: 'CHECK_INPUT_PRESENT',
696
+ object: set !== undefined ? `--set ${set}` : '--all-sets',
697
+ detail: `${partsPath} was not read; alternate sets are read from parts.json's "alternates"`,
698
+ });
699
+ } else {
700
+ const alts = parts.alternates ?? [];
701
+ if (set !== undefined && !alts.some((a) => a.name === set)) {
702
+ problems.push({
703
+ code: 'CHECK_INPUT_PRESENT',
704
+ object: `--set ${set}`,
705
+ detail: `${partsPath} has no alternate set "${set}" in "alternates" (${alts.length === 0 ? 'none declared' : alts.map((a) => `"${a.name}"`).join(', ')})`,
706
+ });
707
+ } else if (allSets && alts.length === 0) {
708
+ problems.push({
709
+ code: 'CHECK_INPUT_PRESENT',
710
+ object: '--all-sets',
711
+ detail: `${partsPath} declares no alternate sets in "alternates"`,
712
+ });
713
+ }
714
+ }
677
715
  }
678
716
  let stage: StageBox | null = null;
679
717
  let rootBone: string | null = null;
@@ -1195,6 +1233,72 @@ export function flatComposite(parts: PartsFile, partsDir: string, background: re
1195
1233
  return canvas;
1196
1234
  }
1197
1235
 
1236
+ /**
1237
+ * The flat stack of parts for an alternate set (issue #454), in `parts.parts`
1238
+ * slot order: hidden slots skipped, covered slots drawn from `alt.records` at
1239
+ * their box, uncovered base slots drawn at their base box.
1240
+ */
1241
+ export function flatCompositeForSet(parts: PartsFile, alt: AlternatePartsRecord, partsDir: string, background: readonly [number, number, number, number]): Raster {
1242
+ const [W, H] = parts.rig_size;
1243
+ let canvas = newRaster(W, H);
1244
+ for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
1245
+ const hides = new Set(alt.hides ?? []);
1246
+ const byName = new Map(alt.records.map((r) => [r.name, r]));
1247
+ for (const p of parts.parts) {
1248
+ if (hides.has(p.name)) continue;
1249
+ const rec = byName.get(p.name);
1250
+ const pngName = rec === undefined ? p.name : `${alt.name}_${rec.name}`;
1251
+ const box = rec ?? p;
1252
+ canvas = alphaComposite(canvas, readPng(join(partsDir, `${pngName}.png`)), box.x, box.y);
1253
+ }
1254
+ return canvas;
1255
+ }
1256
+
1257
+ /**
1258
+ * The setup-pose rig spec with an alternate set applied (issues #454, #465):
1259
+ * hidden slots (and `<slot>_still` for any hidden or covered slot) have no
1260
+ * setup attachment, covered slots have `alt.slots[slot]`, and when the set
1261
+ * carries `drawn` bone rotations its bones are posed at those world rotations
1262
+ * so the throwaway still renders the alternate in the pose it was painted in.
1263
+ */
1264
+ export function stillRigForSet(
1265
+ rig: Record<string, unknown>,
1266
+ alt: AlternatePartsRecord,
1267
+ drawn: Readonly<Record<string, number>> | undefined = alt.drawn,
1268
+ ): Record<string, unknown> {
1269
+ const hides = new Set(alt.hides ?? []);
1270
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).map((s) => {
1271
+ if (!isRecord(s) || typeof s.name !== 'string') return s;
1272
+ const stillBase = s.name.endsWith('_still') ? s.name.slice(0, -'_still'.length) : null;
1273
+ if (hides.has(s.name) || (stillBase !== null && (hides.has(stillBase) || Object.hasOwn(alt.slots, stillBase)))) {
1274
+ return { ...s, attachment: null };
1275
+ }
1276
+ if (Object.hasOwn(alt.slots, s.name)) return { ...s, attachment: alt.slots[s.name] };
1277
+ return s;
1278
+ });
1279
+ if (drawn === undefined || Object.keys(drawn).length === 0) return { ...rig, slots };
1280
+ const rawBones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).map((b) => ({
1281
+ name: String(b.name),
1282
+ ...(typeof b.parent === 'string' && b.parent !== '' ? { parent: b.parent } : {}),
1283
+ ...(typeof b.length === 'number' ? { length: b.length } : {}),
1284
+ ...(typeof b.rotation === 'number' ? { rotation: b.rotation } : {}),
1285
+ x: typeof b.x === 'number' ? b.x : 0,
1286
+ y: typeof b.y === 'number' ? b.y : 0,
1287
+ ...(typeof b.inherit === 'string' ? { inherit: b.inherit as RigBone['inherit'] } : {}),
1288
+ }));
1289
+ const stops = (Array.isArray(rig.stops) ? rig.stops : []).filter(isRecord).map((s) => ({
1290
+ links: Array.isArray(s.links) ? s.links.filter((l): l is string => typeof l === 'string') : [],
1291
+ }));
1292
+ const bones = poseRigBonesForDrawn(rawBones, drawn, stops);
1293
+ const out: Record<string, unknown> = { ...rig, bones, slots };
1294
+ if (isRecord(out.invariants)) {
1295
+ const { axes: _axes, ...rest } = out.invariants;
1296
+ if (Object.keys(rest).length > 0) out.invariants = rest;
1297
+ else delete out.invariants;
1298
+ }
1299
+ return out;
1300
+ }
1301
+
1198
1302
  /**
1199
1303
  * The map from rig (crop) pixel indices to frame pixel indices that the seam
1200
1304
  * and the source line warp by: frame = (sx u + tx, sy v + ty).
@@ -1770,11 +1874,26 @@ export interface CheckFigures {
1770
1874
  * the keys it always wrote.
1771
1875
  */
1772
1876
  skipped?: { loop?: string; seam?: string };
1877
+ /** Written only under `--set` or `--all-sets` (issue #454): each checked alternate set's setup-pose seam and recomposite figures, keyed by set name. */
1878
+ sets?: Record<string, SetCheckFigures>;
1773
1879
  /** Written only under `--requirements` (issue #93): the file's fps, the summary, and one line per declared requirement, by name. */
1774
1880
  requirements?: RequirementsBlock;
1775
1881
  PASS: boolean;
1776
1882
  }
1777
1883
 
1884
+ /** One alternate set's setup-pose check record under `--set` or `--all-sets` (issue #454). */
1885
+ export interface SetCheckFigures {
1886
+ render_px: string;
1887
+ seam_mean: number;
1888
+ seam_px_over_40: number;
1889
+ seam_px_over_80: number;
1890
+ seam_frame_scale: number;
1891
+ seam_border?: { frame_px_left_out: number; px_over_40_left_out: number };
1892
+ seam_pairs?: SeamPairRow[];
1893
+ RECOMPOSITE_HOLES: ReportedLine;
1894
+ PASS: boolean;
1895
+ }
1896
+
1778
1897
  /** `check.json`'s `requirements` block: the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
1779
1898
  export interface RequirementsBlock {
1780
1899
  fps: number;
@@ -1959,7 +2078,10 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1959
2078
  if (typeof fig.loop_max_diff === 'number' && fig.loop_max_diff !== LOOP_MAX_BAR) out.push('loop');
1960
2079
  // A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
1961
2080
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1962
- if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
2081
+ const setFailed =
2082
+ isRecord(fig.sets) &&
2083
+ Object.values(fig.sets).some((s) => isRecord(s) && typeof s.seam_mean === 'number' && typeof s.seam_px_over_40 === 'number' && seamFails(s.seam_mean, s.seam_px_over_40, typeof s.seam_frame_scale === 'number' ? s.seam_frame_scale : 1));
2084
+ if ((typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) || setFailed) out.push('seam');
1963
2085
  for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE]) {
1964
2086
  const l = fig[n];
1965
2087
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
@@ -3072,11 +3194,11 @@ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[
3072
3194
  * MEASURABLE is a problem. Without it nothing here reads, writes or prints
3073
3195
  * anything it did not before.
3074
3196
  */
3075
- export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false): CheckReport {
3197
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false, set?: string, allSets = false): CheckReport {
3076
3198
  let inp: CheckInputs;
3077
3199
  let reqFile: RequirementsFile | null = null;
3078
3200
  let seams: Map<string, SeamPair[]> | null = null;
3079
- if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source);
3201
+ if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source, set, allSets);
3080
3202
  else {
3081
3203
  // Both readers collect every problem; one refusal names both sets.
3082
3204
  const refused: Problem[] = [];
@@ -3089,7 +3211,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3089
3211
  return null;
3090
3212
  }
3091
3213
  };
3092
- const got = take(() => readCheckInputs(rigDir, partsHome, source));
3214
+ const got = take(() => readCheckInputs(rigDir, partsHome, source, set, allSets));
3093
3215
  reqFile = take(() => readRequirements(requirements));
3094
3216
  refuseIfAny(refused);
3095
3217
  inp = got as CheckInputs;
@@ -3436,6 +3558,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3436
3558
  let seamViewport: Viewport | null = null;
3437
3559
  let blink: JudgementLine = skip(lacking);
3438
3560
  let sourceRead: ReportedLine | null = null;
3561
+ const setsFigures: Record<string, SetCheckFigures> = {};
3439
3562
  if (inp.parts !== null || inp.source !== null) {
3440
3563
  try {
3441
3564
  mkdirSync(stillDir, { recursive: true });
@@ -3475,6 +3598,71 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3475
3598
  for (const { row, pixels } of split) writePng(join(out, row.picture), seamPicture(stillSet.frames[0].image, row, pixels));
3476
3599
  seamPairs = split.map((x) => x.row);
3477
3600
  }
3601
+ if (set !== undefined || allSets) {
3602
+ const alts = allSets ? (inp.parts.alternates ?? []) : (inp.parts.alternates ?? []).filter((a) => a.name === set);
3603
+ const setStillAnimations: Record<string, unknown> = { still: animations.still };
3604
+ const setStillBase = { ...withoutSliders(inp.rig, setStillAnimations), images };
3605
+ const setsJsonPath = join(inp.rigDir, SETS_FILE);
3606
+ const setsJsonRaw = existsSync(setsJsonPath) ? readJson(setsJsonPath, []) : null;
3607
+ const setsMap = setsJsonRaw !== null && isRecord(setsJsonRaw.sets) ? setsJsonRaw.sets : null;
3608
+ for (const alt of alts) {
3609
+ const setEntry: Record<string, unknown> | null =
3610
+ setsMap !== null && isRecord(setsMap[alt.name]) ? (setsMap[alt.name] as Record<string, unknown>) : null;
3611
+ const setDrawn: Record<string, number> | undefined =
3612
+ setEntry !== null && isRecord(setEntry.drawn)
3613
+ ? Object.fromEntries(
3614
+ Object.entries(setEntry.drawn).filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])),
3615
+ )
3616
+ : alt.drawn;
3617
+ const setDir = join(stillDir, `set_${alt.name}`);
3618
+ mkdirSync(setDir, { recursive: true });
3619
+ writeFileSync(join(setDir, 'rig.json'), JSON.stringify(stillRigForSet(setStillBase, alt, setDrawn)));
3620
+ writeFileSync(join(setDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations: setStillAnimations }));
3621
+ const asb = rigc(['build', '--rig', join(setDir, 'rig.json'), '--motion', join(setDir, 'motion.json'), '--out', join(setDir, 'build'), '--profile', 'spine']);
3622
+ if (asb.status !== 0) refuseIfAny(rigcFailed(`build (the setup-pose still of set "${alt.name}")`, asb, buildGateLines(asb.out)));
3623
+ const asr = rigc(['render', '--candidate', join(setDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(setDir, 'render')]);
3624
+ if (asr.status !== 0) refuseIfAny(rigcFailed(`render (the setup-pose still of set "${alt.name}")`, asr, asr.out.split('\n').filter((l) => l.includes('FAIL'))));
3625
+ const altStillSet = readFrameSet(join(setDir, 'render'));
3626
+ if (keepStills) cpSync(join(setDir, 'render'), join(keptDir, alt.name), { recursive: true });
3627
+ const altComposite = flatCompositeForSet(inp.parts, alt, inp.partsDir, altStillSet.background);
3628
+ const altSeam = seamFigures(altComposite, altStillSet.frames[0].image, inp.stage, altStillSet.viewport, altStillSet.background, undefined, alt.scene !== undefined);
3629
+ const altMean = round3(altSeam.mean);
3630
+ const altScale = round6(altStillSet.viewport.scale);
3631
+ const altFailed = seamFails(altMean, altSeam.over40, altScale);
3632
+ let altPairs: SeamPairRow[] | null = null;
3633
+ if (seamPairsAsked || altFailed) {
3634
+ const hides = new Set(alt.hides ?? []);
3635
+ const byName = new Map(alt.records.map((r) => [r.name, r]));
3636
+ const placed = inp.parts.parts
3637
+ .filter((p) => !hides.has(p.name))
3638
+ .map((p) => {
3639
+ const rec = byName.get(p.name);
3640
+ const pngName = rec === undefined ? p.name : `${alt.name}_${rec.name}`;
3641
+ const box = rec ?? p;
3642
+ return { name: p.name, from: box.from, x: box.x, y: box.y, image: readPng(join(inp.partsDir, `${pngName}.png`)) };
3643
+ });
3644
+ const split = seamPairRows(altSeam, placed, inp.parts.rig_size, altStillSet.viewport);
3645
+ const altSeamDir = join(seamDir, alt.name);
3646
+ mkdirSync(altSeamDir, { recursive: true });
3647
+ for (const { row, pixels } of split) {
3648
+ row.picture = `${SEAM_DIR}/${alt.name}/${basename(row.picture)}`;
3649
+ writePng(join(out, row.picture), seamPicture(altStillSet.frames[0].image, row, pixels));
3650
+ }
3651
+ altPairs = split.map((x) => x.row);
3652
+ }
3653
+ setsFigures[alt.name] = {
3654
+ render_px: `${altStillSet.viewport.pixelWidth}x${altStillSet.viewport.pixelHeight}`,
3655
+ seam_mean: altMean,
3656
+ seam_px_over_40: altSeam.over40,
3657
+ seam_px_over_80: altSeam.over80,
3658
+ seam_frame_scale: altScale,
3659
+ ...(altSeam.border === null ? {} : { seam_border: { frame_px_left_out: altSeam.border.framePx, px_over_40_left_out: altSeam.border.over40 } }),
3660
+ ...(altPairs === null ? {} : { seam_pairs: altPairs }),
3661
+ RECOMPOSITE_HOLES: altHolesLine(inp.parts, alt),
3662
+ PASS: !altFailed,
3663
+ };
3664
+ }
3665
+ }
3478
3666
  }
3479
3667
  if (inp.source !== null) {
3480
3668
  // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
@@ -3545,6 +3733,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3545
3733
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
3546
3734
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
3547
3735
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
3736
+ ...(Object.keys(setsFigures).length === 0 ? {} : { sets: setsFigures }),
3548
3737
  ...(reqBlock === null ? {} : { requirements: reqBlock }),
3549
3738
  PASS: false,
3550
3739
  };
@@ -3570,6 +3759,20 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3570
3759
  : `; worst of ${(seamPairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)} — every pair is a \`seam pair\` line above and a row of check.json's seam_pairs`),
3571
3760
  });
3572
3761
  }
3762
+ for (const [setName, setFig] of Object.entries(setsFigures)) {
3763
+ if (!setFig.PASS) {
3764
+ const worst = setFig.seam_pairs === undefined || setFig.seam_pairs.length === 0 ? null : setFig.seam_pairs[0];
3765
+ barProblems.push({
3766
+ code: 'CHECK_SEAM_WITHIN_BAR',
3767
+ object: `the setup-pose render of set "${setName}" vs the flat composite of parts/`,
3768
+ detail:
3769
+ `mean |d| ${setFig.seam_mean}/255 and ${setFig.seam_px_over_40} frame px over ${SEAM_PX_LEVEL} (${setFig.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${setFig.seam_frame_scale}, ${seamRigPxText(setFig.seam_px_over_40, setFig.seam_frame_scale)} rig px (the count / s²); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required` +
3770
+ (worst === null
3771
+ ? `; no pixel is over ${SEAM_PX_LEVEL}, so the mean alone fails and there is no pair to name`
3772
+ : `; worst of ${(setFig.seam_pairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)}`),
3773
+ });
3774
+ }
3775
+ }
3573
3776
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
3574
3777
  problems.sort((a, b) => rank(a) - rank(b));
3575
3778
  problems.unshift(...barProblems);
@@ -3775,6 +3978,20 @@ export function holesLine(parts: PartsFile): ReportedLine {
3775
3978
  };
3776
3979
  }
3777
3980
 
3981
+ /** `RECOMPOSITE_HOLES` for an alternate set (issue #454), carrying its assemble `mean_abs` and hole figures. */
3982
+ export function altHolesLine(parts: PartsFile, alt: AlternatePartsRecord): ReportedLine {
3983
+ const r = alt.recomposite;
3984
+ return {
3985
+ status: 'REPORTED',
3986
+ ...(alt.scene === undefined ? {} : { declared_scene: scenePhrase(alt.scene, parts.rig_size[0] * parts.rig_size[1]) }),
3987
+ mean_abs: r.mean_abs,
3988
+ error_px: r.error_px,
3989
+ uncovered_error_px: r.uncovered_error_px,
3990
+ hole_count: r.hole_count,
3991
+ largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]), borders: r.holes[0].borders.map((b) => `${b.part} ${b.px} px`) },
3992
+ };
3993
+ }
3994
+
3778
3995
  function boxText(h: { x: number; y: number; w: number; h: number }): string {
3779
3996
  return `${h.x},${h.y} ${h.w}x${h.h}`;
3780
3997
  }
package/src/config.ts CHANGED
@@ -254,6 +254,8 @@ export interface AlternateSetConfig {
254
254
  hides?: string[];
255
255
  head_box?: [number, number, number, number];
256
256
  cuts?: Cut[];
257
+ skeleton?: string;
258
+ drawn?: Record<string, number>;
257
259
  }
258
260
 
259
261
  export interface Assemble {
@@ -773,6 +775,8 @@ export interface CharacterConfig {
773
775
  cloth?: ClothRecipes;
774
776
  /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
775
777
  proposed_rig_scale?: number;
778
+ /** Alternate painting sets (issue #454): absent, no alternate sets are assembled or emitted. */
779
+ alternates?: AlternateSetConfig[];
776
780
  }
777
781
 
778
782
  /** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
@@ -1141,7 +1145,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1141
1145
  limbs = checkLimbs(c, t.limbs, made, names);
1142
1146
  }
1143
1147
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1144
- checkAlternates(c, t.assemble.alternates, made, limbs);
1148
+ checkAlternates(c, t.assemble.alternates, made, limbs, null);
1145
1149
  }
1146
1150
  }
1147
1151
  if ('cloth' in t) checkCloth(c, t.cloth);
@@ -1293,7 +1297,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1293
1297
  const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1294
1298
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1295
1299
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1296
- checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs);
1300
+ checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones);
1297
1301
  }
1298
1302
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1299
1303
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
@@ -1820,6 +1824,7 @@ function checkAlternates(
1820
1824
  v: Json,
1821
1825
  made: { parts: string[]; patches: string[]; cuts: string[] },
1822
1826
  limbs: LimbNames | null,
1827
+ bones: Set<string> | null = null,
1823
1828
  ): void {
1824
1829
  const p = 'config.assemble.alternates';
1825
1830
  if (!c.array(p, v, true)) return;
@@ -1827,16 +1832,29 @@ function checkAlternates(
1827
1832
  const seenNames = new Map<string, number>();
1828
1833
  (v as Json[]).forEach((entry, i) => {
1829
1834
  const at = `${p}[${i}]`;
1830
- const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts']);
1835
+ const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts', 'skeleton', 'drawn']);
1831
1836
  if (e === null) return;
1832
1837
  if ('name' in e && c.string(`${at}.name`, e.name)) {
1833
1838
  if (!partNameOk(e.name)) c.fail('CONFIG_PART_NAME', `${at}.name`, `names the set ${show(e.name)}; a set name is a file prefix, so it may not be empty, start with "." or hold a slash`);
1834
1839
  if (seenNames.has(e.name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the set "${e.name}" again (first at alternates[${seenNames.get(e.name)}])`);
1835
1840
  else seenNames.set(e.name, i);
1836
1841
  }
1837
- for (const k of ['source', 'full', 'head'] as const) {
1842
+ for (const k of ['source', 'full', 'head', 'skeleton'] as const) {
1838
1843
  if (k in e) c.string(`${at}.${k}`, e[k]);
1839
1844
  }
1845
+ if ('drawn' in e) {
1846
+ const d = e.drawn;
1847
+ if (!isPlainObject(d) || Object.keys(d).length === 0) {
1848
+ c.fail('CONFIG_FIELD_TYPE', `${at}.drawn`, `is ${show(d)}; a non-empty object of bone name -> drawn world angle in Spine degrees is required`);
1849
+ } else {
1850
+ for (const [bName, deg] of Object.entries(d)) {
1851
+ if (bones !== null && !bones.has(bName)) {
1852
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.drawn.${bName}`, `names "${bName}", which config.bones does not declare`);
1853
+ }
1854
+ c.number(`${at}.drawn.${bName}`, deg);
1855
+ }
1856
+ }
1857
+ }
1840
1858
  if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
1841
1859
  const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
1842
1860
  const validParts = new Set<string>([...allSlots, ...altCuts]);
package/src/parts.ts CHANGED
@@ -83,6 +83,7 @@
83
83
  */
84
84
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
85
85
  import { type Problem, refuseIfAny } from './errors.ts';
86
+ import { KEYPOINT_NAMES, type PosePoints } from './skeleton.ts';
86
87
  import { readLayerTag } from './tags.ts';
87
88
 
88
89
  export interface PartRecord {
@@ -244,6 +245,7 @@ export interface RecompositeRecord {
244
245
  export interface AlternateSetMap {
245
246
  slots: Record<string, string>;
246
247
  hides?: string[];
248
+ drawn?: Record<string, number>;
247
249
  }
248
250
 
249
251
  export interface AlternatePartsRecord {
@@ -256,6 +258,8 @@ export interface AlternatePartsRecord {
256
258
  recomposite: RecompositeRecord;
257
259
  scene?: SceneRecord;
258
260
  single_run_pad?: SingleRunPad;
261
+ skeleton?: PosePoints;
262
+ drawn?: Record<string, number>;
259
263
  }
260
264
 
261
265
  export interface PartsFile {
@@ -400,6 +404,8 @@ export function serializeParts(file: PartsFile): string {
400
404
  recomposite: serializeRecomposite(a.recomposite),
401
405
  ...(a.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (a.scene as SceneRecord)[k]])) }),
402
406
  ...(a.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (a.single_run_pad as SingleRunPad)[k]])) }),
407
+ ...(a.skeleton === undefined ? {} : { skeleton: Object.fromEntries(KEYPOINT_NAMES.map((k) => [k, (a.skeleton as PosePoints)[k]])) }),
408
+ ...(a.drawn === undefined ? {} : { drawn: a.drawn }),
403
409
  })),
404
410
  }),
405
411
  };
package/src/rig.ts CHANGED
@@ -164,13 +164,14 @@ import {
164
164
  } from './automesh.ts';
165
165
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
166
166
  import { type LocalInfluence, localInfluences } from './localweights.ts';
167
- import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
167
+ import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
168
168
  import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
169
169
  import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
170
170
  import { EYE_GROUP_TAGS } from './propose.ts';
171
171
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
172
172
  import { pyRound } from './round.ts';
173
173
  import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
174
+ import type { PosePoints } from './skeleton.ts';
174
175
  import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
175
176
  import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
176
177
 
@@ -933,6 +934,93 @@ export interface RigOutput {
933
934
  sets?: AttachmentSetsFile;
934
935
  }
935
936
 
937
+ /**
938
+ * Issue #465: pose a rig's bones so each bone named in `drawn` reaches that
939
+ * world rotation (in Spine degrees, CCW, y up), adjusting its local `rotation`
940
+ * from its parent's posed transform. Bones are walked in parent-first order.
941
+ */
942
+ export function poseRigBonesForDrawn(
943
+ bones: readonly RigBone[],
944
+ drawn: Readonly<Record<string, number>>,
945
+ stops?: ReadonlyArray<{ links: readonly string[] }>,
946
+ ): RigBone[] {
947
+ const undrawn = new Map<string, string>();
948
+ for (const s of stops ?? []) for (const l of s.links) undrawn.set(`${l}_d`, l);
949
+ const realBone = (n: string): string => undrawn.get(n) ?? n;
950
+ const posed: RigBone[] = [];
951
+ const posedRead: RigBone[] = [];
952
+ for (const b of bones) {
953
+ const readB: RigBone = { ...b, ...(b.parent === undefined ? {} : { parent: realBone(b.parent) }) };
954
+ const target = drawn[b.name];
955
+ if (target === undefined) {
956
+ posed.push({ ...b });
957
+ posedRead.push(readB);
958
+ continue;
959
+ }
960
+ const m0 = computeExactFrameTransforms([...posedRead, { ...readB, rotation: 0 }]).get(b.name) as BoneTransform;
961
+ const rotation = places(normaliseDegrees(target - normaliseDegrees(m0.worldRotation)));
962
+ posed.push({ ...b, rotation });
963
+ posedRead.push({ ...readB, rotation });
964
+ }
965
+ return posed;
966
+ }
967
+
968
+ /**
969
+ * Issue #465: derive the alternate's `drawn` bone world rotations (in Spine
970
+ * degrees, CCW, y up) from its body-18 skeleton read (`pts`, in rig pixels, y
971
+ * down) against the base rig's setup bone origins (`B`) and setup world turns
972
+ * (`turn`).
973
+ */
974
+ export function deriveAlternateDrawn(
975
+ pts: PosePoints,
976
+ B: ReadonlyMap<string, { x: number; y: number }>,
977
+ turn: ReadonlyMap<string, number>,
978
+ CX: number,
979
+ H: number,
980
+ ): Record<string, number> {
981
+ const sx = (x: number): number => x - CX;
982
+ const sy = (y: number): number => cropToSpineY(y, H);
983
+ const deg = (a: readonly [number, number], b: readonly [number, number]): number =>
984
+ (Math.atan2(sy(b[1]) - sy(a[1]), sx(b[0]) - sx(a[0])) * 180) / Math.PI;
985
+ const mid = (a: readonly [number, number], b: readonly [number, number]): [number, number] => [
986
+ (a[0] + b[0]) / 2,
987
+ (a[1] + b[1]) / 2,
988
+ ];
989
+ const out: Record<string, number> = {};
990
+ if (B.has('chest') && B.has('hip') && B.has('sleeve_r0') && B.has('sleeve_l0')) {
991
+ const hip = B.get('hip') as { x: number; y: number };
992
+ const sr0 = B.get('sleeve_r0') as { x: number; y: number };
993
+ const sl0 = B.get('sleeve_l0') as { x: number; y: number };
994
+ const setupTrunkAim = deg([hip.x, hip.y], mid([sr0.x, sr0.y], [sl0.x, sl0.y]));
995
+ const altTrunkAim = deg(mid(pts.r_hip, pts.l_hip), mid(pts.r_shoulder, pts.l_shoulder));
996
+ out.chest = places(normaliseDegrees((turn.get('chest') ?? 0) + (altTrunkAim - setupTrunkAim)));
997
+ }
998
+ if (B.has('head') && B.has('eye_r') && B.has('eye_l')) {
999
+ const er = B.get('eye_r') as { x: number; y: number };
1000
+ const el = B.get('eye_l') as { x: number; y: number };
1001
+ const setupEyeAim = deg([er.x, er.y], [el.x, el.y]);
1002
+ const altEyeAim = deg(pts.r_eye, pts.l_eye);
1003
+ out.head = places(normaliseDegrees((turn.get('head') ?? 0) + (altEyeAim - setupEyeAim)));
1004
+ }
1005
+ const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
1006
+ ['sleeve_r0', pts.r_shoulder, pts.r_elbow],
1007
+ ['sleeve_r1', pts.r_elbow, pts.r_wrist],
1008
+ ['sleeve_l0', pts.l_shoulder, pts.l_elbow],
1009
+ ['sleeve_l1', pts.l_elbow, pts.l_wrist],
1010
+ ['leg_r0', pts.r_hip, pts.r_knee],
1011
+ ['leg_r1', pts.r_knee, pts.r_ankle],
1012
+ ['leg_l0', pts.l_hip, pts.l_knee],
1013
+ ['leg_l1', pts.l_knee, pts.l_ankle],
1014
+ ];
1015
+ for (const [bone, a, b] of limbPairs) {
1016
+ if (B.has(bone)) out[bone] = places(normaliseDegrees(deg(a, b)));
1017
+ }
1018
+ for (const foot of ['foot_r', 'foot_l'] as const) {
1019
+ if (B.has(foot)) out[foot] = places(normaliseDegrees(turn.get(foot) ?? 0));
1020
+ }
1021
+ return out;
1022
+ }
1023
+
936
1024
  /**
937
1025
  * `RIG_BLINK_HOLD_SPANS_A_FRAME` (issue #32): the eyes' hold must put an
938
1026
  * idle frame inside the closed window for every `blink.t`, or the idle
@@ -1982,15 +2070,51 @@ export function buildRig(
1982
2070
  });
1983
2071
  }
1984
2072
  refuseIfAny(problems);
1985
- // ---- alternate painting sets (issue #454) -------------------------------
2073
+ // ---- alternate painting sets (issues #454, #465) ------------------------
1986
2074
  // Only when `parts.alternates` is present: each alternate set's `<set>_<part>`
1987
2075
  // attachments are added to `skins.default[<part>]` on the existing bones and
1988
2076
  // slots before `applyStops` and `applyTurn`, so stopped links and turn twins
1989
- // carry the alternate attachments alongside the base ones.
2077
+ // carry the alternate attachments alongside the base ones. When the alternate
2078
+ // carries a skeleton read (`alt.skeleton`) or explicit `drawn` angles, its
2079
+ // attachments are bound in the posed bone frames (`altWorld`) and `sets.json`
2080
+ // records `drawn` so a retarget does not pose the art a second time.
1990
2081
  let sets: AttachmentSetsFile | undefined;
1991
2082
  if (parts.alternates !== undefined && parts.alternates.length > 0) {
1992
2083
  const setEntries: Record<string, AlternateSetMap> = {};
1993
- for (const alt of parts.alternates) {
2084
+ for (const [altIdx, alt] of parts.alternates.entries()) {
2085
+ const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
2086
+ const explicitDrawn = alt.drawn ?? cfgAlt?.drawn;
2087
+ if (explicitDrawn !== undefined) {
2088
+ for (const boneName of Object.keys(explicitDrawn)) {
2089
+ if (!B.has(boneName)) {
2090
+ fail('RIG_NAME_RESOLVES', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
2091
+ }
2092
+ }
2093
+ }
2094
+ refuseIfAny(problems);
2095
+ const derived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
2096
+ const drawn: Record<string, number> | undefined =
2097
+ derived === undefined && explicitDrawn === undefined
2098
+ ? undefined
2099
+ : {
2100
+ ...(derived ?? {}),
2101
+ ...Object.fromEntries(Object.entries(explicitDrawn ?? {}).map(([k, v]) => [k, places(normaliseDegrees(v))])),
2102
+ };
2103
+ const posedBones = drawn === undefined ? null : poseRigBonesForDrawn(bones, drawn);
2104
+ const altWorld = posedBones === null ? world : computeExactFrameTransforms(posedBones);
2105
+ const altTurn = new Map<string, number>();
2106
+ if (posedBones !== null) {
2107
+ for (const b of posedBones) {
2108
+ altTurn.set(b.name, places(normaliseDegrees((altWorld.get(b.name) as BoneTransform).worldRotation)));
2109
+ }
2110
+ } else {
2111
+ for (const [k, v] of turn) altTurn.set(k, v);
2112
+ }
2113
+ const altRegion = (image: string, bone: string, cx: number, cy: number): RegionAttachment => {
2114
+ const [x, y] = toBoneLocal(altWorld.get(bone) as BoneTransform, spineX(cx), spineY(cy));
2115
+ const deg = altTurn.get(bone) as number;
2116
+ return deg === 0 ? { image, x: places(x), y: places(y) } : { image, x: places(x), y: places(y), rotation: places(normaliseDegrees(-deg)) };
2117
+ };
1994
2118
  for (const p of alt.records) {
1995
2119
  const attName = `${alt.name}_${p.name}`;
1996
2120
  const rawImg = images.get(attName);
@@ -2001,14 +2125,45 @@ export function buildRig(
2001
2125
  const mesh = cfg.meshes[p.name];
2002
2126
  if (mesh === undefined) {
2003
2127
  const bone = B.get(cfg.regions[p.name]) as Bone;
2004
- skin[p.name][attName] = region(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
2128
+ skin[p.name][attName] = altRegion(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
2005
2129
  continue;
2006
2130
  }
2007
2131
  const fullSegs = meshSegments.get(p.name) as Segment[];
2008
2132
  const baseAtt = skin[p.name]?.[p.name];
2009
2133
  const baseBones = baseAtt !== undefined && 'type' in baseAtt ? new Set(baseAtt.weights.flatMap((v) => v.map((e) => e.bone))) : null;
2010
2134
  const filteredSegs = baseBones !== null && baseBones.size > 0 ? fullSegs.filter((s) => baseBones.has(s.bone)) : fullSegs;
2011
- const segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
2135
+ let segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
2136
+ if (drawn !== undefined) {
2137
+ const movePt = (bone: string, pt0: Point): Point => {
2138
+ const m0 = world.get(bone) as BoneTransform;
2139
+ const m1 = altWorld.get(bone) as BoneTransform;
2140
+ const [lx, ly] = toBoneLocal(m0, spineX(pt0[0]), spineY(pt0[1]));
2141
+ const [wx, wy] = toWorld(m1, lx, ly);
2142
+ return [wx + CX, cropToSpineY(wy, H)];
2143
+ };
2144
+ const pt = (q: readonly [number, number]): Point => [q[0], q[1]];
2145
+ const skSegs: Record<string, [Point, Point]> | null =
2146
+ alt.skeleton === undefined
2147
+ ? null
2148
+ : {
2149
+ sleeve_r0: [pt(alt.skeleton.r_shoulder), pt(alt.skeleton.r_elbow)],
2150
+ sleeve_r1: [pt(alt.skeleton.r_elbow), pt(alt.skeleton.r_wrist)],
2151
+ sleeve_l0: [pt(alt.skeleton.l_shoulder), pt(alt.skeleton.l_elbow)],
2152
+ sleeve_l1: [pt(alt.skeleton.l_elbow), pt(alt.skeleton.l_wrist)],
2153
+ leg_r0: [pt(alt.skeleton.r_hip), pt(alt.skeleton.r_knee)],
2154
+ leg_r1: [pt(alt.skeleton.r_knee), pt(alt.skeleton.r_ankle)],
2155
+ leg_l0: [pt(alt.skeleton.l_hip), pt(alt.skeleton.l_knee)],
2156
+ leg_l1: [pt(alt.skeleton.l_knee), pt(alt.skeleton.l_ankle)],
2157
+ };
2158
+ segs = segs.map((s) => {
2159
+ const skPair = skSegs?.[s.bone];
2160
+ return skPair !== undefined ? { bone: s.bone, a: skPair[0], b: skPair[1] } : { bone: s.bone, a: movePt(s.bone, s.a), b: movePt(s.bone, s.b) };
2161
+ });
2162
+ const nonSkirt = segs.filter((s) => !hipChainLinks.has(s.bone));
2163
+ if (nonSkirt.length > 0) segs = nonSkirt;
2164
+ const yOverlapping = segs.filter((s) => Math.max(s.a[1], s.b[1]) >= p.y && Math.min(s.a[1], s.b[1]) <= p.y + p.h);
2165
+ segs = yOverlapping.length > 0 ? yOverlapping : [segs[0]];
2166
+ }
2012
2167
  const allArt = alphaAbove(img, ART_ALPHA);
2013
2168
  const strayVal = 'auto' in mesh ? mesh.auto.source.stray : ('contour' in mesh ? mesh.contour.stray : mesh.stray);
2014
2169
  const stray = strayVal === undefined ? null : latticeStray(allArt, strayVal);
@@ -2017,7 +2172,7 @@ export function buildRig(
2017
2172
  const r = 'r' in mesh ? mesh.r : 80;
2018
2173
  const lm = latticeMesh(attName, art, grid, maxLoopPasses);
2019
2174
  if ('code' in lm) {
2020
- skin[p.name][attName] = region(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
2175
+ skin[p.name][attName] = altRegion(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
2021
2176
  continue;
2022
2177
  }
2023
2178
  const ox = p.x - PAD;
@@ -2027,6 +2182,22 @@ export function buildRig(
2027
2182
  const limits: InfluenceLimits = meshLimits('influences' in mesh ? mesh.influences : undefined);
2028
2183
  const segsAt = dressSegsAt(p, segs);
2029
2184
  const exponent = mesh.exponent ?? DISTANCE_EXPONENT;
2185
+ let touchesArt: boolean[] | null = null;
2186
+ if (drawn !== undefined && limits.maxInfluences === 1) {
2187
+ const lc = latticeCells(art, grid);
2188
+ touchesArt = lm.vertices.map(([vx0, vy0]) => {
2189
+ const ii = Math.round(vx0 / grid);
2190
+ const jj = Math.round(vy0 / grid);
2191
+ for (const di of [-1, 0]) {
2192
+ for (const dj of [-1, 0]) {
2193
+ const ci = ii + di;
2194
+ const cj = jj + dj;
2195
+ if (ci >= 0 && ci < lc.nx && cj >= 0 && cj < lc.ny && lc.cells.data[cj * lc.nx + ci] === 1) return true;
2196
+ }
2197
+ }
2198
+ return false;
2199
+ });
2200
+ }
2030
2201
  const rawShares: Influence[][] = [];
2031
2202
  for (const [vx0, vy0] of lm.vertices) {
2032
2203
  const wx = vx0 + ox;
@@ -2038,12 +2209,26 @@ export function buildRig(
2038
2209
  }
2039
2210
  rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
2040
2211
  }
2212
+ if (touchesArt !== null) {
2213
+ let firstArtBone: string | null = null;
2214
+ for (let vi = 0; vi < lm.vertices.length; vi++) {
2215
+ if (touchesArt[vi] && rawShares[vi].length > 0) {
2216
+ firstArtBone = rawShares[vi][0].bone;
2217
+ break;
2218
+ }
2219
+ }
2220
+ if (firstArtBone !== null) {
2221
+ for (let vi = 0; vi < lm.vertices.length; vi++) {
2222
+ if (!touchesArt[vi]) rawShares[vi] = [{ bone: firstArtBone, weight: 1 }];
2223
+ }
2224
+ }
2225
+ }
2041
2226
  const weights: WeightEntry[][] = [];
2042
2227
  for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
2043
2228
  const wx = vx0 + ox;
2044
2229
  const wy = vy0 + oy;
2045
2230
  const ent: WeightEntry[] = rawShares[vi].map(({ bone, weight }) => {
2046
- const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
2231
+ const [x, y] = toBoneLocal(altWorld.get(bone) as BoneTransform, spineX(wx), spineY(wy));
2047
2232
  return { bone, x: places(x), y: places(y), weight };
2048
2233
  });
2049
2234
  weights.push(ent);
@@ -2065,6 +2250,7 @@ export function buildRig(
2065
2250
  setEntries[alt.name] = {
2066
2251
  slots: slotMap,
2067
2252
  ...(hideList.length > 0 ? { hides: hideList } : {}),
2253
+ ...(drawn === undefined ? {} : { drawn }),
2068
2254
  };
2069
2255
  }
2070
2256
  sets = { spec: SETS_SPEC, sets: setEntries };