rig-parts 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/AUTHORING.md CHANGED
@@ -806,19 +806,45 @@ one seed pixel on the target run canvas (integer x right, y down).
806
806
  "run_dir": "runs/other",
807
807
  "st_input": "inputs/other_full.png",
808
808
  "part": "body",
809
- "seed": [120, 240]
809
+ "seed": [120, 240],
810
+ "reference": { "painting": "inputs/painting.png", "sha256": "<SHA-256 of PNG bytes>" }
810
811
  }]
811
812
  ```
812
813
 
813
814
  The region is derived from base alpha < 128, donor alpha >= 128, and the
814
815
  maximum RGB channel difference after rounding each pixel source-over white.
815
816
  The tolerance is exactly `--requirements`' `setup_painting.rgb_tolerance`.
816
- A missing declaration is refused. The target input is derived as for whole-layer
817
- take above. Both run canvases must agree, and the supplied original donor input
817
+ A missing tolerance declaration is refused. The optional `reference` declares
818
+ exactly one of `"source": true` (the stage's `--source`) or
819
+ `"painting": "path/to/reference.png"` (config-relative), and `sha256`, the
820
+ lowercase SHA-256 of that PNG's file bytes. An absent `reference` preserves the
821
+ shipped `source: true` semantics and prints one
822
+ `ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT` warning per build. It reads the same
823
+ stage painting for the predicate. A declared but unreadable reference, or a
824
+ hash or painting-dimension mismatch, is refused by name. Only an explicitly
825
+ declared painting reference keeps the selected mask fixed when `--source`
826
+ changes for projection. Its input is derived as for whole-layer take above.
827
+ Both run canvases must agree, and the supplied original donor input
818
828
  must match the entire target input in RGBA and dimensions, establishing the same
819
829
  crop. Different crops await #973. Painting samples use the existing Lanczos3
820
830
  resize onto the run canvas. Only the 8-connected qualifying component containing
821
831
  the seed is copied. The base layer's depth and draw order stay in use.
832
+ For a declared reference, its hash, binding and painting-to-run transform are logged. For
833
+ `scale` and `origin`, run coordinates are `(painting - origin) * scale`.
834
+ The origin is the head crop's top-left or the negative full-input padding.
835
+ The recorded filter is Lanczos3.
836
+
837
+ A region take evaluates the pre-take assembly once as a counterfactual, retaining
838
+ whole-layer takes. The target set includes cut pieces, limb segments and hybrid
839
+ bands consuming a target, transitively. The same set permits empty pieces only
840
+ in the internal counterfactual. Patches consume the painting directly and have
841
+ no source-part relationship. Every non-target part keeps that assembly's complete image
842
+ and record. This contains its changes to zero everywhere, including beyond the
843
+ mapped region plus bicubic's 2 px support, through subsequent seam operations.
844
+ Final recomposite and error measurements use those retained parts together with
845
+ the taken targets. The counterfactual is never written. A take adds this extra
846
+ assembly evaluation. Builds without region takes follow the existing single
847
+ assembly path. Target filter halos are reported separately and have no bar.
822
848
 
823
849
  No polygon, rectangle or brush input is accepted. A drawn bound is scissors.
824
850
  The log reports the derived pixel count, run-grid `[left, top, width, height]`
@@ -4536,7 +4562,8 @@ Limb source intersections use the final segmentation ownership map separately
4536
4562
  for each adjacent source; another limb's island is excluded before capture.
4537
4563
 
4538
4564
  To enforce a bend, add `cut_material` to the existing
4539
- `spine-parts-requirements/1` file (the other fields remain required):
4565
+ `spine-parts-requirements/1` file. `requirements` remains required. `fps` is
4566
+ required only when a declared measurement reads the file's sampling rate.
4540
4567
 
4541
4568
  ```json
4542
4569
  {
@@ -5122,6 +5149,10 @@ those settings. Changing a config seed alone cannot change a no-config run.
5122
5149
  | <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 |
5123
5150
  | <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 |
5124
5151
  | <a id="EDGE_PAIR_RESIDUAL"></a>`EDGE_PAIR_RESIDUAL` | report-only front/back residual candidates after visible edge composition, with pixel spans, boxes and coverage | read measured candidates and explicit skipped/unavailable reasons in `edge_residuals.json`. No pass/fail threshold is applied |
5152
+ | <a id="ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT"></a>`ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT` | warning, `reference` is absent and the predicate uses `--source` | declare a painting reference and its PNG hash to keep the mask independent of projection-source changes |
5153
+ | <a id="ASSEMBLE_REGION_TAKE_REFERENCE"></a>`ASSEMBLE_REGION_TAKE_REFERENCE` | a declared region reference is unreadable | supply the declared PNG, with `source: true` or `painting` and its byte hash |
5154
+ | <a id="ASSEMBLE_REGION_TAKE_REFERENCE_MATCH"></a>`ASSEMBLE_REGION_TAKE_REFERENCE_MATCH` | a reference hash or painting dimensions differ | supply the declared PNG and preserve the source geometry |
5155
+ | <a id="ASSEMBLE_REGION_TAKE_CONTAINMENT"></a>`ASSEMBLE_REGION_TAKE_CONTAINMENT` | a non-target part has no pre-take counterpart | retain the same neighbour part population before and after the take |
5125
5156
  | <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 |
5126
5157
  | <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 |
5127
5158
  | <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 |
@@ -5557,7 +5588,7 @@ documents do not ship). Each is an input this package built; none is an author's
5557
5588
  | <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 |
5558
5589
  | <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 |
5559
5590
  | <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 |
