rig-parts 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/AUTHORING.md CHANGED
@@ -108,7 +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, #472, #473, #476): `[{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`, `.drawn` when present, and `.threshold: {yaw: 15, pitch: 12}` when the set replaces `face` on a rig with `turn` or `turn`/`tilt` slider animations). `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`); a **subset set** (issue #473, e.g. mouth visemes `a`, `i`, `u` with `"parts": ["mouth"]`) cuts only its covered parts from the alternate runs (refusing with `ASSEMBLE_ALT_INPUT_PRESENT` if a covered part's layer is missing or empty), writes only `{slot: "<set>_<slot>"}` in `sets.json` so `@rig-c/play`'s `setSet(name)` switches those slots while leaving every other slot on its base attachment (and `setSet(null)` restores the base pose), may be keyed on a slot attachment track in `motion.animations_from` as `{"t": …, "set": "<name>" \| null}` (`CONFIG_SET_COVERS_SLOT` if `"set"` is undeclared or does not cover the track's slot), and under `check --set <name>` counts `ANIMATIONS_DO_NOT_FOLD` only on its own covered slots; `hides` lists slots the set hides (`null` in `sets.json`). A **gesture subset set** (issue #476) covers a limb chain declared in `config.limbs.<limb>` (all of its segment `part`s, cut at the base's joints posed to the set's own `drawn` angles by `poseBoneEntriesForDrawn`; naming the unsplit `from` layer, covering only some segments of the limb, or declaring polygon `cuts` on a limb set is refused with `CONFIG_LIMB_SET_JOINTS`) and carries `drawn` (or `skeleton`) for the covered bones only (giving `drawn` for an uncovered bone is refused with `CONFIG_SET_COVERS_BONE` / `RIG_SET_COVERS_BONE`; uncovered bones keep the base's `drawn`). `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 (filtered to covered bones on a subset set, 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
+ | `assemble.alternates` | assemble, rig, check (`--set`, `--all-sets`) | **authored**, optional (issues #454, #465, #472, #473, #476): `[{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`, `.drawn` when present, and `.threshold: {yaw: 15, pitch: 12}` when the set replaces `face` on a rig with `turn` or `turn`/`tilt` slider animations). `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`); a **subset set** (issue #473, e.g. mouth visemes `a`, `i`, `u` with `"parts": ["mouth"]`) cuts only its covered parts from the alternate runs (refusing with `ASSEMBLE_ALT_INPUT_PRESENT` if a covered part's layer is missing or empty), writes only `{slot: "<set>_<slot>"}` in `sets.json` so `@rig-c/play`'s `setSet(name)` switches those slots while leaving every other slot on its base attachment (and `setSet(null)` restores the base pose), may be keyed on a slot attachment track in `motion.animations_from` as `{"t": …, "set": "<name>" \| null}` (`CONFIG_SET_COVERS_SLOT` if `"set"` is undeclared or does not cover the track's slot), and under `check --set <name>` counts `ANIMATIONS_DO_NOT_FOLD` only on its own covered slots; `hides` lists slots the set hides (`null` in `sets.json`). A **gesture subset set** (issue #476) covers a limb chain declared in `config.limbs.<limb>` (all of its segment `part`s; with `drawn` alone, cut at the base's joints posed to the set's own `drawn` angles by `poseBoneEntriesForDrawn`, each segment keeping the base's length; with `skeleton` (issue #539), cut at the skeleton's own joint positions by `placeBoneEntriesForSkeleton` — each covered bone the body-18 table names (`sleeve_*`, `arm_*0`/`arm_*1`, `leg_*0`/`leg_*1`) at its skeleton origin with the skeleton segment's own length and direction (`drawn` overriding the direction of a bone it names), and a covered bone the table does not name (a hand link `arm_r2`, a single `foot_r`) placed from its parent's new place — its base offset from the parent, stretched along the parent by the parent's length change and turned by its turn, so a link or a foot at the parent's end lands on the skeleton's end — with its base length and the drawn or base direction, so a foreshortened shin or forearm is cut where it is painted; naming the unsplit `from` layer, covering only some segments of the limb, or declaring polygon `cuts` on a limb set is refused with `CONFIG_LIMB_SET_JOINTS`) and carries `drawn` (or `skeleton`) for the covered bones only (giving `drawn` for an uncovered bone is refused with `CONFIG_SET_COVERS_BONE` / `RIG_SET_COVERS_BONE`; uncovered bones keep the base's `drawn`). `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 (filtered to covered bones on a subset set, 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, when the skeleton placed the set's limb bones (issue #539), `drawnLength` (`{<bone>: <ratio>}`, each covered table bone's skeleton segment length over its base length, 6 places) and `registration` (`{offset: [dx, dy], scale}`: the similarity without rotation, about the canvas origin, that maps the skeleton's torso — the mean of its shoulders `r_shoulder`/`l_shoulder` to the mean of its hips `r_hip`/`l_hip`, read where the base declares the bone rooted there, `sleeve_*0`/`arm_*0` and `leg_*0` — onto those base bones' origins, 6 places), both also written to `sets.json`; the set's painting must be registered, its torso within 1 rig px and 1 % in scale of the base's, or the set is refused by name before any joint is cut (`ASSEMBLE_ALT_SKELETON_REGISTERED`, with the offset and scale the painting would need) — 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 |
112
112
  | `assemble.head_face_underfill` | assemble, `assemble --propose-plan` | **authored**, optional (issue #492): `boolean` (`CONFIG_FIELD_TYPE` on any other). When both runs exist and `full:face` has at least `200` opaque run px (`PROPOSE_MIN_PX`), the stage maps `full:face` into the head canvas via `fullLayerToHead` and measures how much of its hole-filled silhouette (`alpha > 8`) is covered by `head:face` (`alpha > 8`). A head-run `face` covering less than `75 %` (`HEAD_FACE_COVERAGE_FLOOR`, measured `0.0 %..48.6 %` on hollow/omitted head runs vs `84.8 %..99.6 %` on clean head runs across the 4 public examples and 9-standee corpus) is refused with `SEETHROUGH_HEAD_RUN_HOLLOW` unless `head_face_underfill: true` is declared. When `head_face_underfill: true` is declared and `head:face` exists (`opaqueRunPx >= 200`), `fullLayerToHead(full:face)` is composited under `head:face` on the head canvas (`alpha = max(headAlpha, fullAlpha)`, RGB blended by `headAlpha / 255`) before `head:face` is placed in rig space (`underfill=<N>px(full:face)` on the `face` part log line and a `face: head_face_underfill composited …` note in `assemble --propose-plan`). The fill restores transparent pixels only and leaves the cut's own edge where it is — a hollow whose edge is not under hair stays visible, and that is the author's to fix at the cut. When `head:face` has fewer than `200` opaque pixels, `SEETHROUGH_HEAD_RUN_HOLLOW` still fires even under `head_face_underfill: true` because the head run omitted the face layer and cannot supply a jawline |
113
113
  | `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 |
114
114
  | `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 |
@@ -2835,6 +2835,7 @@ says why each requirement exists and what breaks without it.
2835
2835
  | <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` |
2836
2836
  | <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 |
2837
2837
  | <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 |
2838
+ | <a id="ASSEMBLE_ALT_SKELETON_REGISTERED"></a>`ASSEMBLE_ALT_SKELETON_REGISTERED` | an alternate set whose `skeleton` places its limb bones (issue #539) is not registered on the base: the similarity without rotation that maps the skeleton's torso (the mean of its shoulders to the mean of its hips, where the base declares `sleeve_*0`/`arm_*0` and `leg_*0`) onto those base bones' origins is more than 1 rig px off (the offset's length) or more than 1 % off in scale; the detail gives the offset `[dx, dy]` rig px and the scale the painting would need (`p -> scale * p + offset`, about the canvas origin) and the bones read. Also when the base declares no bone at one end of the torso, or the skeleton's torso has no length. Refused before any joint is cut | the set's painting and skeleton, registered: the figure at the base figure's torso place and scale on the same canvas (`ASSEMBLE_ALT_CANVAS_SIZE` forbids another canvas); or, on a base with no torso bones, `drawn` in place of `skeleton` |
2838
2839
  | <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 |
2839
2840
 
2840
2841
  ### propose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.44.0",
3
+ "version": "1.45.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
@@ -78,7 +78,7 @@ import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
- import { poseBoneEntriesForDrawn } from './limbs.ts';
81
+ import { placeBoneEntriesForSkeleton, poseBoneEntriesForDrawn, REGISTER_OFFSET_BAR, REGISTER_SCALE_BAR, registeredWithinBar, registerSkeleton, SKELETON_LIMB_BONES, skeletonDrawnLength, skeletonLimbJoints } from './limbs.ts';
82
82
  import { type AlternatePartsRecord, type PartRecord, readParts, type RecompositeRecord, type SingleRunPad, tagOf, writeParts } from './parts.ts';
83
83
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
84
84
  import type { Raster } from './raster/types.ts';
@@ -150,6 +150,14 @@ export interface AssembleStageInput {
150
150
  project: ProjectRule;
151
151
  /** `--alt` entries, merged with `config.assemble.alternates` by set name (issue #454). */
152
152
  alts?: readonly AlternateCliInput[];
153
+ /**
154
+ * How an alternate set with a `skeleton` is cut (issue #539): `"skeleton"`
155
+ * (the default, the only one the CLI runs) at the skeleton's own joint
156
+ * positions, registered on the base's torso; `"angles"` at the base's joints
157
+ * posed to the skeleton's angles, the rule before #539 — kept for the
158
+ * selftest's control that shows a foreshortened segment refused under it.
159
+ */
160
+ altPlacement?: 'skeleton' | 'angles';
153
161
  }
154
162
 
155
163
  /** Where the assemble stage writes: `parts.json`, the directory of part PNGs, the recomposite, and its error map. */
@@ -446,34 +454,52 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
446
454
  const altUnder = alt.cuts !== undefined ? readUnders(input.config, altCuts) : altCuts.map(() => null);
447
455
  const altHeadBox = alt.head_box ?? (altRuns.single ? singleRunHeadBox(altSrc.width, altSrc.height, fields.resolution) : fields.headBox);
448
456
  let altBones = fields.bones;
457
+ let altDrawnLength: Record<string, number> | undefined;
458
+ let altRegistration: { offset: [number, number]; scale: number } | undefined;
449
459
  if (Object.keys(altLimbs).length > 0 && fields.bones !== undefined && (altSkeleton !== undefined || alt.drawn !== undefined)) {
450
460
  const coveredLimbBones = new Set(Object.values(altLimbs).flatMap((l) => l.segments.map((sg) => sg.bone)));
451
461
  const limbDrawn: Record<string, number> = {};
462
+ const joints = altSkeleton === undefined ? [] : skeletonLimbJoints(altSkeleton);
452
463
  if (altSkeleton !== undefined) {
453
464
  const deg = (a: readonly [number, number], b: readonly [number, number]): number =>
454
465
  (Math.atan2(-(b[1] - a[1]), b[0] - a[0]) * 180) / Math.PI;
455
- const skPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
456
- ['sleeve_r0', altSkeleton.r_shoulder, altSkeleton.r_elbow],
457
- ['sleeve_r1', altSkeleton.r_elbow, altSkeleton.r_wrist],
458
- ['arm_r0', altSkeleton.r_shoulder, altSkeleton.r_elbow],
459
- ['arm_r1', altSkeleton.r_elbow, altSkeleton.r_wrist],
460
- ['sleeve_l0', altSkeleton.l_shoulder, altSkeleton.l_elbow],
461
- ['sleeve_l1', altSkeleton.l_elbow, altSkeleton.l_wrist],
462
- ['arm_l0', altSkeleton.l_shoulder, altSkeleton.l_elbow],
463
- ['arm_l1', altSkeleton.l_elbow, altSkeleton.l_wrist],
464
- ['leg_r0', altSkeleton.r_hip, altSkeleton.r_knee],
465
- ['leg_r1', altSkeleton.r_knee, altSkeleton.r_ankle],
466
- ['leg_l0', altSkeleton.l_hip, altSkeleton.l_knee],
467
- ['leg_l1', altSkeleton.l_knee, altSkeleton.l_ankle],
468
- ];
469
- for (const [bName, a, b] of skPairs) {
466
+ for (const [bName, a, b] of joints) {
470
467
  if (alt.parts === undefined || coveredLimbBones.has(bName)) limbDrawn[bName] = deg(a, b);
471
468
  }
472
469
  }
473
470
  if (alt.drawn !== undefined) {
474
471
  for (const [bName, degVal] of Object.entries(alt.drawn)) limbDrawn[bName] = degVal;
475
472
  }
476
- altBones = poseBoneEntriesForDrawn(fields.bones, limbDrawn);
473
+ const placedBySkeleton = altSkeleton !== undefined && input.altPlacement !== 'angles' && SKELETON_LIMB_BONES.some(([b]) => coveredLimbBones.has(b));
474
+ if (placedBySkeleton && altSkeleton !== undefined) {
475
+ // Issue #539: the painting must sit where the base figure sits before its own joints are cut at.
476
+ const reg = registerSkeleton(fields.bones, altSkeleton);
477
+ if (!reg.ok) {
478
+ const read = reg.anchors.map((a) => `${a.keypoint} on "${a.bone}"`).join(', ') || 'none';
479
+ altProblems.push({
480
+ code: 'ASSEMBLE_ALT_SKELETON_REGISTERED',
481
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
482
+ detail:
483
+ reg.missing.length > 0
484
+ ? `cannot be registered on the base's torso: config.bones declares no bone rooted at the torso's ${reg.missing.join(' or ')} (the shoulders r_shoulder / l_shoulder root sleeve_r0, arm_r0, sleeve_l0, arm_l0; the hips r_hip / l_hip root leg_r0, leg_l0; read: ${read}); a base that declares a bone at each end of the torso is required, or drawn in place of skeleton`
485
+ : `cannot be registered on the base's torso: its torso keypoints (${read}) stand on one point, so the torso has no length; a skeleton whose shoulders and hips are apart is required`,
486
+ });
487
+ continue;
488
+ }
489
+ if (!registeredWithinBar(reg)) {
490
+ altProblems.push({
491
+ code: 'ASSEMBLE_ALT_SKELETON_REGISTERED',
492
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
493
+ detail: `puts the set's torso off the base's: the painting would need offset [${reg.offset.join(', ')}] rig px and scale ${reg.scale} (p -> scale * p + offset, about the canvas origin; read on ${reg.anchors.map((a) => `${a.keypoint} = "${a.bone}"`).join(', ')}), over the bar of ${REGISTER_OFFSET_BAR} rig px and ${REGISTER_SCALE_BAR * 100} % in scale; a registered painting is required — the set's figure at the base figure's torso place and scale on the same canvas`,
494
+ });
495
+ continue;
496
+ }
497
+ altRegistration = { offset: [reg.offset[0], reg.offset[1]], scale: reg.scale };
498
+ altDrawnLength = skeletonDrawnLength(fields.bones, coveredLimbBones, joints);
499
+ altBones = placeBoneEntriesForSkeleton(fields.bones, coveredLimbBones, joints, limbDrawn);
500
+ } else {
501
+ altBones = poseBoneEntriesForDrawn(fields.bones, limbDrawn);
502
+ }
477
503
  }
