rig-parts 2.7.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
@@ -656,7 +656,7 @@ some layers, can supply those tags without editing either run directory:
656
656
  }]
657
657
  ```
658
658
 
659
- 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`
660
660
  is the **target** `full` or `head` run; `head` requires `--head`. `run_dir` and
661
661
  `st_input` resolve relative to the config, for assemble, build and
662
662
  `assemble --propose-plan` alike. Each target run/tag may be selected once. Empty
@@ -735,6 +735,43 @@ identity an executable check on supplied original inputs instead of trusting
735
735
  that claim or hand-editing `layers.json`. Seed and painting selection remain
736
736
  the author's decisions.
737
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
+
738
775
  ### Editor receipts (`assemble.editor_receipts`, issue #946)
739
776
 
740
777
  An image edited in code — a layer re-segmented by a script, a patch
@@ -4982,6 +5019,13 @@ those settings. Changing a config seed alone cannot change a no-config run.
4982
5019
  | <a id="ASSEMBLE_TAKE_SUPPORT"></a>`ASSEMBLE_TAKE_SUPPORT` | selected layer has zero alpha > 8 pixels | choose a nonempty donor layer |
4983
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 |
4984
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 |
4985
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 |
4986
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 |
4987
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 |
@@ -5353,6 +5397,7 @@ documents do not ship). Each is an input this package built; none is an author's
5353
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 |
5354
5398
  | <a id="CHECK_SOURCE_PNG"></a>`CHECK_SOURCE_PNG` | `--source` names a file that does not read as a PNG | a PNG |
5355
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 |
5356
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 |
5357
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 |
5358
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) |
@@ -6065,7 +6110,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
6065
6110
  | line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
6066
6111
  | --- | --- | --- | --- | --- | --- |
6067
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` |
6068
- | <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 |
6069
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 |
6070
6115
 
6071
6116
  The figures are assemble's by construction, not a second definition beside them;
@@ -6669,7 +6714,19 @@ substituted. Source PNG readers preserve straight RGBA. This optional check
6669
6714
  first composites the painting over opaque white at its **original source
6670
6715
  resolution**, rounding each RGB channel to RGBA8, then passes that opaque
6671
6716
  image through the existing `sourceInRig` Lanczos3 resampling onto the stage.
6672
- 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
6673
6730
  ordering excludes hidden RGB from both resampling taps and figure detection;
6674
6731
  the global `sourceInRig` and legacy checks retain their existing RGB semantics.
6675
6732
  The figure domain is the union of rendered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "2.7.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/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';
@@ -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);
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
2
  import { GROUNDS, type Ground, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
3
- import type { TakeLayers } from './takelayers.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';
@@ -309,8 +309,8 @@ export interface Assemble {
309
309
  project?: ProjectRule;
310
310
  /** Optional per-character painting ground (issue #996); absent, `plain`. Read by `visible` only. */
311
311
  ground?: Ground;
312
- /** Verified named replacements from another See-through run (#866). */
313
- take?: TakeLayers[];
312
+ /** Whole-layer replacements (#866) or seeded, painting-matched region takes (#1000). */
313
+ take?: TakeDeclaration[];
314
314
  /** Issue #946: receipts for images edited in an image editor; absent, no file is read and no byte moves. */
315
315
  editor_receipts?: EditorReceipts;
316
316
  /** Rig pixels per source pixel. */
@@ -1631,7 +1631,7 @@ export interface EarlyConfig {
1631
1631
  depth?: DepthConfig;
1632
1632
  key: string;
1633
1633
  seethrough: SeeThrough;
1634
- assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; ground?: Ground; 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[] };
1635
1635
  }
1636
1636
 
1637
1637
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -2455,15 +2455,32 @@ function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readon
2455
2455
  });
2456
2456
  }
2457
2457
 
2458
- function checkTakeLayers(c: Check, value: Json): void {
2458
+ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): void {
2459
2459
  const at = 'config.assemble.take';
2460
2460
  if (!c.array(at, value, false)) return;
2461
2461
  const seen = new Set<string>();
2462
2462
  (value as Json[]).forEach((v, i) => {
2463
- 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'], []);
2464
2468
  if (o === null) return;
2465
2469
  if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
2466
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
+ }
2467
2484
  if (!c.array(p + '.tags', o.tags, true)) return;
2468
2485
  (o.tags as Json[]).forEach((tag, j) => {
2469
2486
  const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
@@ -2559,7 +2576,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
2559
2576
  }
2560
2577
  checkKeepUnderOccluders(c, a, parts);
2561
2578
  if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
2562
- if ('take' in a) checkTakeLayers(c, a.take);
2579
+ if ('take' in a) checkTakeLayers(c, a.take, parts);
2563
2580
  if ('editor_receipts' in a) checkEditorReceipts(c, a.editor_receipts);
2564
2581
  if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
2565
2582
  (a.extend_below_crop as Json[]).forEach((entry, i) => {
@@ -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
+ }