@rig-c/parts 1.30.0 → 1.32.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
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measureRecomposite, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -71,18 +71,19 @@ import {
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
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';
74
- import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
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';
77
77
  import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
- import { type PartRecord, readParts, type SingleRunPad, tagOf, writeParts } from './parts.ts';
81
+ import { type AlternatePartsRecord, type PartRecord, readParts, type RecompositeRecord, type SingleRunPad, tagOf, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
- import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
85
+ import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, SETS_FILE, type StrayCleared, type TurnRow } from './rig.ts';
86
+ import { KEYPOINT_NAMES, type KeypointName, type PosePoints } from './skeleton.ts';
86
87
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
87
88
  import { TURN_BONE } from './turn.ts';
88
89
  import { pyRound } from './round.ts';
@@ -130,6 +131,14 @@ export function readRuns(full: string, head: string | null): { full: LayerSet; h
130
131
  return { full: f as LayerSet, head: h as LayerSet, single: head === null };
131
132
  }
132
133
 
134
+ /** One `--alt <name>=<source>:<full>[:<head>]` argument (issue #454). */
135
+ export interface AlternateCliInput {
136
+ name: string;
137
+ source: string;
138
+ full: string;
139
+ head?: string;
140
+ }
141
+
133
142
  export interface AssembleStageInput {
134
143
  source: string;
135
144
  full: string;
@@ -138,6 +147,8 @@ export interface AssembleStageInput {
138
147
  config: string;
139
148
  seam: SeamRule;
140
149
  project: ProjectRule;
150
+ /** `--alt` entries, merged with `config.assemble.alternates` by set name (issue #454). */
151
+ alts?: readonly AlternateCliInput[];
141
152
  }
142
153
 
143
154
  /** Where the assemble stage writes: `parts.json`, the directory of part PNGs, the recomposite, and its error map. */
@@ -246,6 +257,250 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
246
257
  const under = readUnders(input.config, fields.cuts);
247
258
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
248
259
  const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
260
+
261
+ // Issue #454: alternate painting sets, assembled before any file is written.
262
+ const configDir = dirname(resolve(input.config));
263
+ const cliAlts = input.alts ?? [];
264
+ const cfgAlts = cfg.assemble.alternates ?? [];
265
+ const cliByName = new Map<string, AlternateCliInput>();
266
+ for (const a of cliAlts) cliByName.set(a.name, a);
267
+ const mergedAlts: Array<{
268
+ name: string;
269
+ at: string;
270
+ source?: string;
271
+ full?: string;
272
+ head?: string;
273
+ parts?: string[];
274
+ hides?: string[];
275
+ head_box?: [number, number, number, number];
276
+ cuts?: Cut[];
277
+ skeleton?: string;
278
+ drawn?: Record<string, number>;
279
+ }> = cfgAlts.map((a, i) => {
280
+ const cli = cliByName.get(a.name);
281
+ if (cli !== undefined) cliByName.delete(a.name);
282
+ return {
283
+ name: a.name,
284
+ at: `config.assemble.alternates[${i}] (set "${a.name}")`,
285
+ source: cli?.source ?? (a.source === undefined ? undefined : resolve(configDir, a.source)),
286
+ full: cli?.full ?? (a.full === undefined ? undefined : resolve(configDir, a.full)),
287
+ head: cli !== undefined ? cli.head : a.head === undefined ? undefined : resolve(configDir, a.head),
288
+ parts: a.parts,
289
+ hides: a.hides,
290
+ head_box: a.head_box,
291
+ cuts: a.cuts,
292
+ skeleton: a.skeleton === undefined ? undefined : resolve(configDir, a.skeleton),
293
+ drawn: a.drawn,
294
+ };
295
+ });
296
+ for (const cli of cliByName.values()) {
297
+ mergedAlts.push({
298
+ name: cli.name,
299
+ at: `--alt ${cli.name}`,
300
+ source: cli.source,
301
+ full: cli.full,
302
+ head: cli.head,
303
+ });
304
+ }
305
+
306
+ const altProblems: Problem[] = [];
307
+ const altBuilt: Array<{ record: AlternatePartsRecord; images: Array<{ name: string; image: Raster }> }> = [];
308
+ const baseSlots = result.parts.parts.map((p) => p.name);
309
+ for (const alt of mergedAlts) {
310
+ if (alt.source === undefined || alt.full === undefined) {
311
+ altProblems.push({
312
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
313
+ object: alt.at,
314
+ detail: 'is missing source or full; declare source and full on the alternate set in config.assemble.alternates, or supply --alt <name>=<source>:<full>[:<head>]',
315
+ });
316
+ continue;
317
+ }
318
+ if (!existsSync(alt.source)) {
319
+ altProblems.push({
320
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
321
+ object: `${alt.source} (alternate set "${alt.name}")`,
322
+ detail: 'no such file; the alternate painting is required',
323
+ });
324
+ continue;
325
+ }
326
+ if (!existsSync(alt.full)) {
327
+ altProblems.push({
328
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
329
+ object: `${alt.full} (alternate set "${alt.name}")`,
330
+ detail: 'no such file or directory; the alternate full run is required',
331
+ });
332
+ continue;
333
+ }
334
+ if (alt.head !== undefined && !existsSync(alt.head)) {
335
+ altProblems.push({
336
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
337
+ object: `${alt.head} (alternate set "${alt.name}")`,
338
+ detail: 'no such file or directory; the alternate head run is required when named',
339
+ });
340
+ continue;
341
+ }
342
+ let altSkeleton: PosePoints | undefined;
343
+ if (alt.skeleton !== undefined) {
344
+ if (!existsSync(alt.skeleton) || !statSync(alt.skeleton).isFile()) {
345
+ altProblems.push({
346
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
347
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
348
+ detail: 'no such file; the alternate pose skeleton is required when named',
349
+ });
350
+ continue;
351
+ }
352
+ try {
353
+ const rawSkel: unknown = JSON.parse(readFileSync(alt.skeleton, 'utf8'));
354
+ const skObj = typeof rawSkel === 'object' && rawSkel !== null && !Array.isArray(rawSkel) ? (rawSkel as Record<string, unknown>) : null;
355
+ const sw = skObj !== null && typeof skObj.width === 'number' && skObj.width > 0 ? skObj.width : null;
356
+ const sh = skObj !== null && typeof skObj.height === 'number' && skObj.height > 0 ? skObj.height : null;
357
+ const spts = skObj !== null && typeof skObj.points === 'object' && skObj.points !== null && !Array.isArray(skObj.points) ? (skObj.points as Record<string, unknown>) : null;
358
+ if (sw === null || sh === null || spts === null || KEYPOINT_NAMES.some((k) => !Array.isArray(spts[k]) || (spts[k] as unknown[]).length !== 2)) {
359
+ altProblems.push({
360
+ code: 'ASSEMBLE_ALT_SKELETON',
361
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
362
+ detail: 'must hold {"width": W, "height": H, "points": {…the 18 body-18 keypoints}}',
363
+ });
364
+ continue;
365
+ }
366
+ const [rW, rH] = result.parts.rig_size;
367
+ const sx = rW / sw;
368
+ const sy = rH / sh;
369
+ const scaled = {} as Record<KeypointName, readonly [number, number]>;
370
+ for (const k of KEYPOINT_NAMES) {
371
+ const [px, py] = spts[k] as [number, number];
372
+ scaled[k] = [pyRound(px * sx, 6), pyRound(py * sy, 6)];
373
+ }
374
+ altSkeleton = scaled;
375
+ } catch (err) {
376
+ altProblems.push({
377
+ code: 'ASSEMBLE_ALT_SKELETON',
378
+ object: `${alt.skeleton} (alternate set "${alt.name}")`,
379
+ detail: `does not parse as JSON: ${(err as Error).message}`,
380
+ });
381
+ continue;
382
+ }
383
+ }
384
+ const altSrc = readSource(alt.source);
385
+ if (altSrc.width !== src.width || altSrc.height !== src.height) {
386
+ altProblems.push({
387
+ code: 'ASSEMBLE_ALT_CANVAS_SIZE',
388
+ object: `${alt.source} (alternate set "${alt.name}")`,
389
+ detail: `is ${altSrc.width}x${altSrc.height}, while the primary painting ${input.source} is ${src.width}x${src.height}; an alternate painting must share the primary painting's canvas size so its parts land in the same rig frame`,
390
+ });
391
+ continue;
392
+ }
393
+ const altRuns = readRuns(alt.full, alt.head ?? null);
394
+ const hidesSet = new Set(alt.hides ?? []);
395
+ const declaredSlots = alt.parts !== undefined ? alt.parts.filter((p) => !hidesSet.has(p)) : baseSlots.filter((p) => !hidesSet.has(p));
396
+ const coveredSet = new Set(declaredSlots);
397
+ const rawCuts = alt.cuts !== undefined ? alt.cuts : fields.cuts.filter((c) => coveredSet.has(c.into) && coveredSet.has(c.from)).map((c) => ({ ...c, under: undefined }));
398
+ const altLimbs: Record<string, Limb> = {};
399
+ if (fields.limbs !== undefined) {
400
+ for (const [ln, limb] of Object.entries(fields.limbs)) {
401
+ if (limb.segments.every((sg) => coveredSet.has(sg.part))) {
402
+ altLimbs[ln] = { ...limb, segments: limb.segments.map((sg) => ({ ...sg, cap: undefined })) };
403
+ }
404
+ }
405
+ }
406
+ const neededPlan = new Set<string>();
407
+ for (const [name] of fields.plan) {
408
+ if (
409
+ coveredSet.has(name) ||
410
+ rawCuts.some((c) => c.from === name) ||
411
+ Object.values(altLimbs).some((l) => l.segments.some((sg) => sg.from === name))
412
+ ) {
413
+ neededPlan.add(name);
414
+ }
415
+ }
416
+ const altPlan: PlanEntry[] = fields.plan
417
+ .filter(([name]) => neededPlan.has(name))
418
+ .map(([name, run, tag]): PlanEntry => (altRuns.single ? [name, 'full', tag] : [name, run, tag]));
419
+ let missingLayer = false;
420
+ for (const [name, run, tag] of altPlan) {
421
+ const l = altRuns[run].layers.find((x) => x.name === tag);
422
+ if (l === undefined || l.opaquePx === 0) {
423
+ altProblems.push({
424
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
425
+ object: `alternate set "${alt.name}" (part "${name}")`,
426
+ detail: `takes ${run}:${tag}, which ${l === undefined ? 'is absent' : 'has 0 opaque pixels'} in ${altRuns[run].source}; every covered part of the alternate set must be present in its runs, or listed in hides`,
427
+ });
428
+ missingLayer = true;
429
+ }
430
+ }
431
+ if (missingLayer) continue;
432
+ const altPlanNames = new Set(altPlan.map((e) => e[0]));
433
+ const remapDraw = (d: Patch['draw']): Patch['draw'] => {
434
+ if (typeof d !== 'object') return d;
435
+ if (altPlanNames.has(d.before)) return d;
436
+ const baseIdx = fields.plan.findIndex((e) => e[0] === d.before);
437
+ for (let i = Math.max(0, baseIdx); i < fields.plan.length; i++) {
438
+ if (altPlanNames.has(fields.plan[i][0])) return { before: fields.plan[i][0] };
439
+ }
440
+ return 'front';
441
+ };
442
+ const altExtend: Extend[] = altRuns.single ? [] : fields.extend.filter((e) => altPlanNames.has(e.part));
443
+ const altPatches: Patch[] = fields.patches.filter((p) => coveredSet.has(p.name)).map((p) => ({ ...p, draw: remapDraw(p.draw) }));
444
+ const altCuts: Cut[] = rawCuts.map((c) => ({ ...c, draw: remapDraw(c.draw) }));
445
+ const altUnder = alt.cuts !== undefined ? readUnders(input.config, altCuts) : altCuts.map(() => null);
446
+ const altHeadBox = alt.head_box ?? (altRuns.single ? singleRunHeadBox(altSrc.width, altSrc.height, fields.resolution) : fields.headBox);
447
+ const altRes = assemble({
448
+ source: altSrc,
449
+ full: altRuns.full,
450
+ head: altRuns.head,
451
+ resolution: fields.resolution,
452
+ headBox: altHeadBox,
453
+ rigScale: fields.rigScale,
454
+ plan: altPlan,
455
+ extend: altExtend,
456
+ patches: altPatches,
457
+ cuts: altCuts,
458
+ under: altUnder,
459
+ ...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: fields.bones } : {}),
460
+ ...(fields.scene === undefined ? {} : { scene: fields.scene }),
461
+ ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
462
+ seamRule: input.seam,
463
+ projectRule: input.project,
464
+ });
465
+ const keptImages = altRes.images
466
+ .filter(({ record }) => coveredSet.has(record.name))
467
+ .map(({ record, image }) => ({ name: `${alt.name}_${record.name}`, image }));
468
+ const keptRecords = altRes.parts.parts.filter((p) => coveredSet.has(p.name));
469
+ const slots: Record<string, string> = {};
470
+ for (const r of keptRecords) slots[r.name] = `${alt.name}_${r.name}`;
471
+ let altRecomposite = altRes.parts.recomposite as RecompositeRecord;
472
+ if (alt.parts !== undefined) {
473
+ const [rW, rH] = result.parts.rig_size;
474
+ const altByName = new Map(altRes.images.map((x) => [x.record.name, x]));
475
+ const fullSetPlaced = result.images
476
+ .filter(({ record }) => !hidesSet.has(record.name))
477
+ .map((baseItem) => altByName.get(baseItem.record.name) ?? baseItem);
478
+ const altSrcRig = sourceInRig(altSrc, rW, rH);
479
+ const altCan = recomposite(fullSetPlaced, rW, rH);
480
+ altRecomposite = recompositeRecord(measureRecomposite(altCan, altSrcRig, fullSetPlaced));
481
+ }
482
+ altBuilt.push({
483
+ record: {
484
+ name: alt.name,
485
+ ...(alt.parts === undefined ? {} : { parts: [...alt.parts] }),
486
+ ...(alt.hides === undefined || alt.hides.length === 0 ? {} : { hides: [...alt.hides] }),
487
+ slots,
488
+ records: keptRecords,
489
+ ghost_px: altRes.parts.ghost_px,
490
+ recomposite: altRecomposite,
491
+ ...(altRes.parts.scene === undefined ? {} : { scene: altRes.parts.scene }),
492
+ ...(altRes.parts.single_run_pad === undefined ? {} : { single_run_pad: altRes.parts.single_run_pad }),
493
+ ...(altSkeleton === undefined ? {} : { skeleton: altSkeleton }),
494
+ ...(alt.drawn === undefined ? {} : { drawn: { ...alt.drawn } }),
495
+ },
496
+ images: keptImages,
497
+ });
498
+ }
499
+ refuseIfAny(altProblems);
500
+ if (altBuilt.length > 0) {
501
+ result.parts.alternates = altBuilt.map((b) => b.record);
502
+ }
503
+
249
504
  // Emit only after green: every refusal above has already thrown.
