@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/build.ts CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
73
+ import { besideIdleLine, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
74
  import { type Cut, type Extend, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
75
75
  import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
@@ -492,6 +492,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
492
492
  ...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: altBones } : {}),
493
493
  ...(fields.scene === undefined ? {} : { scene: fields.scene }),
494
494
  ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
495
+ ...(fields.headFaceUnderfill === undefined ? {} : { headFaceUnderfill: fields.headFaceUnderfill }),
495
496
  seamRule: input.seam,
496
497
  projectRule: input.project,
497
498
  });
@@ -569,10 +570,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
569
570
  const pad = result.parts.single_run_pad;
570
571
  if (pad !== undefined) log(singleRunPadLine(input.full, pad));
571
572
  for (const p of result.parts.parts) {
573
+ const underfillSuffix = p.from === 'head:face' && (result.headFaceUnderfillPx ?? 0) > 0 ? ` underfill=${result.headFaceUnderfillPx}px(full:face)` : '';
572
574
  log(
573
575
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
574
576
  `op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
575
- `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
577
+ `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}${underfillSuffix}`,
576
578
  );
577
579
  }
578
580
  // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
@@ -1483,6 +1485,27 @@ export function sweepCheckLines(line: SweepLine): string[] {
1483
1485
  ];
1484
1486
  }
1485
1487
 
