@rig-c/parts 1.27.0 → 1.29.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
@@ -102,6 +102,7 @@ read by no CPU stage).
102
102
  | `seethrough.resolution` | assemble — each run's canvas must be `resolution` square (`ASSEMBLE_RUN_CANVAS`); with one run (no `--head`, issue #366) the file may be smaller on either side, and is padded to the `resolution` square, centred | recorded from the See-through runs; 1024 in both examples. With one run it is the square the file is read on: at least the file's longer side (`ASSEMBLE_RUN_CANVAS` otherwise), and nothing a part records depends on how much larger |
103
103
  | `seethrough.steps`, `.seed`, `.offload` | no CPU stage | recorded from the runs |
104
104
  | `seethrough.head_box` | inputs (cuts the head image there; optional at the first call); assemble — where the head run sits on the painting | **proposed** by `propose --head-box` from the full run, square, source pixels, shifted inside the painting when needed; the head run must then be fed exactly that crop. With one run (no `--head`, issue #359: an artist's own file, §4 *For artists*) it is left out — there is no crop to place — and a config that sets it is refused (`ASSEMBLE_SINGLE_RUN_HEAD_BOX`) |
105
+ | `seethrough.arm_split` | assemble, `assemble --propose-plan` — separates a single full-run `handwear-r` or `handwear-l` layer that holds both overlapping arms into both arm tags | **authored**, optional (issue #439): `{x?: number, line?: [[x, y], …], order: ["handwear-r", "handwear-l"] \| ["handwear-l", "handwear-r"]}` in `seethrough.resolution` full-run pixels. Exactly one of `x` (image column, `0 <= x <= resolution`) or `line` (a polyline of two or more `[x, y]` points in `[0, resolution]`, interpolated in x along y and clamped above/below its endpoints) is required (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`), and `order` is `[back_tag, front_tag]`, the draw order of the two separated arm layers (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`). Pixels at `< splitX(y)` go to `handwear-r` (image left, the subject's right arm) and pixels at `>= splitX(y)` go to `handwear-l` (image right, the subject's left arm). A split line that cuts a horizontal opaque run of the arm layer into more than two segments is refused (`ASSEMBLE_ARM_SPLIT_CUTS_ARM`). Without the declaration, when one arm layer's 8-connected component reaches both wrists under `propose --keypoints`, `propose` emits an `overlapping-arms` `LINT` naming the part, both wrists and `seethrough.arm_split`; absent, no byte of any output moves |
105
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` |
106
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*) |
107
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 |
@@ -469,6 +470,13 @@ layers, as follows; every step is arithmetic on the declared bones and the layer
469
470
  See-through's one `legwear` holding both legs — each pixel goes to the limb whose polyline (its
470
471
  bones' origins in order, then the last bone's end) passes nearest the pixel's centre, a tie to
471
472
  the limb declared first. No polygon is drawn, and each leg's lower part carries its own cap.
473
+ When the two limbs cross in an X on one part (issue #446), nearest-bone splitting cuts a straight
474
+ line through the crossing; declare `config.limbs.<covering>.split` on the covering limb — either
475
+ `{"line": [[x, y], …]}` (a polyline of two or more points in rig px along the covering limb's
476
+ seam) or `{"edge": "painting"}` (traces the covering limb's dark outline stroke at or below the
477
+ part's Otsu luma split, or its uncrossed silhouette width, across the crossing) — so the covering
478
+ limb's pixels stay on the covering limb and the seam follows the limb. Without `split`, the
479
+ nearest-bone split stays and `propose`'s `LINT` names the crossing.
472
480
  - **The joint line.** Between two segments the joint is the lower bone's origin, and the line
473
481
  runs through it perpendicular to the limb's axis there — the sum of the upper bone's and the
474
482
  lower bone's unit directions, so on a bent limb the bisector. A pixel whose centre is past the
@@ -1587,8 +1595,8 @@ What each rule drafts (`src/cloth.ts` holds them):
1587
1595
 
1588
1596
  | rule | drafts | from |
1589
1597
  | --- | --- | --- |
1590
- | where | `bottomwear`: from `hip`, the root row 20 px below hip's y (the keyed chains' row), the tip row 4 px above the part's bottom. `back hair` that reaches below the shoulders: from `head`, per side of the eye axis (twin tails are a side each), the eye line to 4 px above its bottom. `front hair`: each strand that hangs below the chin (the lock rule's), from `head`, 0.22 face heights above the eye line to 5 px below the strand; the fringe's three columns keep their two links and their places | the keyed chains' own rows, which these replace |
1591
- | trees | tree k of `chains` owns the cell k/N .. (k+1)/N of the art's width in every row, and its links sit at the cell's centre in the 24-row band there, so a piece that widens fans its trees. On `back hair`, `chains` is optional (issue #390): when omitted, `propose --cloth` derives the largest tree count per side up to 2 (`RECIPE_BACK_HAIR_CHAINS`) that fits the narrower side's opaque width at the eye-line root row (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`) and places every root inside the 15 px dilated art mask; an explicit `chains` that exceeds that width cap or places a root off the art is refused (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`). With `split`, the trunk's tip is the fork, and branch b of `into` follows the centre of its share of the cell. Chains are named `<prefix><k>_` (trunk) and `<prefix><k><a, b, …>_` (branches): `skirt`, `hairback_r`/`hairback_l`, `lockt`. With `split.hold_tip: true` (issues #403, #392), sibling branches keep `strength.branch` across the fork, the mesh's `r` is raised to `grid`, and the rig stage blends each fork's sibling branch weight shares across four passes of 1-ring mesh-edge averaging (self weight 0.5, floor 0.005, cap 8 influences) with trunk and fixed shares held in place, holding the triangles where two branches' cells meet | `chains`, `links`, `split` |
1598
+ | where | `bottomwear`: from `hip`, the root row 20 px below hip's y (the keyed chains' row), the tip row 4 px above the cloth's own bottom under each column (4 px above the part box's bottom when every column reaches the bottom band, or 4 px above where the cloth's alpha ends under that column on a floor-spread skirt whose middle hem ends above bare thighs; a column with no cloth below the root row gets no tree drafted there and emits a note saying so, issue #438). `back hair` that reaches below the shoulders: from `head`, per side of the eye axis (twin tails are a side each), the eye line to 4 px above its bottom. `front hair`: each strand that hangs below the chin (the lock rule's), from `head`, 0.22 face heights above the eye line to 5 px below the strand; the fringe's three columns keep their two links and their places | the keyed chains' own rows, which these replace |
1599
+ | trees | tree k of `chains` owns the cell k/N .. (k+1)/N of the art's width in every row, and its links sit at the cell's centre in the 24-row band there, so a piece that widens fans its trees. On `back hair`, `chains` is optional (issues #390, #445): when omitted, `propose --cloth` derives the tree count on each side from the side's **visible hair masses** — connected components of the `back hair` part's alpha below the eye-line root row (after skipping any undivided crown rows that bridge continuously across the eye axis), merged when they touch within one mesh grid cell — drafting one tree per mass, rooted at that mass's own top row and with every link inside that mass's column range; `propose`'s `LINT` names the masses it found and their columns when a side's tree count or link leaves them. An explicit `chains` that exceeds what the narrower side's opaque width at the eye-line root row holds (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`) or places a root off the 15 px dilated art is refused (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`). With `split`, the trunk's tip is the fork, and branch b of `into` follows the centre of its share of the cell. Chains are named `<prefix><k>_` (trunk) and `<prefix><k><a, b, …>_` (branches): `skirt`, `hairback_r`/`hairback_l`, `lockt`. With `split.hold_tip: true` (issues #403, #392), sibling branches keep `strength.branch` across the fork, the mesh's `r` is raised to `grid`, and the rig stage blends each fork's sibling branch weight shares across four passes of 1-ring mesh-edge averaging (self weight 0.5, floor 0.005, cap 8 influences) with trunk and fixed shares held in place, holding the triangles where two branches' cells meet | `chains`, `links`, `split` |
1592
1600
  | lengths | link j's length is the first's times q^j, q = `length_ratio`^(1/(links − 1)), the whole spanning root row to tip row: the links shorten toward the tip | `length_ratio` (default 0.5, below) |
1593
1601
  | stiffness | a trunk link takes `strength.trunk`, a branch link `strength.branch`, and the edge — the outermost branch on each outer side of the piece (both sides of a skirt or a strand; the outer side of each back-hair side; or, under `split.hold_tip: true` with `strength` left out, kept at `strength.branch` so a fork's sibling branches do not pull apart at the default 2:1 stiffness), or without `split` the outermost tree — `strength.edge`; a fringe column is a trunk, its outer two the edge. **Strength does not fall along a chain**: a tip reads soft because each link turns on top of every link above it, so the motion accumulates down the chain at one strength. Only the edge is declared softer | `strength` (default trunk 100, branch 100, edge 50, below) |
1594
1602
  | response | link j of an n-link path takes `wind` and `gravity` at j/(n − 1) of the way from the root value to the tip value; a single-link path takes the tip value. Each is a scalar **multiplier** of the environment the consumer sets (spine-core's skeleton `windX, windY`, `gravityX, gravityY`; rig-c 2.52.0's live track `setEnvironment`, rig-play 1.10.0's `environment()`): no direction is drafted | `wind`, `gravity` |
@@ -2121,7 +2129,7 @@ move off it (`AS245`).
2121
2129
  | `layers` | the table, then one `author-made: layer "author:<label>" — …` line per layer the author made (group `author` in the table; §5, *A layer the author made*), then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
2122
2130
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
2123
2131
  | `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. Then the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…), "<holder>" drawn in front of "<part>"` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order — that layer is not what the painting shows there (none on the five public paintings); and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then — with `config.limbs` (issue #349) — one `limb cut: "<part>" opaque <n> = "<segment part>" <px> + …` line per part a limb takes and one `limb cap:` line per joint, its cap's pixels from the cap image, from the layer and missing, and its box — under `cap_shape: "disc"` also from the upper segment, under `edge: "feather"` the pixels faded, under `cut_band` the line the cut was placed on and the stroke pixels it and the joint line cross (issue #360) (§3, *Limbs: cut at their joints*), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (reference-demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
2124
- | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `reference-sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — cut the part with `assemble.cuts` to separate them; `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
2132
+ | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT hairback_… on mesh '<part>' (side <r\|l>): <N> tree(s) for <M> visible hair mass(es) (mass 0 columns x=<minX>..<maxX>, y=<minY>..<maxY>; …)` — back-hair cloth trees whose count does not match the side's visible hair masses or whose link or tip leaves its mass's column range (issue #445): omit `chains` on `cloth."back hair"` or draft one tree per visible hair mass inside its columns; `LINT <a_limb> and <b_limb> on <part> (<from>) cross at [x, y] with no per-limb split: …` — two limbs sharing one layer cross in an X without a per-limb split (issue #446): declare `config.limbs.<covering>.split` (`{ "line": [[x, y], …] }` or `{ "edge": "painting" }`, §3 *Which limb*); `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `reference-sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — cut the part with `assemble.cuts` to separate them; `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
2125
2133
  | `compare` | one line per pair (origin, parent, tip, length, direction — or SKIP and why), then the per-row summary, the unmapped bones of each side, `required:`, each side's roles and the `controls:` line | proposal against the config you corrected: the rows you changed and no others. Config against the `rig.json` `build` writes for it: measured on both examples, every origin within 6.19e-7 px (the rig's offsets are written to 6 places) and every chain link's tip and length within 4.99e-4 px (`length` is written to 3), printed to three places as `0.000` (a signed figure as `+0.000` or `-0.000`); `parent … is the right's ancestor at depth 2, <bone>_ctl between` for every keyed mesh bone, no `DIFFERENT` and no `NOT MAPPED` | `parent DIFFERENT: left a, right b` — a bone hangs from another parent than the other side's; `frames not related` — a rig with no stage (`skeleton.width`/`height`), two configs at different `rig_scale`: the distance rows SKIP, so declare the frame in a `--map` file if the author knows it; `FAIL STRUCTURE_REQUIRED_PRESENT` — a bone the map requires is missing through its pairs |
2126
2134
  | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`); in `rig.json`, each chain link's (or its `_ctl`'s) `length` and `rotation` | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages); every link's `length` the distance to the next link and `rotation` its direction (Spine degrees, counter-clockwise, y up, local to the parent), every offset under it — a child bone's `x, y`, a weight's bind `x, y`, a region's `x, y` — in that turned frame, and a region on a link carrying `rotation` that turns it back upright. Nothing moved: `flattenRig` (`src/rig.ts`) turns every offset back and gives the unturned numbers | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