250
505
  mkdirSync(outs.partsDir, { recursive: true });
251
506
  mkdirSync(dirname(outs.partsJson), { recursive: true });
@@ -253,6 +508,9 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
253
508
  mkdirSync(dirname(outs.errorMap), { recursive: true });
254
509
  writeParts(outs.partsJson, result.parts);
255
510
  for (const { record, image } of result.images) writePng(join(outs.partsDir, `${record.name}.png`), image);
511
+ for (const b of altBuilt) {
512
+ for (const { name, image } of b.images) writePng(join(outs.partsDir, `${name}.png`), image);
513
+ }
256
514
  writePng(outs.recomposite, result.recomposite);
257
515
  writePng(outs.errorMap, result.errorMap);
258
516
  // Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
@@ -820,11 +1078,19 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
820
1078
  const png = join(input.parts, 'parts', `${p.name}.png`);
821
1079
  if (existsSync(png)) images.set(p.name, readPng(png));
822
1080
  }
1081
+ for (const alt of parts.alternates ?? []) {
1082
+ for (const p of alt.records) {
1083
+ const attName = `${alt.name}_${p.name}`;
1084
+ const png = join(input.parts, 'parts', `${attName}.png`);
1085
+ if (existsSync(png)) images.set(attName, readPng(png));
1086
+ }
1087
+ }
823
1088
  const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
