@rig-c/parts 1.36.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +5 -1
- package/docs/AUTHORING.md +21 -3
- package/docs/MIGRATION.md +19 -0
- package/docs/STABILITY.md +2 -2
- package/package.json +2 -2
- package/src/assemble.ts +591 -12
- package/src/build.ts +27 -3
- package/src/check.ts +280 -1
- package/src/config.ts +58 -8
- package/src/propose.ts +128 -15
- package/src/tags.ts +15 -6
package/src/assemble.ts
CHANGED
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
*
|
|
100
100
|
* Pure: no clock, no randomness, no file access. The CLI reads and writes.
|
|
101
101
|
*/
|
|
102
|
-
import type
|
|
102
|
+
import { type ArmSplit, type BoneEntry, type CharacterConfig, type ControlSkeleton, controlSkeletonOf, type Cut, type EarlyConfig, type Extend, type Limb, type Patch, type PlanEntry, type Run, type 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';
|
|
@@ -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, type LayerTagReading, readAuthorTag, readLayerTag } from './tags.ts';
|
|
126
|
+
import { ARM_MERGED_SPLIT_TAGS, AUTHOR_NO_ROLE, type LayerTagReading, readAuthorTag, readLayerTag } from './tags.ts';
|
|
127
127
|
|
|
128
128
|
const f32 = Math.fround;
|
|
129
129
|
|
|
@@ -234,8 +234,22 @@ export interface Geometry {
|
|
|
234
234
|
single?: boolean;
|
|
235
235
|
/** `seethrough.arm_split` (issue #439): separates overlapping arms on the full run into `handwear-r` and `handwear-l` in the declared order. */
|
|
236
236
|
armSplit?: ArmSplit;
|
|
237
|
+
/** `generation.control.skeleton` (issue #490): the pose keypoints on the `latent` canvas, read for merged arm layers (`splitMergedArmRuns`). */
|
|
238
|
+
keySkeleton?: ControlSkeleton;
|
|
239
|
+
/** `assemble.head_face_underfill` (issue #492): composite `full:face` under `head:face` on the head canvas to fill transparent bare-skin holes. */
|
|
240
|
+
headFaceUnderfill?: boolean;
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Issue #492: minimum share of the full run's hole-filled `face` silhouette
|
|
245
|
+
* (mapped onto the head canvas) that the head run's `face` layer must cover
|
|
246
|
+
* (`alpha > OPAQUE_ALPHA_ABOVE = 8`). Across the 5 public examples the ratio is
|
|
247
|
+
* `0.804..0.986` and across the 6 non-hollow standees `0.816..0.974`, whereas
|
|
248
|
+
* the 3 hollow standee runs sit at `0.000..0.486` — a `0.486 ↔ 0.804` gap of
|
|
249
|
+
* `31.8` percentage points.
|
|
250
|
+
*/
|
|
251
|
+
export const HEAD_FACE_COVERAGE_FLOOR = 0.75;
|
|
252
|
+
|
|
239
253
|
/**
|
|
240
254
|
* The tags a single run must hold, each as a layer with an opaque pixel
|
|
241
255
|
* (issue #359): `face`, which places the face box, and the eye group
|
|
@@ -327,6 +341,10 @@ export interface AssembleInput {
|
|
|
327
341
|
single?: boolean;
|
|
328
342
|
/** `seethrough.arm_split` (issue #439): separates overlapping arms on the full run into `handwear-r` and `handwear-l` in the declared order. */
|
|
329
343
|
armSplit?: ArmSplit;
|
|
344
|
+
/** `generation.control.skeleton` (issue #490): the pose keypoints on the `latent` canvas, read for merged arm layers (`splitMergedArmRuns`). */
|
|
345
|
+
keySkeleton?: ControlSkeleton;
|
|
346
|
+
/** `assemble.head_face_underfill` (issue #492): composite `full:face` under `head:face` on the head canvas to fill transparent bare-skin holes. */
|
|
347
|
+
headFaceUnderfill?: boolean;
|
|
330
348
|
}
|
|
331
349
|
|
|
332
350
|
/** The derived numbers of the two runs' geometry. */
|
|
@@ -725,6 +743,335 @@ function armSplitPlan(plan: readonly PlanEntry[], armSplit: ArmSplit | undefined
|
|
|
725
743
|
return [...plan];
|
|
726
744
|
}
|
|
727
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Issue #490: the minimum Euclidean RGB distance between the sleeve above the
|
|
748
|
+
* wrist and the non-skin overgarment drape extending more than one forearm
|
|
749
|
+
* length below the wrist for `splitMergedArmRuns` to split the merged
|
|
750
|
+
* `handwear-r` / `handwear-l` layer into `drape-<s>`, `handwear-<s>` and
|
|
751
|
+
* `hand-<s>` instead of refusing with `SEETHROUGH_ARM_RUN_MERGED`.
|
|
752
|
+
*
|
|
753
|
+
* Measured across the 9-standee corpus (issue #490, `forearm = 211.3` run px):
|
|
754
|
+
* the 6 clean standees hold `spanBelowWrist <= 94.9` px (`<= 0.45x` forearm) and
|
|
755
|
+
* the 3 merged standees hold `264.9..425.9` px (`1.25x..2.02x` forearm), placing
|
|
756
|
+
* the `1.0x` forearm threshold inside the `0.45x..1.25x` gap; on the 3 merged
|
|
757
|
+
* standees (`780..4,787` skin hand px vs the `GHOST_MIN_AREA = 40` px floor),
|
|
758
|
+
* the two with distinct sleeve and drape colours measure Euclidean RGB distance
|
|
759
|
+
* `103.6 / 106.9` (`standee-1`) and `215.7 / 236.0` (`standee-2`), while the
|
|
760
|
+
* one whose outer robe shares the sleeve's colour measures `20.8 / 28.2`
|
|
761
|
+
* (`standee-5`), placing `80` between `28.2` and `103.6`.
|
|
762
|
+
*/
|
|
763
|
+
export const ARM_DRAPE_COLOR_DISTANCE = 80;
|
|
764
|
+
|
|
765
|
+
function medianChannelRgb(pixels: ReadonlyArray<readonly [number, number, number]>): [number, number, number] | null {
|
|
766
|
+
if (pixels.length === 0) return null;
|
|
767
|
+
const rs = pixels.map((p) => p[0]).sort((a, b) => a - b);
|
|
768
|
+
const gs = pixels.map((p) => p[1]).sort((a, b) => a - b);
|
|
769
|
+
const bs = pixels.map((p) => p[2]).sort((a, b) => a - b);
|
|
770
|
+
const mid = Math.floor(pixels.length / 2);
|
|
771
|
+
return [rs[mid], gs[mid], bs[mid]];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function rgbDist(a: readonly [number, number, number], b: readonly [number, number, number]): number {
|
|
775
|
+
const dr = a[0] - b[0];
|
|
776
|
+
const dg = a[1] - b[1];
|
|
777
|
+
const db = a[2] - b[2];
|
|
778
|
+
return Math.sqrt(dr * dr + dg * dg + db * db);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function faceMedianRgb(set: LayerSet, resolution: number): [number, number, number] {
|
|
782
|
+
const face = set.layers.find((l) => l.name === 'face');
|
|
783
|
+
if (face === undefined) return [250, 225, 210];
|
|
784
|
+
const { canvas } = cleanGhosts(placeLayer(face, resolution));
|
|
785
|
+
const px: Array<[number, number, number]> = [];
|
|
786
|
+
for (let i = 0; i < resolution * resolution; i++) {
|
|
787
|
+
const a = canvas.data[i * 4 + 3];
|
|
788
|
+
if (a < CORE_ALPHA) continue;
|
|
789
|
+
const r = canvas.data[i * 4];
|
|
790
|
+
const g = canvas.data[i * 4 + 1];
|
|
791
|
+
const b = canvas.data[i * 4 + 2];
|
|
792
|
+
if (Math.min(r, g, b) > NEAR_WHITE_MIN) continue;
|
|
793
|
+
px.push([r, g, b]);
|
|
794
|
+
}
|
|
795
|
+
return medianChannelRgb(px) ?? [250, 225, 210];
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Issue #490: inspect `handwear-r` and `handwear-l` in the full run against the
|
|
800
|
+
* pose keypoints (`generation.control.skeleton`). When an arm layer holds a
|
|
801
|
+
* skin-coloured hand cluster at `<s>_wrist` (`>= GHOST_MIN_AREA` = `40` opaque
|
|
802
|
+
* run px) and its non-skin pixels reach more than `1.0x` the forearm length
|
|
803
|
+
* (`|wrist - elbow|`) below `<s>_wrist`, See-through merged the sleeve, the
|
|
804
|
+
* hand and the overgarment drape into one arm layer.
|
|
805
|
+
*
|
|
806
|
+
* - When the Euclidean RGB distance between the sleeve above the wrist and the
|
|
807
|
+
* drape below the wrist is at least {@link ARM_DRAPE_COLOR_DISTANCE} (`80`),
|
|
808
|
+
* splits `handwear-<s>` into three layers in draw order (back to front):
|
|
809
|
+
* `drape-<s>` (overgarment below the cut), `handwear-<s>` (the sleeve above
|
|
810
|
+
* the cut) and `hand-<s>` (the skin hand cluster at the wrist).
|
|
811
|
+
* - When that distance is below {@link ARM_DRAPE_COLOR_DISTANCE}, refuses with
|
|
812
|
+
* `SEETHROUGH_ARM_RUN_MERGED` naming the layer, the span below the wrist vs
|
|
813
|
+
* the forearm length, the sleeve and drape RGBs and their Euclidean distance.
|
|
814
|
+
*/
|
|
815
|
+
export function splitMergedArmRunsWithNotes(
|
|
816
|
+
set: LayerSet,
|
|
817
|
+
resolution: number,
|
|
818
|
+
frame: Pick<Frame, 'fullPad' | 'fullPadTop' | 'fullK'>,
|
|
819
|
+
sourceW: number,
|
|
820
|
+
sourceH: number,
|
|
821
|
+
keySkeleton: ControlSkeleton,
|
|
822
|
+
): { set: LayerSet; notes: string[] } {
|
|
823
|
+
const [latentW, latentH] = keySkeleton.latent;
|
|
824
|
+
const sx = sourceW / latentW;
|
|
825
|
+
const sy = sourceH / latentH;
|
|
826
|
+
const toRun = (pt: readonly [number, number]): { x: number; y: number } => ({
|
|
827
|
+
x: (pt[0] * sx + frame.fullPad) / frame.fullK,
|
|
828
|
+
y: (pt[1] * sy + frame.fullPadTop) / frame.fullK,
|
|
829
|
+
});
|
|
830
|
+
const fRGB = faceMedianRgb(set, resolution);
|
|
831
|
+
const problems: Problem[] = [];
|
|
832
|
+
const notes: string[] = [];
|
|
833
|
+
const replacements = new Map<'handwear-r' | 'handwear-l', Layer[]>();
|
|
834
|
+
|
|
835
|
+
for (const s of ['r', 'l'] as const) {
|
|
836
|
+
const tag = `handwear-${s}` as const;
|
|
837
|
+
const hwLayer = set.layers.find((l) => l.name === tag);
|
|
838
|
+
if (hwLayer === undefined) continue;
|
|
839
|
+
const { canvas: cleaned } = cleanGhosts(placeLayer(hwLayer, resolution));
|
|
840
|
+
const sh = toRun(keySkeleton.points[`${s}_shoulder`]);
|
|
841
|
+
const el = toRun(keySkeleton.points[`${s}_elbow`]);
|
|
842
|
+
const wr = toRun(keySkeleton.points[`${s}_wrist`]);
|
|
843
|
+
const fdx = wr.x - el.x;
|
|
844
|
+
const fdy = wr.y - el.y;
|
|
845
|
+
const forearm = Math.sqrt(fdx * fdx + fdy * fdy);
|
|
846
|
+
if (!(forearm > 0)) continue;
|
|
847
|
+
|
|
848
|
+
// 1. Skin-coloured hand cluster in the wrist window.
|
|
849
|
+
const skinRaw = newMask(resolution, resolution);
|
|
850
|
+
const winX0 = Math.max(0, Math.floor(wr.x - 0.45 * forearm));
|
|
851
|
+
const winX1 = Math.min(resolution - 1, Math.ceil(wr.x + 0.45 * forearm));
|
|
852
|
+
const winY0 = Math.max(0, Math.floor(wr.y - 0.65 * forearm));
|
|
853
|
+
const winY1 = Math.min(resolution - 1, Math.ceil(wr.y + 0.35 * forearm));
|
|
854
|
+
for (let y = winY0; y <= winY1; y++) {
|
|
855
|
+
for (let x = winX0; x <= winX1; x++) {
|
|
856
|
+
const idx = (y * resolution + x) * 4;
|
|
857
|
+
const a = cleaned.data[idx + 3];
|
|
858
|
+
if (a <= OPAQUE_ALPHA_ABOVE) continue;
|
|
859
|
+
const r = cleaned.data[idx];
|
|
860
|
+
const g = cleaned.data[idx + 1];
|
|
861
|
+
const b = cleaned.data[idx + 2];
|
|
862
|
+
if (Math.min(r, g, b) > NEAR_WHITE_MIN) continue;
|
|
863
|
+
const fDist = rgbDist([r, g, b], fRGB);
|
|
864
|
+
const warmOrder = r >= g && g >= b && r - b >= 12 && g - b <= 45;
|
|
865
|
+
if (warmOrder && (fDist < 65 || (r > 145 && g > 95 && b > 55 && r - b <= 95))) {
|
|
866
|
+
skinRaw.data[y * resolution + x] = 1;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const skinClosed = fillHoles(morphClose(skinRaw, CORE_KERNEL));
|
|
871
|
+
const cc = connectedComponents(skinClosed, 8);
|
|
872
|
+
const keepHand = new Uint8Array(cc.count);
|
|
873
|
+
for (let c = 1; c < cc.count; c++) {
|
|
874
|
+
const st = cc.stats[c];
|
|
875
|
+
if (st.area < GHOST_MIN_AREA) continue;
|
|
876
|
+
const dx = wr.x < st.left ? st.left - wr.x : wr.x > st.left + st.width - 1 ? wr.x - (st.left + st.width - 1) : 0;
|
|
877
|
+
const dy = wr.y < st.top ? st.top - wr.y : wr.y > st.top + st.height - 1 ? wr.y - (st.top + st.height - 1) : 0;
|
|
878
|
+
if (Math.sqrt(dx * dx + dy * dy) <= 0.35 * forearm) keepHand[c] = 1;
|
|
879
|
+
}
|
|
880
|
+
const handMask = newMask(resolution, resolution);
|
|
881
|
+
let handPx = 0;
|
|
882
|
+
let handMinY = resolution;
|
|
883
|
+
let handMaxY = -1;
|
|
884
|
+
for (let y = 0; y < resolution; y++) {
|
|
885
|
+
for (let x = 0; x < resolution; x++) {
|
|
886
|
+
const i = y * resolution + x;
|
|
887
|
+
if (keepHand[cc.labels[i]] === 1 && cleaned.data[i * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
|
|
888
|
+
handMask.data[i] = 1;
|
|
889
|
+
handPx++;
|
|
890
|
+
if (y < handMinY) handMinY = y;
|
|
891
|
+
if (y > handMaxY) handMaxY = y;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (handPx < GHOST_MIN_AREA) continue;
|
|
896
|
+
|
|
897
|
+
// 2. Non-skin span below the wrist vs forearm length.
|
|
898
|
+
let nonSkinMaxY = -1;
|
|
899
|
+
for (let y = 0; y < resolution; y++) {
|
|
900
|
+
for (let x = 0; x < resolution; x++) {
|
|
901
|
+
const i = y * resolution + x;
|
|
902
|
+
if (cleaned.data[i * 4 + 3] > OPAQUE_ALPHA_ABOVE && handMask.data[i] === 0) {
|
|
903
|
+
if (y > nonSkinMaxY) nonSkinMaxY = y;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const spanBelowWrist = nonSkinMaxY - wr.y;
|
|
908
|
+
if (!(spanBelowWrist > forearm)) continue;
|
|
909
|
+
|
|
910
|
+
// 3. Sleeve vs drape colour distance.
|
|
911
|
+
const midArmY = Math.round((sh.y + wr.y) / 2);
|
|
912
|
+
const band1Px: Array<[number, number, number]> = [];
|
|
913
|
+
const band2Px: Array<[number, number, number]> = [];
|
|
914
|
+
const drapePx: Array<[number, number, number]> = [];
|
|
915
|
+
const wrFloor = Math.floor(wr.y);
|
|
916
|
+
const drapeStart = Math.floor(wr.y + 0.15 * forearm);
|
|
917
|
+
for (let y = 0; y < resolution; y++) {
|
|
918
|
+
for (let x = 0; x < resolution; x++) {
|
|
919
|
+
const i = y * resolution + x;
|
|
920
|
+
if (handMask.data[i] === 1) continue;
|
|
921
|
+
const a = cleaned.data[i * 4 + 3];
|
|
922
|
+
if (a < CORE_ALPHA) continue;
|
|
923
|
+
const r = cleaned.data[i * 4];
|
|
924
|
+
const g = cleaned.data[i * 4 + 1];
|
|
925
|
+
const b = cleaned.data[i * 4 + 2];
|
|
926
|
+
if (Math.min(r, g, b) > NEAR_WHITE_MIN) continue;
|
|
927
|
+
if (y <= wrFloor) {
|
|
928
|
+
if (y < midArmY) band1Px.push([r, g, b]);
|
|
929
|
+
else band2Px.push([r, g, b]);
|
|
930
|
+
} else if (y >= drapeStart) {
|
|
931
|
+
drapePx.push([r, g, b]);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const c1 = medianChannelRgb(band1Px);
|
|
936
|
+
const c2 = medianChannelRgb(band2Px);
|
|
937
|
+
const drapeRGB = medianChannelRgb(drapePx);
|
|
938
|
+
const d1 = c1 !== null && drapeRGB !== null ? rgbDist(c1, drapeRGB) : 0;
|
|
939
|
+
const d2 = c2 !== null && drapeRGB !== null ? rgbDist(c2, drapeRGB) : 0;
|
|
940
|
+
const bestDist = Math.max(d1, d2);
|
|
941
|
+
const sleeveRGB = (d1 >= d2 ? c1 ?? c2 : c2 ?? c1) ?? [0, 0, 0];
|
|
942
|
+
const drapeShown = drapeRGB ?? [0, 0, 0];
|
|
943
|
+
|
|
944
|
+
if (drapeRGB === null || (c1 === null && c2 === null) || bestDist < ARM_DRAPE_COLOR_DISTANCE) {
|
|
945
|
+
problems.push({
|
|
946
|
+
code: 'SEETHROUGH_ARM_RUN_MERGED',
|
|
947
|
+
object: `the full run (${set.source}) layer "${tag}"`,
|
|
948
|
+
detail:
|
|
949
|
+
`merges the sleeve, the hand (${handPx} skin px around ${s}_wrist at (${Math.round(wr.x)}, ${Math.round(wr.y)}), rows y=${handMinY}..${handMaxY}) and the overgarment into one arm layer: non-skin pixels reach y=${nonSkinMaxY}, ${spanBelowWrist.toFixed(1)} px (${(spanBelowWrist / forearm).toFixed(2)}x the forearm length ${forearm.toFixed(1)} px from ${s}_elbow to ${s}_wrist) below ${s}_wrist, and the sleeve above ${s}_wrist (rgb(${sleeveRGB.join(', ')})) and the drape below ${s}_wrist (rgb(${drapeShown.join(', ')})) have Euclidean RGB distance ${bestDist.toFixed(1)} (< ${ARM_DRAPE_COLOR_DISTANCE}); ` +
|
|
950
|
+
`non-skin pixels reaching at most 1.0x the forearm length (${forearm.toFixed(1)} px) below ${s}_wrist, or a sleeve-to-drape Euclidean RGB distance of at least ${ARM_DRAPE_COLOR_DISTANCE} so the stage can split "${tag}" into "drape-${s}", "${tag}" and "hand-${s}", is required — re-seed config.seethrough.seed, split the merged layer into author: layers in the full run, or cut the overgarment from the sleeve in config.assemble.cuts`,
|
|
951
|
+
});
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// 4. Split into drape-<s>, handwear-<s> and hand-<s> (back to front).
|
|
956
|
+
const cutY = Math.max(Math.round(wr.y), handMaxY);
|
|
957
|
+
const elY = Math.round(el.y);
|
|
958
|
+
const drapeCanvas = newRaster(resolution, resolution);
|
|
959
|
+
const sleeveCanvas = newRaster(resolution, resolution);
|
|
960
|
+
const handCanvas = newRaster(resolution, resolution);
|
|
961
|
+
for (let y = 0; y < resolution; y++) {
|
|
962
|
+
for (let x = 0; x < resolution; x++) {
|
|
963
|
+
const i = y * resolution + x;
|
|
964
|
+
const p = i * 4;
|
|
965
|
+
if (cleaned.data[p + 3] === 0) continue;
|
|
966
|
+
if (handMask.data[i] === 1) {
|
|
967
|
+
handCanvas.data.set(cleaned.data.subarray(p, p + 4), p);
|
|
968
|
+
} else if (y > cutY) {
|
|
969
|
+
drapeCanvas.data.set(cleaned.data.subarray(p, p + 4), p);
|
|
970
|
+
} else if (d2 < ARM_DRAPE_COLOR_DISTANCE && y > elY) {
|
|
971
|
+
const px: [number, number, number] = [cleaned.data[p], cleaned.data[p + 1], cleaned.data[p + 2]];
|
|
972
|
+
const dst = rgbDist(px, drapeRGB) < rgbDist(px, sleeveRGB) ? drapeCanvas.data : sleeveCanvas.data;
|
|
973
|
+
dst.set(cleaned.data.subarray(p, p + 4), p);
|
|
974
|
+
} else {
|
|
975
|
+
sleeveCanvas.data.set(cleaned.data.subarray(p, p + 4), p);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const buildSplitLayer = (name: string, canvas: Raster): Layer | null => {
|
|
980
|
+
let opaque = 0;
|
|
981
|
+
let x0 = resolution;
|
|
982
|
+
let y0 = resolution;
|
|
983
|
+
let x1 = -1;
|
|
984
|
+
let y1 = -1;
|
|
985
|
+
for (let y = 0; y < resolution; y++) {
|
|
986
|
+
for (let x = 0; x < resolution; x++) {
|
|
987
|
+
const a = canvas.data[(y * resolution + x) * 4 + 3];
|
|
988
|
+
if (a > OPAQUE_ALPHA_ABOVE) opaque++;
|
|
989
|
+
if (a > 0) {
|
|
990
|
+
if (x < x0) x0 = x;
|
|
991
|
+
if (x > x1) x1 = x;
|
|
992
|
+
if (y < y0) y0 = y;
|
|
993
|
+
if (y > y1) y1 = y;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (opaque === 0 || x1 < x0) return null;
|
|
998
|
+
return {
|
|
999
|
+
name,
|
|
1000
|
+
tag: readLayerTag(name) as LayerTagReading,
|
|
1001
|
+
file: hwLayer.file,
|
|
1002
|
+
pixels: crop(canvas, x0, y0, x1 - x0 + 1, y1 - y0 + 1),
|
|
1003
|
+
left: x0,
|
|
1004
|
+
top: y0,
|
|
1005
|
+
right: x1 + 1,
|
|
1006
|
+
bottom: y1 + 1,
|
|
1007
|
+
depth: hwLayer.depth,
|
|
1008
|
+
drawOrder: 0,
|
|
1009
|
+
opaquePx: opaque,
|
|
1010
|
+
};
|
|
1011
|
+
};
|
|
1012
|
+
const drapeLayer = buildSplitLayer(`drape-${s}`, drapeCanvas);
|
|
1013
|
+
const sleeveLayer = buildSplitLayer(tag, sleeveCanvas);
|
|
1014
|
+
const handLayer = buildSplitLayer(`hand-${s}`, handCanvas);
|
|
1015
|
+
if (drapeLayer !== null && sleeveLayer !== null && handLayer !== null) {
|
|
1016
|
+
replacements.set(tag, [drapeLayer, sleeveLayer, handLayer]);
|
|
1017
|
+
notes.push(
|
|
1018
|
+
`${tag}: merged arm layer (${handPx} skin hand px at ${s}_wrist y=${handMinY}..${handMaxY}, non-skin reaching y=${nonSkinMaxY}, ${(spanBelowWrist / forearm).toFixed(2)}x forearm ${forearm.toFixed(1)} px; sleeve rgb(${sleeveRGB.join(', ')}) vs drape rgb(${drapeRGB.join(', ')}) Euclidean distance ${bestDist.toFixed(1)} >= ${ARM_DRAPE_COLOR_DISTANCE}) -> split into drape-${s} (${drapeLayer.opaquePx} px), ${tag} (${sleeveLayer.opaquePx} px) and hand-${s} (${handLayer.opaquePx} px)`,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
refuseIfAny(problems);
|
|
1024
|
+
if (replacements.size === 0) return { set, notes };
|
|
1025
|
+
|
|
1026
|
+
const nextLayers: Layer[] = [];
|
|
1027
|
+
for (const l of set.layers) {
|
|
1028
|
+
const rep = (l.name === 'handwear-r' || l.name === 'handwear-l') ? replacements.get(l.name) : undefined;
|
|
1029
|
+
if (rep !== undefined) nextLayers.push(...rep);
|
|
1030
|
+
else nextLayers.push({ ...l });
|
|
1031
|
+
}
|
|
1032
|
+
nextLayers.forEach((l, i) => {
|
|
1033
|
+
l.drawOrder = i;
|
|
1034
|
+
});
|
|
1035
|
+
return { set: { ...set, layers: nextLayers }, notes };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function splitMergedArmRuns(
|
|
1039
|
+
set: LayerSet,
|
|
1040
|
+
resolution: number,
|
|
1041
|
+
frame: Pick<Frame, 'fullPad' | 'fullPadTop' | 'fullK'>,
|
|
1042
|
+
sourceW: number,
|
|
1043
|
+
sourceH: number,
|
|
1044
|
+
keySkeleton: ControlSkeleton,
|
|
1045
|
+
): LayerSet {
|
|
1046
|
+
return splitMergedArmRunsWithNotes(set, resolution, frame, sourceW, sourceH, keySkeleton).set;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Issue #490: when `splitMergedArmRuns` split `full:handwear-<s>` into
|
|
1051
|
+
* `drape-<s>`, `handwear-<s>` and `hand-<s>`, expand a lone `full:handwear-<s>`
|
|
1052
|
+
* row in `assemble.plan` (when `drape-<s>` and `hand-<s>` are not already in
|
|
1053
|
+
* `plan`) into `[["drape_<s>", "full", "drape-<s>"], [name, "full", "handwear-<s>"], ["hand_<s>", "full", "hand-<s>"]]`.
|
|
1054
|
+
*/
|
|
1055
|
+
export function mergedArmSplitPlan(plan: readonly PlanEntry[], full: LayerSet): PlanEntry[] {
|
|
1056
|
+
const fullTags = new Set(full.layers.map((l) => l.name));
|
|
1057
|
+
const plannedFullTags = new Set(plan.filter(([, run]) => run === 'full').map(([, , tag]) => tag));
|
|
1058
|
+
const out: PlanEntry[] = [];
|
|
1059
|
+
for (const entry of plan) {
|
|
1060
|
+
const [name, run, tag] = entry;
|
|
1061
|
+
if (run === 'full' && (tag === 'handwear-r' || tag === 'handwear-l')) {
|
|
1062
|
+
const s = tag === 'handwear-r' ? 'r' : 'l';
|
|
1063
|
+
const drapeTag = `drape-${s}`;
|
|
1064
|
+
const handTag = `hand-${s}`;
|
|
1065
|
+
if (fullTags.has(drapeTag) && fullTags.has(handTag) && !plannedFullTags.has(drapeTag) && !plannedFullTags.has(handTag)) {
|
|
1066
|
+
out.push([aliasOf(drapeTag), 'full', drapeTag], [name, 'full', tag], [aliasOf(handTag), 'full', handTag]);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
out.push(entry);
|
|
1071
|
+
}
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
728
1075
|
// ---------------------------------------------------------------------------
|
|
729
1076
|
// step 2 — resample into rig space
|
|
730
1077
|
// ---------------------------------------------------------------------------
|
|
@@ -782,6 +1129,208 @@ export function layerToRig(canvas: Raster, frame: Frame, run: Run): Raster {
|
|
|
782
1129
|
return out;
|
|
783
1130
|
}
|
|
784
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* Issue #492: warp a full-run layer (`resolution x resolution`) onto the head
|
|
1134
|
+
* run's `resolution x resolution` canvas using the same premultiplied float32
|
|
1135
|
+
* `warpAffine` round-trip as {@link layerToRig} (`head = (fullK / headK) * full
|
|
1136
|
+
* - (headBox + fullPad) / headK`).
|
|
1137
|
+
*/
|
|
1138
|
+
export function fullLayerToHead(
|
|
1139
|
+
canvas: Raster,
|
|
1140
|
+
frame: Pick<Frame, 'fullK' | 'fullPad' | 'fullPadTop' | 'headBox' | 'headK'>,
|
|
1141
|
+
resolution: number,
|
|
1142
|
+
): Raster {
|
|
1143
|
+
const n = canvas.width * canvas.height;
|
|
1144
|
+
let any = false;
|
|
1145
|
+
for (let i = 0; i < n && !any; i++) if (canvas.data[i * 4 + 3] !== 0) any = true;
|
|
1146
|
+
if (!any) return newRaster(resolution, resolution);
|
|
1147
|
+
const pre = newFloatImage(canvas.width, canvas.height, 4);
|
|
1148
|
+
for (let i = 0; i < n; i++) {
|
|
1149
|
+
const a = canvas.data[i * 4 + 3];
|
|
1150
|
+
const af = f32(a / 255);
|
|
1151
|
+
for (let c = 0; c < 3; c++) pre.data[i * 4 + c] = f32(canvas.data[i * 4 + c] * af);
|
|
1152
|
+
pre.data[i * 4 + 3] = a;
|
|
1153
|
+
}
|
|
1154
|
+
const k = frame.fullK / frame.headK;
|
|
1155
|
+
const sx = f32(k);
|
|
1156
|
+
const tx = f32(-(frame.headBox[0] + frame.fullPad) / frame.headK);
|
|
1157
|
+
const ty = frame.fullPadTop === 0 && frame.headBox[1] === 0 ? 0 : f32(-(frame.headBox[1] + frame.fullPadTop) / frame.headK);
|
|
1158
|
+
const wr = warpAffine(pre, { sx, sy: sx, tx, ty }, resolution, resolution, k < 1 ? 'bilinear' : 'bicubic');
|
|
1159
|
+
const out = newRaster(resolution, resolution);
|
|
1160
|
+
const eps = f32(1e-3);
|
|
1161
|
+
const clip = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
|
|
1162
|
+
for (let i = 0; i < resolution * resolution; i++) {
|
|
1163
|
+
const a = f32(clip(wr.data[i * 4 + 3]));
|
|
1164
|
+
for (let c = 0; c < 3; c++) {
|
|
1165
|
+
const v = f32(clip(wr.data[i * 4 + c]));
|
|
1166
|
+
out.data[i * 4 + c] = a > 0 ? Math.trunc(clip(f32(f32(v * 255) / Math.max(a, eps)))) : 0;
|
|
1167
|
+
}
|
|
1168
|
+
out.data[i * 4 + 3] = Math.trunc(a);
|
|
1169
|
+
}
|
|
1170
|
+
return out;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Issue #492: compare the head run's `face` layer against the full run's
|
|
1175
|
+
* hole-filled `face` silhouette on the head canvas (`resolution x resolution`),
|
|
1176
|
+
* optionally compositing `full:face` under `head:face` when
|
|
1177
|
+
* `config.assemble.head_face_underfill: true` is declared and the head run
|
|
1178
|
+
* provides a `face` outline (`opaqueRunPx >= minPx`), and refusing with
|
|
1179
|
+
* `SEETHROUGH_HEAD_RUN_HOLLOW` when the head run's `face` covers less than
|
|
1180
|
+
* {@link HEAD_FACE_COVERAGE_FLOOR} (`0.75`) of the full run's face silhouette.
|
|
1181
|
+
*/
|
|
1182
|
+
function prepareHeadFace(
|
|
1183
|
+
full: LayerSet,
|
|
1184
|
+
head: LayerSet,
|
|
1185
|
+
frame: Pick<Frame, 'fullK' | 'fullPad' | 'fullPadTop' | 'headBox' | 'headK'>,
|
|
1186
|
+
resolution: number,
|
|
1187
|
+
underfill: boolean,
|
|
1188
|
+
minPx = PROPOSE_MIN_PX,
|
|
1189
|
+
): { head: LayerSet; underfillPx: number; underfillNote: string | null } {
|
|
1190
|
+
if (full.source !== '' && full.source === head.source) return { head, underfillPx: 0, underfillNote: null };
|
|
1191
|
+
const fullFaceLayer = full.layers.find((l) => l.name === 'face');
|
|
1192
|
+
if (fullFaceLayer === undefined) return { head, underfillPx: 0, underfillNote: null };
|
|
1193
|
+
const cleanedFull = cleanGhosts(placeLayer(fullFaceLayer, resolution));
|
|
1194
|
+
let fullOpaqueRunPx = 0;
|
|
1195
|
+
for (let i = 3; i < cleanedFull.canvas.data.length; i += 4) {
|
|
1196
|
+
if (cleanedFull.canvas.data[i] > OPAQUE_ALPHA_ABOVE) fullOpaqueRunPx++;
|
|
1197
|
+
}
|
|
1198
|
+
if (fullOpaqueRunPx < minPx) return { head, underfillPx: 0, underfillNote: null };
|
|
1199
|
+
|
|
1200
|
+
const warpedFull = fullLayerToHead(cleanedFull.canvas, frame, resolution);
|
|
1201
|
+
const rawFullMask = newMask(resolution, resolution);
|
|
1202
|
+
for (let p = 0; p < resolution * resolution; p++) {
|
|
1203
|
+
if (warpedFull.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) rawFullMask.data[p] = 1;
|
|
1204
|
+
}
|
|
1205
|
+
const fullSil = fillHoles(rawFullMask);
|
|
1206
|
+
let fullPx = 0;
|
|
1207
|
+
let minY = resolution;
|
|
1208
|
+
let maxY = -1;
|
|
1209
|
+
for (let y = 0; y < resolution; y++) {
|
|
1210
|
+
for (let x = 0; x < resolution; x++) {
|
|
1211
|
+
if (fullSil.data[y * resolution + x] === 1) {
|
|
1212
|
+
fullPx++;
|
|
1213
|
+
if (y < minY) minY = y;
|
|
1214
|
+
if (y > maxY) maxY = y;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (fullPx < minPx) return { head, underfillPx: 0, underfillNote: null };
|
|
1219
|
+
|
|
1220
|
+
const headFaceLayer = head.layers.find((l) => l.name === 'face');
|
|
1221
|
+
const cleanedHead =
|
|
1222
|
+
headFaceLayer === undefined
|
|
1223
|
+
? { canvas: newRaster(resolution, resolution), ghostPx: 0 }
|
|
1224
|
+
: cleanGhosts(placeLayer(headFaceLayer, resolution));
|
|
1225
|
+
let headRawOpaquePx = 0;
|
|
1226
|
+
for (let i = 3; i < cleanedHead.canvas.data.length; i += 4) {
|
|
1227
|
+
if (cleanedHead.canvas.data[i] > OPAQUE_ALPHA_ABOVE) headRawOpaquePx++;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const measureCoverage = (canvas: Raster): { headOv: number; cov: number; upCov: number; loCov: number } => {
|
|
1231
|
+
const midY = (minY + maxY) / 2;
|
|
1232
|
+
let fullUpPx = 0;
|
|
1233
|
+
let fullLoPx = 0;
|
|
1234
|
+
let headOv = 0;
|
|
1235
|
+
let headUpOv = 0;
|
|
1236
|
+
let headLoOv = 0;
|
|
1237
|
+
for (let y = minY; y <= maxY; y++) {
|
|
1238
|
+
for (let x = 0; x < resolution; x++) {
|
|
1239
|
+
const p = y * resolution + x;
|
|
1240
|
+
if (fullSil.data[p] !== 1) continue;
|
|
1241
|
+
if (y <= midY) fullUpPx++;
|
|
1242
|
+
else fullLoPx++;
|
|
1243
|
+
if (canvas.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
|
|
1244
|
+
headOv++;
|
|
1245
|
+
if (y <= midY) headUpOv++;
|
|
1246
|
+
else headLoOv++;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
headOv,
|
|
1252
|
+
cov: headOv / fullPx,
|
|
1253
|
+
upCov: fullUpPx > 0 ? headUpOv / fullUpPx : 0,
|
|
1254
|
+
loCov: fullLoPx > 0 ? headLoOv / fullLoPx : 0,
|
|
1255
|
+
};
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
const rawStats = measureCoverage(cleanedHead.canvas);
|
|
1259
|
+
let activeHead = head;
|
|
1260
|
+
let activeCanvas = cleanedHead.canvas;
|
|
1261
|
+
let underfillPx = 0;
|
|
1262
|
+
let underfillNote: string | null = null;
|
|
1263
|
+
|
|
1264
|
+
if (underfill && headFaceLayer !== undefined && headRawOpaquePx >= minPx) {
|
|
1265
|
+
const comp = newRaster(resolution, resolution);
|
|
1266
|
+
let compOpaquePx = 0;
|
|
1267
|
+
let x0 = resolution;
|
|
1268
|
+
let y0 = resolution;
|
|
1269
|
+
let x1 = -1;
|
|
1270
|
+
let y1 = -1;
|
|
1271
|
+
for (let y = 0; y < resolution; y++) {
|
|
1272
|
+
for (let x = 0; x < resolution; x++) {
|
|
1273
|
+
const p = y * resolution + x;
|
|
1274
|
+
const i = p * 4;
|
|
1275
|
+
const ta = cleanedHead.canvas.data[i + 3];
|
|
1276
|
+
const ua = warpedFull.data[i + 3];
|
|
1277
|
+
const a = Math.max(ta, ua);
|
|
1278
|
+
if (a === 0) continue;
|
|
1279
|
+
const tf = ta / 255;
|
|
1280
|
+
for (let c = 0; c < 3; c++) {
|
|
1281
|
+
comp.data[i + c] = Math.trunc(cleanedHead.canvas.data[i + c] * tf + warpedFull.data[i + c] * (1 - tf));
|
|
1282
|
+
}
|
|
1283
|
+
comp.data[i + 3] = a;
|
|
1284
|
+
if (ta <= OPAQUE_ALPHA_ABOVE && a > OPAQUE_ALPHA_ABOVE) underfillPx++;
|
|
1285
|
+
if (a > OPAQUE_ALPHA_ABOVE) compOpaquePx++;
|
|
1286
|
+
if (x < x0) x0 = x;
|
|
1287
|
+
if (y < y0) y0 = y;
|
|
1288
|
+
if (x > x1) x1 = x;
|
|
1289
|
+
if (y > y1) y1 = y;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (x1 >= x0 && y1 >= y0) {
|
|
1293
|
+
activeCanvas = comp;
|
|
1294
|
+
const postStats = measureCoverage(comp);
|
|
1295
|
+
const replacedLayer: Layer = {
|
|
1296
|
+
...headFaceLayer,
|
|
1297
|
+
pixels: crop(comp, x0, y0, x1 - x0 + 1, y1 - y0 + 1),
|
|
1298
|
+
left: x0,
|
|
1299
|
+
top: y0,
|
|
1300
|
+
right: x1 + 1,
|
|
1301
|
+
bottom: y1 + 1,
|
|
1302
|
+
opaquePx: compOpaquePx,
|
|
1303
|
+
};
|
|
1304
|
+
activeHead = {
|
|
1305
|
+
...head,
|
|
1306
|
+
layers: head.layers.map((l) => (l.name === 'face' ? replacedLayer : l)),
|
|
1307
|
+
};
|
|
1308
|
+
if (underfillPx > 0) {
|
|
1309
|
+
underfillNote = `face: head_face_underfill composited full:face under head:face on the head canvas, filling ${underfillPx} transparent px (${rawStats.headOv} / ${fullPx} px, ${(rawStats.cov * 100).toFixed(1)}% -> ${postStats.headOv} / ${fullPx} px, ${(postStats.cov * 100).toFixed(1)}%)`;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const finalStats = activeCanvas === cleanedHead.canvas ? rawStats : measureCoverage(activeCanvas);
|
|
1315
|
+
if (finalStats.cov < HEAD_FACE_COVERAGE_FLOOR) {
|
|
1316
|
+
const covStr = `${finalStats.headOv} / ${fullPx} px (${(finalStats.cov * 100).toFixed(1)}%, upper half ${(finalStats.upCov * 100).toFixed(1)}%, lower half ${(finalStats.loCov * 100).toFixed(1)}%)`;
|
|
1317
|
+
const floorStr = `${(HEAD_FACE_COVERAGE_FLOOR * 100).toFixed(1)}% (HEAD_FACE_COVERAGE_FLOOR = ${HEAD_FACE_COVERAGE_FLOOR})`;
|
|
1318
|
+
const detail =
|
|
1319
|
+
headFaceLayer === undefined || headRawOpaquePx < minPx
|
|
1320
|
+
? `${headFaceLayer === undefined ? 'holds no layer "face"' : `holds ${headRawOpaquePx} opaque px (alpha > ${OPAQUE_ALPHA_ABOVE}, below PROPOSE_MIN_PX = ${minPx})`}, covering ${covStr} of the full run's "face" silhouette on the head canvas (${fullOpaqueRunPx} full-run px); at least ${floorStr} is required — the head run omitted the face layer (so config.assemble.head_face_underfill cannot supply a jawline); re-run See-through with a different config.seethrough.seed`
|
|
1321
|
+
: `covers ${covStr} of the full run's "face" silhouette on the head canvas (${headRawOpaquePx} head-run px vs ${fullOpaqueRunPx} full-run px) — See-through left bare skin inside the face silhouette transparent; at least ${floorStr} is required — re-run See-through with a different config.seethrough.seed, or set "head_face_underfill": true in config.assemble to composite full:face under head:face on the head canvas`;
|
|
1322
|
+
refuseIfAny([
|
|
1323
|
+
{
|
|
1324
|
+
code: 'SEETHROUGH_HEAD_RUN_HOLLOW',
|
|
1325
|
+
object: `the head run (${head.source}) layer "face"`,
|
|
1326
|
+
detail,
|
|
1327
|
+
},
|
|
1328
|
+
]);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
return { head: activeHead, underfillPx, underfillNote };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
785
1334
|
/**
|
|
786
1335
|
* The painting in rig space: `PIL.Image.resize(..., LANCZOS)` of its RGB, as
|
|
787
1336
|
* an opaque raster. (`src/raster/resize.ts` `lanczos3` is PIL's, bit-exact;
|
|
@@ -2614,6 +3163,8 @@ export interface AssembleResult {
|
|
|
2614
3163
|
hidden: HiddenArtUse[];
|
|
2615
3164
|
seamRule: SeamRule;
|
|
2616
3165
|
projectRule: ProjectRule;
|
|
3166
|
+
/** Issue #492: head-canvas pixels filled by `config.assemble.head_face_underfill: true` when > 0. */
|
|
3167
|
+
headFaceUnderfillPx?: number;
|
|
2617
3168
|
}
|
|
2618
3169
|
|
|
2619
3170
|
/** A hidden-art layer of a run (issue #359) and what read it: `cuts[i].under`, or a limb segment's cap; none is said. */
|
|
@@ -2769,9 +3320,19 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
2769
3320
|
// Issue #366: one run's file, padded to the resolution square; read as both runs, as before.
|
|
2770
3321
|
const fullPadded = single ? padSingleRun(input.full, frame, input.resolution) : input.full;
|
|
2771
3322
|
// Issue #439: when seethrough.arm_split is declared, split the full run's overlapping handwear into handwear-r and handwear-l.
|
|
2772
|
-
const
|
|
2773
|
-
|
|
2774
|
-
const
|
|
3323
|
+
const fullArmSplit = input.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, input.resolution, input.armSplit);
|
|
3324
|
+
// Issue #490: when generation.control.skeleton is present, split merged arm layers (or refuse when sleeve and drape share one colour).
|
|
3325
|
+
const full = input.keySkeleton === undefined ? fullArmSplit : splitMergedArmRuns(fullArmSplit, input.resolution, frame, source.width, source.height, input.keySkeleton);
|
|
3326
|
+
const plan = mergedArmSplitPlan(armSplitPlan(input.plan, input.armSplit), full);
|
|
3327
|
+
// Issue #492: in two-run mode, check that head:face covers >= 75% of full:face's silhouette on the head canvas
|
|
3328
|
+
// (after applying head_face_underfill when declared). Skip only on a single-run subset (a mouth-only or clothes-only alternate).
|
|
3329
|
+
const checkHeadFace =
|
|
3330
|
+
!single &&
|
|
3331
|
+
(plan.some(([, r, t]) => r === 'head' && t === 'face') || (plan.some(([, r]) => r === 'full') && plan.some(([, r]) => r === 'head')));
|
|
3332
|
+
const headFacePrep = checkHeadFace
|
|
3333
|
+
? prepareHeadFace(full, input.head, frame, input.resolution, input.headFaceUnderfill === true)
|
|
3334
|
+
: { head: single ? full : input.head, underfillPx: 0, underfillNote: null };
|
|
3335
|
+
const head = headFacePrep.head;
|
|
2775
3336
|
const padRecord = singleRunPadRecord(single, source.width, source.height, input.resolution, frame);
|
|
2776
3337
|
if (single) checkSingleRun(full, plan, extend);
|
|
2777
3338
|
checkPlanAgainstRuns(plan, extend, full, head);
|
|
@@ -3268,6 +3829,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
3268
3829
|
hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
|
|
3269
3830
|
seamRule,
|
|
3270
3831
|
projectRule,
|
|
3832
|
+
...(headFacePrep.underfillPx > 0 ? { headFaceUnderfillPx: headFacePrep.underfillPx } : {}),
|
|
3271
3833
|
};
|
|
3272
3834
|
}
|
|
3273
3835
|
|
|
@@ -3761,8 +4323,16 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
|
|
|
3761
4323
|
// Issue #366: one run's file, padded to the resolution square as the stage pads it.
|
|
3762
4324
|
const fullPadded = g.single === true ? padSingleRun(full0, frame, g.resolution) : full0;
|
|
3763
4325
|
// Issue #439: when seethrough.arm_split is declared, split the full run's overlapping handwear into handwear-r and handwear-l.
|
|
3764
|
-
const
|
|
3765
|
-
|
|
4326
|
+
const fullArmSplit = g.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, g.resolution, g.armSplit);
|
|
4327
|
+
// Issue #490: when generation.control.skeleton is present, split merged arm layers (or refuse when sleeve and drape share one colour).
|
|
4328
|
+
const mergedSplit = g.keySkeleton === undefined ? { set: fullArmSplit, notes: [] as string[] } : splitMergedArmRunsWithNotes(fullArmSplit, g.resolution, frame, g.sourceW, g.sourceH, g.keySkeleton);
|
|
4329
|
+
const full = mergedSplit.set;
|
|
4330
|
+
// Issue #492: check (and when head_face_underfill: true, repair) a hollow head-run face against full:face mapped into the head canvas.
|
|
4331
|
+
const headFacePrep =
|
|
4332
|
+
g.single === true
|
|
4333
|
+
? { head: full, underfillPx: 0, underfillNote: null as string | null }
|
|
4334
|
+
: prepareHeadFace(full, head0, frame, g.resolution, g.headFaceUnderfill === true, minPx);
|
|
4335
|
+
const head = headFacePrep.head;
|
|
3766
4336
|
const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
|
|
3767
4337
|
const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
|
|
3768
4338
|
const figures: Record<Run, LayerFigures[]> = { full: layerFigures(full), head: layerFigures(head) };
|
|
@@ -3781,7 +4351,8 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
|
|
|
3781
4351
|
};
|
|
3782
4352
|
const ok = (run: Run, tag: string): boolean => {
|
|
3783
4353
|
const l = find(run, tag);
|
|
3784
|
-
|
|
4354
|
+
const floor = ARM_MERGED_SPLIT_TAGS.some((p) => tag === `${p}-r` || tag === `${p}-l`) ? 1 : minPx;
|
|
4355
|
+
return tag !== 'nose' && l !== undefined && l.opaqueRunPx >= floor && !implausible(run, tag, 'not proposed');
|
|
3785
4356
|
};
|
|
3786
4357
|
const body = runs.full.filter((l) => !PROPOSE_HEAD_TAGS.includes(l.tag) && ok('full', l.tag)).map((l) => l.tag);
|
|
3787
4358
|
// Issue #256: an author-made layer is proposed from the run that holds it, at its place in that run's draw order,
|
|
@@ -3807,7 +4378,7 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
|
|
|
3807
4378
|
}
|
|
3808
4379
|
plan.push([t.startsWith('handwear') && hw.length === 1 ? 'sleeves' : aliasOf(t), 'full', t]);
|
|
3809
4380
|
}
|
|
3810
|
-
const notes: string[] = [];
|
|
4381
|
+
const notes: string[] = [...mergedSplit.notes, ...(headFacePrep.underfillNote === null ? [] : [headFacePrep.underfillNote])];
|
|
3811
4382
|
const headArea = (frame.headK * frame.S) ** 2;
|
|
3812
4383
|
const fullArea = (frame.fullK * frame.S) ** 2;
|
|
3813
4384
|
for (const t of ['headwear', 'earwear']) {
|
|
@@ -4013,6 +4584,8 @@ export function stageFields(
|
|
|
4013
4584
|
bones?: BoneEntry[];
|
|
4014
4585
|
single?: boolean;
|
|
4015
4586
|
armSplit?: ArmSplit;
|
|
4587
|
+
keySkeleton?: ControlSkeleton;
|
|
4588
|
+
headFaceUnderfill?: boolean;
|
|
4016
4589
|
} {
|
|
4017
4590
|
const problems: Problem[] = [];
|
|
4018
4591
|
if (cfg.seethrough === undefined) {
|
|
@@ -4023,11 +4596,14 @@ export function stageFields(
|
|
|
4023
4596
|
}
|
|
4024
4597
|
refuseIfAny(problems);
|
|
4025
4598
|
const st = cfg.seethrough as SeeThrough;
|
|
4599
|
+
const keySkeleton = controlSkeletonOf(cfg);
|
|
4026
4600
|
return {
|
|
4027
4601
|
resolution: st.resolution,
|
|
4028
4602
|
headBox: single !== null ? singleRunHeadBox(single.w, single.h, st.resolution) : (st.head_box as [number, number, number, number]),
|
|
4029
4603
|
...(single !== null ? { single: true } : {}),
|
|
4030
4604
|
...(st.arm_split === undefined ? {} : { armSplit: st.arm_split }),
|
|
4605
|
+
...(keySkeleton === undefined ? {} : { keySkeleton }),
|
|
4606
|
+
...(cfg.assemble.head_face_underfill === undefined ? {} : { headFaceUnderfill: cfg.assemble.head_face_underfill }),
|
|
4031
4607
|
rigScale: cfg.assemble.rig_scale,
|
|
4032
4608
|
plan: cfg.assemble.plan,
|
|
4033
4609
|
extend: cfg.assemble.extend_below_crop ?? [],
|
|
@@ -4048,16 +4624,19 @@ export function stageFields(
|
|
|
4048
4624
|
export function proposeFields(
|
|
4049
4625
|
cfg: EarlyConfig,
|
|
4050
4626
|
single: { w: number; h: number } | null = null,
|
|
4051
|
-
): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean; armSplit?: ArmSplit } {
|
|
4627
|
+
): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean; armSplit?: ArmSplit; keySkeleton?: ControlSkeleton; headFaceUnderfill?: boolean } {
|
|
4052
4628
|
const armSplit = cfg.seethrough.arm_split === undefined ? {} : { armSplit: cfg.seethrough.arm_split };
|
|
4629
|
+
const keySkeleton = controlSkeletonOf(cfg);
|
|
4630
|
+
const skelField = keySkeleton === undefined ? {} : { keySkeleton };
|
|
4631
|
+
const underfillField = cfg.assemble.head_face_underfill === undefined ? {} : { headFaceUnderfill: cfg.assemble.head_face_underfill };
|
|
4053
4632
|
if (single !== null) {
|
|
4054
4633
|
refuseIfAny(singleRunHeadBoxProblems(cfg.seethrough));
|
|
4055
|
-
return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true, ...armSplit };
|
|
4634
|
+
return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true, ...armSplit, ...skelField, ...underfillField };
|
|
4056
4635
|
}
|
|
4057
4636
|
if (cfg.seethrough.head_box === undefined) {
|
|
4058
4637
|
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' }]);
|
|
4059
4638
|
}
|
|
4060
|
-
return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale, ...armSplit };
|
|
4639
|
+
return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale, ...armSplit, ...skelField, ...underfillField };
|
|
4061
4640
|
}
|
|
4062
4641
|
|
|
4063
4642
|
/**
|