2127
2135
  | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:` (and `loop physics:` when the rig declares a physics constraint, §7), `seam:`, the six judgement lines, then — when `motion.json` holds an animation, the idle included — `ANIMATIONS_DO_NOT_FOLD` and one `folds` line per folding mesh, rigc's own (issues #320, #333), then — where a `footwear` part rides a bone that hangs from another — `FEET_STAY_LEVEL` and one `foot` line per such part (issue #348), then — when `parts.json` names a `handwear` part and there is an idle — `STRESS_OFF_CHAIN` (issue #347), then — where `parts.json` holds a limb segment's part — `LIMB_ONE_BONE` and one `limb` line per such part (issue #349), then — where a limb record carries a `range` — `SEAM_OVER_RANGE` and one `joint` line per joint and angle (issue #360), `RECOMPOSITE_HOLES` and, under `--source`, `SETUP_POSE_VS_SOURCE` (§7), `check.json`; the last line says how many of the bars measured — nine, ten with the fold bar, one more each with the feet-level bar, the stress bar, the limb bar and the range bar — and names the ones that said SKIP | `check: PASS; 11 of 11 bar(s) measured, 0 skipped` on `demo` and `scarf`, whose sleeves each ride one chain; `check: PASS; 10 of 11 bar(s) measured, 1 skipped (STRESS_OFF_CHAIN)` on `reference-demo` and `reference-sample`, whose one `sleeves` mesh rides both sleeve chains (issue #347) — no example writes `FEET_STAY_LEVEL`, every shoe riding `root`; a judgement line SKIP only where the character lacks what it reads; on a rig spec with no `parts.json` or no `idle` (a merged rig, §7 *Measuring a rig rig-parts did not assemble*), PASS with the skipped bars named | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6); `CHECK_ANIMATIONS_DO_NOT_FOLD` — a clip turns a mesh over (§6); `CHECK_STRESS_OFF_CHAIN` — a sleeve weighted off its chain (§6); `CHECK_LIMB_ONE_BONE` — a limb part on a second bone (§6); `CHECK_SEAM_OVER_RANGE` — a joint that opens when bent (§6) |
@@ -2637,6 +2645,7 @@ See-through with another seed.
2637
2645
  | <a id="CONFIG_CLOTH_PARTS_UNIQUE"></a>`CONFIG_CLOTH_PARTS_UNIQUE` | a `cloth` recipe sets both `part` and `parts`, or its part list names the same part more than once (issue #437) | set at most one of `part` or `parts`, and list each part once in root-to-tip order |
2638
2646
  | <a id="CONFIG_CLOTH_SPRING_STABLE"></a>`CONFIG_CLOTH_SPRING_STABLE` | a `cloth` recipe's strength — declared, or the default 100 / 100 / 50 — whose spring is unstable at a damping the recipe runs at (`damping`, `alternate_damping`, or rig-c's 0.85 when `damping` is left out): k = strength·massInverse·step² at or past 2(1 + damping^(60·step)), step 1/60 and mass 1 (rig-c's defaults; the recipe sets neither). The detail gives k and the bound | a lower strength, or a higher damping |
2639
2647
  | <a id="CONFIG_TURN_BONE_FREE"></a>`CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
