@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/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);
|
|
@@ -2569,7 +2691,7 @@ export function stressPlan(rig: Record<string, unknown>, motion: Record<string,
|
|
|
2569
2691
|
continue;
|
|
2570
2692
|
}
|
|
2571
2693
|
const onSlot = skin[p.name];
|
|
2572
|
-
const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
|
|
2694
|
+
const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[p.name]) ? [onSlot[p.name]] : Object.values(onSlot)) : [];
|
|
2573
2695
|
const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
|
|
2574
2696
|
if (weighted.length === 0) {
|
|
2575
2697
|
notRead.push({ part: p.name, reason: 'it is not a weighted mesh, so it rides one bone whole and cannot stretch' });
|
|
@@ -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
|
}
|
|
@@ -4252,7 +4469,7 @@ function offChainClause(rig: Record<string, unknown>, motion: Record<string, unk
|
|
|
4252
4469
|
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4253
4470
|
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4254
4471
|
const onSlot = skin[part];
|
|
4255
|
-
const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
|
|
4472
|
+
const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
|
|
4256
4473
|
const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
|
|
4257
4474
|
if (weighted.length === 0) return null;
|
|
4258
4475
|
const { found } = chainsCarrying(rig, motion, part);
|
|
@@ -4295,7 +4512,8 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
|
|
|
4295
4512
|
const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
|
|
4296
4513
|
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4297
4514
|
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4298
|
-
const
|
|
4515
|
+
const onSlot = skin[part];
|
|
4516
|
+
const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
|
|
4299
4517
|
const bound = new Set<string>();
|
|
4300
4518
|
for (const a of atts) {
|
|
4301
4519
|
if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
|
|
@@ -4345,7 +4563,8 @@ function breathCarrier(
|
|
|
4345
4563
|
// The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
|
|
4346
4564
|
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4347
4565
|
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4348
|
-
const
|
|
4566
|
+
const onSlot = skin[part];
|
|
4567
|
+
const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
|
|
4349
4568
|
const first = m.shown[0];
|
|
4350
4569
|
if (atts.length !== 1 || first === null || first === undefined) return null;
|
|
4351
4570
|
const att = atts[0];
|
package/src/cloth.ts
CHANGED
|
@@ -107,11 +107,13 @@ export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* 📐 The fallback tree count when `draftTrees` is called without `chains` or
|
|
110
|
-
* `masses` (issues #390, #445). When `chains` is omitted on
|
|
110
|
+
* `masses` (issues #390, #445, #461). When `chains` is omitted on
|
|
111
111
|
* `config.cloth."back hair"`, `propose` derives the count per side from the
|
|
112
|
-
* visible hair masses ({@link backHairMasses}):
|
|
113
|
-
* back-hair part's alpha below the root row (merged
|
|
114
|
-
* grid cell),
|
|
112
|
+
* visible hair masses ({@link backHairMasses}, {@link backHairMassTrees}):
|
|
113
|
+
* connected components of the back-hair part's alpha below the root row (merged
|
|
114
|
+
* when they touch within one grid cell), with `max(1, floor(w / (4 * grid)) + 1)`
|
|
115
|
+
* trees per mass across its root-row width `w`, rooted at that mass's own top
|
|
116
|
+
* row and inside its columns.
|
|
115
117
|
*/
|
|
116
118
|
export const RECIPE_BACK_HAIR_CHAINS = 2;
|
|
117
119
|
|
|
@@ -185,13 +187,16 @@ export interface TreeChain {
|
|
|
185
187
|
/** Which outer sides of the piece are edges: both (a skirt, a lock), or one side of the eye axis (back hair). */
|
|
186
188
|
export type EdgeSides = 'both' | 'r' | 'l';
|
|
187
189
|
|
|
188
|
-
/** Issue #445: one visible hair mass's own frame for a
|
|
190
|
+
/** Issue #445, #461: one visible hair mass's own frame for a tree rooted at that mass's top row. */
|
|
189
191
|
export interface TreeMassInput {
|
|
190
192
|
rootY: number;
|
|
191
193
|
tipY: number;
|
|
192
194
|
xAt: (y: number, frac: number) => number | null;
|
|
193
195
|
fallbackX: (frac: number) => number;
|
|
194
196
|
frame: string;
|
|
197
|
+
/** Issue #461: tree index within this mass (0-based, default 0) and total trees in this mass (default 1). */
|
|
198
|
+
treeInMass?: number;
|
|
199
|
+
treesInMass?: number;
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
export interface TreeColumnTip {
|
|
@@ -213,7 +218,7 @@ export interface TreeInput {
|
|
|
213
218
|
/** What the cell's frame is, for the rules: "skirt's art", "the r side of back's art". */
|
|
214
219
|
frame: string;
|
|
215
220
|
edges: EdgeSides;
|
|
216
|
-
/** Issue #445: when present, one entry per
|
|
221
|
+
/** Issue #445: when present, one entry per drafted back-hair tree; tree k sits inside `masses[k]` rather than cell [k/n, (k+1)/n] of the whole side. */
|
|
217
222
|
masses?: readonly TreeMassInput[];
|
|
218
223
|
/**
|
|
219
224
|
* Issue #438: when present, resolves the tip row (and an optional `xAt` bounded to that column's cloth rows) for
|
|
@@ -267,7 +272,9 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
267
272
|
};
|
|
268
273
|
for (const k of activeTrees) {
|
|
269
274
|
const mass = input.masses?.[k];
|
|
270
|
-
const
|
|
275
|
+
const tInMass = mass?.treeInMass ?? 0;
|
|
276
|
+
const nInMass = mass?.treesInMass ?? 1;
|
|
277
|
+
const fc = mass === undefined ? (k + 0.5) / n : (tInMass + 0.5) / nInMass;
|
|
271
278
|
const trunkCol = trunkCols.get(k) ?? null;
|
|
272
279
|
const branchCols: Array<TreeColumnTip | null> = [];
|
|
273
280
|
if (into > 1 && mass === undefined && input.columnTip !== undefined) {
|
|
@@ -314,7 +321,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
314
321
|
out.push(trunk);
|
|
315
322
|
if (into === 1) continue;
|
|
316
323
|
for (let b = 0; b < into; b++) {
|
|
317
|
-
const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (b + 0.5) / into;
|
|
324
|
+
const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (tInMass + (b + 0.5) / into) / nInMass;
|
|
318
325
|
const bCol = branchCols[b] ?? trunkCol;
|
|
319
326
|
const bTipY = bCol?.tipY ?? trunkTipY;
|
|
320
327
|
const bRows = bTipY === trunkTipY ? rows : linkRows(input.rootY, bTipY, r.links, ratio);
|
|
@@ -764,3 +771,48 @@ export function backHairMasses(mask: Mask, side: 'r' | 'l', axis: number, y0: nu
|
|
|
764
771
|
out.sort((a, b) => a.minX - b.minX);
|
|
765
772
|
return out;
|
|
766
773
|
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 📐 The horizontal influence reach of one back-hair tree in mesh grid cells: 4
|
|
777
|
+
* (2 grid cells on each side of the trunk, issue #461).
|
|
778
|
+
*
|
|
779
|
+
* Under the segment weight rule `w = 1 / (d + r)^2` (`src/weights.ts`) with
|
|
780
|
+
* `partGrid`'s falloff `r ≈ 0.42 * grid` and cutoff `MIN_WEIGHT = 0.03`, a
|
|
781
|
+
* tree's weight relative to `d = 0` (`(r / (d + r))^2`) drops to `MIN_WEIGHT`
|
|
782
|
+
* at `d = (1 / sqrt(0.03) - 1) * r = 4.7735 * 0.42 * grid = 2.0049 * grid ≈ 2 * grid`
|
|
783
|
+
* on each side of the trunk — a horizontal span of `4 * grid` per tree. Beyond
|
|
784
|
+
* that span a single tree leaves outer columns weighted to the static `head`
|
|
785
|
+
* segment while adjacent columns move with the tree, stretching the triangles
|
|
786
|
+
* across the boundary.
|
|
787
|
+
*/
|
|
788
|
+
export const BACK_HAIR_TREE_REACH_CELLS = 4;
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Opaque pixel count of a visible hair mass `m` along its own root row `m.minY`
|
|
792
|
+
* (issue #461).
|
|
793
|
+
*/
|
|
794
|
+
export function backHairMassRootWidth(m: BackHairMass): number {
|
|
795
|
+
const W = m.mask.width;
|
|
796
|
+
const H = m.mask.height;
|
|
797
|
+
if (m.minY < 0 || m.minY >= H) return 0;
|
|
798
|
+
let w = 0;
|
|
799
|
+
for (let x = m.minX; x <= m.maxX; x++) {
|
|
800
|
+
if (m.mask.data[m.minY * W + x] !== 0) w++;
|
|
801
|
+
}
|
|
802
|
+
return w > 0 ? w : m.maxX - m.minX + 1;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Number of trees placed inside one visible hair mass `m` on a `grid` px mesh
|
|
807
|
+
* (issue #461): `max(1, floor(w / (4 * grid)) + 1)` where `w` is the mass's
|
|
808
|
+
* root-row opaque width ({@link backHairMassRootWidth}).
|
|
809
|
+
*
|
|
810
|
+
* A narrow mass (`w < 4 * grid`) gets 1 tree; a wide undivided tail spanning
|
|
811
|
+
* `4 * grid <= w < 8 * grid` gets 2 trees; `8 * grid <= w < 12 * grid` gets 3.
|
|
812
|
+
*/
|
|
813
|
+
export function backHairMassTrees(m: BackHairMass, grid: number): number {
|
|
814
|
+
const step = Math.max(1, BACK_HAIR_TREE_REACH_CELLS * grid);
|
|
815
|
+
const w = backHairMassRootWidth(m);
|
|
816
|
+
return Math.max(1, Math.floor(w / step) + 1);
|
|
817
|
+
}
|
|
818
|
+
|