rig-parts 1.82.2 → 1.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts 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,99 @@ 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. Plan-order checks compare only
672
+ different original runs' layers whose original supports intersect (both alpha
673
+ > 8, in the target-run canvas, without dilation). Disjoint supports have no
674
+ visible order; intersecting bounding boxes alone do not establish overlap.
675
+ Within one role band, larger depth must draw behind smaller depth. In a two-run
676
+ plan, full-run rows follow the proposer's head-fallback roles before scalar
677
+ depth: back hair, body, headwear/earwear, face, head details, front hair. Thus
678
+ fallback headwear is after the body and before the face, and fallback front
679
+ hair stays over the face even when its donor depth is larger. Roles come from
680
+ source tags, never part names. Head-run rows and single-run plans keep scalar
681
+ depth order. A supplied plan reversing an overlapping mixed-run pair under
682
+ these rules is refused; `--propose-plan` drafts the same order. Null/tied depth
683
+ ambiguity remains a refusal even for disjoint supports.
684
+ Replacing a layer with hidden companion art is refused because independent
685
+ hidden-art identity has not been declared. Active scalar depth sheets (`depth`
686
+ opt-in or already bound sheets) are also refused: selecting donor art does not
687
+ rebind a target atlas or invent donor scalar units. Inactive `depth_filename`
688
+ hints remain inactive; numeric `depth_median` ordering is preserved.
689
+
690
+ The proposal's `notes` and assemble/build log name every taken layer's run
691
+ directory, prompt ID, seed and input path, plus support/checked/differing counts.
692
+ `parts.json` adds `taken_layers` only to affected parts: each entry records
693
+ `run`, `tag`, `run_dir`, `st_input`, `prompt_id`, `seed`, `support_px` (run cells),
694
+ `checked_px` (original input cells), and `differing_px` (zero). Cuts, generated
695
+ arm layers, limb segments and copied extension sources retain these receipts.
696
+ The strict parts reader refuses partial, unknown, duplicate or nonzero-difference
697
+ receipts. These source identities are separate from pixel-writer counts.
698
+
699
+ Motivation, from the recorded 2026-10-30 decomposition measurements: a side-view
700
+ control held 62,627 front-hair, 36,643 headwear and 595 mouth pixels at
701
+ **alpha >= 250**. Three body repaints reported zero pixels at that threshold
702
+ for front hair and mouth at seed 42; their alpha > 8 counts were nonzero.
703
+ Six further runs at seeds 43, 101 and 2027 reported no run retaining both front
704
+ hair and headwear. These are recorded measurements, not a new pixel audit:
705
+ the retained run receipts contain no original layer PNGs. The records report
706
+ identical head input pixels and body-dependent limb results; this feature makes
707
+ identity an executable check on supplied original inputs instead of trusting
708
+ that claim or hand-editing `layers.json`. Seed and painting selection remain
709
+ the author's decisions.
710
+
618
711
  ### Alternate part layers (`assemble.alternates[i].layers`, issue #666)
619
712
 
620
713
  A gesture run may hold a raised arm only in a layer the author added, while
@@ -4365,6 +4458,19 @@ those settings. Changing a config seed alone cannot change a no-config run.
4365
4458
 
4366
4459
  | rule | means | change |
4367
4460
  | --- | --- | --- |