478
504
  let altRes: ReturnType<typeof assemble>;
479
505
  try {
@@ -538,6 +564,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
538
564
  ...(altRes.parts.single_run_pad === undefined ? {} : { single_run_pad: altRes.parts.single_run_pad }),
539
565
  ...(altSkeleton === undefined ? {} : { skeleton: altSkeleton }),
540
566
  ...(alt.drawn === undefined ? {} : { drawn: { ...alt.drawn } }),
567
+ ...(altDrawnLength === undefined ? {} : { drawnLength: altDrawnLength }),
568
+ ...(altRegistration === undefined ? {} : { registration: altRegistration }),
541
569
  },
542
570
  images: keptImages,
543
571
  });
package/src/limbs.ts CHANGED
@@ -105,7 +105,9 @@
105
105
  * crossing.
106
106
  */
107
107
  import type { BoneEntry, Limb, LimbSplit } from './config.ts';
108
+ import { edgeLength } from './length.ts';
108
109
  import { pyRound } from './round.ts';
110
+ import type { KeypointName, PosePoints } from './skeleton.ts';
109
111
 
110
112
  export type Pt = [number, number];
111
113
 
@@ -218,6 +220,248 @@ export function poseBoneEntriesForDrawn(bones: readonly BoneEntry[], drawn: Read
218
220
  });
