rig-parts 1.79.1 → 1.80.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
@@ -23,6 +23,17 @@ A path this page names that the installed package does not hold — anything und
23
23
  `bun run` names — is in the repository, https://github.com/rig-c/parts; the package holds the files
24
24
  `package.json` lists under `files` (`npm pack --dry-run` prints them).
25
25
 
26
+ For repository changes, run `bun run selftest:pre` on an ordinary Nova slot,
27
+ beside the change's targeted suites (or add them with
28
+ `bun run selftest:pre -- --only rig,propose`). Its fixed minimum forms are demo's
29
+ AE02, TY12/TY16, one demo build with CH04/CH05 goldens, and declaration ownership
30
+ plus RT14, accounting and the family-memory check. It uses one worker and needs
31
+ demo's pinned inputs. Missing inputs, SKIPs and HOLEs cannot pass. Its result
32
+ covers those forms only: **the full forms and full-tree verdict belong to PR CI**.
33
+ [CONTRIBUTING](../CONTRIBUTING.md#before-you-open-a-pull-request) records their
34
+ historical derivation, the selected forms and the separate pre/ordinary-PARTIAL
35
+ exit contracts.
36
+
26
37
  The examples' names: `reference-sample` and `reference-demo` are the two characters
27
38
  the reference implementation ran (published as `sample` and `demo` until issue #227),
28
39
  kept as fixtures because their `expected/` is that implementation's own output; every
@@ -116,11 +127,12 @@ read by no CPU stage).
116
127
  | `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` |
117
128
  | `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` |
118
129
  | `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 |
130
+ | `assemble.extend_painting` | assemble, base painting only — assigns unheld opaque painting samples to declared final layer-derived parts | **authored**, optional array of `{part, band: [x0,y0,x1,y1], max_distance}`; all fields required, integer rig pixels, half-open band and positive maximum four-neighbour path length. Off when absent or empty. See [Bounded painting extension](#bounded-painting-extension-issue-848) |
119
131
  | `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). Use [Source before scissors](#source-before-scissors) before a patch; §5 describes structural placement |
120
132
  | `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 structural separation under [Source before scissors](#source-before-scissors), cut from a plan part's layer by the author's polygon: 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 |
121
133
  | `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?, anchor?, base_anchor?, drawn?, tips?, front?}]` — 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>]`. Windows absolute drive paths such as `C:\paint\painting.png` are accepted as written; each segment's leading drive colon belongs to that path, while other colons still separate `source`, `full` and `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. `front` (issue #664) lists covered limb segment parts the set draws on their **front slot** instead of the base slot: each must be in the set's `parts` and not its `hides`, and listed in some `config.limbs.<limb>.front.parts` (`CONFIG_SET_FRONT`); the set then maps `<part>_front` (never `<part>`) to `<set>_<part>` in `parts.json` and `sets.json` and hides `<part>` (and `<part>_still`), its #587 exclusion counts only base parts drawn after the front slot's target, and keying that part's set on a slot track is refused (`CONFIG_SET_FRONT_KEYED`) — *Front slots (issue #664)* below `layers` optionally maps covered, nonhidden output part names to layer names in their existing source run (issue #666); for example `"layers": {"upperarm_r": "author:raised", "forearm_r": "author:raised"}`. Limb cuts, caps, islands and base exclusions read the selected layer; the base plan stays unchanged. |
