@rig-c/parts 1.31.0 → 1.33.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/check.ts CHANGED
@@ -125,7 +125,8 @@ import {
125
125
  type Toward,
126
126
  type WorldBox,
127
127
  } from './instruments.ts';
128
- import { type PartRecord, type PartsFile, readParts } from './parts.ts';
128
+ import { type AlternatePartsRecord, type PartRecord, type PartsFile, readParts } from './parts.ts';
129
+ import { poseRigBonesForDrawn, type RigBone, SETS_FILE } from './rig.ts';
129
130
  import { turnTwin } from './turn.ts';
130
131
  import {
131
132
  aimLine,
@@ -622,7 +623,7 @@ function readJson(path: string, problems: Problem[]): Record<string, unknown> |
622
623
  * duration. `sourcePath` is `--source`, read and held to the stage here, so a
623
624
  * painting that is not this rig's is refused before anything is built.
624
625
  */
625
- export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string): CheckInputs {
626
+ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string, set?: string, allSets = false): CheckInputs {
626
627
  const dir = resolve(rigDir);
627
628
  const named = partsHome !== undefined;
628
629
  const home = resolve(partsHome ?? rigDir);
@@ -674,6 +675,43 @@ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?:
674
675
  problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `part "${p.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${p.w}x${p.h}` });
675
676
  }
676
677
  }
678
+ for (const alt of parts.alternates ?? []) {
679
+ for (const r of alt.records) {
680
+ const png = join(partsDir, `${alt.name}_${r.name}.png`);
681
+ if (!existsSync(png)) {
682
+ problems.push({ code: 'CHECK_PART_PNG_PRESENT', object: `set "${alt.name}" part "${r.name}"`, detail: `${png} does not exist; parts.json lists it` });
683
+ continue;
684
+ }
685
+ const im = readPng(png);
686
+ if (im.width !== r.w || im.height !== r.h) {
687
+ problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `set "${alt.name}" part "${r.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${r.w}x${r.h}` });
688
+ }
689
+ }
690
+ }
691
+ }
692
+ if (set !== undefined || allSets) {
693
+ if (parts === null) {
694
+ problems.push({
695
+ code: 'CHECK_INPUT_PRESENT',
696
+ object: set !== undefined ? `--set ${set}` : '--all-sets',
697
+ detail: `${partsPath} was not read; alternate sets are read from parts.json's "alternates"`,
698
+ });
699
+ } else {
700
+ const alts = parts.alternates ?? [];
701
+ if (set !== undefined && !alts.some((a) => a.name === set)) {
702
+ problems.push({
703
+ code: 'CHECK_INPUT_PRESENT',
704
+ object: `--set ${set}`,
705
+ detail: `${partsPath} has no alternate set "${set}" in "alternates" (${alts.length === 0 ? 'none declared' : alts.map((a) => `"${a.name}"`).join(', ')})`,
706
+ });
707
+ } else if (allSets && alts.length === 0) {
708
+ problems.push({
709
+ code: 'CHECK_INPUT_PRESENT',
710
+ object: '--all-sets',
711
+ detail: `${partsPath} declares no alternate sets in "alternates"`,
712
+ });
713
+ }
714
+ }
677
715
  }
678
716
  let stage: StageBox | null = null;
679
717
  let rootBone: string | null = null;
@@ -1195,6 +1233,72 @@ export function flatComposite(parts: PartsFile, partsDir: string, background: re
1195
1233
  return canvas;
1196
1234
  }
1197
1235
 
