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