rig-parts 1.75.0 → 1.76.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
@@ -116,10 +116,10 @@ read by no CPU stage).
116
116
  | `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
117
  | `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
118
  | `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 |
119
- | `assemble.patches` | assemble; `meshes`/`regions` keys may name them too | **authored**, optional: `[{name, box, alpha, draw}]` — an extra part cut from the **painting** itself, for figure no See-through layer holds (a hem both runs dropped). `box` is `[x0, y0, x1, y1]` in **rig** pixels, `x1`/`y1` exclusive — the space of `parts.json` and `recomposite_rig.png`, where the hole is found; `alpha` is `"silhouette"` (the painting's figure silhouette inside the box, the one `--seam silhouette` uses) or `"box"` (the whole box); `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`. A patch is always a region: its bone is `regions.<name>`, in the one place every region's bone lives, and a `meshes` entry for it is refused. `draw` sets assembled part order; an exclusively bound limb rider's final rig slot joins that limb's block (see the limbs subsection). See §5 for how to place one |
120
- | `assemble.cuts` | assemble; `meshes`/`regions` keys may name the new parts too, and must (each is a part like any other) | **authored**, optional (issue #170): `[{from, into, polygon, draw, overlap, under, under_fill}]`, `under` and `under_fill` optional — a plan part's layer cut by a polygon the author draws: the pixels of plan part `from` whose **centre** lies inside `polygon` (even-odd; a centre exactly on an edge is outside) leave `from` and make the new part `into`, colour and alpha, so every pixel of the layer ends in exactly one of the two. `polygon` is `[[x, y], …]`, at least three points, non-negative integer **rig** pixels — the space of `parts.json` and of the patches' boxes. The file that shows the layer in those coordinates is `assemble`'s own `rig/parts/<from>.png`, placed at its `parts.json` `x`, `y` (the PNG's pixel `(i, j)` is rig pixel `(x + i, y + j)`), or `render/recomposite_rig.png`, the whole rig 1:1; the contact sheets (`sheet`) are scaled and carry no coordinates. `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`, as for a patch (to keep the setup pose, draw the piece immediately in front of `from` — `{"before": "<the plan part after from>"}`, or `"front"` when `from` is the last plan part; `{"before": "<from>"}` draws it behind `from`, which `under` refuses as `ASSEMBLE_UNDER_DRAW` because the fill would hide it). `from` must be a plan part (`CONFIG_NAME_RESOLVES`; a patch or another cut's piece is refused; a part a limb takes may be cut where no segment of the limb holds it, issue #564, §5 *Limbs*), `into` a new name (`CONFIG_PART_UNIQUE`), the polygon three or more integer points (`CONFIG_FIELD_TYPE`). The piece's `parts.json` record carries `from`'s `<run>:<tag>` and its own counts, each split from the layer's by where the pixel lies; it is a mesh or a region under its own name, and the coverage rule holds it like a plan part. What the config cannot know the stage refuses: a polygon point past the rig (`ASSEMBLE_CUT_INSIDE`), two cuts of one part sharing a pixel (`ASSEMBLE_CUT_OVERLAP`), a cut that takes none of the layer's opaque pixels or leaves `from` none (`ASSEMBLE_CUT_PIXELS`), a cut of a part a limb takes that takes a pixel a segment of the limb holds (`ASSEMBLE_CUT_CROSSES_LIMB`). `overlap` is required, a non-negative integer of rig pixels with no default (`CONFIG_FIELD_TYPE`): the **band** — the polygon's pixels within `overlap` px of its edge by Chebyshev distance (the polygon's mask less its erosion by a `2 * overlap + 1` square) — stays in `from` as well as going to the piece, so the piece's resampled edge has art under it; the counts stay a strict partition and the band is the base's extra copy, printed on the `cut:` line. At `0` the two abut and `check`'s seam bar judges the edge between them (`CHECK_SEAM_WITHIN_BAR`, §5). A band only covers the edge at rest: a piece that swings well clear of where it was painted shows the hole it leaves, and a thin piece's band shows a ghost of the piece. `under` (issue #193), optional, is the author's image of what lies under the piece: a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE` unless a non-empty string), in rig pixels over the polygon's **bounding box** — the least and greatest polygon x and y, so the image is `max x − min x` by `max y − min y` and its pixel `(i, j)` is rig pixel `(min x + i, min y + j)`. Where the cut removed pixels from `from` — the piece's pixels (alpha above 0) less the band — `from` takes the image's pixel, colour and alpha wherever the image's alpha is at least the alpha the cut removed there (**filled**); elsewhere `from` stays empty there (**left empty**): a transparent pixel is the author's statement that nothing lies under it (the background, or a part drawn behind), and a less opaque one is not half-filled. The image is read nowhere else, so its pixels over the band, over the box outside the polygon or where the layer had no art never change `from`. The `cut:` line then ends `; under <n> px filled, <m> px left empty` (`n + m` is every removed pixel), so an unintended hole is a number in the log, not a guess; the filled pixels count in `from`'s `opaque_px` and `occluded_px` (the piece is drawn over them), never as visible or projected. The stage refuses: a file that is not there (`ASSEMBLE_UNDER_PRESENT`) or not a PNG (`ASSEMBLE_UNDER_DECODES`), an image not exactly the box's size (`ASSEMBLE_UNDER_SIZE`, both sizes given), an image that fills none of the removed pixels — all transparent there, or another file (`ASSEMBLE_UNDER_EMPTY`; to leave the whole hole, drop `under`), and a piece drawn behind `from` (`ASSEMBLE_UNDER_DRAW`: the fill would hide it; draw it `"front"` or `{"before": "<the plan part after from>"}`). Producing the image is the author's, by whatever means; absent, `from` keeps only its band. `under_fill` (issue #360), optional, is the one value `"colour"` (`CONFIG_FIELD_TYPE` on any other): the same pixels are filled and left empty, and a filled pixel takes the image's **colour** at the alpha the cut removed there instead of the image's alpha, so `from`'s alpha is the uncut layer's wherever the image fills and its silhouette does not grow — an opaque image over a soft edge leaves the edge soft. It applies to the part's hidden-art layer as well as to a file; a cut with neither is refused (`ASSEMBLE_UNDER_FILL_SOURCE`). The `cut:` line's filled count then says how many took the colour only: `; under 190 px filled (190 colour only, at the alpha the cut removed), 0 px left empty`. Absent, a filled pixel takes the image's colour and alpha, as above |
119
+ | `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
+ | `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
121
  | `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
- | `assemble.head_face_underfill` | assemble, `assemble --propose-plan` | **authored**, optional (issue #492): `boolean` (`CONFIG_FIELD_TYPE` on any other). When both runs exist and `full:face` has at least `200` opaque run px (`PROPOSE_MIN_PX`), the stage maps `full:face` into the head canvas via `fullLayerToHead` and measures how much of its hole-filled silhouette (`alpha > 8`) is covered by `head:face` (`alpha > 8`). A head-run `face` covering less than `75 %` (`HEAD_FACE_COVERAGE_FLOOR`, measured `0.0 %..48.6 %` on hollow/omitted head runs vs `84.8 %..99.6 %` on clean head runs across the 4 public examples and 9-standee corpus) is refused with `SEETHROUGH_HEAD_RUN_HOLLOW` unless `head_face_underfill: true` is declared. When `head_face_underfill: true` is declared and `head:face` exists (`opaqueRunPx >= 200`), `fullLayerToHead(full:face)` is composited under `head:face` on the head canvas (`alpha = max(headAlpha, fullAlpha)`, RGB blended by `headAlpha / 255`) before `head:face` is placed in rig space (`underfill=<N>px(full:face)` on the `face` part log line and a `face: head_face_underfill composited …` note in `assemble --propose-plan`). The fill restores transparent pixels only and leaves the cut's own edge where it is — a hollow whose edge is not under hair stays visible, and that is the author's to fix at the cut. When `head:face` has fewer than `200` opaque pixels, `SEETHROUGH_HEAD_RUN_HOLLOW` still fires even under `head_face_underfill: true` because the head run omitted the face layer and cannot supply a jawline |
122
+ | `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
123
  | `assemble.yield_refused` | assemble | **authored**, optional boolean, default `false` (issue #819; other types: `CONFIG_FIELD_TYPE`). With `true`, only an original front projection-core pixel refused at max-channel drift > `DRIFT_LIMIT` (90) can yield: its original source pixel must still be opaque, and the actual top-most opaque holder below it (alpha >= `CORE_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 are not original refused pixels. 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 skipped 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
124
  | `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) |
125
125
  | `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 |
@@ -547,8 +547,8 @@ examples the four lashes reach 17-26 % (4-6 px, the lash line itself) and the pa
547
547
  1.00 and 1.04 apart, so neither is noted. When such a lash has a row between its top and
548
548
  the eyewhite's top with no art across its whole width, the lowest such row is proposed as
549
549
  `motion.blink.still` on `head`, and the rows above it stop blinking; when it has none, the
550
- note says so and nothing is cut — clear a row between the crease and the lash line in the
551
- part, or leave the crease to blink. The cut has to be a clear row: two pieces cut through
550
+ note says so and nothing is cut — choose another source layer/run using
551
+ [Source before scissors](#source-before-scissors), or leave the crease to blink. The cut has to be a clear row: two pieces cut through
552
552
  art are each resampled against their own transparent edge, and on the reference-demo example a cut
553
553
  through its lash line changed 36 px of the setup-pose render (up to 7 levels, at render
554
554
  scale 0.942) and up to 21 levels in the idle frames where the head rolls; a clear-row cut
@@ -3331,8 +3331,9 @@ proposing a cut for a defect.
3331
3331
  ### The correction loop (issue #359)
3332
3332
 
3333
3333
  A parse can be wrong: a patch painted into the wrong layer, two layers that should
3334
- be one, a split along a painted edge. An agent cannot edit a PSD, so the loop runs
3335
- on the wrapper form instead, which any image library edits:
3334
+ be one, a split along a painted edge. Start with
3335
+ [Source before scissors](#source-before-scissors). The wrapper export below is
3336
+ an inspection and structural-authoring interface:
3336
3337
 
3337
3338
  1. **Export.** `rig-parts layers --export work art.psd` writes `work/layers.json`
3338
3339
  and one PNG per layer (`<index>_<name>.png`), drawn layers in draw order, then
@@ -3349,12 +3350,12 @@ on the wrapper form instead, which any image library edits:
3349
3350
  and `seam=` counts on the part drawn over it when it is not: projection and the
3350
3351
  seam override repaint that part to the painting's colour, so the error map
3351
3352
  stays clean while the part carries the wrong art.
3352
- 3. **Fix** the PNGs and `layers.json`: move the pixels, and when a layer's art
3353
- leaves its box, grow the PNG and the box together (`LAYERS_PNG_MATCHES_BBOX`
3353
+ 3. **Choose the source remedy** from the ladder above. For a structural piece
3354
+ that needs an author layer, keep its PNG and `layers.json` box together (`LAYERS_PNG_MATCHES_BBOX`
3354
3355
  otherwise).
3355
3356
  4. **Re-run** from step 2 on `work`.
3356
3357
 
3357
- **A worked example** (the selftest's, `fixtures/artist.ts`): a 128-px file whose
3358
+ **A historical reader control**, not a repair prescription (the selftest's, `fixtures/artist.ts`): a 128-px file whose
3358
3359
  right eyewhite was painted half into `back hair` — the 8 x 10 patch at
3359
3360
  `[52, 22, 60, 32]`, near-white, behind the face. Assembled from the export:
3360
3361
 
@@ -3364,7 +3365,7 @@ recomposite vs source: mean |d|=0.40, within 8: 99.5%, error px > 40: 80, uncove
3364
3365
 
3365
3366
  80 covered error pixels, blue on the error map, in one 8 x 10 block; the face's
3366
3367
  part line says `drift=60`, the block's pixels inside the face's projection core
3367
- whose colour is more than 90 off the painting's. The fix:
3368
+ whose colour is more than 90 off the painting's. The historical pixel-transfer control:
3368
3369
  in `work/00_back_hair.png` clear the block (it is `back hair`'s pixels
3369
3370
  `(20..27, 18..27)`, box `[32, 4, 96, 48]`); widen `work/03_eyewhite-r.png` from
3370
3371
  8 x 10 to 16 x 10, painting the block into its right half; and set that layer's
@@ -3423,8 +3424,8 @@ existing hardlinks are refused before artifact output; the ordinary mode names
3423
3424
  | --- | --- | --- | --- |
3424
3425
  | `layers` | the table, then one `author-made: layer "author:<label>" — …` line per layer the author made (group `author` in the table; §5, *A layer the author made*), then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
3425
3426
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
3426
- | `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. Then the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…)[, "<holder>" drawn in front of "<part>"][; the painting is within 90 of "<behind>" (<px> px at alpha >= 250, drawn behind "<part>")]; informational: no gate reads this. Swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do): swap, rebuild and read this line again; if it moves, removing it needs different input layers. At rest, the seam pass repaints the holder's pixels that pass its rule from the painting` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order or that a part drawn behind it matches within 90 of the painting (issues #255, #351, #758) — that layer is not what the painting shows there; no gate reads this line, swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do) — swap, rebuild and read this line again, and if it moves, removing it needs different input layers — and at rest the seam pass repaints the holder's pixels that pass its rule from the painting (none on the five public paintings), then, per subset alternate set, one `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 <joints> (<k> px), dropped <m> (<d> px)` line per limb part cut from a layer of more than one island when the set names one limb side of it (issue #588; issue #608: a joint taken to the nearest island reads `<bone> [x, y] (<e> px off it, within its cap's radius <r>)`, and a part that lost no pixel `dropped none`), then one `LINT set "<set>" <part>: <layer part> holds <limb> and <other limb> in one island (8-connected, alpha above 8), split by the nearest segment line at the set's bones: kept <k> px (<share>), gave <m> px (<share>) to <other limb>; <b> px in the overlap band (nearer one line than the other by 0.5 px or less) went to the limb drawn in front, <bk> kept and <bg> given` line per limb part cut from a layer of one island that a limb the set does not carry also takes (issue #609; k, m and b count the part's pixels above alpha 8, each share of the part's pixels before the split, 3 places) and one `LINT set "<set>" <part>: <n> px under <base part> excluded` line per subset alternate set's limb part and base part drawn over it that the set does not replace which took a pixel (issue #587, `assemble.alternates` above; none on the public examples, which declare no alternate set); and look at `render/recomposite_error_rig.png`. The opt-in `assemble.yield_refused` adds `yielded N px` to each affected part's drift line (including a yielded refusal smaller than the body bar); with it off the line names `assemble.yield_refused: true` as the conditional config remedy before any cut. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then — with `config.limbs` (issue #349) — one `limb cut: "<part>" opaque <n> = "<segment part>" <px> + …` line per part a limb takes and one `limb cap:` line per joint, its cap's pixels from the cap image, from the layer and missing, and its box (under `cap_shape: "ball"`, issue #579, then the lower half's pixels written, left as painted and missing), followed on a short cap by one `LINT "<part>" cap at [x, y] … short_px left <n>, right <m>: no layer gives them …` line (issue #557) — from the upper segment where it gave a pixel (under `cap_shape: "disc"` always), under `edge: "feather"` the pixels faded, under `cut_band` the line the cut was placed on and the stroke pixels it and the joint line cross (issue #360) (§3, *Limbs: cut at their joints*), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (reference-demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
3427
- | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT <bone> under <parent>: amplitude <a> below the link above's <b> in the keyed chain …` — under `--cloth`, a keyed sibling the recipe left the only keyed child of its parent, which `CHAIN_LAG` would refuse (issue #572, §3 *A keyed sibling left behind*): raise the field it names to `<b>` or more in size; `LINT hairback_… on mesh '<part>' (side <r\|l>): <N> tree(s) for <M> visible hair mass(es) (mass 0 columns x=<minX>..<maxX>, y=<minY>..<maxY>; …)` — back-hair cloth trees whose count does not match the side's visible hair masses (`max(1, floor(w / (4 * grid)) + 1)` per mass) or whose link or tip leaves its mass's column range (issues #445, #461): omit `chains` on `cloth."back hair"` or draft trees per visible hair mass inside its columns; `LINT <a_limb> and <b_limb> on <part> (<from>) cross at [x, y] with no per-limb split: …` — two limbs sharing one layer cross in an X without a per-limb split (issue #446): declare `config.limbs.<covering>.split` (`{ "line": [[x, y], …] }` or `{ "edge": "painting" }`, §3 *Which limb*); `LINT foot_<s> at [x, y], the <s>_ankle joint and leg_<s>1's tip, lies on none of leg_<s>'s art within 1 px along its joint line (the nearest of it on that line is the pixel [x, y], D px from the joint): …` — an ankle keypoint off its leg's art on the line `assemble` sizes the ankle's cap by (issue #517): `propose` drafts every leg without its ankle cut, the shoe layer stays one part, and nothing is moved onto the art; move that ankle keypoint onto the leg's or the shoe's art and re-run `propose` (`(the line crosses none of it)` when no art lies on that line at all); `LINT stop on <chain> (<firstLink> at [x0, y0]) against <against> (at [ax, ay]) places <chain>_touch at [tx, ty], above <firstLink>'s root (rest R deg, \|rest\| + outward B deg): …` — a stop's contact point `<chain>_touch` lies above `links[0]`'s root in rig px or pushes `\|rest\| + outward` past `180°` (issue #447, §2 `stops`): stop against the descending limb bone below `firstLink`, set `links` to start at a link below `<chain>_touch`, or omit the stop on a rising limb; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `reference-sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — cut the part with `assemble.cuts` to separate them; `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
3427
+ | `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. Then the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…)[, "<holder>" drawn in front of "<part>"][; the painting is within 90 of "<behind>" (<px> px at alpha >= 250, drawn behind "<part>")]; informational: no gate reads this. Swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do): swap, rebuild and read this line again; if it moves, removing it needs different input layers. At rest, the seam pass repaints the holder's pixels that pass its rule from the painting` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order or that a part drawn behind it matches within 90 of the painting (issues #255, #351, #758) — that layer is not what the painting shows there; no gate reads this line, swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do) — swap, rebuild and read this line again, and if it moves, removing it needs different input layers — and at rest the seam pass repaints the holder's pixels that pass its rule from the painting (none on the five public paintings), then, per subset alternate set, one `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 <joints> (<k> px), dropped <m> (<d> px)` line per limb part cut from a layer of more than one island when the set names one limb side of it (issue #588; issue #608: a joint taken to the nearest island reads `<bone> [x, y] (<e> px off it, within its cap's radius <r>)`, and a part that lost no pixel `dropped none`), then one `LINT set "<set>" <part>: <layer part> holds <limb> and <other limb> in one island (8-connected, alpha above 8), split by the nearest segment line at the set's bones: kept <k> px (<share>), gave <m> px (<share>) to <other limb>; <b> px in the overlap band (nearer one line than the other by 0.5 px or less) went to the limb drawn in front, <bk> kept and <bg> given` line per limb part cut from a layer of one island that a limb the set does not carry also takes (issue #609; k, m and b count the part's pixels above alpha 8, each share of the part's pixels before the split, 3 places) and one `LINT set "<set>" <part>: <n> px under <base part> excluded` line per subset alternate set's limb part and base part drawn over it that the set does not replace which took a pixel (issue #587, `assemble.alternates` above; none on the public examples, which declare no alternate set); and look at `render/recomposite_error_rig.png`. The opt-in `assemble.yield_refused` adds `yielded N px` to each affected part's drift line (including a yielded refusal smaller than the body bar); with it off the line names `assemble.yield_refused: true` as the conditional config remedy before any cut. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then — with `config.limbs` (issue #349) — one `limb cut: "<part>" opaque <n> = "<segment part>" <px> + …` line per part a limb takes and one `limb cap:` line per joint, its cap's pixels from the cap image, from the layer and missing, and its box (under `cap_shape: "ball"`, issue #579, then the lower half's pixels written, left as painted and missing), followed on a short cap by one `LINT "<part>" cap at [x, y] … short_px left <n>, right <m>: no layer gives them …` line (issue #557) — from the upper segment where it gave a pixel (under `cap_shape: "disc"` always), under `edge: "feather"` the pixels faded, under `cut_band` the line the cut was placed on and the stroke pixels it and the joint line cross (issue #360) (§3, *Limbs: cut at their joints*), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (reference-demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. Use [Source before scissors](#source-before-scissors) for the source ladder; a missing independently moving structural piece can use a painting patch (below) |
3428
+ | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT <bone> under <parent>: amplitude <a> below the link above's <b> in the keyed chain …` — under `--cloth`, a keyed sibling the recipe left the only keyed child of its parent, which `CHAIN_LAG` would refuse (issue #572, §3 *A keyed sibling left behind*): raise the field it names to `<b>` or more in size; `LINT hairback_… on mesh '<part>' (side <r\|l>): <N> tree(s) for <M> visible hair mass(es) (mass 0 columns x=<minX>..<maxX>, y=<minY>..<maxY>; …)` — back-hair cloth trees whose count does not match the side's visible hair masses (`max(1, floor(w / (4 * grid)) + 1)` per mass) or whose link or tip leaves its mass's column range (issues #445, #461): omit `chains` on `cloth."back hair"` or draft trees per visible hair mass inside its columns; `LINT <a_limb> and <b_limb> on <part> (<from>) cross at [x, y] with no per-limb split: …` — two limbs sharing one layer cross in an X without a per-limb split (issue #446): declare `config.limbs.<covering>.split` (`{ "line": [[x, y], …] }` or `{ "edge": "painting" }`, §3 *Which limb*); `LINT foot_<s> at [x, y], the <s>_ankle joint and leg_<s>1's tip, lies on none of leg_<s>'s art within 1 px along its joint line (the nearest of it on that line is the pixel [x, y], D px from the joint): …` — an ankle keypoint off its leg's art on the line `assemble` sizes the ankle's cap by (issue #517): `propose` drafts every leg without its ankle cut, the shoe layer stays one part, and nothing is moved onto the art; move that ankle keypoint onto the leg's or the shoe's art and re-run `propose` (`(the line crosses none of it)` when no art lies on that line at all); `LINT stop on <chain> (<firstLink> at [x0, y0]) against <against> (at [ax, ay]) places <chain>_touch at [tx, ty], above <firstLink>'s root (rest R deg, \|rest\| + outward B deg): …` — a stop's contact point `<chain>_touch` lies above `links[0]`'s root in rig px or pushes `\|rest\| + outward` past `180°` (issue #447, §2 `stops`): stop against the descending limb bone below `firstLink`, set `links` to start at a link below `<chain>_touch`, or omit the stop on a rising limb; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `reference-sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — use another layer/run first; when no separate layer exists, a structural `assemble.cuts` entry separates the independently moving arms ([Source before scissors](#source-before-scissors)); `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
3428
3429
  | `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 |
3429
3430
  | `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` |
3430
3431
  | `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) |
@@ -3654,9 +3655,88 @@ run on a `direct` build (it has no rig spec to read) refuses A15 once per keyed
3654
3655
  bone. No stage runs `validate`: the gate is `rigc build`, which reads the rig spec
3655
3656
  and its declaration, and is green on both (`RG19`).
3656
3657
 
3657
- **Patching a hole no layer holds.** When an `uncovered hole` stays large after the
3658
- plan is right — both See-through runs dropped a piece of the figure, red in
3659
- `recomposite_error_rig.png` — add an `assemble.patches` entry:
3658
+ ### Source before scissors
3659
+
3660
+ **Scissors are for structure, never for repair.** `cuts`, `patches` and
3661
+ `author:<label>` layers separate something that must move independently and
3662
+ that See-through has no layer for: a joint, an accessory, or a rear piece that
3663
+ changes draw order. A tear, ghost, smudge, stray outline or seam is a source
3664
+ problem. Follow this repair ladder, recording what each step measured:
3665
+
3666
+ 1. Choose another See-through layer or run (`full` versus `head`, another tag,
3667
+ or `alternates`).
3668
+ 2. Re-run See-through with a tighter resolution or crop.
3669
+ 3. Correct the declared depth or role in config.
3670
+
3671
+ A cut is appropriate for an independently moving accessory still inside its
3672
+ parent's layer, two independently moving limbs in one layer, or a rear piece
3673
+ that needs a separate draw position. A patch or author layer can supply such a
3674
+ structural piece when no run provides it. These are authoring decisions, not
3675
+ ways to hide a defect under another part. A seam finding alone does not justify
3676
+ moving a polygon. Existing edits remain legal: accounting is a report, with no
3677
+ new cut/patch arithmetic, visual threshold or acceptance gate.
3678
+
3679
+ Assembly reports **two independent axes**. Unchanged colour does not mean
3680
+ unedited structure.
3681
+
3682
+ **Value axis.** `provenance:` names each output part; `provenance total:` sums
3683
+ its figure, with separate lines/totals for alternate sets. Each part record's
3684
+ `provenance` contains `unchanged_layer_px`, `hand_px`, `automatic_px`,
3685
+ `edits: [{edit, px}]` and `automatic: [{stage, px}]`. The exclusive partition is
3686
+ `unchanged_layer_px + hand_px + automatic_px = opaque_px`; edit subtotals equal
3687
+ `hand_px`, automatic subtotals equal `automatic_px`. Only final alpha above 8
3688
+ counts, after `seamOverride`. Copies in separate output images count separately;
3689
+ the figure total is not a union silhouette.
3690
+
3691
+ Compare final RGBA first with the immutable original See-through layer after
3692
+ normal `placeLayer`/`layerToRig` mapping, before cleanup, projection or other
3693
+ preparation. Equality is unchanged, even after intervening edits. Otherwise
3694
+ **the last stage that changed a value wins**. A same-value assignment cannot
3695
+ supersede an earlier value-changing operation. Cut, limb-split and hybrid
3696
+ buffer copies at the same coordinates inherit their input's value lineage;
3697
+ creating a new transparent output buffer is not a colour change. A cut piece
3698
+ keeps its real source layer as baseline. An `author:<label>` layer and painting
3699
+ patch have no See-through baseline: they start as `<run>:author:<label>` or
3700
+ `patches[i]`, retaining that label unless a later operation actually changes
3701
+ RGBA. Actual cut under-fill changes use `cuts[i]`; restoring raw-layer values
3702
+ still ends in the unchanged class by the first comparison.
3703
+
3704
+ Automatic value changes are named `sourceProjection`, `extend`, `growRim`,
3705
+ `limbCap`, `limbFeather`, `fringeRestore`, `seamOverride`, `armSplit`,
3706
+ `headFaceUnderfill` or `ghostCleanup`. `limbSplit` and `limbHybrid` remain
3707
+ recognized stage names, but their pure copies do not claim a value change.
3708
+ Run preparation compares actual normalized samples, not layer-wrapper identity:
3709
+ the head under-fill loop assigns the full composite, but equal samples keep
3710
+ their earlier lineage. Arm-generated tags use their original hand-layer
3711
+ contributors (the input copy order for a combined arm split), never the
3712
+ prepared raster as baseline. Cleanup names retained mapped samples whose RGBA
3713
+ it changes. `fringeRestore` records a cleared bridge restored to its previous
3714
+ value; final raw equality still takes precedence. `unattributed` explicitly
3715
+ reports a changed sample with missing lineage, never silently calling it
3716
+ unchanged or inferring a stage. It is an accounting defect to investigate,
3717
+ not a hand-edit acceptance threshold.
3718
+
3719
+ **Ownership axis.** `ownership:` lines and the optional `cut_ownership` array
3720
+ in `parts.json` name every `cuts[i]`, with `from`, `into`,
3721
+ `moved_in_opaque_px`, `retained_band_opaque_px`, `cleared_opaque_px`,
3722
+ `under_filled_px`, `under_filled_opaque_px` and `net_cleared_opaque_px`.
3723
+ These reuse the original parent partition counts before a separately selected
3724
+ cut source, limb processing, fringe or seam passes. Moved-in opaque pixels are
3725
+ those taken from the parent; gross cleared opaque pixels are moved-in minus
3726
+ the retained band, **before refill**. Total under-fill counts every filled
3727
+ pixel (including faint alpha), regardless of colour equality; opaque under-fill
3728
+ counts its alpha above 8. Net cleared opaque pixels equal gross cleared minus
3729
+ opaque under-fill, and can be negative if filling faint art increases opacity.
3730
+ Indices refer to that assembly's effective cut list, including a set's custom
3731
+ list. A cut can therefore report moved pixels while every final value remains
3732
+ unchanged. Patches and author layers retain their own edit names on the value
3733
+ axis; they have no parent subtraction to record in this cut-move census.
3734
+ Neither a value count nor an edit boundary proves a visual defect's cause.
3735
+
3736
+ **Patching an independently moving piece no layer holds.** Follow
3737
+ [Source before scissors](#source-before-scissors) for any uncovered hole first.
3738
+ When the missing piece is structural and must move separately, an
3739
+ `assemble.patches` entry describes it:
3660
3740
  the box round the hole in rig pixels, `alpha: "silhouette"` so the page round the
3661
3741
  figure is not taken, and a `regions.<name>` bone. Draw it `"back"` (or
3662
3742
  `{"before": …}` the part it belongs under) and let the box **reach under its
@@ -3669,8 +3749,8 @@ and read `uncovered error px` again: a patch covering `n` of the uncovered error
3669
3749
  pixels lowers it by exactly `n` (the selftest's `AS17` plants that and counts it).
3670
3750
 
3671
3751
  **Cutting a piece out of a layer.** When one See-through layer holds two things that
3672
- must move apart — a scarf end painted into the sweater's `topwear`, both arms in one
3673
- `handwear` — add an `assemble.cuts` entry (§3): open `rig/parts/<part>.png` from an
3752
+ must move apart and no separate source layer supplies them — a scarf end painted into the sweater's `topwear`, both arms in one
3753
+ `handwear` — use [Source before scissors](#source-before-scissors), then add a structural `assemble.cuts` entry (§3): open `rig/parts/<part>.png` from an
3674
3754
  earlier `assemble`, add its `parts.json` `x`, `y` to a pixel's column and row to get
3675
3755
  rig pixels, and draw the polygon round the piece. The stage prints, between the part
3676
3756
  lines and the `fringe pushed back` lines, one line per cut part in the `pixels:`
@@ -4041,8 +4121,8 @@ says why each requirement exists and what breaks without it.
4041
4121
  | <a id="ASSEMBLE_SINGLE_RUN_PLAN"></a>`ASSEMBLE_SINGLE_RUN_PLAN` | with no `--head`, a plan row takes `"head"`, or an `extend_below_crop` entry continues a part below a crop there is not | `"full"` in the row; remove the entry |
4042
4122
  | <a id="ASSEMBLE_SINGLE_RUN_HEAD_BOX"></a>`ASSEMBLE_SINGLE_RUN_HEAD_BOX` | with no `--head`, the config sets `seethrough.head_box`, which places a head run there is not | remove `head_box`, or give the head run with `--head` |
4043
4123
  | <a id="ASSEMBLE_ARM_SPLIT_CUTS_ARM"></a>`ASSEMBLE_ARM_SPLIT_CUTS_ARM` | `seethrough.arm_split` cuts a horizontal opaque run of the arm layer into more than two segments (issue #439; the detail gives the row, the run and the split x) | `seethrough.arm_split`: move `x` or `line` so each horizontal opaque run of the arm layer is split at most once |
4044
- | <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) | re-seed `config.seethrough.seed`, split the merged layer into `author:` layers in the full run, or cut the overgarment from the sleeve in `config.assemble.cuts` |
4045
- | <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, and that is the author's to fix at the cut) |
4124
+ | <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 |
4125
+ | <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) |
4046
4126
  | <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 |
4047
4127
  | <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 |
4048
4128
  | <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 |
@@ -4095,7 +4175,7 @@ says why each requirement exists and what breaks without it.
4095
4175
  | <a id="KEYPOINTS_JOINT_KNOWN"></a><a id="KEYPOINTS_JOINT_STATE"></a><a id="KEYPOINTS_POSITION_STATED"></a><a id="KEYPOINTS_POSITION_INSIDE"></a>`KEYPOINTS_JOINT_KNOWN`, `KEYPOINTS_JOINT_STATE`, `KEYPOINTS_POSITION_STATED`, `KEYPOINTS_POSITION_INSIDE` | a joint name outside body-18, a state outside observed / occluded / missing, an observed joint with no `at` or a missing one with one, or a position outside the `width`x`height` image | that joint |
4096
4176
  | <a id="KEYPOINTS_PERSON_UNIQUE"></a><a id="KEYPOINTS_PERSON_CHOSEN"></a>`KEYPOINTS_PERSON_UNIQUE`, `KEYPOINTS_PERSON_CHOSEN` | two people share an id; or the file holds more than one person and no `--person` names one, or `--person` names none of them (the message lists the ids) | `--person <id>` |
4097
4177
  | <a id="KEYPOINTS_IMAGE_SIZE"></a>`KEYPOINTS_IMAGE_SIZE` | the file's `width`x`height` is not the `--source` painting's | a file measured on this painting |
4098
- | <a id="PARTS_FILE_PRESENT"></a><a id="PARTS_IS_JSON"></a><a id="PARTS_KEY_KNOWN"></a><a id="PARTS_FIELD_PRESENT"></a><a id="PARTS_FIELD_TYPE"></a><a id="PARTS_NAME_UNIQUE"></a><a id="PARTS_FROM_KNOWN"></a><a id="PARTS_CUT_FROM_KNOWN"></a><a id="PARTS_BOX_INSIDE_RIG"></a><a id="PARTS_COUNTS_ADD_UP"></a><a id="PARTS_HOLES_LARGEST_FIRST"></a><a id="PARTS_HOLE_PART_KNOWN"></a><a id="PARTS_SINGLE_RUN_PAD"></a>`PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_CUT_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`, `PARTS_COUNTS_ADD_UP`, `PARTS_HOLES_LARGEST_FIRST`, `PARTS_HOLE_PART_KNOWN`, `PARTS_SINGLE_RUN_PAD`) | the `parts.json` read is not assemble's contract (`PARTS_FILE_PRESENT`: no `parts.json` under `--parts`, which names the directory holding `parts.json` and `parts/` — `work/rig` from `assemble --out work`, or `out` from `build --out out`; `PARTS_COUNTS_ADD_UP`: `visible_px + occluded_px` is not `opaque_px`, `visible_not_projected_px` is above `visible_px`, or the `recomposite` block's figures disagree — more uncovered than error pixels, a hole larger than its box, the wrong number of holes listed, listed areas that do not sum to `uncovered_error_px`; the last two codes are the block's order and its border names; `PARTS_SINGLE_RUN_PAD`: the `single_run_pad` block's margins are not the centring, its file is not smaller than its square, or `rig_size` is not the file at the scale, issue #366 — §5) | pass the directory holding `parts.json` and `parts/` (`work/rig` or `out`), or re-run assemble; do not edit `parts.json` |
4178
+ | <a id="PARTS_FILE_PRESENT"></a><a id="PARTS_IS_JSON"></a><a id="PARTS_KEY_KNOWN"></a><a id="PARTS_FIELD_PRESENT"></a><a id="PARTS_FIELD_TYPE"></a><a id="PARTS_NAME_UNIQUE"></a><a id="PARTS_FROM_KNOWN"></a><a id="PARTS_CUT_FROM_KNOWN"></a><a id="PARTS_BOX_INSIDE_RIG"></a><a id="PARTS_COUNTS_ADD_UP"></a><a id="PARTS_HOLES_LARGEST_FIRST"></a><a id="PARTS_HOLE_PART_KNOWN"></a><a id="PARTS_SINGLE_RUN_PAD"></a>`PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_CUT_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`, `PARTS_COUNTS_ADD_UP`, `PARTS_HOLES_LARGEST_FIRST`, `PARTS_HOLE_PART_KNOWN`, `PARTS_SINGLE_RUN_PAD`) | the `parts.json` read is not assemble's contract (`PARTS_FILE_PRESENT`: no `parts.json` under `--parts`, which names the directory holding `parts.json` and `parts/` — `work/rig` from `assemble --out work`, or `out` from `build --out out`; `PARTS_COUNTS_ADD_UP`: `visible_px + occluded_px` is not `opaque_px`, `visible_not_projected_px` is above `visible_px`, or the `recomposite` block's figures disagree — more uncovered than error pixels, a hole larger than its box, the wrong number of holes listed, listed areas that do not sum to `uncovered_error_px`; the last two codes are the block's order and its border names; `PARTS_SINGLE_RUN_PAD`: the `single_run_pad` block's margins are not the centring, its file is not smaller than its square, or `rig_size` is not the file at the scale, issue #366 — §5) | pass the directory holding `parts.json` and `parts/` (`work/rig` or `out`), or re-run assemble; do not edit `parts.json`; optional value/ownership report fields follow [Source before scissors](#source-before-scissors) |
4099
4179
 
4100
4180
  ### compare
4101
4181
 
@@ -4390,7 +4470,7 @@ documents do not ship). Each is an input this package built; none is an author's
4390
4470
  | <a id="CHECK_RIGC_GREEN"></a>`CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
4391
4471
  | <a id="CHECK_LOOP_LAST_FRAME_AT_DURATION"></a>`CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
4392
4472
  | <a id="CHECK_LOOP_CLOSES"></a>`CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted); with a physics constraint, the frames of the rig with every physics constraint left out, and the object says so (§7, *loop*) | a track whose last key is not its first |
4393
- | <a id="CHECK_SEAM_WITHIN_BAR"></a>`CHECK_SEAM_WITHIN_BAR` | the setup pose (or, under `--set <name>` / `--all-sets`, an alternate set's setup pose posed at its `drawn` bone angles with its `<set>_*` attachments and `hides` applied, issue #465) does not reproduce the flat stack of parts (`recomposite_rig.png`, or `recomposite_rig_<set>.png`) | read the `seam pair` lines and `check/seam/` (`check/seam_<set>/`) pictures (§7; magenta = a counted pixel, cyan = the box round them): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png` (`recomposite_rig_<set>.png`). A pair of one layer cut in two with `differing_copies_px` is the cut line: move the polygon's edge, not `overlap` |
4473
+ | <a id="CHECK_SEAM_WITHIN_BAR"></a>`CHECK_SEAM_WITHIN_BAR` | the setup pose (or, under `--set <name>` / `--all-sets`, an alternate set's setup pose posed at its `drawn` bone angles with its `<set>_*` attachments and `hides` applied, issue #465) does not reproduce the flat stack of parts (`recomposite_rig.png`, or `recomposite_rig_<set>.png`) | read the `seam pair` lines and `check/seam/` (`check/seam_<set>/`) pictures (§7; magenta = a counted pixel, cyan = the box round them): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png` (`recomposite_rig_<set>.png`). A pair of one layer cut in two with `differing_copies_px` identifies differing copies near the cut; follow [Source before scissors](#source-before-scissors) before revisiting a structural separation. Widening `overlap` is not a source remedy |
4394
4474
  | <a id="CHECK_BREATH_VISIBLE"></a>`CHECK_BREATH_VISIBLE` | the breath alone barely moves the torso (`topwear`, rendered alone, the idle with and without its breath tracks — issue #411; the whole idle's figure is printed beside it, and motion that is not the breath's is not counted), or the idle has no breath track at all — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
4395
4475
  | <a id="CHECK_BLINK_NO_HOLE"></a>`CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
4396
4476
  | <a id="CHECK_CHAIN_LAG"></a>`CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above — or, in a chain that carries a part the scene file declares `rests`, more than the link above (issue #245; the object names the part and the file). A lag refusal (issue #580) prints the step, then the two phases it read — as the sine reader returns them, a negative amplitude read as amplitude > 0 at phase + 0.5 — then their difference, folded into (−¼, ¼] cycle because a sine's sign is half a cycle (so flipping an amplitude's sign does not change the step), then the child phases that pass for this parent: `phase step -0.230 cycle(s); >= 0.001 is required — … Phases read: "chest" 0.000, "sleeve_l0" 0.270 …; the step is their difference, 0.270, folded into (-1/4, 1/4] cycle … here a "sleeve_l0" phase in [0.001, 0.250] or [0.501, 0.750]` | for a lag, that chain track's `phase`/`lag`: a child phase from the parent's + 0.001 to + 0.25, modulo 0.5 — the windows the line prints. A larger phase helps only inside them: a child 0.25 to 0.5 above its parent still reads as a lead, and a negated `amps` moves nothing. For a swing, its `amps` (non-decreasing toward the tip; non-increasing on a chain declared resting) |
@@ -4401,10 +4481,10 @@ documents do not ship). Each is an input this package built; none is an author's
4401
4481
  | <a id="CHECK_FEET_STAY_LEVEL"></a>`CHECK_FEET_STAY_LEVEL` | a `footwear` part riding a bone that hangs from another turns more than ±10 degrees from its setup pose on a frame where a bone above that bone is turned (issue #348) — the bar is HQ's line (rig-c/parts#347 ruling 4), not a figure measured here; the object names the part, its bone and the bone it hangs from, the detail the animation, frame and time, the turned bone and its turn, the drawing's turn, and every bone the turn came through with its `inherit` | config.bones' `inherit` on the foot's bone (§3, `bones`): a mode that does not take the parent's rotation (`noRotationOrReflection`, `onlyTranslation`) keeps the drawing at its setup angle; which mode is the author's. Or the clip's keys on the foot's own bone, which turn the shoe whatever its mode |
4402
4482
  | <a id="CHECK_STRESS_OFF_CHAIN"></a>`CHECK_STRESS_OFF_CHAIN` | a mesh from a See-through `handwear` layer that rides one chain of the idle does not turn rigidly with it: with the chain's first link turned 90°, −90° or 180° on a throwaway copy (physics left out, posed by rigc, issue #347), its worst edge reads above 1.0000 as max(ratio, 1/ratio) against the setup pose. The object names the mesh, the link and the turn; the detail quotes the edge and names each bone the mesh is weighted to off the chain, with how many vertices carry it and the largest weight. Or the turned render shows no mesh on the slot: not read is not a pass | the mesh's `segments`: drop the segment on the bone the line names (a 16 px `chest` stub at the shoulder, as `propose` drafted until issue #347), so the sleeve rides its chain alone. Measured (rigc 2.47.1): `demo`'s sleeves read 2.46 to 254.7 with the stub and 1.0000 without it, and the setup-pose seam does not move (mean 0.147, 0 px over 40, both ways) |
4403
4483
  | <a id="CHECK_LIMB_ONE_BONE"></a>`CHECK_LIMB_ONE_BONE` | a part a `config.limbs` segment made is bound in `rig.json` to a bone besides its segment's — a mesh weighted to a second bone, or a slot on another bone — or to none (issue #349); the object names the part, its limb and its bone, the detail every other bone | `regions.<part>` the segment's bone, or a mesh whose `segments` name that bone alone: a bend is a rotation at the joint |
4404
- | <a id="CHECK_SEAM_OVER_RANGE"></a>`CHECK_SEAM_OVER_RANGE` | a limb joint with a declared `range` (issue #360), bent to one of its range's ends or its middle, fails the rest-pose seam's bar against the same pose with the joint's own art drawn under the limb — the bend uncovers the joint; the object names the joint, its limb and segment, the part, the bone and the angle. Under `cap_shape: "disc"` (issue #360, unit B) or `"ball"` (issue #598), also any pixel of the disc over 40 at a pose, under the seam's bar or not. Also a joint the line cannot read (its bone has no parent in `rig.json`, no slot shows its limb, or its cap's disc holds no opaque art), and, refused, two renders of the bent copy that rigc did not put on one grid | a cap that covers the turn: the layer's art within the cap's radius on the upper side of the joint line, or a `cap` image painting what the layer lacks (`limb cap:` names the missing pixels); or a narrower `range` |
4405
- | <a id="SEAM_OVER_MOTION"></a>`SEAM_OVER_MOTION` | **Existing internal-joint and explicit-seam pairs remain warnings** (issue #493; a seam that opens in motion may be intended). **Proven body-root pairs are a bar** (issue #686): an above-floor opening adds `CHECK_SEAM_OVER_MOTION`, sets `PASS` false and exits 1. Every declared neighbour pair — each limb's two segments at a joint, the upper and the one below (`parts.json`'s limb records, `config.limbs`), and under `--requirements` each `seam`'s `part` and `neighbour`, one pair however many declare it — read in every animation `motion.json` holds, the idle included, by rig-c's seam reading: one `rigc render --seam <a>:<b>,… --fps 12` over the packed build as played (physics on), whose `seam.json` (`rigc-seam/1`, rig-c's AUTHORING §8.8) names per pair and frame a **gap** (background inside the joint) or a **ghost** (one piece's overlap band swung clear of its partner), each frame's cells (`px`, one texel of the coarser drawing — a rig px on a build at scale 1), its cell size and its world box. Printed for existing warning pairs and animations with a frame over the floor: `WARN seam "<a>\|<b>" opens on N frame(s) [<frames>] max <px> px in "<animation>" — over the one-cell floor`, then how many more frames rigc named that were under it. **The floor**: a frame's gap or ghost is reported when it holds more cells than its box is long (its longer side, in cells) — thicker than one cell. A cell is drawn when the renderer paints its centre at half opacity, so an outline that moves by less than a cell flips the cells along it: a line one cell deep, joined or scattered, holds at most as many cells as its box is long, and a hole of any area more. The rule types no number; measured on `examples/wind`'s idle, rigc names 43 openings over its six limb pairs (the two elbows, 1 to 5 cells, boxes up to 17 cells long), every one one cell thick, and on rig-c's `gallery/walk` 13 joint cracks of 1 to 3 cells, all one cell thick. `check.json`'s `SEAM_OVER_MOTION` holds `status` (`WARN`, `CLEAR`, `SKIP`, or `FAIL` for a new root tear), `fps`, `floor`, `pairs`, `animations`, `warnings` and one row per pair and animation (`opens` and `px` over the floor, `max_px`, `under_floor`, rigc's `status`, `drawn` and `banded`); for existing warning pairs it moves no exit status, no `check: PASS` and no bar count — the summary ends `; N warning(s) (SEAM_OVER_MOTION, not a bar)`. **SKIP**, named: no pair declared (no limb segment, no `seam`; then `check.json` has no key), no `parts.json`, no animation, every pair naming a slot `rig.json` lacks (each printed `not read`), or rigc reading no band on any frame of any pair (rigc's own `skip`: the two drawings share no cell at rest) — nothing measured, never a pass | if the opening is not intended: a cap that covers the turn (`config.limbs`, *Limbs: cut at their joints*; `limb cap:` names the pixels the layer lacks), or a motion that bends that joint less; if it is intended, nothing |
4484
+ | <a id="CHECK_SEAM_OVER_RANGE"></a>`CHECK_SEAM_OVER_RANGE` | a limb joint with a declared `range` (issue #360), bent to one of its range's ends or its middle, fails the rest-pose seam's bar against the same pose with the joint's own art drawn under the limb — the bend uncovers the joint; the object names the joint, its limb and segment, the part, the bone and the angle. Under `cap_shape: "disc"` (issue #360, unit B) or `"ball"` (issue #598), also any pixel of the disc over 40 at a pose, under the seam's bar or not. Also a joint the line cannot read (its bone has no parent in `rig.json`, no slot shows its limb, or its cap's disc holds no opaque art), and, refused, two renders of the bent copy that rigc did not put on one grid | [Source before scissors](#source-before-scissors) first; a cap that covers the turn: the layer's art within the cap's radius on the upper side of the joint line, or a `cap` image painting what the layer lacks (`limb cap:` names the missing pixels); or a narrower `range` |
4485
+ | <a id="SEAM_OVER_MOTION"></a>`SEAM_OVER_MOTION` | **Existing internal-joint and explicit-seam pairs remain warnings** (issue #493; a seam that opens in motion may be intended). **Proven body-root pairs are a bar** (issue #686): an above-floor opening adds `CHECK_SEAM_OVER_MOTION`, sets `PASS` false and exits 1. Every declared neighbour pair — each limb's two segments at a joint, the upper and the one below (`parts.json`'s limb records, `config.limbs`), and under `--requirements` each `seam`'s `part` and `neighbour`, one pair however many declare it — read in every animation `motion.json` holds, the idle included, by rig-c's seam reading: one `rigc render --seam <a>:<b>,… --fps 12` over the packed build as played (physics on), whose `seam.json` (`rigc-seam/1`, rig-c's AUTHORING §8.8) names per pair and frame a **gap** (background inside the joint) or a **ghost** (one piece's overlap band swung clear of its partner), each frame's cells (`px`, one texel of the coarser drawing — a rig px on a build at scale 1), its cell size and its world box. Printed for existing warning pairs and animations with a frame over the floor: `WARN seam "<a>\|<b>" opens on N frame(s) [<frames>] max <px> px in "<animation>" — over the one-cell floor`, then how many more frames rigc named that were under it. **The floor**: a frame's gap or ghost is reported when it holds more cells than its box is long (its longer side, in cells) — thicker than one cell. A cell is drawn when the renderer paints its centre at half opacity, so an outline that moves by less than a cell flips the cells along it: a line one cell deep, joined or scattered, holds at most as many cells as its box is long, and a hole of any area more. The rule types no number; measured on `examples/wind`'s idle, rigc names 43 openings over its six limb pairs (the two elbows, 1 to 5 cells, boxes up to 17 cells long), every one one cell thick, and on rig-c's `gallery/walk` 13 joint cracks of 1 to 3 cells, all one cell thick. `check.json`'s `SEAM_OVER_MOTION` holds `status` (`WARN`, `CLEAR`, `SKIP`, or `FAIL` for a new root tear), `fps`, `floor`, `pairs`, `animations`, `warnings` and one row per pair and animation (`opens` and `px` over the floor, `max_px`, `under_floor`, rigc's `status`, `drawn` and `banded`); for existing warning pairs it moves no exit status, no `check: PASS` and no bar count — the summary ends `; N warning(s) (SEAM_OVER_MOTION, not a bar)`. **SKIP**, named: no pair declared (no limb segment, no `seam`; then `check.json` has no key), no `parts.json`, no animation, every pair naming a slot `rig.json` lacks (each printed `not read`), or rigc reading no band on any frame of any pair (rigc's own `skip`: the two drawings share no cell at rest) — nothing measured, never a pass | [Source before scissors](#source-before-scissors) first; if the opening is not intended: a cap that covers the turn (`config.limbs`, *Limbs: cut at their joints*; `limb cap:` names the pixels the layer lacks), or a motion that bends that joint less; if it is intended, nothing |
4406
4486
  | <a id="CHECK_MOTION_SEAM_ROOT"></a>`CHECK_MOTION_SEAM_ROOT` | a body-root declaration has missing or ambiguous segment0, bone, parent, slot or default-skin attachment; the refusal names the part and candidates | supply the declared one-to-one hierarchy/slot/provenance binding; no nearest or mesh-influence fallback |
4407
- | <a id="CHECK_SEAM_OVER_MOTION"></a>`CHECK_SEAM_OVER_MOTION` | a proven body-root pair has an active animation gapOpen plus UNDER-owned ghost above the unchanged one-cell floor; the refusal names pair, animation, frame, cells, cell size and world box | repair the declared rest overlap or authored motion; the existing internal-joint warning policy and physics reference worlds are unchanged |
4487
+ | <a id="CHECK_SEAM_OVER_MOTION"></a>`CHECK_SEAM_OVER_MOTION` | a proven body-root pair has an active animation gapOpen plus UNDER-owned ghost above the unchanged one-cell floor; the refusal names pair, animation, frame, cells, cell size and world box | [Source before scissors](#source-before-scissors) first; repair the declared rest overlap or authored motion; the existing internal-joint warning policy and physics reference worlds are unchanged |
4408
4488
  | <a id="CLOTH_SETTLES"></a>`CLOTH_SETTLES` | **a warning, never a refusal** (issue #501; the owner's ruling: cloth at rest while the character stands may be intended). Every physics chain, read by its tip on the idle `check` renders — `rigc render --animation idle --fps 12 --geometry`, physics on, rigc's default environment. A chain is a **physics leaf** — a bone a physics constraint names (`rig.json`'s `constraints` or `motion.json`'s `physics`) with no such bone under it — and the run of physics bones above it; its tip is the leaf's end (origin plus `length` along its world x axis), or its origin when the leaf has no length; its speed between frames is the tip's distance over their time, from the idle's `geometry.json`. A chain **settles** at the first frame from which every speed to the idle's last frame is under the floor, and each one that does — before the loop's length is out — prints `WARN chain "<root>" .. "<tip>" (N physics link(s)) settles at <t> s of the <d> s idle — its tip's end under 0.01694 rig px/s from there to the last frame (fastest on the idle <v> rig px/s)`. **The floor**, 0.01694 rig px/s, is derived, not chosen: half the slowest tip an idle key drives on the public examples (the repository's floor rule, half the weaker figure) — measured with this port's build of each (rig-c 2.60.0), over the 31 chains a keyed bone at or above the leaf drives (`demo` 12, `reference-sample` 1, `scarf` 11, `wind` 7) the slowest frame-to-frame tip speed is `scarf`'s `skirt_l2`, 0.03388 rig px/s; the ten no key drives — `wind`'s skirt, eight trees and the swing, hung from a `hip` its idle never keys, and `reference-sample`'s `hip_jiggle` — fall under it from 1.5 to 2.75 s on a 3.333 s idle (`hip_jiggle` at 0 s: it never moves) and end at 0.0025 rig px/s or less. `check.json`'s `CLOTH_SETTLES` holds `status` (`WARN`, `CLEAR`), `fps`, `frames`, `duration`, `floor_rig_px_per_s`, `floor`, `levers`, `warnings` and one row per chain (`tip`, `root`, `links`, `point` — `end` or `origin` — `settles_at`, null when it does not, `max_rig_px_per_s`, `min_rig_px_per_s`); it moves no exit status, no `check: PASS` and no bar count — the summary's warning clause names it, `; N warning(s) (CLOTH_SETTLES, not a bar)` (with `SEAM_OVER_MOTION` before it, and N their sum, where that read a pair). It reads the idle alone, one loop from rigc's physics reset at frame 0: a chain that settles later than the idle is long is not seen. **SKIP**, named: no physics constraint (then `check.json` has no key), or no idle — nothing measured, never a pass | if the chain should move while the character stands, one of the consumer's two levers: an environment at render (`rigc render --environment`, wind and gravity in time — rig-c's AUTHORING §8.4), or a consumer's animation that moves the bone the chain hangs from. The drafted idle keys no hip, by measurement: a hip turn at the idle's period fails a bar on all five public examples (issue #502, §3 *What moves them*). A `cloth` recipe's skirt hangs from `hip`, which the drafted idle never keys (§3, *Recipe: cloth and hair that answer the world*, *What moves them*, which has a worked schedule for `wind`). If it is intended, nothing |
4409
4489
  | <a id="CHECK_FOLDS_READ"></a>`CHECK_FOLDS_READ` | the fold render's `frames.json` names no set for an animation, a `folds.json` is missing, not `rigc-folds/1`, another animation's or another rate's, or a folding mesh has no line rigc printed for it | a rigc problem; report it, with `rigc --version` |
4410
4490
  | <a id="CHECK_SEAM_FRAME_SIZE"></a><a id="FRAMES_SIDECAR"></a><a id="CHECK_GEOMETRY_FILE"></a>`CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR`, `CHECK_GEOMETRY_FILE` | rigc's render is not what its `frames.json` says, or its `geometry.json` is not a whole `rigc-geometry/1` export of the frames beside it (a frame count, a mesh whose vertex count differs from its rest entry; for the face half, another viewport than `frames.json`'s, other frame indices, the head bone or a feature's bone missing from a frame, a non-finite transform or time) | a rigc problem; report it |
@@ -4713,8 +4793,9 @@ assemble*, at the end of this section):
4713
4793
  "scarf"` along the cut line, 16 of them reading a band pixel where `topwear`
4714
4794
  holds the layer's colour and `scarf` the painting's, the other 8 where the cut
4715
4795
  line runs along the scarf's red edge [observed, issue #202]. A wider band moves
4716
- neither; the polygon's edge does — run it through art of one colour on both
4717
- sides, in both copies, which `rig/parts/<from>.png` and `rig/parts/<into>.png`
4796
+ neither. The historical polygon-edge experiment measured a structural cut
4797
+ through art of one colour on both sides; it is not a seam-repair prescription
4798
+ ([Source before scissors](#source-before-scissors)). The two copies in `rig/parts/<from>.png` and `rig/parts/<into>.png`
4718
4799
  show pixel for pixel. The composite meets the frame pixel centre to pixel
4719
4800
  centre: rig pixel index u lands on frame index s·u + t + 0.5·(s − 1), `s` the
4720
4801
  scale the `seam:` line prints (issue #209; the reference implementation's map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.75.0",
3
+ "version": "1.76.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assemble.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { AUTOMATIC_WRITERS as AW, compactValueBaseline, pixelProvenance, rgbaEqual, type ValueBaseline } from './provenance.ts';
1
2
  import { bindDepthRun, copyDepthMaterial, registerDepthRun, transportPartDepth, type DepthConfig, type RegisteredDepth } from './depthsheets.ts';
2
3
  /**
3
4
  * The assemble stage: two See-through runs over one painting — a FULL-body run
@@ -2257,7 +2258,7 @@ export function pixelOwners(rigLayers: readonly Raster[], rank: readonly number[
2257
2258
  * masks mark own-layer art under a subset set's actual cover (alpha above 8);
2258
2259
  * those pixels are occluded, outside every projection core, and never recoloured.
2259
2260
  */
2260
- export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE, rank: readonly number[] | null = null, keep: ReadonlyArray<Mask | null> | null = null): ProjectionStats[] {
2261
+ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE, rank: readonly number[] | null = null, keep: ReadonlyArray<Mask | null> | null = null, changed?: (layer: number, rigPixel: number) => void): ProjectionStats[] {
2261
2262
  const { W, H } = frame;
2262
2263
  const owner = pixelOwners(rigLayers, rank);
2263
2264
  const inFront = (o: number, i: number): boolean => frontOf(o, i, rank);
@@ -2320,10 +2321,14 @@ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, r
2320
2321
  if (core.data[p] === 0) continue;
2321
2322
  const w = blurred.data[p];
2322
2323
  const rest = f32(1 - w);
2324
+ let different = false;
2323
2325
  for (let c = 0; c < 3; c++) {
2324
2326
  const v = f32(f32(w * srcr.data[p * 4 + c]) + f32(rest * r.data[p * 4 + c]));
2325
- r.data[p * 4 + c] = Math.trunc(v < 0 ? 0 : v > 255 ? 255 : v);
2327
+ const next = Math.trunc(v < 0 ? 0 : v > 255 ? 255 : v);
2328
+ different ||= next !== r.data[p * 4 + c];
2329
+ r.data[p * 4 + c] = next;
2326
2330
  }
2331
+ if (different) changed?.(i, p);
2327
2332
  }
2328
2333
  return { core: coreN, taken, refused, clear, takenMask, coreMask: core, refusedMask };
2329
2334
  });
@@ -2437,7 +2442,7 @@ export function growRim(part: Raster, sel: Mask, srcr: Raster, front: Mask | nul
2437
2442
  * overwriting an earlier one — the layer whose run says whether the pixel is
2438
2443
  * visible — and `trace.ring` set when the last write was the ring's.
2439
2444
  */
2440
- export function mergeBelowCrop(part: Raster, extras: Raster[], front: Mask, srcr: Raster, headBottom: number, trace?: MergeTrace): number {
2445
+ export function mergeBelowCrop(part: Raster, extras: Raster[], front: Mask, srcr: Raster, headBottom: number, trace?: MergeTrace, changed?: (rigPixel: number, stage: 'extend' | 'growRim') => void): number {
2441
2446
  let merged = 0;
2442
2447
  extras.forEach((extra, e) => {
2443
2448
  const sel = belowCrop(part, extra, headBottom);
@@ -2445,6 +2450,7 @@ export function mergeBelowCrop(part: Raster, extras: Raster[], front: Mask, srcr
2445
2450
  for (let p = 0; p < sel.data.length; p++) {
2446
2451
  if (sel.data[p] === 0) continue;
2447
2452
  copied++;
2453
+ if (!rgbaEqual(part.data, p * 4, extra.data, p * 4)) changed?.(p, 'extend');
2448
2454
  part.data.set(extra.data.subarray(p * 4, p * 4 + 4), p * 4);
2449
2455
  if (trace !== undefined) {
2450
2456
  trace.from[p] = e;
@@ -2458,6 +2464,7 @@ export function mergeBelowCrop(part: Raster, extras: Raster[], front: Mask, srcr
2458
2464
  trace.lastRing = ring;
2459
2465
  for (let p = 0; p < ring.data.length; p++) {
2460
2466
  if (ring.data[p] === 1) {
2467
+ changed?.(p, 'growRim');
2461
2468
  trace.from[p] = e;
2462
2469
  trace.ring.data[p] = 1;
2463
2470
  }
@@ -2805,6 +2812,7 @@ export function pushBackFringe(
2805
2812
  feathered: ReadonlyArray<Mask | null> | null = null,
2806
2813
  keep: ReadonlyArray<Mask | null> | null = null,
2807
2814
  turnMovingPair: ((a: string, b: string) => boolean) | null = null,
2815
+ restored?: (part: number, rigPixel: number) => void,
2808
2816
  ): FringePushed[][] {
2809
2817
  const { width: W } = srcr;
2810
2818
  const { holder, cand } = seamCandidates(parts, srcr, rule, silhouette);
@@ -2840,7 +2848,10 @@ export function pushBackFringe(
2840
2848
  }));
2841
2849
  const counts: Array<Map<number, number>> = parts.map(() => new Map());
2842
2850
  cleared.forEach((list, i) => {
2843
- for (const c of list) if (c.on) counts[i].set(c.h, (counts[i].get(c.h) ?? 0) + 1);
2851
+ for (const c of list) {
2852
+ if (c.on) counts[i].set(c.h, (counts[i].get(c.h) ?? 0) + 1);
2853
+ else restored?.(i, (parts[i].record.y + Math.floor(c.q / parts[i].record.w)) * W + parts[i].record.x + c.q % parts[i].record.w);
2854
+ }
2844
2855
  });
2845
2856
  return counts.map((m) =>
2846
2857
  [...m.entries()]
@@ -2878,6 +2889,7 @@ export function seamOverride(
2878
2889
  silhouette: Mask | null,
2879
2890
  keep: ReadonlyArray<Mask | null> | null = null,
2880
2891
  turnMovingPair: ((a: string, b: string) => boolean) | null = null,
2892
+ written?: (part: number, rigPixel: number) => void,
2881
2893
  ): void {
2882
2894
  const { width: W } = srcr;
2883
2895
  const { top, cand } = seamCandidates(parts, srcr, rule, silhouette);
@@ -2913,9 +2925,11 @@ export function seamOverride(
2913
2925
  ) {
2914
2926
  continue;
2915
2927
  }
2928
+ const different = image.data[s] !== srcr.data[d * 4] || image.data[s + 1] !== srcr.data[d * 4 + 1] || image.data[s + 2] !== srcr.data[d * 4 + 2];
2916
2929
  image.data[s] = srcr.data[d * 4];
2917
2930
  image.data[s + 1] = srcr.data[d * 4 + 1];
2918
2931
  image.data[s + 2] = srcr.data[d * 4 + 2];
2932
+ if (different) written?.(i, d);
2919
2933
  n++;
2920
2934
  }
2921
2935
  }
@@ -3415,6 +3429,8 @@ export interface CutCount {
3415
3429
  band: number;
3416
3430
  /** With `under` only (issue #193): the base pixels filled from the under image (`fillUnder`); absent without it. */
3417
3431
  under?: number;
3432
+ /** Filled pixels with final alpha above the opaque threshold (report-only). */
3433
+ underOpaque?: number;
3418
3434
  /** With `under` only: the removed pixels the image left empty (its alpha below the alpha removed); `under + underEmpty` is the removed count. */
3419
3435
  underEmpty?: number;
3420
3436
  /** With `under_fill: "colour"` only (issue #360): the `under` pixels took the image's colour at the alpha the cut removed; absent without it. */
@@ -3518,6 +3534,8 @@ export function cutBand(polygonMask: Mask, overlap: number): Mask {
3518
3534
 
3519
3535
  /** One layer's part as the stage builds it, before it is placed: its key, pixels, projection figures, merge trace and, when the layer is cut, the rig pixels it owns. */
3520
3536
  interface Built {
3537
+ /** Report-only last writer at each rig coordinate; never read by raster arithmetic. */
3538
+ writers?: Int32Array;
3521
3539
  key: string;
3522
3540
  r: Raster;
3523
3541
  st: ProjectionStats;
@@ -4031,7 +4049,8 @@ function cutLimbs(
4031
4049
  problems.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object: `part "${src}" (config.limbs)`, detail: `opaque px: ${before} before the limbs, ${sum} over their parts; equal is required (a bug in the assembler, not in the inputs)` });
4032
4050
  }
4033
4051
  for (const { q, i } of shares) {
4034
- pieces[i] = { ...b, r: rasters[i], region: regions[i], merged: mergedIn(b, regions[i]) };
4052
+ const writers = b.writers?.slice();
4053
+ pieces[i] = { ...b, writers, r: rasters[i], region: regions[i], merged: mergedIn(b, regions[i]) };
4035
4054
  if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0) {
4036
4055
  problems.push({
4037
4056
  code: 'ASSEMBLE_LIMB_PIXELS',
@@ -4125,6 +4144,10 @@ function cutLimbs(
4125
4144
  return;
4126
4145
  }
4127
4146
  const lower = rasters[at];
4147
+ const capWrite = (p: number, data: Uint8ClampedArray, offset: number): void => {
4148
+ const ids = (pieces[at] as Built).writers;
4149
+ if (ids !== undefined && !rgbaEqual(lower.data, p * 4, data, offset)) ids[p] = AW.limbCap;
4150
+ };
4128
4151
  const lowerSrc = builtOf(sg.from) as Built;
4129
4152
  // Issue #360 (cut_band): the cap's pixels are the disc's on the upper side of the cut, wherever it was placed.
4130
4153
  const cut = (cutLines.get(l) as Array<JointLine | null>)[k] as JointLine;
@@ -4147,6 +4170,7 @@ function cutLimbs(
4147
4170
  const from: CapFrom = { box, codes: new Uint8Array(Math.max(0, box[2]) * Math.max(0, box[3])) };
4148
4171
  const mark = (x: number, y: number, code: number): void => {
4149
4172
  from.codes[(y - box[1]) * box[2] + (x - box[0])] = code;
4173
+
4150
4174
  };
4151
4175
  for (let y = Math.max(0, box[1]); y < Math.min(H, box[1] + box[3]); y++) {
4152
4176
  for (let x = Math.max(0, box[0]); x < Math.min(W, box[0] + box[2]); x++) {
@@ -4161,6 +4185,7 @@ function cutLimbs(
4161
4185
  lowerKept++;
4162
4186
  mark(x, y, CAP_FROM_LOWER_KEPT);
4163
4187
  } else if (img !== null && img.data[u + 3] > 0) {
4188
+ capWrite(p, img.data, u);
4164
4189
  lower.data.set(img.data.subarray(u, u + 4), p * 4);
4165
4190
  lowerPx++;
4166
4191
  mark(x, y, CAP_FROM_LOWER);
@@ -4173,10 +4198,12 @@ function cutLimbs(
4173
4198
  continue;
4174
4199
  }
4175
4200
  if (img !== null && img.data[u + 3] > 0) {
4176
- lower.data.set(img.data.subarray(u, u + 4), p * 4);
4201
+ capWrite(p, img.data, u);
4202
+ lower.data.set(img.data.subarray(u, u + 4), p * 4);
4177
4203
  imagePx++;
4178
4204
  mark(x, y, CAP_FROM_IMAGE);
4179
4205
  } else if (ownLower[p] === li && lowerSrc.r.data[p * 4 + 3] > 0) {
4206
+ capWrite(p, lowerSrc.r.data, p * 4);
4180
4207
  lower.data.set(lowerSrc.r.data.subarray(p * 4, p * 4 + 4), p * 4);
4181
4208
  layerPx++;
4182
4209
  mark(x, y, CAP_FROM_LAYER);
@@ -4184,6 +4211,7 @@ function cutLimbs(
4184
4211
  // Issue #360: the disc is filled from the art — the upper segment's, where the lower's layer has none.
4185
4212
  // Issue #557: under either shape — the half-disc too takes the neighbouring segment's pixels, so a cap is
4186
4213
  // short only where no layer of the limb gives a pixel.
4214
+ capWrite(p, upper.data, p * 4);
4187
4215
  lower.data.set(upper.data.subarray(p * 4, p * 4 + 4), p * 4);
4188
4216
  upperPx++;
4189
4217
  mark(x, y, CAP_FROM_UPPER);
@@ -4230,6 +4258,7 @@ function cutLimbs(
4230
4258
  // piece's region and its remaining ramp is marked feathered so pushBackFringe does not clear the fade.
4231
4259
  if (na <= OPAQUE_ALPHA_ABOVE) upperRegion.data[p] = 0;
4232
4260
  else featheredMask.data[p] = 1;
4261
+ if (upPiece.writers !== undefined) upPiece.writers[p] = AW.limbFeather;
4233
4262
  featheredPx++;
4234
4263
  }
4235
4264
  }
@@ -4334,7 +4363,8 @@ function cutLimbs(
4334
4363
  base.hybrid = { part: sg.hybrid.part, ...geometry };
4335
4364
  capFrom[loAt] = null;
4336
4365
  parts.push({ limb: l, segment: k, part: sg.hybrid.part, of: sg.from, record: { name: l, segment: k, of: base.of, bone: sg.bone, joint: sg.joint, band: geometry } });
4337
- pieces.push({ ...source, r, region, merged: mergedIn(source, region) });
4366
+ const writers = source.writers?.slice();
4367
+ pieces.push({ ...source, writers, r, region, merged: mergedIn(source, region) });
4338
4368
  capFrom.push(null);
4339
4369
  // Existing piece objects refer to the same raster and mask.
4340
4370
  for (const i of [upAt, loAt]) (pieces[i] as Built).merged = mergedIn(pieces[i] as Built, regions[i]);
@@ -4621,6 +4651,19 @@ export function assemble(input: AssembleInput): AssembleResult {
4621
4651
  const coverFootprints = new Map<string, Mask>();
4622
4652
  const limbSources = new Set(Object.values(input.limbs ?? {}).flatMap((l) => l.segments.map((sg) => sg.from)));
4623
4653
  const limbPlans = new Set([...limbSources, ...cuts.filter((c) => limbSources.has(c.into)).map((c) => c.from)]);
4654
+ const editNames: string[] = [];
4655
+ const editId = (name: string): number => {
4656
+ const old = editNames.indexOf(name);
4657
+ if (old >= 0) return old + 1;
4658
+ editNames.push(name);
4659
+ return editNames.length;
4660
+ };
4661
+ const cutIds = cuts.map((_, i) => editId(`cuts[${i}]`));
4662
+ const patchIds = patches.map((_, i) => editId(`patches[${i}]`));
4663
+ const layerWriters = new Map<string, Int32Array>();
4664
+ const valueBaselines = new Map<string, ValueBaseline | null>();
4665
+ // Extend-only rasters are still projected/merged, but are never emitted as own sources.
4666
+ const readKeys = new Set(sourcePlan.map(([, run, tag]) => `${run}:${tag}`));
4624
4667
  const rig = new Map<string, Raster>();
4625
4668
  const stats = new Map<string, ProjectionStats>();
4626
4669
  const ghost: Record<string, number> = {};
@@ -4645,7 +4688,59 @@ export function assemble(input: AssembleInput): AssembleResult {
4645
4688
  coverFootprints.set(`${run}:${l.tag}`, cover);
4646
4689
  return mask;
4647
4690
  });
4648
- const st = projectSource(rigLayers, srcr, frame, run, projectRule, rank, keep);
4691
+ // Normalize the unedited input to the same rig grid. Cleanup effects on
4692
+ // retained samples are named; split/underfill outputs are prepared writes.
4693
+ const writes = layers.map((l, i): Int32Array | null => {
4694
+ const key = `${run}:${l.tag}`;
4695
+ if (!readKeys.has(key)) return null;
4696
+ const ids = new Int32Array(W * H);
4697
+ const originalSet = run === 'full' ? fullPadded : depthHeadInput;
4698
+ const original = originalSet.layers.find(q => q.name === l.tag);
4699
+ const prepared = set.layers.find(q => q.name === l.tag);
4700
+ let preparedWriter = 0;
4701
+ if (run === 'head' && prepared?.pixels !== original?.pixels) preparedWriter = AW.headFaceUnderfill;
4702
+ if (run === 'full') {
4703
+ const arm = fullArmSplit.layers.find(q => q.name === l.tag);
4704
+ if (prepared?.pixels !== arm?.pixels || arm?.pixels !== original?.pixels) preparedWriter = AW.armSplit;
4705
+ }
4706
+ const author = l.tag.startsWith('author:') ? editId(key) : 0;
4707
+ // Split-generated tags retain their actual original run contributors,
4708
+ // never the prepared/split raster as their baseline. arm_split copies
4709
+ // hand layers in input order; the merged-arm split inherits that source.
4710
+ const splitTag = /^(handwear|drape|hand)-[rl]$/.test(l.tag);
4711
+ const parentTag = l.tag.replace(/^(drape|hand)-/, 'handwear-');
4712
+ const sourceLayers = run === 'full' && splitTag && preparedWriter === AW.armSplit
4713
+ ? input.armSplit === undefined ? fullPadded.layers.filter(q => q.name === parentTag) : fullPadded.layers.filter(q => q.name === 'handwear-r' || q.name === 'handwear-l')
4714
+ : original === undefined ? [] : [original];
4715
+ let raw: Raster | null = null;
4716
+ if (author === 0 && sourceLayers.length > 0) {
4717
+ // A sole layer needs no composite copy: layerToRig premultiplies,
4718
+ // so discarded RGB at alpha 0 has exactly the same float32 input.
4719
+ const canvas = sourceLayers.length === 1 ? placeLayer(sourceLayers[0], input.resolution) : newRaster(input.resolution, input.resolution);
4720
+ if (sourceLayers.length > 1) for (const originalLayer of sourceLayers) {
4721
+ const sourceCanvas = placeLayer(originalLayer, input.resolution);
4722
+ for (let p = 0; p < input.resolution * input.resolution; p++) if (sourceCanvas.data[p * 4 + 3] > 0) canvas.data.set(sourceCanvas.data.subarray(p * 4, p * 4 + 4), p * 4);
4723
+ }
4724
+ raw = layerToRig(canvas, frame, run);
4725
+ }
4726
+ // Reuse only a proved identical placed input, never wrapper identity.
4727
+ const samePlacedSource = raw !== null && sourceLayers.length === 1 && prepared !== undefined &&
4728
+ sourceLayers[0].pixels === prepared.pixels && sourceLayers[0].left === prepared.left && sourceLayers[0].top === prepared.top;
4729
+ const preparedRig = samePlacedSource ? raw : prepared === undefined ? null : layerToRig(placeLayer(prepared, input.resolution), frame, run);
4730
+ for (let p = 0; p < W * H; p++) {
4731
+ if (rigLayers[i].data[p * 4 + 3] === 0) continue;
4732
+ ids[p] = author;
4733
+ if (preparedWriter !== 0 && (raw === null || preparedRig !== null && !rgbaEqual(raw.data, p * 4, preparedRig.data, p * 4))) ids[p] = preparedWriter;
4734
+ if (preparedRig !== null && !rgbaEqual(preparedRig.data, p * 4, rigLayers[i].data, p * 4)) ids[p] = AW.ghostCleanup;
4735
+ }
4736
+ // Keep only samples that could equal a final opaque pixel; full raw and
4737
+ // prepared normalization temporaries end with this layer's callback.
4738
+ valueBaselines.set(key, raw === null ? null : compactValueBaseline(raw));
4739
+ return ids;
4740
+ });
4741
+ const st = projectSource(rigLayers, srcr, frame, run, projectRule, rank, keep, (i, p) => { if (writes[i] !== null) (writes[i] as Int32Array)[p] = AW.sourceProjection; });
4742
+ writes.forEach((ids, i) => { if (ids !== null) layerWriters.set(`${run}:${layers[i].tag}`, ids); });
4743
+ writes.length = 0;
4649
4744
  layers.forEach((l, i) => {
4650
4745
  rig.set(`${run}:${l.tag}`, rigLayers[i]);
4651
4746
  stats.set(`${run}:${l.tag}`, st[i]);
@@ -4775,6 +4870,7 @@ export function assemble(input: AssembleInput): AssembleResult {
4775
4870
  const extendKeys = extend.filter((e) => e.part === name).map((e) => `${e.run}:${e.tag}`);
4776
4871
  const extras = extendKeys.map((k) => rig.get(k) as Raster);
4777
4872
  let merged = 0;
4873
+ const writers = (layerWriters.get(key) as Int32Array).slice();
4778
4874
  const trace: MergeTrace = { from: new Int32Array(W * H).fill(-1), ring: newMask(W, H) };
4779
4875
  if (extras.length > 0) {
4780
4876
  const front = newMask(W, H);
@@ -4782,7 +4878,7 @@ export function assemble(input: AssembleInput): AssembleResult {
4782
4878
  const f = rig.get(`${run2}:${tag2}`) as Raster;
4783
4879
  for (let p = 0; p < W * H; p++) if (f.data[p * 4 + 3] >= FRONT_ALPHA) front.data[p] = 1;
4784
4880
  }
4785
- merged = mergeBelowCrop(r, extras, front, srcr, frame.headBottom, trace);
4881
+ merged = mergeBelowCrop(r, extras, front, srcr, frame.headBottom, trace, (p, stage) => { writers[p] = AW[stage]; });
4786
4882
  }
4787
4883
  const { x1, opaque } = measureBox(r);
4788
4884
  if (opaque === 0) {
@@ -4793,7 +4889,7 @@ export function assemble(input: AssembleInput): AssembleResult {
4793
4889
  });
4794
4890
  return null;
4795
4891
  }
4796
- return { key, r, st, extendKeys, trace, merged, region: null };
4892
+ return { key, r, st, extendKeys, trace, merged, region: null, writers };
4797
4893
  };
4798
4894
  plan.forEach(([name, run, tag], pi) => { built[pi] = buildSource(name, run, tag, pi); });
4799
4895
  // Snapshot independent piece pixels before any parent's subtraction or under fill.
@@ -4802,6 +4898,9 @@ export function assemble(input: AssembleInput): AssembleResult {
4802
4898
  const row = plan[pi], tag = input.cutLayers?.get(c.into);
4803
4899
  return tag === undefined ? null : buildSource(c.from, row[1], tag, pi);
4804
4900
  });
4901
+ // Every Built owns its writer slice. No source-map lookup follows this
4902
+ // last cut-layer build; drop originals before cut/limb working sets grow.
4903
+ layerWriters.clear();
4805
4904
  const silhouette = seamRule === 'silhouette' || patches.some((q) => q.alpha === 'silhouette') ? figureSilhouette(srcr) : null;
4806
4905
  const cut = patches.map((q, i) => {
4807
4906
  const c = cutPatch(q, srcr, silhouette);
@@ -4863,7 +4962,9 @@ export function assemble(input: AssembleInput): AssembleResult {
4863
4962
  if (band.data[p] === 1) nb++;
4864
4963
  }
4865
4964
  }
4866
- pieces[i] = { ...b, r, region: m, merged: mergedIn(b, m) };
4965
+ const writers = (b.writers as Int32Array).slice();
4966
+ // A retained base band is not written: it keeps its earlier writer.
4967
+ pieces[i] = { ...b, r, writers, region: m, merged: mergedIn(b, m) };
4867
4968
  bands.push(nb);
4868
4969
  return n;
4869
4970
  });
@@ -4912,6 +5013,10 @@ export function assemble(input: AssembleInput): AssembleResult {
4912
5013
  // Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
4913
5014
  // With under_fill "colour" (issue #360) a filled pixel keeps the alpha the cut removed and takes the image's colour only.
4914
5015
  const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon), cuts[i].under_fill);
5016
+ const band = cutBand(cutMasks[i], cuts[i].overlap);
5017
+ let opaqueFilled = 0;
5018
+ // The under operation wrote only removed pixels it actually filled.
5019
+ for (let p = 0; p < W * H; p++) if (cutMasks[i].data[p] === 1 && band.data[p] === 0 && (pieces[i] as Built).r.data[p * 4 + 3] > 0 && b.r.data[p * 4 + 3] > 0) { (b.writers as Int32Array)[p] = cutIds[i]; if (b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) opaqueFilled++; }
4915
5020
  // A hidden-art layer (issue #359) is the whole layer's covered art, read by every cut of its part: a cut it holds nothing
4916
5021
  // under fills 0, which the cut line counts, and is not refused as a file made for this one cut would be.
4917
5022
  if (f.filled === 0 && input.cuts[i].under !== undefined) {
@@ -4921,13 +5026,14 @@ export function assemble(input: AssembleInput): AssembleResult {
4921
5026
  detail: `${cuts[i].under} fills 0 of the ${f.empty} pixel(s) the cut removed from "${name}": at every one its alpha is below the alpha removed (an all-transparent image, or another file); an image that fills at least one is required — to leave the whole hole, drop under`,
4922
5027
  });
4923
5028
  }
4924
- return f;
5029
+ return { ...f, opaqueFilled };
4925
5030
  });
4926
5031
  mine.forEach((i, k) => {
4927
5032
  const c: CutCount = { from: name, into: cuts[i].into, before, kept, taken: taken[k], band: bands[k] };
4928
5033
  const f = filled[k];
4929
5034
  if (f !== undefined) {
4930
5035
  c.under = f.filled;
5036
+ c.underOpaque = f.opaqueFilled;
4931
5037
  c.underEmpty = f.empty;
4932
5038
  if (cuts[i].under_fill === 'colour') c.underFill = 'colour';
4933
5039
  }
@@ -4941,7 +5047,8 @@ export function assemble(input: AssembleInput): AssembleResult {
4941
5047
  const m = cutMasks[i], r = newRaster(W, H);
4942
5048
  for (let p = 0; p < W * H; p++) if (m.data[p] === 1) r.data.set(source.r.data.subarray(p * 4, p * 4 + 4), p * 4);
4943
5049
  if (opaqueOf(r) === 0) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
4944
- pieces[i] = { ...source, r, region: m, merged: mergedIn(source, m) };
5050
+ const writers = (source.writers as Int32Array).slice();
5051
+ pieces[i] = { ...source, r, writers, region: m, merged: mergedIn(source, m) };
4945
5052
  });
4946
5053
  refuseIfAny(empty);
4947
5054
  // In cut order, as the config lists them.
@@ -5064,7 +5171,9 @@ export function assemble(input: AssembleInput): AssembleResult {
5064
5171
  });
5065
5172
  const featheredInOrder: Array<Mask | null> = order.map((d) => ('patch' in d ? null : (pieceAt(d).b.feathered ?? null)));
5066
5173
  const keepInOrder = order.map((d) => ('limb' in d ? limbKeep[d.limb] : null));
5067
- const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, featheredInOrder, keepInOrder, input.turnMovingPair ?? null);
5174
+ const patchWriters = patches.map((_, i) => new Int32Array(W * H).fill(patchIds[i]));
5175
+ const writersInOrder = order.map(d => 'patch' in d ? patchWriters[d.patch] : pieceAt(d).b.writers as Int32Array);
5176
+ const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, featheredInOrder, keepInOrder, input.turnMovingPair ?? null, (i, p) => { writersInOrder[i][p] = AW.fringeRestore; });
5068
5177
  // Issue #255: a part's drift refusals in a body, and who holds the body in the draw order (the push-back clears
5069
5178
  // only fringe below CORE_ALPHA; these are the holders before the opt-in yield, and the seam override only recolours).
5070
5179
  const holderAt = new Int32Array(W * H).fill(-1);
@@ -5242,7 +5351,14 @@ export function assemble(input: AssembleInput): AssembleResult {
5242
5351
  for (const p of placed) if (p.record.depth !== undefined && artFiles.has(p.record.depth.image)) refuseIfAny([{ code: 'DEPTH_SHEET_UNAMBIGUOUS', object: p.record.depth.image, detail: 'collides with a part artwork filename; choose noncolliding part names before sheet emission' }]);
5243
5352
 
5244
5353
 
5245
- seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, keepInOrder, input.turnMovingPair ?? null);
5354
+ seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, keepInOrder, input.turnMovingPair ?? null, (i, p) => { writersInOrder[i][p] = AW.seamOverride; });
5355
+ placed.forEach((p, i) => { p.record.provenance = pixelProvenance(p.image, p.record.x, p.record.y, W, writersInOrder[i], editNames, 0, valueBaselines.get(p.record.from) ?? null); });
5356
+ // Provenance is now numbers/labels only. No later raster/figure operation
5357
+ // reads report buffers; release all aliases before recomposite/error work.
5358
+ valueBaselines.clear();
5359
+ writersInOrder.length = 0;
5360
+ patchWriters.length = 0;
5361
+ for (const group of [built, pieces, cutSources, limbPieces]) for (const b of group) if (b !== null) b.writers = undefined;
5246
5362
  const can = recomposite(placed, W, H);
5247
5363
  // Issue #242: on a scene painting the figure is read off the full run, and every recomposite figure inside it.
5248
5364
  const inside = input.scene === true ? sceneFigure([...rig.entries()].filter(([k]) => k.startsWith('full:')).map(([, r]) => r), W, H) : null;
@@ -5272,6 +5388,9 @@ export function assemble(input: AssembleInput): AssembleResult {
5272
5388
  scale_rig_per_source: frame.S,
5273
5389
  ...(input.motion_seams === undefined ? {} : { motion_seams: input.motion_seams.map(({ part, neighbour }) => ({ part, neighbour })) }),
5274
5390
  parts: placed.map((p) => p.record),
5391
+ ...(cutCounts.length === 0 ? {} : { cut_ownership: cutCounts.map((c, i) => ({ edit: `cuts[${i}]`, from: c.from, into: c.into, moved_in_opaque_px: c.taken,
5392
+ retained_band_opaque_px: c.band, cleared_opaque_px: c.taken - c.band, under_filled_px: c.under ?? 0,
5393
+ under_filled_opaque_px: c.underOpaque ?? 0, net_cleared_opaque_px: c.taken - c.band - (c.underOpaque ?? 0) })) }),
5275
5394
  ghost_px: ghost,
5276
5395
  recomposite: recompositeRecord(figures),
5277
5396
  ...(sceneRecord === null ? {} : { scene: sceneRecord }),
package/src/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ownershipLines, provenanceLines } from './provenance.ts';
1
2
  import { readSourceDepth } from './depthsheets.ts';
2
3
  import { orderedBaseAnchor } from './baseanchor.ts';
3
4
  import { alternateLayerSources, alternateLayerIslands, alternateLayerOthers } from './alternatelayers.ts';
@@ -705,6 +706,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
705
706
  ...(altFront.size === 0 ? {} : { front: [...altFront] }),
706
707
  slots,
707
708
  records: keptRecords,
709
+ ...(altRes.parts.cut_ownership === undefined ? {} : { cut_ownership: altRes.parts.cut_ownership }),
708
710
  ghost_px: altRes.parts.ghost_px,
709
711
  recomposite: altRecomposite,
710
712
  ...(altRes.parts.scene === undefined ? {} : { scene: altRes.parts.scene }),
@@ -782,6 +784,9 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
782
784
  `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}${underfillSuffix}`,
783
785
  );
784
786
  }
787
+ for (const line of provenanceLines(result.parts.parts)) log(line);
788
+ for (const line of ownershipLines(result.parts.cut_ownership ?? [])) log(line);
789
+ for (const alt of altBuilt) for (const line of [...provenanceLines(alt.record.records), ...ownershipLines(alt.record.cut_ownership ?? [])]) log(` set "${alt.record.name}" ${line.trimStart()}`);
785
790
  // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
786
791
  for (const l of authorPartLines(result.parts.parts)) log(l);
787
792
  // One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
package/src/parts.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { AUTOMATIC_WRITERS, provenanceAddsUp, type PixelProvenance, type CutOwnership } from './provenance.ts';
1
2
  import { baseAnchorProblems, orderedBaseAnchor, type BaseAnchor } from './baseanchor.ts';
2
3
  import { partDepthProblems, type PartDepthReference } from './depthsheets.ts';
3
4
  /**
@@ -96,6 +97,8 @@ import { edgeLength } from './length.ts';
96
97
  import { recordedFocusProblems } from './setfocus.ts';
97
98
 
98
99
  export interface PartRecord {
100
+ /** Report-only final last-writer census (#817); absent on older records. */
101
+ provenance?: PixelProvenance;
99
102
  /** Optional #773 input reference; production geometry does not consume it yet. */
100
103
  depth?: PartDepthReference;
101
104
  name: string;
@@ -326,6 +329,7 @@ export interface AlternateSetMap {
326
329
  export const FRONT_SUFFIX = '_front';
327
330
 
328
331
  export interface AlternatePartsRecord {
332
+ cut_ownership?: CutOwnership[];
329
333
  name: string;
330
334
  base_anchor?: BaseAnchor;
331
335
  /** Original authored painting point and its source size, only when supplied. */
@@ -358,6 +362,8 @@ export interface AlternatePartsRecord {
358
362
  }
359
363
 
360
364
  export interface PartsFile {
365
+ /** Structural moves, independent of per-pixel value equality. */
366
+ cut_ownership?: CutOwnership[];
361
367
  rig_size: [number, number];
362
368
  scale_rig_per_source: number;
363
369
  parts: PartRecord[];
@@ -406,8 +412,8 @@ export interface SceneRecord {
406
412
  figure_mask: string;
407
413
  }
408
414
 
409
- const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates', 'motion_seams'] as const;
410
- const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates' && k !== 'motion_seams');
415
+ const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates', 'motion_seams', 'cut_ownership'] as const;
416
+ const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates' && k !== 'motion_seams' && k !== 'cut_ownership');
411
417
  export const SINGLE_RUN_PAD_KEYS = ['file_size', 'side', 'left', 'top'] as const;
412
418
  export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
413
419
  export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
@@ -423,6 +429,7 @@ export const PART_KEYS = [
423
429
  'w',
424
430
  'h',
425
431
  'opaque_px',
432
+ 'provenance',
426
433
  'visible_px',
427
434
  'occluded_px',
428
435
  'projected_core_px',
@@ -441,10 +448,10 @@ const FRINGE_KEY = 'fringe_pushed_back';
441
448
  const CUT_FROM_KEY = 'cut_from';
442
449
  /** The one part field that is a record of its own: a limb segment's provenance (issue #349), written only on one. */
443
450
  const LIMB_KEY = 'limb';
444
- const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth') as ReadonlyArray<(typeof PART_KEYS)[number]>;
451
+ 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]>;
445
452
  /** The port's own counts: all three or none (none = a record the reference wrote). */
446
453
  export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
447
- 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');
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');
448
455
 
449
456
  /** 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. */
450
457
  export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
@@ -467,9 +474,34 @@ export function tagOf(from: string): string | null {
467
474
  return f === null || f.run === 'painting' ? null : f.tag;
468
475
  }
469
476
 
477
+ const OWNERSHIP_KEYS = ['edit', 'from', 'into', 'moved_in_opaque_px', 'retained_band_opaque_px', 'cleared_opaque_px', 'under_filled_px', 'under_filled_opaque_px', 'net_cleared_opaque_px'] as const;
478
+ function orderedOwnership(rows: readonly CutOwnership[]): Array<Record<string, unknown>> {
479
+ return rows.map(r => Object.fromEntries(OWNERSHIP_KEYS.map(k => [k, r[k]])));
480
+ }
481
+ function checkOwnership(raw: unknown, object: string, fail: (code: string, object: string, detail: string) => void): void {
482
+ if (!Array.isArray(raw)) { fail('PARTS_FIELD_TYPE', object, `is ${show(raw)}; an array of indexed cut ownership records is required`); return; }
483
+ const seen = new Set<string>();
484
+ raw.forEach((row, i) => {
485
+ const r = row as CutOwnership | null;
486
+ const shape = r !== null && typeof r === 'object' && !Array.isArray(r) && Object.keys(r).sort().join(',') === [...OWNERSHIP_KEYS].sort().join(',') &&
487
+ typeof r.edit === 'string' && /^cuts\[\d+\]$/.test(r.edit) && !seen.has(r.edit) && typeof r.from === 'string' && r.from !== '' && typeof r.into === 'string' && r.into !== '' &&
488
+ OWNERSHIP_KEYS.slice(3).every(k => Number.isSafeInteger(r[k]) && (k === 'net_cleared_opaque_px' || (r[k] as number) >= 0));
489
+ if (!shape) { fail('PARTS_FIELD_TYPE', `${object}[${i}]`, `is ${show(row)}; a distinct cuts[i] with from/into names, nonnegative move/clear/fill counts and signed net_cleared_opaque_px is required`); return; }
490
+ const q = r as CutOwnership; seen.add(q.edit);
491
+ if (q.moved_in_opaque_px - q.retained_band_opaque_px !== q.cleared_opaque_px || q.cleared_opaque_px - q.under_filled_opaque_px !== q.net_cleared_opaque_px || q.under_filled_opaque_px > q.under_filled_px) {
492
+ fail('PARTS_COUNTS_ADD_UP', `${object}[${i}]`, `moved ${q.moved_in_opaque_px} - retained ${q.retained_band_opaque_px} = ${q.moved_in_opaque_px - q.retained_band_opaque_px} (gross cleared ${q.cleared_opaque_px} required); cleared - opaque fill ${q.under_filled_opaque_px} = ${q.cleared_opaque_px - q.under_filled_opaque_px} (net ${q.net_cleared_opaque_px} required); opaque fill must be at most total fill ${q.under_filled_px}`);
493
+ }
494
+ });
495
+ }
496
+
497
+ function orderedProvenance(p: PixelProvenance): PixelProvenance {
498
+ return { unchanged_layer_px: p.unchanged_layer_px, hand_px: p.hand_px, automatic_px: p.automatic_px,
499
+ edits: p.edits.map(e => ({ edit: e.edit, px: e.px })), automatic: p.automatic.map(e => ({ stage: e.stage, px: e.px })) };
500
+ }
501
+
470
502
  function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
471
503
  return parts.map((p) =>
472
- Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
504
+ Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : k === 'provenance' ? orderedProvenance(p.provenance as PixelProvenance) : p[k]])),
473
505
  );
474
506
  }
475
507
 
@@ -485,6 +517,7 @@ export function serializeParts(file: PartsFile): string {
485
517
  rig_size: file.rig_size,
486
518
  scale_rig_per_source: file.scale_rig_per_source,
487
519
  parts: serializePartList(file.parts),
520
+ ...(file.cut_ownership === undefined ? {} : { cut_ownership: orderedOwnership(file.cut_ownership) }),
488
521
  ...(file.motion_seams === undefined ? {} : { motion_seams: file.motion_seams.map(({ part, neighbour }) => ({ part, neighbour })) }),
489
522
  ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
490
523
  ...(file.recomposite === undefined ? {} : { recomposite: serializeRecomposite(file.recomposite) }),
@@ -501,6 +534,7 @@ export function serializeParts(file: PartsFile): string {
501
534
  ...(a.front === undefined ? {} : { front: a.front }),
502
535
  slots: a.slots,
503
536
  records: serializePartList(a.records),
537
+ ...(a.cut_ownership === undefined ? {} : { cut_ownership: orderedOwnership(a.cut_ownership) }),
504
538
  ghost_px: Object.fromEntries(Object.keys(a.ghost_px).sort().map((k) => [k, a.ghost_px[k]])),
505
539
  recomposite: serializeRecomposite(a.recomposite),
506
540
  ...(a.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (a.scene as SceneRecord)[k]])) }),
@@ -541,6 +575,30 @@ function show(v: unknown): string {
541
575
  return v === undefined ? 'absent' : JSON.stringify(v);
542
576
  }
543
577
 
578
+ /** The same optional measurement contract for base and alternate records. */
579
+ function checkProvenance(raw: unknown, opaque: unknown, object: string, fail: (code: string, object: string, detail: string) => void): void {
580
+ const v = raw as PixelProvenance | null;
581
+ const count = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
582
+ const edit = (name: string): boolean => /^(cuts\[\d+\]|patches\[\d+\])$/.test(name) ||
583
+ (/^(full|head):author:/.test(name) && readFrom(name) !== null);
584
+ const shape = v !== null && typeof v === 'object' && !Array.isArray(v) &&
585
+ Object.keys(v).sort().join(',') === 'automatic,automatic_px,edits,hand_px,unchanged_layer_px' &&
586
+ [v.unchanged_layer_px, v.hand_px, v.automatic_px].every(count) && Array.isArray(v.edits) && Array.isArray(v.automatic) &&
587
+ v.edits.every(e => e !== null && typeof e === 'object' && Object.keys(e).sort().join(',') === 'edit,px' && typeof e.edit === 'string' && edit(e.edit) && count(e.px) && e.px > 0) &&
588
+ v.automatic.every(e => e !== null && typeof e === 'object' && Object.keys(e).sort().join(',') === 'px,stage' && typeof e.stage === 'string' && Object.hasOwn(AUTOMATIC_WRITERS, e.stage) && count(e.px) && e.px > 0) &&
589
+ new Set(v.edits.map(e => e.edit)).size === v.edits.length && new Set(v.automatic.map(e => e.stage)).size === v.automatic.length;
590
+ if (!shape) {
591
+ fail('PARTS_FIELD_TYPE', object, `is ${show(raw)}; unchanged_layer_px, hand_px and automatic_px as nonnegative safe integers, edits as distinct cuts[i]/patches[i]/<run>:author:<label> entries, and automatic as distinct known stage entries, each with a positive integer px, are required`);
592
+ } else if (!count(opaque)) {
593
+ fail('PARTS_FIELD_TYPE', object, `opaque_px is ${show(opaque)}; a nonnegative safe integer opaque_px is required beside provenance`);
594
+ } else if (!provenanceAddsUp(v as PixelProvenance, opaque as number)) {
595
+ const p = v as PixelProvenance;
596
+ const total = p.unchanged_layer_px + p.hand_px + p.automatic_px;
597
+ const hand = p.edits.reduce((n, e) => n + e.px, 0), automatic = p.automatic.reduce((n, e) => n + e.px, 0);
598
+ fail('PARTS_COUNTS_ADD_UP', object, `unchanged_layer_px ${p.unchanged_layer_px} + hand_px ${p.hand_px} + automatic_px ${p.automatic_px} = ${total}; opaque_px ${opaque} is required; edit subtotal ${hand} (hand_px ${p.hand_px} required), stage subtotal ${automatic} (automatic_px ${p.automatic_px} required)`);
599
+ }
600
+ }
601
+
544
602
  function checkParts(raw: unknown, path: string): PartsFile {
545
603
  const problems: Problem[] = [];
546
604
  const fail = (code: string, object: string, detail: string): void => {
@@ -601,6 +659,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
601
659
  if (k in r && !(Number.isInteger(r[k]) && (r[k] as number) >= 0)) fail('PARTS_FIELD_TYPE', `${label} field "${k}"`, `is ${show(r[k])}; a non-negative integer is required`);
602
660
  }
603
661
  const count = (k: string): number | null => (Number.isInteger(r[k]) && (r[k] as number) >= 0 ? (r[k] as number) : null);
662
+ if ('provenance' in r) checkProvenance(r.provenance, r.opaque_px, label + ' provenance', fail);
604
663
  const [op, vis, occ, vnp] = ['opaque_px', 'visible_px', 'occluded_px', 'visible_not_projected_px'].map(count);
605
664
  if (held.length === VISIBILITY_KEYS.length && op !== null && vis !== null && occ !== null && vnp !== null) {
606
665
  if (vis + occ !== op) fail('PARTS_COUNTS_ADD_UP', `${label}`, `visible_px ${vis} + occluded_px ${occ} = ${vis + occ}; opaque_px ${op} is required`);
@@ -692,6 +751,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
692
751
  if (!('recomposite' in o)) fail('PARTS_FIELD_PRESENT', `${path} field "recomposite"`, 'is absent; a scene block says how the recomposite block beside it was measured, so it is required with one');
693
752
  }
694
753
  }
754
+ if ('cut_ownership' in o) checkOwnership(o.cut_ownership, `${path} cut_ownership`, fail);
695
755
  if ('single_run_pad' in o) checkSingleRunPad(o.single_run_pad, path, sizeOk ? (size as [number, number]) : null, typeof scale === 'number' ? scale : null, fail);
696
756
  if ('recomposite' in o && sizeOk) {
697
757
  const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
@@ -706,6 +766,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
706
766
  if (typeof a !== 'object' || a === null || Array.isArray(a)) return;
707
767
  const set = a as Record<string, unknown>;
708
768
  const at = `${path} alternates[${i}]${typeof set.name === 'string' ? ` (set "${set.name}")` : ''}`;
769
+ if ('cut_ownership' in set) checkOwnership(set.cut_ownership, `${at} cut_ownership`, fail);
709
770
  if ('base_anchor' in set) {
710
771
  const names = new Set(Array.isArray(set.records) ? set.records.flatMap(v => typeof v === 'object' && v !== null && typeof v.name === 'string' ? [v.name] : []) : []);
711
772
  const patches = new Set(Array.isArray(set.records) ? set.records.flatMap(v => typeof v === 'object' && v !== null && typeof v.name === 'string' && typeof v.from === 'string' && v.from.startsWith('painting:') ? [v.name] : []) : []);
@@ -749,6 +810,8 @@ function checkParts(raw: unknown, path: string): PartsFile {
749
810
  set.records.forEach((p, j) => {
750
811
  if (typeof p !== 'object' || p === null || Array.isArray(p)) return;
751
812
  const r = p as Record<string, unknown>;
813
+ const part817 = typeof r.name === 'string' ? `part "${r.name}"` : `records[${j}]`;
814
+ if ('provenance' in r) checkProvenance(r.provenance, r.opaque_px, `${at} ${part817} provenance`, fail);
752
815
  const l = r[LIMB_KEY];
753
816
  if (typeof l !== 'object' || l === null || Array.isArray(l) || !('cap' in l)) return;
754
817
  const part = typeof r.name === 'string' ? `part "${r.name}"` : `records[${j}]`;
@@ -0,0 +1,106 @@
1
+ /** Report-only last-writer accounting over final output pixels (#817). */
2
+ import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
3
+ import type { PartRecord } from './parts.ts';
4
+ import { crop, type Raster } from './raster/index.ts';
5
+
6
+ /** Negative IDs distinguish automatic writes from positive, config-indexed hand edits. */
7
+ export const AUTOMATIC_WRITERS = {
8
+ sourceProjection: -1, extend: -2, growRim: -3, limbSplit: -4,
9
+ limbCap: -5, limbHybrid: -6, limbFeather: -7, fringeRestore: -8,
10
+ seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13,
11
+ } as const;
12
+ export type AutomaticWriter = keyof typeof AUTOMATIC_WRITERS;
13
+ export interface PixelProvenance {
14
+ unchanged_layer_px: number;
15
+ hand_px: number;
16
+ automatic_px: number;
17
+ edits: Array<{ edit: string; px: number }>;
18
+ automatic: Array<{ stage: AutomaticWriter; px: number }>;
19
+ }
20
+
21
+ /** RGBA equality on the same rig coordinate, including alpha. */
22
+ export function rgbaEqual(a: Uint8ClampedArray, ai: number, b: Uint8ClampedArray, bi: number): boolean {
23
+ return a[ai] === b[bi] && a[ai + 1] === b[bi + 1] && a[ai + 2] === b[bi + 2] && a[ai + 3] === b[bi + 3];
24
+ }
25
+
26
+ /** An immutable crop of normalized raw samples that can equal an opaque output.
27
+ * Outside this box raw alpha is <= 8, so it cannot equal final alpha > 8.
28
+ * The crop copies RGBA; it must never alias a raster later projected in place. */
29
+ export interface ValueBaseline { readonly image: Raster; readonly x: number; readonly y: number }
30
+ export function compactValueBaseline(raw: Raster): ValueBaseline {
31
+ let x0 = raw.width, y0 = raw.height, x1 = -1, y1 = -1;
32
+ for (let y = 0; y < raw.height; y++) for (let x = 0; x < raw.width; x++) {
33
+ if (raw.data[(y * raw.width + x) * 4 + 3] <= OPAQUE_ALPHA_ABOVE) continue;
34
+ x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
35
+ }
36
+ return x1 < 0 ? { image: crop(raw, 0, 0, 0, 0), x: 0, y: 0 }
37
+ : { image: crop(raw, x0, y0, x1 - x0 + 1, y1 - y0 + 1), x: x0, y: y0 };
38
+ }
39
+
40
+ /** Value equality with the raw normalized See-through baseline wins first.
41
+ * Otherwise the last stage that CHANGED RGBA wins; buffer copies inherit IDs.
42
+ * A null baseline is explicit for authored layers/painting patches. */
43
+ export function pixelProvenance(image: Raster, x: number, y: number, width: number, writers: Int32Array | null, edits: readonly string[], patchId = 0, baseline: Raster | ValueBaseline | null = null): PixelProvenance {
44
+ const out: PixelProvenance = { unchanged_layer_px: 0, hand_px: 0, automatic_px: 0, edits: [], automatic: [] };
45
+ const counts = new Map<number, number>();
46
+ const reference = baseline === null ? null : 'image' in baseline ? baseline.image : baseline;
47
+ const bx = baseline !== null && 'image' in baseline ? baseline.x : 0;
48
+ const by = baseline !== null && 'image' in baseline ? baseline.y : 0;
49
+ for (let j = 0; j < image.height; j++) for (let i = 0; i < image.width; i++) {
50
+ if (image.data[(j * image.width + i) * 4 + 3] <= OPAQUE_ALPHA_ABOVE) continue;
51
+ const q = (y + j) * width + x + i;
52
+ const u = x + i - bx, v = y + j - by;
53
+ const same = reference !== null && u >= 0 && v >= 0 && u < reference.width && v < reference.height &&
54
+ rgbaEqual(image.data, (j * image.width + i) * 4, reference.data, (v * reference.width + u) * 4);
55
+ const previous = writers?.[q] ?? patchId;
56
+ // Unknown lineage is reported explicitly, never invented as unchanged.
57
+ const id = same ? 0 : previous === 0 ? AUTOMATIC_WRITERS.unattributed : previous;
58
+ if (id > 0) out.hand_px++;
59
+ else if (id < 0) out.automatic_px++;
60
+ else out.unchanged_layer_px++;
61
+ if (id !== 0) counts.set(id, (counts.get(id) ?? 0) + 1);
62
+ }
63
+ out.edits = edits.flatMap((edit, i) => counts.has(i + 1) ? [{ edit, px: counts.get(i + 1) as number }] : []);
64
+ out.automatic = (Object.keys(AUTOMATIC_WRITERS) as AutomaticWriter[]).flatMap(stage => counts.has(AUTOMATIC_WRITERS[stage]) ? [{ stage, px: counts.get(AUTOMATIC_WRITERS[stage]) as number }] : []);
65
+ return out;
66
+ }
67
+
68
+ /** The reader and selftests share this arithmetic check; no hand-edit gate. */
69
+ export function provenanceAddsUp(p: PixelProvenance, opaque: number): boolean {
70
+ return p.unchanged_layer_px + p.hand_px + p.automatic_px === opaque &&
71
+ p.edits.reduce((n, e) => n + e.px, 0) === p.hand_px && p.automatic.reduce((n, e) => n + e.px, 0) === p.automatic_px;
72
+ }
73
+
74
+ export function provenanceLines(parts: readonly PartRecord[]): string[] {
75
+ const lines: string[] = [];
76
+ let opaque = 0, unchanged = 0, automatic = 0, hand = 0;
77
+ const edits = new Map<string, number>(), stages = new Map<string, number>();
78
+ const detail = (e: ReadonlyMap<string, number>): string => [...e].map(([name, px]) => `${name} ${px}`).join(', ') || 'none';
79
+ for (const p of parts) {
80
+ const v = p.provenance;
81
+ if (v === undefined) continue;
82
+ opaque += p.opaque_px; unchanged += v.unchanged_layer_px; automatic += v.automatic_px; hand += v.hand_px;
83
+ for (const e of v.edits) edits.set(e.edit, (edits.get(e.edit) ?? 0) + e.px);
84
+ for (const e of v.automatic) stages.set(e.stage, (stages.get(e.stage) ?? 0) + e.px);
85
+ lines.push(` provenance: part "${p.name}" (${p.from}) opaque ${p.opaque_px} = unchanged layer ${v.unchanged_layer_px} + hand ${v.hand_px} + automatic ${v.automatic_px}; edits ${detail(new Map(v.edits.map(e => [e.edit, e.px])))}; stages ${detail(new Map(v.automatic.map(e => [e.stage, e.px])))}`);
86
+ }
87
+ lines.push(` provenance total: opaque ${opaque} = unchanged layer ${unchanged} + hand ${hand} + automatic ${automatic}; edits ${detail(edits)}; stages ${detail(stages)}; output pixels, including copies in separate parts; value equality first; last value-changing stage wins`);
88
+ return lines;
89
+ }
90
+
91
+ /** Structural cut census, independent of the value partition. Counts are from
92
+ * the original parent partition before limb/fringe/seam processing. */
93
+ export interface CutOwnership {
94
+ edit: string;
95
+ from: string;
96
+ into: string;
97
+ moved_in_opaque_px: number;
98
+ retained_band_opaque_px: number;
99
+ cleared_opaque_px: number;
100
+ under_filled_px: number;
101
+ under_filled_opaque_px: number;
102
+ net_cleared_opaque_px: number;
103
+ }
104
+ export function ownershipLines(rows: readonly CutOwnership[]): string[] {
105
+ return rows.map(r => ` ownership: ${r.edit} "${r.from}" -> "${r.into}" moved in ${r.moved_in_opaque_px} opaque px; retained band ${r.retained_band_opaque_px}; gross cleared ${r.cleared_opaque_px} before refill; under filled ${r.under_filled_px} px (${r.under_filled_opaque_px} opaque); net cleared ${r.net_cleared_opaque_px}; independent of RGBA equality`);
106
+ }