1236
+ /**
1237
+ * The flat stack of parts for an alternate set (issue #454), in `parts.parts`
1238
+ * slot order: hidden slots skipped, covered slots drawn from `alt.records` at
1239
+ * their box, uncovered base slots drawn at their base box.
1240
+ */
1241
+ export function flatCompositeForSet(parts: PartsFile, alt: AlternatePartsRecord, partsDir: string, background: readonly [number, number, number, number]): Raster {
1242
+ const [W, H] = parts.rig_size;
1243
+ let canvas = newRaster(W, H);
1244
+ for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
1245
+ const hides = new Set(alt.hides ?? []);
1246
+ const byName = new Map(alt.records.map((r) => [r.name, r]));
1247
+ for (const p of parts.parts) {
1248
+ if (hides.has(p.name)) continue;
1249
+ const rec = byName.get(p.name);
1250
+ const pngName = rec === undefined ? p.name : `${alt.name}_${rec.name}`;
1251
+ const box = rec ?? p;
1252
+ canvas = alphaComposite(canvas, readPng(join(partsDir, `${pngName}.png`)), box.x, box.y);
1253
+ }
1254
+ return canvas;
1255
+ }
1256
+
1257
+ /**
1258
+ * The setup-pose rig spec with an alternate set applied (issues #454, #465):
1259
+ * hidden slots (and `<slot>_still` for any hidden or covered slot) have no
1260
+ * setup attachment, covered slots have `alt.slots[slot]`, and when the set
1261
+ * carries `drawn` bone rotations its bones are posed at those world rotations
1262
+ * so the throwaway still renders the alternate in the pose it was painted in.
1263
+ */
1264
+ export function stillRigForSet(
1265
+ rig: Record<string, unknown>,
1266
+ alt: AlternatePartsRecord,
1267
+ drawn: Readonly<Record<string, number>> | undefined = alt.drawn,
1268
+ ): Record<string, unknown> {
1269
+ const hides = new Set(alt.hides ?? []);
1270
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).map((s) => {
1271
+ if (!isRecord(s) || typeof s.name !== 'string') return s;
1272
+ const stillBase = s.name.endsWith('_still') ? s.name.slice(0, -'_still'.length) : null;
1273
+ if (hides.has(s.name) || (stillBase !== null && (hides.has(stillBase) || Object.hasOwn(alt.slots, stillBase)))) {
1274
+ return { ...s, attachment: null };
1275
+ }
1276
+ if (Object.hasOwn(alt.slots, s.name)) return { ...s, attachment: alt.slots[s.name] };
1277
+ return s;
1278
+ });
1279
+ if (drawn === undefined || Object.keys(drawn).length === 0) return { ...rig, slots };
1280
+ const rawBones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).map((b) => ({
1281
+ name: String(b.name),
1282
+ ...(typeof b.parent === 'string' && b.parent !== '' ? { parent: b.parent } : {}),
1283
+ ...(typeof b.length === 'number' ? { length: b.length } : {}),
1284
+ ...(typeof b.rotation === 'number' ? { rotation: b.rotation } : {}),
1285
+ x: typeof b.x === 'number' ? b.x : 0,
1286
+ y: typeof b.y === 'number' ? b.y : 0,
1287
+ ...(typeof b.inherit === 'string' ? { inherit: b.inherit as RigBone['inherit'] } : {}),
1288
+ }));
1289
+ const stops = (Array.isArray(rig.stops) ? rig.stops : []).filter(isRecord).map((s) => ({
1290
+ links: Array.isArray(s.links) ? s.links.filter((l): l is string => typeof l === 'string') : [],
1291
+ }));
1292
+ const bones = poseRigBonesForDrawn(rawBones, drawn, stops);
1293
+ const out: Record<string, unknown> = { ...rig, bones, slots };
1294
+ if (isRecord(out.invariants)) {
1295
+ const { axes: _axes, ...rest } = out.invariants;
1296
+ if (Object.keys(rest).length > 0) out.invariants = rest;
1297
+ else delete out.invariants;
1298
+ }
1299
+ return out;
1300
+ }
1301
+
1198
1302
  /**
1199
1303
  * The map from rig (crop) pixel indices to frame pixel indices that the seam
1200
1304
  * and the source line warp by: frame = (sx u + tx, sy v + ty).
@@ -1770,11 +1874,26 @@ export interface CheckFigures {
1770
1874
  * the keys it always wrote.
1771
1875
  */
1772
1876
  skipped?: { loop?: string; seam?: string };
1877
+ /** Written only under `--set` or `--all-sets` (issue #454): each checked alternate set's setup-pose seam and recomposite figures, keyed by set name. */
1878
+ sets?: Record<string, SetCheckFigures>;
1773
1879
  /** Written only under `--requirements` (issue #93): the file's fps, the summary, and one line per declared requirement, by name. */
1774
1880
  requirements?: RequirementsBlock;
1775
1881
  PASS: boolean;
1776
1882
  }
1777
1883
 
