@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/cli.ts +49 -11
- package/docs/AUTHORING.md +9 -5
- package/docs/STABILITY.md +5 -4
- package/package.json +1 -1
- package/src/build.ts +301 -8
- package/src/check.ts +229 -10
- package/src/cloth.ts +60 -8
- package/src/config.ts +103 -16
- package/src/parts.ts +59 -12
- package/src/propose.ts +52 -27
- package/src/rig.ts +305 -3
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
|
}
|