1488
+ /**
1489
+ * The seam over every animation (issue #493) as the console prints it: the status, what was read, the floor and that
1490
+ * it is not a bar, then one WARN line per pair and animation with a frame over the floor, and a line per pair rigc
1491
+ * read nothing of (its SKIP) or the line could not read. A SKIP with nothing declared prints its reason alone.
1492
+ */
1493
+ export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {}): string[] {
1494
+ if (line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}`];
1495
+ const read = `${line.pairs.length} declared pair(s) in ${line.animations.length} animation(s) (${line.animations.join(', ')}), read by rigc render --seam at ${line.fps} fps on the build as played`;
1496
+ const head =
1497
+ line.status === 'SKIP'
1498
+ ? `${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}; ${read}`
1499
+ : `${MOTION_SEAM_LINE}: ${line.status} — ${read}: ${line.warnings} warning(s), never a bar; ${line.floor}`;
1500
+ return [
1501
+ head,
1502
+ ...line.pairs.filter((p) => p.unread !== undefined).map((p) => `seam "${p.pair}" (${p.declared.join('; ')}): not read — ${String(p.unread)}`),
1503
+ ...line.rows.filter((r) => r.status === 'skip').map((r) => `seam "${r.pair}" in "${r.animation}": SKIP — rigc read no band on any of the ${r.drawn} frame(s) both slots draw, so there is no seam to read`),
1504
+ ...(plant.silent === true ? [] : line.rows.filter((r) => r.opens.length > 0).map(motionSeamWarnText)),
1505
+ ...line.rows.filter((r) => r.opens.length === 0 && r.under_floor.length > 0).map((r) => `seam "${r.pair}" in "${r.animation}": under the floor — rigc names ${r.under_floor.length} frame(s) open ${frameRuns(r.under_floor)}, each opening one cell thick`),
1506
+ ];
1507
+ }
1508
+
1486
1509
  /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
1487
1510
  export function requirementText(name: string, line: RequirementLine): string {
1488
1511
  const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
@@ -1541,6 +1564,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1541
1564
  if (limbs !== undefined) for (const [i, l] of limbCheckLines(limbs).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1542
1565
  const sweep = fig[SWEEP_LINE];
1543
1566
  if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1567
+ for (const [i, l] of motionSeamLines(r.motionSeam).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1544
1568
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1545
1569
  if (fig.sets !== undefined) {
1546
1570
  for (const [setName, setFig] of Object.entries(fig.sets)) {
@@ -1558,7 +1582,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1558
1582
  for (const p of r.problems) log(failLine(p));
1559
1583
  const reqNotPass = r.requirements === null ? 0 : r.requirements.summary.declared - r.requirements.summary.pass;
1560
1584
  const notMet = r.requirements === null ? `${r.problems.length} bar(s) not met` : `${r.problems.length - reqNotPass} bar(s) not met, ${reqNotPass} declared requirement(s) not PASS`;
1561
- log(`${fig.PASS ? 'check: PASS' : `check: FAIL — ${notMet}`}; ${barsSummary(r)}`);
1585
+ log(`${fig.PASS ? 'check: PASS' : `check: FAIL — ${notMet}`}; ${barsSummary(r)}${r.motionSeam.status === 'SKIP' ? '' : `; ${r.motionSeam.warnings} warning(s) (${MOTION_SEAM_LINE}, not a bar)`}`);
1562
1586
  return r;
1563
1587
  }
1564
1588
 
package/src/check.ts CHANGED
@@ -125,7 +125,7 @@ import {
125
125
  type Toward,
126
126
  type WorldBox,
127
127
  } from './instruments.ts';
128
- import { type AlternatePartsRecord, type PartRecord, type PartsFile, readParts } from './parts.ts';
128
+ import { type AlternatePartsRecord, type LimbRecord, type PartRecord, type PartsFile, readParts } from './parts.ts';
129
129
  import { poseRigBonesForDrawn, type RigBone, SETS_FILE } from './rig.ts';
130
130
  import { turnTwin } from './turn.ts';
131
131
  import {
@@ -1863,6 +1863,7 @@ export interface CheckFigures {
1863
1863
  [LIMB_LINE]?: LimbLine;
1864
1864
  /** Written only where parts.json holds a limb record with a `range` (issue #360): {@link SweepLine}. */
1865
1865
  [SWEEP_LINE]?: SweepLine;
1866
+ [MOTION_SEAM_LINE]?: MotionSeamLine;
1866
1867
  RECOMPOSITE_HOLES: ReportedLine;
1867
1868
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1868
1869
  SETUP_POSE_VS_SOURCE?: ReportedLine;
@@ -2125,6 +2126,8 @@ export interface CheckReport {
2125
2126
  * reads the idle. Empty when the idle is alone.
2126
2127
  */
2127
2128
  besideIdle: string[];
2129
+ /** `SEAM_OVER_MOTION` (issue #493): a warning, never a bar — in `check.json` only when a pair is declared; its SKIP is printed either way. */
2130
+ motionSeam: MotionSeamLine;
2128
2131
  }
2129
2132
 
2130
2133
  /**
@@ -3154,6 +3157,249 @@ export function sweepLine(
3154
3157
  };
3155
3158
  }
3156
3159
 
3160
+ // ---------------------------------------------------------------------------
3161
+ // the seam over every animation (issue #493; the line is {@link MOTION_SEAM_LINE}'s)
3162
+ // ---------------------------------------------------------------------------
3163
+
3164
+ /**
3165
+ * `SEAM_OVER_MOTION` (issue #493): every declared neighbour pair read in every
3166
+ * animation `motion.json` holds, the idle included, by rig-c's seam reading
3167
+ * (`rigc render --seam`, rig-c 2.60.0) — a **warning, never a bar**. The
3168
+ * owner's ruling (2026-10-18): a seam opening in motion may be intentional, so
3169
+ * the line names the pair, the animation, the frames and the cells, and moves
3170
+ * neither the exit status nor `check: PASS`; the summary counts it apart from
3171
+ * the bars.
3172
+ *
3173
+ * The pairs are the ones the inputs declare and nothing else: each limb's
3174
+ * consecutive segments in `parts.json` (`config.limbs`, the upper and the
3175
+ * segment below each joint) and, under `--requirements`, each `seam`
3176
+ * requirement's part and neighbour. None declared, the line is a SKIP naming
3177
+ * why, never a pass, and `check.json` carries no key for it.
3178
+ *
3179
+ * **The floor** ({@link openingOverFloor}): a frame's gap or ghost is reported
3180
+ * when it holds more cells than its box is long — when it is thicker than one
3181
+ * cell. rigc counts a cell drawn when the renderer paints its centre at half
3182
+ * opacity or more, so a boundary that moves by less than a cell flips the
3183
+ * cells along it in or out: a line of cells one deep, scattered or joined,
3184
+ * holds at most as many cells as its box's longer side. A hole of any area
3185
+ * holds more. The rule types no number; it is read off rigc's own cell and
3186
+ * box, and measured, not chosen: on `examples/wind`'s idle rigc names 43
3187
+ * openings over its six limb pairs (28 and 15 frames of the two elbows, 1 to
3188
+ * 5 cells, boxes up to 17 cells long) and every one is one cell thick; on
3189
+ * rig-c's `gallery/walk` its 13 joint cracks of 1 to 3 cells are too; the
3190
+ * ghost rig-c's own reading names on a skirt swung clear of a leg (1564 cells
3191
+ * in a box 92 long) is far over it.
3192
+ */
3193
+ export const MOTION_SEAM_LINE = 'SEAM_OVER_MOTION';
3194
+
3195
+ /** The seam reading's scratch build and renders, under `--out`; removed before the stage returns. */
3196
+ const MOTION_SEAM_SCRATCH = '_seam_motion';
3197
+
3198
+ /** rig-c's seam reading: the file it writes in each set's directory, and its format tag. */
3199
+ export const SEAM_READING_FILE = 'seam.json';
3200
+ export const SEAM_READING_SPEC = 'rigc-seam/1';
3201
+
3202
+ /** The floor as the console and `check.json` state it. */
3203
+ export const MOTION_SEAM_FLOOR = "a frame opens when its gap or its ghost holds more cells than its box is long (rig-c's cell, its box's longer side) — thicker than one cell; a crack one cell deep is the half-opacity cut moving by less than a cell, and is not reported";
3204
+
3205
+ /** One declared pair: the two slots, and every declaration that names it. */
3206
+ export interface MotionSeamPair {
3207
+ /** `"<a>|<b>"`, the two slots in the order declared first. */
3208
+ pair: string;
3209
+ a: string;
3210
+ b: string;
3211
+ declared: string[];
3212
+ /** Why the pair is not read: a slot `rig.json` does not have. */
3213
+ unread?: string;
3214
+ }
3215
+
3216
+ /** One pair in one animation, as the floor reads rigc's file. */
3217
+ export interface MotionSeamRow {
3218
+ pair: string;
3219
+ animation: string;
3220
+ /** rigc's: `skip` when no frame has a band to read — nothing measured, not a seam that held. */
3221
+ status: 'read' | 'skip';
3222
+ /** Frames both slots draw, and of those the frames read (rigc's `drawn`, `banded`). */
3223
+ drawn: number;
3224
+ banded: number;
3225
+ /** The frames over the floor, ascending: the warning's frames. */
3226
+ opens: number[];
3227
+ /** Beside `opens`, frame by frame: the most cells of either kind on that frame. */
3228
+ px: number[];
3229
+ /** The most cells of either kind on any frame over the floor; 0 when none is. */
3230
+ max_px: number;
3231
+ /** The frames rigc names open whose every opening is one cell thick: under the floor, not reported. */
3232
+ under_floor: number[];
3233
+ }
3234
+
3235
+ /** The line: `WARN` with at least one warning, `CLEAR` with none, `SKIP` with nothing declared or nothing to read. */
3236
+ export interface MotionSeamLine {
3237
+ status: 'WARN' | 'CLEAR' | 'SKIP';
3238
+ reason?: string;
3239
+ fps: number;
3240
+ floor: string;
3241
+ pairs: MotionSeamPair[];
3242
+ animations: string[];
3243
+ /** Rows with a frame over the floor: the WARN lines. */
3244
+ warnings: number;
3245
+ rows: MotionSeamRow[];
3246
+ }
3247
+
3248
+ /**
3249
+ * The neighbour pairs the inputs declare, in their order: each limb's
3250
+ * consecutive segments (`parts.json`'s limb records, by segment), then each
3251
+ * `seam` requirement's part and neighbour. A pair named twice, in either
3252
+ * order, is one pair with both declarations.
3253
+ */
3254
+ export function motionSeamPairs(parts: PartsFile, seams: ReadonlyArray<{ name: string; part: string; neighbour: string }>): MotionSeamPair[] {
3255
+ const out: MotionSeamPair[] = [];
3256
+ const add = (a: string, b: string, declared: string): void => {
3257
+ const hit = out.find((p) => (p.a === a && p.b === b) || (p.a === b && p.b === a));
3258
+ if (hit !== undefined) hit.declared.push(declared);
3259
+ else out.push({ pair: `${a}|${b}`, a, b, declared: [declared] });
3260
+ };
3261
+ const limbs: string[] = [];
3262
+ for (const p of parts.parts) if (p.limb !== undefined && !limbs.includes(p.limb.name)) limbs.push(p.limb.name);
3263
+ for (const limb of limbs) {
3264
+ const segs = parts.parts.filter((p) => p.limb?.name === limb).sort((x, y) => (x.limb as LimbRecord).segment - (y.limb as LimbRecord).segment);
3265
+ for (let i = 1; i < segs.length; i++) {
3266
+ const lower = segs[i].limb as LimbRecord;
3267
+ add(segs[i - 1].name, segs[i].name, `config.limbs.${limb} segment ${lower.segment}${lower.joint === undefined ? '' : `, joint "${lower.joint}"`}`);
3268
+ }
3269
+ }
3270
+ for (const r of seams) add(r.part, r.neighbour, `requirement "${r.name}" (seam)`);
3271
+ return out;
3272
+ }
3273
+
3274
+ /** A frame's opening of one kind over the floor ({@link MOTION_SEAM_FLOOR}): more cells than its box, in cells, is long. */
3275
+ export function openingOverFloor(hit: { px: number; cell: number; box: readonly [number, number, number, number] }): boolean {
3276
+ const long = Math.round(Math.max(hit.box[2] - hit.box[0], hit.box[3] - hit.box[1]) / hit.cell);
3277
+ return hit.px > long;
3278
+ }
3279
+
3280
+ /** Frame indices as runs, `[0-6, 8, 13]`: the form rigc prints a frame list in. */
3281
+ export function frameRuns(frames: readonly number[]): string {
3282
+ const runs: string[] = [];
3283
+ for (let i = 0; i < frames.length; ) {
3284
+ let j = i;
3285
+ while (j + 1 < frames.length && frames[j + 1] === frames[j] + 1) j++;
3286
+ runs.push(i === j ? String(frames[i]) : `${frames[i]}-${frames[j]}`);
3287
+ i = j + 1;
3288
+ }
3289
+ return `[${runs.join(', ')}]`;
3290
+ }
3291
+
3292
+ /**
3293
+ * A planted defect, for the suite's mutants only (`SO` controls in `selftest.ts`). Every caller in `src/` passes none.
3294
+ */
3295
+ export interface MotionSeamPlant {
3296
+ /** Report every frame rigc names open, the one-cell cracks included: the floor removed. */
3297
+ noFloor?: true;
3298
+ /** Print the line's head and none of its WARN lines: the warning suppressed. */
3299
+ silent?: true;
3300
+ }
3301
+
3302
+ type SeamHitRead = { index: number; px: number; cell: number; box: [number, number, number, number] };
3303
+
3304
+ /**
3305
+ * The rows of one `rigc render --seam` run over every animation: `frames.json`
3306
+ * names each set's directory, each `seam.json` (`rigc-seam/1`) its pairs in
3307
+ * the flag's order. Refuses (`FRAMES_SIDECAR`), every problem at once, a set or
3308
+ * file that is missing, a file that is not the animation's or not at the rate
3309
+ * asked, a pair out of the flag's order, and a field of the wrong shape.
3310
+ */
3311
+ export function readMotionSeams(dir: string, animations: readonly string[], fps: number, pairs: readonly MotionSeamPair[], plant: MotionSeamPlant = {}): MotionSeamRow[] {
3312
+ const problems: Problem[] = [];
3313
+ const bad = (object: string, detail: string): void => {
3314
+ problems.push({ code: 'FRAMES_SIDECAR', object, detail });
3315
+ };
3316
+ const rows: MotionSeamRow[] = [];
3317
+ const sidecar = readJson(join(dir, 'frames.json'), problems);
3318
+ const sets = sidecar !== null && Array.isArray(sidecar.sets) ? sidecar.sets.filter(isRecord) : [];
3319
+ const nums = (v: unknown): v is number[] => Array.isArray(v) && v.every((x) => typeof x === 'number');
3320
+ const hitOf = (h: unknown): SeamHitRead | null =>
3321
+ isRecord(h) && typeof h.index === 'number' && typeof h.px === 'number' && typeof h.cell === 'number' && h.cell > 0 && nums(h.box) && h.box.length === 4 ? { index: h.index, px: h.px, cell: h.cell, box: [h.box[0], h.box[1], h.box[2], h.box[3]] } : null;
3322
+ for (const animation of animations) {
3323
+ const set = sets.find((x) => x.animation === animation);
3324
+ if (set === undefined || typeof set.dir !== 'string') {
3325
+ bad(join(dir, 'frames.json'), `names no frame set for animation ${JSON.stringify(animation)}; \`rigc render --seam\` over the build writes one per animation`);
3326
+ continue;
3327
+ }
3328
+ const path = join(dir, set.dir, SEAM_READING_FILE);
3329
+ const file = readJson(path, problems);
3330
+ if (file === null) continue;
3331
+ if (file.spec !== SEAM_READING_SPEC) bad(path, `spec is ${JSON.stringify(file.spec)}; "${SEAM_READING_SPEC}" is required`);
3332
+ if (file.animation !== animation) bad(path, `animation is ${JSON.stringify(file.animation)}; ${JSON.stringify(animation)} is required`);
3333
+ if (file.fps !== fps) bad(path, `fps is ${JSON.stringify(file.fps)}; ${fps}, the rate asked for, is required`);
3334
+ const read = Array.isArray(file.pairs) ? file.pairs : [];
3335
+ if (read.length !== pairs.length) bad(path, `holds ${read.length} pair(s); the ${pairs.length} asked for, in the flag's order, are required`);
3336
+ pairs.forEach((want, i) => {
3337
+ const p = read[i];
3338
+ const at = `${path} pairs[${i}]`;
3339
+ if (!isRecord(p) || p.a !== want.a || p.b !== want.b) {
3340
+ bad(at, `is ${JSON.stringify(isRecord(p) ? `${String(p.a)}|${String(p.b)}` : p)}; "${want.pair}", the flag's entry ${i}, is required`);
3341
+ return;
3342
+ }
3343
+ if ((p.status !== 'read' && p.status !== 'skip') || typeof p.drawn !== 'number' || typeof p.banded !== 'number' || !nums(p.opens)) {
3344
+ bad(at, 'is not { a, b, drawn, banded, status, opens, maxPx, gap, ghost } as rigc-seam/1 writes it');
3345
+ return;
3346
+ }
3347
+ const over = new Map<number, number>();
3348
+ for (const kind of ['gap', 'ghost'] as const) {
3349
+ const k = p[kind];
3350
+ const frames = isRecord(k) && Array.isArray(k.frames) ? k.frames.map(hitOf) : null;
3351
+ if (frames === null || frames.some((h) => h === null)) {
3352
+ bad(`${at} ${kind}`, 'is not { frames: [{ index, px, cell, box }…], spans, worst } as rigc-seam/1 writes it');
3353
+ return;
3354
+ }
3355
+ for (const h of frames as SeamHitRead[]) if (plant.noFloor === true || openingOverFloor(h)) over.set(h.index, Math.max(over.get(h.index) ?? 0, h.px));
3356
+ }
3357
+ const opens = [...over.keys()].sort((x, y) => x - y);
3358
+ rows.push({
3359
+ pair: want.pair,
3360
+ animation,
3361
+ status: p.status,
3362
+ drawn: p.drawn,
3363
+ banded: p.banded,
3364
+ opens,
3365
+ px: opens.map((f) => over.get(f) ?? 0),
3366
+ max_px: opens.reduce((m, f) => Math.max(m, over.get(f) ?? 0), 0),
3367
+ under_floor: p.opens.filter((f) => !over.has(f)),
3368
+ });
3369
+ });
3370
+ }
3371
+ refuseIfAny(problems);
3372
+ return rows;
3373
+ }
3374
+
3375
+ /** The line from its rows: WARN when a row has a frame over the floor, CLEAR when none has, SKIP when no row was read. */
3376
+ export function motionSeamLine(pairs: MotionSeamPair[], animations: string[], fps: number, rows: MotionSeamRow[], reason?: string): MotionSeamLine {
3377
+ const warnings = rows.filter((r) => r.opens.length > 0).length;
3378
+ const read = rows.some((r) => r.status === 'read');
3379
+ const skipWhy =
3380
+ reason ??
3381
+ (read
3382
+ ? undefined
3383
+ : pairs.every((p) => p.unread !== undefined)
3384
+ ? `no declared pair is read: ${pairs.map((p) => `"${p.pair}" ${String(p.unread)}`).join('; ')}`
3385
+ : `rigc read no band on any frame of any declared pair in any animation — nothing measured, which is not a seam that held`);
3386
+ return {
3387
+ status: skipWhy !== undefined ? 'SKIP' : warnings > 0 ? 'WARN' : 'CLEAR',
3388
+ ...(skipWhy === undefined ? {} : { reason: skipWhy }),
3389
+ fps,
3390
+ floor: MOTION_SEAM_FLOOR,
3391
+ pairs,
3392
+ animations,
3393
+ warnings,
3394
+ rows,
3395
+ };
3396
+ }
3397
+
3398
+ /** One warning as the console prints it (issue #493's form, the floor's clause after it). */
3399
+ export function motionSeamWarnText(r: MotionSeamRow): string {
3400
+ return `WARN seam "${r.pair}" opens on ${r.opens.length} frame(s) ${frameRuns(r.opens)} max ${r.max_px} px in "${r.animation}" — over the one-cell floor${r.under_floor.length === 0 ? '' : `, ${r.under_floor.length} more frame(s) one cell thick not reported`}`;
3401
+ }
3402
+
3157
3403
  /**
3158
3404
  * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
3159
3405
  * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
@@ -3570,6 +3816,37 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3570
3816
  }
3571
3817
  }
3572
3818
 
3819
+ // 3e. every declared neighbour pair read in every animation by rigc's seam reading (issue #493): a warning, never a
3820
+ // bar — the build as played, at the idle's rate; SKIP, named, with no pair declared or no animation to read
3821
+ let motionSeam: MotionSeamLine;
3822
+ {
3823
+ const fps = IDLE_FPS;
3824
+ const seamReqs = reqFile === null || seams === null ? [] : reqFile.requirements.flatMap((r) => (r.kind === 'seam' && seams?.has(r.name) === true ? [{ name: r.name, part: r.part, neighbour: r.neighbour }] : []));
3825
+ const pairs = inp.parts === null ? [] : motionSeamPairs(inp.parts, seamReqs);
3826
+ const slotNames = new Set((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => String(sl.name)));
3827
+ for (const p of pairs) {
3828
+ const missing = [p.a, p.b].filter((n) => !slotNames.has(n));
3829
+ if (missing.length > 0) p.unread = `names ${missing.map((n) => `slot "${n}"`).join(' and ')}, which rig.json does not have`;
3830
+ }
3831
+ const read = pairs.filter((p) => p.unread === undefined);
3832
+ if (inp.parts === null) motionSeam = motionSeamLine([], animationNames, fps, [], inp.noParts ?? 'no parts.json');
3833
+ else if (pairs.length === 0) motionSeam = motionSeamLine([], animationNames, fps, [], 'no neighbour pair is declared: parts.json holds no limb segment below a joint (config.limbs) and no --requirements seam names two parts');
3834
+ else if (animationNames.length === 0) motionSeam = motionSeamLine(pairs, [], fps, [], 'motion.json holds no animation to read the declared pairs in');
3835
+ else if (read.length === 0) motionSeam = motionSeamLine(pairs, animationNames, fps, []);
3836
+ else {
3837
+ const seamMotionDir = join(out, MOTION_SEAM_SCRATCH);
3838
+ rmSync(seamMotionDir, { recursive: true, force: true });
3839
+ try {
3840
+ const list = read.map((p) => `${p.a}:${p.b}`).join(',');
3841
+ const r = rigc(['render', '--candidate', buildDir, '--seam', list, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', seamMotionDir]);
3842
+ if (r.status !== 0) refuseIfAny(rigcFailed(`render --seam ${list} --fps ${fps}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
3843
+ motionSeam = motionSeamLine(pairs, animationNames, fps, readMotionSeams(seamMotionDir, animationNames, fps, read));
3844
+ } finally {
3845
+ rmSync(seamMotionDir, { recursive: true, force: true });
3846
+ }
3847
+ }
3848
+ }
3849
+
3573
3850
  // 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
3574
3851
  // the painting — and, beside it, the same pose with the eyes shut
3575
3852
  let seam: SeamFigures | null = null;
@@ -3749,6 +4026,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3749
4026
  ...(stress === null ? {} : { [STRESS_LINE]: stress }),
3750
4027
  ...(limbs === null ? {} : { [LIMB_LINE]: limbs }),
3751
4028
  ...(sweep === null ? {} : { [SWEEP_LINE]: sweep }),
4029
+ ...(motionSeam.pairs.length === 0 ? {} : { [MOTION_SEAM_LINE]: motionSeam }),
3752
4030
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
3753
4031
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
3754
4032
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
@@ -3832,6 +4110,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3832
4110
  written,
3833
4111
  requirements: reqBlock,
3834
4112
  besideIdle: named,
4113
+ motionSeam,
3835
4114
  };
3836
4115
  }
3837
4116
 
package/src/config.ts CHANGED
@@ -92,7 +92,7 @@ import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING
92
92
  import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { edgeLength } from './length.ts';
95
- import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
+ import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, scaledPoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
96
96
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
97
97
  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';
98
98
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
@@ -267,6 +267,8 @@ export interface Assemble {
267
267
  patches?: Patch[];
268
268
  cuts?: Cut[];
269
269
  alternates?: AlternateSetConfig[];
270
+ /** Issue #492: composite full:face under head:face on the head canvas to fill transparent holes inside the face silhouette. */
271
+ head_face_underfill?: boolean;
270
272
  }