5560
- | <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). |
5591
+ | <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` (a positive number when supplied, required only when a declared measurement reads the file's sampling rate, with 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). |
5561
5592
  | <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 |
5562
5593
  | <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 |
5563
5594
  | <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 |
@@ -6628,8 +6659,10 @@ and an entry whose slot does not fold in its animation fails the line
6628
6659
  (`CHECK_ANIMATIONS_DO_NOT_FOLD`, `declared_not_folding`) — an exception that excepts nothing
6629
6660
  is taken out of the file, as rig-c refuses one for `invariants.deformMayFold`.
6630
6661
 
6631
- `fps` and `requirements` are required as always; a file that declares only exceptions
6632
- is `{ "spec": "spine-parts-requirements/1", "fps": 12, "requirements": [], "not_planted": ["shoes"], "not_swinging": ["under"], "rises": ["arm"], "rests": ["hem"] }`.
6662
+ `requirements` is required. `fps` is required only when a declared measurement
6663
+ reads the file's sampling rate. Instruments using their own rate do not require it.
6664
+ A file that declares only exceptions
6665
+ is `{ "spec": "spine-parts-requirements/1", "requirements": [], "not_planted": ["shoes"], "not_swinging": ["under"], "rises": ["arm"], "rests": ["hem"] }`.
6633
6666
 
6634
6667
  **Four outcomes, never merged** (#87):
6635
6668
 
@@ -6717,8 +6750,9 @@ render each requirement on that animation reads: the rig under test's own build,
6717
6750
  copy with its scene targets placed), and, per follow, `requirements/released/<name>/`
6718
6751
  and `requirements/full/<name>/` — each a `rigc render` frame set with its
6719
6752
  `geometry.json`. The copies' specs and builds are scratch, removed before the stage
6720
- returns. `check.json` gains `requirements` — `fps`, the summary and each line by name
6721
- — before `PASS`, and `PASS` needs every declared requirement to PASS.
6753
+ returns. `check.json` gains `requirements` — the summary and each line by name,
6754
+ with `fps` only when the file supplies it — before `PASS`, and `PASS` needs every
6755
+ declared requirement to PASS.
6722
6756
 
6723
6757
  The selftest holds each kind and outcome to a hand value on a generated rig
6724
6758
  (`fixtures/reqrig.ts`): a two-bone ik that reaches its scene target holds its contact
@@ -6780,8 +6814,9 @@ Distance **greater than** the declared limit fails; equality passes. Without a
6780
6814
  limit, the block and every joint are **SKIP**, with the measured distances and
6781
6815
  `within_px: null`; this line is excluded from measured bars and contributes no
6782
6816
  PASS. Other independent checks may still pass. With no `fixed_skeleton` block,
6783
- this measurement adds no output or render call. `fps` and `requirements` remain
6784
- required by the enclosing file; they do not change this setup measurement.
6817
+ this measurement adds no output or render call. `requirements` remains required
6818
+ by the enclosing file. `fps` is required only when a declared measurement reads
6819
+ the file's sampling rate. Neither field changes this setup measurement.
6785
6820
 
6786
6821
  | rule | change |
6787
6822
  | --- | --- |
package/docs/STABILITY.md CHANGED
@@ -156,7 +156,8 @@ part name, `[i]` an entry of a list):
156
156
  - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
157
157
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
158
158
  - **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance`; optional: `min_held_alpha`, `order` (issues #848, #854, #855; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only). The optional integer floor 1–255 admits exact original alpha-over coverage strictly below it; absent or 1 blocks every nonzero holder. Only selected target samples below the floor may take painting RGBA; target samples at/above the floor and every non-target holder remain exact. By default overlapping reachable claims refuse. With `order` on every entry (unique nonnegative safe integers), lower values claim first and their gains block later candidates and paths; array order never breaks ties or supplies missing precedence
159
- - **assemble.take[i]** — required: `run`, `run_dir`, `st_input`, `tags` (issue #866)
159
+ - **assemble.take[i]** — required: `run`, `run_dir`, `st_input`; optional: `tags`, `part`, `seed`, `reference` (issues #866, #1000, #1031). Whole-layer entries require `tags`; region entries require `part` and `seed`. An absent `reference` preserves the shipped `source: true` predicate and emits one named implicit-reference warning; only an explicitly declared painting reference makes the mask invariant to changes in `--source`
160
+ - **assemble.take[i].reference** — required: `sha256`; optional: `source`, `painting` (issue #1031). Exactly one of `source: true` or a config-relative `painting` PNG is required
160
161
  - **assemble.editor_receipts** — required: `receipts`; optional: `required` (issue #946, additive), `trace_parts` (issue #947, additive)
161
162
  - **assemble.editor_receipts.receipts[i]** — required: `file`, `sha256`, `from_sha256`, `editor` (issue #946)
162
163
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
@@ -657,3 +658,9 @@ changing a calibration requires preparing its recipe again. Compare the same
657
658
  provider maps and preserve their provenance. Each report row names the part,
658
659
  declared relief, group `zScale` and clipping over original alpha>=128 support;
659
660
  it remains a measurement rather than a shape-quality gate.
661
+
662
+ The requirements file's `fps` becomes optional when no declared measurement
663
+ reads that rate (issue #965, additive for readers). Measurements in
664
+ `requirements` still require a positive `fps`, with no default. Declarations
665
+ whose instruments use their own rate keep that rate.
666
+ The output `check.json` omits `requirements.fps` when the file supplies no `fps`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assemble.ts CHANGED
@@ -300,6 +300,8 @@ export function singleRunPadRecord(single: boolean, sourceW: number, sourceH: nu
300
300
  }
301
301
 
302
302
  export interface AssembleInput {
303
+ /** Counterfactual before seeded takes, with whole-layer takes already applied. */
304
+ regionTake?: { full: LayerSet; head: LayerSet; targets: string[] };
303
305
  /** Turn context for the exact part/carrier declarations, never an exemption flag. */
304
306
  turn?: TurnSpec;
305
307
  regions?: Record<string, RegionSpec>;
@@ -4052,6 +4054,7 @@ function cutLimbs(
4052
4054
  readHidden: (layer: string, by: string) => void = () => undefined,
4053
4055
  tips?: Record<string, [number, number]>,
4054
4056
  sourceGroups: ReadonlyMap<string, string> = new Map(),
4057
+ emptyTargets?: ReadonlySet<string>,
4055
4058
  ): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[]; capFrom: Array<CapFrom | null>; material: MaterialRow[]; sources: MaterialSource[] } {
4056
4059
  const logical = (source: string): string => sourceGroups.get(source) ?? source;
4057
4060
  const names = Object.keys(limbs);
@@ -4110,7 +4113,7 @@ function cutLimbs(
4110
4113
  const on = names.filter((l) => limbs[l].segments.some((sg) => logical(sg.from) === logical(src)));
4111
4114
  const own = new Int32Array(W * H).fill(-1);
4112
4115
  const before = opaqueOf(b.r);
4113
- if (before === 0) {
4116
+ if (before === 0 && !emptyTargets?.has(src)) {
4114
4117
  problems.push({ code: 'ASSEMBLE_LIMB_TAKES', object: `part "${src}" (config.limbs.${on[0]})`, detail: 'has no opaque pixel to cut; a part with art is required — the limb takes its pixels' });
4115
4118
  continue;
4116
4119
  }
@@ -4211,7 +4214,7 @@ function cutLimbs(
4211
4214
  for (const { q, i } of shares) {
4212
4215
  const writers = b.writers?.slice();
4213
4216
  pieces[i] = { ...b, writers, r: rasters[i], region: regions[i], merged: mergedIn(b, regions[i]) };
4214
- if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0) {
4217
+ if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0 && !emptyTargets?.has(q.part)) {
4215
4218
  problems.push({
4216
4219
  code: 'ASSEMBLE_LIMB_PIXELS',
4217
4220
  object: `config.limbs.${q.limb}.segments[${q.segment}] (part "${q.part}")`,
@@ -4251,6 +4254,7 @@ function cutLimbs(
4251
4254
  const hasCoverSplit = names.some((other) => other !== l && limbs[other].split !== undefined && limbs[other].segments.some((osg) => osg.from === upFrom || osg.from === sg.from));
4252
4255
  const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
4253
4256
  const run = limbRun(line, art, [0, 0, W - 1, H - 1]) ?? (hasCoverSplit ? limbRun(line, sharedArt, [0, 0, W - 1, H - 1]) : null);
4257
+ if (run === null && emptyTargets?.has(sg.part)) return;
4254
4258
  if (run === null) {
4255
4259
  problems.push({
4256
4260
  code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
@@ -4383,7 +4387,7 @@ function cutLimbs(
4383
4387
  }
4384
4388
  }
4385
4389
  }
4386
- if (img !== null && imagePx + lowerPx === 0 && hidden === null) {
4390
+ if (img !== null && imagePx + lowerPx === 0 && hidden === null && !emptyTargets?.has(sg.part)) {
4387
4391
  problems.push({
4388
4392
  code: 'ASSEMBLE_LIMB_CAP_EMPTY',
4389
4393
  object: `${object}.cap`,
@@ -4477,6 +4481,7 @@ function cutLimbs(
4477
4481
  const li = names.indexOf(l), own = owners.get(sg.from) as Int32Array;
4478
4482
  const art = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < W && y < H && own[y * W + x] === li && source.r.data[(y * W + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE;
4479
4483
  const run = limbRun(line, art, [0, 0, W - 1, H - 1]);
4484
+ if (run === null && emptyTargets?.has(sg.hybrid.part)) return;
4480
4485
  const width = sg.hybrid.width ?? (run === null ? 0 : 2 * halfWidthAbout(run));
4481
4486
  const upper = places.get(segs[k - 1].bone) as BonePlace;
4482
4487
  const lower = places.get(sg.bone) as BonePlace;
@@ -4799,12 +4804,56 @@ export function checkSingleRun(full: LayerSet, plan: readonly PlanEntry[] | null
4799
4804
  refuseIfAny(problems);
4800
4805
  }
4801
4806
 
4807
+ /** All output pieces that consume a taken source, including cut-to-limb paths.
4808
+ * Painting patches have no source-part edge in the supported Patch schema. */
4809
+ export function regionTakeTargets(names: readonly string[], cuts: readonly Cut[], limbs: Readonly<Record<string, Limb>> = {}): ReadonlySet<string> {
4810
+ const targets = new Set(names);
4811
+ const edges = cuts.map(c => ({ from: c.from, into: c.into }));
4812
+ for (const limb of Object.values(limbs)) for (const [index, segment] of limb.segments.entries()) {
4813
+ edges.push({ from: segment.from, into: segment.part });
4814
+ // A lower cap can copy the upper segment's source art.
4815
+ if (index > 0) edges.push({ from: limb.segments[index - 1].from, into: segment.part });
4816
+ if (segment.hybrid !== undefined) edges.push({ from: segment.from, into: segment.hybrid.part });
4817
+ }
4818
+ // Set iteration visits newly added descendants and terminates on cycles.
4819
+ for (const source of targets) for (const edge of edges) if (edge.from === source) targets.add(edge.into);
4820
+ return targets;
4821
+ }
4822
+
4823
+ /** Retain neighbour pixels and their matching records after every coupled edit. */
4824
+ export function retainRegionNeighbours(placed: PlacedPart[], before: readonly PlacedPart[], targets: ReadonlySet<string>): void {
4825
+ const originals = new Map(before.map(p => [p.record.name, p]));
4826
+ for (let i = 0; i < placed.length; i++) {
4827
+ if (targets.has(placed[i].record.name)) continue;
4828
+ const original = originals.get(placed[i].record.name);
4829
+ if (original === undefined) throw new PartsError([{
4830
+ code: 'ASSEMBLE_REGION_TAKE_CONTAINMENT', object: `part "${placed[i].record.name}"`,
4831
+ detail: 'has no pre-take counterpart; unchanged neighbour pixels and their measurements are required',
4832
+ }]);
4833
+ placed[i] = original;
4834
+ }
4835
+ if (before.some(p => !targets.has(p.record.name) && !placed.some(q => q.record.name === p.record.name))) throw new PartsError([{
4836
+ code: 'ASSEMBLE_REGION_TAKE_CONTAINMENT', object: 'config.assemble.take',
4837
+ detail: 'removed a non-target part; unchanged neighbour pixels and their measurements are required',
4838
+ }]);
4839
+ }
4840
+
4802
4841
  /**
4803
4842
  * Run the stage in memory. Everything that can be refused is refused before
4804
4843
  * this returns; nothing here writes a file, so a caller that writes only
4805
4844
  * after this returned writes only after green.
4806
4845
  */
4807
4846
  export function assemble(input: AssembleInput): AssembleResult {
4847
+ if (input.regionTake === undefined) return assembleParts(input);
4848
+ const targets = regionTakeTargets(input.regionTake.targets, input.cuts, input.limbs);
4849
+ const before = assembleParts({ ...input, full: input.regionTake.full, head: input.regionTake.head, regionTake: undefined }, targets);
4850
+ return assembleParts(input, targets, before);
4851
+ }
4852
+
4853
+ /** The counterfactual is internal and is never emitted. Empty taken targets
4854
+ * may be recovered by a take, so only that counterfactual permits them. */
4855
+ function assembleParts(input: AssembleInput, targets?: ReadonlySet<string>, beforeTake: AssembleResult | null = null): AssembleResult {
4856
+ const emptyTargets = beforeTake === null ? targets : undefined;
4808
4857
  const { source, extend, patches, seamRule, projectRule } = input;
4809
4858
  const authoredDepths = input.depth === undefined ? new Map<string, AuthoredUnitDepth>() : authoredUnitDepths(input);
4810
4859
  if (input.paintingExtension !== undefined) refuseIfAny(paintingExtensionProblems(input.paintingExtension));
@@ -5130,7 +5179,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5130
5179
  merged = mergeBelowCrop(r, extras, front, srcr, frame.headBottom, trace, (p, stage) => { writers[p] = AW[stage]; });
5131
5180
  }
5132
5181
  const { x1, opaque } = measureBox(r);
5133
- if (opaque === 0) {
5182
+ if (opaque === 0 && !emptyTargets?.has(name)) {
5134
5183
  empty.push({
5135
5184
  code: 'ASSEMBLE_PART_OPAQUE',
5136
5185
  object: `part "${name}" (${key}, config.assemble.plan[${pi}])`,
@@ -5238,7 +5287,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5238
5287
  if (whole !== parts) empty.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object, detail: `${what}: ${whole} before its cuts, ${parts} over the pieces; equal is required (a bug in the assembler, not in the inputs)` });
5239
5288
  }
5240
5289
  mine.forEach((i, k) => {
5241
- if (taken[k] === 0) {
5290
+ if (taken[k] === 0 && !emptyTargets?.has(cuts[i].into)) {
5242
5291
  empty.push({
5243
5292
  code: 'ASSEMBLE_CUT_PIXELS',
5244
5293
  object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`,
@@ -5246,7 +5295,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5246
5295
  });
5247
5296
  }
5248
5297
  });
5249
- if (kept === 0) {
5298
+ if (kept === 0 && !emptyTargets?.has(name)) {
5250
5299
  empty.push({
5251
5300
  code: 'ASSEMBLE_CUT_PIXELS',
5252
5301
  object,
@@ -5271,7 +5320,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5271
5320
  for (let p = 0; p < W * H; p++) if (cutMasks[i].data[p] === 1 && band.data[p] === 0 && (pieces[i] as Built).r.data[p * 4 + 3] > 0 && b.r.data[p * 4 + 3] > 0) { (b.writers as Int32Array)[p] = cutIds[i]; if (b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) opaqueFilled++; }
5272
5321
  // A hidden-art layer (issue #359) is the whole layer's covered art, read by every cut of its part: a cut it holds nothing
5273
5322
  // under fills 0, which the cut line counts, and is not refused as a file made for this one cut would be.
5274
- if (f.filled === 0 && input.cuts[i].under !== undefined) {
5323
+ if (f.filled === 0 && input.cuts[i].under !== undefined && !emptyTargets?.has(cuts[i].into)) {
5275
5324
  empty.push({
5276
5325
  code: 'ASSEMBLE_UNDER_EMPTY',
5277
5326
  object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
@@ -5300,7 +5349,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5300
5349
  if (source === null) return;
5301
5350
  const m = cutMasks[i], r = newRaster(W, H);
5302
5351
  for (let p = 0; p < W * H; p++) if (m.data[p] === 1) r.data.set(source.r.data.subarray(p * 4, p * 4 + 4), p * 4);
5303
- if (opaqueOf(r) === 0) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
5352
+ if (opaqueOf(r) === 0 && !emptyTargets?.has(cuts[i].into)) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
5304
5353
  const writers = (source.writers as Int32Array).slice();
5305
5354
  pieces[i] = { ...source, r, writers, region: m, merged: mergedIn(source, m) };
5306
5355
  for (let j = materialSources.length - 1; j >= 0; j--) if (materialSources[j].part === cuts[i].into) materialSources.splice(j, 1);
@@ -5328,7 +5377,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5328
5377
  const ci = cuts.findIndex((q) => q.into === name);
5329
5378
  return ci >= 0 ? pieces[ci] : null;
5330
5379
  };
5331
- const lim = cutLimbs(input.limbs ?? {}, input.bones ?? [], input.caps ?? new Map(), W, H, limbSource, opaqueOf, mergedIn, count, (name) => hiddenArt.get(name) ?? null, (layer, by) => hiddenUses.get(layer)?.push(by), input.boneTips, input.limbSourceGroups);
5380
+ const lim = cutLimbs(input.limbs ?? {}, input.bones ?? [], input.caps ?? new Map(), W, H, limbSource, opaqueOf, mergedIn, count, (name) => hiddenArt.get(name) ?? null, (layer, by) => hiddenUses.get(layer)?.push(by), input.boneTips, input.limbSourceGroups, emptyTargets);
5332
5381
  // Issue #564: and the cap a run's own layer fills above its upper joint, read once the limbs are cut.
5333
5382
  if (bands !== null) refuseIfAny(limbBands(cuts, pieces, input.limbs ?? {}, input.bones ?? [], W, H, (l, k) => lim.parts.find((q) => q.limb === l && q.segment === k)?.record.cap, input.boneTips).problems);
5334
5383
  const limbPieces = lim.pieces;
@@ -5736,6 +5785,18 @@ export function assemble(input: AssembleInput): AssembleResult {
5736
5785
  if (edged.get(name)?.data[p] !== 1 && paintingGains.get(name)?.data[p] !== 1 && retakeByName.get(name)?.data[p] !== 1 && yields.get(name)?.mask.data[p] !== 1 && trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
5737
5786
  }
5738
5787
  const { x0, y0, x1, y1, opaque } = measureBox(r);
5788
+ if (opaque === 0 && emptyTargets?.has(name)) {
5789
+ set({
5790
+ record: {
5791
+ name, from: key, x: 0, y: 0, w: 1, h: 1,
5792
+ opaque_px: 0, visible_px: 0, occluded_px: 0, projected_core_px: 0,
5793
+ source_px_taken: 0, visible_not_projected_px: 0, refused_drift_px: 0,
5794
+ merged_px: 0, seam_override_px: 0,
5795
+ },
5796
+ image: newRaster(1, 1),
5797
+ });
5798
+ return;
5799
+ }
5739
5800
  if (opaque === 0) {
5740
5801
  const pushed = pushedHere.reduce((a, q) => a + q.px, 0);
5741
5802
  empty.push({
@@ -5817,6 +5878,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5817
5878
  const depth = authoredDepths.get(p.record.name);
5818
5879
  if (depth !== undefined) p.record.provenance.depth = depth;
5819
5880
  });
5881
+ if (beforeTake !== null) retainRegionNeighbours(placed, beforeTake.images, targets!);
5820
5882
  // Provenance is now numbers/labels only. No later raster/figure operation
5821
5883
  // reads report buffers; release all aliases before recomposite/error work.
5822
5884
  valueBaselines.clear();
package/src/build.ts CHANGED
@@ -351,12 +351,13 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
351
351
  // Issue #947: under `trace_parts`, a shape drawn in the config through a part's pixels is refused with them.
352
352
  refuseIfAny([...receipts.problems, ...missingReceipts(receipts, [...editedRunImages(read), ...editedConfigImages(input.config, cfg.assemble.cuts ?? [], cfg.limbs)]), ...untracedPartSources(cfg)]);
353
353
  const takenRuns = applyTakenLayers(read, src, cfg, input.config);
354
- const runs = applyTakenRegions(takenRuns, src, cfg, input.config, requirementsFile?.setup_painting, receipts);
355
- for (const note of runs.notes) log(`note: ${note}`);
354
+ const runs = applyTakenRegions(takenRuns, src, cfg, input.config, requirementsFile?.setup_painting, receipts, input.source);
355
+ for (const note of runs.notes) log(note.startsWith('WARN ') ? note : `note: ${note}`);
356
356
  const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
357
357
  const under = readUnders(input.config, fields.cuts);
358
358
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
359
- const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: seam, projectRule: project });
359
+ const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under,
360
+ ...((cfg.assemble.take ?? []).some(t => 'part' in t) ? { regionTake: { full: takenRuns.full, head: takenRuns.head, targets: (cfg.assemble.take ?? []).flatMap(t => 'part' in t ? [t.part] : []) } } : {}), ...(caps === undefined ? {} : { caps }), seamRule: seam, projectRule: project });
360
361
 
361
362
  // Issue #454: alternate painting sets, assembled before any file is written.
362
363
  const configDir = dirname(resolve(input.config));
package/src/check.ts CHANGED
@@ -149,6 +149,7 @@ import { TURN_BONE, turnConstraintName, turnTwin } from './turn.ts';
149
149
  import { ROOT_BONE, loadConfigWithBytes, type Point } from './config.ts';
150
150
  import { influences, meshLimits, type Segment, type Influence, type InfluenceLimits } from './weights.ts';
151
151
  import {
152
+ requirementAnimations,
152
153
  aimLine,
153
154
  contactLine,
154
155
  declareConsumerDriven,
@@ -2767,7 +2768,7 @@ export interface SetCheckFigures {
2767
2768
  /** `check.json`'s `requirements` block: which file was read, the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
2768
2769
  export interface RequirementsBlock {
2769
2770
  file: RequirementsFileRecord;
2770
- fps: number;
2771
+ fps?: number;
2771
2772
  summary: RequirementsSummary;
2772
2773
  lines: Record<string, RequirementLine>;
2773
2774
  }
@@ -7489,12 +7490,11 @@ export function measureRequirements(inp: CheckInputs, file: RequirementsFile, bu
7489
7490
  return p;
7490
7491
  };
7491
7492
  const declared = new Map<string, { poses: Poses; geo: IdleGeometry; path: string }>();
7492
- for (const r of file.requirements) {
7493
- if (declared.has(r.animation)) continue;
7494
- const scene = file.targets.some((t) => t.animation === r.animation);
7495
- const label = scene ? `as declared, the scene targets of "${r.animation}" placed` : 'as declared';
7496
- const candidate = scene ? green(buildCopy(placedFor(r.animation).rig, placedFor(r.animation).motion), `the rig ${label}`) : buildDir;
7497
- declared.set(r.animation, posesOf(candidate, r.animation, join(reqDir, 'as-declared', r.animation), label));
7493
+ for (const animation of requirementAnimations(file.requirements)) {
7494
+ const scene = file.targets.some((t) => t.animation === animation);
7495
+ const label = scene ? `as declared, the scene targets of "${animation}" placed` : 'as declared';
7496
+ const candidate = scene ? green(buildCopy(placedFor(animation).rig, placedFor(animation).motion), `the rig ${label}`) : buildDir;
7497
+ declared.set(animation, posesOf(candidate, animation, join(reqDir, 'as-declared', animation), label));
7498
7498
  }
7499
7499
  for (const r of file.requirements) {
7500
7500
  const d = declared.get(r.animation) as { poses: Poses; geo: IdleGeometry; path: string };
package/src/config.ts CHANGED
@@ -2500,12 +2500,20 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
2500
2500
  const p = `${at}[${i}]`;
2501
2501
  const region = isPlainObject(v) && ('part' in v || 'seed' in v);
2502
2502
  const o = region
2503
- ? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], [])
2503
+ ? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], ['reference'])
2504
2504
  : c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
2505
2505
  if (o === null) return;
2506
2506
  if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
2507
2507
  for (const k of ['run_dir', 'st_input']) c.string(`${p}.${k}`, o[k]);
2508
2508
  if (region) {
2509
+ if (o.reference !== undefined) {
2510
+ const r = c.object(`${p}.reference`, o.reference, ['sha256'], ['source', 'painting']);
2511
+ if (r !== null) {
2512
+ if (typeof r.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.sha256)) c.fail('CONFIG_FIELD_TYPE', `${p}.reference.sha256`, 'a lowercase SHA-256 of the reference PNG bytes is required');
2513
+ if ((r.source === true) === (typeof r.painting === 'string') || ('source' in r && r.source !== true)) c.fail('CONFIG_FIELD_TYPE', `${p}.reference`, 'exactly one of source: true or painting is required');
2514
+ if ('painting' in r) c.string(`${p}.reference.painting`, r.painting);
2515
+ }
2516
+ }
2509
2517
  if (c.string(`${p}.part`, o.part) && plan !== null && !plan.includes(o.part)) {
2510
2518
  c.fail('CONFIG_NAME_RESOLVES', `${p}.part`, `names "${o.part}"; a part named in assemble.plan is required`);
2511
2519
  }
@@ -78,6 +78,9 @@ import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts'
78
78
  * part, BEHIND is made opaque there in `Cb` (in the painting's own colour
79
79
  * where FRONT's alpha is 0), so the flat composite at p is the painting, the
80
80
  * rim stays FRONT's, and BEHIND's hidden surface past the window is untouched.
81
+ * Where BEHIND already has alpha at least {@link EDGE_OWNER_ALPHA}, its own
82
+ * pre-step RGB is the fill colour. FRONT is unmixed against that same colour,
83
+ * including the clamping error calculation, to preserve the pair's composite.
81
84
  *
82
85
  * A pixel is left as it was — and counted, by reason — when a part drawn in
83
86
  * front of FRONT, or between BEHIND and FRONT, has alpha above
@@ -491,11 +494,16 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
491
494
  decisions.forEach((q, k) => {
492
495
  const s = q.p * 4;
493
496
  const P = [srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]];
494
- const unmixAt = (c: number, a: number): number => Math.min(255, Math.max(0, Math.round(q.cb[c] + ((P[c] - q.cb[c]) * 255) / a)));
497
+ // An existing BEHIND surface keeps its own colour. Unmix FRONT against
498
+ // that same surface so the pair still reconstructs the painting.
499
+ const cb = q.behind >= 0 && parts[q.behind].data[s + 3] >= EDGE_OWNER_ALPHA
500
+ ? [parts[q.behind].data[s], parts[q.behind].data[s + 1], parts[q.behind].data[s + 2]]
501
+ : q.cb;
502
+ const unmixAt = (c: number, a: number): number => Math.min(255, Math.max(0, Math.round(cb[c] + ((P[c] - cb[c]) * 255) / a)));
495
503
  // FRONT's colour is clamped to 0..255, so a pixel the two colours cannot mix at that coverage (a painted line darker or
496
504
  // lighter than both) would miss the painting: FRONT's coverage rises to the least that brings the composite within half
497
505
  // the contrast floor of it (at full coverage it is the painting).
498
- const miss = (a: number): number => Math.max(...[0, 1, 2].map((c) => Math.abs(Math.round((unmixAt(c, a) * a + q.cb[c] * (255 - a)) / 255) - P[c])));
506
+ const miss = (a: number): number => Math.max(...[0, 1, 2].map((c) => Math.abs(Math.round((unmixAt(c, a) * a + cb[c] * (255 - a)) / 255) - P[c])));
499
507
  let alpha = alphaOf[k];
500
508
  if (alpha > 0 && alpha < 255) while (alpha < 255 && 2 * miss(alpha) > EDGE_CONTRAST) alpha++;
501
509
  if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
@@ -512,7 +520,7 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
512
520
  else plan(q.front, q.p, unmix(0), unmix(1), unmix(2), alpha);
513
521
  if (q.behind >= 0 && alpha < 255) {
514
522
  if (alpha === 0) plan(q.behind, q.p, P[0], P[1], P[2], 255);
515
- else plan(q.behind, q.p, q.cb[0], q.cb[1], q.cb[2], 255);
523
+ else plan(q.behind, q.p, cb[0], cb[1], cb[2], 255);
516
524
  fills[q.behind].set(q.p, q.front);
517
525
  }
518
526
  });
@@ -133,6 +133,11 @@ export interface SeamRequirement extends RequirementCommon {
133
133
 
134
134
  export type Requirement = ContactRequirement | FollowRequirement | AimRequirement | RangeRequirement | StretchRequirement | SeamRequirement;
135
135
 
136
+ /** Animations whose measurements consume the requirements file's sampling rate. */
137
+ export function requirementAnimations(requirements: readonly { animation: string }[]): string[] {
138
+ return [...new Set(requirements.map((requirement) => requirement.animation))];
139
+ }
140
+
136
141
  /** One stage point of a scene target, at a time in seconds (null for a fixed point). */
137
142
  export interface TargetPoint {
138
143
  t: number | null;
@@ -149,7 +154,7 @@ export interface SceneTarget {
149
154
 
150
155
  export interface RequirementsFile {
151
156
  path: string;
152
- fps: number;
157
+ fps?: number;
153
158
  targets: SceneTarget[];
154
159
  requirements: Requirement[];
155
160
  /**
@@ -314,8 +319,8 @@ const EXCEPTION_ABSENT: Record<ExceptionList, string> = {
314
319
 
315
320
  /**
316
321
  * Read and shape-check a requirements file. Every problem is collected before
317
- * one refusal: the file, `spec`, `fps` (required, a positive number: no
318
- * default), `targets` (optional), `requirements` (required, possibly empty)
322
+ * one refusal: the file, `spec`, `fps` (a positive number required when a measurement reads
323
+ * the file's sampling rate, with no default), `targets` (optional), `requirements` (required, possibly empty)
319
324
  * and `not_planted`, `not_swinging`, `rises` and `rests` (each optional,
320
325
  * issues #231 and #245: a non-empty list of distinct part names, no part both
321
326
  * in `not_swinging` and in `rises` or `rests`;
@@ -355,9 +360,6 @@ export function readRequirements(path: string): RequirementsFile {
355
360
  if ('setup_painting' in raw) problems.push(...setupPaintingProblems(raw.setup_painting, `${at} setup_painting`));
356
361
  if ('cut_material' in raw) problems.push(...jointMaterialProblems(raw.cut_material, `${at} cut_material`));
357
362
  if ('fixed_skeleton' in raw) problems.push(...fixedSkeletonProblems(raw.fixed_skeleton, `${at} fixed_skeleton`));
358
- let fps = NaN;
359
- if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
360
- else fps = raw.fps;
361
363
 
362
364
  const requirements: Requirement[] = [];
363
365
  if (!Array.isArray(raw.requirements)) field(`${at} field "requirements"`, `is ${JSON.stringify(raw.requirements)}; an array of requirements is required (an empty one declares none)`);
@@ -537,6 +539,11 @@ export function readRequirements(path: string): RequirementsFile {
537
539
  });
538
540
  }
539
541
  }
542
+ const samplesAnimation = requirementAnimations(requirements).length > 0;
543
+ if ((raw.fps !== undefined || samplesAnimation) && !(finite(raw.fps) && raw.fps > 0)) {
544
+ field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
545
+ }
546
+ const fps = finite(raw.fps) ? raw.fps : undefined;
540
547
  refuseIfAny(problems);
541
548
  return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }), ...(raw.cut_material === undefined ? {} : { cut_material: raw.cut_material as JointMaterial[] }), ...(raw.setup_painting === undefined ? {} : { setup_painting: raw.setup_painting as SetupPainting }), ...(raw.pinholes === undefined ? {} : { pinholes: { required: (raw.pinholes as PinholesDeclaration).required } }), ...(raw.seam_over_motion === undefined ? {} : { seam_over_motion: { required: (raw.seam_over_motion as SeamOverMotionDeclaration).required } }), ...(raw.boundary_colour === undefined ? {} : { boundary_colour: { max_step_excess: (raw.boundary_colour as BoundaryColour).max_step_excess } }) };
542
549
  }
package/src/takeregion.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import type { AssembleConfig, Run } from './config.ts';
4
4
  import { PartsError, refuseIfAny } from './errors.ts';
5
- import { editedRunImages, missingReceipts, type ReceiptLedger } from './editreceipt.ts';
5
+ import { editedRunImages, fileSha256, missingReceipts, type ReceiptLedger } from './editreceipt.ts';
6
6
  import { makeInputs } from './inputs.ts';
7
7
  import { OPAQUE_ALPHA_ABOVE, readLayers, type Layer, type LayerSet } from './layers.ts';
8
8
  import { connectedComponents } from './raster/components.ts';
@@ -19,6 +19,8 @@ export interface TakeRegion {
19
19
  part: string;
20
20
  /** One integer pixel on the target run canvas, x right and y down. */
21
21
  seed: [number, number];
22
+ /** PNG bytes, explicitly bound to the stage source or a separate painting. */
23
+ reference?: { source: true; sha256: string } | { painting: string; sha256: string };
22
24
  }
23
25
  export type TakeDeclaration = TakeLayers | TakeRegion;
24
26
  export interface RegionReading {
@@ -31,6 +33,8 @@ export interface RegionReading {
31
33
  painting_match_share: number;
32
34
  rgb_tolerance: number;
33
35
  }
36
+ const IMPLICIT_REFERENCE_WARNING = 'ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT';
37
+
34
38
  type Runs = { full: LayerSet; head: LayerSet; single: boolean; notes: string[] };
35
39
 
36
40
  function refuse(code: string, object: string, detail: string): never {
@@ -98,7 +102,7 @@ export function deriveTakenRegion(base: Layer, donor: Layer, painting: Raster, s
98
102
  }
99
103
 
100
104
  /** 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 {
105
+ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleConfig, configPath: string, setup: SetupPainting | undefined, receipts: ReceiptLedger, sourcePath?: string): Runs {
102
106
  const declarations = (cfg.assemble.take ?? []).filter((item): item is TakeRegion => 'part' in item);
103
107
  if (declarations.length === 0) return runs;
104
108
  const tolerance = setup?.rgb_tolerance;
@@ -108,9 +112,11 @@ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleCon
108
112
  if (cfg.depth !== undefined || [...runs.full.layers, ...runs.head.layers].some(layer => layer.depthSheet !== undefined)) {
109
113
  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
114
  }
111
- const inputs = makeInputs(painting, cfg, 'region take painting');
112
115
  const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
113
116
  const notes = [...runs.notes];
117
+ if (declarations.some(item => item.reference === undefined)) {
118
+ notes.push(`WARN ${IMPLICIT_REFERENCE_WARNING}: config.assemble.take — reference is absent; uses --source for the colour predicate; declare reference to bind its PNG sha256`);
119
+ }
114
120
  const seen = new Set<string>();
115
121
  for (const declaration of declarations) {
116
122
  const at = `config.assemble.take part "${declaration.part}" from "${declaration.run_dir}"`;
@@ -121,13 +127,28 @@ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleCon
121
127
  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
128
  }
123
129
  seen.add(key);
130
+ const reference = declaration.reference;
131
+ let referencePainting = painting;
132
+ let referenceHash: string | undefined;
133
+ if (reference !== undefined) {
134
+ const referencePath = 'source' in reference ? sourcePath : resolve(dirname(resolve(configPath)), reference.painting);
135
+ try {
136
+ if (referencePath === undefined) throw new Error('source path is absent');
137
+ referenceHash = fileSha256(referencePath);
138
+ referencePainting = readPng(referencePath);
139
+ } catch { return refuse('ASSEMBLE_REGION_TAKE_REFERENCE', at, 'reference must name a readable PNG with its declared sha256; a source binding requires the stage source path'); }
140
+ if (referenceHash !== reference.sha256 || referencePainting.width !== painting.width || referencePainting.height !== painting.height) {
141
+ return refuse('ASSEMBLE_REGION_TAKE_REFERENCE_MATCH', at, `reference sha256 ${referenceHash}, expected ${reference.sha256}; reference ${referencePainting.width}x${referencePainting.height}, source ${painting.width}x${painting.height}; matching hash and painting dimensions are required`);
142
+ }
143
+ }
144
+ const inputs = makeInputs(referencePainting, cfg, reference === undefined ? 'region take painting' : 'region take reference');
124
145
  const set = runs[declaration.run];
125
146
  const donor = readLayers(resolve(dirname(resolve(configPath)), declaration.run_dir));
126
147
  refuseIfAny(missingReceipts(receipts, editedRunImages(
127
148
  { full: donor, head: donor, single: true },
128
149
  `region take "${declaration.run}:${declaration.part}" donor "${declaration.run_dir}" `,
129
150
  )));
130
- const target = declaration.run === 'head' ? inputs.head : runs.single && set.canvas.w !== set.canvas.h ? painting : inputs.full;
151
+ const target = declaration.run === 'head' ? inputs.head : runs.single && set.canvas.w !== set.canvas.h ? referencePainting : inputs.full;
131
152
  let supplied: Raster;
132
153
  try { supplied = readPng(resolve(dirname(resolve(configPath)), declaration.st_input)); }
133
154
  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`); }
@@ -144,6 +165,9 @@ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleCon
144
165
  const derived = deriveTakenRegion(base, material, source, declaration.seed, tolerance, at);
145
166
  lists[declaration.run] = lists[declaration.run].map(layer => layer === base ? derived.layer : layer);
146
167
  const reading: RegionReading = { part: declaration.part, run: declaration.run, run_dir: declaration.run_dir, ...derived.reading };
168
+ const pad = target === referencePainting ? [0, 0] : declaration.run === 'head' ? inputs.headBox! : [-inputs.padLeft, -inputs.padTop];
169
+ const transform = { scale: [set.canvas.w / target.width, set.canvas.h / target.height], origin: [pad[0], pad[1]], filter: 'lanczos3' };
170
+ if (reference !== undefined) notes.push(`region reference ${reading.run}:${reading.part}: sha256 ${referenceHash}; binding ${'source' in reference ? 'source' : 'painting'}; painting-to-run ${JSON.stringify(transform)}`);
147
171
  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
172
  }
149
173
  const full = { ...runs.full, layers: lists.full };