824
1089
  const textsOf = (r: RigOutput): Array<[string, string]> => [
825
1090
  ['rig.json', rigJsonText(r.rig)],
826
1091
  ['motion.json', rigJsonText(withAnimations(r.motion, from === null ? null : from.table))],
827
1092
  ['mesh_report.json', rigJsonText(r.meshReport)],
1093
+ ...(r.sets === undefined ? [] : [[SETS_FILE, rigJsonText(r.sets)] as [string, string]]),
828
1094
  ];
829
1095
  const firstTexts = textsOf(first);
830
1096
  // The gate runs before the lines are printed, and its lines print where they always did: an automatic part's motion
@@ -855,7 +1121,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
855
1121
  const replays = new Map<string, ReplayRow | MultiIntervalRow>();
856
1122
  if (settled) {
857
1123
  const swapped = JSON.parse(rigJsonText(first.rig)) as RigSpec;
858
- for (const k of chosen) swapped.skins.default[k.part] = { [k.part]: k.candidate.attachment };
1124
+ for (const k of chosen) swapped.skins.default[k.part] = { ...swapped.skins.default[k.part], [k.part]: k.candidate.attachment };
859
1125
  rig = { ...first, rig: swapped, meshReport: first.meshReport.map((m) => chosen.find((k) => k.part === m.part)?.candidate.row ?? m) };
860
1126
  texts = textsOf(rig);
861
1127
  let model: string | null = null;
@@ -1004,6 +1270,10 @@ export interface CheckStageInput {
1004
1270
  seamPairs?: boolean;
1005
1271
  /** `check --keep-stills` (issue #209): leave the setup-pose still and its `frames.json` under {@link STILL_DIR}. */
1006
1272
  keepStills?: boolean;
1273
+ /** `check --set <name>` (issue #454): measure one declared alternate set's setup-pose seam and recomposite figures. */
1274
+ set?: string;
1275
+ /** `check --all-sets` (issue #454): measure every declared alternate set's setup-pose seam and recomposite figures. */
1276
+ allSets?: boolean;
1007
1277
  }
1008
1278
 
1009
1279
  /**
@@ -1183,7 +1453,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1183
1453
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
1184
1454
  log(` ${rigcIdentity(bin, v)}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1185
1455
  const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
1186
- const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true);
1456
+ const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true, input.set, input.allSets === true);
1187
1457
  log(` gate rig-play (rigc ${packedBuildLabel(mode)}), verbatim:`);
1188
1458
  for (const l of r.gateHtml) log(l);
1189
1459
  for (const l of packLines(r)) log(` ${l}`);
@@ -1201,6 +1471,16 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1201
1471
  );
1202
1472
  const pairs = fig.seam_pairs ?? [];
1203
1473
  pairs.forEach((row, i) => log(` seam pair ${i + 1} of ${pairs.length}: ${seamPairText(row)}`));
1474
+ if (fig.sets !== undefined) {
1475
+ for (const [setName, setFig] of Object.entries(fig.sets)) {
1476
+ log(
1477
+ ` seam (${setName})${setFig.seam_border === undefined ? '' : ` (config.scene: ${setFig.seam_border.frame_px_left_out} frame px on the painting's border rows and columns left out, ${setFig.seam_border.px_over_40_left_out} of them over ${SEAM_PX_LEVEL})`}: setup pose at ${setFig.render_px}, scale ${setFig.seam_frame_scale.toFixed(4)}: mean |d| ${setFig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
1478
+ `${setFig.seam_px_over_40} px over ${SEAM_PX_LEVEL}, ${seamRigPxText(setFig.seam_px_over_40, setFig.seam_frame_scale)} rig px (the count / s²; <= ${SEAM_PX_BAR}), ${setFig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
1479
+ );
1480
+ const setPairs = setFig.seam_pairs ?? [];
1481
+ setPairs.forEach((row, i) => log(` seam pair (${setName}) ${i + 1} of ${setPairs.length}: ${seamPairText(row)}`));
1482
+ }
1483
+ }
1204
1484
  }
1205
1485
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
1206
1486
  const folds = fig[FOLD_LINE];
@@ -1214,6 +1494,11 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1214
1494
  const sweep = fig[SWEEP_LINE];
1215
1495
  if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1216
1496
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1497
+ if (fig.sets !== undefined) {
1498
+ for (const [setName, setFig] of Object.entries(fig.sets)) {
1499
+ log(` ${judgementLine(`RECOMPOSITE_HOLES (${setName})`, setFig.RECOMPOSITE_HOLES)}`);
1500
+ }
1501
+ }
1217
1502
  const vsSource = fig[SOURCE_LINE];
1218
1503
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
1219
1504
  if (r.requirements !== null) {
@@ -1499,6 +1784,12 @@ export interface BuildInput {
1499
1784
  seamPairs?: boolean;
1500
1785
  /** `--keep-stills`, forwarded to the check as `check` takes it (issue #257): the setup-pose still under `check/setup_still/`; absent, nothing moves. */
1501
1786
  keepStills?: boolean;
1787
+ /** `--alt`, forwarded to the assemble stage as `assemble` takes it (issue #454). */
1788
+ alts?: readonly AlternateCliInput[];
1789
+ /** `--set <name>`, forwarded to the check as `check` takes it (issue #454). */
1790
+ set?: string;
1791
+ /** `--all-sets`, forwarded to the check as `check` takes it (issue #454). */
1792
+ allSets?: boolean;
1502
1793
  }
