@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/cli.ts +5 -1
- package/docs/AUTHORING.md +21 -3
- package/docs/MIGRATION.md +19 -0
- package/docs/STABILITY.md +2 -2
- package/package.json +2 -2
- package/src/assemble.ts +591 -12
- package/src/build.ts +27 -3
- package/src/check.ts +280 -1
- package/src/config.ts +58 -8
- package/src/propose.ts +128 -15
- package/src/tags.ts +15 -6
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
|
|
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
|
-
|
|
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
|
-
|
|
1435
|
+
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
1392
1436
|
refuseIfAny(c.problems);
|
|
1393
|
-
|
|
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>();
|