@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/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 { ArmSplit, BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
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 full = input.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, input.resolution, input.armSplit);
2773
- const head = single ? full : input.head;
2774
- const plan = armSplitPlan(input.plan, input.armSplit);
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 full = g.armSplit === undefined ? fullPadded : applyArmSplit(fullPadded, g.resolution, g.armSplit);
3765
- const head = g.single === true ? full : head0;
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
- return tag !== 'nose' && l !== undefined && l.opaqueRunPx >= minPx && !implausible(run, tag, 'not proposed');
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
  /**