1503
1794
 
1504
1795
  /** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value itself. */
@@ -1521,6 +1812,8 @@ export const CHECK_FLAGS_IN_BUILD: Readonly<Record<string, CheckFlagInBuild>> =
1521
1812
  '--page-edges': { as: 'forwarded' },
1522
1813
  '--pack-shape': { as: 'forwarded' },
1523
1814
  '--requirements': { as: 'forwarded' },
1815
+ '--set': { as: 'forwarded' },
1816
+ '--all-sets': { as: 'forwarded' },
1524
1817
  '--seam-pairs': { as: 'forwarded' },
1525
1818
  '--keep-stills': { as: 'forwarded' },
1526
1819
  };
@@ -1617,7 +1910,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1617
1910
  // The same for the requirements file's own shape; its names resolve against the rig, in the check, before it builds.
1618
1911
  if (input.requirements !== undefined) readRequirements(input.requirements);
1619
1912
  assembleStage(
1620
- { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project },
1913
+ { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(input.alts === undefined ? {} : { alts: input.alts }) },
1621
1914
  { partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
1622
1915
  prefixed('assemble'),
1623
1916
  );
@@ -1633,7 +1926,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1633
1926
 
1634
1927
  let report: CheckReport;
1635
1928
  try {
1636
- report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true }, run.check, run.checkBin, prefixed('check'));
1929
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true, ...(input.set === undefined ? {} : { set: input.set }), allSets: input.allSets === true }, run.check, run.checkBin, prefixed('check'));
1637
1930
  } catch (err) {
1638
1931
  return refused('check', err);
1639
1932
  }