rig-parts 1.82.2 → 1.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { applyTakenLayers } from './src/takelayers.ts';
2
3
  /**
3
4
  * rig-parts — one painting and its See-through layers in, Spine-ready parts
4
5
  * and rig specs out, verified through rig-c.
@@ -1722,10 +1723,12 @@ function cmdAssemble(args: string[]): number {
1722
1723
  if (propose) {
1723
1724
  const target = writePath === undefined ? null : readConfigWriteTarget(writePath);
1724
1725
  const src = readSource(source);
1725
- const runs = readRuns(full, head);
1726
- const g = proposeFields(loadEarlyConfig(config, 'layers'), runs.single ? { w: src.width, h: src.height } : null);
1726
+ const cfg = loadEarlyConfig(config, 'layers');
1727
+ const runs = applyTakenLayers(readRuns(full, head), src, { ...cfg, assemble: { ...cfg.assemble, plan: undefined } }, config);
1728
+ const g = proposeFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
1727
1729
  const geometry = { sourceW: src.width, sourceH: src.height, ...g };
1728
1730
  const proposal = runs.single ? proposeSinglePlan(runs.full, geometry) : proposePlan(runs.full, runs.head, geometry);
1731
+ proposal.notes.unshift(...runs.notes);
1729
1732
  if (target !== null) {
1730
1733
  staged = stageConfigWrite(target, { kind: 'assemble-plan', plan: proposal.plan, extend_below_crop: proposal.extend_below_crop });
1731
1734
  staged.commit();
package/docs/AUTHORING.md CHANGED
@@ -615,6 +615,88 @@ remain separate authoring decisions; a shifted shoe can still require work on
615
615
  its neighbouring shin. A walk built from eight generated keyframes motivated
616
616
  the opt-in placement; it does not change the default for existing inputs.
617
617
 
618
+ ### Taking named layers from another run (`assemble.take`, issue #866)
619
+
620
+ A run of the same painting, or a different painting with the same pixels under
621
+ some layers, can supply those tags without editing either run directory:
622
+
623
+ ```json
624
+ "take": [{
625
+ "run": "full",
626
+ "run_dir": "runs/other",
627
+ "st_input": "inputs/other_full.png",
628
+ "tags": ["front hair", "mouth"]
629
+ }]
630
+ ```
631
+
632
+ This array belongs in `assemble`. Every entry requires all four fields. `run`
633
+ is the **target** `full` or `head` run; `head` requires `--head`. `run_dir` and
634
+ `st_input` resolve relative to the config, for assemble, build and
635
+ `assemble --propose-plan` alike. Each target run/tag may be selected once. Empty
636
+ `take` and absent `take` leave the existing output unchanged. This selects base
637
+ run layers; alternate painting sets keep their own existing declarations.
638
+
639
+ `st_input` is the author's explicit declaration of the original PNG fed to the
640
+ donor. The tool reads `run_dir/layers.json`
641
+ and `run_dir/meta.json`, requiring a nonempty `prompt_id` and a nonnegative safe
642
+ integer `seed`; it never infers either from the target config. Existing metadata
643
+ does **not** bind that PNG's bytes to the prompt or layers. This checks the
644
+ supplied evidence's pixel identity, not cryptographic provenance of a remote
645
+ execution. Keep the original run, its metadata and original input together;
646
+ renaming a supplied image does not establish that it was the donor's input.
647
+
648
+ The target input is derived from the painting by the existing input model:
649
+ `full` uses the exact centred white `squarePad`, and `head` the configured
650
+ `head_box`. A nonsquare single-run file uses the painting at its original file
651
+ size before the assembler's existing padding. Donor and target run canvases must
652
+ have equal dimensions; donor and target input PNGs must also have equal
653
+ dimensions. Incompatible canvases or input sizes are refused, never resized to
654
+ make the comparison pass.
655
+
656
+ For each selected layer, support means its **original** alpha > 8, including
657
+ specks before ghost cleanup. Grow that support by the existing projection-core
658
+ `CORE_KERNEL` (5 x 5, radius 2 run pixels), clipped to the run canvas. Map each
659
+ covered run cell to **every intersecting** original input pixel cell. Compare
660
+ all four RGBA bytes exactly, with no filtering or tolerance. Checking each
661
+ layer's grown support checks their union too; the note reports both per-layer
662
+ and union counts. A mismatch names the donor run, tag, number of differing
663
+ pixels and `[left, top, right, bottom]` bbox in input pixels, right/bottom
664
+ exclusive. A tag absent from the donor, or with no alpha > 8 support, is refused.
665
+ An input difference outside that union is permitted.
666
+
667
+ Taken layers replace the target's tag, keeping the donor's tag and
668
+ `depth_median`, so existing hair/blink classification continues to read `from`.
669
+ Larger depth draws behind smaller depth. Numeric ties keep manifest order only
670
+ inside one original run. Equal or null depths **across** runs are ambiguous
671
+ and refused, even if the plan gives an order. A supplied plan reversing the
672
+ mixed-run depth order is also refused; `--propose-plan` drafts the depth order.
673
+ Replacing a layer with hidden companion art is refused because independent
674
+ hidden-art identity has not been declared. Active scalar depth sheets (`depth`
675
+ opt-in or already bound sheets) are also refused: selecting donor art does not
676
+ rebind a target atlas or invent donor scalar units. Inactive `depth_filename`
677
+ hints remain inactive; numeric `depth_median` ordering is preserved.
678
+
679
+ The proposal's `notes` and assemble/build log name every taken layer's run
680
+ directory, prompt ID, seed and input path, plus support/checked/differing counts.
681
+ `parts.json` adds `taken_layers` only to affected parts: each entry records
682
+ `run`, `tag`, `run_dir`, `st_input`, `prompt_id`, `seed`, `support_px` (run cells),
683
+ `checked_px` (original input cells), and `differing_px` (zero). Cuts, generated
684
+ arm layers, limb segments and copied extension sources retain these receipts.
685
+ The strict parts reader refuses partial, unknown, duplicate or nonzero-difference
686
+ receipts. These source identities are separate from pixel-writer counts.
687
+
688
+ Motivation, from the recorded 2026-10-30 decomposition measurements: a side-view
689
+ control held 62,627 front-hair, 36,643 headwear and 595 mouth pixels at
690
+ **alpha >= 250**. Three body repaints reported zero pixels at that threshold
691
+ for front hair and mouth at seed 42; their alpha > 8 counts were nonzero.
692
+ Six further runs at seeds 43, 101 and 2027 reported no run retaining both front
693
+ hair and headwear. These are recorded measurements, not a new pixel audit:
694
+ the retained run receipts contain no original layer PNGs. The records report
695
+ identical head input pixels and body-dependent limb results; this feature makes
696
+ identity an executable check on supplied original inputs instead of trusting
697
+ that claim or hand-editing `layers.json`. Seed and painting selection remain
698
+ the author's decisions.
699
+
618
700
  ### Alternate part layers (`assemble.alternates[i].layers`, issue #666)
619
701
 
620
702
  A gesture run may hold a raised arm only in a layer the author added, while
@@ -4365,6 +4447,19 @@ those settings. Changing a config seed alone cannot change a no-config run.
4365
4447
 
4366
4448
  | rule | means | change |
4367
4449
  | --- | --- | --- |
4450
+ | <a id="ASSEMBLE_TAKE_INPUT_IDENTITY"></a>`ASSEMBLE_TAKE_INPUT_IDENTITY` | original input RGBA differs under a taken tag's grown support; detail gives run/tag, count and exclusive input bbox | select a compatible donor and its actual original `st_input`; no tolerance or repaint is applied |
4451
+ | <a id="ASSEMBLE_TAKE_TAG_IN_RUN"></a>`ASSEMBLE_TAKE_TAG_IN_RUN` | selected donor does not hold the tag | choose a tag present in that donor |
4452
+ | <a id="ASSEMBLE_TAKE_SUPPORT"></a>`ASSEMBLE_TAKE_SUPPORT` | selected layer has zero alpha > 8 pixels | choose a nonempty donor layer |
4453
+ | <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 |
4454
+ | <a id="ASSEMBLE_TAKE_PLAN_ORDER"></a>`ASSEMBLE_TAKE_PLAN_ORDER` | supplied plan reverses mixed-run depth order | use the depth order drafted by `assemble --propose-plan` |
4455
+ | <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 |
4456
+ | <a id="ASSEMBLE_TAKE_META"></a>`ASSEMBLE_TAKE_META` | donor `meta.json` lacks a readable prompt ID or seed | retain the original run metadata |
4457
+ | <a id="ASSEMBLE_TAKE_INPUT"></a>`ASSEMBLE_TAKE_INPUT` | declared donor input PNG cannot be read | name its original PNG relative to the config |
4458
+ | <a id="ASSEMBLE_TAKE_DEPTH_SHEET"></a>`ASSEMBLE_TAKE_DEPTH_SHEET` | taking layers with an active depth opt-in or bound scalar sheets has no declared mixed-run binding | keep scalar depth inactive; numeric ordering depth is retained, and a target sheet is never attached silently |
4459
+ | <a id="ASSEMBLE_TAKE_HIDDEN"></a>`ASSEMBLE_TAKE_HIDDEN` | a replaced tag has hidden companion art | independent hidden-art identity is undeclared; choose a run without that companion |
4460
+ | <a id="ASSEMBLE_TAKE_UNIQUE"></a>`ASSEMBLE_TAKE_UNIQUE` | repeated donor for one target tag at runtime | declare each target run/tag once |
4461
+ | <a id="CONFIG_TAKE_UNIQUE"></a>`CONFIG_TAKE_UNIQUE` | config selects the same target run/tag more than once | retain one donor per target tag |
4462
+ | <a id="PARTS_TAKEN_LAYERS"></a>`PARTS_TAKEN_LAYERS` | a taken-layer receipt is partial, unknown, duplicated, malformed or claims differing pixels | regenerate parts with assemble; never patch source receipts by hand |
4368
4463
  | <a id="ASSEMBLE_FIELD_PRESENT"></a>`ASSEMBLE_FIELD_PRESENT` | `seethrough.head_box` is missing | `propose --head-box` proposes it (§3) |
4369
4464
  | <a id="ASSEMBLE_RIG_SIZE"></a>`ASSEMBLE_RIG_SIZE` | `rig_scale` makes an empty rig | `assemble.rig_scale` |
4370
4465
  | <a id="ASSEMBLE_HEAD_BOX_INSIDE"></a>`ASSEMBLE_HEAD_BOX_INSIDE` | the head box is outside the painting | `head_box` — `propose --head-box` holds it inside |
package/docs/STABILITY.md CHANGED
@@ -129,10 +129,11 @@ part name, `[i]` an entry of a list):
129
129
  - **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
130
130
  - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
131
131
  - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
132
- - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`
132
+ - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`
133
133
  - **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
134
134
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
135
135
  - **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
136
+ - **assemble.take[i]** — required: `run`, `run_dir`, `st_input`, `tags` (issue #866)
136
137
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
137
138
  - **assemble.patches[i].draw**, when an object — required: `before`
138
139
  - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`, `under_fill`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.82.2",
3
+ "version": "1.83.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assemble.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { unionTakenSources } from './takesource.ts';
1
2
  import { paintingExtensionMasks, paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
2
3
  import { AUTOMATIC_WRITERS as AW, compactValueBaseline, pixelProvenance, rgbaEqual, type ValueBaseline } from './provenance.ts';
3
4
  import { bindDepthRun, copyDepthMaterial, registerDepthRun, transportPartDepth, type DepthConfig, type RegisteredDepth } from './depthsheets.ts';
@@ -1494,6 +1495,7 @@ export function applyArmSplit(set: LayerSet, resolution: number, armSplit: ArmSp
1494
1495
  name,
1495
1496
  tag: readLayerTag(name) as LayerTagReading,
1496
1497
  file: refLayer.file,
1498
+ takenLayers: unionTakenSources(handLayers.map(l => l.takenLayers)),
1497
1499
  pixels: crop(canvas, m.x0, m.y0, m.x1 - m.x0 + 1, m.y1 - m.y0 + 1),
1498
1500
  left: m.x0,
1499
1501
  top: m.y0,
@@ -1803,6 +1805,7 @@ export function splitMergedArmRunsWithNotes(
1803
1805
  name,
1804
1806
  tag: readLayerTag(name) as LayerTagReading,
1805
1807
  file: hwLayer.file,
1808
+ ...(hwLayer.takenLayers === undefined ? {} : { takenLayers: hwLayer.takenLayers }),
1806
1809
  pixels: crop(canvas, x0, y0, x1 - x0 + 1, y1 - y0 + 1),
1807
1810
  left: x0,
1808
1811
  top: y0,
@@ -2098,6 +2101,7 @@ function prepareHeadFace(
2098
2101
  const postStats = measureCoverage(comp);
2099
2102
  const replacedLayer: Layer = {
2100
2103
  ...headFaceLayer,
2104
+ takenLayers: unionTakenSources([headFaceLayer.takenLayers, fullFaceLayer.takenLayers]),
2101
2105
  pixels: crop(comp, x0, y0, x1 - x0 + 1, y1 - y0 + 1),
2102
2106
  left: x0,
2103
2107
  top: y0,
@@ -5522,6 +5526,13 @@ export function assemble(input: AssembleInput): AssembleResult {
5522
5526
  merged_px: merged,
5523
5527
  seam_override_px: 0,
5524
5528
  };
5529
+ const sourceKeys = new Set([key, ...extendKeys.filter((_, i) => trace.from.some(v => v === i))]);
5530
+ const takenLayers = unionTakenSources([...sourceKeys].map(k => {
5531
+ const colon = k.indexOf(':');
5532
+ const run = k.slice(0, colon) as Run;
5533
+ return (run === 'full' ? full : head).layers.find(l => l.name === k.slice(colon + 1))?.takenLayers;
5534
+ }));
5535
+ if (takenLayers.length > 0) record.taken_layers = takenLayers;
5525
5536
  const paintingGain = paintingGains.get(name);
5526
5537
  if (paintingGain !== undefined) record.painting_extension_px = paintingGain.data.reduce((sum, value) => sum + value, 0);
5527
5538
  if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
@@ -5542,6 +5553,15 @@ export function assemble(input: AssembleInput): AssembleResult {
5542
5553
  },
5543
5554
  ...((limb?.cap?.image_px ?? 0) > 0 || (limb?.cap?.lower_px ?? 0) > 0 ? { unsupported: `cap copies ${(limb?.cap?.image_px ?? 0) + (limb?.cap?.lower_px ?? 0)} pixels from a separate cap image whose scalar grid is undeclared` } : {}),
5544
5555
  });
5556
+ if (capSource !== null && upperIndex >= 0 && capSource.codes.some(code => code === CAP_FROM_UPPER)) {
5557
+ const upper = limbPieces[upperIndex];
5558
+ const keys = [upper.key, ...upper.extendKeys.filter((_, i) => upper.trace.from.some(v => v === i))];
5559
+ const sources = unionTakenSources([record.taken_layers, ...keys.map(k => {
5560
+ const colon = k.indexOf(':');
5561
+ return (k.slice(0, colon) === 'full' ? full : head).layers.find(l => l.name === k.slice(colon + 1))?.takenLayers;
5562
+ })]);
5563
+ if (sources.length > 0) record.taken_layers = sources;
5564
+ }
5545
5565
  if (scalar !== undefined) record.depth = scalar.reference;
5546
5566
  if (scalar !== undefined && scalar.clampedSamples > 0) depthClamps.push({ part: name, samples: scalar.clampedSamples });
5547
5567
  set({ record, image, ...(scalar === undefined ? {} : { depthImage: scalar.image }) });
package/src/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { applyTakenLayers } from './takelayers.ts';
1
2
  import { ownershipLines, provenanceLines } from './provenance.ts';
2
3
  import { readSourceDepth } from './depthsheets.ts';
3
4
  import { orderedBaseAnchor } from './baseanchor.ts';
@@ -320,7 +321,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
320
321
  // regions and motion, so it requires only what it reads (issue #20).
321
322
  const cfg = loadEarlyConfig(input.config, 'assemble');
322
323
  const src = readSource(input.source);
323
- const runs = readRuns(input.full, input.head);
324
+ const runs = applyTakenLayers(readRuns(input.full, input.head), src, cfg, input.config);
325
+ for (const note of runs.notes) log(`note: ${note}`);
324
326
  const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
325
327
  const under = readUnders(input.config, fields.cuts);
326
328
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { TakeLayers } from './takelayers.ts';
1
2
  import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
2
3
  import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
3
4
  import type { DepthConfig } from './depthsheets.ts';
@@ -299,6 +300,8 @@ export interface AlternateSetConfig {
299
300
  }
300
301
 
301
302
  export interface Assemble {
303
+ /** Verified named replacements from another See-through run (#866). */
304
+ take?: TakeLayers[];
302
305
  /** Rig pixels per source pixel. */