219
221
  }
220
222
 
223
+ /**
224
+ * The limb bones a body-18 skeleton places (issues #465, #539): each bone's
225
+ * origin keypoint and end keypoint. The one table `assemble` cuts an
226
+ * alternate set at and the `rig` stage derives and weights from; a bone not
227
+ * named here (a hand link `arm_r2`, a single `foot_r`) is placed from its
228
+ * parent ({@link placeBoneEntriesForSkeleton}).
229
+ */
230
+ export const SKELETON_LIMB_BONES: ReadonlyArray<readonly [string, KeypointName, KeypointName]> = [
231
+ ['sleeve_r0', 'r_shoulder', 'r_elbow'],
232
+ ['sleeve_r1', 'r_elbow', 'r_wrist'],
233
+ ['arm_r0', 'r_shoulder', 'r_elbow'],
234
+ ['arm_r1', 'r_elbow', 'r_wrist'],
235
+ ['sleeve_l0', 'l_shoulder', 'l_elbow'],
236
+ ['sleeve_l1', 'l_elbow', 'l_wrist'],
237
+ ['arm_l0', 'l_shoulder', 'l_elbow'],
238
+ ['arm_l1', 'l_elbow', 'l_wrist'],
239
+ ['leg_r0', 'r_hip', 'r_knee'],
240
+ ['leg_r1', 'r_knee', 'r_ankle'],
241
+ ['leg_l0', 'l_hip', 'l_knee'],
242
+ ['leg_l1', 'l_knee', 'l_ankle'],
243
+ ];
244
+
245
+ /** {@link SKELETON_LIMB_BONES} read off `pts`: each bone's origin and end, in `pts`' space, in the table's order. */
246
+ export function skeletonLimbJoints(pts: PosePoints): Array<readonly [string, Pt, Pt]> {
247
+ return SKELETON_LIMB_BONES.map(([bone, a, b]): readonly [string, Pt, Pt] => [bone, [pts[a][0], pts[a][1]], [pts[b][0], pts[b][1]]]);
248
+ }
249
+
250
+ /**
251
+ * Issue #539: the torso a set's skeleton is registered by — the keypoints at
252
+ * the root of the limbs, each paired with the base bones that root there in
253
+ * {@link SKELETON_LIMB_BONES} (the first the base declares is read). The
254
+ * shoulders are the torso's top, the hips its bottom.
255
+ */
256
+ export const SKELETON_TORSO: ReadonlyArray<{ keypoint: KeypointName; end: 'top' | 'bottom'; bones: readonly string[] }> = [
257
+ { keypoint: 'r_shoulder', end: 'top', bones: ['sleeve_r0', 'arm_r0'] },
258
+ { keypoint: 'l_shoulder', end: 'top', bones: ['sleeve_l0', 'arm_l0'] },
259
+ { keypoint: 'r_hip', end: 'bottom', bones: ['leg_r0'] },
260
+ { keypoint: 'l_hip', end: 'bottom', bones: ['leg_l0'] },
261
+ ];
262
+
263
+ /** Issue #539: the most a registered skeleton's torso may sit off the base's, in rig px (the offset's length). */
264
+ export const REGISTER_OFFSET_BAR = 1;
265
+ /** Issue #539: the most a registered skeleton's torso may differ from the base's in scale, as a share (`|s - 1|`). */
266
+ export const REGISTER_SCALE_BAR = 0.01;
267
+
268
+ /** Issue #539: how a set's skeleton sits on the base's torso, or which torso ends the base declares no bone for. */
269
+ export type SkeletonRegistration =
270
+ | { ok: true; offset: Pt; scale: number; anchors: Array<{ keypoint: KeypointName; bone: string }> }
271
+ | { ok: false; missing: Array<'top' | 'bottom'>; anchors: Array<{ keypoint: KeypointName; bone: string }> };
272
+
273
+ /**
274
+ * Issue #539: the similarity without rotation that maps a set's skeleton
275
+ * torso onto the base's, both in rig px — `p -> scale * p + offset`, about the
276
+ * canvas origin, the transform the set's painting would need to sit where the
277
+ * base figure sits. Each end of the torso is the mean of its
278
+ * {@link SKELETON_TORSO} keypoints whose base bone `bones` declares, the
279
+ * base's the mean of those bones' origins; `scale` is the base torso's length
280
+ * over the skeleton's, and `offset` takes the skeleton torso's midpoint, so
281
+ * scaled, onto the base's. Both to 6 places. Not ok when the base declares no
282
+ * bone at one end, or the skeleton's torso has no length.
283
+ */
284
+ export function registerSkeleton(bones: readonly BoneEntry[], pts: PosePoints): SkeletonRegistration {
285
+ const places = bonePlaces(bones);
286
+ const anchors: Array<{ keypoint: KeypointName; bone: string; end: 'top' | 'bottom' }> = [];
287
+ for (const t of SKELETON_TORSO) {
288
+ const bone = t.bones.find((b) => places.has(b));
289
+ if (bone !== undefined) anchors.push({ keypoint: t.keypoint, bone, end: t.end });
290
+ }
291
+ const named = anchors.map((a) => ({ keypoint: a.keypoint, bone: a.bone }));
292
+ const missing = (['top', 'bottom'] as const).filter((e) => !anchors.some((a) => a.end === e));
293
+ const mean = (end: 'top' | 'bottom', of: (a: { keypoint: KeypointName; bone: string }) => Pt): Pt => {
294
+ const at = anchors.filter((a) => a.end === end).map(of);
295
+ return [at.reduce((s, p) => s + p[0], 0) / at.length, at.reduce((s, p) => s + p[1], 0) / at.length];
296
+ };
297
+ if (missing.length > 0) return { ok: false, missing, anchors: named };
298
+ const base = (a: { bone: string }): Pt => (places.get(a.bone) as BonePlace).at;
299
+ const skel = (a: { keypoint: KeypointName }): Pt => [pts[a.keypoint][0], pts[a.keypoint][1]];
300
+ const bTop = mean('top', base);
301
+ const bBot = mean('bottom', base);
302
+ const kTop = mean('top', skel);
303
+ const kBot = mean('bottom', skel);
304
+ const kLen = edgeLength(kTop[0] - kBot[0], kTop[1] - kBot[1]);
305
+ if (kLen === 0) return { ok: false, missing: [], anchors: named };
306
+ const scale = edgeLength(bTop[0] - bBot[0], bTop[1] - bBot[1]) / kLen;
307
+ const offset: Pt = [(bTop[0] + bBot[0]) / 2 - (scale * (kTop[0] + kBot[0])) / 2, (bTop[1] + bBot[1]) / 2 - (scale * (kTop[1] + kBot[1])) / 2];
308
+ const zero = (v: number): number => (v === 0 ? 0 : v);
309
+ return { ok: true, offset: [zero(pyRound(offset[0], 6)), zero(pyRound(offset[1], 6))], scale: pyRound(scale, 6), anchors: named };
310
+ }
311
+
312
+ /** Whether a registration is the identity within {@link REGISTER_OFFSET_BAR} and {@link REGISTER_SCALE_BAR}. */
313
+ export function registeredWithinBar(r: { offset: Pt; scale: number }): boolean {
314
+ return edgeLength(r.offset[0], r.offset[1]) <= REGISTER_OFFSET_BAR && Math.abs(r.scale - 1) <= REGISTER_SCALE_BAR;
315
+ }
316
+
317
+ /**
318
+ * Issue #539: place `bones` (rig px, y down) where a set's skeleton draws
319
+ * them, so `cutLimbs` cuts the set's painting at the painting's own joints.
320
+ * Every bone in `covered` that `joints` names sits at its skeleton origin and
321
+ * takes the skeleton segment's own length and direction ({@link
322
+ * poseBoneEntriesForDrawn} keeps the base's length, which puts the next joint
323
+ * past a foreshortened segment's painted end); `drawn` overrides the
324
+ * direction of any bone it names, the length staying the skeleton's. Every
325
+ * other bone keeps its base entry unless an ancestor moved: then it is placed
326
+ * from its parent's new place — its base offset from the parent's origin,
327
+ * the part along the parent stretched by the parent's length change and the
328
+ * whole turned by the parent's turn (a chain's next link, or a `foot_r` at
329
+ * the ankle, lands on the parent's new end) — with its base length, and the
330
+ * parent's turn added to its direction unless it inherits no rotation, or
331
+ * `drawn`'s direction when named. With no length change this is
332
+ * {@link poseBoneEntriesForDrawn}'s rule for those bones.
333
+ */
334
+ export function placeBoneEntriesForSkeleton(
335
+ bones: readonly BoneEntry[],
336
+ covered: ReadonlySet<string>,
337
+ joints: ReadonlyArray<readonly [string, Pt, Pt]>,
338
+ drawn: Readonly<Record<string, number>>,
339
+ ): BoneEntry[] {
340
+ const fromSkeleton = new Map<string, readonly [Pt, Pt]>();
341
+ for (const [bone, a, b] of joints) if (covered.has(bone) && !fromSkeleton.has(bone)) fromSkeleton.set(bone, [a, b]);
342
+ if (fromSkeleton.size === 0) return poseBoneEntriesForDrawn(bones, drawn);
343
+ const flat = flatBones(bones);
344
+ const deg = (dx: number, dy: number): number => (Math.atan2(-dy, dx) * 180) / Math.PI;
345
+ const posed = new Map<string, { at: Pt; end: Pt | null; theta: number; length: number }>();
346
+ const solve = (name: string): { at: Pt; end: Pt | null; theta: number; length: number } => {
347
+ const cached = posed.get(name);
348
+ if (cached !== undefined) return cached;
349
+ const node = flat.get(name);
350
+ if (node === undefined) return { at: [0, 0], end: null, theta: 0, length: 0 };
351
+ let at: Pt = [node.at0[0], node.at0[1]];
352
+ let theta = node.theta0;
353
+ let length = node.length;
354
+ const sk = fromSkeleton.get(name);
355
+ if (sk !== undefined) {
356
+ at = [sk[0][0], sk[0][1]];
357
+ length = edgeLength(sk[1][0] - sk[0][0], sk[1][1] - sk[0][1]);
358
+ theta = drawn[name] !== undefined ? drawn[name] : deg(sk[1][0] - sk[0][0], sk[1][1] - sk[0][1]);
359
+ } else {
360
+ let dParent = 0;
361
+ const pNode = flat.get(node.parent);
362
+ if (pNode !== undefined) {
363
+ const pPosed = solve(node.parent);
364
+ dParent = pPosed.theta - pNode.theta0;
365
+ const stretch = pNode.length > 0 ? pPosed.length / pNode.length : 1;
366
+ const t0 = (pNode.theta0 * Math.PI) / 180;
367
+ const ux = Math.cos(t0);
368
+ const uy = -Math.sin(t0);
369
+ const dx0 = node.at0[0] - pNode.at0[0];
370
+ const dy0 = node.at0[1] - pNode.at0[1];
371
+ const along = (dx0 * ux + dy0 * uy) * (stretch - 1);
372
+ const ox = dx0 + along * ux;
373
+ const oy = dy0 + along * uy;
374
+ const rad = (dParent * Math.PI) / 180;
375
+ const cos = Math.cos(rad);
376
+ const sin = Math.sin(rad);
377
+ at = [pPosed.at[0] + ox * cos + oy * sin, pPosed.at[1] - ox * sin + oy * cos];
378
+ }
379
+ const inheritsRot = node.inherit !== 'noRotationOrReflection' && node.inherit !== 'onlyTranslation';
380
+ theta = drawn[name] !== undefined ? drawn[name] : node.theta0 + (inheritsRot ? dParent : 0);
381
+ }
382
+ let end: Pt | null = null;
383
+ if (node.end0 !== null) {
384
+ const rad = (theta * Math.PI) / 180;
385
+ end = sk !== undefined && drawn[name] === undefined ? [sk[1][0], sk[1][1]] : [at[0] + length * Math.cos(rad), at[1] - length * Math.sin(rad)];
386
+ }
387
+ const res = { at, end, theta, length };
388
+ posed.set(name, res);
389
+ return res;
390
+ };
391
+ for (const name of flat.keys()) solve(name);
392
+ return bones.map((b): BoneEntry => {
393
+ if ('chain' in b) {
394
+ const points = b.points.map((_, k): Pt => {
395
+ const s = solve(`${b.chain}${k}`);
396
+ return [s.at[0], s.at[1]];
397
+ });
398
+ const last = solve(`${b.chain}${b.points.length - 1}`);
399
+ const tip: Pt = last.end === null ? [b.tip[0], b.tip[1]] : [last.end[0], last.end[1]];
400
+ return { ...b, points, tip };
401
+ }
402
+ const s = solve(b.name);
403
+ return {
404
+ ...b,
405
+ at: [s.at[0], s.at[1]],
406
+ ...(b.tip === undefined || s.end === null ? {} : { tip: [s.end[0], s.end[1]] }),
407
+ };
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Issue #539: each covered bone's skeleton segment length over its base
413
+ * length (`sets.json` `drawnLength`), to 6 places, in {@link
414
+ * SKELETON_LIMB_BONES}' order; a bone the base gives no length is left out.
415
+ */
416
+ export function skeletonDrawnLength(bones: readonly BoneEntry[], covered: ReadonlySet<string>, joints: ReadonlyArray<readonly [string, Pt, Pt]>): Record<string, number> {
417
+ const places = bonePlaces(bones);
418
+ const out: Record<string, number> = {};
419
+ for (const [bone, a, b] of joints) {
420
+ if (!covered.has(bone) || bone in out) continue;
421
+ const p = places.get(bone);
422
+ if (p === undefined || p.end === null) continue;
423
+ const base = edgeLength(p.end[0] - p.at[0], p.end[1] - p.at[1]);
424
+ if (base === 0) continue;
425
+ out[bone] = pyRound(edgeLength(b[0] - a[0], b[1] - a[1]) / base, 6);
426
+ }
427
+ return out;
428
+ }
429
+
430
+ interface FlatNode {
431
+ name: string;
432
+ parent: string;
433
+ at0: Pt;
434
+ end0: Pt | null;
435
+ theta0: number;
436
+ length: number;
437
+ inherit?: string;
438
+ }
439
+
440
+ /** Every bone `bones` declares, chains flattened to their links: origin, end, world direction (Spine degrees) and length. */
441
+ function flatBones(bones: readonly BoneEntry[]): Map<string, FlatNode> {
442
+ const flat = new Map<string, FlatNode>();
443
+ for (const b of bones) {
444
+ if ('chain' in b) {
445
+ b.points.forEach((p, k) => {
446
+ const name = `${b.chain}${k}`;
447
+ const parent = k === 0 ? b.parent : `${b.chain}${k - 1}`;
448
+ const at0: Pt = [p[0], p[1]];
449
+ const end0: Pt = k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]];
450
+ const dx = end0[0] - at0[0];
451
+ const dy = end0[1] - at0[1];
452
+ flat.set(name, { name, parent, at0, end0, theta0: (Math.atan2(-dy, dx) * 180) / Math.PI, length: Math.sqrt(dx * dx + dy * dy), inherit: b.link_inherit?.[name] });
453
+ });
454
+ } else {
455
+ const at0: Pt = [b.at[0], b.at[1]];
456
+ const end0: Pt | null = b.tip === undefined ? null : [b.tip[0], b.tip[1]];
457
+ const dx = end0 === null ? 0 : end0[0] - at0[0];
458
+ const dy = end0 === null ? 0 : end0[1] - at0[1];
459
+ flat.set(b.name, { name: b.name, parent: b.parent, at0, end0, theta0: end0 === null ? 0 : (Math.atan2(-dy, dx) * 180) / Math.PI, length: end0 === null ? 0 : Math.sqrt(dx * dx + dy * dy), inherit: b.inherit });
460
+ }
461
+ }
462
+ return flat;
463
+ }
464
+
221
465
  /** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
222
466
  export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
223
467
  const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);
package/src/parts.ts CHANGED
@@ -246,6 +246,10 @@ export interface AlternateSetMap {
246
246
  slots: Record<string, string>;
247
247
  hides?: string[];
248
248
  drawn?: Record<string, number>;
249
+ /** Issue #539: each covered limb bone's skeleton length over its base length, when the set gives `skeleton`. */
250
+ drawnLength?: Record<string, number>;
251
+ /** Issue #539: the measured transform onto the base's torso (within the bar), when the set gives `skeleton`. */
252
+ registration?: { offset: [number, number]; scale: number };
249
253
  threshold?: { yaw: number; pitch: number };
250
254
  }