271
273
 
272
274
  export interface SingleBone {
@@ -1140,7 +1142,7 @@ export interface PaintConfig {
1140
1142
  export interface EarlyConfig {
1141
1143
  key: string;
1142
1144
  seethrough: SeeThrough;
1143
- assemble: { rig_scale: number };
1145
+ assemble: { rig_scale: number; head_face_underfill?: boolean };
1144
1146
  }
1145
1147
 
1146
1148
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1199,8 +1201,13 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1199
1201
  if (door === 'layers') {
1200
1202
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1201
1203
  if ('assemble' in t) {
1202
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1203
- if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1204
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
1205
+ if (a !== null) {
1206
+ if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1207
+ if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1208
+ c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
1209
+ }
1210
+ }
1204
1211
  }
1205
1212
  }
1206
1213
  if (door === 'assemble') {
@@ -1223,7 +1230,10 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1223
1230
  if ('cloth' in t) checkCloth(c, t.cloth);
1224
1231
  const pose = base === null ? null : poseBeside(c, t.generation, base);
1225
1232
  refuseIfAny(c.problems);
1226
- return { config: raw as PaintConfig | EarlyConfig | AssembleConfig, pose };
1233
+ const config = raw as PaintConfig | EarlyConfig | AssembleConfig;
1234
+ const controlSkel = resolveControlSkeleton(t.generation, pose);
1235
+ if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
1236
+ return { config, pose };
1227
1237
  }
1228
1238
 
1229
1239
  export function loadEarlyConfig(path: string, door: 'paint'): PaintConfig;
@@ -1243,6 +1253,40 @@ export function loadPaintConfig(path: string): { config: PaintConfig; pose: Cont
1243
1253
  return { config: config as PaintConfig, pose };
1244
1254
  }
1245
1255
 
1256
+ /**
1257
+ * Issue #490: the control skeleton `config.generation.control.skeleton` names,
1258
+ * with its points on the `generation.latent` canvas (`[w, h]`, x right, y down).
1259
+ */
1260
+ export interface ControlSkeleton {
1261
+ latent: readonly [number, number];
1262
+ points: PosePoints;
1263
+ }
1264
+
1265
+ const controlSkeletons = new WeakMap<object, ControlSkeleton>();
1266
+
1267
+ function resolveControlSkeleton(gen: Json, pose: ControlPose | null): ControlSkeleton | undefined {
1268
+ if (!isPlainObject(gen) || !isPlainObject(gen.control)) return undefined;
1269
+ const l = gen.latent;
1270
+ if (!(Array.isArray(l) && l.length === 2 && typeof l[0] === 'number' && typeof l[1] === 'number' && l[0] > 0 && l[1] > 0)) return undefined;
1271
+ const latent: readonly [number, number] = [l[0], l[1]];
1272
+ if (pose !== null) return { latent, points: pose.points };
1273
+ const skel = gen.control.skeleton;
1274
+ if (isSkeletonName(skel)) return { latent, points: scaledPoints(skel, latent[0], latent[1]) };
1275
+ return undefined;
1276
+ }
1277
+
1278
+ /**
1279
+ * Return the {@link ControlSkeleton} resolved for a loaded or parsed config
1280
+ * (`loadEarlyConfig`, `loadConfig`, `parseEarlyConfig`, `parseConfig`), or
1281
+ * computed from an inline `generation.control.skeleton` built-in when present.
1282
+ */
1283
+ export function controlSkeletonOf(cfg: object): ControlSkeleton | undefined {
1284
+ const cached = controlSkeletons.get(cfg);
1285
+ if (cached !== undefined) return cached;
1286
+ const gen = (cfg as { generation?: unknown }).generation;
1287
+ return resolveControlSkeleton(gen, null);
1288
+ }
1289
+
1246
1290
  /** The pose file a checked `generation` block's control names, read relative to `base`; null for a built-in, an absent control, or a block already refused. */
1247
1291
  function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
1248
1292
  if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
@@ -1388,9 +1432,12 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1388
1432
  checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
1389
1433
  checkAnimationSetKeys(c, animations, t.assemble, new Set<string>([...parts, ...patches, ...cuts, ...(limbs?.parts.keys() ?? [])]));
1390
1434
  }