1884
+ /** One alternate set's setup-pose check record under `--set` or `--all-sets` (issue #454). */
1885
+ export interface SetCheckFigures {
1886
+ render_px: string;
1887
+ seam_mean: number;
1888
+ seam_px_over_40: number;
1889
+ seam_px_over_80: number;
1890
+ seam_frame_scale: number;
1891
+ seam_border?: { frame_px_left_out: number; px_over_40_left_out: number };
1892
+ seam_pairs?: SeamPairRow[];
1893
+ RECOMPOSITE_HOLES: ReportedLine;
1894
+ PASS: boolean;
1895
+ }
1896
+
1778
1897
  /** `check.json`'s `requirements` block: the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
1779
1898
  export interface RequirementsBlock {
1780
1899
  fps: number;
@@ -1959,7 +2078,10 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1959
2078
  if (typeof fig.loop_max_diff === 'number' && fig.loop_max_diff !== LOOP_MAX_BAR) out.push('loop');
1960
2079
  // A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
1961
2080
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1962
- if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
2081
+ const setFailed =
2082
+ isRecord(fig.sets) &&
2083
+ Object.values(fig.sets).some((s) => isRecord(s) && typeof s.seam_mean === 'number' && typeof s.seam_px_over_40 === 'number' && seamFails(s.seam_mean, s.seam_px_over_40, typeof s.seam_frame_scale === 'number' ? s.seam_frame_scale : 1));
2084
+ if ((typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) || setFailed) out.push('seam');
1963
2085
  for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE]) {
1964
2086
  const l = fig[n];
1965
2087
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
@@ -3072,11 +3194,11 @@ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[
3072
3194
  * MEASURABLE is a problem. Without it nothing here reads, writes or prints
3073
3195
  * anything it did not before.
3074
3196
  */
3075
- export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false): CheckReport {
3197
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false, set?: string, allSets = false): CheckReport {
3076
3198
  let inp: CheckInputs;
3077
3199
  let reqFile: RequirementsFile | null = null;
3078
3200
  let seams: Map<string, SeamPair[]> | null = null;
3079
- if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source);
3201
+ if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source, set, allSets);
3080
3202
  else {
3081
3203
  // Both readers collect every problem; one refusal names both sets.
3082
3204
  const refused: Problem[] = [];
@@ -3089,7 +3211,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3089
3211
  return null;
3090
3212
  }
3091
3213
  };
3092
- const got = take(() => readCheckInputs(rigDir, partsHome, source));
3214
+ const got = take(() => readCheckInputs(rigDir, partsHome, source, set, allSets));
3093
3215
  reqFile = take(() => readRequirements(requirements));
3094
3216
  refuseIfAny(refused);
3095
3217
  inp = got as CheckInputs;
@@ -3436,6 +3558,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3436
3558
  let seamViewport: Viewport | null = null;
3437
3559
  let blink: JudgementLine = skip(lacking);
3438
3560
  let sourceRead: ReportedLine | null = null;
