@rig-c/parts 1.28.0 → 1.30.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/src/assemble.ts CHANGED
@@ -99,12 +99,12 @@
99
99
  *
100
100
  * Pure: no clock, no randomness, no file access. The CLI reads and writes.
101
101
  */
102
- import type { BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
102
+ import type { ArmSplit, BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
103
103
  import { type Problem, refuseIfAny } from './errors.ts';
104
104
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
105
105
  import { padToSquare, squarePad } from './inputs.ts';
106
106
  import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
107
- import { assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
107
+ import { assignCrossedLimbs, assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
108
108
  import {
109
109
  alphaComposite,
110
110
  connectedComponents,
@@ -123,7 +123,7 @@ import {
123
123
  warpAffine,
124
124
  } from './raster/index.ts';
125
125
  import { pyRound } from './round.ts';
126
- import { AUTHOR_NO_ROLE, readAuthorTag } from './tags.ts';
126
+ import { AUTHOR_NO_ROLE, type LayerTagReading, readAuthorTag, readLayerTag } from './tags.ts';
127
127
 
128
128
  const f32 = Math.fround;
129
129
 
@@ -232,6 +232,8 @@ export interface Geometry {
232
232
  * per source pixel, and the painting must be the file's size.
233
233
  */
234
234
  single?: boolean;
235
+ /** `seethrough.arm_split` (issue #439): separates overlapping arms on the full run into `handwear-r` and `handwear-l` in the declared order. */
236
+ armSplit?: ArmSplit;
235
237
  }
236
238
 
237
239
  /**
@@ -323,6 +325,8 @@ export interface AssembleInput {
323
325
  caps?: ReadonlyMap<string, Raster>;
324
326
  /** One run (issue #359): `head` is `full`, `headBox` {@link singleRunHeadBox}, and {@link checkSingleRun} holds the plan to it. Absent: two runs. */
325
327
  single?: boolean;
328
+ /** `seethrough.arm_split` (issue #439): separates overlapping arms on the full run into `handwear-r` and `handwear-l` in the declared order. */
329
+ armSplit?: ArmSplit;
326
330
  }
327
331
 
328
332
  /** The derived numbers of the two runs' geometry. */
@@ -489,6 +493,238 @@ export function runLayers(set: LayerSet, resolution: number): RunLayer[] {
489
493
  });
490
494
  }
491
495
 
496
+ /**
497
+ * The integer split column(s) `armSplit` defines on run row `y`: pixels with
498
+ * `x < col` lie on the `handwear-r` side (left of the split in the image) and
499
+ * pixels with `x >= col` lie on the `handwear-l` side.
500
+ */
501
+ function splitColumnsAtRow(y: number, armSplit: ArmSplit): number[] {
502
+ if (armSplit.x !== undefined) return [Math.ceil(armSplit.x)];
503
+ const pts = armSplit.line ?? [];
504
+ if (pts.length === 0) return [0];
505
+ const cols: number[] = [];
506
+ for (let i = 0; i + 1 < pts.length; i++) {
507
+ const [xA, yA] = pts[i];
508
+ const [xB, yB] = pts[i + 1];
509
+ if (y < Math.min(yA, yB) || y > Math.max(yA, yB)) continue;
510
+ if (yA === yB) {
511
+ cols.push(Math.ceil(Math.min(xA, xB)));
512
+ if (xA !== xB) cols.push(Math.ceil(Math.max(xA, xB)));
513
+ } else {
514
+ cols.push(Math.ceil(xA + ((y - yA) * (xB - xA)) / (yB - yA)));
515
+ }
516
+ }
517
+ if (cols.length === 0) {
518
+ let minY = pts[0][1];
519
+ let minX = pts[0][0];
520
+ let maxY = pts[0][1];
521
+ let maxX = pts[0][0];
522
+ for (const [px, py] of pts) {
523
+ if (py < minY) {
524
+ minY = py;
525
+ minX = px;
526
+ }
527
+ if (py > maxY) {
528
+ maxY = py;
529
+ maxX = px;
530
+ }
531
+ }
532
+ cols.push(Math.ceil(y < minY ? minX : maxX));
533
+ }
534
+ return [...new Set(cols)].sort((a, b) => a - b);
535
+ }
536
+
537
+ /**
538
+ * Separate an overlapping handwear layer of the full run into `handwear-r` and
539
+ * `handwear-l` along `seethrough.arm_split` (issue #439), placed in the draw
540
+ * order `armSplit.order` declares.
541
+ *
542
+ * Refuses under `ASSEMBLE_ARM_SPLIT_CUTS_ARM` when:
543
+ * - the polyline crosses a single horizontal opaque run at two or more columns;
544
+ * - on a row where the two arms already have separate horizontal opaque runs,
545
+ * the split falls strictly inside one of those runs;
546
+ * - either resulting arm layer is left with 0 opaque pixels, or an arm is cut
547
+ * into disconnected components.
548
+ */
549
+ export function applyArmSplit(set: LayerSet, resolution: number, armSplit: ArmSplit): LayerSet {
550
+ const handLayers = set.layers.filter((l) => l.name === 'handwear-r' || l.name === 'handwear-l');
551
+ if (handLayers.length === 0) {
552
+ refuseIfAny([
553
+ {
554
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
555
+ object: 'config.seethrough.arm_split',
556
+ detail: `the full run (${set.source}) holds neither "handwear-r" nor "handwear-l"; a handwear layer with opaque pixels on both sides of the split is required`,
557
+ },
558
+ ]);
559
+ }
560
+ const combinedRaw = newRaster(resolution, resolution);
561
+ for (const l of handLayers) {
562
+ const { canvas } = cleanGhosts(placeLayer(l, resolution));
563
+ for (let p = 0; p < resolution * resolution; p++) {
564
+ if (canvas.data[p * 4 + 3] > 0) combinedRaw.data.set(canvas.data.subarray(p * 4, p * 4 + 4), p * 4);
565
+ }
566
+ }
567
+ const { canvas: combined } = cleanGhosts(combinedRaw);
568
+ const rightCanvas = newRaster(resolution, resolution);
569
+ const leftCanvas = newRaster(resolution, resolution);
570
+ let firstCutRun: { y: number; rx0: number; rx1: number; col: number } | null = null;
571
+ for (let y = 0; y < resolution; y++) {
572
+ const runs: Array<[number, number]> = [];
573
+ let x = 0;
574
+ while (x < resolution) {
575
+ if (combined.data[(y * resolution + x) * 4 + 3] <= OPAQUE_ALPHA_ABOVE) {
576
+ x++;
577
+ continue;
578
+ }
579
+ const rx0 = x;
580
+ while (x + 1 < resolution && combined.data[(y * resolution + x + 1) * 4 + 3] > OPAQUE_ALPHA_ABOVE) x++;
581
+ runs.push([rx0, x]);
582
+ x++;
583
+ }
584
+ const cols = splitColumnsAtRow(y, armSplit);
585
+ for (const [rx0, rx1] of runs) {
586
+ const cutting = cols.filter((c) => rx0 < c && c <= rx1);
587
+ if (cutting.length >= 2) {
588
+ refuseIfAny([
589
+ {
590
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
591
+ object: 'config.seethrough.arm_split',
592
+ detail: `cuts the pixel run x = ${rx0}..${rx1} on run row ${y} at columns ${cutting.join(', ')}, splitting one arm's pixel run into multiple parts; a line that crosses each horizontal pixel run at most once is required`,
593
+ },
594
+ ]);
595
+ }
596
+ if (runs.length >= 2 && cutting.length > 0) {
597
+ refuseIfAny([
598
+ {
599
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
600
+ object: 'config.seethrough.arm_split',
601
+ detail: `cuts the pixel run x = ${rx0}..${rx1} on run row ${y} at column ${cutting[0]} into ${rx0}..${cutting[0] - 1} and ${cutting[0]}..${rx1} (row ${y} holds separate arm runs ${runs.map(([a, b]) => `${a}..${b}`).join(', ')}); a split between the arms (${runs[0][1] + 1} <= x <= ${runs[runs.length - 1][0]}) is required so no pixel run of one arm is cut into two parts`,
602
+ },
603
+ ]);
604
+ }
605
+ if (cutting.length === 1 && firstCutRun === null) firstCutRun = { y, rx0, rx1, col: cutting[0] };
606
+ }
607
+ const col = (runs.length === 1 ? cols.find((c) => runs[0][0] < c && c <= runs[0][1]) : undefined) ?? cols[0];
608
+ for (let cx = 0; cx < resolution; cx++) {
609
+ const p = (y * resolution + cx) * 4;
610
+ if (combined.data[p + 3] === 0) continue;
611
+ const dst = cx < col ? rightCanvas.data : leftCanvas.data;
612
+ dst.set(combined.data.subarray(p, p + 4), p);
613
+ }
614
+ }
615
+ const maskOf = (r: Raster): { mask: Mask; opaque: number; x0: number; y0: number; x1: number; y1: number } => {
616
+ const mask = newMask(resolution, resolution);
617
+ let opaque = 0;
618
+ let x0 = resolution;
619
+ let y0 = resolution;
620
+ let x1 = -1;
621
+ let y1 = -1;
622
+ for (let y = 0; y < resolution; y++) {
623
+ for (let x = 0; x < resolution; x++) {
624
+ const a = r.data[(y * resolution + x) * 4 + 3];
625
+ if (a > OPAQUE_ALPHA_ABOVE) {
626
+ mask.data[y * resolution + x] = 1;
627
+ opaque++;
628
+ }
629
+ if (a > 0) {
630
+ if (x < x0) x0 = x;
631
+ if (x > x1) x1 = x;
632
+ if (y < y0) y0 = y;
633
+ if (y > y1) y1 = y;
634
+ }
635
+ }
636
+ }
637
+ return { mask, opaque, x0, y0, x1, y1 };
638
+ };
639
+ const mr = maskOf(rightCanvas);
640
+ const ml = maskOf(leftCanvas);
641
+ if (mr.opaque === 0 || ml.opaque === 0) {
642
+ const emptyTag = mr.opaque === 0 ? 'handwear-r' : 'handwear-l';
643
+ const fullTag = mr.opaque === 0 ? 'handwear-l' : 'handwear-r';
644
+ refuseIfAny([
645
+ {
646
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
647
+ object: 'config.seethrough.arm_split',
648
+ detail: `leaves "${emptyTag}" with 0 opaque pixels and "${fullTag}" with ${mr.opaque + ml.opaque}; a split that separates the overlapping handwear into two non-empty arm layers is required`,
649
+ },
650
+ ]);
651
+ }
652
+ const ccR = connectedComponents(mr.mask, 8);
653
+ const ccL = connectedComponents(ml.mask, 8);
654
+ for (const [tag, cc] of [
655
+ ['handwear-r', ccR],
656
+ ['handwear-l', ccL],
657
+ ] as const) {
658
+ const comps = cc.count - 1;
659
+ if (comps > 1) {
660
+ const cutNote = firstCutRun !== null ? ` (cutting pixel run x = ${firstCutRun.rx0}..${firstCutRun.rx1} on run row ${firstCutRun.y} at column ${firstCutRun.col})` : '';
661
+ refuseIfAny([
662
+ {
663
+ code: 'ASSEMBLE_ARM_SPLIT_CUTS_ARM',
664
+ object: 'config.seethrough.arm_split',
665
+ detail: `cuts "${tag}" into ${comps} disconnected components${cutNote}; a split that separates the two arms without cutting one arm into two parts is required`,
666
+ },
667
+ ]);
668
+ }
669
+ }
670
+ const refLayer = handLayers.reduce((best, l) => (l.opaquePx > best.opaquePx ? l : best), handLayers[0]);
671
+ const makeLayer = (name: 'handwear-r' | 'handwear-l', canvas: Raster, m: ReturnType<typeof maskOf>): Layer => ({
672
+ name,
673
+ tag: readLayerTag(name) as LayerTagReading,
674
+ file: refLayer.file,
675
+ pixels: crop(canvas, m.x0, m.y0, m.x1 - m.x0 + 1, m.y1 - m.y0 + 1),
676
+ left: m.x0,
677
+ top: m.y0,
678
+ right: m.x1 + 1,
679
+ bottom: m.y1 + 1,
680
+ depth: refLayer.depth,
681
+ drawOrder: 0,
682
+ opaquePx: m.opaque,
683
+ });
684
+ const byTag = {
685
+ 'handwear-r': makeLayer('handwear-r', rightCanvas, mr),
686
+ 'handwear-l': makeLayer('handwear-l', leftCanvas, ml),
687
+ };
688
+ const insertAt = set.layers.findIndex((l) => l === refLayer);
689
+ const nextLayers: Layer[] = [];
690
+ set.layers.forEach((l, i) => {
691
+ if (i === insertAt) nextLayers.push(byTag[armSplit.order[0]], byTag[armSplit.order[1]]);
692
+ if (l.name !== 'handwear-r' && l.name !== 'handwear-l') nextLayers.push({ ...l });
693
+ });
694
+ nextLayers.forEach((l, i) => {
695
+ l.drawOrder = i;
696
+ });
697
+ return { ...set, layers: nextLayers };
698
+ }
699
+
700
+ /**
701
+ * Apply `seethrough.arm_split`'s draw order to `assemble.plan` (issue #439):
702
+ * a lone `full:handwear-*` row is expanded into `[handwear_r, handwear_l]` in
703
+ * `armSplit.order`, and two `full:handwear-*` rows are ordered as `armSplit.order`
704
+ * declares.
705
+ */
706
+ function armSplitPlan(plan: readonly PlanEntry[], armSplit: ArmSplit | undefined): PlanEntry[] {
707
+ if (armSplit === undefined) return [...plan];
708
+ const fullHwIndices = plan.map((e, i) => (e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l') ? i : -1)).filter((i) => i >= 0);
709
+ if (fullHwIndices.length === 1) {
710
+ const idx = fullHwIndices[0];
711
+ const [t0, t1] = armSplit.order;
712
+ return [...plan.slice(0, idx), [aliasOf(t0), 'full', t0], [aliasOf(t1), 'full', t1], ...plan.slice(idx + 1)];
713
+ }
714
+ if (fullHwIndices.length === 2) {
715
+ const [i0, i1] = fullHwIndices;
716
+ const e0 = plan[i0];
717
+ const e1 = plan[i1];
718
+ if (e0[2] !== e1[2] && e0[2] !== armSplit.order[0]) {
719
+ const out = [...plan];
720
+ out[i0] = e1;
721
+ out[i1] = e0;
722
+ return out;
723
+ }
724
+ }
725
+ return [...plan];
726
+ }
727
+
492
728
  // ---------------------------------------------------------------------------
493
729
  // step 2 — resample into rig space
494
730
  // ---------------------------------------------------------------------------
@@ -1905,6 +2141,8 @@ export interface LimbPart {
1905
2141
  part: string;
1906
2142
  /** The part whose pixels it takes. */
1907
2143
  of: string;
2144
+ /** Issue #446: true when this limb declares `split` and covers the other limb(s) taking `of`. */
2145
+ covering?: boolean;
1908
2146
  record: LimbRecord;
1909
2147
  }
1910
2148
 
@@ -1972,7 +2210,8 @@ export function limbLines(counts: readonly LimbCount[]): string[] {
1972
2210
  * leaves the order, and each limb's parts stand where its first segment's
1973
2211
  * part stood, lowest segment first (drawn behind), so the upper is drawn over
1974
2212
  * the lower at every joint; limbs whose first segment takes one part stand
1975
- * there in `config.limbs` order. Without limbs, `order` as it was.
2213
+ * there in `config.limbs` order (or with the `split` covering limb drawn in
2214
+ * front, issue #446). Without limbs, `order` as it was.
1976
2215
  */
1977
2216
  export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[], cuts: readonly Cut[], parts: readonly LimbPart[]): DrawSlot[] {
1978
2217
  if (parts.length === 0) return [...order];
@@ -1985,7 +2224,9 @@ export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[]
1985
2224
  out.push(d);
1986
2225
  continue;
1987
2226
  }
1988
- const limbs = [...new Set(parts.filter((q) => q.segment === 0 && q.of === n).map((q) => q.limb))];
2227
+ const limbs = [...new Set(parts.filter((q) => q.segment === 0 && q.of === n).map((q) => q.limb))].sort(
2228
+ (a, b) => (parts.some((q) => q.limb === a && q.of === n && q.covering === true) ? 1 : 0) - (parts.some((q) => q.limb === b && q.of === n && q.covering === true) ? 1 : 0),
2229
+ );
1989
2230
  for (const l of limbs) {
1990
2231
  const mine = parts.map((q, i) => ({ q, i })).filter(({ q }) => q.limb === l);
1991
2232
  for (const { i } of [...mine].reverse()) out.push({ limb: i });
@@ -2031,8 +2272,9 @@ function cutLimbs(
2031
2272
  const places = bonePlaces(bones);
2032
2273
  const parts: LimbPart[] = [];
2033
2274
  for (const l of names) {
2275
+ const covering = limbs[l].split !== undefined;
2034
2276
  limbs[l].segments.forEach((sg, k) =>
2035
- parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string, ...(sg.range === undefined ? {} : { range: [sg.range[0], sg.range[1]] as [number, number] }) }) } }),
2277
+ parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, ...(covering ? { covering: true } : {}), record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string, ...(sg.range === undefined ? {} : { range: [sg.range[0], sg.range[1]] as [number, number] }) }) } }),
2036
2278
  );