303
306
  rig_scale: number;
304
307
  plan: PlanEntry[];
@@ -1619,7 +1622,7 @@ export interface EarlyConfig {
1619
1622
  depth?: DepthConfig;
1620
1623
  key: string;
1621
1624
  seethrough: SeeThrough;
1622
- assemble: { rig_scale: number; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1625
+ assemble: { rig_scale: number; take?: TakeLayers[]; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1623
1626
  }
1624
1627
 
1625
1628
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1688,7 +1691,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1688
1691
  if (door === 'layers') {
1689
1692
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1690
1693
  if ('assemble' in t) {
1691
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting']);
1694
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take']);
1692
1695
  if (a !== null) {
1693
1696
  if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1694
1697
  if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
@@ -1696,6 +1699,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1696
1699
  }
1697
1700
  checkKeepUnderOccluders(c, a, null);
1698
1701
  if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
1702
+ if ('take' in a) checkTakeLayers(c, a.take);
1699
1703
  if ('yield_refused' in a && typeof a.yield_refused !== 'boolean') {
1700
1704
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.yield_refused', `is ${show(a.yield_refused)}; true or false is required`);
1701
1705
  }
@@ -2433,9 +2437,29 @@ function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readon
2433
2437
  });
2434
2438
  }
2435
2439
 
2440
+ function checkTakeLayers(c: Check, value: Json): void {
2441
+ const at = 'config.assemble.take';
2442
+ if (!c.array(at, value, false)) return;
2443
+ const seen = new Set<string>();
2444
+ (value as Json[]).forEach((v, i) => {
2445
+ const p = `${at}[${i}]`, o = c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
2446
+ if (o === null) return;
2447
+ if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
2448
+ for (const k of ['run_dir', 'st_input']) c.string(`${p}.${k}`, o[k]);
2449
+ if (!c.array(p + '.tags', o.tags, true)) return;
2450
+ (o.tags as Json[]).forEach((tag, j) => {
2451
+ const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
2452
+ if (reading === null || reading.group === 'author') c.fail('CONFIG_TAG_KNOWN', `${p}.tags[${j}]`, 'a See-through tag is required; author layers do not carry a See-through role');
2453
+ const key = `${String(o.run)}:${String(tag)}`;
2454
+ if (seen.has(key)) c.fail('CONFIG_TAKE_UNIQUE', `${p}.tags[${j}]`, `names ${key} again; one donor per target tag is required`);
2455
+ seen.add(key);
2456
+ });
2457
+ });
2458
+ }
2459
+
2436
2460
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
2437
2461
  const required = sectionRequired(door, 'assemble');
2438
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting']);
2462
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take']);
2439
2463
  if (a === null) return { parts: [], patches: [], cuts: [] };
2440
2464
  if (required.includes('plan') && !('plan' in a)) {
2441
2465
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
@@ -2471,6 +2495,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
2471
2495
  }
2472
2496
  checkKeepUnderOccluders(c, a, parts);
2473
2497
  if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
2498
+ if ('take' in a) checkTakeLayers(c, a.take);
2474
2499
  if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
2475
2500
  (a.extend_below_crop as Json[]).forEach((entry, i) => {
2476
2501
  const at = `${p}.extend_below_crop[${i}]`;
package/src/layers.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { TakenLayerSource } from './takesource.ts';
1
2
  /**
2
3
  * The input contract: one See-through decomposition as a `LayerSet`.
3
4
  *
@@ -53,6 +54,8 @@ export const CORE_ALPHA = 250;
53
54
  export const NEAR_WHITE_MIN = 235;
54
55
 
55
56
  export interface Layer {
57
+ /** Verified donor identities retained through generated splits. */
58
+ takenLayers?: TakenLayerSource[];
56
59
  /** As the input names it: a v3 tag, possibly with a `-r`/`-l` side, or an author-made layer's `author:<label>`. */
57
60
  name: string;
58
61
  tag: LayerTagReading;
package/src/parts.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { takenSourceProblems, type TakenLayerSource } from './takesource.ts';
1
2
  import { AUTOMATIC_WRITERS, provenanceAddsUp, type PixelProvenance, type CutOwnership } from './provenance.ts';
2
3
  import { baseAnchorProblems, orderedBaseAnchor, type BaseAnchor } from './baseanchor.ts';
3
4
  import { partDepthProblems, type PartDepthReference } from './depthsheets.ts';
@@ -97,6 +98,8 @@ import { edgeLength } from './length.ts';
97
98
  import { recordedFocusProblems } from './setfocus.ts';
98
99
 
99
100
  export interface PartRecord {
101
+ /** Verified donor sources, including sources of cuts, limbs and extensions. */
102
+ taken_layers?: TakenLayerSource[];
100
103
  /** Report-only final last-writer census (#817); absent on older records. */
101
104
  provenance?: PixelProvenance;
102
105
  /** Painting ownership gained by #848, independent of final raw-value equality. */
@@ -426,6 +429,7 @@ export const PART_KEYS = [
426
429
  'name',
427
430
  'from',
428
431
  'cut_from',
432
+ 'taken_layers',
429
433
  'x',
430
434
  'y',
431
435
  'w',
@@ -451,10 +455,10 @@ const FRINGE_KEY = 'fringe_pushed_back';
451
455
  const CUT_FROM_KEY = 'cut_from';
452
456
  /** The one part field that is a record of its own: a limb segment's provenance (issue #349), written only on one. */
453
457
  const LIMB_KEY = 'limb';
454
- const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance') as ReadonlyArray<(typeof PART_KEYS)[number]>;
458
+ const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance' && k !== 'taken_layers') as ReadonlyArray<(typeof PART_KEYS)[number]>;
455
459
  /** The port's own counts: all three or none (none = a record the reference wrote). */
456
460
  export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
457
- const REQUIRED_KEYS = PART_KEYS.filter((k) => k !== 'painting_extension_px' && !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance');
461
+ const REQUIRED_KEYS = PART_KEYS.filter((k) => k !== 'painting_extension_px' && !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance' && k !== 'taken_layers');
458
462
 
459
463
  /** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
460
464
  export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
@@ -504,7 +508,7 @@ function orderedProvenance(p: PixelProvenance): PixelProvenance {
504
508
 
505
509
  function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
506
510
  return parts.map((p) =>
507
- Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : k === 'provenance' ? orderedProvenance(p.provenance as PixelProvenance) : p[k]])),
511
+ Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : k === 'taken_layers' ? p.taken_layers?.map(t => ({ run: t.run, tag: t.tag, run_dir: t.run_dir, st_input: t.st_input, prompt_id: t.prompt_id, seed: t.seed, support_px: t.support_px, checked_px: t.checked_px, differing_px: t.differing_px })) : k === 'provenance' ? orderedProvenance(p.provenance as PixelProvenance) : p[k]])),
508
512
  );
509
513
  }
510
514
 
@@ -649,6 +653,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
649
653
  return;
650
654
  }
651
655
  const r = p as Record<string, unknown>;
656
+ if ('taken_layers' in r) problems.push(...takenSourceProblems(r.taken_layers, `${at}.taken_layers`));
652
657
  if ('depth' in r) problems.push(...partDepthProblems(r.depth, `${path} part depth`));
653
658
  const label = typeof r.name === 'string' ? `part "${r.name}"` : at;
654
659
  for (const k of Object.keys(r)) if (!(PART_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${label} key "${k}"`, `is not a part field; known: ${PART_KEYS.join(', ')}`);
@@ -0,0 +1,110 @@
1
+ /** Verified replacement in an existing full/head run; never writes or forges a run manifest. */
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import type { EarlyConfig, PlanEntry, Run } from './config.ts';
5
+ import { CORE_KERNEL } from './assemble.ts';
6
+ import { makeInputs } from './inputs.ts';
7
+ import { OPAQUE_ALPHA_ABOVE, readLayers, type Layer, type LayerSet } from './layers.ts';
8
+ import { PartsError, refuseIfAny, type Problem } from './errors.ts';
9
+ import { dilate } from './raster/morph.ts';
10
+ import { newMask, type Raster } from './raster/types.ts';
11
+ import { readPng } from './raster/png.ts';
12
+ import type { TakenLayerSource } from './takesource.ts';
13
+
14
+ export interface TakeLayers {
15
+ run: Run;
16
+ run_dir: string;
17
+ /** The exact PNG supplied to See-through, resolved from the config directory. */
18
+ st_input: string;
19
+ tags: string[];
20
+ }
21
+ function refuse(code: string, object: string, detail: string): never { throw new PartsError([{ code, object, detail }]); }
22
+ function metadata(path: string): { prompt_id: string; seed: number } {
23
+ let value: unknown;
24
+ try { value = JSON.parse(readFileSync(path, 'utf8')); } catch { return refuse('ASSEMBLE_TAKE_META', path, 'a readable meta.json with prompt_id and seed from the donor run is required'); }
25
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return refuse('ASSEMBLE_TAKE_META', path, 'a metadata object with prompt_id and seed is required');
26
+ const o = value as Record<string, unknown>;
27
+ if (typeof o.prompt_id !== 'string' || !o.prompt_id.trim() || !Number.isSafeInteger(o.seed) || (o.seed as number) < 0) return refuse('ASSEMBLE_TAKE_META', path, 'a nonempty prompt_id and nonnegative safe integer seed are required; neither is invented');
28
+ return { prompt_id: o.prompt_id, seed: o.seed as number };
29
+ }
30
+ function identity(layer: Layer, canvas: LayerSet['canvas'], donor: Raster, target: Raster, object: string): { support: number; checked: Uint8Array } {
31
+ const mask = newMask(canvas.w, canvas.h); let support = 0;
32
+ for (let y = 0; y < layer.pixels.height; y++) for (let x = 0; x < layer.pixels.width; x++) if (layer.pixels.data[(y * layer.pixels.width + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
33
+ mask.data[(layer.top + y) * canvas.w + layer.left + x] = 1; support++;
34
+ }
35
+ if (support === 0) return refuse('ASSEMBLE_TAKE_SUPPORT', object, `has 0 pixels of alpha above ${OPAQUE_ALPHA_ABOVE}; a nonempty taken layer is required`);
36
+ const grown = dilate(mask, CORE_KERNEL, CORE_KERNEL);
37
+ const checked = new Uint8Array(target.width * target.height);
38
+ // A run pixel is a cell on st_input, not a sample that can be rounded to a
39
+ // neighbouring centre. Compare EVERY intersecting input cell before any resize.
40
+ for (let y = 0; y < canvas.h; y++) for (let x = 0; x < canvas.w; x++) if (grown.data[y * canvas.w + x] === 1) {
41
+ for (let iy = Math.floor(y * target.height / canvas.h); iy < Math.ceil((y + 1) * target.height / canvas.h); iy++)
42
+ for (let ix = Math.floor(x * target.width / canvas.w); ix < Math.ceil((x + 1) * target.width / canvas.w); ix++) checked[iy * target.width + ix] = 1;
43
+ }
44
+ let differing = 0, x0 = target.width, y0 = target.height, x1 = 0, y1 = 0;
45
+ for (let p = 0; p < checked.length; p++) if (checked[p] === 1 && [0, 1, 2, 3].some(c => donor.data[p * 4 + c] !== target.data[p * 4 + c])) {
46
+ differing++; const x = p % target.width, y = Math.floor(p / target.width); x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x + 1); y1 = Math.max(y1, y + 1);
47
+ }
48
+ if (differing > 0) return refuse('ASSEMBLE_TAKE_INPUT_IDENTITY', object, `${differing} differing pixel(s), bbox [${x0}, ${y0}, ${x1}, ${y1}] in st_input pixels (right/bottom exclusive); exact RGBA identity under alpha > ${OPAQUE_ALPHA_ABOVE}, grown by ${CORE_KERNEL}x${CORE_KERNEL}, is required`);
49
+ return { support, checked };
50
+ }
51
+ export function takeLayerNote(source: TakenLayerSource): string {
52
+ return `take ${source.run}:${source.tag} from ${source.run_dir}; prompt_id ${source.prompt_id}; seed ${source.seed}; st_input ${source.st_input}; support ${source.support_px} run px; checked ${source.checked_px} input px; differing ${source.differing_px}; alpha > ${OPAQUE_ALPHA_ABOVE}, ${CORE_KERNEL}x${CORE_KERNEL} margin`;
53
+ }
54
+ /** Apply every declaration together, so replacing all tied layers from one run can retain its known order. */
55
+ export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single: boolean }, painting: Raster, cfg: Pick<EarlyConfig, 'seethrough' | 'depth'> & { assemble: { take?: TakeLayers[]; plan?: PlanEntry[] } }, configPath: string): { full: LayerSet; head: LayerSet; single: boolean; notes: string[] } {
56
+ const declarations = cfg.assemble.take ?? [];
57
+ if (declarations.length === 0) return { ...runs, notes: [] };
58
+ 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)');
59
+ const inputs = makeInputs(painting, cfg, 'take target painting');
60
+ const base = dirname(resolve(configPath));
61
+ const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
62
+ const origins = new Map<Layer, string>();
63
+ const notes: string[] = [], seen = new Set<string>();
64
+ for (const d of declarations) {
65
+ const at = `config.assemble.take run "${d.run_dir}" (${d.run})`;
66
+ if (d.run === 'head' && runs.single) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, 'there is no --head run; choose full or supply a head run');
67
+ const target = d.run === 'head' ? inputs.head : runs.single && (runs.full.canvas.w !== runs.full.canvas.h) ? painting : inputs.full;
68
+ const set = runs[d.run];
69
+ const donor = readLayers(resolve(base, d.run_dir));
70
+ const meta = metadata(resolve(base, d.run_dir, 'meta.json'));
71
+ let image: Raster;
72
+ try { image = readPng(resolve(base, d.st_input)); } catch { return refuse('ASSEMBLE_TAKE_INPUT', at, `st_input "${d.st_input}" must name the original readable PNG supplied to the donor run`); }
73
+ if (target === null || donor.canvas.w !== set.canvas.w || donor.canvas.h !== set.canvas.h || image.width !== target.width || image.height !== target.height) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, `donor canvas ${donor.canvas.w}x${donor.canvas.h}, target canvas ${set.canvas.w}x${set.canvas.h}, donor st_input ${image.width}x${image.height}, target st_input ${target?.width ?? 0}x${target?.height ?? 0}; equal canvases and equal input dimensions are required (full uses squarePad; head uses head_box)`);
74
+ const union = new Uint8Array(image.width * image.height);
75
+ for (const tag of d.tags) {
76
+ const key = `${d.run}:${tag}`;
77
+ if (seen.has(key)) return refuse('ASSEMBLE_TAKE_UNIQUE', at, `tag "${tag}" replaces ${key} twice; declare one donor per target tag`);
78
+ seen.add(key);
79
+ const layer = donor.layers.find(l => l.name === tag);
80
+ if (layer === undefined) return refuse('ASSEMBLE_TAKE_TAG_IN_RUN', at, `tag "${tag}" is absent; a named See-through tag held by that donor is required`);
81
+ const checked = identity(layer, donor.canvas, image, target, `${at} tag "${tag}"`);
82
+ for (let p = 0; p < union.length; p++) union[p] |= checked.checked[p];
83
+ const source: TakenLayerSource = { run: d.run, tag, run_dir: d.run_dir, st_input: d.st_input, prompt_id: meta.prompt_id, seed: meta.seed, support_px: checked.support, checked_px: checked.checked.reduce((a, b) => a + b, 0), differing_px: 0 };
84
+ const replacement: Layer = { ...layer, takenLayers: [source] };
85
+ lists[d.run] = [...lists[d.run].filter(l => l.name !== tag), replacement];
86
+ origins.set(replacement, resolve(base, d.run_dir));
87
+ notes.push(takeLayerNote(source));
88
+ }
89
+ notes.push(`take union ${d.run} from ${d.run_dir}: checked ${union.reduce((a, b) => a + b, 0)} input px; differing 0`);
90
+ }
91
+ for (const run of ['full', 'head'] as const) {
92
+ const list = lists[run], targetOrigin = resolve(dirname(runs[run].source));
93
+ const origin = (l: Layer): string => origins.get(l) ?? targetOrigin;
94
+ const problems: Problem[] = [];
95
+ for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) if (origin(list[i]) !== origin(list[j]) && (list[i].depth === null || list[j].depth === null || list[i].depth === list[j].depth)) problems.push({ code: 'ASSEMBLE_TAKE_DEPTH_AMBIGUOUS', object: `${run}: "${list[i].name}" (${origin(list[i])}) / "${list[j].name}" (${origin(list[j])})`, detail: `depths ${list[i].depth} / ${list[j].depth}; distinct numeric depths across runs are required; manifest order resolves ties only inside the same run` });
96
+ refuseIfAny(problems);
97
+ list.sort((a, b) => a.depth === null || b.depth === null || a.depth === b.depth ? a.drawOrder - b.drawOrder : b.depth - a.depth);
98
+ lists[run] = list.map((l, drawOrder) => ({ ...l, drawOrder }));
99
+ const selected = (cfg.assemble.plan ?? []).filter(p => p[1] === run).map(p => ({ part: p[0], layer: list.find(l => l.name === p[2]) }));
100
+ for (let i = 0; i < selected.length; i++) for (let j = i + 1; j < selected.length; j++) {
101
+ const a = selected[i], b = selected[j];
102
+ if (a.layer !== undefined && b.layer !== undefined && origin(a.layer) !== origin(b.layer) && list.indexOf(a.layer) > list.indexOf(b.layer)) problems.push({ code: 'ASSEMBLE_TAKE_PLAN_ORDER', object: `${run} plan parts "${a.part}" / "${b.part}"`, detail: `plan order reverses mixed-run depths ${a.layer.depth} / ${b.layer.depth}; put the larger depth behind, as in assemble --propose-plan` });
103
+ }
104
+ refuseIfAny(problems);
105
+ const replaced = new Set(declarations.filter(d => d.run === run).flatMap(d => d.tags));
106
+ if (runs[run].hidden.some(l => replaced.has(l.of)) || declarations.some(d => d.run === run && readLayers(resolve(base, d.run_dir)).hidden.some(l => d.tags.includes(l.of)))) return refuse('ASSEMBLE_TAKE_HIDDEN', run, 'a replaced layer has hidden companion art; separate hidden provenance is not declared, so the combination is refused');
107
+ }
108
+ const full = { ...runs.full, layers: lists.full }, head = runs.single ? full : { ...runs.head, layers: lists.head };
109
+ return { full, head, single: runs.single, notes };
110
+ }
@@ -0,0 +1,43 @@
1
+ /** Layer identity receipts, retained independently of part names and derived geometry. */
2
+ import type { Problem } from './errors.ts';
3
+ import { readLayerTag } from './tags.ts';
4
+ export interface TakenLayerSource {
5
+ run: 'full' | 'head';
6
+ tag: string;
7
+ run_dir: string;
8
+ st_input: string;
9
+ prompt_id: string;
10
+ seed: number;
11
+ support_px: number;
12
+ checked_px: number;
13
+ differing_px: 0;
14
+ }
15
+ export function unionTakenSources(lists: readonly (readonly TakenLayerSource[] | undefined)[]): TakenLayerSource[] {
16
+ const out: TakenLayerSource[] = [];
17
+ for (const list of lists) for (const item of list ?? []) if (!out.some(p => p.run === item.run && p.tag === item.tag && p.run_dir === item.run_dir)) out.push(item);
18
+ return out;
19
+ }
20
+ /** Strict parts reader: unknown fields and partial or invented identity receipts are refused. */
21
+ export function takenSourceProblems(value: unknown, at: string): Problem[] {
22
+ const problems: Problem[] = [];
23
+ const fail = (detail: string): void => { problems.push({ code: 'PARTS_TAKEN_LAYERS', object: at, detail }); };
24
+ const keys = ['run', 'tag', 'run_dir', 'st_input', 'prompt_id', 'seed', 'support_px', 'checked_px', 'differing_px'];
25
+ if (!Array.isArray(value) || value.length === 0) { fail('a non-empty list of verified layer sources is required'); return problems; }
26
+ const seen = new Set<string>();
27
+ for (const item of value) {
28
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) { fail('each source must be a complete identity receipt'); continue; }
29
+ const o = item as Record<string, unknown>;
30
+ const tag = typeof o.tag === 'string' ? readLayerTag(o.tag) : null;
31
+ if (Object.keys(o).some(k => !keys.includes(k)) || keys.some(k => !(k in o)) ||
32
+ (o.run !== 'full' && o.run !== 'head') || tag === null || tag.group === 'author' ||
33
+ ['run_dir', 'st_input', 'prompt_id'].some(k => typeof o[k] !== 'string' || !(o[k] as string).trim()) ||
34
+ !Number.isSafeInteger(o.seed) || (o.seed as number) < 0 ||
35
+ ['support_px', 'checked_px'].some(k => !Number.isSafeInteger(o[k]) || (o[k] as number) <= 0) || o.differing_px !== 0) {
36
+ fail('each source requires exactly run, See-through tag, run_dir, st_input, prompt_id, nonnegative safe integer seed, positive support_px/checked_px and differing_px 0'); continue;
37
+ }
38
+ const key = JSON.stringify([o.run, o.tag, o.run_dir]);
39
+ if (seen.has(key)) fail(`duplicate source ${key}; each source must appear once`);
40
+ seen.add(key);
41
+ }
42
+ return problems;
43
+ }