@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/docs/AUTHORING.md +30 -10
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/assemble.ts +286 -13
- package/src/cloth.ts +297 -29
- package/src/config.ts +129 -4
- package/src/diagnostics.ts +1 -0
- package/src/limbs.ts +221 -1
- package/src/propose.ts +712 -40
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
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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
|
/**
|