3561
+ const setsFigures: Record<string, SetCheckFigures> = {};
3439
3562
  if (inp.parts !== null || inp.source !== null) {
3440
3563
  try {
3441
3564
  mkdirSync(stillDir, { recursive: true });
@@ -3475,6 +3598,71 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3475
3598
  for (const { row, pixels } of split) writePng(join(out, row.picture), seamPicture(stillSet.frames[0].image, row, pixels));
3476
3599
  seamPairs = split.map((x) => x.row);
3477
3600
  }
3601
+ if (set !== undefined || allSets) {
3602
+ const alts = allSets ? (inp.parts.alternates ?? []) : (inp.parts.alternates ?? []).filter((a) => a.name === set);
3603
+ const setStillAnimations: Record<string, unknown> = { still: animations.still };
3604
+ const setStillBase = { ...withoutSliders(inp.rig, setStillAnimations), images };
3605
+ const setsJsonPath = join(inp.rigDir, SETS_FILE);
3606
+ const setsJsonRaw = existsSync(setsJsonPath) ? readJson(setsJsonPath, []) : null;
3607
+ const setsMap = setsJsonRaw !== null && isRecord(setsJsonRaw.sets) ? setsJsonRaw.sets : null;
3608
+ for (const alt of alts) {
3609
+ const setEntry: Record<string, unknown> | null =
3610
+ setsMap !== null && isRecord(setsMap[alt.name]) ? (setsMap[alt.name] as Record<string, unknown>) : null;
3611
+ const setDrawn: Record<string, number> | undefined =
3612
+ setEntry !== null && isRecord(setEntry.drawn)
3613
+ ? Object.fromEntries(
3614
+ Object.entries(setEntry.drawn).filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])),
3615
+ )
3616
+ : alt.drawn;
3617
+ const setDir = join(stillDir, `set_${alt.name}`);
3618
+ mkdirSync(setDir, { recursive: true });
3619
+ writeFileSync(join(setDir, 'rig.json'), JSON.stringify(stillRigForSet(setStillBase, alt, setDrawn)));
3620
+ writeFileSync(join(setDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations: setStillAnimations }));
3621
+ const asb = rigc(['build', '--rig', join(setDir, 'rig.json'), '--motion', join(setDir, 'motion.json'), '--out', join(setDir, 'build'), '--profile', 'spine']);
3622
+ if (asb.status !== 0) refuseIfAny(rigcFailed(`build (the setup-pose still of set "${alt.name}")`, asb, buildGateLines(asb.out)));
3623
+ const asr = rigc(['render', '--candidate', join(setDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(setDir, 'render')]);
3624
+ if (asr.status !== 0) refuseIfAny(rigcFailed(`render (the setup-pose still of set "${alt.name}")`, asr, asr.out.split('\n').filter((l) => l.includes('FAIL'))));
3625
+ const altStillSet = readFrameSet(join(setDir, 'render'));
3626
+ if (keepStills) cpSync(join(setDir, 'render'), join(keptDir, alt.name), { recursive: true });
3627
+ const altComposite = flatCompositeForSet(inp.parts, alt, inp.partsDir, altStillSet.background);
3628
+ const altSeam = seamFigures(altComposite, altStillSet.frames[0].image, inp.stage, altStillSet.viewport, altStillSet.background, undefined, alt.scene !== undefined);
3629
+ const altMean = round3(altSeam.mean);
3630
+ const altScale = round6(altStillSet.viewport.scale);
3631
+ const altFailed = seamFails(altMean, altSeam.over40, altScale);
3632
+ let altPairs: SeamPairRow[] | null = null;
3633
+ if (seamPairsAsked || altFailed) {
3634
+ const hides = new Set(alt.hides ?? []);
3635
+ const byName = new Map(alt.records.map((r) => [r.name, r]));
3636
+ const placed = inp.parts.parts
3637
+ .filter((p) => !hides.has(p.name))
3638
+ .map((p) => {
3639
+ const rec = byName.get(p.name);
3640
+ const pngName = rec === undefined ? p.name : `${alt.name}_${rec.name}`;
3641
+ const box = rec ?? p;
3642
+ return { name: p.name, from: box.from, x: box.x, y: box.y, image: readPng(join(inp.partsDir, `${pngName}.png`)) };
3643
+ });
3644
+ const split = seamPairRows(altSeam, placed, inp.parts.rig_size, altStillSet.viewport);
3645
+ const altSeamDir = join(seamDir, alt.name);
3646
+ mkdirSync(altSeamDir, { recursive: true });
3647
+ for (const { row, pixels } of split) {
3648
+ row.picture = `${SEAM_DIR}/${alt.name}/${basename(row.picture)}`;
3649
+ writePng(join(out, row.picture), seamPicture(altStillSet.frames[0].image, row, pixels));
3650
+ }
3651
+ altPairs = split.map((x) => x.row);
3652
+ }
3653
+ setsFigures[alt.name] = {
3654
+ render_px: `${altStillSet.viewport.pixelWidth}x${altStillSet.viewport.pixelHeight}`,
3655
+ seam_mean: altMean,
3656
+ seam_px_over_40: altSeam.over40,
3657
+ seam_px_over_80: altSeam.over80,
3658
+ seam_frame_scale: altScale,
3659
+ ...(altSeam.border === null ? {} : { seam_border: { frame_px_left_out: altSeam.border.framePx, px_over_40_left_out: altSeam.border.over40 } }),
3660
+ ...(altPairs === null ? {} : { seam_pairs: altPairs }),
3661
+ RECOMPOSITE_HOLES: altHolesLine(inp.parts, alt),
3662
+ PASS: !altFailed,
3663
+ };
3664
+ }
3665
+ }
3478
3666
  }
3479
3667
  if (inp.source !== null) {
3480
3668
  // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
@@ -3545,6 +3733,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3545
3733
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
3546
3734
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
3547
3735
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
3736
+ ...(Object.keys(setsFigures).length === 0 ? {} : { sets: setsFigures }),
3548
3737
  ...(reqBlock === null ? {} : { requirements: reqBlock }),
3549
3738
  PASS: false,
3550
3739
  };
