rig-parts 2.7.0 → 2.8.1
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 +87 -6
- package/package.json +1 -1
- package/src/build.ts +3 -1
- package/src/check.ts +47 -16
- package/src/config.ts +65 -11
- package/src/meshdraft.ts +6 -22
- 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) |
|
|
@@ -5406,7 +5451,7 @@ documents do not ship). Each is an input this package built; none is an author's
|
|
|
5406
5451
|
| <a id="BUILD_REQUIREMENTS_CONFLICT"></a>`BUILD_REQUIREMENTS_CONFLICT` | `build --requirements <file>` names a file with other bytes than the one `config.requirements` declares (issue #556): the detail names both, each resolved (the flag against the working directory, the declaration against the config's) with its sha256 — or `no such file`. Refused before assemble writes anything. The same file by both, or a copy of its bytes, is not refused: the declaration is read and recorded | drop the flag to build from the config's file, or point `config.requirements` at the flag's |
|
|
5407
5452
|
| <a id="BUILD_STAGE_REQUEST"></a>`BUILD_STAGE_REQUEST` | an internal build worker was given a missing or malformed request, an unknown stage, or incomplete runner, input, output or scratch paths; the worker refuses before running a stage. This is the internal process protocol, not a public command or option | invoke `rig-parts build` with its documented flags; if that command produces this refusal, report the named request defect rather than constructing or modifying a worker request |
|
|
5408
5453
|
| <a id="REQUIREMENTS_FILE"></a>`REQUIREMENTS_FILE` | `--requirements` names no file, or one that does not parse as a JSON object. Refused before anything is built (by `build`, before assemble) | the path of a `spine-parts-requirements/1` file |
|
|
5409
|
-
| <a id="REQUIREMENTS_FIELD"></a>`REQUIREMENTS_FIELD` | a field of the file is missing, of the wrong type, or not one the format reads: `spec`, `fps` (no default), `requirements`, each requirement's `name` (letters, digits, underscores; once each), `kind`, `animation` and its kind's fields — every bar among them (`within_px`, `fraction`, `tolerance`, `least_drive` above 0, `within_degrees` from 0 to 180, `lo_degrees` ≤ `hi_degrees`, `within_ratio` ≥ 1) — and each target's `bone`, `animation` and exactly one of `at` and `keys` (times strictly increasing), and `not_planted`, `not_swinging`, `rises` and `rests` when given (each a non-empty list of part names, each once; no part both in `not_swinging` and in `rises` or `rests`), and `may_fold` when given (a non-empty list of `{ "slot", "animation", "why" }`, every one a non-blank string, each pair once; issue #320), and `pinholes` when given (an object whose one field, `required`, is `true` or `false`; issue #944), `seam_over_motion` when given (the same shape; issue #941), and `boundary_colour` when given (an object whose one field, `max_step_excess`, is an integer from 0 through 255; issue #942). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing, as in the config | write the field; the bar is yours |
|
|
5454
|
+
| <a id="REQUIREMENTS_FIELD"></a>`REQUIREMENTS_FIELD` | a field of the file is missing, of the wrong type, or not one the format reads: `spec`, `fps` (no default), `requirements`, each requirement's `name` (letters, digits, underscores; once each), `kind`, `animation` and its kind's fields — every bar among them (`within_px`, `fraction`, `tolerance`, `least_drive` above 0, `within_degrees` from 0 to 180, `lo_degrees` ≤ `hi_degrees`, `within_ratio` ≥ 1) — and each target's `bone`, `animation` and exactly one of `at` and `keys` (times strictly increasing), and `not_planted`, `not_swinging`, `rises` and `rests` when given (each a non-empty list of part names, each once; no part both in `not_swinging` and in `rises` or `rests`), and `may_fold` when given (a non-empty list of `{ "slot", "animation", "why" }`, every one a non-blank string, each pair once; issue #320), and `pinholes` when given (an object whose one field, `required`, is `true` or `false`; issue #944), `seam_over_motion` when given (the same shape; issue #941), and `boundary_colour` when given (an object whose one field, `max_step_excess`, is an integer from 0 through 255; issue #942). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing, as in the config | write the field; the bar is yours. For a pair read only by `seam_over_motion.required`, declare `config.motion_seams` `[{part, neighbour}]` and omit a seam requirement. See the [carrier contract](#CONFIG_MOTION_SEAM) and [background over every frame](#background-over-every-frame-issue-941). |
|
|
5410
5455
|
| <a id="REQUIREMENTS_RESOLVES"></a>`REQUIREMENTS_RESOLVES` | a bone, an `ik` or `transform` constraint (by name and `constraint_type`, as rig-c resolves it), a slot, a mesh attachment or an animation the file names is not in `rig.json` or `motion.json`, or a seam's part or a `not_planted`, `not_swinging`, `rises` or `rests` name is not in `parts.json` — the detail lists the ones that are; or an animation whose name cannot be a directory. Refused before anything is built | the name as the rig spells it |
|
|
5411
5456
|
| <a id="REQUIREMENTS_CONSTRAINT_DRIVES"></a>`REQUIREMENTS_CONSTRAINT_DRIVES` | a `follow` names a bone its constraint does not constrain, or a property it does not drive: an `ik` drives `rotate` only; a `transform` drives `rotate` when a `to` names `rotate`, `translate` when one names `x` or `y` | the constraint's own bone and property |
|
|
5412
5457
|
| <a id="REQUIREMENTS_TARGET_PARENT"></a>`REQUIREMENTS_TARGET_PARENT` | a scene target's bone is the root or is not parented to it, or the root is not at rest at the origin, or the animation keys the root — a stage point is a bone's position only under a root that stands still at the origin | a bone under the root for the scene to place |
|
|
@@ -5779,7 +5824,7 @@ the two examples [observed]:
|
|
|
5779
5824
|
| <a id="BLINK_NO_HOLE"></a>`BLINK_NO_HOLE` | the setup pose with every `scaley` track on the eyewhite slots' bones held at its closed value, against the setup pose, at full size: pixels in the eyewhite box that show the page where the open eye had art | 0 px | 0 | 0 | no `eyewhite` part, or no `scaley` track on its bones goes below its first key |
|
|
5780
5825
|
| ″ (reported) | the same box: each closed-eye pixel's max-channel distance to the nearest colour the open eye's box holds — max, and pixels over 40 | none | 15; 0 | 11; 0 | as above |
|
|
5781
5826
|
| <a id="CHAIN_LAG"></a>`CHAIN_LAG` | `motion.json`'s rotate tracks read as sines (DFT of the keys: period, amplitude, phase) and arranged by the bone tree — a keyed bone's parent is its nearest keyed ancestor | every lag ≥ 0.001 cycle; amplitude non-decreasing down each unbranched chain — non-increasing down a chain that carries a part declared `rests` (issue #245) | lags 0.040 (neck to head) to 0.120; 12 chains | lags 0.040 to 0.100; 9 chains | no rotate track under another of the same period |
|
|
5782
|
-
| <a id="TIP_OVER_ROOT"></a>`TIP_OVER_ROOT` | each `handwear`/`bottomwear` part: how far the centroid of its art travels in the lower half of its box against the upper half — the art's AREA centroid inside each fixed half (cut at h/2), every art pixel (alpha above 8) carried through its attachment's posed triangles from the idle's `geometry.json` and clipped to the half exactly, rig px; `instrument: "geometry"` in the line (issue #118). Since issue #987 every posed vertex is first carried into the setup place of the bone the part hangs from (`hang_from`, below), so the halves travel against it; the row keeps the world reading as `world_root_rig_px`, `world_tip_rig_px`, `world_ratio`, and the line says `instrument: "geometry, carrier frame"`. A half that travels 0 at the line's precision is 0 | ratio ≥ 1.4725; a part declared `rests`, ratio ≤ 1 (issue #245) | `bottomwear` 3.222, `sleeves` 9.891 against `chest` (world 4.762) | `bottomwear` 5.393, `sleeves` 27.482 against `chest` (world 11.830) | no such part |
|
|
5827
|
+
| <a id="TIP_OVER_ROOT"></a>`TIP_OVER_ROOT` | each `handwear`/`bottomwear` part: how far the centroid of its art travels in the lower half of its box against the upper half — the art's AREA centroid inside each fixed half (cut at h/2), every art pixel (alpha above 8) carried through its attachment's posed triangles from the idle's `geometry.json` and clipped to the half exactly, rig px; `instrument: "geometry"` in the line (issue #118). Since issue #987 every posed vertex is first carried into the setup place of the bone the part hangs from (`hang_from`, below), so the halves travel against it; the row keeps the world reading as `world_root_rig_px`, `world_tip_rig_px`, `world_ratio`, and the line says `instrument: "geometry, carrier frame"`. A half that travels 0 at the line's precision is 0 | carrier-frame 0 / 0 FAILs; otherwise a link with max(root, tip) below 0.5 rig px is SKIP (issue #994); measured ratio ≥ 1.4725; a part declared `rests`, ratio ≤ 1 (issue #245) | `bottomwear` 3.222, `sleeves` 9.891 against `chest` (world 4.762) | `bottomwear` 5.393, `sleeves` 27.482 against `chest` (world 11.830) | no such part, or every applicable row is unmeasured, including sub-floor link motion after the zero guard |
|
|
5783
5828
|
| <a id="STILL_REGIONS_DARK"></a>`STILL_REGIONS_DARK` | from the idle's posed geometry (`geometry.json`), `instrument: "geometry"` in each half (issue #123). **Face:** the still set — the `face` slots, and every slot drawn after the first of them whose art overlaps a face part's at the setup pose, that the head bone alone carries (a region on it, or a mesh whose every weight is it) and that the idle does not key by slot (`deform`, `sequence`, a slot track: `driven_slots`). The head bone is the bone the `face` parts' slot rides in `rig.json`. Each still slot's art (alpha above 8), carried through its posed attachment and back through the head bone's setup-to-frame map on every idle frame, against its setup position: `max_rig_px` (and where, `at`, when it is a motion), `rms_rig_px`, per slot in `still_slots`. **Feet:** the `footwear` slots the same way in screen space — they ride the root, which the idle does not key, so the screen is their own frame. **Reported, no bar:** `crossing` — per slot drawn after the still set's first slot and not in it, how deep its art reaches into the region (`face` on top at the setup pose, clear of where `eyewhite`, `irides`, `eyelash`, `eyebrow` and `mouth` go in the head's frame; for the feet, `footwear` on top), `depth_rig_px`, and the most of it covered on one frame, `area_rig_px2` | each half's `max_rig_px` ≤ its `tolerance_rig_px`: nothing moves, up to the arithmetic's rounding, √2 × 2 × 35 × M² × ulp(C) (below) | face 4.202e-13 ≤ 2.286e-11; `hair_front` crossing 2.816 rig px deep, 89.395 rig px² (frame 47); feet 0 ≤ 2.814e-12 | face 4.418e-13 ≤ 2.286e-11 (`face`, `ear_l`, `ear_r`); `hair_front` 2.194 deep, 50.605 rig px² (frame 23); feet 0 ≤ 2.814e-12 | no `face` and no `footwear` part (one of the two absent leaves that half unmeasured; so do face parts on two slot bones, or idle frames with no `geometry.json`) |
|
|
5784
5829
|
| <a id="TEXTURE_STRETCH"></a>`TEXTURE_STRETCH` | every mesh triangle's three edges in every idle frame, read from the `geometry.json` the idle render writes (`rigc render --geometry`, rig-c 1.4.0 or later): skinned length over rest length, the rest being the setup pose's bones with no deform. The figure is the rig's worst max(ratio, 1/ratio); each mesh's largest and smallest ratio, with triangle, vertices, edge and frame, is the detail | max(ratio, 1/ratio) ≤ 1.926544 | 1.388: `hair_back` triangle 449, edge 42-43 at 0.720 in frame 26 (largest stretch 1.291, frame 2) | 1.227: `sleeves` triangle 169, edge 126-51 at 0.815 in frame 28 (largest stretch 1.196, frame 5) | the rig draws no mesh, or the render wrote no `geometry.json` |
|
|
5785
5830
|
| <a id="ANIMATIONS_DO_NOT_FOLD"></a>`ANIMATIONS_DO_NOT_FOLD` | every animation, the idle included, through `rigc render --folds` at 12 fps over the build with its physics stepping and, when the rig declares physics constraints, over the keyed build with them left out beside it (issue #363): the meshes with a triangle turned over against the setup pose on some frame, less those the requirements file's `may_fold` declares (below) | 0 undeclared folding meshes, and no `may_fold` entry that matches no fold | 0, over `idle` | 0, over `idle` and `hop`, 2 physics constraints on and off | never SKIP: written whenever `motion.json` holds an animation |
|
|
@@ -5788,6 +5833,30 @@ the two examples [observed]:
|
|
|
5788
5833
|
| <a id="LIMB_ONE_BONE"></a>`LIMB_ONE_BONE` | issue #349. Each part `parts.json` records as a limb segment's (`limb`), read off `rig.json` alone, nothing rendered: the bones its attachments are bound to — a mesh's weights, or its slot's bone for a region — against its segment's bone (under `config.turn`, that bone's leaf twin counts as the bone) | every limb part bound to its one bone alone, weight 1 | not written: no limbs | not written: no limbs | never: written only when `parts.json` holds a limb part, so the examples' `check.json` keep their keys |
|
|
5789
5834
|
| <a id="SEAM_OVER_RANGE"></a>`SEAM_OVER_RANGE` | issue #360. Each limb part `parts.json` records with a `range` (`config.limbs.<limb>.segments[k].range`): a copy of the rig with its physics constraints left out, its joint's bone keyed `rotate` to the range's min, its middle and its max (each angle once, rigc's sense, counter-clockwise on screen), one `rigc build --profile spine` and two `rigc render`s on one grid — one with the joint's own art drawn under the limb (the limb's parts at rest inside the cap's disc, one pixel in from every edge of what is opaque there, on the bone the joint's bone hangs from), one with it hidden (`--hide`); per pose, the seam figures of the second against the first. Issue #557: per pose also the **contour step**, a reading and never a bar — on the frame with the joint's art hidden, lines across the upper segment (perpendicular to its bone's direction at the setup pose, origin to the joint) one frame pixel apart, from one cap radius before the joint line to one past it; on each, from the axis outward on each side, the outline's extent (the first sample within 40 of the background, out to two radii); the step on a side is the largest change between adjacent lines, in rig px, `left` and `right` as the cap's `short_px` names them. A limb whose outline the bend keeps reads one frame pixel, 1 / s rig px (1.161 at the synthetic fixtures' s 0.861413); the unfilled two-layer knee of the selftest reads 11.609 on the opening side at ±15° and the filled one 1.161. The row prints it; `check.json` carries it (`step_px` on each pose) only for a joint whose cap record carries `short_px` | the rest-pose seam's: mean \|d\| ≤ 1.0 and ≤ 50 rig px over 40 at every pose; a `cap_shape: "disc"` or `"ball"` joint also 0 frame px over 40 (issue #598: the ball over its whole disc; the row says `shape`) | not written: no limbs | not written: no limbs | never: written only when `parts.json` holds a limb record with a `range`, so the examples' `check.json` keep their keys |
|
|
5790
5835
|
|
|
5836
|
+
**Small link motion (issue #994).** After the carrier-frame zero guard, a part on
|
|
5837
|
+
one of the links identified by the hang-bone walk is unmeasured when the larger of
|
|
5838
|
+
its unrounded root and tip travels is below **0.5 rig px**. Its row retains both travels, the ratio,
|
|
5839
|
+
the world reading and `travel_floor_rig_px: 0.5`, with `unmeasured` giving
|
|
5840
|
+
"below half a rig pixel: the motion does not show on the art's own grid". If every
|
|
5841
|
+
row is unmeasured the line is SKIP and retains those rows. In a mixed line the
|
|
5842
|
+
measured rows decide PASS or FAIL.
|
|
5843
|
+
|
|
5844
|
+
Half a rig pixel is the art-grid threshold fixed before measurement, taking one
|
|
5845
|
+
rig pixel as the grid unit. The larger-travel operand was amended after measuring
|
|
5846
|
+
the existing defect plants (issue #994). A root above the floor beside a still tip
|
|
5847
|
+
is measured by the ratio bar. The continuous geometry instrument still resolves the
|
|
5848
|
+
travel below it. Its three-place reporting quantum and its 12 fps time sampling
|
|
5849
|
+
do not establish this spatial threshold. A SKIP asks the author to increase the
|
|
5850
|
+
link's motion until either half reaches the threshold when swing is intended, or to
|
|
5851
|
+
retain the low-motion choice with an explicitly unmeasured swing reading. A part
|
|
5852
|
+
that rides its carrier whole still FAILs at carrier-frame 0 / 0, even if its world
|
|
5853
|
+
travel is nonzero. A `rests` declaration also keeps this structural failure, whose
|
|
5854
|
+
detail says to declare it in `not_swinging` if it is meant to ride rigidly. Declare
|
|
5855
|
+
a deliberately fitted garment in `not_swinging`.
|
|
5856
|
+
Translate-only deformation without a link keeps its existing ratio verdict,
|
|
5857
|
+
including the nonzero-root `rests` relation. At or above 0.5 for either half the
|
|
5858
|
+
link's existing ratio bar applies.
|
|
5859
|
+
|
|
5791
5860
|
**`TIP_OVER_ROOT` is measured from geometry, not pixels (issue #118).** Until #118 the
|
|
5792
5861
|
line rendered each part alone at `--max 640` and took, per half of its frame box, the
|
|
5793
5862
|
centroid of every pixel that was not the background; its fields were `root_px` and
|
|
@@ -6065,7 +6134,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
|
|
|
6065
6134
|
| line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
|
|
6066
6135
|
| --- | --- | --- | --- | --- | --- |
|
|
6067
6136
|
| <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 |
|
|
6137
|
+
| <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
6138
|
| <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
6139
|
|
|
6071
6140
|
The figures are assemble's by construction, not a second definition beside them;
|
|
@@ -6457,7 +6526,7 @@ is `{ "spec": "spine-parts-requirements/1", "fps": 12, "requirements": [], "not_
|
|
|
6457
6526
|
| `range` | `bone`, `lo_degrees`, `hi_degrees` | the bone's world rotation less its parent's (the world's, for the root), less the same at the setup pose, as a signed shortest angle: the least and the greatest, and their frames | — |
|
|
6458
6527
|
| `stretch` | `slot`, `attachment` (a mesh), `within_ratio` | `TEXTURE_STRETCH`'s own per-mesh measure (imported, not rewritten): the mesh's worst max(ratio, 1/ratio) over the frames; `held_by` names what the worst edge's two vertices hang on, as `CHECK_TEXTURE_STRETCH` does (issue #321) | a rest edge of length 0; no frame shows the mesh |
|
|
6459
6528
|
| `follow` | `constraint`, `constraint_type` (`ik` or `transform`), `bone`, `property` (`rotate` or `translate`), `fraction`, `tolerance`, `least_drive` | below | no frame's drive reaches `least_drive` (the line prints the largest drive there was) |
|
|
6460
|
-
| `seam` | `part`, `neighbour` (two part names: each is drawn by the slot of its name), `within_px` | below | no seam pair reads on any frame (each pixel off its attachment's rest geometry, or the slot shows nothing) |
|
|
6529
|
+
| `seam` | `part`, `neighbour` (two part names: each is drawn by the slot of its name), `within_px` (required for parting distance). For `seam_over_motion.required` pair coverage alone, declare `config.motion_seams` `[{part, neighbour}]` and omit a seam requirement. See the [carrier contract](#CONFIG_MOTION_SEAM) and [background over every frame](#background-over-every-frame-issue-941) | below | no seam pair reads on any frame (each pixel off its attachment's rest geometry, or the slot shows nothing) |
|
|
6461
6530
|
|
|
6462
6531
|
A `contact` held against its own bone and point, and an `aim` at a point of the aimed bone itself, read 0 by construction (or, an aim at its own origin, no line at all), so they are refused before anything is built (`REQUIREMENTS_SELF_TARGET`, issue #540); a bone's tip against its own origin stays a contact, measured.
|
|
6463
6532
|
|
|
@@ -6669,7 +6738,19 @@ substituted. Source PNG readers preserve straight RGBA. This optional check
|
|
|
6669
6738
|
first composites the painting over opaque white at its **original source
|
|
6670
6739
|
resolution**, rounding each RGB channel to RGBA8, then passes that opaque
|
|
6671
6740
|
image through the existing `sourceInRig` Lanczos3 resampling onto the stage.
|
|
6672
|
-
The rendered setup is composited over white on the native stage grid.
|
|
6741
|
+
The rendered setup is composited over white on the native stage grid.
|
|
6742
|
+
For a full-frame painting whose figure was painted over an opaque background,
|
|
6743
|
+
declare `setup_painting.backdrop: {"path": "backdrop.png", "sha256": "<64 lowercase hex digits>"}`.
|
|
6744
|
+
The PNG path resolves relative to the requirements file. The exact bytes read
|
|
6745
|
+
are hashed before decoding; the plate must be opaque everywhere and match the
|
|
6746
|
+
original painting dimensions. It is never fitted or inferred from the art.
|
|
6747
|
+
With the declaration, both observations use that plate instead of white:
|
|
6748
|
+
the painting is flattened over it before resampling, and the plate is
|
|
6749
|
+
resampled onto the stage before the rendered setup is composited over it.
|
|
6750
|
+
The report carries the declared path and verified hash. Pixels where painting
|
|
6751
|
+
RGB differs from the plate above the authored tolerance also enter the figure
|
|
6752
|
+
domain, even when no setup piece draws there. Missing material remains counted.
|
|
6753
|
+
Without the declaration, report bytes and the white comparator are unchanged. This
|
|
6673
6754
|
ordering excludes hidden RGB from both resampling taps and figure detection;
|
|
6674
6755
|
the global `sourceInRig` and legacy checks retain their existing RGB semantics.
|
|
6675
6756
|
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.1",
|
|
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';
|
|
@@ -235,6 +236,8 @@ export const BLINK_PATCH_LEVEL = SEAM_PX_LEVEL;
|
|
|
235
236
|
/** The smallest lag the reading resolves, in cycles: phases are written to three places. */
|
|
236
237
|
export const CHAIN_LAG_MIN_STEP = 0.001;
|
|
237
238
|
export const TIP_RATIO_FLOOR = 1.4725;
|
|
239
|
+
/** Issue #994 sets half a rig pixel as the floor on the larger half travel. */
|
|
240
|
+
export const TIP_TRAVEL_FLOOR = 0.5;
|
|
238
241
|
/**
|
|
239
242
|
* Issue #245: the bar a part the scene declares `rests` is held to — its tip
|
|
240
243
|
* half travels no further than its root half, tip / root <= 1, read at the
|
|
@@ -1940,7 +1943,7 @@ export function officialRenderer(): OfficialRenderer {
|
|
|
1940
1943
|
|
|
1941
1944
|
/** Observe the actual setup on the declared stage grid. No frame-zero motion,
|
|
1942
1945
|
* fitted alignment, flat recomposite or duplicate core validation. */
|
|
1943
|
-
export function measureSetupPainting(build: string, stage: StageBox, source: Raster, declared: SetupPainting, problems: Problem[]): SetupPaintingLine {
|
|
1946
|
+
export function measureSetupPainting(build: string, stage: StageBox, source: Raster, declared: SetupPainting, problems: Problem[], backdrop?: Raster): SetupPaintingLine {
|
|
1944
1947
|
const api = officialRenderer();
|
|
1945
1948
|
const skeleton = join(build, 'skeleton.json'), atlas = join(build, 'skeleton.atlas');
|
|
1946
1949
|
const candidate = api.loadCandidate({ skeletonText: readFileSync(skeleton, 'utf8'), atlasText: readFileSync(atlas, 'utf8'), atlasDir: build, label: 'setup versus painting' }, { skeleton, atlas }, undefined);
|
|
@@ -1954,17 +1957,17 @@ export function measureSetupPainting(build: string, stage: StageBox, source: Ras
|
|
|
1954
1957
|
const project = api.projector(vp), owners = new Int32Array(W * H).fill(-1);
|
|
1955
1958
|
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
1959
|
// sourceInRig intentionally reads opaque RGB, discarding source alpha before
|
|
1957
|
-
// resizing. This optional comparison declares source-over white
|
|
1960
|
+
// resizing. This optional comparison declares source-over the plate or white:
|
|
1958
1961
|
// flatten at ORIGINAL source resolution first, so hidden RGB cannot reach
|
|
1959
1962
|
// Lanczos taps or the figure silhouette. Do not change the legacy helper.
|
|
1960
1963
|
const whiteSource = newRaster(source.width, source.height);
|
|
1961
1964
|
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));
|
|
1965
|
+
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
1966
|
whiteSource.data[p * 4 + 3] = 255;
|
|
1964
1967
|
}
|
|
1965
1968
|
const painting = sourceInRig(whiteSource, W, H);
|
|
1966
1969
|
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);
|
|
1970
|
+
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
1971
|
}
|
|
1969
1972
|
|
|
1970
1973
|
/** One decoder/poser lifetime per state; retain only finite bounds and identities, never atlas pages or rasters. */
|
|
@@ -6399,6 +6402,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6399
6402
|
return null;
|
|
6400
6403
|
}
|
|
6401
6404
|
};
|
|
6405
|
+
let setupBackdrop: Raster | undefined;
|
|
6402
6406
|
// Issue #538: the schedule is read once, first, by rigc's own reader; refused, nothing is built or written.
|
|
6403
6407
|
const env = environment === undefined ? null : take(() => readCheckEnvironment(environment));
|
|
6404
6408
|
// Issue #590: the steady-wind band spec is read and resolved before anything is built or written.
|
|
@@ -6423,6 +6427,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6423
6427
|
if (file.setup_painting !== undefined) take(() => {
|
|
6424
6428
|
refuseIfAny(setupPaintingBounds(file.setup_painting as SetupPainting, ready.stage.width, ready.stage.height));
|
|
6425
6429
|
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' }]);
|
|
6430
|
+
setupBackdrop = readSetupBackdrop(file.setup_painting as SetupPainting, requirements, ready.source?.painting ?? null);
|
|
6426
6431
|
});
|
|
6427
6432
|
// Issue #942: a declared boundary_colour reads parts.json's pieces and the idle; without either it cannot be taken.
|
|
6428
6433
|
if (file.boundary_colour !== undefined) take(() => refuseIfAny([
|
|
@@ -7018,7 +7023,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
7018
7023
|
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
7024
|
if (sr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still)', sr, sr.out.split('\n').filter((l) => l.includes('FAIL'))));
|
|
7020
7025
|
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);
|
|
7026
|
+
if (reqFile?.setup_painting !== undefined && inp.source !== null) setupPainting = measureSetupPainting(join(stillDir, 'build'), inp.stage, inp.source.painting, reqFile.setup_painting, problems, setupBackdrop);
|
|
7022
7027
|
if (fixedSkeleton !== undefined) {
|
|
7023
7028
|
const geometryPath = join(stillSet.dir, GEOMETRY_FILE);
|
|
7024
7029
|
const geometry = readGeometry(geometryPath, stillSet.written);
|
|
@@ -7905,6 +7910,11 @@ export const TIP_INSTRUMENT = 'geometry, carrier frame';
|
|
|
7905
7910
|
* nothing or the walk leaves the tree, and the part is then read in the world.
|
|
7906
7911
|
*/
|
|
7907
7912
|
export function hangBone(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): string | null {
|
|
7913
|
+
return tipHang(rig, motion, part).bone;
|
|
7914
|
+
}
|
|
7915
|
+
|
|
7916
|
+
/** The same walk also identifies a link below the carrier for the art-grid policy. */
|
|
7917
|
+
function tipHang(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): { bone: string | null; onLink: boolean } {
|
|
7908
7918
|
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
7909
7919
|
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
7910
7920
|
const upFrom = (b: string): string[] => {
|
|
@@ -7913,10 +7923,10 @@ export function hangBone(rig: Record<string, unknown>, motion: Record<string, un
|
|
|
7913
7923
|
return path;
|
|
7914
7924
|
};
|
|
7915
7925
|
const bound = [...boundBones(rig, part)];
|
|
7916
|
-
if (bound.length === 0) return null;
|
|
7926
|
+
if (bound.length === 0) return { bone: null, onLink: false };
|
|
7917
7927
|
const paths = bound.map(upFrom);
|
|
7918
7928
|
const common = paths[0].find((b) => paths.every((p) => p.includes(b)));
|
|
7919
|
-
if (common === undefined) return null;
|
|
7929
|
+
if (common === undefined) return { bone: null, onLink: false };
|
|
7920
7930
|
// A link: a bone of a chain CHAIN_LAG reads, or a bone a physics constraint drives. The rig stage keys a chain's
|
|
7921
7931
|
// `<link>_ctl` and binds the mesh to its child the physics drives, and a physics-only chain has no keys at all, so
|
|
7922
7932
|
// both are walked up through until the body bone that carries them.
|
|
@@ -7964,7 +7974,8 @@ export function hangBone(rig: Record<string, unknown>, motion: Record<string, un
|
|
|
7964
7974
|
seen.add(at);
|
|
7965
7975
|
at = parent.get(at) ?? null;
|
|
7966
7976
|
}
|
|
7967
|
-
|
|
7977
|
+
const onLink = paths.some((path) => path.slice(0, at === null ? path.length : path.indexOf(at)).some((b) => links.has(b)));
|
|
7978
|
+
return { bone: at, onLink };
|
|
7968
7979
|
}
|
|
7969
7980
|
|
|
7970
7981
|
/**
|
|
@@ -8029,7 +8040,8 @@ export function carrierFrame(geo: IdleGeometry | null, bone: string | null): ((i
|
|
|
8029
8040
|
* body's carry is not scored as a tip that moves less; the row names that bone
|
|
8030
8041
|
* (`hang_from`) and keeps the world reading (`world_*`). A half that travels 0
|
|
8031
8042
|
* at the line's precision is 0, so a part that rides its carrier whole reads
|
|
8032
|
-
* 0 / 0 and fails, not a ratio of round-off.
|
|
8043
|
+
* 0 / 0 and fails, not a ratio of round-off. Issue #994 applies the fixed
|
|
8044
|
+
* 0.5 rig px art-grid policy to max(root, tip) of a link part after this guard.
|
|
8033
8045
|
*/
|
|
8034
8046
|
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
|
|
8035
8047
|
const swing = partsTagged(inp.parts, SWING_TAGS);
|
|
@@ -8064,6 +8076,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
8064
8076
|
}
|
|
8065
8077
|
const rows: Array<Record<string, unknown>> = [];
|
|
8066
8078
|
let measured = 0;
|
|
8079
|
+
let belowGrid = 0;
|
|
8067
8080
|
let failed = false;
|
|
8068
8081
|
for (const p of swing) {
|
|
8069
8082
|
if (excepted.not_swinging.includes(p.name)) {
|
|
@@ -8082,7 +8095,8 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
8082
8095
|
const shown = poses.map((f) => f.get(p.name) ?? null);
|
|
8083
8096
|
const w = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
|
|
8084
8097
|
// Issue #987: the halves are read in the frame of the bone the part hangs from; the world reading rides beside them.
|
|
8085
|
-
const
|
|
8098
|
+
const hanging = tipHang(inp.rig, inp.motion, p.name);
|
|
8099
|
+
const hangFound = hanging.bone;
|
|
8086
8100
|
const frameOf = carrierFrame(idleGeo, hangFound);
|
|
8087
8101
|
const hang = frameOf === null ? null : hangFound;
|
|
8088
8102
|
const toCarrier = frameOf ?? ((_i: number, posed: readonly number[]) => posed.slice());
|
|
@@ -8095,19 +8109,27 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
8095
8109
|
rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${half} half of its box holds no art its attachment draws` });
|
|
8096
8110
|
continue;
|
|
8097
8111
|
}
|
|
8098
|
-
measured++;
|
|
8099
8112
|
// Issue #987: a half that does not move against its carrier, read at the line's precision, is 0 and not the
|
|
8100
8113
|
// arithmetic's residue — a rigid part reads 0 / 0, not a ratio of two round-off figures.
|
|
8101
8114
|
const t = { root: round3(c.root) === 0 ? 0 : c.root, tip: round3(c.tip) === 0 ? 0 : c.tip };
|
|
8102
8115
|
const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
|
|
8103
8116
|
const worldRatio = w.root > 0 ? round3(w.tip / w.root) : null;
|
|
8104
8117
|
// Issue #245: a resting tip is held to the opposite relation — no further than its root, at the line's precision.
|
|
8105
|
-
const
|
|
8118
|
+
const rigid = t.root === 0 && t.tip === 0;
|
|
8119
|
+
const ok = !rigid && (rests ? (ratio === null ? t.tip === 0 : ratio <= TIP_REST_RATIO_CEILING) : ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR);
|
|
8106
8120
|
rows.push({ part: p.name, ...said, hang_from: hang, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio, world_root_rig_px: round3(w.root), world_tip_rig_px: round3(w.tip), world_ratio: worldRatio });
|
|
8121
|
+
// Structural 0 / 0 stays a failure before the link-only magnitude policy (issue #994).
|
|
8122
|
+
if (!rigid && hanging.onLink && Math.max(c.root, c.tip) < TIP_TRAVEL_FLOOR) {
|
|
8123
|
+
belowGrid++;
|
|
8124
|
+
rows[rows.length - 1] = { ...rows[rows.length - 1], travel_floor_rig_px: TIP_TRAVEL_FLOOR,
|
|
8125
|
+
unmeasured: "below half a rig pixel: the motion does not show on the art's own grid" };
|
|
8126
|
+
continue;
|
|
8127
|
+
}
|
|
8128
|
+
measured++;
|
|
8107
8129
|
const against = hang === null ? 'in the world' : `against \`${hang}\`, the bone it hangs from`;
|
|
8108
8130
|
const inWorld = hang === null ? '' : `; in the world ${round3(w.tip)} and ${round3(w.root)}, ratio ${worldRatio ?? 'undefined'}`;
|
|
8109
8131
|
const travels = `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px ${against}`;
|
|
8110
|
-
if (!ok && rests) {
|
|
8132
|
+
if (!ok && rests && !rigid) {
|
|
8111
8133
|
failed = true;
|
|
8112
8134
|
problems.push({
|
|
8113
8135
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
@@ -8138,7 +8160,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
8138
8160
|
problems.push({
|
|
8139
8161
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
8140
8162
|
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
|
|
8141
|
-
detail: `${travels} (ratio ${ratio ?? (hang === null ? 'undefined: nothing moves' : `undefined: nothing moves against \`${hang}\``)}${inWorld}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}`,
|
|
8163
|
+
detail: `${travels} (ratio ${ratio ?? (hang === null ? 'undefined: nothing moves' : `undefined: nothing moves against \`${hang}\``)}${inWorld}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}${rigid && rests ? '; declare it in `not_swinging` if it is meant to ride rigidly' : ''}`,
|
|
8142
8164
|
});
|
|
8143
8165
|
}
|
|
8144
8166
|
}
|
|
@@ -8147,8 +8169,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
8147
8169
|
...(excepted.rises.length === 0 ? {} : { declared_rises: swing.filter((p) => excepted.rises.includes(p.name)).map((p) => p.name) }),
|
|
8148
8170
|
...(excepted.rests.length === 0 ? {} : { declared_rests: swing.filter((p) => excepted.rests.includes(p.name)).map((p) => p.name) }),
|
|
8149
8171
|
};
|
|
8150
|
-
|
|
8151
|
-
|
|
8172
|
+
const figures = { ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR,
|
|
8173
|
+
...(excepted.rests.length === 0 ? {} : { rest_ratio_ceiling: TIP_REST_RATIO_CEILING }) };
|
|
8174
|
+
if (measured === 0) {
|
|
8175
|
+
const reasons = rows.map((r) => {
|
|
8176
|
+
const travel = r.travel_floor_rig_px === undefined ? '' : `; root ${String(r.root_rig_px)} rig px, tip ${String(r.tip_rig_px)} rig px, floor ${String(r.travel_floor_rig_px)} rig px`;
|
|
8177
|
+
return `${String(r.part)}: ${String(r.unmeasured)}${travel}`;
|
|
8178
|
+
});
|
|
8179
|
+
return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${reasons.join('; ')}`,
|
|
8180
|
+
...(belowGrid === 0 ? {} : figures) };
|
|
8181
|
+
}
|
|
8182
|
+
return { status: failed ? 'FAIL' : 'PASS', ...figures };
|
|
8152
8183
|
}
|
|
8153
8184
|
|
|
8154
8185
|
/**
|
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. */
|
|
@@ -1434,6 +1434,41 @@ const DOORS = 'a project\'s own record goes under a key beginning "x-" (any JSON
|
|
|
1434
1434
|
|
|
1435
1435
|
type Json = unknown;
|
|
1436
1436
|
|
|
1437
|
+
/** The loader's config-relative file fields, also used when relocating a config. */
|
|
1438
|
+
const CONFIG_FILE_FIELDS = {
|
|
1439
|
+
requirements: { path: ['requirements'], isFile: (_value: string) => true },
|
|
1440
|
+
animations: { path: ['motion', 'animations_from'], isFile: (_value: string) => true },
|
|
1441
|
+
skeleton: { path: ['generation', 'control', 'skeleton'], isFile: (value: string) => !isSkeletonName(value) },
|
|
1442
|
+
} as const;
|
|
1443
|
+
|
|
1444
|
+
function configFilePath(field: keyof typeof CONFIG_FILE_FIELDS, base: string, value: string): string | null {
|
|
1445
|
+
return CONFIG_FILE_FIELDS[field].isFile(value) ? resolve(base, value) : null;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** A file reference in a parsed config and its setter, excluding built-in names. */
|
|
1449
|
+
export interface ConfigFileReference {
|
|
1450
|
+
at: string;
|
|
1451
|
+
value: string;
|
|
1452
|
+
set: (value: string) => void;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** Enumerate the same file fields that the loader resolves, preserving invalid fields for its diagnostics. */
|
|
1456
|
+
export function configFileReferences(raw: unknown): ConfigFileReference[] {
|
|
1457
|
+
const out: ConfigFileReference[] = [];
|
|
1458
|
+
for (const field of Object.values(CONFIG_FILE_FIELDS)) {
|
|
1459
|
+
let parent = raw;
|
|
1460
|
+
for (const key of field.path.slice(0, -1)) parent = isPlainObject(parent) ? parent[key] : undefined;
|
|
1461
|
+
const key = field.path[field.path.length - 1];
|
|
1462
|
+
if (!isPlainObject(parent)) continue;
|
|
1463
|
+
const value = parent[key];
|
|
1464
|
+
if (typeof value !== 'string' || value === '' || !field.isFile(value)) continue;
|
|
1465
|
+
const target = parent;
|
|
1466
|
+
out.push({ at: field.path.join('.'), value, set: (next) => { target[key] = next; } });
|
|
1467
|
+
}
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
|
|
1437
1472
|
class Check {
|
|
1438
1473
|
readonly problems: Problem[] = [];
|
|
1439
1474
|
|
|
@@ -1631,7 +1666,7 @@ export interface EarlyConfig {
|
|
|
1631
1666
|
depth?: DepthConfig;
|
|
1632
1667
|
key: string;
|
|
1633
1668
|
seethrough: SeeThrough;
|
|
1634
|
-
assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; ground?: Ground; take?:
|
|
1669
|
+
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
1670
|
}
|
|
1636
1671
|
|
|
1637
1672
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1836,10 +1871,12 @@ export function cloneConfig<T extends object>(cfg: T): T {
|
|
|
1836
1871
|
function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
|
|
1837
1872
|
if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
|
|
1838
1873
|
const skel = gen.control.skeleton;
|
|
1839
|
-
if (typeof skel !== 'string' || skel === ''
|
|
1874
|
+
if (typeof skel !== 'string' || skel === '') return null;
|
|
1875
|
+
const file = configFilePath('skeleton', base, skel);
|
|
1876
|
+
if (file === null) return null;
|
|
1840
1877
|
const l = gen.latent;
|
|
1841
1878
|
const latent = Array.isArray(l) && l.length === 2 && l.every((n) => typeof n === 'number' && Number.isInteger(n) && n > 0) ? ([l[0], l[1]] as [number, number]) : null;
|
|
1842
|
-
return readControlPose(c,
|
|
1879
|
+
return readControlPose(c, file, latent);
|
|
1843
1880
|
}
|
|
1844
1881
|
|
|
1845
1882
|
/**
|
|
@@ -1992,7 +2029,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1992
2029
|
if ('turn' in t) checkTurn(c, t.turn, attached, names.bones, hasAltFace, parts, declaredSliders, meshParts, t.meshes, names.chains, names.parents);
|
|
1993
2030
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1994
2031
|
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1995
|
-
if ('requirements' in t && c.string('config.requirements', t.requirements) && base !== null) checkRequirementsFile(c,
|
|
2032
|
+
if ('requirements' in t && c.string('config.requirements', t.requirements) && base !== null) checkRequirementsFile(c, configFilePath('requirements', base, t.requirements) as string);
|
|
1996
2033
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1997
2034
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, base, from) : null;
|
|
1998
2035
|
if (animations !== null) {
|
|
@@ -2074,7 +2111,7 @@ function checkRequirementsFile(c: Check, file: string): void {
|
|
|
2074
2111
|
*/
|
|
2075
2112
|
function readAnimationsFrom(c: Check, base: string, from: string): AnimationsFrom | null {
|
|
2076
2113
|
const field = 'config.motion.animations_from';
|
|
2077
|
-
const file =
|
|
2114
|
+
const file = configFilePath('animations', base, from) as string;
|
|
2078
2115
|
const form = `{"spec": "${ANIMATIONS_FROM_SPEC}", "animations": {"<name>": {"duration": …, "tracks": [...]}}}`;
|
|
2079
2116
|
const shape = `a file in rig-c's motion-spec shape, ${form}, its path relative to the config's directory`;
|
|
2080
2117
|
const where = `names ${JSON.stringify(from)}, resolved against the config's directory ${base} (not the working directory) to ${file}`;
|
|
@@ -2455,15 +2492,32 @@ function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readon
|
|
|
2455
2492
|
});
|
|
2456
2493
|
}
|
|
2457
2494
|
|
|
2458
|
-
function checkTakeLayers(c: Check, value: Json): void {
|
|
2495
|
+
function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): void {
|
|
2459
2496
|
const at = 'config.assemble.take';
|
|
2460
2497
|
if (!c.array(at, value, false)) return;
|
|
2461
2498
|
const seen = new Set<string>();
|
|
2462
2499
|
(value as Json[]).forEach((v, i) => {
|
|
2463
|
-
const p = `${at}[${i}]
|
|
2500
|
+
const p = `${at}[${i}]`;
|
|
2501
|
+
const region = isPlainObject(v) && ('part' in v || 'seed' in v);
|
|
2502
|
+
const o = region
|
|
2503
|
+
? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], [])
|
|
2504
|
+
: c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
|
|
2464
2505
|
if (o === null) return;
|
|
2465
2506
|
if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
|
|
2466
2507
|
for (const k of ['run_dir', 'st_input']) c.string(`${p}.${k}`, o[k]);
|
|
2508
|
+
if (region) {
|
|
2509
|
+
if (c.string(`${p}.part`, o.part) && plan !== null && !plan.includes(o.part)) {
|
|
2510
|
+
c.fail('CONFIG_NAME_RESOLVES', `${p}.part`, `names "${o.part}"; a part named in assemble.plan is required`);
|
|
2511
|
+
}
|
|
2512
|
+
const key = `${String(o.run)}:part:${String(o.part)}`;
|
|
2513
|
+
if (seen.has(key)) c.fail('CONFIG_TAKE_UNIQUE', `${p}.part`, `names ${key} again; one donor per target part is required`);
|
|
2514
|
+
seen.add(key);
|
|
2515
|
+
const seed = o.seed;
|
|
2516
|
+
if (!(Array.isArray(seed) && seed.length === 2 && seed.every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0))) {
|
|
2517
|
+
c.fail('CONFIG_FIELD_TYPE', `${p}.seed`, 'requires [x, y], nonnegative safe integers in target-run canvas pixels');
|
|
2518
|
+
}
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2467
2521
|
if (!c.array(p + '.tags', o.tags, true)) return;
|
|
2468
2522
|
(o.tags as Json[]).forEach((tag, j) => {
|
|
2469
2523
|
const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
|
|
@@ -2559,7 +2613,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
|
|
|
2559
2613
|
}
|
|
2560
2614
|
checkKeepUnderOccluders(c, a, parts);
|
|
2561
2615
|
if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
|
|
2562
|
-
if ('take' in a) checkTakeLayers(c, a.take);
|
|
2616
|
+
if ('take' in a) checkTakeLayers(c, a.take, parts);
|
|
2563
2617
|
if ('editor_receipts' in a) checkEditorReceipts(c, a.editor_receipts);
|
|
2564
2618
|
if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
|
|
2565
2619
|
(a.extend_below_crop as Json[]).forEach((entry, i) => {
|
package/src/meshdraft.ts
CHANGED
|
@@ -67,7 +67,7 @@ import { MeshReductionError } from '@rig-c/core/mesh';
|
|
|
67
67
|
import { explicitFrameId, idleSchedule } from './automotion.ts';
|
|
68
68
|
import { checkStage, gateThroughRigc, type Log, RIGC_MODEL_DOCUMENT, rigStage } from './build.ts';
|
|
69
69
|
import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner } from './check.ts';
|
|
70
|
-
import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
|
|
70
|
+
import { type CharacterConfig, configFileReferences, type ConfigFileReference, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
|
|
71
71
|
import { PartsError, type Problem, problemLine } from './errors.ts';
|
|
72
72
|
import { IDLE_FPS } from './motion.ts';
|
|
73
73
|
import { type ContourBridges, FAINT_ALPHA } from './contour.ts';
|
|
@@ -743,28 +743,12 @@ export function splicedConfig(text: string, drafted: ReadonlyMap<string, unknown
|
|
|
743
743
|
return r.text;
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
-
/** A
|
|
747
|
-
export
|
|
748
|
-
at: string;
|
|
749
|
-
value: string;
|
|
750
|
-
set: (v: string) => void;
|
|
751
|
-
}
|
|
746
|
+
/** A config-relative file reference supplied by the loader. */
|
|
747
|
+
export type RelativeConfigPath = ConfigFileReference;
|
|
752
748
|
|
|
753
|
-
/**
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
*/
|
|
757
|
-
export function relativeConfigPaths(configDir: string, o: Json): RelativeConfigPath[] {
|
|
758
|
-
const out: RelativeConfigPath[] = [];
|
|
759
|
-
const motion = o.motion as Json | undefined;
|
|
760
|
-
if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
|
|
761
|
-
out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
|
|
762
|
-
}
|
|
763
|
-
const control = (o.generation as Json | undefined)?.control as Json | undefined;
|
|
764
|
-
if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
|
|
765
|
-
out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
|
|
766
|
-
}
|
|
767
|
-
return out;
|
|
749
|
+
/** Each relative file path the loader knows, with built-in skeleton names excluded. */
|
|
750
|
+
export function relativeConfigPaths(_configDir: string, o: Json): RelativeConfigPath[] {
|
|
751
|
+
return configFileReferences(o).filter((field) => !isAbsolute(field.value));
|
|
768
752
|
}
|
|
769
753
|
|
|
770
754
|
/**
|
|
@@ -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
|
+
}
|