251
255
 
@@ -261,6 +265,10 @@ export interface AlternatePartsRecord {
261
265
  single_run_pad?: SingleRunPad;
262
266
  skeleton?: PosePoints;
263
267
  drawn?: Record<string, number>;
268
+ /** Issue #539: as `sets.json`'s; written only when `skeleton` placed the set's limb bones. */
269
+ drawnLength?: Record<string, number>;
270
+ /** Issue #539: as `sets.json`'s; written only when `skeleton` placed the set's limb bones. */
271
+ registration?: { offset: [number, number]; scale: number };
264
272
  }
265
273
 
266
274
  export interface PartsFile {
@@ -407,6 +415,8 @@ export function serializeParts(file: PartsFile): string {
407
415
  ...(a.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (a.single_run_pad as SingleRunPad)[k]])) }),
408
416
  ...(a.skeleton === undefined ? {} : { skeleton: Object.fromEntries(KEYPOINT_NAMES.map((k) => [k, (a.skeleton as PosePoints)[k]])) }),
409
417
  ...(a.drawn === undefined ? {} : { drawn: a.drawn }),
418
+ ...(a.drawnLength === undefined ? {} : { drawnLength: a.drawnLength }),
419
+ ...(a.registration === undefined ? {} : { registration: { offset: a.registration.offset, scale: a.registration.scale } }),
410
420
  })),