@@ -3570,6 +3759,20 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3570
3759
  : `; worst of ${(seamPairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)} — every pair is a \`seam pair\` line above and a row of check.json's seam_pairs`),
3571
3760
  });
3572
3761
  }
3762
+ for (const [setName, setFig] of Object.entries(setsFigures)) {
3763
+ if (!setFig.PASS) {
3764
+ const worst = setFig.seam_pairs === undefined || setFig.seam_pairs.length === 0 ? null : setFig.seam_pairs[0];
3765
+ barProblems.push({
3766
+ code: 'CHECK_SEAM_WITHIN_BAR',
3767
+ object: `the setup-pose render of set "${setName}" vs the flat composite of parts/`,
3768
+ detail:
3769
+ `mean |d| ${setFig.seam_mean}/255 and ${setFig.seam_px_over_40} frame px over ${SEAM_PX_LEVEL} (${setFig.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${setFig.seam_frame_scale}, ${seamRigPxText(setFig.seam_px_over_40, setFig.seam_frame_scale)} rig px (the count / s²); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required` +
3770
+ (worst === null
3771
+ ? `; no pixel is over ${SEAM_PX_LEVEL}, so the mean alone fails and there is no pair to name`
3772
+ : `; worst of ${(setFig.seam_pairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)}`),
3773
+ });
3774
+ }
3775
+ }
3573
3776
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
3574
3777
  problems.sort((a, b) => rank(a) - rank(b));
3575
3778
  problems.unshift(...barProblems);
@@ -3775,6 +3978,20 @@ export function holesLine(parts: PartsFile): ReportedLine {
3775
3978
  };
3776
3979
  }
3777
3980
 
3981
+ /** `RECOMPOSITE_HOLES` for an alternate set (issue #454), carrying its assemble `mean_abs` and hole figures. */
3982
+ export function altHolesLine(parts: PartsFile, alt: AlternatePartsRecord): ReportedLine {
3983
+ const r = alt.recomposite;
3984
+ return {
3985
+ status: 'REPORTED',
3986
+ ...(alt.scene === undefined ? {} : { declared_scene: scenePhrase(alt.scene, parts.rig_size[0] * parts.rig_size[1]) }),
3987
+ mean_abs: r.mean_abs,
3988
+ error_px: r.error_px,
3989
+ uncovered_error_px: r.uncovered_error_px,
3990
+ hole_count: r.hole_count,
3991
+ largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]), borders: r.holes[0].borders.map((b) => `${b.part} ${b.px} px`) },
3992
+ };
3993
+ }
3994
+
3778
3995
  function boxText(h: { x: number; y: number; w: number; h: number }): string {
3779
3996
  return `${h.x},${h.y} ${h.w}x${h.h}`;
3780
3997
  }