2648
+ | <a id="CONFIG_TURN_DEFORM_RANGE"></a>`CONFIG_TURN_DEFORM_RANGE` | an entry in `turn.deform` lies outside its allowed `[min, max]` interval in `FACE_DEFORM_RANGES` (`src/turn.ts`, issue #436); the detail gives the value found, the interval required and the default fraction | put that `turn.deform.<key>` inside the stated range, or remove it to take the Set B ×2.5 default |
2640
2649
  | <a id="CONFIG_PROPOSED_RIG_SCALE"></a>`CONFIG_PROPOSED_RIG_SCALE` | `proposed_rig_scale` (the `scale_rig_per_source` `propose` drafted the rig sections at, issue #354) differs from `assemble.rig_scale`: the coordinates in `bones`, `meshes`, `limbs` and `motion` are in that rig canvas's pixels and do not scale when `assemble.rig_scale` changes | re-run `assemble` and `propose` at the new `assemble.rig_scale`, or restore `assemble.rig_scale` to `proposed_rig_scale` |
2641
2650
 
2642
2651
  ### inputs
@@ -2689,6 +2698,7 @@ says why each requirement exists and what breaks without it.
2689
2698
  | <a id="ASSEMBLE_SINGLE_RUN_FACE"></a>`ASSEMBLE_SINGLE_RUN_FACE` | with no `--head` (issue #359), the one run holds no painted layer for one of `face`, `eyewhite-r`, `eyewhite-l` — the parts the head run exists to separate (one problem per tag) | paint the layer, or give the head run with `--head` |
2690
2699
  | <a id="ASSEMBLE_SINGLE_RUN_PLAN"></a>`ASSEMBLE_SINGLE_RUN_PLAN` | with no `--head`, a plan row takes `"head"`, or an `extend_below_crop` entry continues a part below a crop there is not | `"full"` in the row; remove the entry |
2691
2700
  | <a id="ASSEMBLE_SINGLE_RUN_HEAD_BOX"></a>`ASSEMBLE_SINGLE_RUN_HEAD_BOX` | with no `--head`, the config sets `seethrough.head_box`, which places a head run there is not | remove `head_box`, or give the head run with `--head` |
2701
+ | <a id="ASSEMBLE_ARM_SPLIT_CUTS_ARM"></a>`ASSEMBLE_ARM_SPLIT_CUTS_ARM` | `seethrough.arm_split` cuts a horizontal opaque run of the arm layer into more than two segments (issue #439; the detail gives the row, the run and the split x) | `seethrough.arm_split`: move `x` or `line` so each horizontal opaque run of the arm layer is split at most once |
2692
2702
  | <a id="ASSEMBLE_PLAN_TAG_IN_RUN"></a><a id="ASSEMBLE_EXTEND_TAG_IN_RUN"></a>`ASSEMBLE_PLAN_TAG_IN_RUN`, `ASSEMBLE_EXTEND_TAG_IN_RUN` | an entry takes a tag its run does not hold (the detail lists what it does hold) | that entry's run or tag |
2693
2703
  | <a id="ASSEMBLE_PART_OPAQUE"></a>`ASSEMBLE_PART_OPAQUE` | a part ended with no opaque pixel | drop the entry, or take the tag from the other run |
2694
2704
  | <a id="ASSEMBLE_PATCH_BOX_INSIDE"></a>`ASSEMBLE_PATCH_BOX_INSIDE` | a patch's box reaches past the rig (the painting times `rig_scale`; the detail gives the size) | that patch's `box` — it is in rig pixels, not painting pixels |
@@ -2716,10 +2726,10 @@ says why each requirement exists and what breaks without it.
2716
2726
  | <a id="PROPOSE_SOURCE_PRESENT"></a><a id="PROPOSE_PNG_PRESENT"></a><a id="PROPOSE_PNG_MATCHES_BOX"></a>`PROPOSE_SOURCE_PRESENT`, `PROPOSE_PNG_PRESENT`, `PROPOSE_PNG_MATCHES_BOX` | the painting or a part PNG is missing, or a PNG is not its box | `--source`, `--parts` (re-run assemble) |
2717
2727
  | <a id="PROPOSE_FACE_PRESENT"></a>`PROPOSE_FACE_PRESENT` | no part comes from a `face` layer, and the face-less fallback cannot derive a face box either: it needs the head run's `neck` and at least one head-run `front hair`/`back hair` part whose top is above the neck's, and the message names the half that is missing. With both, `propose` does not refuse — it derives the box and says so in its first note (§5) | `assemble.plan`: take the face, or the head run's hair and neck |
2718
2728
  | <a id="PROPOSE_CUT_FROM_RECORDED"></a>`PROPOSE_CUT_FROM_RECORDED` | two parts share one layer and none of them records `cut_from`: a `parts.json` an `assemble` before 1.6.0 wrote with `assemble.cuts`. The records are in draw order, so which is the plan part and which the piece cannot be read off the file; the message names the parts and the layer. Every other reader still takes the file | `--parts`: re-run assemble (1.6.0+) to record `cut_from` |
2719
- | <a id="PROPOSE_LIMB_PARTS"></a>`PROPOSE_LIMB_PARTS` | `parts.json` holds parts `config.limbs` cut at their joints (issue #349); the limbs are drafted from the parts before the cut, which the rules read by layer | `--parts` a build assembled with the config's `limbs` left out; keep the block the config holds (its caps and ranges are the author's). `propose --from-config` reads the config's bones on either build |
2729
+ | <a id="PROPOSE_LIMB_PARTS"></a>`PROPOSE_LIMB_PARTS` | `parts.json` holds a standalone or incomplete limb part `config.limbs` cut at its joints (issues #349, #440) that cannot be reassembled into the source part `of` (a missing segment or `cap` record, or `of` still present beside the limb parts); a complete `assemble` output with `config.limbs` reconstructs its pre-limb parts in the same run | `--parts` a complete `assemble` output (every segment of each limb, with its `cap` record) or a build assembled with `config.limbs` left out; `propose --from-config` reads the config's bones on either build |
2720
2730
  | <a id="PROPOSE_CLOTH_DRAFTED"></a>`PROPOSE_CLOTH_DRAFTED` | with `--cloth` (issue #364): the config's `cloth` block holds a recipe for a tag the proposer drafts no chain on — no part has that tag, or every `back hair` part ends above the shoulders (a bun bone, no chain). A `bottomwear` recipe with no `bottomwear` part names every `topwear` part shaped like a skirt (issue #402) | leave that recipe out, or assemble the layer it is for, or declare the named part in `cloth.bottomwear.part` |
2721
2731
  | <a id="PROPOSE_CLOTH_PART"></a>`PROPOSE_CLOTH_PART` | with `--cloth` (issues #402, #437): `cloth.bottomwear.part` (a string) names no part, a part or cut piece whose source is not `topwear` (a `bottomwear` part takes the recipe by its tag already), or a target whose box ends above the hip + 0.25 face heights, the topwear rule's waist line; or `cloth.<tag>.parts` (or `part` as a list, issue #437) names a part not in `parts.json`, a part no link of the drafted tree crosses (`spanCrosses == 0`), or parts out of root-to-tip depth order along the chain | name the `topwear` part or its cut skirt piece that holds the skirt (or leave `part` out), or list only parts in `parts.json` that the drafted tree links cross in root-to-tip order |
2722
- | <a id="PROPOSE_CLOTH_BACK_HAIR_CHAINS"></a>`PROPOSE_CLOTH_BACK_HAIR_CHAINS` | with `--cloth` (issue #390): `cloth."back hair".chains` exceeds what the narrower side's opaque width at the eye-line root row holds at the part's mesh grid (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`), or places a tree root outside the 15 px dilated art mask of the `back hair` part (e.g. an inner twin-tail root over the face where the hair has no pixel); the message names the part, each side's root-row width, the grid spacing, and any off-art root with its pixel alpha | lower `cloth."back hair".chains` to at most the max fit named in the message, or omit `chains` so `propose --cloth` derives the tree count per side from the root-row tail widths |
2732
+ | <a id="PROPOSE_CLOTH_BACK_HAIR_CHAINS"></a>`PROPOSE_CLOTH_BACK_HAIR_CHAINS` | with `--cloth` (issues #390, #445): `cloth."back hair".chains` exceeds what the narrower side's opaque width at the eye-line root row holds at the part's mesh grid (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`), or places a tree root outside the 15 px dilated art mask of the `back hair` part (e.g. an inner twin-tail root over the face where the hair has no pixel); the message names the part, each side's root-row width, the grid spacing, and any off-art root with its pixel alpha | lower `cloth."back hair".chains` to at most the max fit named in the message, or omit `chains` so `propose --cloth` derives one tree per visible hair mass on each side |
2723
2733
  | <a id="PROPOSE_ACCESSORY_BODY"></a>`PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
2724
2734
  | <a id="PROPOSE_TURN_RUN_GIVEN"></a><a id="PROPOSE_TURN_LAYER_PRESENT"></a><a id="PROPOSE_TURN_DEPTH_PRESENT"></a>`PROPOSE_TURN_RUN_GIVEN`, `PROPOSE_TURN_LAYER_PRESENT`, `PROPOSE_TURN_DEPTH_PRESENT` | with `--full`/`--head` (issue #251): a part came from a run whose layers were not given, the run given holds no layer of the part's tag, or its layer carries no `depth_median` while another part's layer carries one; a run with no depth on any part's layer (a PSD, or an export of one) is not refused — the turn is skipped, and the note says `turn: SKIP` and names the run (issue #359) | give the run the parts were assembled from, as the wrapper's `layers.json` |
2725
2735
  | <a id="BASIS_RECORDS_EVERY_BONE"></a><a id="BASIS_NAMES_RESOLVE"></a><a id="BASIS_KIND_KNOWN"></a><a id="BASIS_KIND_HOLDS"></a>`BASIS_RECORDS_EVERY_BONE`, `BASIS_NAMES_RESOLVE`, `BASIS_KIND_KNOWN`, `BASIS_KIND_HOLDS` | `propose --keypoints`'s record of how it placed each bone, `basis.json`, does not hold to the proposal it was written with: a proposed bone with no record, one recorded twice or out of the proposal's order; a part, bone or frame quantity it names that is not there; a placement kind outside the known ones; or a placement that does not hold what its kind says (a joint placement with no joint, a non-joint one with a joint, a derived one with no bone, a constant that is not finite). Nothing is written | a proposer bug, not an input problem: report it with the line |
package/docs/STABILITY.md CHANGED
@@ -118,7 +118,8 @@ part name, `[i]` an entry of a list):
118
118
  - **generation.sampler** — required: `steps`, `cfg`, `sampler`, `scheduler`
119
119
  - **generation.control** — required: `skeleton`, `strength`, `end_percent`; optional: `model`
120
120
  - **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
121
- - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`
121
+ - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
122
+ - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
122
123
  - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`
123
124
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
124
125
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
@@ -160,8 +161,9 @@ part name, `[i]` an entry of a list):
160
161
  - **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
161
162
  - **motion.blink.still.&lt;part&gt;** — required: `row`, `bone`
162
163
  - **motion.animations_from**, the file it names — required: `spec`, `animations`
163
- - **turn** — required: `pivot`, `depths`
164
+ - **turn** — required: `pivot`, `depths`; optional: `deform`
164
165
  - **turn.depths.&lt;part&gt;** — a See-through depth
166
+ - **turn.deform.&lt;key&gt;** — a per-degree fraction or coupling share (FACE_DEFORM_FRACTIONS, issue #436)
165
167
  - **scene** — no field (`{}`; a record or an annotation only)
166
168
  - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `stops` when supplied, `motion` under the rules above, `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes`
167
169
  <!-- /stable:config -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.27.0",
3
+ "version": "1.29.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/assemble.ts CHANGED
@@ -99,12 +99,12 @@
99
99
  *
100
100
  * Pure: no clock, no randomness, no file access. The CLI reads and writes.
101
101
  */
102
- import type { BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
102
+ import type { ArmSplit, BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
103
103
  import { type Problem, refuseIfAny } from './errors.ts';
104
104
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
105
105
  import { padToSquare, squarePad } from './inputs.ts';
106
106
  import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
107
- import { assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
107
+ import { assignCrossedLimbs, assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
108
108
  import {
109
109
  alphaComposite,
110
110
  connectedComponents,
@@ -123,7 +123,7 @@ import {
123
123
  warpAffine,
124
124
  } from './raster/index.ts';
125
125
  import { pyRound } from './round.ts';
126
- import { AUTHOR_NO_ROLE, readAuthorTag } from './tags.ts';
126
+ import { AUTHOR_NO_ROLE, type LayerTagReading, readAuthorTag, readLayerTag } from './tags.ts';
127
127
 
128
128
  const f32 = Math.fround;
129
129
 
@@ -232,6 +232,8 @@ export interface Geometry {
232
232
  * per source pixel, and the painting must be the file's size.
233
233
  */
234
234
  single?: boolean;
235
+ /** `seethrough.arm_split` (issue #439): separates overlapping arms on the full run into `handwear-r` and `handwear-l` in the declared order. */
236
+ armSplit?: ArmSplit;
235
237
  }
236
238
 
237
239
  /**
@@ -323,6 +325,8 @@ export interface AssembleInput {
323
325
  caps?: ReadonlyMap<string, Raster>;
324
326
  /** One run (issue #359): `head` is `full`, `headBox` {@link singleRunHeadBox}, and {@link checkSingleRun} holds the plan to it. Absent: two runs. */
325
327
  single?: boolean;
328
+ /** `seethrough.arm_split` (issue #439): separates overlapping arms on the full run into `handwear-r` and `handwear-l` in the declared order. */
329
+ armSplit?: ArmSplit;
326
330
  }
327
331
 
328
332
  /** The derived numbers of the two runs' geometry. */
@@ -489,6 +493,238 @@ export function runLayers(set: LayerSet, resolution: number): RunLayer[] {
489
493
  });
490
494
  }
491
495
 
496
+ /**
497
+ * The integer split column(s) `armSplit` defines on run row `y`: pixels with
498
+ * `x < col` lie on the `handwear-r` side (left of the split in the image) and
499
+ * pixels with `x >= col` lie on the `handwear-l` side.
500
+ */
501
+ function splitColumnsAtRow(y: number, armSplit: ArmSplit): number[] {
502
+ if (armSplit.x !== undefined) return [Math.ceil(armSplit.x)];
503
+ const pts = armSplit.line ?? [];
504
+ if (pts.length === 0) return [0];
505
+ const cols: number[] = [];
506
+ for (let i = 0; i + 1 < pts.length; i++) {
507
+ const [xA, yA] = pts[i];
508
+ const [xB, yB] = pts[i + 1];
509
+ if (y < Math.min(yA, yB) || y > Math.max(yA, yB)) continue;
510
+ if (yA === yB) {
511
+ cols.push(Math.ceil(Math.min(xA, xB)));
512
+ if (xA !== xB) cols.push(Math.ceil(Math.max(xA, xB)));
513
+ } else {
514
+ cols.push(Math.ceil(xA + ((y - yA) * (xB - xA)) / (yB - yA)));
515
+ }
516
+ }
517
+ if (cols.length === 0) {
518
+ let minY = pts[0][1];
519
+ let minX = pts[0][0];
520
+ let maxY = pts[0][1];
521
+ let maxX = pts[0][0];
522
+ for (const [px, py] of pts) {
523
+ if (py < minY) {
524
+ minY = py;
525
+ minX = px;
526
+ }
527
+ if (py > maxY) {
528
+ maxY = py;
529
+ maxX = px;
530
+ }
531
+ }
532
+ cols.push(Math.ceil(y < minY ? minX : maxX));
533
+ }
534
+ return [...new Set(cols)].sort((a, b) => a - b);
535
+ }
536
+
537
+ /**
538
+ * Separate an overlapping handwear layer of the full run into `handwear-r` and
539
+ * `handwear-l` along `seethrough.arm_split` (issue #439), placed in the draw
540
+ * order `armSplit.order` declares.
541
+ *
542
+ * Refuses under `ASSEMBLE_ARM_SPLIT_CUTS_ARM` when:
543
+ * - the polyline crosses a single horizontal opaque run at two or more columns;
544
+ * - on a row where the two arms already have separate horizontal opaque runs,
545
+ * the split falls strictly inside one of those runs;
546
+ * - either resulting arm layer is left with 0 opaque pixels, or an arm is cut
547
+ * into disconnected components.
548
+ */
549
+ export function applyArmSplit(set: LayerSet, resolution: number, armSplit: ArmSplit): LayerSet {
550
+ const handLayers = set.layers.filter((l) => l.name === 'handwear-r' || l.name === 'handwear-l');
551
+ if (handLayers.length === 0) {
552
+ refuseIfAny([
553
+ {
554
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
555
+ object: 'config.seethrough.arm_split',
556
+ detail: `the full run (${set.source}) holds neither "handwear-r" nor "handwear-l"; a handwear layer with opaque pixels on both sides of the split is required`,
557
+ },
558
+ ]);
559
+ }
560
+ const combinedRaw = newRaster(resolution, resolution);
561
+ for (const l of handLayers) {
562
+ const { canvas } = cleanGhosts(placeLayer(l, resolution));
563
+ for (let p = 0; p < resolution * resolution; p++) {
564
+ if (canvas.data[p * 4 + 3] > 0) combinedRaw.data.set(canvas.data.subarray(p * 4, p * 4 + 4), p * 4);
565
+ }
566
+ }
567
+ const { canvas: combined } = cleanGhosts(combinedRaw);
568
+ const rightCanvas = newRaster(resolution, resolution);
569
+ const leftCanvas = newRaster(resolution, resolution);
570
+ let firstCutRun: { y: number; rx0: number; rx1: number; col: number } | null = null;
571
+ for (let y = 0; y < resolution; y++) {
572
+ const runs: Array<[number, number]> = [];
573
+ let x = 0;
574
+ while (x < resolution) {
575
+ if (combined.data[(y * resolution + x) * 4 + 3] <= OPAQUE_ALPHA_ABOVE) {
576
+ x++;
577
+ continue;
578
+ }
579
+ const rx0 = x;
580
+ while (x + 1 < resolution && combined.data[(y * resolution + x + 1) * 4 + 3] > OPAQUE_ALPHA_ABOVE) x++;
581
+ runs.push([rx0, x]);
582
+ x++;
583
+ }
584
+ const cols = splitColumnsAtRow(y, armSplit);
585
+ for (const [rx0, rx1] of runs) {
586
+ const cutting = cols.filter((c) => rx0 < c && c <= rx1);
587
+ if (cutting.length >= 2) {
588
+ refuseIfAny([
589
+ {
590
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
591
+ object: 'config.seethrough.arm_split',
592
+ detail: `cuts the pixel run x = ${rx0}..${rx1} on run row ${y} at columns ${cutting.join(', ')}, splitting one arm's pixel run into multiple parts; a line that crosses each horizontal pixel run at most once is required`,
593
+ },
594
+ ]);
595
+ }
596
+ if (runs.length >= 2 && cutting.length > 0) {
597
+ refuseIfAny([
598
+ {
599
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
600
+ object: 'config.seethrough.arm_split',
601
+ detail: `cuts the pixel run x = ${rx0}..${rx1} on run row ${y} at column ${cutting[0]} into ${rx0}..${cutting[0] - 1} and ${cutting[0]}..${rx1} (row ${y} holds separate arm runs ${runs.map(([a, b]) => `${a}..${b}`).join(', ')}); a split between the arms (${runs[0][1] + 1} <= x <= ${runs[runs.length - 1][0]}) is required so no pixel run of one arm is cut into two parts`,
602
+ },
603
+ ]);
604
+ }
605
+ if (cutting.length === 1 && firstCutRun === null) firstCutRun = { y, rx0, rx1, col: cutting[0] };
606
+ }
607
+ const col = (runs.length === 1 ? cols.find((c) => runs[0][0] < c && c <= runs[0][1]) : undefined) ?? cols[0];
608
+ for (let cx = 0; cx < resolution; cx++) {
609
+ const p = (y * resolution + cx) * 4;
610
+ if (combined.data[p + 3] === 0) continue;
611
+ const dst = cx < col ? rightCanvas.data : leftCanvas.data;
612
+ dst.set(combined.data.subarray(p, p + 4), p);
613
+ }
614
+ }
615
+ const maskOf = (r: Raster): { mask: Mask; opaque: number; x0: number; y0: number; x1: number; y1: number } => {
616
+ const mask = newMask(resolution, resolution);
617
+ let opaque = 0;
618
+ let x0 = resolution;
619
+ let y0 = resolution;
620
+ let x1 = -1;
621
+ let y1 = -1;
622
+ for (let y = 0; y < resolution; y++) {
623
+ for (let x = 0; x < resolution; x++) {
624
+ const a = r.data[(y * resolution + x) * 4 + 3];
625
+ if (a > OPAQUE_ALPHA_ABOVE) {
626
+ mask.data[y * resolution + x] = 1;
627
+ opaque++;
628
+ }
629
+ if (a > 0) {
630
+ if (x < x0) x0 = x;
631
+ if (x > x1) x1 = x;
632
+ if (y < y0) y0 = y;
633
+ if (y > y1) y1 = y;
634
+ }
635
+ }
636
+ }
637
+ return { mask, opaque, x0, y0, x1, y1 };
638
+ };
639
+ const mr = maskOf(rightCanvas);
640
+ const ml = maskOf(leftCanvas);
641
+ if (mr.opaque === 0 || ml.opaque === 0) {
642
+ const emptyTag = mr.opaque === 0 ? 'handwear-r' : 'handwear-l';
643
+ const fullTag = mr.opaque === 0 ? 'handwear-l' : 'handwear-r';
644
+ refuseIfAny([
645
+ {
646
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
647
+ object: 'config.seethrough.arm_split',
648
+ detail: `leaves "${emptyTag}" with 0 opaque pixels and "${fullTag}" with ${mr.opaque + ml.opaque}; a split that separates the overlapping handwear into two non-empty arm layers is required`,
649
+ },
650
+ ]);
651
+ }
652
+ const ccR = connectedComponents(mr.mask, 8);
653
+ const ccL = connectedComponents(ml.mask, 8);
654
+ for (const [tag, cc] of [
655
+ ['handwear-r', ccR],
656
+ ['handwear-l', ccL],
657
+ ] as const) {
658
+ const comps = cc.count - 1;
659
+ if (comps > 1) {
660
+ const cutNote = firstCutRun !== null ? ` (cutting pixel run x = ${firstCutRun.rx0}..${firstCutRun.rx1} on run row ${firstCutRun.y} at column ${firstCutRun.col})` : '';
661
+ refuseIfAny([
662
+ {
663
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
664
+ object: 'config.seethrough.arm_split',
665
+ detail: `cuts "${tag}" into ${comps} disconnected components${cutNote}; a split that separates the two arms without cutting one arm into two parts is required`,
666
+ },
667
+ ]);
668
+ }
669
+ }
670
+ const refLayer = handLayers.reduce((best, l) => (l.opaquePx > best.opaquePx ? l : best), handLayers[0]);
671
+ const makeLayer = (name: 'handwear-r' | 'handwear-l', canvas: Raster, m: ReturnType<typeof maskOf>): Layer => ({
672
+ name,
673
+ tag: readLayerTag(name) as LayerTagReading,
674
+ file: refLayer.file,
675
+ pixels: crop(canvas, m.x0, m.y0, m.x1 - m.x0 + 1, m.y1 - m.y0 + 1),
676
+ left: m.x0,
677
+ top: m.y0,
678
+ right: m.x1 + 1,
679
+ bottom: m.y1 + 1,
680
+ depth: refLayer.depth,
681
+ drawOrder: 0,
682
+ opaquePx: m.opaque,
683
+ });
684
+ const byTag = {
685
+ 'handwear-r': makeLayer('handwear-r', rightCanvas, mr),
686
+ 'handwear-l': makeLayer('handwear-l', leftCanvas, ml),
687
+ };
688
+ const insertAt = set.layers.findIndex((l) => l === refLayer);
689
+ const nextLayers: Layer[] = [];
690
+ set.layers.forEach((l, i) => {
691
+ if (i === insertAt) nextLayers.push(byTag[armSplit.order[0]], byTag[armSplit.order[1]]);
692
+ if (l.name !== 'handwear-r' && l.name !== 'handwear-l') nextLayers.push({ ...l });
693
+ });
694
+ nextLayers.forEach((l, i) => {
695
+ l.drawOrder = i;
696
+ });
697
+ return { ...set, layers: nextLayers };
698
+ }
699
+
700
+ /**
701
+ * Apply `seethrough.arm_split`'s draw order to `assemble.plan` (issue #439):
702
+ * a lone `full:handwear-*` row is expanded into `[handwear_r, handwear_l]` in
703
+ * `armSplit.order`, and two `full:handwear-*` rows are ordered as `armSplit.order`
704
+ * declares.
705
+ */
706
+ function armSplitPlan(plan: readonly PlanEntry[], armSplit: ArmSplit | undefined): PlanEntry[] {
707
+ if (armSplit === undefined) return [...plan];
708
+ const fullHwIndices = plan.map((e, i) => (e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l') ? i : -1)).filter((i) => i >= 0);
709
+ if (fullHwIndices.length === 1) {
710
+ const idx = fullHwIndices[0];
711
+ const [t0, t1] = armSplit.order;
712
+ return [...plan.slice(0, idx), [aliasOf(t0), 'full', t0], [aliasOf(t1), 'full', t1], ...plan.slice(idx + 1)];
713
+ }
714
+ if (fullHwIndices.length === 2) {
715
+ const [i0, i1] = fullHwIndices;
716
+ const e0 = plan[i0];
717
+ const e1 = plan[i1];
718
+ if (e0[2] !== e1[2] && e0[2] !== armSplit.order[0]) {
719
+ const out = [...plan];
720
+ out[i0] = e1;
721
+ out[i1] = e0;
722
+ return out;
723
+ }
724
+ }
725
+ return [...plan];
726
+ }
727
+
492
728
  // ---------------------------------------------------------------------------
493
729
  // step 2 — resample into rig space
494
730
  // ---------------------------------------------------------------------------
@@ -1905,6 +2141,8 @@ export interface LimbPart {
1905
2141
  part: string;
1906
2142
  /** The part whose pixels it takes. */
1907
2143
  of: string;
2144
+ /** Issue #446: true when this limb declares `split` and covers the other limb(s) taking `of`. */
2145
+ covering?: boolean;
1908
2146
  record: LimbRecord;
1909
2147
  }
1910
2148
 
@@ -1972,7 +2210,8 @@ export function limbLines(counts: readonly LimbCount[]): string[] {
1972
2210
  * leaves the order, and each limb's parts stand where its first segment's
1973
2211
  * part stood, lowest segment first (drawn behind), so the upper is drawn over
1974
2212
  * the lower at every joint; limbs whose first segment takes one part stand
1975
- * there in `config.limbs` order. Without limbs, `order` as it was.
2213
+ * there in `config.limbs` order (or with the `split` covering limb drawn in
2214
+ * front, issue #446). Without limbs, `order` as it was.
1976
2215
  */
1977
2216
  export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[], cuts: readonly Cut[], parts: readonly LimbPart[]): DrawSlot[] {
1978
2217
  if (parts.length === 0) return [...order];
@@ -1985,7 +2224,9 @@ export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[]
1985
2224
  out.push(d);
1986
2225
  continue;
1987
2226
  }
1988
- const limbs = [...new Set(parts.filter((q) => q.segment === 0 && q.of === n).map((q) => q.limb))];
2227
+ const limbs = [...new Set(parts.filter((q) => q.segment === 0 && q.of === n).map((q) => q.limb))].sort(
2228
+ (a, b) => (parts.some((q) => q.limb === a && q.of === n && q.covering === true) ? 1 : 0) - (parts.some((q) => q.limb === b && q.of === n && q.covering === true) ? 1 : 0),
2229
+ );
1989
2230
  for (const l of limbs) {
1990
2231
  const mine = parts.map((q, i) => ({ q, i })).filter(({ q }) => q.limb === l);
1991
2232
  for (const { i } of [...mine].reverse()) out.push({ limb: i });
@@ -2031,8 +2272,9 @@ function cutLimbs(
2031
2272
  const places = bonePlaces(bones);
2032
2273
  const parts: LimbPart[] = [];
2033
2274
  for (const l of names) {
2275
+ const covering = limbs[l].split !== undefined;
2034
2276
  limbs[l].segments.forEach((sg, k) =>
2035
- parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string, ...(sg.range === undefined ? {} : { range: [sg.range[0], sg.range[1]] as [number, number] }) }) } }),
2277
+ parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, ...(covering ? { covering: true } : {}), record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string, ...(sg.range === undefined ? {} : { range: [sg.range[0], sg.range[1]] as [number, number] }) }) } }),
2036
2278
  );