411
421
  }),
412
422
  };
package/src/rig.ts CHANGED
@@ -166,6 +166,7 @@ import {
166
166
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
167
167
  import { type LocalInfluence, localInfluences } from './localweights.ts';
168
168
  import { edgeLength } from './length.ts';
169
+ import { skeletonLimbJoints } from './limbs.ts';
169
170
  import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
170
171
  import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
171
172
  import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
@@ -1092,20 +1093,7 @@ export function deriveAlternateDrawn(
1092
1093
  const altEyeAim = deg(pts.r_eye, pts.l_eye);
1093
1094
  out.head = places(normaliseDegrees((turn.get('head') ?? 0) + (altEyeAim - setupEyeAim)));
1094
1095
  }
1095
- const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
1096
- ['sleeve_r0', pts.r_shoulder, pts.r_elbow],
1097
- ['sleeve_r1', pts.r_elbow, pts.r_wrist],
1098
- ['arm_r0', pts.r_shoulder, pts.r_elbow],
1099
- ['arm_r1', pts.r_elbow, pts.r_wrist],
1100
- ['sleeve_l0', pts.l_shoulder, pts.l_elbow],
1101
- ['sleeve_l1', pts.l_elbow, pts.l_wrist],
1102
- ['arm_l0', pts.l_shoulder, pts.l_elbow],
1103
- ['arm_l1', pts.l_elbow, pts.l_wrist],
1104
- ['leg_r0', pts.r_hip, pts.r_knee],
1105
- ['leg_r1', pts.r_knee, pts.r_ankle],
1106
- ['leg_l0', pts.l_hip, pts.l_knee],
1107
- ['leg_l1', pts.l_knee, pts.l_ankle],
1108
- ];
1096
+ const limbPairs = skeletonLimbJoints(pts);
1109
1097
  for (const [bone, a, b] of limbPairs) {
1110
1098
  if (B.has(bone)) out[bone] = places(normaliseDegrees(deg(a, b)));
1111
1099
  }
