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 +6 -0
- package/docs/AUTHORING.md +60 -3
- package/package.json +1 -1
- package/src/build.ts +3 -1
- package/src/check.ts +8 -5
- package/src/config.ts +24 -7
- package/src/setupbackdrop.ts +28 -0
- package/src/setuppainting.ts +24 -8
- package/src/takelayers.ts +3 -2
- package/src/takeregion.ts +151 -0
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.
|
|
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.
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
313
|
-
take?:
|
|
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?:
|
|
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}]
|
|
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
|
+
}
|
package/src/setuppainting.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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?:
|
|
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
|
+
}
|