2037
2279
  }
2038
2280
  const polylines = new Map(names.map((l) => [l, limbPolyline(limbs[l], places)]));
@@ -2089,6 +2331,16 @@ function cutLimbs(
2089
2331
  own[p] = names.indexOf(on[on.length === 1 ? 0 : nearestLimb(lns, x, y)]);
2090
2332
  }
2091
2333
  }
2334
+ // Issue #446: when a covering limb on `src` declares `split` (`line` or `edge: "painting"`), reassign `own` so
2335
+ // the covering limb's pixels across the crossing stay on the covering limb and the seam follows the declared split.
2336
+ const coverLimb = on.find((l) => limbs[l].split !== undefined);
2337
+ if (coverLimb !== undefined && on.length >= 2) {
2338
+ const coverIdx = names.indexOf(coverLimb);
2339
+ const underNames = on.filter((l) => l !== coverLimb);
2340
+ const underIndices = underNames.map((l) => names.indexOf(l));
2341
+ const underPolys = underNames.map((l) => polylines.get(l) as Array<[number, number]>);
2342
+ assignCrossedLimbs(coverIdx, polylines.get(coverLimb) as Array<[number, number]>, limbs[coverLimb].split as NonNullable<Limb['split']>, underIndices, underPolys, b.r, OPAQUE_ALPHA_ABOVE, own);
2343
+ }
2092
2344
  // Issue #360 (item 14): each joint of this part that declares cut_band has its cut placed, on the limb's own art.
