rig-parts 1.55.2 → 1.56.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
@@ -106,7 +106,7 @@ read by no CPU stage).
106
106
  | `assemble.rig_scale` | assemble — the rig is `trunc(w × rig_scale)` x `trunc(h × rig_scale)`, and every rig-px value after it (`bones`, `meshes`, `motion`, `patches`, `cuts`) is in that rig's pixels; `check --source` (`CHECK_SOURCE_SIZE`) and `compose` (`SCENE_RIG_SCALE_AGREES`) read it back off `parts.json` | authored; rig pixels per source pixel. 0.5 in both examples (a 1664x2432 painting makes an 832x1216 rig). **Keep the figure about 1,100 to 1,200 rig px tall** — the `H` of `propose`'s `mesh spacing:` note, the height of the parts' boxes together — so for another painting, about 1,150 over the figure's height in painting px: that is the size the proposer's spacing divisor was calibrated at (H 1106–1185, the `mesh spacing` note says so) and the size of the two examples `check`'s bars are derived from, and the proposer and `check` also hold fixed rig-px values that do not scale with it (the fringe's 10 px step, the trunk segments' 18, 20, 30 and 100 px, the 15 px wrist tolerance, the 10-row pendant floor, the seam bar's 50 rig px over 40). What else it trades, measured on `demo` with the README loop from a fresh config at 0.25, 0.5 and 1 (issue #345, rig-c 2.47.1): the same plan and the same ten chains; every bar but the one the uncorrected `demo` always fails (`bottomwear` `TIP_OVER_ROOT`, the README loop's `RL01`) passing at all three; the atlas page 418x606, 832x1205 and 1825x2047; the figure's mesh spacing 16, 32 and 64, so 830, 868 and 932 triangles; the breath 2.5 % of a torso that scales with it; and the proposed bones, scaled to 0.5, off the 0.5 proposal's by up to 38 px at 0.25 and 22.5 px at 1 (the hairpin's origin; the skirt roots 20 and 10.5) — the fixed-px values above. `propose` records it on the proposal as `proposed_rig_scale` (issue #354), and changing `assemble.rig_scale` after the rig sections are written is refused (`CONFIG_PROPOSED_RIG_SCALE`): without that refusal `demo`'s tracked config built at 0.25 gated green and `check` failed only `TIP_OVER_ROOT` on both sleeves (`propose --from-config` named 54 `LINT` lines, links off the art) — re-run `assemble` and `propose` at the new scale. Absent: `CONFIG_FIELD_PRESENT`; 0, negative or not a number: `CONFIG_FIELD_TYPE`; a rig under 1x1: `ASSEMBLE_RIG_SIZE` |
107
107
  | `assemble.plan` | assemble; also the list every `meshes`/`regions` key must come from | **proposed** by `assemble --propose-plan`. `[part name, "full" \| "head", See-through tag]`, in draw order back to front. Names are free; roles come from the tag. A layer the author made and added to a run takes `author:<label>` in the tag's place, and has no role (§5, *A layer the author made*). When `generation.control.skeleton` is present and a full-run `handwear-r` or `handwear-l` layer merges sleeve, bare hand and overgarment drape into one layer — a skin-coloured hand cluster (`>= 40` run px, `GHOST_MIN_AREA`, measured `516..4,787` run px across the 9-standee corpus) lies within `0.35` forearm lengths of `<s>_wrist` and non-skin pixels reach more than `1.0x` the forearm length (`<s>_elbow -> <s>_wrist`) below `<s>_wrist` (issue #490; measured `-0.15x..0.45x` on the 6 clean standees vs `1.25x..2.02x` on the 3 merged standees, placing `1.0x` inside the `0.45x..1.25x` gap) — the layer is split into `drape-<s>`, `handwear-<s>` and `hand-<s>` (back to front) when the Euclidean RGB distance between the sleeve median colour above `<s>_wrist` and the drape median colour below `<s>_wrist` is at least `80` (`ARM_DRAPE_COLOR_DISTANCE`, measured `103.6..236.0` on the two distinct-colour merged standees vs `20.8..28.2` on the same-colour merged standee, placing `80` between `28.2` and `103.6`), or refused with `SEETHROUGH_ARM_RUN_MERGED` when the distance is under `80` |
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
- | `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 |
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. `draw` sets assembled part order; an exclusively bound limb rider's final rig slot joins that limb's block (see the limbs subsection). 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; a part a limb takes may be cut where no segment of the limb holds it, issue #564, §5 *Limbs*), `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`), a cut of a part a limb takes that takes a pixel a segment of the limb holds (`ASSEMBLE_CUT_CROSSES_LIMB`). `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
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`). A subset set's limb part takes no pixel that a base part drawn after it, which the set neither covers nor hides, covers at setup (issue #587): the set's attachment is drawn in the base slot's place, so that base part is drawn over it and what the set's painting shows there is that base part (shorts over a leg set's thigh top, which See-through synthesised in the set's leg layer); `assemble` clears those pixels — the base part's PNG at alpha above 8, at its `parts.json` x, y, on the shared canvas — from the set's part once its limbs are cut, before any count — its `merged_px` and its cap's record too (issue #603): a cap pixel it clears leaves the count it was in (`layer_px`, `image_px`, `upper_px`, `lower_px`, `lower_kept_px`) for the cap's `excluded_px`, written only when above 0, so every count is the written PNG's — and prints `LINT set "<set>" <part>: <n> px under <base part> excluded` per part and base part that took a pixel (n counts alpha above 8); a set that carries that base part itself (`"parts": ["thigh_l", "shin_l", "bottomwear"]`) excludes nothing and writes what it wrote before. A subset set that names one limb side of a layer more limbs take (issue #588: `"parts": ["thigh_r", "shin_r"]` where `leg_r` and `leg_l` both take the one `legs` layer) would put every pixel of that layer on its one limb, so each of its parts would hold a piece of both legs; when the set's layer holds more than one island (8-connected pixels with alpha above 8) of at least 1 % of its largest (issue #608: the ghost clean-up's own share; a smaller island is a speck, which goes with the island nearest it — the one holding the pixel centre nearest one of the speck's, none when two are equally near — and is never an island of its own), each segment part keeps the island its joints fall on — its bone's origin and the next segment's bone's origin, at the set's own bones, a joint on the island of a pixel whose square holds the point, and a joint on no island on the one island whose nearest pixel centre lies within the radius of the cap cut at that joint (`cap.radius`: an ankle just past the legwear's edge, on the shoe) — and drops the rest, once its limbs are cut and before the exclusion above, and `assemble` prints `LINT set "<set>" <part>: <layer part> holds <n> islands (8-connected, alpha above 8)[, and <s> speck(s) under 1 % of the largest (<f> px), each with the island nearest it], kept the one under <bone> [x, y] and <bone> [x, y][ (<e> px off it, within its cap's radius <r>)] (<k> px), dropped <m> (<d> px)` per part (k and d count the part's pixels above alpha 8, m the islands it lost one from, and `dropped none` when it lost none); a joint on no island with none or two within that radius, or with no cap cut at it (a limb's first joint), and a segment whose joints fall on two, are refused with `ASSEMBLE_ALT_LIMB_ISLAND` rather than guessed. A layer of one island — specks aside — may hold both legs over each other (a folded shin across the standing leg; issue #609): each of the set's segment parts then keeps the pixels nearer its own limb's segment line — the polyline of only its segments taking that source, their bone origins and the last segment's supplied tip or following segment's joint (issue #638) — than the other limb's, both in the set's pose, and gives the rest away, once its limbs are cut and before the exclusion above; a pixel whose two distances differ by 0.5 px or less (the overlap band: half a pixel, the reach by which a pixel centre is read as on a line) goes to the limb whose part there is drawn later in the base's draw order, and the `LINT set` line above counts what each part kept, gave and held in the band. The other limb stands where the set's `skeleton` places it — its keypoints place every limb bone of the body-18 table, the other leg's as well as the set's own (its `drawn` and `drawnLength` stay the covered bones') — and never at the base's place: a set that does not place it (a `drawn` set, which may not name a bone it does not cover, or a set with neither) is refused with `ASSEMBLE_ALT_LIMB_POSE` when its layer has art where the base draws that limb, and when it has none nothing says the layer holds it and the layer is cut as before; two limbs whose segment lines run closer than the limb's half-width over most of where their art can meet are refused with `ASSEMBLE_ALT_LIMB_OVERLAP`. A set that carries every limb of the layer is cut as before. For a subset set, where a limb's own source layer holds art (`alpha > 8`) under another layer of that run drawn in front (`alpha > 8`, by the projection's plan/depth order), the limb keeps its own layer pixels through projection, limb cutting, island/nearest-line assignment, base-footprint exclusion, fringe clearing and seam correction (issue #637). Those pixels are occluded, never counted as painting projection; no base footprint or painting colour replaces them. After source-art island/nearest-line assignment, a source-transparent cap pixel is assigned before protection, in both doors: its centre is compared against only the segments taking the recipient's source, at the set's supplied bones (ankle-to-toe for a separate foot source), with the existing 0.5 px overlap band and base draw order. A missing last endpoint is refused with `ASSEMBLE_ALT_LIMB_POSE`; an actual following joint may supply that endpoint, never a guessed point. An unposed other limb with base art beneath these cap pixels is likewise refused; without such overlap the existing compatible path retains the cap without inventing the other limb's pose. A pixel cleared by this assignment is never restored. `CAP_FROM_UPPER` records a copy made before assignment and alone proves no ownership: only a retained copy is protected beneath the upper segment, which is drawn over it. The source-art island selection itself is unchanged. Other retained cap pixels (from the layer or a supplied cap image) are protected under an actual front layer even where the lower source is transparent. Protection follows each assigned piece through exclusion, fringe clearing and seam correction; it never restores a pixel ownership removed. Cap pixels removed during ownership leave their original counts for `excluded_px`, just as excluded pixels do, so the cap ledger describes the written PNG. Target assignment reconciles this ledger even when there are no source-transparent cap candidates and when a direct caller does not request cover protection. Unprotected source pixels still obey the base-footprint exclusion. Missing limb/cap art is never extended or guessed. Base builds and uncovered, unexcluded sets keep their existing bytes. `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 |
@@ -538,6 +538,79 @@ section about fitting a mesh to motion — the weight cut, the contour and autom
538
538
  the physics recipe — is for the parts that have no joints: the skirt, the clothing, the hair,
539
539
  the torso.
540
540
 
541
+ **Assembled order and final rig slot order are distinct** (issue #646). Assembly keeps
542
+ `parts.json` and its recomposite in the authored part order, including each patch's
543
+ `draw`. At rig time, each declared limb gathers its exclusively bound rider slots
544
+ locally into its existing segment block. The block's anchor is its first segment
545
+ slot in the original setup draw order; block-anchor order and each limb's owned
546
+ slots' internal relative order are stable. This applies to every declared limb,
547
+ without inferring an anatomical pair or a torso target. A front painting patch
548
+ bound exclusively to a foot therefore stays in front of that limb's own earlier
549
+ slots, but need not remain globally in front of every unrelated slot in the rig.
550
+
551
+ Ownership reads explicit region bindings and every weight of every base/alternate
552
+ attachment, following the bone graph to a declared segment boundary. A still slot
553
+ is read from its own binding. Mixed bindings spanning limbs, a limb and outside
554
+ bones, or attachments with different owners are nonexclusive and are not forced
555
+ into one block; complete closure is not promised across them. With no declared
556
+ limbs, rig output is unchanged. Gathering riders can cross an intervening part
557
+ whose pixels overlap them, so setup RGBA equality must be measured on the authored
558
+ input. Public generated setup comparisons are evidence for those inputs, not a
559
+ universal promise that all art keeps its compositing order.
560
+
561
+ **Explicit adjacency** (issue #647) is optional and takes effect at **rig time**,
562
+ after every base and alternate attachment has been built. For example, on an
563
+ existing limb named `unit_a`:
564
+
565
+ ```json
566
+ "layout": { "before": { "limb": "unit_b" } }
567
+ ```
568
+
569
+ Exactly one of `before` and `after` is required, and its target names exactly one
570
+ `limb` or produced `part`. It means immediately adjacent in setup slot order, with
571
+ the subject block before or after the stationary target block. It does not name
572
+ an anatomical role or request a pose depth policy. A consumed source layer is not
573
+ a produced part: name the emitted segment part instead.
574
+
575
+ Without `parts`, the subject is its whole exclusive limb block, including its
576
+ inherited riders. To move only a source-bound segment scope, write, for example:
577
+
578
+ ```json
579
+ "layout": { "parts": ["piece_a_lower"], "after": { "part": "panel" } }
580
+ ```
581
+
582
+ `parts` is a nonempty list of this limb's own **consecutive segment-part names**,
583
+ not numeric indices or rider names. List order does not reorder the art. Inherited
584
+ riders move only when every base/alternate binding belongs entirely to the selected
585
+ segment boundaries. A rider spanning selected and unselected boundaries stays
586
+ unforced, as does mixed ownership across limbs. Partial layout explicitly overrides
587
+ whole-limb closure for that scope: it never drags an unselected upper segment along
588
+ with the selected lower one. A target limb is its whole exclusive block; a target
589
+ part uses its actual generated slot family, including a still piece, without
590
+ suffix matching. A requested segment or target with mixed bindings is refused.
591
+
592
+ Only disjoint placements are supported. Self, reciprocal, chained/shared-slot,
593
+ and pair/part conflicts name both declaration paths and the shared slot; no
594
+ constraint solver chooses a winner. Internal block order and all unselected
595
+ slots' mutual order are stable. Declaring placement may change visible occlusion;
596
+ assembly's part/PNG/recomposite order and every setup seam bar remain unchanged,
597
+ so check can still refuse a setup whose occlusion differs from its authored stack.
598
+
599
+ A declared layout writes **`layout.json`**, `spec: "rig-parts-layout/1"`, beside
600
+ `rig.json` and optional `sets.json`. The companion records the SHA-256 of those
601
+ **exact final accepted bytes**, final `setup_slots`, and deterministic `placements`
602
+ with `limb`, selected segment `parts`, authored `before`/`after`, resolved `slots`
603
+ and `target_slots`. It is never a new core RigSpec field or a fictitious alternate
604
+ set. Replayed candidates re-resolve closure before their gate. All metadata checks
605
+ precede output; only a successful green undeclared `rig` removes its stale owned
606
+ companion. With no layout declaration, output and log bytes retain the preceding
607
+ limb-closure path and no companion is emitted.
608
+
609
+ Producer adjacency is **not consumer motion acceptance**. Pose 0.42 does not read
610
+ this companion. Pose issue #346 owns its actual reader and explicit depth-source
611
+ mapping, including a refusal when a part/scope has no supported depth source. Do
612
+ not infer it from names, parents, weights, an ordinal or a zero-depth fallback.
613
+
541
614
  `propose --keypoints` drafts the block from the body-18 joints (§3, *A posed figure*): an arm
542
615
  whose shoulder, elbow and wrist are given is `arm_<s>`, `upperarm_<s>` on `sleeve_<s>0` and
543
616
  `forearm_<s>` on `sleeve_<s>1`, cut at `elbow`; a leg whose hip, knee and ankle are given is
@@ -2984,10 +3057,13 @@ Do not borrow an unused See-through tag for it instead (`tail`, `wings`): `layer
2984
3057
  prints it as a body layer, `parts.json` records it as See-through's `tail`, and
2985
3058
  `propose` reads it as a tail — a role the part does not have (selftest `AS229`).
2986
3059
 
2987
- `parts.json` holds one record per part, in draw order: the plan's, with each patch
3060
+ `parts.json` holds one record per part, in **assembled** draw order: the plan's, with each patch
2988
3061
  and each cut's piece where its `draw` puts it (`"back"` patches first in their own
2989
3062
  order, then `"back"` pieces; each `{"before": X}` patch, then each such piece,
2990
- immediately behind `X`; `"front"` patches last, then `"front"` pieces). Its counts:
3063
+ immediately behind `X`; `"front"` patches last, then `"front"` pieces). This is also the
3064
+ recomposite order. The final rig locally gathers exclusively bound limb riders into
3065
+ their existing segment blocks as described above; assembled `front` does not mean
3066
+ global final-slot front when that closure moves a rider. Its counts:
2991
3067
 
2992
3068
  | field | counts |
2993
3069
  | --- | --- |
@@ -3336,6 +3412,12 @@ says why each requirement exists and what breaks without it.
3336
3412
  | <a id="RIG_KEY_FRAME_UNTURNED"></a>`RIG_KEY_FRAME_UNTURNED` | a `translate` key on a bone whose parent is a chain link turned along its chain, or a `scale`/`shear` key (a track, or the blink's `eyes`/`brows` group) on a turned link: the key would act along the link's axis, not the picture's; the detail names the track, the bone and the turn | key the chain's own parent, or key `rotate`; a link whose turn is 0 (a level chain) keeps every key |
3337
3413
  | <a id="RIG_BLINK_INSIDE_IDLE"></a>`RIG_BLINK_INSIDE_IDLE` | the blink runs outside the idle | `motion.blink.t` |
3338
3414
  | <a id="RIG_BLINK_HOLD_SPANS_A_FRAME"></a>`RIG_BLINK_HOLD_SPANS_A_FRAME` | the tree's `BLINK.hold` is shorter than one frame at `IDLE_FPS`, so for some `motion.blink.t` no idle frame — and no frame of the loop — shows the closed eye; the detail counts the phases that miss and names the first | nothing in the config: `BLINK.hold` in `src/motion.ts`, at least `1/IDLE_FPS` s (§3) |
3415
+ | <a id="CONFIG_LAYOUT_TARGET"></a>`CONFIG_LAYOUT_TARGET` | `config.limbs.<name>.layout` declares both or neither `before`/`after`, or a target declares both or neither `limb`/`part` | write one immediate placement side and one typed named target |
3416
+ | <a id="CONFIG_LAYOUT_NAME"></a>`CONFIG_LAYOUT_NAME` | a layout names no declared target limb or produced part; a consumed source layer is not an emitted part | use the declared limb or produced segment/part names printed in the detail |
3417
+ | <a id="CONFIG_LAYOUT_SCOPE"></a>`CONFIG_LAYOUT_SCOPE` | layout `parts` is empty, duplicated, nonconsecutive, foreign, or names a rider instead of an own source-bound segment part | select distinct consecutive own segment-part references; inherited riders are proven separately |
3418
+ | <a id="RIG_LAYOUT_NONEXCLUSIVE"></a>`RIG_LAYOUT_NONEXCLUSIVE` | a requested segment family/target, or an accepted replay's bindings, span owners or leave the selected segment scope across base/alternate attachments | correct the named bindings or scope; mixed ownership is never forced and no stale proof is emitted |
3419
+ | <a id="RIG_LAYOUT_UNREPRESENTABLE"></a>`RIG_LAYOUT_UNREPRESENTABLE` | the named slot family is absent, a target is not contiguous, the accepted slot population/adjacency changed, or final rig text is stale | use an emitted contiguous target and the exact final accepted source; unrelated slots are not absorbed |
3420
+ | <a id="RIG_LAYOUT_CONFLICT"></a>`RIG_LAYOUT_CONFLICT` | layout declarations share a slot, target themselves, or form a reciprocal/chained pair; both field paths are printed | declare one disjoint pair, with each participating slot in only that placement |
3339
3421
  | <a id="RIG_STILL_ROW_INSIDE_PART"></a><a id="RIG_STILL_ROW_CLEAR"></a><a id="RIG_STILL_PIECES_HAVE_ART"></a><a id="RIG_STILL_NAME_FREE"></a>`RIG_STILL_ROW_INSIDE_PART`, `RIG_STILL_ROW_CLEAR`, `RIG_STILL_PIECES_HAVE_ART`, `RIG_STILL_NAME_FREE` | a `motion.blink.still` row that is not strictly inside the part, that crosses art (a cut through art changes the render even at rest), that leaves one piece with no art, or whose `<part>_still` slot name another part already has | that entry's `row` — a row with no art between the crease and the lash line — or rename the other part |
3340
3422
  | <a id="RIG_SET_COVERS_BONE"></a>`RIG_SET_COVERS_BONE` | `assemble.alternates[i].drawn.<bone>` (issue #476): a subset set in `parts.json` `alternates` (`parts` present) states a `drawn` world angle for a bone none of its covered slots ride; the detail names the bone, the set, and the covered bones | remove the uncovered bone from that set's `drawn`, or add the part that rides it to `parts` |
3341
3423
  | <a id="RIG_INFLUENCE_REACH"></a>`RIG_INFLUENCE_REACH` | an influence mesh's `radius` is at or under the distance from its farthest lattice vertex to that vertex's nearest link (issue #378): a link pulls only when it is nearer than `radius`, so that vertex would be pulled by none. The vertex, where it is and the distance are named | a `radius` above the distance named, or a segment nearer that part of the art |
package/docs/STABILITY.md CHANGED
@@ -130,7 +130,9 @@ part name, `[i]` an entry of a list):
130
130
  - **assemble.patches[i].draw**, when an object — required: `before`
131
131
  - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`, `under_fill`
132
132
  - **assemble.cuts[i].draw**, when an object — required: `before`
133
- - **limbs.&lt;limb&gt;** — required: `segments` (issue #349)
133
+ - **limbs.&lt;limb&gt;** — required: `segments`; optional: `split`, `layout` (issues #349, #446, #647)
134
+ - **limbs.&lt;limb&gt;.layout** — exactly one of `before`, `after`; optional: `parts` (own consecutive segment-part references, issue #647)
135
+ - **limbs.&lt;limb&gt;.layout.before**, **limbs.&lt;limb&gt;.layout.after** — exactly one typed target: `limb` or `part`
134
136
  - **limbs.&lt;limb&gt;.segments[i]** — required: `part`, `from`, `bone`; required below a joint (i > 0): `joint`; optional below a joint: `range`, `cap`, `edge`, `cap_shape`, `cut_band`
135
137
  - **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`, `inherit`
136
138
  - **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`; optional: `link_inherit`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.55.2",
3
+ "version": "1.56.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
@@ -93,6 +93,7 @@ import { KEYPOINT_NAMES, type KeypointName, type PosePoints } from './skeleton.t
93
93
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
94
94
  import { TURN_BONE } from './turn.ts';
95
95
  import { pyRound } from './round.ts';
96
+ import { LAYOUT_FILE, layoutCompanion, refreshLimbLayout } from './limblayout.ts';
96
97
 
97
98
  /** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
98
99
  export type Log = (line: string) => void;
@@ -1289,18 +1290,22 @@ export function readRigInputs(config: string, partsDir: string): RigInputs {
1289
1290
  return { cfg, from, parts, images };
1290
1291
  }
1291
1292
 
1292
- export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log): RigOutput {
1293
- const { cfg, from, parts, images } = readRigInputs(input.config, input.parts);
1294
- const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
1295
- const textsOf = (r: RigOutput): Array<[string, string]> => {
1296
- const mergedMotion = withAnimations(r.motion, from === null ? null : from.table);
1293
+ /** Final candidate serialization shared by initial and settled motion replay. */
1294
+ export function rigOutputTexts(r: RigOutput, animations: Parameters<typeof withAnimations>[1]): Array<[string, string]> {
1295
+ const mergedMotion = withAnimations(r.motion, animations);
1296
+ if (r.layout !== undefined) r.layout = refreshLimbLayout(r.layout, r.rig);
1297
1297
  return [
1298
1298
  ['rig.json', rigJsonText(r.rig)],
1299
1299
  ['motion.json', rigJsonText(mergedMotion)],
1300
1300
  ['mesh_report.json', rigJsonText(r.meshReport)],
1301
1301
  ...(r.sets === undefined ? [] : [[SETS_FILE, rigJsonText(r.sets)] as [string, string]]),
1302
1302
  ];
1303
- };
1303
+ }
1304
+
1305
+ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log): RigOutput {
1306
+ const { cfg, from, parts, images } = readRigInputs(input.config, input.parts);
1307
+ const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
1308
+ const textsOf = (r: RigOutput): Array<[string, string]> => rigOutputTexts(r, from === null ? null : from.table);
1304
1309
  const firstTexts = textsOf(first);
1305
1310
  // The gate runs before the lines are printed, and its lines print where they always did: an automatic part's motion
1306
1311
  // gate compares the model document of this very build, the one written (issue #126 item 3). mesh_report.json is staged
@@ -1449,9 +1454,13 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
1449
1454
  rig.meshReport.splice(0, rig.meshReport.length, ...rows);
1450
1455
  texts[2] = ['mesh_report.json', rigJsonText(rig.meshReport)];
1451
1456
  }
1457
+ const layout = rig.layout === undefined ? undefined : layoutCompanion(rig.layout, rig.rig, texts[0][1], texts.find(([n]) => n === SETS_FILE)?.[1]);
1458
+ // This stage owns the companion. A red run reaches neither cleanup nor emit.
1459
+ if (layout === undefined) rmSync(join(input.out, LAYOUT_FILE), { force: true });
1452
1460
  mkdirSync(join(input.out, 'images'), { recursive: true });
1453
1461
  for (const [file, img] of rig.images) writePng(join(input.out, 'images', file), img);
1454
1462
  for (const [file, text] of texts) writeFileSync(join(input.out, file), text);
1463
+ if (layout !== undefined) writeFileSync(join(input.out, LAYOUT_FILE), rigJsonText(layout));
1455
1464
  log(`rig-parts rig: wrote ${join(input.out, 'rig.json')}, motion.json, mesh_report.json and ${rig.images.length} image(s) under ${join(input.out, 'images')}`);
1456
1465
  return rig;
1457
1466
  }
package/src/config.ts CHANGED
@@ -931,8 +931,15 @@ export interface LimbSplit {
931
931
  export interface Limb {
932
932
  segments: LimbSegment[];
933
933
  split?: LimbSplit;
934
+ /** Explicit rig-stage adjacency; absent, the existing limb closure is unchanged. */
935
+ layout?: LimbLayout;
934
936
  }
935
937
 
938
+ export type LimbLayoutTarget = { limb: string; part?: never } | { part: string; limb?: never };
939
+ export type LimbLayout = { parts?: string[] } & (
940
+ { before: LimbLayoutTarget; after?: never } | { after: LimbLayoutTarget; before?: never }
941
+ );
942
+
936
943
  export interface CharacterConfig {
937
944
  key: string;
938
945
  note?: string;
@@ -1791,7 +1798,7 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
1791
1798
  const taken = isPlainObject(top.limbs)
1792
1799
  ? Object.values(top.limbs).flatMap((l) => (isPlainObject(l) && Array.isArray(l.segments) ? l.segments.flatMap((sg) => (isPlainObject(sg) && typeof sg.from === 'string' ? [sg.from] : [])) : []))
1793
1800
  : [];
1794
- checkLimbs(c, top.limbs, { parts: [...new Set(taken)], patches: [], cuts: [] }, names);
1801
+ checkLimbs(c, top.limbs, { parts: [...new Set(taken)], patches: [], cuts: [] }, names, false);
1795
1802
  }
1796
1803
  if ('meshes' in top) checkMeshes(c, top.meshes, names.bones, names.chains);
1797
1804
  if ('regions' in top) checkRegions(c, top.regions, names.bones);
@@ -2969,7 +2976,7 @@ interface LimbNames {
2969
2976
  * has two segments or more. With `names` null (no `bones`), bones are not
2970
2977
  * resolved. Returns the limb parts and the parts the limbs take.
2971
2978
  */
2972
- function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
2979
+ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null, resolveLayoutParts = true): LimbNames {
2973
2980
  const out: LimbNames = { parts: new Map(), consumed: new Map(), chains: new Map() };
2974
2981
  if (!isPlainObject(v)) {
2975
2982
  c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
@@ -2981,7 +2988,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2981
2988
  const splitLimbs: string[] = [];
2982
2989
  for (const [limb, spec] of Object.entries(v)) {
2983
2990
  const at = `config.limbs.${limb}`;
2984
- const o = c.object(at, spec, ['segments'], ['split']);
2991
+ const o = c.object(at, spec, ['segments'], ['split', 'layout']);
2985
2992
  if (o === null) continue;
2986
2993
  if ('split' in o) {
2987
2994
  const sp = c.object(`${at}.split`, o.split, [], ['line', 'edge']);
@@ -3119,9 +3126,46 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
3119
3126
  }
3120
3127
  }
3121
3128
  }
3129
+ checkLimbLayouts(c, v, made, out, resolveLayoutParts);
3122
3130
  return out;
3123
3131
  }
3124
3132
 
3133
+ /** Validate after every limb is known; a declaration may refer forward by name. */
3134
+ function checkLimbLayouts(c: Check, limbs: Record<string, Json>, made: { parts: string[]; patches: string[]; cuts: string[] }, names: LimbNames, resolveParts: boolean): void {
3135
+ const produced = new Set([...made.parts, ...made.patches, ...made.cuts].filter((n) => !names.consumed.has(n)));
3136
+ for (const n of names.parts.keys()) produced.add(n);
3137
+ for (const [limb, raw] of Object.entries(limbs)) {
3138
+ if (!isPlainObject(raw) || !('layout' in raw)) continue;
3139
+ const at = `config.limbs.${limb}.layout`;
3140
+ const l = c.object(at, raw.layout, [], ['before', 'after', 'parts']);
3141
+ if (l === null) continue;
3142
+ const sides = ['before', 'after'].filter((s) => s in l);
3143
+ if (sides.length !== 1) c.fail('CONFIG_LAYOUT_TARGET', at, 'exactly one of before or after is required; it means immediate setup-slot adjacency');
3144
+ for (const side of sides) {
3145
+ const t = c.object(`${at}.${side}`, l[side], [], ['limb', 'part']);
3146
+ if (t === null) continue;
3147
+ const keys = ['limb', 'part'].filter((k) => k in t);
3148
+ if (keys.length !== 1) { c.fail('CONFIG_LAYOUT_TARGET', `${at}.${side}`, 'exactly one typed target, {limb: name} or {part: name}, is required'); continue; }
3149
+ const k = keys[0], n = t[k];
3150
+ if (!c.string(`${at}.${side}.${k}`, n)) continue;
3151
+ if (k === 'limb' ? !Object.hasOwn(limbs, n) : resolveParts && !produced.has(n)) {
3152
+ const consumed = names.consumed.get(n);
3153
+ c.fail('CONFIG_LAYOUT_NAME', `${at}.${side}.${k}`, `names "${n}"; ${k === 'limb' ? `a declared limb (${Object.keys(limbs).join(', ')})` : `a produced part (${[...produced].join(', ')})`} is required${consumed === undefined ? '' : `; "${n}" is consumed by limb "${consumed}" into ${names.chains.get(consumed)?.parts.join(', ')}`}`);
3154
+ }
3155
+ }
3156
+ if (!('parts' in l)) continue;
3157
+ const own = names.chains.get(limb)?.parts ?? [];
3158
+ if (!Array.isArray(l.parts) || l.parts.length === 0 || !l.parts.every((p) => typeof p === 'string' && own.includes(p))) {
3159
+ c.fail('CONFIG_LAYOUT_SCOPE', `${at}.parts`, `is ${show(l.parts)}; a nonempty list of this limb's source-bound segment parts (${own.join(', ')}) is required; rider names are not selectors`);
3160
+ continue;
3161
+ }
3162
+ const positions = l.parts.map((p) => own.indexOf(p as string)).sort((a, b) => a - b);
3163
+ if (new Set(positions).size !== positions.length || positions.some((p, i) => i > 0 && p !== positions[i - 1] + 1)) {
3164
+ c.fail('CONFIG_LAYOUT_SCOPE', `${at}.parts`, `is ${show(l.parts)}; distinct consecutive segment-part references in ${own.join(', ')} are required`);
3165
+ }
3166
+ }
3167
+ }
3168
+
3125
3169
  /**
3126
3170
  * Every plan part — and every cut's piece, which the caller lists with them —
3127
3171
  * is exactly one of a mesh or a region, every patch is a region, and every
@@ -0,0 +1,181 @@
1
+ /** Explicit setup adjacency (#647). Ownership proves eligibility, never intent. */
2
+ import type { Limb, LimbLayoutTarget } from './config.ts';
3
+ import { createHash } from 'node:crypto';
4
+ import { type Problem, refuseIfAny } from './errors.ts';
5
+ import type { LimbSlotLayout } from './limbslots.ts';
6
+ import type { RigSpec } from './rig.ts';
7
+
8
+ export const LAYOUT_FILE = 'layout.json';
9
+ export const LAYOUT_SPEC = 'rig-parts-layout/1';
10
+ interface SegmentSource { limb: string; part: string }
11
+ export interface LayoutPlacement {
12
+ limb: string;
13
+ parts: string[];
14
+ before?: LimbLayoutTarget;
15
+ after?: LimbLayoutTarget;
16
+ slots: string[];
17
+ target_slots: string[];
18
+ }
19
+ /** Internal proof is not a RigSpec field and is never serialized as one. */
20
+ export interface LayoutPlan {
21
+ placements: LayoutPlacement[];
22
+ sources: Map<string, SegmentSource[]>;
23
+ limbs: Readonly<Record<string, Limb>>;
24
+ families: ReadonlyMap<string, readonly string[]>;
25
+ originalOrder: string[];
26
+ }
27
+ export interface LayoutCompanion {
28
+ spec: typeof LAYOUT_SPEC;
29
+ rig: { file: 'rig.json'; sha256: string };
30
+ sets?: { file: 'sets.json'; sha256: string };
31
+ setup_slots: string[];
32
+ placements: LayoutPlacement[];
33
+ }
34
+
35
+ /** Keep the concrete declared boundary that 646's parent walk encounters. */
36
+ function segmentSources(limbs: Readonly<Record<string, Limb>>, bones: RigSpec['bones']): Map<string, SegmentSource[]> {
37
+ const seeds = new Map<string, SegmentSource[]>();
38
+ for (const [limb, l] of Object.entries(limbs)) for (const s of l.segments) seeds.set(s.bone, [...(seeds.get(s.bone) ?? []), { limb, part: s.part }]);
39
+ const parents = new Map(bones.map((b) => [b.name, b.parent]));
40
+ const result = new Map<string, SegmentSource[]>();
41
+ for (const b of bones) {
42
+ let at: string | undefined = b.name;
43
+ const seen = new Set<string>();
44
+ while (at !== undefined && !seen.has(at) && !seeds.has(at)) { seen.add(at); at = parents.get(at); }
45
+ result.set(b.name, at === undefined ? [] : seeds.get(at) ?? []);
46
+ }
47
+ return result;
48
+ }
49
+
50
+ function bindings(slot: RigSpec['slots'][number], skin: RigSpec['skins']['default']): string[][] {
51
+ return Object.values(skin[slot.name] ?? {}).map((a) => 'type' in a ? a.weights.flatMap((v) => v.map((w) => w.bone)) : [slot.bone]);
52
+ }
53
+ function bindingDetail(slots: RigSpec['slots'], skin: RigSpec['skins']['default'], sources: LayoutPlan['sources']): string {
54
+ return JSON.stringify(slots.map((s) => ({ slot: s.name, attachments: Object.entries(skin[s.name] ?? {}).map(([attachment, a]) => ({ attachment, bones: ('type' in a ? a.weights.flatMap((v) => v.map((w) => w.bone)) : [s.bone]).map((bone) => ({ bone, sources: sources.get(bone) ?? [] })) })) })));
55
+ }
56
+ function owned(slot: RigSpec['slots'][number], skin: RigSpec['skins']['default'], sources: LayoutPlan['sources'], limb: string, parts: readonly string[]): boolean {
57
+ const bs = bindings(slot, skin);
58
+ return bs.length > 0 && bs.every((b) => b.length > 0 && b.every((n) => {
59
+ const s = sources.get(n) ?? [];
60
+ return s.length === 1 && s[0].limb === limb && parts.includes(s[0].part);
61
+ }));
62
+ }
63
+ /** A crossing target may be outside every limb, but may not mix owners. */
64
+ function targetSafe(slots: RigSpec['slots'], skin: RigSpec['skins']['default'], sources: LayoutPlan['sources']): boolean {
65
+ const owners = new Set<string>();
66
+ let good = slots.length > 0;
67
+ for (const slot of slots) {
68
+ const bs = bindings(slot, skin);
69
+ if (bs.length === 0) good = false;
70
+ for (const b of bs) {
71
+ if (b.length === 0) good = false;
72
+ for (const n of b) {
73
+ const s = sources.get(n) ?? [];
74
+ if (s.length > 1) good = false;
75
+ owners.add(s.length === 0 ? 'outside' : `limb:${s[0].limb}`);
76
+ }
77
+ }
78
+ }
79
+ return good && owners.size === 1;
80
+ }
81
+
82
+ /** Called only for opt-in declarations, over 646's unchanged permutation. */
83
+ export function placeLimbLayouts(limbs: Readonly<Record<string, Limb>> | undefined, bones: RigSpec['bones'], base: LimbSlotLayout, skin: RigSpec['skins']['default'], families: ReadonlyMap<string, readonly string[]>, inheritedSources?: LayoutPlan['sources']): { slots: RigSpec['slots']; plan?: LayoutPlan } {
84
+ if (limbs === undefined || !Object.values(limbs).some((l) => l.layout !== undefined)) return { slots: base.slots };
85
+ const sources = inheritedSources ?? segmentSources(limbs, bones), problems: Problem[] = [], placements: LayoutPlacement[] = [];
86
+ const names = base.slots.map((s) => s.name);
87
+ for (const [limb, l] of Object.entries(limbs)) {
88
+ const d = l.layout;
89
+ if (d === undefined) continue;
90
+ const at = `config.limbs.${limb}.layout`, parts = l.segments.map((s) => s.part).filter((p) => d.parts === undefined || d.parts.includes(p));
91
+ const subject = base.slots.filter((s) => owned(s, skin, sources, limb, parts));
92
+ const authored = d.before ?? d.after as LimbLayoutTarget;
93
+ const target: LimbLayoutTarget = authored.limb === undefined ? { part: authored.part } : { limb: authored.limb };
94
+ let targets: RigSpec['slots'];
95
+ if (target.limb !== undefined) {
96
+ const other = limbs[target.limb];
97
+ const all = other?.segments.map((s) => s.part) ?? [];
98
+ targets = base.slots.filter((s) => owned(s, skin, sources, target.limb as string, all));
99
+ for (const p of all) {
100
+ if ((families.get(p) ?? []).length === 0) problems.push({ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: at, detail: `target limb "${target.limb}" segment part "${p}" has no emitted family` });
101
+ for (const n of families.get(p) ?? []) if (!targets.some((s) => s.name === n)) problems.push({ code: 'RIG_LAYOUT_NONEXCLUSIVE', object: `${at} target limb "${target.limb}" slot "${n}"`, detail: `requires exclusive target ownership across every attachment; found ${bindingDetail(base.slots.filter((s) => s.name === n), skin, sources)}` });
102
+ }
103
+ } else targets = base.slots.filter((s) => (families.get(target.part) ?? []).includes(s.name));
104
+ for (const p of parts) {
105
+ const family = families.get(p) ?? [];
106
+ if (family.length === 0) problems.push({ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: at, detail: `selected segment part "${p}" has no emitted slot family` });
107
+ for (const n of family) if (!subject.some((s) => s.name === n)) problems.push({ code: 'RIG_LAYOUT_NONEXCLUSIVE', object: `${at} part "${p}" slot "${n}"`, detail: `requires exclusive selected-scope bindings across every attachment; found ${bindingDetail(base.slots.filter((s) => s.name === n), skin, sources)}` });
108
+ }
109
+ if (targets.length === 0) problems.push({ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: at, detail: `target ${JSON.stringify(target)} has no emitted exclusive slot group` });
110
+ else if (!targetSafe(targets, skin, sources)) problems.push({ code: 'RIG_LAYOUT_NONEXCLUSIVE', object: `${at} target ${JSON.stringify(target)}`, detail: `requires one binding owner across base/alternate attachments; found ${bindingDetail(targets, skin, sources)}` });
111
+ const idx = targets.map((s) => names.indexOf(s.name));
112
+ if (idx.some((v, i) => i > 0 && v !== idx[i - 1] + 1)) problems.push({ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: at, detail: `target slots ${targets.map((s) => s.name).join(', ')} do not form one contiguous block; no unrelated slot is absorbed` });
113
+ placements.push({ limb, parts, ...(d.before === undefined ? { after: target } : { before: target }), slots: subject.map((s) => s.name), target_slots: targets.map((s) => s.name) });
114
+ }
115
+ const participating = new Map<string, string>();
116
+ for (const p of placements) for (const n of [...p.slots, ...p.target_slots]) {
117
+ const at = `config.limbs.${p.limb}.layout`, other = participating.get(n);
118
+ if (other !== undefined) problems.push({ code: 'RIG_LAYOUT_CONFLICT', object: at, detail: `slot "${n}" also participates in ${other}; self, reciprocal, chained and shared-slot declarations are refused, so declare one disjoint pair` });
119
+ else participating.set(n, at);
120
+ }
121
+ refuseIfAny(problems);
122
+ placements.sort((a, b) => names.indexOf(a.target_slots[0]) - names.indexOf(b.target_slots[0]));
123
+ const moving = new Set(placements.flatMap((p) => p.slots));
124
+ const result: RigSpec['slots'] = [];
125
+ for (const s of base.slots) {
126
+ if (moving.has(s.name)) continue;
127
+ for (const p of placements) if (p.before !== undefined && p.target_slots[0] === s.name) result.push(...base.slots.filter((x) => p.slots.includes(x.name)));
128
+ result.push(s);
129
+ for (const p of placements) if (p.after !== undefined && p.target_slots[p.target_slots.length - 1] === s.name) result.push(...base.slots.filter((x) => p.slots.includes(x.name)));
130
+ }
131
+ const plan = { placements, sources, limbs, families, originalOrder: names };
132
+ validateLimbLayout(plan, { slots: result, skins: { default: skin } });
133
+ return { slots: result, plan };
134
+ }
135
+
136
+ /** A replay may change weights. Resolve closure again before that candidate is gated. */
137
+ export function refreshLimbLayout(plan: LayoutPlan, rig: RigSpec): LayoutPlan {
138
+ const byName = new Map(rig.slots.map((s) => [s.name, s]));
139
+ const slots = plan.originalOrder.flatMap((n) => { const s = byName.get(n); return s === undefined ? [] : [s]; });
140
+ if (slots.length !== rig.slots.length || new Set(rig.slots.map((s) => s.name)).size !== slots.length) refuseIfAny([{ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: 'rig.slots', detail: 'accepted candidate changed the source slot population; an exact permutation is required' }]);
141
+ const next = placeLimbLayouts(plan.limbs, rig.bones, { slots, owners: [], anchors: [] }, rig.skins.default, plan.families, plan.sources);
142
+ rig.slots = next.slots;
143
+ return next.plan as LayoutPlan;
144
+ }
145
+
146
+ /** Explicit generated alias, supplied where stops/turn actually create it. */
147
+ export function layoutAlias(plan: LayoutPlan | undefined, original: string, emitted: string): void {
148
+ if (plan !== undefined) plan.sources.set(emitted, plan.sources.get(original) ?? []);
149
+ }
150
+
151
+ /** Recheck accepted attachments after rebinds or mesh replay, before any emit. */
152
+ export function validateLimbLayout(plan: LayoutPlan, rig: Pick<RigSpec, 'slots' | 'skins'>): void {
153
+ const names = rig.slots.map((s) => s.name), problems: Problem[] = [];
154
+ for (const p of plan.placements) {
155
+ const at = `config.limbs.${p.limb}.layout`, target = p.before ?? p.after as LimbLayoutTarget;
156
+ const read = (ns: readonly string[]) => rig.slots.filter((s) => ns.includes(s.name));
157
+ const ss = read(p.slots), ts = read(p.target_slots);
158
+ const contiguous = (ns: string[]) => ns.length > 0 && ns.every((n, i) => i === 0 || names.indexOf(n) === names.indexOf(ns[i - 1]) + 1);
159
+ if (ss.length !== p.slots.length || ts.length !== p.target_slots.length || !contiguous(p.slots) || !contiguous(p.target_slots) || (p.before !== undefined ? names.indexOf(p.slots[p.slots.length - 1]) + 1 !== names.indexOf(p.target_slots[0]) : names.indexOf(p.target_slots[p.target_slots.length - 1]) + 1 !== names.indexOf(p.slots[0]))) {
160
+ problems.push({ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: at, detail: `accepted setup slots do not preserve the exact declared adjacency: ${JSON.stringify(p)}` });
161
+ }
162
+ if (ss.some((s) => !owned(s, rig.skins.default, plan.sources, p.limb, p.parts)) || !targetSafe(ts, rig.skins.default, plan.sources) || (target.limb !== undefined && ts.some((s) => !owned(s, rig.skins.default, plan.sources, target.limb as string, plan.limbs[target.limb as string].segments.map((s) => s.part))))) {
163
+ problems.push({ code: 'RIG_LAYOUT_NONEXCLUSIVE', object: at, detail: `accepted/replayed bindings must remain exclusive to their declared group; found ${bindingDetail([...ss, ...ts], rig.skins.default, plan.sources)}; no stale ownership proof is emitted` });
164
+ }
165
+ }
166
+ refuseIfAny(problems);
167
+ }
168
+
169
+ /** Hash exact accepted texts, never the pre-replay candidate or a core extension. */
170
+ export function layoutCompanion(plan: LayoutPlan, rig: RigSpec, rigText: string, setsText?: string): LayoutCompanion {
171
+ validateLimbLayout(plan, rig);
172
+ if (JSON.stringify(JSON.parse(rigText)) !== JSON.stringify(rig)) refuseIfAny([{ code: 'RIG_LAYOUT_UNREPRESENTABLE', object: 'layout.json rig source', detail: 'final rig text and accepted layout object differ; nothing may be emitted from a stale candidate' }]);
173
+ const hash = (s: string): string => createHash('sha256').update(s).digest('hex');
174
+ return {
175
+ spec: LAYOUT_SPEC,
176
+ rig: { file: 'rig.json', sha256: hash(rigText) },
177
+ ...(setsText === undefined ? {} : { sets: { file: 'sets.json' as const, sha256: hash(setsText) } }),
178
+ setup_slots: rig.slots.map((s) => s.name),
179
+ placements: plan.placements.map((p) => ({ limb: p.limb, parts: [...p.parts], ...(p.before === undefined ? { after: p.after } : { before: p.before }), slots: [...p.slots], target_slots: [...p.target_slots] })),
180
+ };
181
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Issue #646: close each declared limb's existing slot block over its riders.
3
+ * No anatomical role or crossed target is inferred. A declared segment is an
4
+ * ownership boundary: descendants belong to the first segment encountered up
5
+ * their parent graph, so a nested limb does not become its ancestor's rider.
6
+ */
7
+ import type { Limb } from './config.ts';
8
+ import type { RigSpec } from './rig.ts';
9
+
10
+ export interface LimbSlotOwner {
11
+ slot: string;
12
+ kind: 'exclusive' | 'nonexclusive' | 'outside' | 'unbound';
13
+ limb: string | null;
14
+ /** Every attachment's actual binding, not a mesh slot's carrier bone. */
15
+ bindings: Array<{ attachment: string; bones: string[] }>;
16
+ limbs: string[];
17
+ outside: string[];
18
+ }
19
+
20
+ export interface LimbSlotLayout {
21
+ slots: RigSpec['slots'];
22
+ owners: LimbSlotOwner[];
23
+ /** First segment SLOT encountered in setup order, not anatomical segment 0. */
24
+ anchors: Array<{ limb: string; slot: string | null }>;
25
+ }
26
+
27
+ /**
28
+ * Read after all alternate attachments exist and before stops/turn rewrite
29
+ * bindings. Keep the returned slot references until those rewrites finish;
30
+ * only then publish the permutation, so their own enumeration order is intact.
31
+ *
32
+ * All weights of all attachments must resolve exclusively to the same limb.
33
+ * Even a listed zero-weight reference is read, as in meshBoundBones: there is
34
+ * no epsilon or first-weight rule. Mixed/outside/empty bindings stay outside
35
+ * the gathered blocks, with their classification returned for inspection.
36
+ * Their mutual order is preserved; complete closure is not promised across a
37
+ * nonexclusive segment or rider. No new refusal is introduced.
38
+ */
39
+ export function coalesceLimbSlots(
40
+ limbs: Readonly<Record<string, Limb>> | undefined,
41
+ bones: RigSpec['bones'],
42
+ slots: RigSpec['slots'],
43
+ skin: RigSpec['skins']['default'],
44
+ ): LimbSlotLayout {
45
+ if (limbs === undefined || Object.keys(limbs).length === 0) return { slots, owners: [], anchors: [] };
46
+ const seeds = new Map<string, string[]>();
47
+ for (const [name, limb] of Object.entries(limbs)) {
48
+ for (const s of limb.segments) {
49
+ const names = seeds.get(s.bone) ?? [];
50
+ if (!names.includes(name)) names.push(name);
51
+ seeds.set(s.bone, names);
52
+ }
53
+ }
54
+ const parents = new Map(bones.map((b) => [b.name, b.parent]));
55
+ const ownerOf = (bone: string): readonly string[] => {
56
+ let at: string | undefined = bone;
57
+ const seen = new Set<string>();
58
+ while (at !== undefined && !seen.has(at)) {
59
+ seen.add(at);
60
+ const declared = seeds.get(at);
61
+ if (declared !== undefined) return declared;
62
+ at = parents.get(at);
63
+ }
64
+ return [];
65
+ };
66
+ const owners: LimbSlotOwner[] = slots.map((slot) => {
67
+ const bindings = Object.entries(skin[slot.name] ?? {}).map(([attachment, att]) => ({
68
+ attachment,
69
+ bones: 'type' in att ? [...new Set(att.weights.flatMap((v) => v.map((w) => w.bone)))] : [slot.bone],
70
+ }));
71
+ const mine = new Set<string>();
72
+ const outside = new Set<string>();
73
+ let exclusive = bindings.length > 0;
74
+ for (const b of bindings) {
75
+ if (b.bones.length === 0) exclusive = false;
76
+ for (const bone of b.bones) {
77
+ const names = ownerOf(bone);
78
+ if (names.length !== 1) exclusive = false;
79
+ if (names.length === 0) outside.add(bone);
80
+ for (const name of names) mine.add(name);
81
+ }
82
+ }
83
+ const kind = exclusive && mine.size === 1 && outside.size === 0 ? 'exclusive' : mine.size > 0 ? 'nonexclusive' : bindings.some((b) => b.bones.length > 0) ? 'outside' : 'unbound';
84
+ return { slot: slot.name, kind, limb: kind === 'exclusive' ? [...mine][0] : null, bindings, limbs: [...mine], outside: [...outside] };
85
+ });
86
+ const anchors = Object.entries(limbs).map(([name, limb]) => ({ limb: name, slot: slots.find((s) => limb.segments.some((sg) => sg.part === s.name))?.name ?? null }));
87
+ const at = new Map<string, string[]>();
88
+ const anchored = new Set<string>();
89
+ for (const anchor of anchors) {
90
+ if (anchor.slot === null) continue;
91
+ at.set(anchor.slot, [...(at.get(anchor.slot) ?? []), anchor.limb]);
92
+ anchored.add(anchor.limb);
93
+ }
94
+ const owned = new Map(owners.map((o) => [o.slot, o.limb !== null && anchored.has(o.limb) ? o.limb : null]));
95
+ const gathered = new Map<string, RigSpec['slots']>();
96
+ for (const slot of slots) {
97
+ const name = owned.get(slot.name);
98
+ if (name !== null && name !== undefined) gathered.set(name, [...(gathered.get(name) ?? []), slot]);
99
+ }
100
+ const reordered: RigSpec['slots'] = [];
101
+ for (const slot of slots) {
102
+ for (const name of at.get(slot.name) ?? []) reordered.push(...(gathered.get(name) ?? []));
103
+ if (owned.get(slot.name) === null) reordered.push(slot);
104
+ }
105
+ return { slots: reordered.every((s, i) => s === slots[i]) ? slots : reordered, owners, anchors };
106
+ }
package/src/rig.ts CHANGED
@@ -83,8 +83,9 @@
83
83
  * transparent edge and the render changes at rest — measured on the demo
84
84
  * example, 36 px of the setup pose (max 7 levels) — while a clear-row cut
85
85
  * changes no pixel outside the blink (issue #26, `RG16`). A `painting:`
86
- * patch (`assemble.patches`) is always a region; its slot sits where
87
- * parts.json puts it, which is where its `draw` put it.
86
+ * patch (`assemble.patches`) is always a region. With declared limbs, an
87
+ * exclusively bound rider joins its limb's slot block (issue #646), keeping
88
+ * that limb's internal draw order; other patches keep the parts order.
88
89
  * - **The idle** and, under `idleKeys: 'ctl'` (the default), its control
89
90
  * bones (`src/motion.ts`). Under `idleKeys: 'direct'` the keys stay on the
90
91
  * bones the meshes are weighted to, no `<bone>_ctl` is added, and the rig
@@ -167,6 +168,8 @@ import { type ContourChainRibs, type ContourReport, contourMesh, type ContourReg
167
168
  import { type LocalInfluence, localInfluences } from './localweights.ts';
168
169
  import { edgeLength } from './length.ts';
169
170
  import { skeletonLimbJoints } from './limbs.ts';
171
+ import { coalesceLimbSlots } from './limbslots.ts';
172
+ import { layoutAlias, placeLimbLayouts, type LayoutPlan } from './limblayout.ts';
170
173
  import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
171
174
  import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
172
175
  import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
@@ -1077,6 +1080,8 @@ export interface AttachmentSetsFile {
1077
1080
 
1078
1081
  export interface RigOutput {
1079
1082
  rig: RigSpec;
1083
+ /** Internal explicit-layout provenance; serialized only as an external companion. */
1084
+ layout?: LayoutPlan;
1080
1085
  motion: MotionSpec;
1081
1086
  meshReport: MeshReport[];
1082
1087
  /** The padded images, by file name (`<part>.png`), in parts.json order. */
@@ -1693,6 +1698,11 @@ export function buildRig(
1693
1698
 
1694
1699
  // ---- attachments -------------------------------------------------------
1695
1700
  const slots: RigSpec['slots'] = [];
1701
+ const slotFamilies = new Map<string, string[]>();
1702
+ const addSlots = (part: string, ...rows: RigSpec['slots']): void => {
1703
+ slots.push(...rows);
1704
+ slotFamilies.set(part, [...(slotFamilies.get(part) ?? []), ...rows.map((s) => s.name)]);
1705
+ };
1696
1706
  const skin: RigSpec['skins']['default'] = {};
1697
1707
  const meshReport: MeshReport[] = [];
1698
1708
  const outImages: Array<[string, Raster]> = [];
@@ -2219,12 +2229,12 @@ export function buildRig(
2219
2229
  const cyUp = p.y + r / 2;
2220
2230
  skin[p.name] = { [p.name]: region(file, bone.name, cx, cyLow) };
2221
2231
  skin[still] = { [still]: region(stillFile, sb.name, cx, cyUp) };
2222
- slots.push({ name: p.name, bone: bone.name, attachment: p.name }, { name: still, bone: sb.name, attachment: still });
2232
+ addSlots(p.name, { name: p.name, bone: bone.name, attachment: p.name }, { name: still, bone: sb.name, attachment: still });
2223
2233
  continue;
2224
2234
  }
2225
2235
  const cy = p.y + p.h / 2;
2226
2236
  skin[p.name] = { [p.name]: region(file, bone.name, cx, cy) };
2227
- slots.push({ name: p.name, bone: bone.name, attachment: p.name });
2237
+ addSlots(p.name, { name: p.name, bone: bone.name, attachment: p.name });
2228
2238
  continue;
2229
2239
  }
2230
2240
  const segs = meshSegments.get(p.name) as Segment[];
@@ -2232,7 +2242,7 @@ export function buildRig(
2232
2242
  const row = autoAttachment(p, img, file, mesh.auto, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
2233
2243
  if (row === null) continue;
2234
2244
  skin[p.name] = { [p.name]: row.attachment };
2235
- slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
2245
+ addSlots(p.name, { name: p.name, bone: segs[0].bone, attachment: p.name });
2236
2246
  meshReport.push(row.report);
2237
2247
  autoMotion.push(row.motion);
2238
2248
  continue;
@@ -2241,7 +2251,7 @@ export function buildRig(
2241
2251
  const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, mesh.influences, problems);
2242
2252
  if (row === null) continue;
2243
2253
  skin[p.name] = { [p.name]: row.attachment };
2244
- slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
2254
+ addSlots(p.name, { name: p.name, bone: segs[0].bone, attachment: p.name });
2245
2255
  meshReport.push(row.report);
2246
2256
  continue;
2247
2257
  }
@@ -2249,7 +2259,7 @@ export function buildRig(
2249
2259
  const row = influenceAttachment(p, img, file, mesh, segs, meshSegmentEntries.get(p.name) as number[], problems);
2250
2260
  if (row === null) continue;
2251
2261
  skin[p.name] = { [p.name]: row.attachment };
2252
- slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
2262
+ addSlots(p.name, { name: p.name, bone: segs[0].bone, attachment: p.name });
2253
2263
  meshReport.push(row.report);
2254
2264
  continue;
2255
2265
  }
@@ -2329,7 +2339,7 @@ export function buildRig(
2329
2339
  const uvs: number[] = [];
2330
2340
  for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
2331
2341
  skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
2332
- slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
2342
+ addSlots(p.name, { name: p.name, bone: segs[0].bone, attachment: p.name });
2333
2343
  meshReport.push({
2334
2344
  part: p.name,
2335
2345
  vertices: lm.vertices.length,
@@ -2558,14 +2568,19 @@ export function buildRig(
2558
2568
  };
2559
2569
  }
2560
2570
  const hasAlternateFaceSet = sets !== undefined && Object.values(sets.sets).some((e) => Object.hasOwn(e.slots, 'face'));
2571
+ // Read every base/alternate binding before rebinds; publish only the slot
2572
+ // permutation below, after stops/turn have used the original enumeration.
2573
+ const limbLayout = coalesceLimbSlots(cfg.limbs, bones, slots, skin);
2574
+ const explicitLayout = placeLimbLayouts(cfg.limbs, bones, limbLayout, skin, slotFamilies);
2561
2575
  // ---- leg stops (issue #404) --------------------------------------------
2562
2576
  // Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
2563
2577
  // from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
2564
- const stops = cfg.stops === undefined ? null : applyStops(cfg.stops, chains, bones, slots, skin);
2578
+ const stops = cfg.stops === undefined ? null : applyStops(cfg.stops, chains, bones, slots, skin, (original, emitted) => layoutAlias(explicitLayout.plan, original, emitted));
2565
2579
  // ---- the turn control (issue #251) -------------------------------------
2566
2580
  // Only when the config has `turn`: every other build adds nothing here, so no byte moves.
2567
2581
  if (cfg.turn !== undefined) refuseIfAny(turnRiderProblems(cfg.turn, cfg, parts.parts));
2568
2582
  const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, slots, skin, cfg.constraints ?? []);
2583
+ for (const row of turned?.rows ?? []) for (const t of row.twins) layoutAlias(explicitLayout.plan, t.bone, t.twin);
2569
2584
  // issue #536: every row names the bones the emitted attachment binds, read once the turn and the stops have
2570
2585
  // rebound the weights; a rig with neither rebinds nothing, so this reads back the names each row already holds.
2571
2586
  for (const row of meshReport) {
@@ -2580,7 +2595,7 @@ export function buildRig(
2580
2595
  images: 'images',
2581
2596
  skeleton: { x: -CX, y: 0, width: W, height: H },
2582
2597
  bones,
2583
- slots,
2598
+ slots: explicitLayout.slots,
2584
2599
  skins: { default: skin },
2585
2600
  };
2586
2601
  if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
@@ -2633,6 +2648,7 @@ export function buildRig(
2633
2648
  faceAxis,
2634
2649
  turn: turned?.rows ?? null,
2635
2650
  ...(sets === undefined ? {} : { sets }),
2651
+ ...(explicitLayout.plan === undefined ? {} : { layout: explicitLayout.plan }),
2636
2652
  };
2637
2653
  }
2638
2654
 
@@ -2829,12 +2845,13 @@ function applyStops(
2829
2845
  bones: RigBone[],
2830
2846
  slots: RigSpec['slots'],
2831
2847
  skin: RigSpec['skins']['default'],
2848
+ alias?: (original: string, emitted: string) => void,
2832
2849
  ): RigStop[] {
2833
2850
  const out: RigStop[] = [];
2834
2851
  const drawn = new Map<string, string>();
2835
2852
  for (const s of stops) {
2836
2853
  const links = s.links ?? chains.get(s.chain) ?? [];
2837
- for (const l of links) drawn.set(l, `${l}_d`);
2854
+ for (const l of links) { drawn.set(l, `${l}_d`); alias?.(l, `${l}_d`); }
2838
2855
  out.push(stopForRig(s, links));
2839
2856
  }
2840
2857
  for (const b of bones) {