rig-parts 2.6.0 → 2.8.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/cli.ts CHANGED
@@ -1759,6 +1759,12 @@ function cmdAssemble(args: string[]): number {
1759
1759
  const target = writePath === undefined ? null : readConfigWriteTarget(writePath);
1760
1760
  const src = readSource(source);
1761
1761
  const cfg = loadEarlyConfig(config, 'layers');
1762
+ if (cfg.assemble.take?.some(item => 'part' in item)) {
1763
+ throw new PartsError([{
1764
+ code: 'ASSEMBLE_REGION_TAKE_PLAN', object: 'config.assemble.take',
1765
+ detail: 'region takes require a declared plan and setup_painting requirements at assemble/build; draft the plan before declaring a region take',
1766
+ }]);
1767
+ }
1762
1768
  const runs = applyTakenLayers(readRuns(full, head), src, { ...cfg, assemble: { ...cfg.assemble, plan: undefined } }, config);
1763
1769
  const g = proposeFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
1764
1770
  const geometry = { sourceW: src.width, sourceH: src.height, ...g };
package/docs/AUTHORING.md CHANGED
@@ -149,6 +149,7 @@ read by no CPU stage).
149
149
  | `seethrough.arm_split` | assemble, `assemble --propose-plan` — separates a single full-run `handwear-r` or `handwear-l` layer that holds both overlapping arms into both arm tags | **authored**, optional (issue #439): `{x?: number, line?: [[x, y], …], order: ["handwear-r", "handwear-l"] \| ["handwear-l", "handwear-r"]}` in `seethrough.resolution` full-run pixels. Exactly one of `x` (image column, `0 <= x <= resolution`) or `line` (a polyline of two or more `[x, y]` points in `[0, resolution]`, interpolated in x along y and clamped above/below its endpoints) is required (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`), and `order` is `[back_tag, front_tag]`, the draw order of the two separated arm layers (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`). Pixels at `< splitX(y)` go to `handwear-r` (image left, the subject's right arm) and pixels at `>= splitX(y)` go to `handwear-l` (image right, the subject's left arm). A split line that cuts a horizontal opaque run of the arm layer into more than two segments is refused (`ASSEMBLE_ARM_SPLIT_CUTS_ARM`). Without the declaration, when one arm layer's 8-connected component reaches both wrists under `propose --keypoints`, `propose` emits an `overlapping-arms` `LINT` naming the part, both wrists and `seethrough.arm_split`; absent, no byte of any output moves |
150
150
  | `assemble.seam` | assemble, build | **authored**, optional `"near-white"` or `"silhouette"` (issue #905). An explicit `--seam` overrides the config; otherwise this field overrides the existing `near-white` default. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.seam`, including when a CLI override is given. Absent, existing build bytes are unchanged. |
151
151
  | `assemble.project` | assemble, build | **authored**, optional `"core"` or `"visible"` (issue #953): the projection rule for this character. An explicit `--project` overrides the config; otherwise this field overrides the existing `core` default. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.project`, including when a CLI override is given. Absent, existing build bytes are unchanged. |
152
+ | `assemble.ground` | assemble, build | **authored**, optional `"plain"` or `"busy"` (issue #996): what lies behind the figure in the painting. `plain` (the default) is a ground of one colour; `busy` is an opaque ground of many colours (a wall, a banner). Read by `--project visible` only: a busy, opaque ground needs `assemble.ground: busy`; without it, visible takes backdrop pixels. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.ground`. Absent or `plain`, existing build bytes are unchanged. `assemble` warns (`ASSEMBLE_GROUND_PLAIN`, §5), under project: visible, where paintingEdges reads the ground, when a plain ground's border ring is not one colour. |
152
153
  | `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` |
153
154
  | `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` |
154
155
  | `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 |
@@ -655,7 +656,7 @@ some layers, can supply those tags without editing either run directory:
655
656
  }]
656
657
  ```
657
658
 
658
- This array belongs in `assemble`. Every entry requires all four fields. `run`
659
+ This array belongs in `assemble`. Every whole-layer entry requires all four fields. `run`
659
660
  is the **target** `full` or `head` run; `head` requires `--head`. `run_dir` and
660
661
  `st_input` resolve relative to the config, for assemble, build and
661
662
  `assemble --propose-plan` alike. Each target run/tag may be selected once. Empty
@@ -734,6 +735,43 @@ identity an executable check on supplied original inputs instead of trusting
734
735
  that claim or hand-editing `layers.json`. Seed and painting selection remain
735
736
  the author's decisions.
736
737
 
738
+ ### Taking a derived region (`assemble.take`, issue #1000)
739
+
740
+ A region entry names one existing plan part, its target run, one donor run and
741
+ one seed pixel on the target run canvas (integer x right, y down).
742
+
743
+ ```json
744
+ "take": [{
745
+ "run": "full",
746
+ "run_dir": "runs/other",
747
+ "st_input": "inputs/other_full.png",
748
+ "part": "body",
749
+ "seed": [120, 240]
750
+ }]
751
+ ```
752
+
753
+ The region is derived from base alpha < 128, donor alpha >= 128, and the
754
+ maximum RGB channel difference after rounding each pixel source-over white.
755
+ The tolerance is exactly `--requirements`' `setup_painting.rgb_tolerance`.
756
+ A missing declaration is refused. The target input is derived as for whole-layer
757
+ take above. Both run canvases must agree, and the supplied original donor input
758
+ must match the entire target input in RGBA and dimensions, establishing the same
759
+ crop. Different crops await #973. Painting samples use the existing Lanczos3
760
+ resize onto the run canvas. Only the 8-connected qualifying component containing
761
+ the seed is copied. The base layer's depth and draw order stay in use.
762
+
763
+ No polygon, rectangle or brush input is accepted. A drawn bound is scissors.
764
+ The log reports the derived pixel count, run-grid `[left, top, width, height]`
765
+ bounding box, painting-match share and tolerance. The share is 1 because every
766
+ selected pixel passed the painting predicate. An empty region, seed outside its
767
+ components or missing donor tag is refused before output. A target may have one
768
+ region take or one whole-layer take. Hidden companions and active scalar depth
769
+ sheets require separate bindings and are refused. Region donor runs use the same
770
+ `editor_receipts` ledger as the base runs. Under `required` or `trace_parts`,
771
+ every donor author/hidden image requires a valid receipt before pixels are
772
+ copied. A receipted author-layer target is accepted. The plan proposal command
773
+ refuses region entries because their part names depend on an existing plan.
774
+
737
775
  ### Editor receipts (`assemble.editor_receipts`, issue #946)
738
776
 
739
777
  An image edited in code — a layer re-segmented by a script, a patch
@@ -3990,6 +4028,7 @@ and a usage error with `--head-box`, `--cloth` or `--from-config`.
3990
4028
  | `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*) |
3991
4029
  | `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 |
3992
4030
  | `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. After the part lines, `provenance:` per part and `provenance total:` report the value partition, followed by `ownership:` per cut for structural moves, independent of RGBA equality; alternate sets get their own `set "<set>"`-prefixed lines ([the two axes](#source-before-scissors)). The author-made/cut/limb/fringe lines described below precede 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 the `ASSEMBLE_PINHOLES: PASS — 0 pinholes …` or `ASSEMBLE_PINHOLES: SKIP — not measured, not a pass: …` line ([Pinholes at rest](#pinholes-at-rest-issue-944)), 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 informational `drift: "<part>" source re-take changed N own-layer px` lines under soft front cover, naming `assemble.keep_under_occluders` and its kept count when enabled ([source ladder](#source-before-scissors)), 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) |
4031
+ | `assemble` (the ground) | a `WARN ASSEMBLE_GROUND_PLAIN` line | no such line: the ground is plain and its border ring one colour (the five public examples read 0.0006 or less), or it is declared `busy` | `N % of the border ring is not one colour` — more than 2 % of the ring (`SILHOUETTE_BORDER` px wide) differs from its median by more than `SILHOUETTE_LIMIT`, under a ground absent or declared `plain`, under project: visible, where paintingEdges reads the ground. Two causes: the ground is not plain (declare `assemble.ground: busy`), or the figure crosses the border (leave the ground as it is). A warning, never a refusal; the ground stays as declared |
3993
4032
  | `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 |
3994
4033
  | `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 |
3995
4034
  | `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` |
@@ -4222,6 +4261,25 @@ and 13,872 to 1,110 (`wind`), and the holes from 1,020 to 62, 453 to 40, 259 to
4222
4261
  291 to 34 and 1,118 to 62; `check` passes the same bars as before on all five. A
4223
4262
  painting patch is read placed on the rig canvas, and is never written. `core` is unchanged.
4224
4263
 
4264
+ **A busy ground (`assemble.ground`, issue #996).** The step above reads the painting
4265
+ as a figure on a ground of one colour: a painted pixel no part covers is the figure's
4266
+ when it differs from the border ring's colour, and the colour behind a part's edge is
4267
+ one colour across the window. On an opaque ground of many colours — a wall, a banner —
4268
+ both readings take the ground: the ground beside a thin part (a strand of hair, a
4269
+ finger) becomes the part's, opaque, in the ground's own colour. A busy, opaque ground
4270
+ needs `assemble.ground: busy`; without it, visible takes backdrop pixels. Under `busy`
4271
+ a part's alpha rises only where See-through's own layer reaches (its alpha above 0
4272
+ before the step), a painted pixel no part covers is a gap only where two parts' layers
4273
+ meet, and a pixel on the ground's side of the edge is read only where it is a mix of
4274
+ the two colours (within 20 of the line between them). Measured on a busy ground planted
4275
+ under two public examples (`fixtures/busy_ground.ts`): part pixels outside the `core`
4276
+ build's footprint went from 8,065 to 0 (`wind`) and 6,119 to 0 (`demo`); 50 and 31
4277
+ pixels inside See-through's soft rim still show the ground's colour where `core` showed
4278
+ none. The price of the bound is the painted outline beyond See-through's reach, which a
4279
+ plain ground keeps, so `busy` is a declaration, not a default: on the five public
4280
+ examples it would raise the recomposite's error pixels. The painting cannot tell the two
4281
+ grounds apart near the figure, so the ground is the author's to declare.
4282
+
4225
4283
  **`rig --idle-keys ctl|direct`** (and `build --idle-keys`, which forwards the
4226
4284
  value to its rig stage; without the flag, or with `ctl`, a build writes and prints
4227
4285
  what it did before `build` took the flag). rig-c's `A15_IDLE_NO_MESH_BONE_KEYS`
@@ -4779,6 +4837,7 @@ row in this page's `rule` and `line` tables; the selftest holds that (`TY16`).
4779
4837
  | <a id="LAYERS_EXPORT_DIR_EMPTY"></a>`LAYERS_EXPORT_DIR_EMPTY` | `layers --export <dir>` was given a file, or a directory that is not empty (issue #359): a file left there could be read in place of the one written | a new directory, or empty it |
4780
4838
  | <a id="LAYERS_EXPORT_ROUND_TRIP"></a>`LAYERS_EXPORT_ROUND_TRIP` | the export, read back, differs from the input's reading (the detail names the first difference) — an exporter bug, not an input problem | report it with the input named |
4781
4839
  | <a id="ASSEMBLE_SOURCE_PRESENT"></a><a id="ASSEMBLE_SOURCE_DECODES"></a>`ASSEMBLE_SOURCE_PRESENT`, `ASSEMBLE_SOURCE_DECODES` | the painting is missing or not a PNG | `--source` |
4840
+ | <a id="ASSEMBLE_GROUND_PLAIN"></a>`ASSEMBLE_GROUND_PLAIN` | WARN only, never a refusal (issue #996): under project: visible, where paintingEdges reads the ground, with `assemble.ground` absent or `plain`, more than 2 % of the painting's border ring is not one colour (the ring is `SILHOUETTE_BORDER` px wide; a pixel counts when it differs from the ring's median by more than `SILHOUETTE_LIMIT`). Two causes: the ground is not plain, or the figure crosses the border | if it is the ground, declare `assemble.ground: busy`; if the figure crosses the border, leave the ground as it is (the WARN never changes output) |
4782
4841
  | <a id="SHEET_SOURCE_PRESENT"></a><a id="SHEET_PNG_PRESENT"></a>`SHEET_SOURCE_PRESENT`, `SHEET_PNG_PRESENT` | `sheet`'s painting, or a part PNG a `parts.json` lists, is missing | `--source`, `--layers` |
4783
4842
 
4784
4843
  ### Plausibility (`WARN` in `layers`, a note in `assemble --propose-plan`)
@@ -4960,6 +5019,13 @@ those settings. Changing a config seed alone cannot change a no-config run.
4960
5019
  | <a id="ASSEMBLE_TAKE_SUPPORT"></a>`ASSEMBLE_TAKE_SUPPORT` | selected layer has zero alpha > 8 pixels | choose a nonempty donor layer |
4961
5020
  | <a id="ASSEMBLE_TAKE_DEPTH_AMBIGUOUS"></a>`ASSEMBLE_TAKE_DEPTH_AMBIGUOUS` | different original runs have equal or null depths | use a run with distinct numeric depths; do not invent an inter-run tie order |
4962
5021
  | <a id="ASSEMBLE_TAKE_PLAN_ORDER"></a>`ASSEMBLE_TAKE_PLAN_ORDER` | supplied plan reverses an overlapping mixed-run pair: two-run full-layer head roles first, scalar depth within a role band (head-run and single-run rows use scalar depth); names the parts and support intersection | use the order drafted by `assemble --propose-plan`; fallback headwear follows the body and precedes the face, front hair follows the face; disjoint supports impose no plan-order constraint |
5022
+ | <a id="ASSEMBLE_REGION_TAKE_TOLERANCE"></a>`ASSEMBLE_REGION_TAKE_TOLERANCE` | a region take has no authored `setup_painting.rgb_tolerance` | supply the same requirements file to assemble/build and check |
5023
+ | <a id="ASSEMBLE_REGION_TAKE_GEOMETRY"></a>`ASSEMBLE_REGION_TAKE_GEOMETRY` | donor and target canvases or original input RGBA differ, or the target head run is absent | supply the same crop and original input PNG. Cropped donors await #973 |
5024
+ | <a id="ASSEMBLE_REGION_TAKE_PART"></a>`ASSEMBLE_REGION_TAKE_PART` | the named plan part/run or its base/donor tag is absent, or the target is taken twice | name an existing plan part and one donor holding its tag |
5025
+ | <a id="ASSEMBLE_REGION_TAKE_EMPTY"></a>`ASSEMBLE_REGION_TAKE_EMPTY` | the derived qualifying region has 0 pixels | supply material missing from the base that covers and matches the painting |
5026
+ | <a id="ASSEMBLE_REGION_TAKE_SEED"></a>`ASSEMBLE_REGION_TAKE_SEED` | the seed touches no qualifying component | name an integer pixel in the derived region on the target run canvas |
5027
+ | <a id="ASSEMBLE_REGION_TAKE_BINDING"></a>`ASSEMBLE_REGION_TAKE_BINDING` | active scalar depth or hidden companions have no region binding | supply a part without those companions |
5028
+ | <a id="ASSEMBLE_REGION_TAKE_PLAN"></a>`ASSEMBLE_REGION_TAKE_PLAN` | a region entry is present during plan proposal | draft the plan before declaring region takes, then use assemble/build with requirements |
4963
5029
  | <a id="ASSEMBLE_TAKE_GEOMETRY"></a>`ASSEMBLE_TAKE_GEOMETRY` | missing target head, unequal run canvases or unequal original input dimensions | supply matching geometry; the check does not resize inputs |
4964
5030
  | <a id="ASSEMBLE_TAKE_META"></a>`ASSEMBLE_TAKE_META` | donor `meta.json` lacks a readable prompt ID or seed | retain the original run metadata |
4965
5031
  | <a id="ASSEMBLE_TAKE_INPUT"></a>`ASSEMBLE_TAKE_INPUT` | declared donor input PNG cannot be read | name its original PNG relative to the config |
@@ -5331,6 +5397,7 @@ documents do not ship). Each is an input this package built; none is an author's
5331
5397
  | <a id="CHECK_SOURCE_SIZE"></a>`CHECK_SOURCE_SIZE` | the painting `--source` names is not this rig's: no single `rig_scale` takes its width and height to the stage's (assemble makes the canvas `trunc(w × rig_scale)` x `trunc(h × rig_scale)`), or, with `parts.json` read, its `scale_rig_per_source` does not; or the stage is not whole pixels. Refused before anything is built | the painting the rig was made from — the stage's own size, or the painting `assemble` was given |
5332
5398
  | <a id="CHECK_SOURCE_PNG"></a>`CHECK_SOURCE_PNG` | `--source` names a file that does not read as a PNG | a PNG |
5333
5399
  | <a id="CHECK_SETUP_MATCHES_PAINTING"></a>`CHECK_SETUP_MATCHES_PAINTING` | region, slot, failing-pixel box, slot pixelcount, complete region pixelcount and declared limit | compare the source painting beside the actual setup; follow [Source before scissors](#source-before-scissors), then rerun with the same authored limit |
5400
+ | <a id="CHECK_SETUP_PAINTING_BACKDROP"></a>`CHECK_SETUP_PAINTING_BACKDROP` | declared backdrop PNG cannot be read or decoded, its byte SHA-256 differs, its original painting grid differs, or any alpha is not 255 | supply an opaque plate with the declared lowercase SHA-256, aligned to the original painting; no fitting or white fallback |
5334
5401
  | <a id="CHECK_SETUP_PAINTING_SOURCE"></a>`CHECK_SETUP_PAINTING_SOURCE` | a declaration without a source painting | supply `--source`; the assembled composite is not an independent reference |
5335
5402
  | <a id="CHECK_SETUP_PAINTING_MEASURED"></a>`CHECK_SETUP_PAINTING_MEASURED` | missing native setup/ownership or unsupported clipping | supply observable textured setup geometry; an unavailable instrument never passes |
5336
5403
  | <a id="CHECK_BOUNDARY_COLOUR_STEP"></a>`CHECK_BOUNDARY_COLOUR_STEP` | the requirements file declares `boundary_colour.max_step_excess` and a part boundary's colour step in the rig's render exceeds the same boundary's step in the rig's own pieces by more than it; the part pair, the frame (`setup` or `idle frame <n> (t <s> s)`), the stage pixel, the excess, the declared allowance and both steps are quoted. Unlike [`CHECK_SETUP_MATCHES_PAINTING`](#CHECK_SETUP_MATCHES_PAINTING), which compares the painting per declared region at the setup pose, this compares against the rig's own flat pieces (`parts.json` placement at setup, each piece's rest colour through its art UVs on every idle frame), so a part drawn over its neighbour's line art is not a failure | the pair's rig image or mesh: `rig/images/<part>.png` (a texture stretched into its padded UVs, or a notch cut out of it), or that part's mesh in `config.json` `meshes` when the mesh cuts a corner off the art; see [Boundary colour step](#boundary-colour-step-issue-942) |
@@ -6043,7 +6110,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
6043
6110
  | line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
6044
6111
  | --- | --- | --- | --- | --- | --- |
6045
6112
  | <a id="SETUP_POSE_VS_SOURCE"></a>`SETUP_POSE_VS_SOURCE` | the setup pose against the painting, by `assemble`'s own `measureRecomposite` over the stage's pixels — `mean_abs` and `within_share` (mean channel, within 8), `error_px` (max channel over 40), `uncovered_error_px` (of those, where the pose has alpha 128 or less), `hole_count` and the largest hole's box — with `painting_px`, `stage_px`, `render_px` and `render_scale` beside them. The painting is resampled onto the stage as assemble resamples it (lanczos3, alpha dropped). rigc renders onto an opaque grey, so the pose over white and its alpha are read off a second still of the same pose with every slot tinted black; both are carried onto the stage with the seam's map turned round (bilinear) | none — status `REPORTED`; assemble applies no bar to the figures it measures, so none is derived here | 3.146 / 93.43 % / 12,399 / 1,732, 518 holes, the largest 61 px at 351,157 6x20 (assemble's recomposite: 2.391 / 95.81 % / 11,050 / 1,564, 453) | 1.095 / 97.30 % / 5,348 / 1,239, 313 holes, the largest 88 px at 479,416 6x29 (assemble's: 0.835 / 97.95 % / 4,512 / 1,185, 259) | not written without `--source` |
6046
- | <a id="SETUP_MATCHES_PAINTING"></a>`SETUP_MATCHES_PAINTING` | declared region count, native box, limit and actual top contributor rows | PASS/FAIL against authored limits; no limit is SKIP, or the refusal `SETUP_PAINTING_REQUIRED_LIMIT` under `setup_painting.required: true`; with `--source`, no declaration is SKIP with its reason and declaration remedy; undeclared regions are not judged |
6113
+ | <a id="SETUP_MATCHES_PAINTING"></a>`SETUP_MATCHES_PAINTING` | declared region count, native box, limit and actual top contributor rows; observations source-over white unless `setup_painting.backdrop` declares the opaque plate behind a full-frame painting | PASS/FAIL against authored limits; no limit is SKIP, or the refusal `SETUP_PAINTING_REQUIRED_LIMIT` under `setup_painting.required: true`; with `--source`, no declaration is SKIP with its reason and declaration remedy; undeclared regions are not judged |
6047
6114
  | <a id="BOUNDARY_COLOUR_STEP"></a>`BOUNDARY_COLOUR_STEP` | the worst colour-step excess over the rig's own pieces at any part boundary of the setup and every idle frame, its part pair, frame and stage pixel, and the declared `max_step_excess` | PASS/FAIL against the declared allowance (FAIL is [`CHECK_BOUNDARY_COLOUR_STEP`](#CHECK_BOUNDARY_COLOUR_STEP), an unreadable piece [`CHECK_BOUNDARY_COLOUR_UNMEASURED`](#CHECK_BOUNDARY_COLOUR_UNMEASURED)); no `boundary_colour` in the requirements file is SKIP with its reason and the declaration to add, never a pass |
6048
6115
 
6049
6116
  The figures are assemble's by construction, not a second definition beside them;
@@ -6647,7 +6714,19 @@ substituted. Source PNG readers preserve straight RGBA. This optional check
6647
6714
  first composites the painting over opaque white at its **original source
6648
6715
  resolution**, rounding each RGB channel to RGBA8, then passes that opaque
6649
6716
  image through the existing `sourceInRig` Lanczos3 resampling onto the stage.
6650
- The rendered setup is composited over white on the native stage grid. This
6717
+ The rendered setup is composited over white on the native stage grid.
6718
+ For a full-frame painting whose figure was painted over an opaque background,
6719
+ declare `setup_painting.backdrop: {"path": "backdrop.png", "sha256": "<64 lowercase hex digits>"}`.
6720
+ The PNG path resolves relative to the requirements file. The exact bytes read
6721
+ are hashed before decoding; the plate must be opaque everywhere and match the
6722
+ original painting dimensions. It is never fitted or inferred from the art.
6723
+ With the declaration, both observations use that plate instead of white:
6724
+ the painting is flattened over it before resampling, and the plate is
6725
+ resampled onto the stage before the rendered setup is composited over it.
6726
+ The report carries the declared path and verified hash. Pixels where painting
6727
+ RGB differs from the plate above the authored tolerance also enter the figure
6728
+ domain, even when no setup piece draws there. Missing material remains counted.
6729
+ Without the declaration, report bytes and the white comparator are unchanged. This
6651
6730
  ordering excludes hidden RGB from both resampling taps and figure detection;
6652
6731
  the global `sourceInRig` and legacy checks retain their existing RGB semantics.
6653
6732
  The figure domain is the union of rendered
package/docs/STABILITY.md CHANGED
@@ -147,7 +147,7 @@ part name, `[i]` an entry of a list):
147
147
  - **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
148
148
  - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
149
149
  - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
150
- - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `project`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`, `editor_receipts`
150
+ - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `project`, `ground`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`, `editor_receipts`
151
151
  - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
152
152
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
153
153
  - **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance`; optional: `min_held_alpha`, `order` (issues #848, #854, #855; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only). The optional integer floor 1–255 admits exact original alpha-over coverage strictly below it; absent or 1 blocks every nonzero holder. Only selected target samples below the floor may take painting RGBA; target samples at/above the floor and every non-target holder remain exact. By default overlapping reachable claims refuse. With `order` on every entry (unique nonnegative safe integers), lower values claim first and their gains block later candidates and paths; array order never breaks ties or supplies missing precedence
@@ -232,6 +232,12 @@ an absent field preserves existing output bytes. The bytes `visible` writes
232
232
  are not promised across versions (issue #953 resolves its edges from the
233
233
  painting).
234
234
 
235
+ `assemble.ground` accepts `plain` or `busy` (issue #996). Invalid values refuse
236
+ as `CONFIG_FIELD_TYPE` at `config.assemble.ground`; an absent field and `plain`
237
+ preserve existing output bytes. It is read by `visible` only, whose bytes are
238
+ not promised. The `ASSEMBLE_GROUND_PLAIN` warning prints under `visible` only, is a
239
+ report line and never changes output.
240
+
235
241
  `TY14` holds the set of field names in backticks above to the set of names the
236
242
  loader's object checks spell (every `c.object(…)` in `src/config.ts`, with the lists
237
243
  they name), in both directions, and refuses an object check whose key list it cannot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "2.6.0",
3
+ "version": "2.8.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,5 +1,5 @@
1
1
  import { type SeamRule } from './seamrule.ts';
2
- import { DEFAULT_PROJECT_RULE, type ProjectRule } from './projectrule.ts';
2
+ import { DEFAULT_GROUND, DEFAULT_PROJECT_RULE, type Ground, type ProjectRule } from './projectrule.ts';
3
3
  import { paintingEdges, type PaintingEdgeSkips } from './paintingedges.ts';
4
4
  import { edgeLength } from './length.ts';
5
5
  import { materialSource, type MaterialSource, materialRows, polygonMaterialEdges, type MaterialRow, type MaterialEdge, type PlacedArt } from './cutmaterial.ts';
@@ -193,7 +193,7 @@ export const SILHOUETTE_LIMIT = 40;
193
193
 
194
194
  export { DEFAULT_SEAM_RULE, SEAM_RULES, type SeamRule } from './seamrule.ts';
195
195
 
196
- export { DEFAULT_PROJECT_RULE, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
196
+ export { DEFAULT_GROUND, DEFAULT_PROJECT_RULE, GROUNDS, type Ground, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
197
197
 
198
198
  // ---------------------------------------------------------------------------
199
199
  // inputs
@@ -331,6 +331,8 @@ export interface AssembleInput {
331
331
  under?: ReadonlyArray<Raster | null>;
332
332
  seamRule: SeamRule;
333
333
  projectRule: ProjectRule;
334
+ /** `config.assemble.ground` (issue #996), read by `paintingEdges` under `visible` only; absent, `plain`. */
335
+ ground?: Ground;
334
336
  /** `config.scene` (issue #242): the painting is a scene, so the recomposite is read inside the figure ({@link sceneFigure}). Absent or false: nothing changes. */
335
337
  scene?: boolean;
336
338
  /** `config.limbs` (issue #349): the parts cut at their joints ({@link LimbCount}). Absent or empty: nothing changes. */
@@ -2619,6 +2621,47 @@ export function paintedFigure(srcr: Raster): Uint8Array {
2619
2621
  return painted;
2620
2622
  }
2621
2623
 
2624
+ /**
2625
+ * Issue #996: the share of the border ring that `paintedFigure`'s own one-colour
2626
+ * test reads as figure — the ring's pixels (the outer `SILHOUETTE_BORDER` rig
2627
+ * pixels) whose `paintedFigure` value is 1, over the ring's pixel count. A plain
2628
+ * ground reads near 0; a ground of many colours, or a figure crossing the
2629
+ * border, reads more.
2630
+ */
2631
+ export function groundRingShare(srcr: Raster): number {
2632
+ const { width: W, height: H } = srcr;
2633
+ const painted = paintedFigure(srcr);
2634
+ let ring = 0;
2635
+ let figure = 0;
2636
+ for (let y = 0; y < H; y++) {
2637
+ for (let x = 0; x < W; x++) {
2638
+ if (y >= SILHOUETTE_BORDER && y < H - SILHOUETTE_BORDER && x >= SILHOUETTE_BORDER && x < W - SILHOUETTE_BORDER) continue;
2639
+ ring++;
2640
+ figure += painted[y * W + x];
2641
+ }
2642
+ }
2643
+ return ring === 0 ? 0 : figure / ring;
2644
+ }
2645
+
2646
+ /**
2647
+ * Share of the border ring that paintedFigure's own one-colour test reads as figure. Plain grounds measured at most 0.001 and the busy fixtures 0.464; 0.02 sits near the geometric middle of that gap, an order of magnitude from each side. A figure crossing the border counts too, so the WARN names both causes and leaves the ground as declared.
2648
+ */
2649
+ export const GROUND_RING_SHARE_LIMIT = 0.02;
2650
+
2651
+ /**
2652
+ * Issue #996: the WARN for a ground declared or defaulted `plain` whose border ring
2653
+ * is not one colour (more than {@link GROUND_RING_SHARE_LIMIT} of it reads as
2654
+ * figure). Report-level only: it never changes the ground and never refuses. Null
2655
+ * when the ground is declared `busy` or the share is at or under the limit, and under
2656
+ * `project: core`: only the visible rule's paintingEdges reads the ground, so under
2657
+ * core the declaration changes nothing and there is nothing to warn about.
2658
+ */
2659
+ export function groundWarning(share: number, ground: Ground | undefined, rule: ProjectRule): Problem | null {
2660
+ if (rule !== 'visible' || (ground ?? DEFAULT_GROUND) !== 'plain' || share <= GROUND_RING_SHARE_LIMIT) return null;
2661
+ const pct = (100 * share).toFixed(1);
2662
+ return { code: 'ASSEMBLE_GROUND_PLAIN', object: 'config.assemble.ground', detail: `${pct} % of the border ring is not one colour: either the ground is not plain or the figure crosses the border; if it is the ground, declare assemble.ground: busy` };
2663
+ }
2664
+
2622
2665
  export interface PlacedPart {
2623
2666
  depthImage?: Raster;
2624
2667
  record: PartRecord;
@@ -4586,6 +4629,8 @@ export interface AssembleResult {
4586
4629
  projectRule: ProjectRule;
4587
4630
  /** Issue #953: what `paintingEdges` resolved, under `projectRule: 'visible'` only. */
4588
4631
  paintingEdges?: PaintingEdgeReading;
4632
+ /** Issue #996: {@link groundRingShare} of the painting in the rig, read by the ground WARN. */
4633
+ groundShare: number;
4589
4634
  depthReadings?: Array<{ from: string; reading: import('./depthsheets.ts').DepthReading }>;
4590
4635
  /** Visible registered samples clamped within <1 source pixel of a crop. */
4591
4636
  depthClamps?: Array<{ part: string; samples: number }>;
@@ -4801,6 +4846,7 @@ export function assemble(input: AssembleInput): AssembleResult {
4801
4846
  checkPlanAgainstRuns(sourcePlan, extend, full, head);
4802
4847
  const { W, H } = frame;
4803
4848
  const srcr = sourceInRig(source, W, H);
4849
+ const groundShare = groundRingShare(srcr);
4804
4850
  // Issue #359: a part's hidden-art layer is the under of a cut that names none — and, with one named, a refusal, since
4805
4851
  // reading either would be a guess. Its uses are counted for `hiddenLines`.
4806
4852
  const hiddenArt = hiddenArtByPart(plan, input.cuts, { full, head }, frame, input.resolution);
@@ -5608,6 +5654,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5608
5654
  };
5609
5655
  const e = paintingEdges(stack.map(onRig), srcr, {
5610
5656
  painted: paintedFigure(srcr),
5657
+ ...(input.ground === undefined ? {} : { ground: input.ground }),
5611
5658
  editable: order.map((d) => !('patch' in d)),
5612
5659
  keep: keepInOrder,
5613
5660
  feathered: featheredInOrder,
@@ -5821,6 +5868,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5821
5868
  hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
5822
5869
  seamRule,
5823
5870
  projectRule,
5871
+ groundShare,
5824
5872
  ...(edgeReading === null ? {} : { paintingEdges: edgeReading }),
5825
5873
  ...(headFacePrep.underfillPx > 0 ? { headFaceUnderfillPx: headFacePrep.underfillPx } : {}),
5826
5874
  excluded,
@@ -6705,6 +6753,8 @@ export function stageFields(
6705
6753
  regions?: Record<string, RegionSpec>;
6706
6754
  meshes?: Record<string, MeshSpec>;
6707
6755
  motion?: CharacterConfig['motion'];
6756
+ /** `assemble.ground` (issue #996); absent, `plain`. */
6757
+ ground?: Ground;
6708
6758
  } {
6709
6759
  const problems: Problem[] = [];
6710
6760
  if (cfg.seethrough === undefined) {
@@ -6726,6 +6776,7 @@ export function stageFields(
6726
6776
  ...(cfg.assemble.head_face_underfill === undefined ? {} : { headFaceUnderfill: cfg.assemble.head_face_underfill }),
6727
6777
  ...(cfg.assemble.yield_refused === undefined ? {} : { yieldRefused: cfg.assemble.yield_refused }),
6728
6778
  ...(cfg.assemble.keep_under_occluders === undefined ? {} : { keepUnderOccluders: cfg.assemble.keep_under_occluders }),
6779
+ ...(cfg.assemble.ground === undefined ? {} : { ground: cfg.assemble.ground }),
6729
6780
  ...(cfg.assemble.extend_painting === undefined ? {} : { paintingExtension: cfg.assemble.extend_painting }),
6730
6781
  ...(turnMovingPair === null ? {} : { turnMovingPair }),
6731
6782
  ...(cfg.turn === undefined ? {} : { turn: cfg.turn, regions: cfg.regions, meshes: cfg.meshes, motion: cfg.motion }),
package/src/build.ts CHANGED
@@ -7,6 +7,7 @@ import { writeMaterialSources } from './cutmaterialio.ts';
7
7
  import { CUT_MATERIAL_LINE, CUT_MATERIAL_SOURCES, materialLines } from './cutmaterial.ts';
8
8
  import { ART_JOINT_LINE, artJointLines } from './artjoints.ts';
9
9
  import { applyTakenLayers } from './takelayers.ts';
10
+ import { applyTakenRegions } from './takeregion.ts';
10
11
  import { ownershipLines, provenanceLines } from './provenance.ts';
11
12
  import { readSourceDepth } from './depthsheets.ts';
12
13
  import { orderedBaseAnchor } from './baseanchor.ts';
@@ -58,7 +59,7 @@ import { createHash } from 'node:crypto';
58
59
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
59
60
  import { dirname, join, resolve } from 'node:path';
60
61
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
61
- import { assemble, type AssembleResult, cutLines, type ExcludedCount, excludedLines, setExclusions, islandLines, setIslandSources, type IslandCount, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, softOcclusionLine, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measurePinholes, measureRecomposite, pinholeProblems, pinholesLine, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
62
+ import { assemble, type AssembleResult, cutLines, type ExcludedCount, excludedLines, setExclusions, islandLines, setIslandSources, type IslandCount, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, softOcclusionLine, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, groundWarning, holeKindsLine, holeLines, measurePinholes, measureRecomposite, pinholeProblems, pinholesLine, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
62
63
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
63
64
  import { residualClause } from './autoenvelope.ts';
64
65
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, prepareComparison, type ReplayCandidate, runComparison, runPreparedComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -345,7 +346,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
345
346
  const receipts = readEditorReceipts(cfg.assemble.editor_receipts, input.config);
346
347
  // Issue #947: under `trace_parts`, a shape drawn in the config through a part's pixels is refused with them.
347
348
  refuseIfAny([...receipts.problems, ...missingReceipts(receipts, [...editedRunImages(read), ...editedConfigImages(input.config, cfg.assemble.cuts ?? [], cfg.limbs)]), ...untracedPartSources(cfg)]);
348
- const runs = applyTakenLayers(read, src, cfg, input.config);
349
+ const takenRuns = applyTakenLayers(read, src, cfg, input.config);
350
+ const runs = applyTakenRegions(takenRuns, src, cfg, input.config, requirementsFile?.setup_painting, receipts);
349
351
  for (const note of runs.notes) log(`note: ${note}`);
350
352
  const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
351
353
  const under = readUnders(input.config, fields.cuts);
@@ -689,6 +691,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
689
691
  ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
690
692
  ...(fields.headFaceUnderfill === undefined ? {} : { headFaceUnderfill: fields.headFaceUnderfill }),
691
693
  ...(fields.yieldRefused === undefined ? {} : { yieldRefused: fields.yieldRefused }),
694
+ ...(fields.ground === undefined ? {} : { ground: fields.ground }),
692
695
  ...(fields.turnMovingPair === undefined ? {} : { turnMovingPair: fields.turnMovingPair }),
693
696
  ...(fields.turn === undefined ? {} : { turn: fields.turn, regions: fields.regions, meshes: fields.meshes, motion: fields.motion }),
694
697
  seamRule: seam,
@@ -809,10 +812,15 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
809
812
  if (figurePath !== null) writePng(figurePath, result.figure as Raster);
810
813
  const [W, H] = result.parts.rig_size;
811
814
  log(`rig-parts assemble: ${result.images.length} part(s) on a ${W}x${H} rig (${result.parts.scale_rig_per_source} rig px per source px), seam rule ${result.seamRule}, projection rule ${result.projectRule}`);
815
+ // Issue #996: a ground declared or defaulted plain whose border ring is not one colour, under project: visible only.
816
+ const groundWarn = groundWarning(result.groundShare, fields.ground, result.projectRule);
817
+ if (groundWarn !== null) log(` WARN ${groundWarn.code}: ${groundWarn.object} — ${groundWarn.detail}`);
812
818
  // Issue #953: what the painting's edges resolved, under the visible rule only.
813
819
  if (result.paintingEdges !== undefined) {
814
820
  const e = result.paintingEdges;
815
821
  log(`painting edges: ${e.resolved} edge px resolved from the painting (${e.unseparated} against the two reference colours where the window's far sides overlap), ${e.changed} part px changed; left as they were: ${e.skipped.cover} under a part the two-layer model does not carry, ${e.skipped.contrast} where the painting draws no edge (contrast below ${EDGE_CONTRAST}), ${e.skipped.locked} kept, feathered, a patch or a turn's moving pair, ${e.skipped.noBehind} with no behind pixel in the window; ${e.skipped.pieces} part px not written so a part's art keeps its pieces`);
822
+ // Issue #996: under a declared busy ground, what the two ground rules left.
823
+ if (fields.ground === 'busy') log(` busy ground (assemble.ground): ${e.skipped.mix} behind-side px left that are not a mix of the two reference colours, ${e.skipped.support} part px not raised outside the part's own support`);
816
824
  }
817
825
  // Issue #359: one run, said, so a reader of the lines knows no head run was placed.
818
826
  if (runs.single) log(` one run: ${input.full} holds the face (${SINGLE_RUN_FACE_TAGS.join(', ')}), so no head run is read; every part comes from the full run`);
package/src/check.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readSetupBackdrop } from './setupbackdrop.ts';
1
2
  import { SETUP_PAINTING_LINE, UNDECLARED_SETUP_PAINTING_REASON, setupPaintingBounds, setupPaintingLine, type SetupPainting, type SetupPaintingLine, type SetupPaintingObservation } from './setuppainting.ts';
2
3
  import { readMaterialSources } from './cutmaterialio.ts';
3
4
  import { CUT_MATERIAL_LINE, checkJointMaterial, type MaterialLine } from './cutmaterial.ts';
@@ -1940,7 +1941,7 @@ export function officialRenderer(): OfficialRenderer {
1940
1941
 
1941
1942
  /** Observe the actual setup on the declared stage grid. No frame-zero motion,
1942
1943
  * fitted alignment, flat recomposite or duplicate core validation. */
1943
- export function measureSetupPainting(build: string, stage: StageBox, source: Raster, declared: SetupPainting, problems: Problem[]): SetupPaintingLine {
1944
+ export function measureSetupPainting(build: string, stage: StageBox, source: Raster, declared: SetupPainting, problems: Problem[], backdrop?: Raster): SetupPaintingLine {
1944
1945
  const api = officialRenderer();
1945
1946
  const skeleton = join(build, 'skeleton.json'), atlas = join(build, 'skeleton.atlas');
1946
1947
  const candidate = api.loadCandidate({ skeletonText: readFileSync(skeleton, 'utf8'), atlasText: readFileSync(atlas, 'utf8'), atlasDir: build, label: 'setup versus painting' }, { skeleton, atlas }, undefined);
@@ -1954,17 +1955,17 @@ export function measureSetupPainting(build: string, stage: StageBox, source: Ras
1954
1955
  const project = api.projector(vp), owners = new Int32Array(W * H).fill(-1);
1955
1956
  pose.pieces.forEach((piece, i) => api.rasterisePiece(api.pageFor(candidate.pages, piece), piece, project, vp, (x, y, _r, _g, _b, a) => { if (a > 0) owners[y * W + x] = i; }));
1956
1957
  // sourceInRig intentionally reads opaque RGB, discarding source alpha before
1957
- // resizing. This optional comparison declares source-over white instead:
1958
+ // resizing. This optional comparison declares source-over the plate or white:
1958
1959
  // flatten at ORIGINAL source resolution first, so hidden RGB cannot reach
1959
1960
  // Lanczos taps or the figure silhouette. Do not change the legacy helper.
1960
1961
  const whiteSource = newRaster(source.width, source.height);
1961
1962
  for (let p = 0; p < source.width * source.height; p++) {
1962
- for (let c = 0; c < 3; c++) whiteSource.data[p * 4 + c] = Math.round(source.data[p * 4 + c] * source.data[p * 4 + 3] / 255 + 255 * (1 - source.data[p * 4 + 3] / 255));
1963
+ for (let c = 0; c < 3; c++) whiteSource.data[p * 4 + c] = Math.round(source.data[p * 4 + c] * source.data[p * 4 + 3] / 255 + (backdrop?.data[p * 4 + c] ?? 255) * (1 - source.data[p * 4 + 3] / 255));
1963
1964
  whiteSource.data[p * 4 + 3] = 255;
1964
1965
  }
1965
1966
  const painting = sourceInRig(whiteSource, W, H);
1966
1967
  const setup = api.renderFrame(pose, candidate.pages, vp, [0, 0, 0, 0]);
1967
- return setupPaintingLine(declared, painting, { width: W, height: H, data: new Uint8ClampedArray(setup.data) }, figureSilhouette(painting).data, owners, pose.pieces.map(p => p.slot), problems);
1968
+ return setupPaintingLine(declared, painting, { width: W, height: H, data: new Uint8ClampedArray(setup.data) }, figureSilhouette(painting).data, owners, pose.pieces.map(p => p.slot), problems, backdrop === undefined ? undefined : sourceInRig(backdrop, W, H));
1968
1969
  }
1969
1970
 
1970
1971
  /** One decoder/poser lifetime per state; retain only finite bounds and identities, never atlas pages or rasters. */
@@ -6399,6 +6400,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6399
6400
  return null;
6400
6401
  }
6401
6402
  };
6403
+ let setupBackdrop: Raster | undefined;
6402
6404
  // Issue #538: the schedule is read once, first, by rigc's own reader; refused, nothing is built or written.
6403
6405
  const env = environment === undefined ? null : take(() => readCheckEnvironment(environment));
6404
6406
  // Issue #590: the steady-wind band spec is read and resolved before anything is built or written.
@@ -6423,6 +6425,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6423
6425
  if (file.setup_painting !== undefined) take(() => {
6424
6426
  refuseIfAny(setupPaintingBounds(file.setup_painting as SetupPainting, ready.stage.width, ready.stage.height));
6425
6427
  if (ready.source === null) refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_SOURCE', object: 'requirements.setup_painting', detail: 'requires --source naming the painting; the assembled composite is not an independent source painting' }]);
6428
+ setupBackdrop = readSetupBackdrop(file.setup_painting as SetupPainting, requirements, ready.source?.painting ?? null);
6426
6429
  });
6427
6430
  // Issue #942: a declared boundary_colour reads parts.json's pieces and the idle; without either it cannot be taken.
6428
6431
  if (file.boundary_colour !== undefined) take(() => refuseIfAny([
@@ -7018,7 +7021,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
7018
7021
  const sr = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), ...(fixedSkeleton === undefined ? [] : ['--geometry']), '--out', join(stillDir, 'render')]);
7019
7022
  if (sr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still)', sr, sr.out.split('\n').filter((l) => l.includes('FAIL'))));
7020
7023
  const stillSet = readFrameSet(join(stillDir, 'render'));
7021
- if (reqFile?.setup_painting !== undefined && inp.source !== null) setupPainting = measureSetupPainting(join(stillDir, 'build'), inp.stage, inp.source.painting, reqFile.setup_painting, problems);
7024
+ if (reqFile?.setup_painting !== undefined && inp.source !== null) setupPainting = measureSetupPainting(join(stillDir, 'build'), inp.stage, inp.source.painting, reqFile.setup_painting, problems, setupBackdrop);
7022
7025
  if (fixedSkeleton !== undefined) {
7023
7026
  const geometryPath = join(stillSet.dir, GEOMETRY_FILE);
7024
7027
  const geometry = readGeometry(geometryPath, stillSet.written);
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SEAM_RULES, type SeamRule } from './seamrule.ts';
2
- import { PROJECT_RULES, type ProjectRule } from './projectrule.ts';
3
- import type { TakeLayers } from './takelayers.ts';
2
+ import { GROUNDS, type Ground, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
3
+ import type { TakeDeclaration } from './takeregion.ts';
4
4
  import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
5
5
  import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
6
6
  import type { DepthConfig } from './depthsheets.ts';
@@ -307,8 +307,10 @@ export interface Assemble {
307
307
  seam?: SeamRule;
308
308
  /** Optional per-character projection rule (issue #953); an explicit CLI flag wins. */
309
309
  project?: ProjectRule;
310
- /** Verified named replacements from another See-through run (#866). */
311
- take?: TakeLayers[];
310
+ /** Optional per-character painting ground (issue #996); absent, `plain`. Read by `visible` only. */
311
+ ground?: Ground;
312
+ /** Whole-layer replacements (#866) or seeded, painting-matched region takes (#1000). */
313
+ take?: TakeDeclaration[];
312
314
  /** Issue #946: receipts for images edited in an image editor; absent, no file is read and no byte moves. */
313
315
  editor_receipts?: EditorReceipts;
314
316
  /** Rig pixels per source pixel. */
@@ -1629,7 +1631,7 @@ export interface EarlyConfig {
1629
1631
  depth?: DepthConfig;
1630
1632
  key: string;
1631
1633
  seethrough: SeeThrough;
1632
- assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1634
+ assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; ground?: Ground; take?: TakeDeclaration[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1633
1635
  }
1634
1636
 
1635
1637
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1705,7 +1707,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1705
1707
  if (door === 'layers') {
1706
1708
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1707
1709
  if ('assemble' in t) {
1708
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'project', 'editor_receipts']);
1710
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'project', 'ground', 'editor_receipts']);
1709
1711
  if (a !== null) {
1710
1712
  checkAssembleRules(c, a);
1711
1713
  if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
@@ -2453,15 +2455,32 @@ function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readon
2453
2455
  });
2454
2456
  }
2455
2457
 
2456
- function checkTakeLayers(c: Check, value: Json): void {
2458
+ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): void {
2457
2459
  const at = 'config.assemble.take';
2458
2460
  if (!c.array(at, value, false)) return;
2459
2461
  const seen = new Set<string>();
2460
2462
  (value as Json[]).forEach((v, i) => {
2461
- const p = `${at}[${i}]`, o = c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
2463
+ const p = `${at}[${i}]`;
2464
+ const region = isPlainObject(v) && ('part' in v || 'seed' in v);
2465
+ const o = region
2466
+ ? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], [])
2467
+ : c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
2462
2468
  if (o === null) return;
2463
2469
  if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
2464
2470
  for (const k of ['run_dir', 'st_input']) c.string(`${p}.${k}`, o[k]);
2471
+ if (region) {
2472
+ if (c.string(`${p}.part`, o.part) && plan !== null && !plan.includes(o.part)) {
2473
+ c.fail('CONFIG_NAME_RESOLVES', `${p}.part`, `names "${o.part}"; a part named in assemble.plan is required`);
2474
+ }
2475
+ const key = `${String(o.run)}:part:${String(o.part)}`;
2476
+ if (seen.has(key)) c.fail('CONFIG_TAKE_UNIQUE', `${p}.part`, `names ${key} again; one donor per target part is required`);
2477
+ seen.add(key);
2478
+ const seed = o.seed;
2479
+ if (!(Array.isArray(seed) && seed.length === 2 && seed.every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0))) {
2480
+ c.fail('CONFIG_FIELD_TYPE', `${p}.seed`, 'requires [x, y], nonnegative safe integers in target-run canvas pixels');
2481
+ }
2482
+ return;
2483
+ }
2465
2484
  if (!c.array(p + '.tags', o.tags, true)) return;
2466
2485
  (o.tags as Json[]).forEach((tag, j) => {
2467
2486
  const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
@@ -2513,11 +2532,14 @@ function checkAssembleRules(c: Check, a: Record<string, Json>): void {
2513
2532
  if ('project' in a && !(PROJECT_RULES as readonly unknown[]).includes(a.project)) {
2514
2533
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.project', `is ${show(a.project)}; one of ${PROJECT_RULES.join(', ')} is required`);
2515
2534
  }
2535
+ if ('ground' in a && !(GROUNDS as readonly unknown[]).includes(a.ground)) {
2536
+ c.fail('CONFIG_FIELD_TYPE', 'config.assemble.ground', `is ${show(a.ground)}; one of ${GROUNDS.join(', ')} is required (busy for an opaque ground of many colours, issue #996)`);
2537
+ }
2516
2538
  }
2517
2539
 
2518
2540
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
2519
2541
  const required = sectionRequired(door, 'assemble');
2520
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'project', 'editor_receipts']);
2542
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'project', 'ground', 'editor_receipts']);
2521
2543
  if (a === null) return { parts: [], patches: [], cuts: [] };
2522
2544
  checkAssembleRules(c, a);
2523
2545
  if (required.includes('plan') && !('plan' in a)) {
@@ -2554,7 +2576,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
2554
2576
  }
2555
2577
  checkKeepUnderOccluders(c, a, parts);
2556
2578
  if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
2557
- if ('take' in a) checkTakeLayers(c, a.take);
2579
+ if ('take' in a) checkTakeLayers(c, a.take, parts);
2558
2580
  if ('editor_receipts' in a) checkEditorReceipts(c, a.editor_receipts);
2559
2581
  if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
2560
2582
  (a.extend_below_crop as Json[]).forEach((entry, i) => {
@@ -1,5 +1,6 @@
1
1
  import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
2
2
  import { connectedComponents } from './raster/components.ts';
3
+ import { type Ground } from './projectrule.ts';
3
4
  import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts';
4
5
 
5
6
  /**
@@ -100,6 +101,31 @@ import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts'
100
101
  * and pixel). Where that leaves out BEHIND's fill under FRONT, FRONT takes the
101
102
  * painting's pixel at full alpha instead, under the same rule.
102
103
  *
104
+ * ⚖️ A busy ground (`ground: 'busy'`, issue #996). The two premises above —
105
+ * `painted` separates figure from ground, and BEHIND is one colour `Cb` across
106
+ * the window — hold only where the ground is one colour. On an opaque ground of
107
+ * many colours `paintedFigure` marks the ground as figure, so the gap rule hands
108
+ * ground pixels to the nearest part, and a ground pixel that is not `Cb`
109
+ * projects along `Cb -> Cf` as FRONT. Under a busy ground three rules bound what
110
+ * a part may take:
111
+ *
112
+ * - a part's alpha rises at a pixel only where its own alpha before the step is
113
+ * above 0 (its support, the reach of See-through's layer through the
114
+ * resampling into the rig) — FRONT's coverage, BEHIND's fill and the
115
+ * painting's pixel FRONT takes where BEHIND's fill is held back alike;
116
+ * a write that would raise one is not made (counted as `support`);
117
+ * - the gap rule takes a pixel only where the supports of at least two parts
118
+ * meet (a gap between two soft rims, which is what it is for), and gives it
119
+ * to the nearest of those parts;
120
+ * - a pixel on BEHIND's side of See-through's line is read only where it is a
121
+ * mix of `Cb` and `Cf`: the painting differs from the nearest point of the
122
+ * segment `Cb..Cf` by at most half of {@link EDGE_CONTRAST} (max channel, the
123
+ * tolerance the composite is held to above); otherwise it is left as it was
124
+ * (counted as `mix`).
125
+ *
126
+ * Under a plain ground (the default) none of the three applies, and the step is
127
+ * the one described above, byte for byte.
128
+ *
103
129
  * Every decision is read off the stack as it stood before the step, then
104
130
  * applied, so the result does not depend on the order pixels are visited.
105
131
  * Deterministic: integer rig arithmetic, medians by sort, no float reduction
@@ -119,6 +145,10 @@ export interface PaintingEdgeSkips {
119
145
  noBehind: number;
120
146
  /** Part writes left out so a part's art keeps its 4-connected pieces (counted per part and pixel, not per edge pixel). */
121
147
  pieces: number;
148
+ /** Busy ground only (issue #996): BEHIND-side pixels that are not a mix of the two reference colours. */
149
+ mix: number;
150
+ /** Busy ground only (issue #996): part writes left out because they would raise a part's alpha outside its own support (per part and pixel). */
151
+ support: number;
122
152
  }
123
153
 
124
154
  export interface PaintingEdgeResult {
@@ -148,6 +178,8 @@ export interface PaintingEdgeOptions {
148
178
  movingPair?: ((a: number, b: number) => boolean) | null;
149
179
  /** Called once per part and pixel whose RGBA changed. */
150
180
  written?: (part: number, pixel: number) => void;
181
+ /** Issue #996: the painting's ground; absent, `plain` (see the invariant above {@link EDGE_RADIUS}). */
182
+ ground?: Ground;
151
183
  }
152
184
 
153
185
  /** Chebyshev distance to the nearest set pixel, capped at `cap` (two-pass, exact for the 8-neighbour metric). */
@@ -219,6 +251,9 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
219
251
  });
220
252
  const R = EDGE_RADIUS;
221
253
  const cap = 2 * R + 2;
254
+ const busy = options.ground === 'busy';
255
+ /** A part's support before the step: where its own alpha is above 0. */
256
+ const supports = (i: number, p: number): boolean => parts[i].data[p * 4 + 3] > 0;
222
257
  const owner = new Int16Array(W * H).fill(-1);
223
258
  for (let p = 0; p < W * H; p++) {
224
259
  for (let i = n - 1; i >= 0; i--) {
@@ -239,9 +274,15 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
239
274
  const fill: Array<[number, number]> = [];
240
275
  for (let p = 0; p < W * H; p++) {
241
276
  if (owner[p] !== -1 || options.painted[p] !== 1) continue;
277
+ // Busy ground: only where two parts' supports meet, and only to a part whose support holds the pixel.
278
+ if (busy) {
279
+ let holders = 0;
280
+ for (let k = 0; k < n && holders < 2; k++) if (supports(k, p)) holders++;
281
+ if (holders < 2) continue;
282
+ }
242
283
  let best = -1;
243
284
  let bd = cap;
244
- for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd) { best = k; bd = near[k][p]; }
285
+ for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd && (!busy || supports(k, p))) { best = k; bd = near[k][p]; }
245
286
  if (best >= 0) fill.push([p, best]);
246
287
  }
247
288
  for (const [p, k] of fill) owner[p] = k;
@@ -261,7 +302,7 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
261
302
  dist.push(any ? chebyshev(set, W, H, cap) : new Uint8Array(W * H).fill(cap));
262
303
  }
263
304
  const distOf = (label: number): Uint8Array => dist[label < 0 ? n : label];
264
- const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0 };
305
+ const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0, mix: 0, support: 0 };
265
306
  const decisions: Decision[] = [];
266
307
  let resolved = 0;
267
308
  let unseparated = 0;
@@ -363,6 +404,17 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
363
404
  const sideHi = sideF.length === 0 ? -Infinity : Math.min(...sideF.map(along));
364
405
  const separate = sideHi > sideLo;
365
406
  if (!separate) unseparated++;
407
+ // Busy ground: a pixel on BEHIND's side is read only where it is a mix of Cb and Cf (its distance, max channel, from
408
+ // the nearest point of the segment Cb..Cf within half the contrast floor); a ground of many colours is not Cb.
409
+ if (busy && a !== front) {
410
+ const ax2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
411
+ const t = Math.min(1, Math.max(0, along(p) / ax2));
412
+ const off = Math.max(...[0, 1, 2].map((c) => Math.abs(srcr.data[p * 4 + c] - (cb[c] + t * axis[c]))));
413
+ if (2 * off > EDGE_CONTRAST) {
414
+ skipped.mix++;
415
+ continue;
416
+ }
417
+ }
366
418
  const lo = separate ? sideLo : 0;
367
419
  const hi = separate ? sideHi : along(ref);
368
420
  resolved++;
@@ -428,6 +480,10 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
428
480
  const d = parts[i].data;
429
481
  const s = p * 4;
430
482
  if (d[s] === r && d[s + 1] === g && d[s + 2] === bl && d[s + 3] === al) return;
483
+ if (busy && al > d[s + 3] && !supports(i, p)) {
484
+ skipped.support++;
485
+ return;
486
+ }
431
487
  planned[i].set(p, [r, g, bl, al]);
432
488
  };
433
489
  /** Per part, the pixels it fills as BEHIND, with the FRONT part over them. */
@@ -444,7 +500,14 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
444
500
  if (alpha > 0 && alpha < 255) while (alpha < 255 && 2 * miss(alpha) > EDGE_CONTRAST) alpha++;
445
501
  if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
446
502
  const unmix = (c: number): number => unmixAt(c, alpha);
447
- if (alpha === 0) plan(q.front, q.p, 0, 0, 0, 0);
503
+ // Busy ground: a BEHIND raise outside BEHIND's own support is refused in `plan`, so FRONT is not lowered there either —
504
+ // FRONT keeps the coverage it had, and a seam whose two supports do not overlap is never left transparent.
505
+ // The refused BEHIND raise below is the one counted in `skipped.support`.
506
+ const behindRefused = busy && q.behind >= 0 && alpha < 255 && parts[q.behind].data[s + 3] < 255 && !supports(q.behind, q.p);
507
+ const keepFront = behindRefused && alpha < parts[q.front].data[s + 3];
508
+ if (keepFront) {
509
+ // FRONT keeps its coverage: nothing is planned for it here.
510
+ } else if (alpha === 0) plan(q.front, q.p, 0, 0, 0, 0);
448
511
  else if (alpha === 255) plan(q.front, q.p, P[0], P[1], P[2], 255);
449
512
  else plan(q.front, q.p, unmix(0), unmix(1), unmix(2), alpha);
450
513
  if (q.behind >= 0 && alpha < 255) {
@@ -526,7 +589,10 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
526
589
  // A fill BEHIND cannot take: FRONT takes the painting's pixel whole, so the composite there is still the painting
527
590
  // (FRONT is later in draw order, so its own pieces are held after this).
528
591
  const front = fills[i].get(p);
529
- if (front !== undefined) planned[front].set(p, [srcr.data[p * 4], srcr.data[p * 4 + 1], srcr.data[p * 4 + 2], 255]);
592
+ if (front !== undefined) {
593
+ if (busy && !supports(front, p)) skipped.support++;
594
+ else planned[front].set(p, [srcr.data[p * 4], srcr.data[p * 4 + 1], srcr.data[p * 4 + 2], 255]);
595
+ }
530
596
  }
531
597
  }
532
598
  });
@@ -20,3 +20,18 @@ export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
20
20
  * it is opt-in.
21
21
  */
22
22
  export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
23
+
24
+ /**
25
+ * Issue #996: what lies behind the figure in the painting. `plain` is a ground
26
+ * of one colour, the assumption `paintedFigure` (src/assemble.ts) makes when it
27
+ * reads the border ring's median as the ground. `busy` is an opaque ground of
28
+ * many colours (a wall, a banner), where that test marks the ground as figure
29
+ * and the painting alone cannot tell a part's painted outline from the ground
30
+ * next to it. Under `visible`, `paintingEdges` then bounds what a part may take
31
+ * (see its invariant). The ground is declared by the author, never inferred.
32
+ */
33
+ export type Ground = 'plain' | 'busy';
34
+ export const GROUNDS: readonly Ground[] = ['plain', 'busy'];
35
+
36
+ /** The ground when none is declared: `plain`, the code path every build ran before issue #996. */
37
+ export const DEFAULT_GROUND: Ground = 'plain';
@@ -0,0 +1,28 @@
1
+ /** Declared setup plate: hash the same bytes that are decoded, before builds. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { refuseIfAny } from './errors.ts';
6
+ import { decodePngBytes } from './raster/png.ts';
7
+ import type { Raster } from './raster/types.ts';
8
+ import type { SetupPainting } from './setuppainting.ts';
9
+
10
+ export function readSetupBackdrop(declared: SetupPainting, requirements: string, source: Raster | null): Raster | undefined {
11
+ const plate = declared.backdrop;
12
+ if (plate === undefined) return undefined;
13
+ const fail = (detail: string): never => {
14
+ refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_BACKDROP', object: 'setup_painting.backdrop', detail }]);
15
+ throw new Error('unreachable');
16
+ };
17
+ let bytes: Uint8Array;
18
+ try { bytes = new Uint8Array(readFileSync(resolve(dirname(requirements), plate.path))); }
19
+ catch { return fail(`cannot read declared PNG "${plate.path}"; requires the authored opaque backdrop, not a guessed colour`); }
20
+ const hash = createHash('sha256').update(bytes).digest('hex');
21
+ if (hash !== plate.sha256) return fail(`PNG "${plate.path}" has sha256 ${hash}; requires declared ${plate.sha256}`);
22
+ let raster: Raster;
23
+ try { raster = decodePngBytes(bytes, plate.path); }
24
+ catch { return fail(`"${plate.path}" is not a decodable PNG; requires an opaque backdrop on the original painting grid`); }
25
+ if (source === null || raster.width !== source.width || raster.height !== source.height) return fail(`PNG "${plate.path}" is ${raster.width}x${raster.height}; requires the original source painting grid ${source === null ? '(no source)' : `${source.width}x${source.height}`}, without fitting`);
26
+ if (raster.data.some((v, i) => i % 4 === 3 && v !== 255)) return fail(`PNG "${plate.path}" contains nonopaque pixels; requires alpha 255 throughout`);
27
+ return raster;
28
+ }
@@ -16,6 +16,8 @@ export interface SetupPainting {
16
16
  * refused by name (SETUP_PAINTING_REQUIRED_LIMIT) instead of reported as
17
17
  * SKIP. Absent or false: the pre-#943 reading, unchanged. */
18
18
  required?: boolean;
19
+ /** Opaque plate on the original painting grid; path relative to requirements. */
20
+ backdrop?: { path: string; sha256: string };
19
21
  /** A pixel counts when its maximum RGB difference is strictly greater. */
20
22
  rgb_tolerance: number;
21
23
  regions: SetupPaintingRegion[];
@@ -33,8 +35,17 @@ export function setupPaintingProblems(value: unknown, at: string): Problem[] {
33
35
  for (const k of Object.keys(v)) if (!allowed.includes(k) && !free(k, v[k])) fail(`${where}.${k}`, `is unknown; fields are ${allowed.join(', ')}`);
34
36
  };
35
37
  if (!object(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: 'requires {rgb_tolerance, regions: [{name, box, max_pixels?}]}' }];
36
- keys(value, ['required', 'rgb_tolerance', 'regions'], at);
38
+ keys(value, ['required', 'backdrop', 'rgb_tolerance', 'regions'], at);
37
39
  if ('required' in value && typeof value.required !== 'boolean') fail(`${at}.required`, `is ${JSON.stringify(value.required)}; requires true (the check may not SKIP: every limit must be declared) or false (the same as omitting it)`);
40
+ if ('backdrop' in value) {
41
+ const b = value.backdrop, where = `${at}.backdrop`;
42
+ if (!object(b)) fail(where, 'requires {path, sha256} for an opaque PNG on the original painting grid');
43
+ else {
44
+ keys(b, ['path', 'sha256'], where);
45
+ if (typeof b.path !== 'string' || b.path.trim() === '') fail(`${where}.path`, 'requires a nonempty PNG path relative to the requirements file');
46
+ if (typeof b.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(b.sha256)) fail(`${where}.sha256`, 'requires the lowercase SHA-256 of the PNG bytes');
47
+ }
48
+ }
38
49
  // Issue #943: under required: true an absent limit is not a SKIP to report but a refusal naming the field.
39
50
  const required = value.required === true;
40
51
  const missing = (where: string, needed: string): void => {
@@ -81,8 +92,9 @@ export interface SetupPaintingLine {
81
92
  status: 'PASS' | 'FAIL' | 'SKIP';
82
93
  instrument: 'rigc renderer setup textured pieces';
83
94
  registration: 'declared stage grid, scale 1, no fitting';
84
- compositing: 'source-over white, RGBA8';
85
- figure: 'source figureSilhouette union rendered alpha > 0';
95
+ compositing: 'source-over white, RGBA8' | 'source-over declared backdrop, RGBA8';
96
+ backdrop?: { path: string; sha256: string };
97
+ figure: 'source figureSilhouette union rendered alpha > 0' | 'source figureSilhouette union rendered alpha > 0 union source-backdrop RGB difference > tolerance';
86
98
  ownership: 'last actual drawn piece with sampled alpha > 0; uncovered explicit';
87
99
  rgb_tolerance: number;
88
100
  regions: SetupPaintingReading[];
@@ -95,12 +107,13 @@ export const UNDECLARED_SETUP_PAINTING_REASON = 'setup_painting is undeclared; s
95
107
 
96
108
  /** Pixel metric only. Caller supplies the actual renderer's textured setup and
97
109
  * its per-pixel drawn-piece owners on precisely the source grid. RGB under
98
- * zero alpha is ignored: both observations are source-over white first.
110
+ * zero alpha is ignored: both observations are source-over the declared opaque
111
+ * backdrop, or white when absent.
99
112
  * Region totals count each pixel once, even with translucent contributors.
100
113
  */
101
114
  export function setupPaintingLine(
102
115
  declared: SetupPainting, source: Raster, setup: Raster, figure: Uint8Array,
103
- owners: Int32Array, slots: readonly string[], problems: Problem[],
116
+ owners: Int32Array, slots: readonly string[], problems: Problem[], backdrop?: Raster,
104
117
  ): SetupPaintingLine {
105
118
  refuseIfAny(setupPaintingProblems(declared, 'setup_painting'));
106
119
  refuseIfAny(setupPaintingBounds(declared, source.width, source.height));
@@ -108,15 +121,18 @@ export function setupPaintingLine(
108
121
  if (setup.width !== W || setup.height !== H || figure.length !== n || owners.length !== n || source.data.length !== n * 4 || setup.data.length !== n * 4 || owners.some(i => i < -1 || i >= slots.length)) {
109
122
  refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_MEASURED', object: SETUP_PAINTING_LINE, detail: 'requires actual setup pixels, figure domain and renderer owners on the declared native source grid; no fitted registration or guessed owner is substituted' }]);
110
123
  }
124
+ if ((declared.backdrop !== undefined) !== (backdrop !== undefined) || (backdrop !== undefined && (backdrop.width !== W || backdrop.height !== H || backdrop.data.length !== n * 4 || backdrop.data.some((v, i) => i % 4 === 3 && v !== 255)))) {
125
+ refuseIfAny([{ code: 'CHECK_SETUP_PAINTING_BACKDROP', object: 'setup_painting.backdrop', detail: 'requires the declared opaque backdrop pixels on the native stage grid; an absent or mismatched plate is never replaced with white' }]);
126
+ }
111
127
  const regions = declared.regions.map<SetupPaintingReading>(r => {
112
128
  const rows = new Map<string, { pixels: number; x0: number; y0: number; x1: number; y1: number }>();
113
129
  let count = 0;
114
130
  for (let y = r.box[1]; y < r.box[1] + r.box[3]; y++) for (let x = r.box[0]; x < r.box[0] + r.box[2]; x++) {
115
131
  const p = y * W + x, q = p * 4;
116
- if (figure[p] === 0 && setup.data[q + 3] === 0) continue;
132
+ if (figure[p] === 0 && setup.data[q + 3] === 0 && (backdrop === undefined || ![0, 1, 2].some(c => Math.abs(source.data[q + c] - backdrop.data[q + c]) > declared.rgb_tolerance))) continue;
117
133
  let delta = 0;
118
134
  for (let c = 0; c < 3; c++) {
119
- const white = (image: Raster): number => Math.round(image.data[q + c] * image.data[q + 3] / 255 + 255 * (1 - image.data[q + 3] / 255));
135
+ const white = (image: Raster): number => Math.round(image.data[q + c] * image.data[q + 3] / 255 + (backdrop?.data[q + c] ?? 255) * (1 - image.data[q + 3] / 255));
120
136
  delta = Math.max(delta, Math.abs(white(source) - white(setup)));
121
137
  }
122
138
  if (delta <= declared.rgb_tolerance) continue;
@@ -134,7 +150,7 @@ export function setupPaintingLine(
134
150
  return result;
135
151
  });
136
152
  const noLimit = regions.some(r => r.status === 'SKIP');
137
- return { status: regions.some(r => r.status === 'FAIL') ? 'FAIL' : noLimit ? 'SKIP' : 'PASS', instrument: 'rigc renderer setup textured pieces', registration: 'declared stage grid, scale 1, no fitting', compositing: 'source-over white, RGBA8', figure: 'source figureSilhouette union rendered alpha > 0', ownership: 'last actual drawn piece with sampled alpha > 0; uncovered explicit', rgb_tolerance: declared.rgb_tolerance, regions,
153
+ return { status: regions.some(r => r.status === 'FAIL') ? 'FAIL' : noLimit ? 'SKIP' : 'PASS', instrument: 'rigc renderer setup textured pieces', registration: 'declared stage grid, scale 1, no fitting', compositing: backdrop === undefined ? 'source-over white, RGBA8' : 'source-over declared backdrop, RGBA8', ...(declared.backdrop === undefined ? {} : { backdrop: { ...declared.backdrop } }), figure: backdrop === undefined ? 'source figureSilhouette union rendered alpha > 0' : 'source figureSilhouette union rendered alpha > 0 union source-backdrop RGB difference > tolerance', ownership: 'last actual drawn piece with sampled alpha > 0; uncovered explicit', rgb_tolerance: declared.rgb_tolerance, regions,
138
154
  ...(noLimit ? { reason: 'a region has no declared max_pixels; it is measured as SKIP, never PASS' } : {}) };
139
155
  }
140
156
 
package/src/takelayers.ts CHANGED
@@ -9,6 +9,7 @@ import { PartsError, refuseIfAny, type Problem } from './errors.ts';
9
9
  import { dilate } from './raster/morph.ts';
10
10
  import { newMask, type Raster } from './raster/types.ts';
11
11
  import { readPng } from './raster/png.ts';
12
+ import type { TakeDeclaration } from './takeregion.ts';
12
13
  import type { TakenLayerSource } from './takesource.ts';
13
14
 
14
15
  export interface TakeLayers {
@@ -62,8 +63,8 @@ function supportOverlap(a: Layer, b: Layer): number {
62
63
  return px;
63
64
  }
64
65
  /** Apply every declaration together, so replacing all tied layers from one run can retain its known order. */
65
- export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single: boolean }, painting: Raster, cfg: Pick<EarlyConfig, 'seethrough' | 'depth'> & { assemble: { take?: TakeLayers[]; plan?: PlanEntry[] } }, configPath: string): { full: LayerSet; head: LayerSet; single: boolean; notes: string[] } {
66
- const declarations = cfg.assemble.take ?? [];
66
+ export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single: boolean }, painting: Raster, cfg: Pick<EarlyConfig, 'seethrough' | 'depth'> & { assemble: { take?: TakeDeclaration[]; plan?: PlanEntry[] } }, configPath: string): { full: LayerSet; head: LayerSet; single: boolean; notes: string[] } {
67
+ const declarations = (cfg.assemble.take ?? []).filter((item): item is TakeLayers => 'tags' in item);
67
68
  if (declarations.length === 0) return { ...runs, notes: [] };
68
69
  if (cfg.depth !== undefined || [...runs.full.layers, ...runs.head.layers].some(l => l.depthSheet !== undefined)) return refuse('ASSEMBLE_TAKE_DEPTH_SHEET', 'config.assemble.take', 'active scalar depth sheets have no mixed-run binding; disable the depth opt-in or assemble a separately bound run before taking layers (numeric depth_median ordering is retained)');
69
70
  const inputs = makeInputs(painting, cfg, 'take target painting');
@@ -0,0 +1,151 @@
1
+ /** Seeded, painting-matched additions to one planned part (#1000). */
2
+ import { dirname, resolve } from 'node:path';
3
+ import type { AssembleConfig, Run } from './config.ts';
4
+ import { PartsError, refuseIfAny } from './errors.ts';
5
+ import { editedRunImages, missingReceipts, type ReceiptLedger } from './editreceipt.ts';
6
+ import { makeInputs } from './inputs.ts';
7
+ import { OPAQUE_ALPHA_ABOVE, readLayers, type Layer, type LayerSet } from './layers.ts';
8
+ import { connectedComponents } from './raster/components.ts';
9
+ import { readPng } from './raster/png.ts';
10
+ import { resize } from './raster/resize.ts';
11
+ import { newMask, newRaster, type Raster } from './raster/types.ts';
12
+ import type { SetupPainting } from './setuppainting.ts';
13
+ import type { TakeLayers } from './takelayers.ts';
14
+
15
+ export interface TakeRegion {
16
+ run: Run;
17
+ run_dir: string;
18
+ st_input: string;
19
+ part: string;
20
+ /** One integer pixel on the target run canvas, x right and y down. */
21
+ seed: [number, number];
22
+ }
23
+ export type TakeDeclaration = TakeLayers | TakeRegion;
24
+ export interface RegionReading {
25
+ part: string;
26
+ run: Run;
27
+ run_dir: string;
28
+ pixels: number;
29
+ /** Run pixels, [left, top, width, height]. */
30
+ box: [number, number, number, number];
31
+ painting_match_share: number;
32
+ rgb_tolerance: number;
33
+ }
34
+ type Runs = { full: LayerSet; head: LayerSet; single: boolean; notes: string[] };
35
+
36
+ function refuse(code: string, object: string, detail: string): never {
37
+ throw new PartsError([{ code, object, detail }]);
38
+ }
39
+
40
+ /** Mirrors setupPaintingLine, src/setuppainting.ts:117-122. Agreement is
41
+ * checked through that function's public signature, including alpha rounding. */
42
+ export function regionPaintingDelta(source: Raster, donor: Raster, sourceOffset: number, donorOffset: number): number {
43
+ let delta = 0;
44
+ for (let channel = 0; channel < 3; channel++) {
45
+ const white = (image: Raster, offset: number): number => Math.round(
46
+ image.data[offset + channel] * image.data[offset + 3] / 255 + 255 * (1 - image.data[offset + 3] / 255),
47
+ );
48
+ delta = Math.max(delta, Math.abs(white(source, sourceOffset) - white(donor, donorOffset)));
49
+ }
50
+ return delta;
51
+ }
52
+
53
+ function offsetAt(layer: Layer, x: number, y: number): number | null {
54
+ if (x < layer.left || x >= layer.right || y < layer.top || y >= layer.bottom) return null;
55
+ return ((y - layer.top) * layer.pixels.width + x - layer.left) * 4;
56
+ }
57
+
58
+ /** Copy exactly the seed's 8-connected qualifying component. Inputs stay intact. */
59
+ export function deriveTakenRegion(base: Layer, donor: Layer, painting: Raster, seed: readonly [number, number], tolerance: number, at: string): { layer: Layer; reading: Omit<RegionReading, 'part' | 'run' | 'run_dir'> } {
60
+ const { width, height } = painting;
61
+ const qualifying = newMask(width, height);
62
+ let candidates = 0;
63
+ for (let y = donor.top; y < donor.bottom; y++) for (let x = donor.left; x < donor.right; x++) {
64
+ const donorOffset = offsetAt(donor, x, y)!;
65
+ const baseOffset = offsetAt(base, x, y);
66
+ if (baseOffset !== null && base.pixels.data[baseOffset + 3] >= 128) continue;
67
+ if (donor.pixels.data[donorOffset + 3] < 128) continue;
68
+ if (regionPaintingDelta(painting, donor.pixels, (y * width + x) * 4, donorOffset) > tolerance) continue;
69
+ qualifying.data[y * width + x] = 1;
70
+ candidates++;
71
+ }
72
+ if (candidates === 0) return refuse('ASSEMBLE_REGION_TAKE_EMPTY', at, 'derived 0 qualifying run pixels; base alpha < 128, donor alpha >= 128 and painting match within the declared setup_painting.rgb_tolerance are required');
73
+ const [sx, sy] = seed;
74
+ if (!Number.isSafeInteger(sx) || !Number.isSafeInteger(sy) || sx < 0 || sy < 0 || sx >= width || sy >= height || qualifying.data[sy * width + sx] !== 1) {
75
+ return refuse('ASSEMBLE_REGION_TAKE_SEED', at, `seed [${seed.join(', ')}] touches no qualifying component on the ${width}x${height} run canvas; an integer qualifying pixel is required`);
76
+ }
77
+ const components = connectedComponents(qualifying, 8);
78
+ const label = components.labels[sy * width + sx];
79
+ const stats = components.stats[label];
80
+ const left = Math.min(base.left, stats.left), top = Math.min(base.top, stats.top);
81
+ const right = Math.max(base.right, stats.left + stats.width), bottom = Math.max(base.bottom, stats.top + stats.height);
82
+ const pixels = newRaster(right - left, bottom - top);
83
+ for (let y = base.top; y < base.bottom; y++) for (let x = base.left; x < base.right; x++) {
84
+ const from = offsetAt(base, x, y)!;
85
+ pixels.data.set(base.pixels.data.subarray(from, from + 4), ((y - top) * pixels.width + x - left) * 4);
86
+ }
87
+ for (let y = stats.top; y < stats.top + stats.height; y++) for (let x = stats.left; x < stats.left + stats.width; x++) {
88
+ if (components.labels[y * width + x] !== label) continue;
89
+ const from = offsetAt(donor, x, y)!;
90
+ pixels.data.set(donor.pixels.data.subarray(from, from + 4), ((y - top) * pixels.width + x - left) * 4);
91
+ }
92
+ let opaquePx = 0;
93
+ for (let i = 3; i < pixels.data.length; i += 4) if (pixels.data[i] > OPAQUE_ALPHA_ABOVE) opaquePx++;
94
+ return {
95
+ layer: { ...base, pixels, left, top, right, bottom, opaquePx },
96
+ reading: { pixels: stats.area, box: [stats.left, stats.top, stats.width, stats.height], painting_match_share: 1, rgb_tolerance: tolerance },
97
+ };
98
+ }
99
+
100
+ /** Called beside whole-layer take, before assembly or any output emission. */
101
+ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleConfig, configPath: string, setup: SetupPainting | undefined, receipts: ReceiptLedger): Runs {
102
+ const declarations = (cfg.assemble.take ?? []).filter((item): item is TakeRegion => 'part' in item);
103
+ if (declarations.length === 0) return runs;
104
+ const tolerance = setup?.rgb_tolerance;
105
+ if (typeof tolerance !== 'number' || !Number.isSafeInteger(tolerance) || tolerance < 0 || tolerance > 255) {
106
+ return refuse('ASSEMBLE_REGION_TAKE_TOLERANCE', 'config.assemble.take', 'a region take requires --requirements declaring setup_painting.rgb_tolerance, the same authored integer check reads; no default is supplied');
107
+ }
108
+ if (cfg.depth !== undefined || [...runs.full.layers, ...runs.head.layers].some(layer => layer.depthSheet !== undefined)) {
109
+ return refuse('ASSEMBLE_REGION_TAKE_BINDING', 'config.assemble.take', 'active scalar depth sheets have no region donor binding; a run without active scalar depth is required');
110
+ }
111
+ const inputs = makeInputs(painting, cfg, 'region take painting');
112
+ const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
113
+ const notes = [...runs.notes];
114
+ const seen = new Set<string>();
115
+ for (const declaration of declarations) {
116
+ const at = `config.assemble.take part "${declaration.part}" from "${declaration.run_dir}"`;
117
+ const plan = cfg.assemble.plan.find(entry => entry[0] === declaration.part && entry[1] === declaration.run);
118
+ if (plan === undefined) return refuse('ASSEMBLE_REGION_TAKE_PART', at, `requires a plan part named "${declaration.part}" in target run "${declaration.run}"`);
119
+ const key = `${plan[1]}:${plan[2]}`;
120
+ if (seen.has(key) || (cfg.assemble.take ?? []).some(item => 'tags' in item && item.run === plan[1] && item.tags.includes(plan[2]))) {
121
+ return refuse('ASSEMBLE_REGION_TAKE_PART', at, `target ${key} is taken more than once; one region donor or whole-layer donor per target is required`);
122
+ }
123
+ seen.add(key);
124
+ const set = runs[declaration.run];
125
+ const donor = readLayers(resolve(dirname(resolve(configPath)), declaration.run_dir));
126
+ refuseIfAny(missingReceipts(receipts, editedRunImages(
127
+ { full: donor, head: donor, single: true },
128
+ `region take "${declaration.run}:${declaration.part}" donor "${declaration.run_dir}" `,
129
+ )));
130
+ const target = declaration.run === 'head' ? inputs.head : runs.single && set.canvas.w !== set.canvas.h ? painting : inputs.full;
131
+ let supplied: Raster;
132
+ try { supplied = readPng(resolve(dirname(resolve(configPath)), declaration.st_input)); }
133
+ catch { return refuse('ASSEMBLE_REGION_TAKE_GEOMETRY', at, `st_input "${declaration.st_input}" must name the original readable donor input PNG so its geometry can be checked`); }
134
+ if ((declaration.run === 'head' && runs.single) || target === null || donor.canvas.w !== set.canvas.w || donor.canvas.h !== set.canvas.h || supplied.width !== target.width || supplied.height !== target.height || supplied.data.some((byte, i) => byte !== target.data[i])) {
135
+ return refuse('ASSEMBLE_REGION_TAKE_GEOMETRY', at, `donor canvas ${donor.canvas.w}x${donor.canvas.h}, target canvas ${set.canvas.w}x${set.canvas.h}, donor input ${supplied.width}x${supplied.height}; equal canvases and exact target input RGBA are required to establish one crop; cropped donors await #973`);
136
+ }
137
+ const base = lists[declaration.run].find(layer => layer.name === plan[2]);
138
+ const material = donor.layers.find(layer => layer.name === plan[2]);
139
+ if (base === undefined || material === undefined) return refuse('ASSEMBLE_REGION_TAKE_PART', at, `tag "${plan[2]}" is absent from ${base === undefined ? 'the base' : 'the donor'} run; both must hold the plan part's tag`);
140
+ if (base.depthSheet !== undefined || material.depthSheet !== undefined || [...set.hidden, ...donor.hidden].some(layer => layer.of === plan[2])) {
141
+ return refuse('ASSEMBLE_REGION_TAKE_BINDING', at, 'hidden companions or active depth sheets require independent region bindings; a part without those companions is required');
142
+ }
143
+ const source = resize(target, set.canvas.w, set.canvas.h, 'lanczos3');
144
+ const derived = deriveTakenRegion(base, material, source, declaration.seed, tolerance, at);
145
+ lists[declaration.run] = lists[declaration.run].map(layer => layer === base ? derived.layer : layer);
146
+ const reading: RegionReading = { part: declaration.part, run: declaration.run, run_dir: declaration.run_dir, ...derived.reading };
147
+ notes.push(`region take ${reading.run}:${reading.part} from ${reading.run_dir}: ${reading.pixels} run px; box [${reading.box.join(', ')}] (left, top, width, height); painting-match share ${reading.painting_match_share}; RGB tolerance ${reading.rgb_tolerance}; seed [${declaration.seed.join(', ')}], 8-connected`);
148
+ }
149
+ const full = { ...runs.full, layers: lists.full };
150
+ return { full, head: runs.single ? full : { ...runs.head, layers: lists.head }, single: runs.single, notes };
151
+ }