122
134
  | `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; follow [Source before scissors](#source-before-scissors) for its source remedy. 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 |
123
- | `assemble.yield_refused` | assemble | **authored**, optional boolean, default `false` (issue #819; other types: `CONFIG_FIELD_TYPE`). With `true`, an original front projection-core pixel refused at max-channel drift > `DRIFT_LIMIT` (90) can yield when its original source pixel is still opaque. Non-core rim pixels (0 < alpha < `CORE_ALPHA`, 250) on the same 8-connected part body can then yield too (issue #832). Connectivity reads the part support before clearing: the opaque border outside the eroded projection core connects the body to its rim but is not itself newly cleared. A separate rim island without a yielded core seed is unchanged. At every cleared pixel, the actual top-most opaque holder below it (alpha >= 250), after earlier yields, must have colour within 90 of the painting. A deeper matching part cannot bypass an intervening opaque mismatch. This setting also applies to alternate sets. Its alpha becomes 0; RGB is not repainted. Merge/ring replacements, samples explicitly kept by `keep_under_occluders`, and pixels outside a derived piece cannot connect a rim or be newly yielded. Front is relative to the matching part behind, so original refusals in another run are eligible even when a different part is the global top holder. The reading proceeds back to front against the current behind pixels; alpha changes do not repaint or create a new matching colour. `yieldReadings` records `yieldedRim` as the rim subset of total `yielded`, skipped core merge/ring/nonopaque/no-match counts and original/final global holders; a yielded pixel also taken by projection closing is omitted from the written `source_px_taken` count. Existing cuts/patches and thresholds are unchanged. The `drift:` line reports `yielded N px` per affected part, even below the drift-body size bar; with the setting off it names `assemble.yield_refused: true` as the conditional remedy. Rebuild and inspect the newly exposed layer in motion before considering any cut; a mismatching or absent behind holder cannot be repaired by this setting. |
135
+ | `assemble.yield_refused` | assemble | **authored**, optional boolean, default `false` (issue #819; other types: `CONFIG_FIELD_TYPE`). With `true`, an original front projection-core pixel refused at max-channel drift > `DRIFT_LIMIT` (90) can yield when its original source pixel is still opaque. Non-core rim pixels (0 < alpha < `CORE_ALPHA`, 250) on the same 8-connected part body can then yield too (issue #832). Connectivity reads the part support before clearing: the opaque border outside the eroded projection core connects the body to its rim but is not itself newly cleared. A separate rim island without a yielded core seed is unchanged. At every cleared pixel, the actual top-most opaque holder below it (alpha >= 250), after earlier yields, must have colour within 90 of the painting. A deeper matching part cannot bypass an intervening opaque mismatch. This setting also applies to alternate sets. A yielded pixel's alpha becomes 0 and its RGB is not repainted. Accepted own-core pixels in the same connected support within the original yielded core's Gaussian footprint (radius 4 rig pixels), with a matching opaque behind holder, finish to painting RGB while retaining alpha and ownership (issue #841); original refusals, protected samples and replacements are excluded. Away from that footprint projection is unchanged. Merge/ring replacements, samples explicitly kept by `keep_under_occluders`, and pixels outside a derived piece cannot connect a rim or be newly yielded. Front is relative to the matching part behind, so original refusals in another run are eligible even when a different part is the global top holder. The reading proceeds back to front against the current behind pixels; alpha changes do not repaint or create a new matching colour. `yieldReadings` optionally records `finishedProjection` (accepted samples whose RGB changed), and records `yieldedRim` as the rim subset of total `yielded`, skipped core merge/ring/nonopaque/no-match counts and original/final global holders; a yielded pixel also taken by projection closing is omitted from the written `source_px_taken` count. Existing cuts/patches and thresholds are unchanged. The `drift:` line reports `yielded N px` per affected part, even below the drift-body size bar; with the setting off it names `assemble.yield_refused: true` as the conditional remedy. Rebuild and inspect the newly exposed layer in motion before considering any cut; a mismatching or absent behind holder cannot be repaired by this setting. |
124
136
  | `assemble.keep_under_occluders` | assemble | **authored**, optional array of unique source part names from `assemble.plan`, default `[]` (issue #825). For each named part, keep its own See-through RGBA where the front-most covering part in the final draw stack has alpha greater than 0 and below `CORE_ALPHA` (250), including a cover in the other run. For an explicitly named part, original alpha 1–8 fringe removed by ghost cleanup also counts as source cover, within its final piece region: those pixels still contributed to the painting. Only original opaque own-layer samples are restored after source projection/closing and protected from later seam re-take; merged/rim, cap, under-fill and authored replacement pixels are excluded. This keeps their existing provenance and makes no holes; fully opaque cover and absent cover keep the old path. Cuts and limb pieces inherit the source part's protection. Unknown names: `CONFIG_NAME_RESOLVES`; duplicates: `CONFIG_PART_UNIQUE`; invalid shape: `CONFIG_FIELD_TYPE`. Inspect the part's own amodal layer first: this handle preserves its existing content and cannot complete a missing pixel. Example: `"keep_under_occluders": ["shin_r"]`. |
125
137
  | `limbs` | assemble (the cut and the caps), rig (through `regions`), check (`LIMB_ONE_BONE`) | **proposed** by `propose --keypoints` (issue #349), then checked: `{<limb>: {segments: [{part, from, bone}, {part, from, bone, joint, range?, cap?, edge?, cap_shape?, cut_band?, hybrid?}, …], split?, layout?, front?}}`, the upper segment first. The rule is *Limbs: cut at their joints* below the table. `part` is a new part (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` against the plan, patches, cuts and every other limb part); `from` a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`) — the layer the segment's pixels come from, which is cut into the limb parts and is not itself made: a `meshes` or `regions` entry for it is `CONFIG_NAME_RESOLVES`, and the segments of one limb that take one part are consecutive (`CONFIG_LIMB_FROM_RUN`); `bone` a declared bone, each segment's the child of the one before (`CONFIG_LIMB_BONES_CHAIN`); the upper segment names no `joint`, `range`, `cap`, `edge`, `cap_shape` or `cut_band` (`CONFIG_KEY_KNOWN`), every other names its `joint` (`CONFIG_FIELD_PRESENT`) — the joint is its bone's origin; `range` is `[min, max]` degrees from rest with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`), which `assemble` writes into the lower part's `parts.json` record and `check` poses the joint at — its two ends and its middle — reading the seam there (`SEAM_OVER_RANGE`, issue #360; rig-c needs no rotation limit); `cap` a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE`); `edge` `"feather"` (issue #360), the one value it takes, and `cap_shape` `"disc"` (issue #360) or `"ball"` (issue #579) (`CONFIG_FIELD_TYPE` on any other) — the cut's geometry at that joint, *Limbs: cut at their joints* below; absent, the cut is hard and the cap the layer's half-disc of half the limb's width; `cut_band` a whole number of px, 1 or more (`CONFIG_FIELD_TYPE`), on a segment that takes the segment above's part — between two layers nothing is cut, so a band there is `CONFIG_KEY_KNOWN` — places the cut within that many px of the joint line (issue #360, *Limbs: cut at their joints*); absent, the cut is the joint line. Each limb part is attached like any part — `regions.<part>`, which must be the segment's `bone` (`CONFIG_LIMB_BONE`), or a mesh, which `check` holds to that bone alone (`LIMB_ONE_BONE`). With `limbs`, `assemble` reads `bones` too (`CONFIG_FIELD_PRESENT` without it). Absent, nothing is cut at a joint and no byte of any output moves. Optional `front` (issue #664), `{parts: [<own segment part>, …], after: "<produced part>"}`, gives each named segment a second slot `<part>_front`, empty at setup, placed after `after`'s slot family, for a gesture set to draw on (`CONFIG_FRONT_SCOPE`, `CONFIG_FRONT_NAME`, `CONFIG_FRONT_LAYOUT`; *Front slots (issue #664)* below) |
126
138
  | `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip, link_inherit?}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone with a `tip` is turned the same way, towards its tip, with that distance as its length (issue #239); a tip on the bone's own origin is refused (`RIG_TIP_DISTINCT`). The five face bones `eye_r`, `eye_l`, `brow_r`, `brow_l` and `mouth` (by name, when one has no tip of its own) are turned to the eye line — the line through the origins of `eye_r` and `eye_l` as `bones` writes them, read image-left to image-right, drawn when parts.json holds both an `eyewhite-r` and an `eyewhite-l` part — so the blink's `scaley` squashes across the eye line on a tilted head; move an eye bone and the line moves with it. With one eyewhite or none, or an eye bone missing, the rig stage's `face axis:` line says so and they keep the page's axes. A bone under a face bone keeps the face's axis. With the line, `rig.json` also declares `invariants.axes` — one entry, `"face"`, through `eye_r` and `eye_l`, holding the turned face bones (and their controls) — so rig-c's gate holds the build to the line; the line is the one rigc draws through the same two bones, so the tolerance is only the float floor — the largest gap the emitted (float32) bones have to it, rounded up to 6 places plus one 6-place unit — and the `face axis:` line prints it. With the line the rig stage also adds `head_up` (issue #250): a slotless bone under `head`, at head's origin, turned to the eye line + 90 degrees and as long as the eye midpoint lies from head's origin along that axis — the head's frame, which a rule that needs it reads; `head` keeps its own frame, so no key on it or under it changes meaning, and a config bone of that name is refused (`RIG_HEAD_UP_NAME_FREE`). A face bone with its own `tip` is held too (issue #258), in a second entry, `"face_tips"`, when its tip lies along the line within whole-pixel rounding — a tip written in whole pixels sits at most sqrt(2)/2 px off the point of the line it stands for, so its direction may sit up to asin((sqrt(2)/2) / length) degrees off — with its tolerance measured the same way; a tip further off is the bone's own axis, and the `face axis:` line names it with its gap and that bound. A failure is rig-c's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` line, which names the bone and the fix (rig-c's `docs/AUTHORING.md` §3.7 and §5.2); with no line no entry is written and A51 reports SKIP. Every region on a turned bone is turned back, so the setup pose does not move. Any other single bone keeps world rotation 0; one whose parent is a link is turned back upright. A single bone may declare `inherit` (issue #348): what it takes from its parent's transform, one of rig-c's own mode names exactly as rig-c's table spells them — `normal`, `onlyTranslation`, `noRotationOrReflection`, `noScale`, `noScaleOrReflection` (rig-c's `RigBone.inherit`) — written to that bone in `rig.json` as declared; any other spelling, `"NoScale"` included (the runtime would fold its first letter; nothing here folds or maps), is refused (`CONFIG_BONE_INHERIT`), and a chain takes no `inherit` (`CONFIG_KEY_KNOWN`). A chain may instead declare `link_inherit` (issue #369), an object mapping its own expanded link names to those same modes, for example `"link_inherit": {"leg_r1": "noScale"}`. Only the named link changes; omitted links inherit normally. A key outside this chain is `CONFIG_NAME_RESOLVES`, with this chain's valid link names in the detail; a non-object or non-string mode is `CONFIG_FIELD_TYPE`, and another mode spelling is `CONFIG_BONE_INHERIT`. The mode is forwarded to the expanded link, or to its control under `--idle-keys ctl`, by the same rule as a single bone. This is an authored opt-in: `propose` does not add it. Its setup rotation is the turn from where rig-c's own poser puts the bone with no rotation of its own under that mode, so it still stands upright (or along its tip) at the setup pose. Where the idle gives the bone a `<bone>_ctl`, the mode goes on the control: the control is the bone the parent's rotation reaches, and the bone under it inherits normally, so the idle's keys moved onto the control still turn it. A foot that does not take its shin's rotation (`noRotationOrReflection` or `onlyTranslation`) keeps its shoe level while the leg is posed (`FEET_STAY_LEVEL`, §7); which mode is the author's. `noScale` keeps a shin or shoe's shape under a scaled parent but still takes the parent's rotation: a shoe that must also stay level needs a separate rotation constraint, for example a transform constraint from an unrotated bone. This does not change the proposed feet's existing inheritance or `FEET_STAY_LEVEL` default. An automatic mesh weighted through a bone with a mode other than `normal` gets no derived amplitude (its row names the bone) and no residual envelope (rig-c's `SKINNING_RANGE_UNSUPPORTED`). Absent, `rig.json` carries no `inherit` and no byte moves |
@@ -3498,7 +3510,7 @@ existing hardlinks are refused before artifact output; the ordinary mode names
3498
3510
  | `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 |
3499
3511
  | `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` |
3500
3512
  | `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), then `SEAM_OVER_MOTION` — a SKIP naming why on a rig that declares no neighbour pair, else the pairs read in every animation; existing internal-joint and explicit-seam pairs retain their `WARN seam` policy (issue #493), while proven body-root tears print `FAIL seam` and fail a counted bar (issue #686), then `CLOTH_SETTLES` — a SKIP naming why on a rig with no physics constraint, else the chains read and one `WARN chain` line per chain whose tip comes to rest before the idle's loop length (issue #501, a warning and never a bar), `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, the range bar and, where proven body-root pairs exist, the body-root seam bar — and names the ones that said SKIP, then — where `SEAM_OVER_MOTION` read a pair — how many warnings it printed, `; N warning(s) (SEAM_OVER_MOTION, not a bar)` — with `CLOTH_SETTLES` named beside it, and N their sum, where the rig declares a physics constraint — which no bar counts | `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); `CHECK_SEAM_OVER_MOTION` — a proven body-root seam opens over the existing floor (§6) |
3501
- | `check` / `build`: extremes observations | `extremes/<anim>/extremes.png` and `extremes.json` beside the other check outputs, generated by core `render --extremes` at 12 fps on its fitted world box at scale 1; one `EXTREMES` geometry-pair frame/distance summary per animation | open the native setup/min/max/diffs and label every changed boundary tear, ghost or clean before human review (step 9) | core's named static/observation SKIPs retain JSON without a PNG; diagnostic failure is `CHECK_EXTREMES_RENDER_FAILED` SKIP and leaves the measured verdict unchanged; `--no-extremes` runs no extra render and removes stale `extremes/` |
3513
+ | `check` / `build`: extremes observations | `extremes/<anim>/extremes.png` and `extremes.json` beside the other check outputs, generated by core `render --extremes-only` at 12 fps on its fitted world box at scale 1; one `EXTREMES` geometry-pair frame/distance summary per animation | open the native setup/min/max/diffs and label every changed boundary tear, ghost or clean before human review (step 9) | core's named static/observation SKIPs retain JSON without a PNG; diagnostic failure is `CHECK_EXTREMES_RENDER_FAILED` SKIP and leaves the measured verdict unchanged; `--no-extremes` runs no extra render and removes stale `extremes/` |
3502
3514
  | `check --requirements` (and `build --requirements`) | after the lines above, one line per declared requirement, `NAME: PASS`, `FAIL` or `NOT MEASURABLE — …` with its figures, the bar as declared and the worst frame; then `requirements: N declared — M measured (P PASS, F FAIL), K NOT MEASURABLE; not declared: <kinds>`; `check.json`'s `requirements` block, first its `file` record (issue #556); the renders under `requirements/` (§7, *Declared requirements*) — `build` reads `config.requirements` in place of the flag when it is not given | every line PASS and `0 NOT MEASURABLE`; a follow's `released_copy` says whether rigc's consumer-driven door was taken on the throwaway copy; a `stretch` line's `held_by` (issue #321), right after `worst`, names the bones the worst edge's two vertices hang on and their idle chains, in `CHECK_TEXTURE_STRETCH`'s words (§6) and by the same function, on a PASS as on a FAIL | `CHECK_REQUIREMENT_MET` (a figure past its bar; the line names the frame) or `CHECK_REQUIREMENT_MEASURABLE` (the quantity is undefined in the frames: a tip or an axis on a bone of length 0, an aim with no line, a follow no frame of which reaches its least drive); the last line then reads `check: FAIL — B bar(s) not met, R declared requirement(s) not PASS` |
3503
3515
  | `compose` | the `[compose]` lines: one per character (its offset, its scale, its placed bounds, the shift and how many first-level bones, regions on `root` and weights bound to `root` it moved, its bone, slot, track and constraint counts, its `rig_scale` and idle), the declared order and the slot count it expands to, rigc's gate lines; then the `[check]` lines as for `check`, and `scene.json` | both gate summaries `0 failed`, `check: PASS` with the bars that measured and the ones that said SKIP named — on a composed rig `4 of 9`: the gate, the loop, `CHAIN_LAG` and `TEXTURE_STRETCH` (there is no `parts.json`; each character's own `build` measured all nine). `scene.json`'s `order.slots` is the order the compiled skeleton draws | a `SCENE_*` refusal (§6, *compose*) — the scene file, a build, or the order; `COMPOSE_RIGC_GREEN` — rigc's own line, quoted |
3504
3516
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, or, when the end frame is not frame 0, the jump line: its max \|d\| and px over 40, then why — under `build`, `the rig holds N physics constraint(s), and rigc's render starts every one at rest at frame 0` with the remedy `build --loop --lead-in <n>`, or `the rig holds no physics constraint, so the idle itself does not close` (`CHECK_LOOP_CLOSES`); under `loop --frames`, which reads frames alone, both causes (issue #325); under `build --lead-in`, the lead-in line in its place (the frames kept and the jump at the wrap); each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
@@ -3725,6 +3737,67 @@ run on a `direct` build (it has no rig spec to read) refuses A15 once per keyed
3725
3737
  bone. No stage runs `validate`: the gate is `rigc build`, which reads the rig spec
3726
3738
  and its declaration, and is green on both (`RG19`).
3727
3739
 
3740
+ ### Bounded painting extension (issue #848)
3741
+
3742
+ When two See-through layers both omit painted pixels, projection, keep/yield
3743
+ and the seam colour guard have no existing sample to transfer. Declare an
3744
+ owner only for the measured missing region:
3745
+
3746
+ ```json
3747
+ "extend_painting": [
3748
+ {"part": "shin_l", "band": [40, 80, 56, 88], "max_distance": 3}
3749
+ ]
3750
+ ```
3751
+
3752
+ This field is inside `assemble`. `part` names a **final layer-derived output
3753
+ part**, including a cut piece or limb segment, after cuts and limb splitting;
3754
+ not a consumed plan source or a painting patch. Each part may appear once.
3755
+ `band` is `[x0,y0,x1,y1]` in rig pixels (painting pixels times `rig_scale`),
3756
+ x right/y down, upper bounds exclusive, wholly inside the rig. Every coordinate
3757
+ is a nonnegative safe integer; the rectangle must have positive width and
3758
+ height. `max_distance` is a positive safe integer of **four-neighbour steps**.
3759
+ Neither bound is inferred or clipped. The example numbers illustrate the
3760
+ schema; they are not proposed coordinates for a particular painting.
3761
+
3762
+ The stage reads the final assembled stack after cuts, limb splits, exclusions,
3763
+ fringe push-back and yield. A candidate is unheld only if **every assembled
3764
+ part has alpha exactly zero** there; even alpha 1 on another part blocks it.
3765
+ Its painting alpha, Lanczos-resampled to rig size, must be at least 250.
3766
+ The original target silhouette (alpha above 8) seeds candidate pixels one
3767
+ horizontal or vertical step away, including from just outside the band. Paths
3768
+ then traverse only eligible pixels inside the band, up to `max_distance` steps
3769
+ from the original silhouette. Diagonal contact alone does not connect a pixel.
3770
+ Growing a candidate never resets the distance or fills a whole component.
3771
+ A disconnected painted island, a pixel outside the band and a connected pixel
3772
+ past the distance bound stay unheld. Two declared parts reaching the same
3773
+ pixel are refused as `ASSEMBLE_PAINTING_EXTENSION_CONFLICT`; declaration order
3774
+ does not choose the owner.
3775
+
3776
+ Each gained sample takes the **exact RGB of the existing rig-space painting
3777
+ resample** (`sourceInRig`, the legacy RGB-only Lanczos resample), and alpha from
3778
+ the separately resampled painting alpha. At `rig_scale: 1` this copies the
3779
+ painting's RGB bytes directly; at other scales it copies that resample's RGB
3780
+ bytes, not a nearest source texel. It never feathers, repaints, invents hidden
3781
+ art, changes an already held sample or infers which opaque colour is figure.
3782
+ An opaque background inside the declared reachable band is eligible too:
3783
+ choose the band from the measured omission. Transparent painting pixels cannot
3784
+ bridge a path. No depth is invented: a gain on a target with registered layer
3785
+ depth refuses `DEPTH_PART_SUPPORT`, because the painting supplies no declared
3786
+ scalar material for those new samples.
3787
+
3788
+ `parts.json` attributes gained output samples to
3789
+ `provenance.automatic[].stage: "paintingExtension"` when their final RGBA
3790
+ differs from the normalized raw layer. The optional part field
3791
+ `painting_extension_px` records the exact count assigned from the painting,
3792
+ including zero when the declaration reaches nothing. It is independent of the
3793
+ value partition: a newly assigned painting sample that happens to equal a raw
3794
+ layer sample remains `unchanged_layer` on the value axis. They contribute to `opaque_px` and `visible_px`, not the older
3795
+ projection's `source_px_taken` or `merged_px`. Existing provenance partitions
3796
+ and reader sum checks still apply. Absent/empty declarations take no extension
3797
+ path. Declarations apply to the **base painting only** and are not inherited
3798
+ into alternate paintings. Native setup and animation boundary checks remain
3799
+ consumer acceptance work; assembly provenance does not establish motion quality.
3800
+
3728
3801
  ### Source before scissors
3729
3802
 
3730
3803
  <a id="source-before-scissors-issue-819"></a>
@@ -3744,7 +3817,17 @@ problem. Follow this repair ladder, recording what each step measured:
3744
3817
  opaque behind holder, measure
3745
3818
  `assemble.yield_refused: true` (issue #819). It clears original refused front
3746
3819
  cores and their connected non-core rim alpha (issue #832) under the conditions
3747
- in the field table; it does not paint a repair.
3820
+ in the field table. Beside an actually yielded core, accepted own-core samples
3821
+ in the same connected part support finish to the painting's RGB within the
3822
+ existing Gaussian footprint (sigma 1, radius 4 rig pixels; issue #841), provided
3823
+ the exposed opaque behind holder also matches within `DRIFT_LIMIT`. Their
3824
+ alpha and ownership stay intact; original refusals, kept samples, merge/ring
3825
+ replacements and other cut regions are excluded. Outside that footprint the
3826
+ projection weights stay unchanged. The synthetic 16x16 refused square with a
3827
+ four-pixel accepted surround has 320 opaque surround samples: before finishing,
3828
+ 140 retain colour error (maximum 27 channel levels); afterwards the maximum
3829
+ is 0, with the body holding all 256 yielded pixels and no open hole. These are
3830
+ fixture measurements, not a claim about an arbitrary painting.
3748
3831
  5. For a moving part under a soft hem, inspect its own amodal See-through layer.
3749
3832
  If the painting's blended hem is re-taken onto otherwise complete hidden art,
3750
3833
  measure `assemble.keep_under_occluders: ["<source-part>"]`. The informational
@@ -3792,8 +3875,11 @@ still ends in the unchanged class by the first comparison.
3792
3875
 
3793
3876
  Automatic value changes are named `sourceProjection`, `extend`, `growRim`,
3794
3877
  `limbCap`, `limbFeather`, `fringeRestore`, `seamOverride`, `armSplit`,
3795
- `headFaceUnderfill` or `ghostCleanup`. `limbSplit` and `limbHybrid` remain
3878
+ `headFaceUnderfill`, `ghostCleanup` or `paintingExtension`. `limbSplit` and `limbHybrid` remain
3796
3879
  recognized stage names, but their pure copies do not claim a value change.
3880
+ `paintingExtension` copies unheld painting samples within an explicit band;
3881
+ `painting_extension_px` counts that ownership assignment independently, even
3882
+ when restoring raw-layer values makes its final value class unchanged.
3797
3883
  Run preparation compares actual normalized samples, not layer-wrapper identity:
3798
3884
  the head under-fill loop assigns the full composite, but equal samples keep
3799
3885
  their earlier lineage. Arm-generated tags use their original hand-layer
@@ -4214,6 +4300,9 @@ says why each requirement exists and what breaks without it.
4214
4300
  | <a id="SEETHROUGH_ARM_RUN_MERGED"></a>`SEETHROUGH_ARM_RUN_MERGED` | a full-run `handwear-r` or `handwear-l` layer merges sleeve, bare hand (`>= 40` skin run px around `<s>_wrist`, `GHOST_MIN_AREA`) and overgarment into one arm layer with non-skin pixels reaching more than `1.0x` the forearm length (`<s>_elbow -> <s>_wrist`) below `<s>_wrist` (measured `-0.15x..0.45x` on the 6 clean standees vs `1.25x..2.02x` on the 3 merged standees), and the sleeve above `<s>_wrist` and the drape below `<s>_wrist` have Euclidean RGB distance `< 80` (`ARM_DRAPE_COLOR_DISTANCE`, measured `20.8..28.2` when same-coloured vs `103.6..236.0` when distinct) so the stage cannot split them by colour (issue #490; the detail gives the hand px, the non-skin span, the forearm length and both RGB medians) | follow [Source before scissors](#source-before-scissors): select another layer/run, then a tighter run, then correct depth/role; if no separate source exists and sleeve and overgarment must move independently, a structural `author:` layer or `assemble.cuts` entry separates them |
4215
4301
  | <a id="SEETHROUGH_HEAD_RUN_HOLLOW"></a>`SEETHROUGH_HEAD_RUN_HOLLOW` | `full:face` has at least `200` opaque run px (`PROPOSE_MIN_PX`) and `head:face` covers 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) of `fullLayerToHead(full:face)`'s hole-filled silhouette (`alpha > 8`) in the head canvas while `config.assemble.head_face_underfill: true` is not declared, or `head:face` has fewer than `200` opaque pixels so there is no head-run `face` layer to under-fill (issue #492; the detail gives the full-run mapped count, the head-run overlap and upper/lower half shares) | re-seed `config.seethrough.seed`, or set `config.assemble.head_face_underfill: true` when `head:face` exists so the stage composites `fullLayerToHead(full:face)` under `head:face` on the head canvas (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; follow [Source before scissors](#source-before-scissors) for its source remedy) |
4216
4302
  | <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 |
4303
+ | <a id="ASSEMBLE_PAINTING_BAND_INSIDE"></a>`ASSEMBLE_PAINTING_BAND_INSIDE` | an extension band reaches past the rig canvas | supply an in-canvas half-open band in rig pixels; no bound is clipped |
4304
+ | <a id="ASSEMBLE_PAINTING_EXTENSION_CONFLICT"></a>`ASSEMBLE_PAINTING_EXTENSION_CONFLICT` | two declared final parts can reach the same unheld painting pixel | make their reachable bands disjoint; order never guesses ownership |
4305
+ | <a id="ASSEMBLE_PAINTING_CANVAS"></a>`ASSEMBLE_PAINTING_CANVAS` | a callable extension stage receives a part outside the rig canvas | pass the final in-canvas assembled stack with its crop offsets |
4217
4306
  | <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 |
4218
4307
  | <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 |
4219
4308
  | <a id="ASSEMBLE_PATCH_OPAQUE"></a>`ASSEMBLE_PATCH_OPAQUE` | a patch takes no pixel: a `"silhouette"` patch whose box holds none of the figure | move the box onto the figure, or `"alpha": "box"` |
@@ -4727,6 +4816,7 @@ and stay absent).
4727
4816
  | <a id="CONFIG_TURN_COUNTER_RANGE"></a>`CONFIG_TURN_COUNTER_RANGE` | `config.turn.counter` (or `config.turn.body.counter`) is outside `[0, 1]` | supply a non-negative rear counter-translation fraction in `[0, 1]` |
4728
4817
  | <a id="CONFIG_TURN_ROLE_UNKNOWN"></a>`CONFIG_TURN_ROLE_UNKNOWN` | `config.turn.roles.<part>` (or `config.turn.body.roles.<part>`) is not `"surface"`, `"unit"` or `"rear"` | declare `"surface"`, `"unit"` or `"rear"` |
4729
4818
  | <a id="CONFIG_TURN_SURFACE_REQUIRED"></a>`CONFIG_TURN_SURFACE_REQUIRED` | `config.turn.roles` (or `config.turn.body.roles`) declares no `"surface"` part | declare at least one part as `"surface"` |
4819
+ | <a id="CONFIG_TURN_SURFACE_AUTO"></a>`CONFIG_TURN_SURFACE_AUTO` | a part with role `"surface"` in `config.turn.roles` or `config.turn.body.roles` uses `meshes.<part>.auto`; its emitted-mesh deform cannot also key the unreduced reference used by the auto motion gate | use `grid` or `contour` for the named part to keep the depth turn |
4730
4820
  | <a id="CONFIG_TURN_UNIT_MESH_BONES"></a>`CONFIG_TURN_UNIT_MESH_BONES` | `config.turn.roles.<part>` (or `config.turn.body.roles.<part>`) declares `"unit"` for a mesh part in `config.meshes` that binds a bone outside the `"head"` subtree (or outside the torso carrier subtree for `turn.body`), or shares a bound bone or chain with another `"unit"` mesh part, so its bones cannot be moved under `<part>_unit` (#790) | bind each `"unit"` mesh only to `"head"` and its own child bones or chains under `"head"` (or to the torso root bone and its own non-head descendants for `turn.body`), or give the part role `"rear"` |
4731
4821
  | <a id="CONFIG_TURN_EAR_DRAW_ORDER"></a>`CONFIG_TURN_EAR_DRAW_ORDER` | an ear part in `config.turn.roles` is drawn after `face` with role `"surface"` in `assemble.plan` | place `ear_r` and `ear_l` before `face` in `assemble.plan` so the turning cheek occludes the far ear |
4732
4822
  | <a id="CONFIG_TURN_HEAD_BONE"></a>`CONFIG_TURN_HEAD_BONE` | `config.turn.roles` is declared without a single bone `"head"` in `config.bones`, or `config.turn.body` without a single bone `"chest"` / `"hip"` (or its declared `bone`) | declare `"head"` (or the body turn root bone) as a single bone in `config.bones` |
@@ -5808,6 +5898,18 @@ parents and stops keep their emitted names; Core creates the draw bones later.
5808
5898
  A name ending in `_d` alone is not an alias: a missing non-stop bone still
5809
5899
  raises `RIG_TURN_SURFACE_MESH_BINDINGS`. No new config field is required.
5810
5900
 
5901
+ Depth-turn `"surface"` parts may use authored weighted `grid`, `contour`,
5902
+ `influence` or `explicit` meshes, or the turn-generated grid when attached as
5903
+ a region. `auto` cannot combine with `"surface"` in either `turn.roles` or
5904
+ `turn.body.roles`: [`CONFIG_TURN_SURFACE_AUTO`](#CONFIG_TURN_SURFACE_AUTO)
5905
+ refuses the config before mesh generation or any build output. The turn keys
5906
+ the emitted mesh's vertices and influence order, while the auto motion gate
5907
+ also substitutes its unreduced source attachment with the same motion. Those
5908
+ topologies need separate deform keys even when the reduced candidate itself
5909
+ passes the gate. Use `grid` or `contour` for that part to retain depth turning.
5910
+ This restriction does not apply to `"unit"` or `"rear"` parts, which use bone
5911
+ carriers, or to the legacy scalar-depth turn without `roles`.
5912
+
5811
5913
  A weighted surface spanning the head and body subtrees (#796) takes both
5812
5914
  fields. Declare it as `"surface"` in `turn.roles` or `turn.body.roles` (both is
5813
5915
  also accepted), with both 2.5D groups present. Head descendants belong to the
package/docs/MIGRATION.md CHANGED
@@ -54,7 +54,7 @@ installed the former upstream name beside this package installs nothing more:
54
54
 
55
55
  ### Scoped core dependency
56
56
 
57
- The current dependency is `@rig-c/core` at `^2.81.0`, replacing the literal
57
+ The current dependency is `@rig-c/core` at exactly `2.82.0`, replacing the literal
58
58
  `rig-c` dependency described for earlier versions above. The alias remains
59
59
  published. Imports retain their subpaths: `rig-c/mesh` becomes
60
60
  `@rig-c/core/mesh`, and `rig-c/core` becomes `@rig-c/core/core`. The command
package/docs/STABILITY.md CHANGED
@@ -129,9 +129,10 @@ part name, `[i]` an entry of a list):
129
129
  - **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
130
130
  - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
131
131
  - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
132
- - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`
132
+ - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`
133
133
  - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
134
134
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
135
+ - **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance` (issue #848; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only)
135
136
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
136
137
  - **assemble.patches[i].draw**, when an object — required: `before`
137
138
  - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`, `under_fill`
@@ -303,7 +304,7 @@ in the package.
303
304
 
304
305
  ## rig-c
305
306
 
306
- `dependencies` takes `@rig-c/core` `^2.81.0`: 2.81.0 or any later 2.x. Bun 1.4 or later
307
+ `dependencies` takes `@rig-c/core` `2.82.0` exactly. Bun 1.4 or later
307
308
  is the declared floor (`engines.bun` `>=1.4.0`; older 1.4 releases are not tested in
308
309
  CI, which runs `latest`, and compatibility with an older 1.4 release is handled when
309
310
  reported as an issue). What this package relies on in rig-c, every import
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.79.1",
3
+ "version": "1.80.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": {
@@ -58,6 +58,7 @@
58
58
  "scripts": {
59
59
  "fetch-examples": "bash scripts/fetch-examples.sh",
60
60
  "selftest": "bun selftest.ts",
61
+ "selftest:pre": "bun selftest.ts --pre",
61
62
  "smoke": "bun scripts/install_smoke.ts",
62
63
  "typecheck": "bunx tsc --noEmit",
63
64
  "lint": "bunx eslint .",
@@ -65,7 +66,7 @@
65
66
  },
66
67
  "dependencies": {
67
68
  "ag-psd": "^31.0.2",
68
- "@rig-c/core": "^2.81.0"
69
+ "@rig-c/core": "2.82.0"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@esotericsoftware/spine-core": "4.3.13",
package/src/assemble.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { paintingExtensionMasks, paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
1
2
  import { AUTOMATIC_WRITERS as AW, compactValueBaseline, pixelProvenance, rgbaEqual, type ValueBaseline } from './provenance.ts';
2
3
  import { bindDepthRun, copyDepthMaterial, registerDepthRun, transportPartDepth, type DepthConfig, type RegisteredDepth } from './depthsheets.ts';
3
4
  /**
@@ -118,6 +119,7 @@ import {
118
119
  erode,
119
120
  fillHoles,
120
121
  gaussianBlur,
122
+ gaussianKernelSize,
121
123
  type Mask,
122
124
  morphClose,
123
125
  newFloatImage,
@@ -241,7 +243,7 @@ export interface Geometry {
241
243
  keySkeleton?: ControlSkeleton;
242
244
  /** `assemble.head_face_underfill` (issue #492): composite `full:face` under `head:face` on the head canvas to fill transparent bare-skin holes. */
243
245
  headFaceUnderfill?: boolean;
244
- /** `assemble.yield_refused`, default false; alpha-only transfer of refused cores and their connected non-core rims. */
246
+ /** `assemble.yield_refused`, default false; transfer of refused cores/rims, finishing nearby accepted projection RGB. */
245
247
  yieldRefused?: boolean;
246
248
  /** Plan parts whose own samples survive source re-take beneath soft front cover. */
247
249
  keepUnderOccluders?: readonly string[];
@@ -317,7 +319,7 @@ export interface AssembleInput {
317
319
  depth?: DepthConfig;
318
320
  /** Config-relative user sheet paths; caller supplies its actual base. */
319
321
  depthBase?: string;
320
- /** The painting. Its alpha is ignored, as the reference's `convert("RGB")` drops it. */
322
+ /** The painting. Legacy RGB stages ignore alpha; explicit paintingExtension requires opaque source alpha. */
321
323
  source: Raster;
322
324
  full: LayerSet;
323
325
  head: LayerSet;
@@ -362,10 +364,12 @@ export interface AssembleInput {
362
364
  keySkeleton?: ControlSkeleton;
363
365
  /** `assemble.head_face_underfill` (issue #492): composite `full:face` under `head:face` on the head canvas to fill transparent bare-skin holes. */
364
366
  headFaceUnderfill?: boolean;
365
- /** `assemble.yield_refused`, default false; alpha-only transfer of refused cores and their connected non-core rims. */
367
+ /** `assemble.yield_refused`, default false; transfer of refused cores/rims, finishing nearby accepted projection RGB. */
366
368
  yieldRefused?: boolean;
367
369
  /** Plan parts whose own samples survive source re-take beneath soft front cover. */
368
370
  keepUnderOccluders?: readonly string[];
371
+ /** Base painting only; named final parts, explicit band and path bound (#848). */
372
+ paintingExtension?: readonly PaintingExtension[];
369
373
  /**
370
374
  * Issue #587: rig pixels taken out of named parts once the cuts and the limbs are made, before the fringe pass and
371
375
  * every figure a part's record holds — each entry clears `part`'s pixels (alpha, colour and region) where `mask` is
@@ -2169,6 +2173,30 @@ export interface ProjectionStats {
2169
2173
  refusedMask: Mask | null;
2170
2174
  }
2171
2175
 
2176
+ /** Finish accepted source RGB near an actually yielded core (#841).
2177
+ * The caller freezes the Gaussian footprint before clearing any rim, and passes
2178
+ * only the original connected support. Recheck material ownership at the write:
2179
+ * a copied sample or another derived piece never inherits the source's acceptance.
2180
+ * Alpha and the projection counts are unchanged; the callback records RGB writes.
2181
+ */
2182
+ export function finishYieldedProjection(
2183
+ image: Raster, source: Raster, projection: ProjectionStats, footprint: Mask,
2184
+ support: Int32Array,
2185
+ own: { kept: Mask | null; region: Mask | null; merged: Int32Array; ring: Mask; behind: Int32Array },
2186
+ changed?: (pixel: number) => void,
2187
+ ): number {
2188
+ let finished = 0;
2189
+ for (const p of support) {
2190
+ if (footprint.data[p] !== 1 || projection.coreMask?.data[p] !== 1 || projection.takenMask.data[p] !== 1 || projection.refusedMask?.data[p] !== 0 || image.data[p * 4 + 3] < CORE_ALPHA || own.behind[p] < 0) continue;
2191
+ if (own.kept?.data[p] === 1 || (own.region !== null && own.region.data[p] !== 1) || own.merged[p] >= 0 || own.ring.data[p] !== 0) continue;
2192
+ if (maxDiff(image.data, p * 4, source.data, p * 4) === 0) continue;
2193
+ for (let c = 0; c < 3; c++) image.data[p * 4 + c] = source.data[p * 4 + c];
2194
+ changed?.(p);
2195
+ finished++;
2196
+ }
2197
+ return finished;
2198
+ }
2199
+
2172
2200
  function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: number): number {
2173
2201
  return Math.max(Math.abs(a[i] - b[j]), Math.abs(a[i + 1] - b[j + 1]), Math.abs(a[i + 2] - b[j + 2]));
2174
2202
  }
@@ -4392,6 +4420,8 @@ export interface DriftYieldReading {
4392
4420
  yieldedRim: number;
4393
4421
  /** Yielded refusals also accepted by projection closing; removed from the written projected count. */
4394
4422
  yieldedTaken: number;
4423
+ /** Accepted own-core samples finished to painting RGB near a yielded core (#841); alpha is unchanged. */
4424
+ finishedProjection?: number;
4395
4425
  /** Original refused pixels explicitly kept from their own layer (#825). */
4396
4426
  preserved?: number;
4397
4427
  skipped: { merge: number; ring: number; notOpaque: number; noMatchingBehind: number };
@@ -4604,6 +4634,7 @@ export function checkSingleRun(full: LayerSet, plan: readonly PlanEntry[] | null
4604
4634
  */
4605
4635
  export function assemble(input: AssembleInput): AssembleResult {
4606
4636
  const { source, extend, patches, seamRule, projectRule } = input;
4637
+ if (input.paintingExtension !== undefined) refuseIfAny(paintingExtensionProblems(input.paintingExtension));
4607
4638
  const single = input.single === true;
4608
4639
  const frame = checkGeometry(
4609
4640
  { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale, ...(single ? { single: true } : {}) },
@@ -5339,6 +5370,9 @@ export function assemble(input: AssembleInput): AssembleResult {
5339
5370
  yielded++;
5340
5371
  }
5341
5372
  if (yielded > 0) {
5373
+ // The same finite support as projectSource's Gaussian: only original
5374
+ // yielded cores seed this neighbourhood, never a subsequently cleared rim.
5375
+ const finish = dilate(mask, gaussianKernelSize(FEATHER_SIGMA));
5342
5376
  // Connectivity reads the original part support, including the opaque
5343
5377
  // band outside the eroded projection core. Only non-core rim pixels
5344
5378
  // may additionally yield: traversing an opaque pixel never clears it.
@@ -5369,6 +5403,10 @@ export function assemble(input: AssembleInput): AssembleResult {
5369
5403
  yielded++;
5370
5404
  }
5371
5405
  }
5406
+ const finished = finishYieldedProjection(b.r, srcr, b.st, finish, queue.subarray(0, end),
5407
+ { kept: keptRetake[orderIdx], region: b.region, merged: b.trace.from, ring: b.trace.ring, behind: behindAt },
5408
+ p => { writersInOrder[orderIdx][p] = AW.sourceProjection; });
5409
+ if (finished > 0) reading.finishedProjection = finished;
5372
5410
  }
5373
5411
  reading.yielded = yielded;
5374
5412
  reading.originalHolders = [...holders].map(([part, px]) => ({ part, px }));
@@ -5389,6 +5427,32 @@ export function assemble(input: AssembleInput): AssembleResult {
5389
5427
  reading.finalHolders = [...holders].map(([part, px]) => ({ part, px }));
5390
5428
  }
5391
5429
  }
5430
+ // #848: read one frozen final stack after every ownership-changing pass.
5431
+ // The ordinary RGB painting still ignores alpha, as before. Only this opt-in
5432
+ // stage reads separately resampled painting alpha; its RGB is never used.
5433
+ const paintingAlpha = (input.paintingExtension?.length ?? 0) === 0 ? null : resize(source, W, H, 'lanczos3');
5434
+ const paintingGains = paintingAlpha === null ? new Map<string, Mask>()
5435
+ : paintingExtensionMasks(input.paintingExtension!, stack.map(p => ({ name: p.record.name, image: p.image, x: p.record.x, y: p.record.y, canExtend: !p.record.from.startsWith('painting:') })), paintingAlpha);
5436
+ stack.forEach(({ record, image }, i) => {
5437
+ const gain = paintingGains.get(record.name);
5438
+ if (gain === undefined) return;
5439
+ const d = order[i];
5440
+ if (!('patch' in d)) {
5441
+ const b = pieceAt(d).b;
5442
+ if (gain.data.some(v => v === 1) && [b.key, ...b.extendKeys].some(k => registeredDepth.has(k))) {
5443
+ refuseIfAny([{ code: 'DEPTH_PART_SUPPORT', object: `part "${record.name}"`, detail: 'painting extension has no declared painting depth material; remove this extension or the target layer depth, never extrapolate a layer sheet onto new painting samples' }]);
5444
+ }
5445
+ }
5446
+ for (let p = 0; p < W * H; p++) if (gain.data[p] === 1) {
5447
+ image.data.set(srcr.data.subarray(p * 4, p * 4 + 3), p * 4);
5448
+ image.data[p * 4 + 3] = paintingAlpha!.data[p * 4 + 3];
5449
+ writersInOrder[i][p] = AW.paintingExtension;
5450
+ if (!('patch' in d)) {
5451
+ const region = pieceAt(d).b.region;
5452
+ if (region !== null) region.data[p] = 1;
5453
+ }
5454
+ }
5455
+ });
5392
5456
  const pushedOf: FringePushed[][] = plan.map(() => []);
5393
5457
  const pushedOfCut: FringePushed[][] = cuts.map(() => []);
5394
5458
  const pushedOfLimb: FringePushed[][] = lim.parts.map(() => []);
@@ -5425,7 +5489,8 @@ export function assemble(input: AssembleInput): AssembleResult {
5425
5489
  const own = e < 0 ? st : (stats.get(extendKeys[e]) as ProjectionStats);
5426
5490
  // A pixel outside the piece's region is a base pixel filled from a cut's under image (issue #193): occluded, never visible.
5427
5491
  if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1 && (region === null || region.data[p] === 1)) visible.data[p] = 1;
5428
- if (retakeByName.get(name)?.data[p] !== 1 && yields.get(name)?.mask.data[p] !== 1 && trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
5492
+ if (paintingGains.get(name)?.data[p] === 1) visible.data[p] = 1;
5493
+ if (paintingGains.get(name)?.data[p] !== 1 && retakeByName.get(name)?.data[p] !== 1 && yields.get(name)?.mask.data[p] !== 1 && trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
5429
5494
  }
5430
5495
  const { x0, y0, x1, y1, opaque } = measureBox(r);
5431
5496
  if (opaque === 0) {
@@ -5450,12 +5515,14 @@ export function assemble(input: AssembleInput): AssembleResult {
5450
5515
  visible_px: vc.visible_px,
5451
5516
  occluded_px: vc.occluded_px,
5452
5517
  projected_core_px: region === null ? st.core : count(st.coreMask, region),
5453
- source_px_taken: (region === null ? st.taken : count(st.takenMask, region)) - (yields.get(name)?.reading.yieldedTaken ?? 0) - (retakeByName.get(name) == null ? 0 : count(st.takenMask, retakeByName.get(name)!)),
5518
+ source_px_taken: (region === null ? st.taken : count(st.takenMask, region)) - (yields.get(name)?.reading.yieldedTaken ?? 0) - (retakeByName.get(name) == null ? 0 : count(st.takenMask, retakeByName.get(name)!)) - (paintingGains.has(name) ? count(st.takenMask, paintingGains.get(name)!) : 0),
5454
5519
  visible_not_projected_px: vc.visible_not_projected_px,
5455
5520
  refused_drift_px: region === null ? st.refused : count(st.refusedMask, region),
5456
5521
  merged_px: merged,
5457
5522
  seam_override_px: 0,
5458
5523
  };
5524
+ const paintingGain = paintingGains.get(name);
5525
+ if (paintingGain !== undefined) record.painting_extension_px = paintingGain.data.reduce((sum, value) => sum + value, 0);
5459
5526
  if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
5460
5527
  if (limb !== undefined) record.limb = limb;
5461
5528
  const image = crop(r, x0, y0, record.w, record.h);
@@ -6363,10 +6430,12 @@ export function stageFields(
6363
6430
  armSplit?: ArmSplit;
6364
6431
  keySkeleton?: ControlSkeleton;
6365
6432
  headFaceUnderfill?: boolean;
6366
- /** `assemble.yield_refused`, default false; alpha-only transfer of refused cores and their connected non-core rims. */
6433
+ /** `assemble.yield_refused`, default false; transfer of refused cores/rims, finishing nearby accepted projection RGB. */
6367
6434
  yieldRefused?: boolean;
6368
6435
  /** Plan parts whose own samples survive source re-take beneath soft front cover. */
6369
6436
  keepUnderOccluders?: readonly string[];
6437
+ /** Base painting only; named final parts, explicit band and path bound (#848). */
6438
+ paintingExtension?: readonly PaintingExtension[];
6370
6439
  turnMovingPair?: (a: string, b: string) => boolean;
6371
6440
  } {
6372
6441
  const problems: Problem[] = [];
@@ -6389,6 +6458,7 @@ export function stageFields(
6389
6458
  ...(cfg.assemble.head_face_underfill === undefined ? {} : { headFaceUnderfill: cfg.assemble.head_face_underfill }),
6390
6459
  ...(cfg.assemble.yield_refused === undefined ? {} : { yieldRefused: cfg.assemble.yield_refused }),
6391
6460
  ...(cfg.assemble.keep_under_occluders === undefined ? {} : { keepUnderOccluders: cfg.assemble.keep_under_occluders }),
6461
+ ...(cfg.assemble.extend_painting === undefined ? {} : { paintingExtension: cfg.assemble.extend_painting }),
6392
6462
  ...(turnMovingPair === null ? {} : { turnMovingPair }),
6393
6463
  rigScale: cfg.assemble.rig_scale,
6394
6464
  plan: cfg.assemble.plan,
package/src/check.ts CHANGED
@@ -5748,9 +5748,9 @@ export function windBandLines(line: WindBandLine, plant: WindBandPlant = {}): st
5748
5748
  * its grid to one pixel per world unit makes the panels native, without a
5749
5749
  * second framing implementation. Each animation is rendered separately to
5750
5750
  * release its sampled poses before the next. Only the two observation files
5751
- * survive; the ordinary frames and contact sheets are scratch.
5751
+ * survive; core's extremes-only mode omits ordinary frames and contact sheets.
5752
5752
  */
5753
- function renderExtremes(build: string, out: string, animations: readonly string[], frames: unknown, render: RigcRunner): string[] {
5753
+ function renderExtremes(build: string, out: string, animations: readonly string[], frames: unknown, frameSets: unknown, render: RigcRunner): string[] {
5754
5754
  if (animations.length === 0) return ['EXTREMES SKIP: CHECK_EXTREMES_NO_ANIMATIONS — no animation in motion.json'];
5755
5755
  const scratch = join(out, '_extremes');
5756
5756
  const dest = join(out, 'extremes');
@@ -5767,15 +5767,18 @@ function renderExtremes(build: string, out: string, animations: readonly string[
5767
5767
  const lines: string[] = [];
5768
5768
  for (const animation of animations) {
5769
5769
  const rendered = join(scratch, 'render');
5770
- const call = render(['render', '--candidate', build, '--animation', animation, '--fps', String(IDLE_FPS), '--viewport-from', grid, '--extremes', '--out', rendered]);
5771
- if (call.status !== 0) throw new Error(`render --extremes ${JSON.stringify(animation)} exited ${call.status}: ${call.out.trim()}${rigcRanClause(call)}`);
5770
+ const call = render(['render', '--candidate', build, '--animation', animation, '--fps', String(IDLE_FPS), '--viewport-from', grid, '--extremes-only', '--out', rendered]);
5771
+ if (call.status !== 0) throw new Error(`render --extremes-only ${JSON.stringify(animation)} exited ${call.status}: ${call.out.trim()}${rigcRanClause(call)}`);
5772
5772
  const summaries = call.out.split('\n').map(line => line.trim().replace(/^\.\.\s+/, '')).filter(line => line.startsWith('EXTREMES '));
5773
- if (summaries.length === 0) throw new Error(`render --extremes ${JSON.stringify(animation)} printed no EXTREMES summary`);
5774
- // The sidecar owns directory naming, including core's escaping of names.
5775
- const sidecar = JSON.parse(readFileSync(join(rendered, 'frames.json'), 'utf8')) as { sets: Array<{ animation: string; dir: string }> };
5776
- const set = sidecar.sets.find(row => row.animation === animation);
5777
- if (set === undefined) throw new Error(`render --extremes has no set for ${JSON.stringify(animation)}`);
5778
- const source = join(rendered, set.dir), target = join(dest, set.dir);
5773
+ if (summaries.length === 0) throw new Error(`render --extremes-only ${JSON.stringify(animation)} printed no EXTREMES summary`);
5774
+ // The earlier ordinary render already named every animation's directory.
5775
+ // Observation-only renders deliberately write no new frames.json index.
5776
+ const sets = isRecord(frameSets) && Array.isArray(frameSets.sets) ? frameSets.sets.filter(isRecord).filter(row => row.animation === animation) : [];
5777
+ const dir = sets.length === 1 ? sets[0].dir : undefined;
5778
+ if (typeof dir !== 'string' || dir.length === 0 || isAbsolute(dir) || dir.split(/[\\/]/).includes('..')) throw new Error(`the existing render has no local set for ${JSON.stringify(animation)}`);
5779
+ const source = join(rendered, dir), target = join(dest, dir);
5780
+ const observation: unknown = JSON.parse(readFileSync(join(source, 'extremes.json'), 'utf8'));
5781
+ if (!isRecord(observation) || observation.animation !== animation) throw new Error(`render --extremes-only has no set for ${JSON.stringify(animation)}`);
5779
5782
  mkdirSync(target, { recursive: true });
5780
5783
  copyFileSync(join(source, 'extremes.json'), join(target, 'extremes.json'));
5781
5784
  if (existsSync(join(source, 'extremes.png'))) copyFileSync(join(source, 'extremes.png'), join(target, 'extremes.png'));
@@ -6100,7 +6103,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6100
6103
  level = levelLine(feet, inp.rig, levelReadings, fps, stripped.physics.length, levelProblems);
6101
6104
  }
6102
6105
  }
6103
- if (!noExtremes && idle === null) extremesFrames = readJson(join(stripped === null ? foldDir : join(foldDir, 'on'), 'frames.json'), []);
6106
+ if (!noExtremes) extremesFrames = readJson(join(stripped === null ? foldDir : join(foldDir, 'on'), 'frames.json'), []);
6104
6107
  } finally {
6105
6108
  rmSync(foldDir, { recursive: true, force: true });
6106
6109
  }
@@ -6666,7 +6669,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6666
6669
  const publishCheck = (): void => write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
6667
6670
  if (motionSeam.worlds === undefined) publishCheck();
6668
6671
  else publishMotionSeamWorlds(out, seamMotionDir, motionSeam.worlds, publishCheck);
6669
- const extremes = noExtremes ? [] : renderExtremes(buildDir, out, animationNames, idle === null ? extremesFrames : readJson(join(idleDir, 'frames.json'), []), measured);
6672
+ const extremes = noExtremes ? [] : renderExtremes(buildDir, out, animationNames, idle === null ? extremesFrames : readJson(join(idleDir, 'frames.json'), []), extremesFrames, measured);
6670
6673
  if (existsSync(join(out, 'extremes'))) written.push('extremes/');
6671
6674
  const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
6672
6675
  const tally = (bar: string, reason: string | null): void => {
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
1
2
  import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
2
3
  import type { DepthConfig } from './depthsheets.ts';
3
4
  /**
@@ -302,6 +303,8 @@ export interface Assemble {
302
303
  rig_scale: number;
303
304
  plan: PlanEntry[];
304
305
  extend_below_crop?: Extend[];
306
+ /** Bounded base-painting samples assigned to named final output parts (#848). */
307
+ extend_painting?: PaintingExtension[];
305
308
  patches?: Patch[];
306
309
  cuts?: Cut[];
307
310
  alternates?: AlternateSetConfig[];
@@ -1616,7 +1619,7 @@ export interface EarlyConfig {
1616
1619
  depth?: DepthConfig;
1617
1620
  key: string;
1618
1621
  seethrough: SeeThrough;
1619
- assemble: { rig_scale: number; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[] };
1622
+ assemble: { rig_scale: number; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1620
1623
  }
1621
1624
 
1622
1625
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1685,13 +1688,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1685
1688
  if (door === 'layers') {
1686
1689
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1687
1690
  if ('assemble' in t) {
1688
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders']);
1691
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting']);
1689
1692
  if (a !== null) {
1690
1693
  if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1691
1694
  if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1692
1695
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
1693
1696
  }
1694
1697
  checkKeepUnderOccluders(c, a, null);
1698
+ if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
1695
1699
  if ('yield_refused' in a && typeof a.yield_refused !== 'boolean') {
1696
1700
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.yield_refused', `is ${show(a.yield_refused)}; true or false is required`);
1697
1701
  }
@@ -1718,6 +1722,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1718
1722
  checkAlternates(c, t.assemble.alternates, made, limbs, names?.bones ?? null, names?.chains ?? null, t.regions, t.meshes);
1719
1723
  }
1720
1724
  const output = new Set([...made.parts.filter(n => !limbs?.consumed.has(n)), ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
1725
+ if (isPlainObject(t.assemble) && 'extend_painting' in t.assemble) checkPaintingExtensions(c, t.assemble.extend_painting, new Set([...output].filter(n => !made.patches.includes(n) && !limbs?.consumed.has(n))));
1721
1726
  if ('motion_seams' in t) {
1722
1727
  c.problems.push(...motionSeamProblems(t.motion_seams, output, 'config.motion_seams', 'CONFIG_MOTION_SEAM'));
1723
1728
  }
@@ -1935,6 +1940,10 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1935
1940
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1936
1941
  checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones, names.chains, t.regions, t.meshes);
1937
1942
  }
1943
+ if (isPlainObject(t.assemble) && 'extend_painting' in t.assemble) {
1944
+ const output = new Set([...parts.filter(n => !limbs?.consumed.has(n)), ...cuts.filter(n => !limbs?.consumed.has(n)), ...(limbs?.parts.keys() ?? [])]);
1945
+ checkPaintingExtensions(c, t.assemble.extend_painting, output);
1946
+ }
1938
1947
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains, t.stops);
1939
1948
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
1940
1949
  if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
@@ -2400,6 +2409,17 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
2400
2409
  * writes one — the step a config without a plan has most likely skipped.
2401
2410
  */
2402
2411
  /** Validate at the early door too; resolve against the plan once that door reads it. */
2412
+ /** Spell the object schema at the config door, then share semantic checks with assemble. */
2413
+ function checkPaintingExtensions(c: Check, value: Json, names: ReadonlySet<string> | null = null): void {
2414
+ const at = 'config.assemble.extend_painting';
2415
+ if (c.array(at, value, false)) (value as Json[]).forEach((entry, i) => {
2416
+ c.object(`${at}[${i}]`, entry, ['part', 'band', 'max_distance'], []);
2417
+ });
2418
+ for (const p of paintingExtensionProblems(value, names)) {
2419
+ if (!c.problems.some(old => old.code === p.code && old.object === p.object)) c.problems.push(p);
2420
+ }
2421
+ }
2422
+
2403
2423
  function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readonly string[] | null): void {
2404
2424
  if (!('keep_under_occluders' in a)) return;
2405
2425
  const at = 'config.assemble.keep_under_occluders';
@@ -2415,7 +2435,7 @@ function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readon
2415
2435
 
2416
2436
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
2417
2437
  const required = sectionRequired(door, 'assemble');
2418
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders']);
2438
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting']);
2419
2439
  if (a === null) return { parts: [], patches: [], cuts: [] };
2420
2440
  if (required.includes('plan') && !('plan' in a)) {
2421
2441
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
@@ -2450,6 +2470,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
2450
2470
  });
2451
2471
  }
2452
2472
  checkKeepUnderOccluders(c, a, parts);
2473
+ if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
2453
2474
  if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
2454
2475
  (a.extend_below_crop as Json[]).forEach((entry, i) => {
2455
2476
  const at = `${p}.extend_below_crop[${i}]`;
@@ -4676,6 +4697,18 @@ function checkTurn(
4676
4697
  parents: ReadonlyMap<string, string> = new Map(),
4677
4698
  ): void {
4678
4699
  const at = 'config.turn';
4700
+ // The auto motion gate swaps in the unreduced source but retains the motion.
4701
+ // A depth surface's numeric deform belongs to the emitted topology and its
4702
+ // influence order; it cannot also describe that reference attachment.
4703
+ const checkSurfaceMeshMode = (part: string, roleAt: string): void => {
4704
+ if (isPlainObject(rawMeshes) && isPlainObject(rawMeshes[part]) && 'auto' in rawMeshes[part]) {
4705
+ c.fail(
4706
+ 'CONFIG_TURN_SURFACE_AUTO',
4707
+ roleAt,
4708
+ `declares "${part}" as "surface", but config.meshes.${part} uses auto; depth-turn deform keys target the emitted mesh, while auto's motion gate also builds its unreduced source with the same motion; use grid or contour for config.meshes.${part} to keep the depth turn`,
4709
+ );
4710
+ }
4711
+ };
4679
4712
  const meshBoundBones = (part: string): string[] => {
4680
4713
  if (!isPlainObject(rawMeshes) || !isPlainObject(rawMeshes[part])) return [];
4681
4714
  const m = rawMeshes[part] as Record<string, Json>;
@@ -4832,6 +4865,7 @@ function checkTurn(
4832
4865
  } else {
4833
4866
  if (role === 'surface') {
4834
4867
  surfaceParts.push(part);
4868
+ checkSurfaceMeshMode(part, `${at}.roles.${part}`);
4835
4869
  }
4836
4870
  if (role === 'unit') {
4837
4871
  if (part === 'ear_r' || part === 'ear_l') earParts.push(part);
@@ -4963,6 +4997,7 @@ function checkTurn(
4963
4997
  c.fail('CONFIG_TURN_ROLE_UNKNOWN', `${bAt}.roles.${part}`, `is ${show(role)}; expected one of ${TURN_ROLES.map((x) => `"${x}"`).join(', ')}`);
4964
4998
  } else if (role === 'surface') {
4965
4999
  bodySurfaceParts.push(part);
5000
+ checkSurfaceMeshMode(part, `${bAt}.roles.${part}`);
4966
5001
  } else if (role === 'unit' && meshParts.has(part)) {
4967
5002
  bodyUnitMeshParts.push(part);
4968
5003
  if (bones.has(bodyRoot)) {
@@ -0,0 +1,109 @@
1
+ /** Explicit painting ownership, after assembly and before output cropping (#848). */
2
+ import type { Problem } from './errors.ts';
3
+ import { refuseIfAny } from './errors.ts';
4
+ import { CORE_ALPHA, OPAQUE_ALPHA_ABOVE } from './layers.ts';
5
+ import { newMask, type Mask, type Raster } from './raster/types.ts';
6
+
7
+ export interface PaintingExtension {
8
+ part: string;
9
+ /** Half-open rectangle in rig pixels; no candidate or path leaves it. */
10
+ band: [number, number, number, number];
11
+ /** Maximum four-neighbour path length from the original target silhouette. */
12
+ max_distance: number;
13
+ }
14
+
15
+ /** Shared by every config door and the callable assemble API. */
16
+ export function paintingExtensionProblems(value: unknown, names: ReadonlySet<string> | null = null): Problem[] {
17
+ const at = 'config.assemble.extend_painting', out: Problem[] = [];
18
+ const fail = (code: string, object: string, detail: string): void => { out.push({ code, object, detail }); };
19
+ if (!Array.isArray(value)) {
20
+ fail('CONFIG_FIELD_TYPE', at, 'requires an array of {part, band, max_distance}; omit it for no extension');
21
+ return out;
22
+ }
23
+ const seen = new Set<string>();
24
+ value.forEach((entry: unknown, i) => {
25
+ const path = `${at}[${i}]`;
26
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
27
+ fail('CONFIG_FIELD_TYPE', path, 'requires {part, band, max_distance}'); return;
28
+ }
29
+ const e = entry as Record<string, unknown>;
30
+ for (const key of Object.keys(e)) if (!['part', 'band', 'max_distance'].includes(key)) fail('CONFIG_KEY_KNOWN', `${path}.${key}`, 'is unknown; only part, band and max_distance are read');
31
+ for (const key of ['part', 'band', 'max_distance']) if (!(key in e)) fail('CONFIG_FIELD_PRESENT', `${path}.${key}`, 'is absent and required; no bound or target is inferred');
32
+ if ('part' in e) {
33
+ if (typeof e.part !== 'string' || e.part === '') fail('CONFIG_FIELD_TYPE', `${path}.part`, `is ${JSON.stringify(e.part)}; a nonempty final layer-derived output part name is required`);
34
+ else {
35
+ if (seen.has(e.part)) fail('CONFIG_PART_UNIQUE', `${path}.part`, `names "${e.part}" again; declare each part once`);
36
+ seen.add(e.part);
37
+ if (names !== null && !names.has(e.part)) fail('CONFIG_NAME_RESOLVES', `${path}.part`, `names "${e.part}"; a final layer-derived output part is required, after cuts and limb splitting — known: ${[...names].join(', ')}`);
38
+ }
39
+ }
40
+ if ('band' in e && !(Array.isArray(e.band) && e.band.length === 4 && e.band.every(n => Number.isSafeInteger(n) && n >= 0) && e.band[0] < e.band[2] && e.band[1] < e.band[3])) {
41
+ fail('CONFIG_FIELD_TYPE', `${path}.band`, `is ${JSON.stringify(e.band)}; [x0,y0,x1,y1] as nonnegative safe integers with x0 < x1 and y0 < y1 is required, rig pixels, upper bounds exclusive`);
42
+ }
43
+ if ('max_distance' in e && !(Number.isSafeInteger(e.max_distance) && (e.max_distance as number) > 0)) {
44
+ fail('CONFIG_FIELD_TYPE', `${path}.max_distance`, `is ${JSON.stringify(e.max_distance)}; a positive safe integer of four-neighbour rig-pixel steps is required`);
45
+ }
46
+ });
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Returns disjoint gain masks from one frozen final stack, without changing it.
52
+ * Alpha >0 on ANY assembled part blocks a candidate. Only original target
53
+ * alpha >8 seeds paths. A path stays in-band on unheld painting alpha >=250;
54
+ * its first pixel is distance 1. Conflicting claims refuse, never pick a part.
55
+ * RGB copying is the caller's job, from its existing RGB painting resample.
56
+ */
57
+ export function paintingExtensionMasks(
58
+ declarations: readonly PaintingExtension[],
59
+ parts: readonly { name: string; image: Raster; x?: number; y?: number; canExtend?: boolean }[],
60
+ painting: Raster,
61
+ ): ReadonlyMap<string, Mask> {
62
+ refuseIfAny(paintingExtensionProblems(declarations, new Set(parts.filter(p => p.canExtend !== false).map(p => p.name))));
63
+ const { width: W, height: H } = painting;
64
+ const problems: Problem[] = [];
65
+ declarations.forEach((e, i) => {
66
+ if (e.band[2] > W || e.band[3] > H) problems.push({ code: 'ASSEMBLE_PAINTING_BAND_INSIDE', object: `config.assemble.extend_painting[${i}] (part "${e.part}")`, detail: `band is [${e.band.join(', ')}]; rig is ${W}x${H}, so x1 <= ${W} and y1 <= ${H} are required; no clipping is applied` });
67
+ });
68
+ refuseIfAny(problems);
69
+ const held = new Uint8Array(W * H), claimed = new Map<number, string>(), masks = new Map<string, Mask>();
70
+ for (const { name, image, x = 0, y = 0 } of parts) {
71
+ if (x < 0 || y < 0 || x + image.width > W || y + image.height > H) problems.push({ code: 'ASSEMBLE_PAINTING_CANVAS', object: `part "${name}"`, detail: `is ${image.width}x${image.height}; a final part inside the ${W}x${H} rig is required` });
72
+ else for (let j = 0; j < image.height; j++) for (let i = 0; i < image.width; i++) if (image.data[(j * image.width + i) * 4 + 3] > 0) held[(y + j) * W + x + i] = 1;
73
+ }
74
+ refuseIfAny(problems);
75
+ for (const e of declarations) {
76
+ const target = parts.find(p => p.name === e.part)!;
77
+ const [x0, y0, x1, y1] = e.band, bw = x1 - x0, bh = y1 - y0;
78
+ const distance = new Uint32Array(bw * bh), queue = new Int32Array(bw * bh), gain = newMask(W, H);
79
+ const eligible = (x: number, y: number): boolean => held[y * W + x] === 0 && painting.data[(y * W + x) * 4 + 3] >= CORE_ALPHA;
80
+ const seed = (x: number, y: number): boolean => {
81
+ const tx = x - (target.x ?? 0), ty = y - (target.y ?? 0);
82
+ return tx >= 0 && ty >= 0 && tx < target.image.width && ty < target.image.height && target.image.data[(ty * target.image.width + tx) * 4 + 3] > OPAQUE_ALPHA_ABOVE;
83
+ };
84
+ let end = 0;
85
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
86
+ if (!eligible(x, y) || !(seed(x - 1, y) || seed(x + 1, y) || seed(x, y - 1) || seed(x, y + 1))) continue;
87
+ const q = (y - y0) * bw + x - x0;
88
+ distance[q] = 1; queue[end++] = q;
89
+ }
90
+ for (let at = 0; at < end; at++) {
91
+ const q = queue[at], x = x0 + q % bw, y = y0 + Math.floor(q / bw), p = y * W + x;
92
+ gain.data[p] = 1;
93
+ const previous = claimed.get(p);
94
+ if (previous !== undefined) {
95
+ refuseIfAny([{ code: 'ASSEMBLE_PAINTING_EXTENSION_CONFLICT', object: `parts "${previous}" and "${e.part}"`, detail: `both claim rig pixel (${x},${y}); disjoint reachable bands are required, no order-based ownership is guessed` }]);
96
+ }
97
+ claimed.set(p, e.part);
98
+ if (distance[q] >= e.max_distance) continue;
99
+ for (const [nx, ny] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
100
+ if (nx < x0 || ny < y0 || nx >= x1 || ny >= y1) continue;
101
+ const n = (ny - y0) * bw + nx - x0;
102
+ if (distance[n] !== 0 || !eligible(nx, ny)) continue;
103
+ distance[n] = distance[q] + 1; queue[end++] = n;
104
+ }
105
+ }
106
+ masks.set(e.part, gain);
107
+ }
108
+ return masks;
109
+ }
package/src/parts.ts CHANGED
@@ -99,6 +99,8 @@ import { recordedFocusProblems } from './setfocus.ts';
99
99
  export interface PartRecord {
100
100
  /** Report-only final last-writer census (#817); absent on older records. */
101
101
  provenance?: PixelProvenance;
102
+ /** Painting ownership gained by #848, independent of final raw-value equality. */
103
+ painting_extension_px?: number;
102
104
  /** Optional #773 input reference; production geometry does not consume it yet. */
103
105
  depth?: PartDepthReference;
104
106
  name: string;
@@ -430,6 +432,7 @@ export const PART_KEYS = [
430
432
  'h',
431
433
  'opaque_px',
432
434
  'provenance',
435
+ 'painting_extension_px',
433
436
  'visible_px',
434
437
  'occluded_px',
435
438
  'projected_core_px',
@@ -451,7 +454,7 @@ const LIMB_KEY = 'limb';
451
454
  const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance') as ReadonlyArray<(typeof PART_KEYS)[number]>;
452
455
  /** The port's own counts: all three or none (none = a record the reference wrote). */
453
456
  export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
454
- const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance');
457
+ const REQUIRED_KEYS = PART_KEYS.filter((k) => k !== 'painting_extension_px' && !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance');
455
458
 
456
459
  /** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
457
460
  export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
@@ -571,6 +574,14 @@ export function readParts(path: string): PartsFile {
571
574
  return checkParts(raw, path);
572
575
  }
573
576
 
577
+ /** Additive ownership census; not another term in the value partition. */
578
+ function checkPaintingExtensionCount(r: Record<string, unknown>, label: string, fail: (code: string, object: string, detail: string) => void): void {
579
+ if (!('painting_extension_px' in r)) return;
580
+ const n = r.painting_extension_px;
581
+ if (!Number.isSafeInteger(n) || (n as number) < 0) fail('PARTS_FIELD_TYPE', label + ' painting_extension_px', `is ${show(n)}; a nonnegative safe integer is required`);
582
+ else if (!Number.isSafeInteger(r.opaque_px) || (n as number) > (r.opaque_px as number)) fail('PARTS_COUNTS_ADD_UP', label + ' painting_extension_px', `is ${show(n)}; at most opaque_px ${show(r.opaque_px)} is required`);
583
+ }
584
+
574
585
  function show(v: unknown): string {
575
586
  return v === undefined ? 'absent' : JSON.stringify(v);
576
587
  }
@@ -660,6 +671,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
660
671
  }
661
672
  const count = (k: string): number | null => (Number.isInteger(r[k]) && (r[k] as number) >= 0 ? (r[k] as number) : null);
662
673
  if ('provenance' in r) checkProvenance(r.provenance, r.opaque_px, label + ' provenance', fail);
674
+ checkPaintingExtensionCount(r, label, fail);
663
675
  const [op, vis, occ, vnp] = ['opaque_px', 'visible_px', 'occluded_px', 'visible_not_projected_px'].map(count);
664
676
  if (held.length === VISIBILITY_KEYS.length && op !== null && vis !== null && occ !== null && vnp !== null) {
665
677
  if (vis + occ !== op) fail('PARTS_COUNTS_ADD_UP', `${label}`, `visible_px ${vis} + occluded_px ${occ} = ${vis + occ}; opaque_px ${op} is required`);
@@ -812,6 +824,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
812
824
  const r = p as Record<string, unknown>;
813
825
  const part817 = typeof r.name === 'string' ? `part "${r.name}"` : `records[${j}]`;
814
826
  if ('provenance' in r) checkProvenance(r.provenance, r.opaque_px, `${at} ${part817} provenance`, fail);
827
+ checkPaintingExtensionCount(r, `${at} ${part817}`, fail);
815
828
  const l = r[LIMB_KEY];
816
829
  if (typeof l !== 'object' || l === null || Array.isArray(l) || !('cap' in l)) return;
817
830
  const part = typeof r.name === 'string' ? `part "${r.name}"` : `records[${j}]`;
package/src/provenance.ts CHANGED
@@ -7,7 +7,7 @@ import { crop, type Raster } from './raster/index.ts';
7
7
  export const AUTOMATIC_WRITERS = {
8
8
  sourceProjection: -1, extend: -2, growRim: -3, limbSplit: -4,
9
9
  limbCap: -5, limbHybrid: -6, limbFeather: -7, fringeRestore: -8,
10
- seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13,
10
+ seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14,
11
11
  } as const;
12
12
  export type AutomaticWriter = keyof typeof AUTOMATIC_WRITERS;
13
13
  export interface PixelProvenance {
@@ -77,6 +77,7 @@ export function provenanceLines(parts: readonly PartRecord[]): string[] {
77
77
  const edits = new Map<string, number>(), stages = new Map<string, number>();
78
78
  const detail = (e: ReadonlyMap<string, number>): string => [...e].map(([name, px]) => `${name} ${px}`).join(', ') || 'none';
79
79
  for (const p of parts) {
80
+ if (p.painting_extension_px !== undefined) lines.push(` painting extension: part "${p.name}" gained ${p.painting_extension_px} opaque painting samples; source RGB copied exactly in rig space; ownership count independent of final raw-value equality`);
80
81
  const v = p.provenance;
81
82
  if (v === undefined) continue;
82
83
  opaque += p.opaque_px; unchanged += v.unchanged_layer_px; automatic += v.automatic_px; hand += v.hand_px;