2037
2279
  }
2038
2280
  const polylines = new Map(names.map((l) => [l, limbPolyline(limbs[l], places)]));
@@ -2089,6 +2331,16 @@ function cutLimbs(
2089
2331
  own[p] = names.indexOf(on[on.length === 1 ? 0 : nearestLimb(lns, x, y)]);
2090
2332
  }
2091
2333
  }
2334
+ // Issue #446: when a covering limb on `src` declares `split` (`line` or `edge: "painting"`), reassign `own` so
2335
+ // the covering limb's pixels across the crossing stay on the covering limb and the seam follows the declared split.
2336
+ const coverLimb = on.find((l) => limbs[l].split !== undefined);
2337
+ if (coverLimb !== undefined && on.length >= 2) {
2338
+ const coverIdx = names.indexOf(coverLimb);
2339
+ const underNames = on.filter((l) => l !== coverLimb);
2340
+ const underIndices = underNames.map((l) => names.indexOf(l));
2341
+ const underPolys = underNames.map((l) => polylines.get(l) as Array<[number, number]>);
2342
+ assignCrossedLimbs(coverIdx, polylines.get(coverLimb) as Array<[number, number]>, limbs[coverLimb].split as NonNullable<Limb['split']>, underIndices, underPolys, b.r, OPAQUE_ALPHA_ABOVE, own);
2343
+ }
2092
2344
  // Issue #360 (item 14): each joint of this part that declares cut_band has its cut placed, on the limb's own art.