4461
+ | <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 |
4462
+ | <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 |
4463
+ | <a id="ASSEMBLE_TAKE_SUPPORT"></a>`ASSEMBLE_TAKE_SUPPORT` | selected layer has zero alpha > 8 pixels | choose a nonempty donor layer |
4464
+ | <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 |
4465
+ | <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 |
4466
+ | <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 |
4467
+ | <a id="ASSEMBLE_TAKE_META"></a>`ASSEMBLE_TAKE_META` | donor `meta.json` lacks a readable prompt ID or seed | retain the original run metadata |
4468
+ | <a id="ASSEMBLE_TAKE_INPUT"></a>`ASSEMBLE_TAKE_INPUT` | declared donor input PNG cannot be read | name its original PNG relative to the config |
4469
+ | <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 |
4470
+ | <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 |
4471
+ | <a id="ASSEMBLE_TAKE_UNIQUE"></a>`ASSEMBLE_TAKE_UNIQUE` | repeated donor for one target tag at runtime | declare each target run/tag once |
4472
+ | <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 |
4473
+ | <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
4474
  | <a id="ASSEMBLE_FIELD_PRESENT"></a>`ASSEMBLE_FIELD_PRESENT` | `seethrough.head_box` is missing | `propose --head-box` proposes it (§3) |
4369
4475
  | <a id="ASSEMBLE_RIG_SIZE"></a>`ASSEMBLE_RIG_SIZE` | `rig_scale` makes an empty rig | `assemble.rig_scale` |
4370
4476
  | <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.1",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/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 }) });
@@ -6028,6 +6048,18 @@ export const PROPOSE_HEAD_TAGS: readonly string[] = [
6028
6048
  'eyebrow-l',
6029
6049
  ];
6030
6050
 
6051
+ /** #869: two-run full rows are body rows or head fallbacks, regardless of part spelling.
6052
+ * Within one band the run's depth order survives; the bands encode the proposer's roles.
6053
+ */
6054
+ export function fullPlanBand(tag: string): number {
6055
+ if (tag === 'back hair') return 0;
6056
+ if (!PROPOSE_HEAD_TAGS.includes(tag)) return 1;
6057
+ if (tag === 'headwear' || tag === 'earwear') return 2;
6058
+ if (tag === 'face') return 3;
6059
+ if (tag === 'front hair') return 5;
6060
+ return 4;
6061
+ }
6062
+
6031
6063
  /** #863: prefer suitable head layers; only unusable head candidates may fall back for these tags. */
6032
6064
  const HEAD_FALLBACK_TAGS: readonly string[] = ['back hair', 'face', 'front hair'];
6033
6065
 
@@ -6086,7 +6118,7 @@ function fmt0(v: number): string {
6086
6118
  * A suitable head candidate is retained. Every fallback names the two rig
6087
6119
  * areas and the reason. A `headwear`/`earwear` the head run drops or
6088
6120
  * shrinks (its rig-pixel area below 0.8 of the full run's) is taken from the
6089
- * full run instead, drawn right after `back hair` — noted. `front hair` and
6121
+ * full run instead, drawn after the body and before the face — noted. `front hair` and
6090
6122
  * `back hair` taken from the head run are extended below the crop from the
6091
6123
  * full run when the full run's layer reaches more than 8 rig pixels below the
6092
6124
  * crop line. A lone `handwear` layer is named `sleeves`.
@@ -6212,7 +6244,7 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
6212
6244
  return tag !== 'nose' && !(run === 'head' && tag === 'face' && headFaceRefusal !== null) &&
6213
6245
  l !== undefined && l.opaqueRunPx >= floor && !implausible(run, tag, 'not proposed');
6214
6246
  };
6215
- const body = runs.full.filter((l) => !PROPOSE_HEAD_TAGS.includes(l.tag) && ok('full', l.tag)).map((l) => l.tag);
6247
+ const body = runs.full.filter((l) => fullPlanBand(l.tag) === 1 && ok('full', l.tag)).map((l) => l.tag);
6216
6248
  // Issue #256: an author-made layer is proposed from the run that holds it, at its place in that run's draw order,
6217
6249
  // like any layer; one both runs hold is proposed once, from the full run when the full run's is proposed, and the note says so.
6218
6250
  const authorBoth = runs.head.filter((l) => readAuthorTag(l.tag) !== null && body.includes(l.tag)).map((l) => l.tag);
@@ -6259,15 +6291,15 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
6259
6291
  const draw = t === 'back hair' ? 'behind the body and face' : t === 'face' ? 'before head details and front hair' : 'in front of the face';
6260
6292
  notes.push(`${t}: head run ${fmt0(hp)} rig px (${reason}); full run ${fmt0(fp)} rig px -> full-run layer, drawn ${draw}`);
6261
6293
  }