1391
- if (base !== null) poseBeside(c, t.generation, base);
1435
+ const pose = base === null ? null : poseBeside(c, t.generation, base);
1392
1436
  refuseIfAny(c.problems);
1393
- return { config: raw as CharacterConfig, animations };
1437
+ const config = raw as CharacterConfig;
1438
+ const controlSkel = resolveControlSkeleton(t.generation, pose);
1439
+ if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
1440
+ return { config, animations };
1394
1441
  }
1395
1442
 
1396
1443
  /**
@@ -1791,13 +1838,16 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
1791
1838
  */
1792
1839
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
1793
1840
  const required = sectionRequired(door, 'assemble');
1794
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1841
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
1795
1842
  if (a === null) return { parts: [], patches: [], cuts: [] };
1796
1843
  if (required.includes('plan') && !('plan' in a)) {
1797
1844
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
1798
1845
  }
1799
1846
  const p = 'config.assemble';
1800
1847
  if ('rig_scale' in a) c.number(`${p}.rig_scale`, a.rig_scale, 'positive');
1848
+ if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1849
+ c.fail('CONFIG_FIELD_TYPE', `${p}.head_face_underfill`, `is ${show(a.head_face_underfill)}; true or false is required`);
1850
+ }
1801
1851
  const parts: string[] = [];
1802
1852
  if ('plan' in a && c.array(`${p}.plan`, a.plan, true)) {
1803
1853
  const seenPart = new Map<string, number>();