package/src/config.ts CHANGED
@@ -93,7 +93,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
95
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
96
- import { FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
96
+ import { EAR_DEPTH_RANGE, FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, FACE_RATED_PITCH_BAND, FACE_RATED_YAW_BAND, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
97
97
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
98
98
 
99
99
  export type Point = [number, number];
@@ -254,6 +254,8 @@ export interface AlternateSetConfig {
254
254
  hides?: string[];
255
255
  head_box?: [number, number, number, number];
256
256
  cuts?: Cut[];
257
+ skeleton?: string;
258
+ drawn?: Record<string, number>;
257
259
  }
258
260
 
259
261
  export interface Assemble {
@@ -773,6 +775,8 @@ export interface CharacterConfig {
773
775
  cloth?: ClothRecipes;
774
776
  /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
775
777
  proposed_rig_scale?: number;
778
+ /** Alternate painting sets (issue #454): absent, no alternate sets are assembled or emitted. */
779
+ alternates?: AlternateSetConfig[];
776
780
  }
777
781
 
778
782
  /** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
@@ -1141,7 +1145,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1141
1145
  limbs = checkLimbs(c, t.limbs, made, names);
1142
1146
  }
1143
1147
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1144
- checkAlternates(c, t.assemble.alternates, made, limbs);
1148
+ checkAlternates(c, t.assemble.alternates, made, limbs, null);
1145
1149
  }
1146
1150
  }
1147
1151
  if ('cloth' in t) checkCloth(c, t.cloth);
@@ -1293,7 +1297,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1293
1297
  const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1294
1298
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1295
1299
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1296
- checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs);
1300
+ checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones);
1297
1301
  }
1298
1302
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1299
1303
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
@@ -1301,11 +1305,13 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1301
1305
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1302
1306
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1303
1307
  if ('stops' in t) checkStops(c, t.stops, names.bones, names.chains);
1304
- if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
1308
+ const hasAltFace = hasAlternateFaceSet(t.assemble, [...parts, ...cuts]);
1309
+ if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones, hasAltFace);
1305
1310
  if ('scene' in t) checkScene(c, t.scene);
1306
1311
  if ('cloth' in t) checkCloth(c, t.cloth);
1307
1312
  const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
1308
1313
  const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
1314
+ if (animations !== null) checkFaceAnimationBand(c, animations, hasAltFace);
1309
1315
  if (base !== null) poseBeside(c, t.generation, base);
1310
1316
  refuseIfAny(c.problems);
1311
1317
  return { config: raw as CharacterConfig, animations };
@@ -1820,6 +1826,7 @@ function checkAlternates(
1820
1826
  v: Json,
1821
1827
  made: { parts: string[]; patches: string[]; cuts: string[] },
1822
1828
  limbs: LimbNames | null,
1829
+ bones: Set<string> | null = null,
1823
1830
  ): void {
1824
1831
  const p = 'config.assemble.alternates';
1825
1832
  if (!c.array(p, v, true)) return;
@@ -1827,16 +1834,29 @@ function checkAlternates(
1827
1834
  const seenNames = new Map<string, number>();
1828
1835
  (v as Json[]).forEach((entry, i) => {
1829
1836
  const at = `${p}[${i}]`;
1830
- const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts']);
1837
+ const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts', 'skeleton', 'drawn']);
1831
1838
  if (e === null) return;
1832
1839
  if ('name' in e && c.string(`${at}.name`, e.name)) {
1833
1840
  if (!partNameOk(e.name)) c.fail('CONFIG_PART_NAME', `${at}.name`, `names the set ${show(e.name)}; a set name is a file prefix, so it may not be empty, start with "." or hold a slash`);
1834
1841
  if (seenNames.has(e.name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the set "${e.name}" again (first at alternates[${seenNames.get(e.name)}])`);
1835
1842
  else seenNames.set(e.name, i);
1836
1843
  }
1837
- for (const k of ['source', 'full', 'head'] as const) {
1844
+ for (const k of ['source', 'full', 'head', 'skeleton'] as const) {
1838
1845
  if (k in e) c.string(`${at}.${k}`, e[k]);
1839
1846
  }
1847
+ if ('drawn' in e) {
1848
+ const d = e.drawn;
1849
+ if (!isPlainObject(d) || Object.keys(d).length === 0) {
1850
+ c.fail('CONFIG_FIELD_TYPE', `${at}.drawn`, `is ${show(d)}; a non-empty object of bone name -> drawn world angle in Spine degrees is required`);
1851
+ } else {
1852
+ for (const [bName, deg] of Object.entries(d)) {
1853
+ if (bones !== null && !bones.has(bName)) {
1854
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.drawn.${bName}`, `names "${bName}", which config.bones does not declare`);
1855
+ }
1856
+ c.number(`${at}.drawn.${bName}`, deg);
1857
+ }
1858
+ }
1859
+ }
1840
1860
  if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
1841
1861
  const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
1842
1862
  const validParts = new Set<string>([...allSlots, ...altCuts]);
@@ -3167,9 +3187,96 @@ function checkScene(c: Check, v: Json): void {
3167
3187
  }
3168
3188
  }
3169
3189
 
3170
- function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
3190
+ function hasAlternateFaceSet(assemble: Json, planParts: readonly string[]): boolean {
3191
+ if (!isPlainObject(assemble) || !Array.isArray(assemble.alternates)) return false;
3192
+ const allParts = new Set(planParts);
3193
+ for (const alt of assemble.alternates) {
3194
+ if (!isPlainObject(alt)) continue;
3195
+ const hides = Array.isArray(alt.hides) ? alt.hides : [];
3196
+ if (Array.isArray(alt.parts)) {
3197
+ if (alt.parts.includes('face') && !hides.includes('face')) return true;
3198
+ } else if (allParts.has('face') && !hides.includes('face')) {
3199
+ return true;
3200
+ }
3201
+ }
3202
+ return false;
3203
+ }
3204
+
3205
+ function maxDeriveDegrees(anim: unknown): number {
3206
+ if (!isPlainObject(anim) || !Array.isArray(anim.tracks)) return 0;
3207
+ let maxDeg = 0;
3208
+ for (const tr of anim.tracks) {
3209
+ if (!isPlainObject(tr) || !Array.isArray(tr.keys)) continue;
3210
+ for (const k of tr.keys) {
3211
+ if (!isPlainObject(k) || !isPlainObject(k.derive)) continue;
3212
+ const deg = k.derive.degrees;
3213
+ if (typeof deg === 'number' && Number.isFinite(deg)) {
3214
+ const a = Math.abs(deg);
3215
+ if (a > maxDeg) maxDeg = a;
3216
+ }
3217
+ }
3218
+ }
3219
+ return maxDeg;
3220
+ }
3221
+
3222
+ function checkFaceAnimationBand(c: Check, animations: AnimationsFrom, hasAltFace: boolean): void {
3223
+ const at = `config.motion.animations_from (${animations.file}).animations`;
3224
+ const maxYaw = maxDeriveDegrees(animations.table.turn);
3225
+ const maxPitch = maxDeriveDegrees(animations.table.tilt);
3226
+ if (maxYaw > FACE_RATED_YAW_BAND && !hasAltFace) {
3227
+ c.fail(
3228
+ 'CONFIG_FACE_BAND_EXCEEDED',
3229
+ `${at}.turn`,
3230
+ `rates yaw ±${maxYaw}° on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
3231
+ );
3232
+ }
3233
+ if (maxPitch > FACE_RATED_PITCH_BAND && !hasAltFace) {
3234
+ c.fail(
3235
+ 'CONFIG_FACE_BAND_EXCEEDED',
3236
+ `${at}.tilt`,
3237
+ `rates pitch ±${maxPitch}° on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
3238
+ );
3239
+ }
3240
+ const [earLo, earHi] = EAR_DEPTH_RANGE;
3241
+ for (const [animName, anim] of [
3242
+ ['tilt', animations.table.tilt],
3243
+ ['turn', animations.table.turn],
3244
+ ] as const) {
3245
+ if (!isPlainObject(anim) || !Array.isArray(anim.tracks)) continue;
3246
+ anim.tracks.forEach((tr, ti) => {
3247
+ if (!isPlainObject(tr) || !Array.isArray(tr.keys)) return;
3248
+ const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
3249
+ if (!isEarTrack) return;
3250
+ tr.keys.forEach((k, ki) => {
3251
+ if (!isPlainObject(k) || !isPlainObject(k.derive)) return;
3252
+ const d = k.derive.depth;
3253
+ if (typeof d === 'number' && Number.isFinite(d)) {
3254
+ if (d < earLo || d > earHi) {
3255
+ c.fail(
3256
+ 'CONFIG_TURN_EAR_DEPTH_RANGE',
3257
+ `${at}.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
3258
+ `is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
3259
+ );
3260
+ }
3261
+ } else if (isPlainObject(d)) {
3262
+ for (const [member, val] of Object.entries(d)) {
3263
+ if (typeof val === 'number' && Number.isFinite(val) && (val < earLo || val > earHi)) {
3264
+ c.fail(
3265
+ 'CONFIG_TURN_EAR_DEPTH_RANGE',
3266
+ `${at}.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
3267
+ `is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
3268
+ );
3269
+ }
3270
+ }
3271
+ }
3272
+ });
3273
+ });
3274
+ }
3275
+ }
3276
+
3277
+ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>, hasAltFace: boolean): void {
3171
3278
  const at = 'config.turn';
3172
- const o = c.object(at, v, ['pivot', 'depths'], ['deform']);
3279
+ const o = c.object(at, v, ['pivot', 'depths'], ['deform', 'band']);
3173
3280
  if (o === null) return;
3174
3281
  const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
3175
3282
  const depths: Record<string, number> = {};
@@ -3190,6 +3297,39 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>)
3190
3297
  }
3191
3298
  }
3192
3299
  }
3300
+ if ('band' in o) {
3301
+ const b = o.band;
3302
+ if (typeof b === 'number' && Number.isFinite(b) && b > 0) {
3303
+ if (b > FACE_RATED_YAW_BAND && !hasAltFace) {
3304
+ c.fail(
3305
+ 'CONFIG_FACE_BAND_EXCEEDED',
3306
+ `${at}.band`,
3307
+ `rates yaw ±${b}° on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
3308
+ );
3309
+ }
3310
+ } else if (isPlainObject(b)) {
3311
+ const bo = c.object(`${at}.band`, b, [], ['yaw', 'pitch']);
3312
+ if (bo !== null) {
3313
+ const yawOk = !('yaw' in bo) || c.number(`${at}.band.yaw`, bo.yaw, 'positive');
3314
+ const pitchOk = !('pitch' in bo) || c.number(`${at}.band.pitch`, bo.pitch, 'positive');
3315
+ const yawVal = yawOk && typeof bo.yaw === 'number' ? bo.yaw : null;
3316
+ const pitchVal = pitchOk && typeof bo.pitch === 'number' ? bo.pitch : null;
3317
+ if (!hasAltFace && ((yawVal !== null && yawVal > FACE_RATED_YAW_BAND) || (pitchVal !== null && pitchVal > FACE_RATED_PITCH_BAND))) {
3318
+ const foundParts = [
3319
+ ...(yawVal !== null ? [`yaw ±${yawVal}°`] : []),
3320
+ ...(pitchVal !== null ? [`pitch ±${pitchVal}°`] : []),
3321
+ ];
3322
+ c.fail(
3323
+ 'CONFIG_FACE_BAND_EXCEEDED',
3324
+ `${at}.band`,
3325
+ `rates ${foundParts.join(' / ')} on a single face plate; the rated band for one face plate is yaw ±${FACE_RATED_YAW_BAND}° / pitch ±${FACE_RATED_PITCH_BAND}° — declare an alternate face set in config.assemble.alternates to go beyond ±${FACE_RATED_YAW_BAND}° / ±${FACE_RATED_PITCH_BAND}°`,
3326
+ );
3327
+ }
3328
+ }
3329
+ } else {
3330
+ c.fail('CONFIG_FIELD_TYPE', `${at}.band`, `is ${show(b)}; a positive yaw number or {yaw, pitch} is required`);
3331
+ }
3332
+ }
3193
3333
  if ('deform' in o) {
3194
3334
  const df = o.deform;
3195
3335
  if (!isPlainObject(df)) {
package/src/parts.ts CHANGED
@@ -83,6 +83,7 @@
83
83
  */
84
84
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
85
85
  import { type Problem, refuseIfAny } from './errors.ts';
86
+ import { KEYPOINT_NAMES, type PosePoints } from './skeleton.ts';
86
87
  import { readLayerTag } from './tags.ts';
87
88
 
88
89
  export interface PartRecord {
@@ -244,6 +245,8 @@ export interface RecompositeRecord {
244
245
  export interface AlternateSetMap {
245
246
  slots: Record<string, string>;
246
247
  hides?: string[];
248
+ drawn?: Record<string, number>;
249
+ threshold?: { yaw: number; pitch: number };
247
250
  }
248
251
 
249
252
  export interface AlternatePartsRecord {
@@ -256,6 +259,8 @@ export interface AlternatePartsRecord {
256
259
  recomposite: RecompositeRecord;
257
260
  scene?: SceneRecord;
258
261
  single_run_pad?: SingleRunPad;
262
+ skeleton?: PosePoints;
263
+ drawn?: Record<string, number>;
259
264
  }
260
265
 
261
266
  export interface PartsFile {
@@ -400,6 +405,8 @@ export function serializeParts(file: PartsFile): string {
400
405
  recomposite: serializeRecomposite(a.recomposite),
401
406
  ...(a.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (a.scene as SceneRecord)[k]])) }),
402
407
  ...(a.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (a.single_run_pad as SingleRunPad)[k]])) }),
408
+ ...(a.skeleton === undefined ? {} : { skeleton: Object.fromEntries(KEYPOINT_NAMES.map((k) => [k, (a.skeleton as PosePoints)[k]])) }),
409
+ ...(a.drawn === undefined ? {} : { drawn: a.drawn }),
403
410
  })),
404
411
  }),
405
412
  };
package/src/propose.ts CHANGED
@@ -1962,7 +1962,7 @@ export function proposeWithBasis(
1962
1962
  const [g, r] = gridR(p);
1963
1963
  const top = p.y;
1964
1964
  const bot = p.y + p.h;
1965
- if (bot < neckY + 0.5 * fh) {
1965
+ if (bot < neckY + 0.5 * fh && isOn(dilate(P.alpha(p), 31), pyRound(axis), pyRound(bot - 0.1 * p.h))) {
1966
1966
  B(
1967
1967
  'bun',
1968
1968
  'head',