2093
2345
  for (const l of on) {
2094
2346
  const li = names.indexOf(l);
@@ -2183,8 +2435,18 @@ function cutLimbs(
2183
2435
  }
2184
2436
  return false;
2185
2437
  };
2438
+ const sharedArt = (x: number, y: number): boolean => {
2439
+ if (x < 0 || y < 0 || x >= W || y >= H) return false;
2440
+ const p = y * W + x;
2441
+ for (const src of new Set([upFrom, sg.from])) {
2442
+ const b = builtOf(src);
2443
+ if (b !== null && b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) return true;
2444
+ }
2445
+ return false;
2446
+ };
2447
+ const hasCoverSplit = names.some((other) => other !== l && limbs[other].split !== undefined && limbs[other].segments.some((osg) => osg.from === upFrom || osg.from === sg.from));
2186
2448
  const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
2187
- const run = limbRun(line, art, [0, 0, W - 1, H - 1]);
2449
+ const run = limbRun(line, art, [0, 0, W - 1, H - 1]) ?? (hasCoverSplit ? limbRun(line, sharedArt, [0, 0, W - 1, H - 1]) : null);
2188
2450
  if (run === null) {
2189
2451
  problems.push({
2190
2452
  code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
@@ -2498,15 +2760,18 @@ export function checkSingleRun(full: LayerSet, plan: readonly PlanEntry[] | null
2498
2760
  * after this returned writes only after green.
2499
2761
  */
2500
2762
  export function assemble(input: AssembleInput): AssembleResult {
2501
- const { source, plan, extend, patches, seamRule, projectRule } = input;
2763
+ const { source, extend, patches, seamRule, projectRule } = input;
2502
2764
  const single = input.single === true;
2503
2765
  const frame = checkGeometry(
2504
2766
  { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale, ...(single ? { single: true } : {}) },
2505
2767
  { full: input.full, head: input.head },
2506
2768
  );
2507
2769
  // Issue #366: one run's file, padded to the resolution square; read as both runs, as before.
2508
- const full = single ? padSingleRun(input.full, frame, input.resolution) : input.full;
2770
+ const fullPadded = single ? padSingleRun(input.full, frame, input.resolution) : input.full;
2771
+ // Issue #439: when seethrough.arm_split is declared, split the full run's overlapping handwear into handwear-r and handwear-l.
2772
+ const full = input.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, input.resolution, input.armSplit);
2509
2773
  const head = single ? full : input.head;
2774
+ const plan = armSplitPlan(input.plan, input.armSplit);
2510
2775
  const padRecord = singleRunPadRecord(single, source.width, source.height, input.resolution, frame);
2511
2776
  if (single) checkSingleRun(full, plan, extend);
2512
2777
  checkPlanAgainstRuns(plan, extend, full, head);
@@ -3494,7 +3759,9 @@ function fmt0(v: number): string {
3494
3759
  export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
3495
3760
  const frame = checkGeometry(g, { full: full0, head: head0 });
3496
3761
  // Issue #366: one run's file, padded to the resolution square as the stage pads it.
3497
- const full = g.single === true ? padSingleRun(full0, frame, g.resolution) : full0;
3762
+ const fullPadded = g.single === true ? padSingleRun(full0, frame, g.resolution) : full0;
3763
+ // Issue #439: when seethrough.arm_split is declared, split the full run's overlapping handwear into handwear-r and handwear-l.
3764
+ const full = g.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, g.resolution, g.armSplit);
3498
3765
  const head = g.single === true ? full : head0;
3499
3766
  const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
3500
3767
  const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
@@ -3745,6 +4012,7 @@ export function stageFields(
3745
4012
  limbs?: Record<string, Limb>;
3746
4013
  bones?: BoneEntry[];
3747
4014
  single?: boolean;
4015
+ armSplit?: ArmSplit;
3748
4016
  } {
3749
4017
  const problems: Problem[] = [];
3750
4018
  if (cfg.seethrough === undefined) {
@@ -3759,6 +4027,7 @@ export function stageFields(
3759
4027
  resolution: st.resolution,
3760
4028
  headBox: single !== null ? singleRunHeadBox(single.w, single.h, st.resolution) : (st.head_box as [number, number, number, number]),
3761
4029
  ...(single !== null ? { single: true } : {}),
4030
+ ...(st.arm_split === undefined ? {} : { armSplit: st.arm_split }),
3762
4031
  rigScale: cfg.assemble.rig_scale,
3763
4032
  plan: cfg.assemble.plan,
3764
4033
  extend: cfg.assemble.extend_below_crop ?? [],
@@ -3776,15 +4045,19 @@ export function stageFields(
3776
4045
  * loader's rules; what is left here is that this stage needs the head box,
3777
4046
  * which that loader leaves optional.
3778
4047
  */
3779
- export function proposeFields(cfg: EarlyConfig, single: { w: number; h: number } | null = null): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean } {
4048
+ export function proposeFields(
4049
+ cfg: EarlyConfig,
4050
+ single: { w: number; h: number } | null = null,
4051
+ ): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean; armSplit?: ArmSplit } {
4052
+ const armSplit = cfg.seethrough.arm_split === undefined ? {} : { armSplit: cfg.seethrough.arm_split };
3780
4053
  if (single !== null) {
3781
4054
  refuseIfAny(singleRunHeadBoxProblems(cfg.seethrough));
3782
- return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true };
4055
+ return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true, ...armSplit };
3783
4056
  }
3784
4057
  if (cfg.seethrough.head_box === undefined) {
3785
4058
  refuseIfAny([{ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough.head_box', detail: 'is absent; the plan is proposed from both runs, and the head run cannot be placed on the painting without the box it was cropped from — run `propose --head-box` first' }]);
3786
4059
  }
3787
- return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale };
4060
+ return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale, ...armSplit };
3788
4061
  }
3789
4062
 
3790
4063
  /**