6262
- for (const t of ['headwear', 'earwear']) {
6294
+ // Keep the full run's order inside the accessory band, including mixed donors.
6295
+ for (const { tag: t } of runs.full.filter(l => fullPlanBand(l.tag) === 2)) {
6263
6296
  const hl = find('head', t);
6264
6297
  const fl = find('full', t);
6265
6298
  const hp = hl !== undefined ? hl.opaqueRunPx * headArea : 0;
6266
6299
  const fp = fl !== undefined ? fl.opaqueRunPx * fullArea : 0;
6267
6300
  if (ok('full', t) && hp < 0.8 * fp) {
6268
6301
  drop(t);
6269
- const at = plan.length > 0 && plan[0][2] === 'back hair' ? 1 : 0;
6270
- plan.splice(at, 0, [PROPOSE_ALIAS[t], 'full', t]);
6302
+ plan.push([PROPOSE_ALIAS[t], 'full', t]);
6271
6303
  notes.push(`${t}: head run ${fmt0(hp)} rig px < 0.8 x full run ${fmt0(fp)} -> full-run layer, drawn behind the face`);
6272
6304
  }
6273
6305
  }
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,127 @@
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, fullPlanBand } 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
+ /** Intersection of original alpha supports in the common target-run canvas; no bbox or dilation proxy. */
55
+ function supportOverlap(a: Layer, b: Layer): number {
56
+ let px = 0;
57
+ for (let y = Math.max(a.top, b.top); y < Math.min(a.bottom, b.bottom); y++)
58
+ for (let x = Math.max(a.left, b.left); x < Math.min(a.right, b.right); x++) {
59
+ if (a.pixels.data[((y - a.top) * a.pixels.width + x - a.left) * 4 + 3] > OPAQUE_ALPHA_ABOVE &&
60
+ b.pixels.data[((y - b.top) * b.pixels.width + x - b.left) * 4 + 3] > OPAQUE_ALPHA_ABOVE) px++;
61
+ }
62
+ return px;
63
+ }
64
+ /** Apply every declaration together, so replacing all tied layers from one run can retain its known order. */
65
+ export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single: boolean }, painting: Raster, cfg: Pick<EarlyConfig, 'seethrough' | 'depth'> & { assemble: { take?: TakeLayers[]; plan?: PlanEntry[] } }, configPath: string): { full: LayerSet; head: LayerSet; single: boolean; notes: string[] } {
66
+ const declarations = cfg.assemble.take ?? [];
67
+ if (declarations.length === 0) return { ...runs, notes: [] };
68
+ if (cfg.depth !== undefined || [...runs.full.layers, ...runs.head.layers].some(l => l.depthSheet !== undefined)) return refuse('ASSEMBLE_TAKE_DEPTH_SHEET', 'config.assemble.take', 'active scalar depth sheets have no mixed-run binding; disable the depth opt-in or assemble a separately bound run before taking layers (numeric depth_median ordering is retained)');
69
+ const inputs = makeInputs(painting, cfg, 'take target painting');
70
+ const base = dirname(resolve(configPath));
71
+ const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
72
+ const origins = new Map<Layer, string>();
73
+ const notes: string[] = [], seen = new Set<string>();
74
+ for (const d of declarations) {
75
+ const at = `config.assemble.take run "${d.run_dir}" (${d.run})`;
76
+ if (d.run === 'head' && runs.single) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, 'there is no --head run; choose full or supply a head run');
77
+ const target = d.run === 'head' ? inputs.head : runs.single && (runs.full.canvas.w !== runs.full.canvas.h) ? painting : inputs.full;
78
+ const set = runs[d.run];
79
+ const donor = readLayers(resolve(base, d.run_dir));
80
+ const meta = metadata(resolve(base, d.run_dir, 'meta.json'));
81
+ let image: Raster;
82
+ 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`); }
83
+ 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)`);
84
+ const union = new Uint8Array(image.width * image.height);
85
+ for (const tag of d.tags) {
86
+ const key = `${d.run}:${tag}`;
87
+ if (seen.has(key)) return refuse('ASSEMBLE_TAKE_UNIQUE', at, `tag "${tag}" replaces ${key} twice; declare one donor per target tag`);
88
+ seen.add(key);
89
+ const layer = donor.layers.find(l => l.name === tag);
90
+ 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`);
91
+ const checked = identity(layer, donor.canvas, image, target, `${at} tag "${tag}"`);
92
+ for (let p = 0; p < union.length; p++) union[p] |= checked.checked[p];
93
+ 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 };
94
+ const replacement: Layer = { ...layer, takenLayers: [source] };
95
+ lists[d.run] = [...lists[d.run].filter(l => l.name !== tag), replacement];
96
+ origins.set(replacement, resolve(base, d.run_dir));
97
+ notes.push(takeLayerNote(source));
98
+ }
99
+ notes.push(`take union ${d.run} from ${d.run_dir}: checked ${union.reduce((a, b) => a + b, 0)} input px; differing 0`);
100
+ }
101
+ for (const run of ['full', 'head'] as const) {
102
+ const list = lists[run], targetOrigin = resolve(dirname(runs[run].source));
103
+ const origin = (l: Layer): string => origins.get(l) ?? targetOrigin;
104
+ const problems: Problem[] = [];
105
+ 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` });
106
+ refuseIfAny(problems);
107
+ list.sort((a, b) => a.depth === null || b.depth === null || a.depth === b.depth ? a.drawOrder - b.drawOrder : b.depth - a.depth);
108
+ lists[run] = list.map((l, drawOrder) => ({ ...l, drawOrder }));
109
+ const selected = (cfg.assemble.plan ?? []).filter(p => p[1] === run).map(p => ({ part: p[0], layer: list.find(l => l.name === p[2]) }));
110
+ for (let i = 0; i < selected.length; i++) for (let j = i + 1; j < selected.length; j++) {
111
+ const a = selected[i], b = selected[j];
112
+ if (a.layer === undefined || b.layer === undefined || origin(a.layer) === origin(b.layer)) continue;
113
+ const overlap = supportOverlap(a.layer, b.layer);
114
+ if (overlap === 0) continue;
115
+ // Only full rows in a two-run plan have the proposer's fallback roles.
116
+ // Head rows and one-run plans retain the scalar order, as do peers in a band.
117
+ const band = !runs.single && run === 'full' ? fullPlanBand(a.layer.name) - fullPlanBand(b.layer.name) : 0;
118
+ const reversed = band === 0 ? list.indexOf(a.layer) > list.indexOf(b.layer) : band > 0;
119
+ if (reversed) problems.push({ code: 'ASSEMBLE_TAKE_PLAN_ORDER', object: `${run} plan parts "${a.part}" / "${b.part}"`, detail: `plan order reverses ${band === 0 ? `mixed-run depths ${a.layer.depth} / ${b.layer.depth}; put the larger depth behind` : `two-run full-layer roles "${a.layer.name}" / "${b.layer.name}"; order back hair, body, headwear/earwear, face, head details, front hair`}; supports overlap at ${overlap} run px (alpha > ${OPAQUE_ALPHA_ABOVE}); use assemble --propose-plan` });
120
+ }
121
+ refuseIfAny(problems);
122
+ const replaced = new Set(declarations.filter(d => d.run === run).flatMap(d => d.tags));
123
+ 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');
124
+ }
125
+ const full = { ...runs.full, layers: lists.full }, head = runs.single ? full : { ...runs.head, layers: lists.head };
126
+ return { full, head, single: runs.single, notes };
127
+ }
@@ -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
+ }