@@ -2379,21 +2367,10 @@ export function buildRig(
2379
2367
  return [wx + CX, cropToSpineY(wy, H)];
2380
2368
  };
2381
2369
  const pt = (q: readonly [number, number]): Point => [q[0], q[1]];
2382
- const skSegs: Record<string, [Point, Point]> | null =
2383
- alt.skeleton === undefined
2384
- ? null
2385
- : {
2386
- sleeve_r0: [pt(alt.skeleton.r_shoulder), pt(alt.skeleton.r_elbow)],
2387
- sleeve_r1: [pt(alt.skeleton.r_elbow), pt(alt.skeleton.r_wrist)],
2388
- sleeve_l0: [pt(alt.skeleton.l_shoulder), pt(alt.skeleton.l_elbow)],
2389
- sleeve_l1: [pt(alt.skeleton.l_elbow), pt(alt.skeleton.l_wrist)],
2390
- leg_r0: [pt(alt.skeleton.r_hip), pt(alt.skeleton.r_knee)],
2391
- leg_r1: [pt(alt.skeleton.r_knee), pt(alt.skeleton.r_ankle)],
2392
- leg_l0: [pt(alt.skeleton.l_hip), pt(alt.skeleton.l_knee)],
2393
- leg_l1: [pt(alt.skeleton.l_knee), pt(alt.skeleton.l_ankle)],
2394
- };
2370
+ const skSegs: Map<string, readonly [Point, Point]> | null =
2371
+ alt.skeleton === undefined ? null : new Map(skeletonLimbJoints(alt.skeleton).map(([bone, a, b]) => [bone, [pt(a), pt(b)] as const]));
2395
2372
  segs = segs.map((s) => {
2396
- const skPair = skSegs?.[s.bone];
2373
+ const skPair = skSegs?.get(s.bone);
2397
2374
  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) };
2398
2375
  });
2399
2376
  const nonSkirt = segs.filter((s) => !hipChainLinks.has(s.bone));
@@ -2489,6 +2466,8 @@ export function buildRig(
2489
2466
  slots: slotMap,
2490
2467
  ...(hideList.length > 0 ? { hides: hideList } : {}),
2491
2468
  ...(drawn === undefined ? {} : { drawn }),
2469
+ ...(alt.drawnLength === undefined ? {} : { drawnLength: { ...alt.drawnLength } }),
2470
+ ...(alt.registration === undefined ? {} : { registration: { offset: [alt.registration.offset[0], alt.registration.offset[1]], scale: alt.registration.scale } }),
2492
2471
  ...(coversFace && cfg.turn !== undefined ? { threshold: { ...FACE_RATED_BAND } } : {}),
2493
2472
  };
2494
2473
  }