2093
2345
  for (const l of on) {
2094
2346
  const li = names.indexOf(l);
@@ -2183,8 +2435,18 @@ function cutLimbs(
2183
2435
  }
2184
2436
  return false;
2185
2437
  };
2438
+ const sharedArt = (x: number, y: number): boolean => {
2439
+ if (x < 0 || y < 0 || x >= W || y >= H) return false;
2440
+ const p = y * W + x;
2441
+ for (const src of new Set([upFrom, sg.from])) {
2442
+ const b = builtOf(src);
2443
+ if (b !== null && b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) return true;
2444
+ }
2445
+ return false;
2446
+ };
2447
+ const hasCoverSplit = names.some((other) => other !== l && limbs[other].split !== undefined && limbs[other].segments.some((osg) => osg.from === upFrom || osg.from === sg.from));
2186
2448
  const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
2187
- const run = limbRun(line, art, [0, 0, W - 1, H - 1]);
2449
+ const run = limbRun(line, art, [0, 0, W - 1, H - 1]) ?? (hasCoverSplit ? limbRun(line, sharedArt, [0, 0, W - 1, H - 1]) : null);
2188
2450
  if (run === null) {
2189
2451
  problems.push({
2190
2452
  code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
@@ -2498,15 +2760,18 @@ export function checkSingleRun(full: LayerSet, plan: readonly PlanEntry[] | null
2498
2760
  * after this returned writes only after green.
2499
2761
  */
2500
2762
  export function assemble(input: AssembleInput): AssembleResult {
2501
- const { source, plan, extend, patches, seamRule, projectRule } = input;
2763
+ const { source, extend, patches, seamRule, projectRule } = input;
2502
2764
  const single = input.single === true;
2503
2765
  const frame = checkGeometry(
2504
2766
  { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale, ...(single ? { single: true } : {}) },
2505
2767
  { full: input.full, head: input.head },
2506
2768
  );
2507
2769
  // Issue #366: one run's file, padded to the resolution square; read as both runs, as before.
2508
- const full = single ? padSingleRun(input.full, frame, input.resolution) : input.full;
2770
+ const fullPadded = single ? padSingleRun(input.full, frame, input.resolution) : input.full;
2771
+ // Issue #439: when seethrough.arm_split is declared, split the full run's overlapping handwear into handwear-r and handwear-l.
2772
+ const full = input.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, input.resolution, input.armSplit);
2509
2773
  const head = single ? full : input.head;
2774
+ const plan = armSplitPlan(input.plan, input.armSplit);
2510
2775
  const padRecord = singleRunPadRecord(single, source.width, source.height, input.resolution, frame);
2511
2776
  if (single) checkSingleRun(full, plan, extend);
2512
2777
  checkPlanAgainstRuns(plan, extend, full, head);
@@ -3494,7 +3759,9 @@ function fmt0(v: number): string {
3494
3759
  export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
3495
3760
  const frame = checkGeometry(g, { full: full0, head: head0 });
3496
3761
  // Issue #366: one run's file, padded to the resolution square as the stage pads it.
3497
- const full = g.single === true ? padSingleRun(full0, frame, g.resolution) : full0;
3762
+ const fullPadded = g.single === true ? padSingleRun(full0, frame, g.resolution) : full0;
3763
+ // Issue #439: when seethrough.arm_split is declared, split the full run's overlapping handwear into handwear-r and handwear-l.
3764
+ const full = g.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, g.resolution, g.armSplit);
3498
3765
  const head = g.single === true ? full : head0;
3499
3766
  const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
3500
3767
  const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
@@ -3745,6 +4012,7 @@ export function stageFields(
3745
4012
  limbs?: Record<string, Limb>;
3746
4013
  bones?: BoneEntry[];
3747
4014
  single?: boolean;
4015
+ armSplit?: ArmSplit;
3748
4016
  } {
3749
4017
  const problems: Problem[] = [];
3750
4018
  if (cfg.seethrough === undefined) {
@@ -3759,6 +4027,7 @@ export function stageFields(
3759
4027
  resolution: st.resolution,
3760
4028
  headBox: single !== null ? singleRunHeadBox(single.w, single.h, st.resolution) : (st.head_box as [number, number, number, number]),
3761
4029
  ...(single !== null ? { single: true } : {}),
4030
+ ...(st.arm_split === undefined ? {} : { armSplit: st.arm_split }),
3762
4031
  rigScale: cfg.assemble.rig_scale,
3763
4032
  plan: cfg.assemble.plan,
3764
4033
  extend: cfg.assemble.extend_below_crop ?? [],
@@ -3776,15 +4045,19 @@ export function stageFields(
3776
4045
  * loader's rules; what is left here is that this stage needs the head box,
3777
4046
  * which that loader leaves optional.
3778
4047
  */
3779
- export function proposeFields(cfg: EarlyConfig, single: { w: number; h: number } | null = null): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean } {
4048
+ export function proposeFields(
4049
+ cfg: EarlyConfig,
4050
+ single: { w: number; h: number } | null = null,
4051
+ ): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean; armSplit?: ArmSplit } {
4052
+ const armSplit = cfg.seethrough.arm_split === undefined ? {} : { armSplit: cfg.seethrough.arm_split };
3780
4053
  if (single !== null) {
3781
4054
  refuseIfAny(singleRunHeadBoxProblems(cfg.seethrough));
3782
- return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true };
4055
+ return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true, ...armSplit };
3783
4056
  }
3784
4057
  if (cfg.seethrough.head_box === undefined) {
3785
4058
  refuseIfAny([{ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough.head_box', detail: 'is absent; the plan is proposed from both runs, and the head run cannot be placed on the painting without the box it was cropped from — run `propose --head-box` first' }]);
3786
4059
  }
3787
- return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale };
4060
+ return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale, ...armSplit };
3788
4061
  }
3789
4062
 
3790
4063
  /**