@rig-c/parts 1.5.4 → 1.6.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/NOTICE.md +23 -10
- package/README.md +56 -55
- package/cli.ts +10 -2
- package/docs/AUTHORING.md +196 -114
- package/docs/STABILITY.md +2 -2
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +2 -2
- package/src/assemble.ts +329 -9
- package/src/build.ts +2 -1
- package/src/check.ts +224 -28
- package/src/instruments.ts +34 -10
- package/src/parts.ts +39 -2
- package/src/propose.ts +97 -39
- package/src/requirements.ts +58 -3
package/src/check.ts
CHANGED
|
@@ -111,6 +111,8 @@ import {
|
|
|
111
111
|
topmostIndex,
|
|
112
112
|
TORSO_TAGS,
|
|
113
113
|
halfTravels,
|
|
114
|
+
chainToward,
|
|
115
|
+
type Toward,
|
|
114
116
|
type WorldBox,
|
|
115
117
|
} from './instruments.ts';
|
|
116
118
|
import { type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
@@ -1757,6 +1759,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1757
1759
|
const file = reqFile as RequirementsFile;
|
|
1758
1760
|
const ready = inp;
|
|
1759
1761
|
take(() => resolveRequirements(file, ready.rig, ready.motion, ready.rootBone));
|
|
1762
|
+
take(() => refuseIfAny(exceptionProblems(file, ready.parts, ready.noParts)));
|
|
1760
1763
|
// A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
|
|
1761
1764
|
if (file.requirements.some((r) => r.kind === 'seam')) {
|
|
1762
1765
|
const parts = ready.parts;
|
|
@@ -1873,12 +1876,14 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1873
1876
|
if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation idle --slot ${ps.map((p) => p.name).join(',')}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1874
1877
|
return readFrameSet(dir);
|
|
1875
1878
|
};
|
|
1879
|
+
// Issue #231: the footwear parts the scene file declares not planted; none without --requirements or the field.
|
|
1880
|
+
const notPlanted = reqFile === null ? [] : reqFile.not_planted;
|
|
1876
1881
|
let breath: JudgementLine = skip(lacking);
|
|
1877
1882
|
let tip: JudgementLine = skip(lacking);
|
|
1878
1883
|
if (full !== null) {
|
|
1879
1884
|
try {
|
|
1880
|
-
breath = breathLine(full, isolated, problems);
|
|
1881
|
-
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems);
|
|
1885
|
+
breath = breathLine(full, isolated, problems, notPlanted);
|
|
1886
|
+
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems, reqFile === null ? NO_TIP_EXCEPTIONS : { path: reqFile.path, not_swinging: reqFile.not_swinging, rises: reqFile.rises });
|
|
1882
1887
|
} finally {
|
|
1883
1888
|
rmSync(isoDir, { recursive: true, force: true });
|
|
1884
1889
|
}
|
|
@@ -1892,7 +1897,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1892
1897
|
const geometryPath = join(idle.dir, GEOMETRY_FILE);
|
|
1893
1898
|
const geometry = readGeometry(geometryPath, idle.written);
|
|
1894
1899
|
stretch = stretchLine(geometry, geometryPath, problems);
|
|
1895
|
-
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems);
|
|
1900
|
+
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
|
|
1896
1901
|
}
|
|
1897
1902
|
|
|
1898
1903
|
// 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
|
|
@@ -2242,22 +2247,98 @@ function quoteParts(ps: readonly PartRecord[]): string {
|
|
|
2242
2247
|
|
|
2243
2248
|
type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
|
|
2244
2249
|
|
|
2245
|
-
/**
|
|
2246
|
-
|
|
2250
|
+
/**
|
|
2251
|
+
* The scene file's three exceptions (issue #231), read against parts.json
|
|
2252
|
+
* before anything is built: each name must be a part parts.json lists
|
|
2253
|
+
* (`REQUIREMENTS_RESOLVES`), and one the bar it relaxes reads — a `footwear`
|
|
2254
|
+
* part for `not_planted` (`REQUIREMENTS_NOT_PLANTED`), a `handwear` or
|
|
2255
|
+
* `bottomwear` part for `not_swinging` and `rises`
|
|
2256
|
+
* (`REQUIREMENTS_TIP_EXCEPTION`): an exception that excepts nothing is a miss,
|
|
2257
|
+
* refused by name. With no parts.json every name is refused. Whether a `rises`
|
|
2258
|
+
* part's chain does rise is read off the idle's geometry by `TIP_OVER_ROOT`.
|
|
2259
|
+
*/
|
|
2260
|
+
export function exceptionProblems(file: Pick<RequirementsFile, 'not_planted' | 'not_swinging' | 'rises' | 'path'>, parts: PartsFile | null, noParts: string | null): Problem[] {
|
|
2261
|
+
const out: Problem[] = [];
|
|
2262
|
+
// Issue #231: not_swinging and rises name swinging parts, the ones TIP_OVER_ROOT reads; whether a rises part's chain does rise is read off the idle's geometry, in the line.
|
|
2263
|
+
for (const key of ['not_swinging', 'rises'] as const) {
|
|
2264
|
+
file[key].forEach((name, i) => {
|
|
2265
|
+
const object = `${file.path} field "${key}"[${i}]`;
|
|
2266
|
+
if (parts === null) {
|
|
2267
|
+
out.push({ code: 'REQUIREMENTS_TIP_EXCEPTION', object, detail: `names part "${name}", and the swinging parts are chosen by parts.json's See-through tags, of which none was read: ${noParts ?? 'no parts.json'}` });
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
const rec = parts.parts.find((p) => p.name === name);
|
|
2271
|
+
if (rec === undefined) {
|
|
2272
|
+
out.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${name}", which parts.json does not list; it lists ${parts.parts.map((p) => `"${p.name}"`).join(', ') || 'none'}` });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
const swing = partsTagged(parts, SWING_TAGS).map((p) => p.name);
|
|
2276
|
+
if (!swing.includes(name)) {
|
|
2277
|
+
out.push({
|
|
2278
|
+
code: 'REQUIREMENTS_TIP_EXCEPTION',
|
|
2279
|
+
object,
|
|
2280
|
+
detail: `names part "${name}" (from "${rec.from}"), which TIP_OVER_ROOT does not read; only a part from a See-through ${tagWords(SWING_TAGS)} layer is (${swing.map((n) => `"${n}"`).join(', ') || 'none here'}), so declaring it excepts nothing`,
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
file.not_planted.forEach((name, i) => {
|
|
2286
|
+
const object = `${file.path} field "not_planted"[${i}]`;
|
|
2287
|
+
if (parts === null) {
|
|
2288
|
+
out.push({ code: 'REQUIREMENTS_NOT_PLANTED', object, detail: `names part "${name}", and the feet are chosen by parts.json's See-through tags, of which none was read: ${noParts ?? 'no parts.json'}` });
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const rec = parts.parts.find((p) => p.name === name);
|
|
2292
|
+
if (rec === undefined) {
|
|
2293
|
+
out.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${name}", which parts.json does not list; it lists ${parts.parts.map((p) => `"${p.name}"`).join(', ') || 'none'}` });
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
const feet = partsTagged(parts, FEET_TAGS).map((p) => p.name);
|
|
2297
|
+
if (!feet.includes(name)) {
|
|
2298
|
+
out.push({
|
|
2299
|
+
code: 'REQUIREMENTS_NOT_PLANTED',
|
|
2300
|
+
object,
|
|
2301
|
+
detail: `names part "${name}" (from "${rec.from}"), which is not read as a planted foot; only a part from a See-through ${tagWords(FEET_TAGS)} layer is (${feet.map((n) => `"${n}"`).join(', ') || 'none here'}), so declaring it excepts nothing`,
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
return out;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/** The feet a line holds still (issue #231): the `footwear` parts less those the scene file declares not planted, and the declared ones, in parts.json's order. */
|
|
2309
|
+
function plantedFeet(parts: PartsFile, notPlanted: readonly string[]): { all: PartRecord[]; held: PartRecord[]; declared: string[] } {
|
|
2310
|
+
const all = partsTagged(parts, FEET_TAGS);
|
|
2311
|
+
return { all, held: all.filter((p) => !notPlanted.includes(p.name)), declared: all.filter((p) => notPlanted.includes(p.name)).map((p) => p.name) };
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
/** The sentence a feet half carries when every footwear part is declared not planted (issue #231). */
|
|
2315
|
+
function noneHeldText(declared: readonly string[]): string {
|
|
2316
|
+
return `every ${tagWords(FEET_TAGS)} part (${declared.map((n) => `"${n}"`).join(', ')}) is declared not planted by the scene file's not_planted, so there are no feet to hold still`;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* (1) The torso moves while the feet do not: each rendered alone, heat over
|
|
2321
|
+
* its own box. The feet are the `footwear` parts less those the scene file
|
|
2322
|
+
* declares `not_planted` (issue #231); a declaration is the line's first
|
|
2323
|
+
* figure, `declared_not_planted`, so the line never reads as though the feet
|
|
2324
|
+
* it names were held still. With every foot declared, the torso is measured
|
|
2325
|
+
* and the feet figures are an empty list and null.
|
|
2326
|
+
*/
|
|
2327
|
+
function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
|
|
2247
2328
|
const torso = partsTagged(inp.parts, TORSO_TAGS);
|
|
2248
|
-
const feet =
|
|
2249
|
-
if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`);
|
|
2250
|
-
if (
|
|
2329
|
+
const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
|
|
2330
|
+
if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe${declared.length === 0 ? '' : `; ${declared.map((n) => `"${n}"`).join(', ')} declared not planted`}`);
|
|
2331
|
+
if (allFeet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
|
|
2251
2332
|
const H = inp.parts.rig_size[1];
|
|
2252
2333
|
const ts = isolated('torso', torso);
|
|
2253
|
-
const fs = isolated('feet', feet);
|
|
2334
|
+
const fs = feet.length === 0 ? null : isolated('feet', feet);
|
|
2254
2335
|
const tb = frameBox(torso, H, inp.stage, ts.viewport);
|
|
2255
|
-
const fb = frameBox(feet, H, inp.stage, fs.viewport);
|
|
2256
|
-
if (tb === null || fb === null) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
|
|
2336
|
+
const fb = fs === null ? null : frameBox(feet, H, inp.stage, fs.viewport);
|
|
2337
|
+
if (tb === null || (fs !== null && fb === null)) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
|
|
2257
2338
|
const th = regionHeat(heatField(ts.frames.map((f) => f.image)), ts.viewport.pixelWidth, tb);
|
|
2258
|
-
const fh = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
|
|
2339
|
+
const fh = fs === null || fb === null ? null : regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
|
|
2259
2340
|
const mean = round3(th.mean);
|
|
2260
|
-
const ok = mean >= BREATH_TORSO_MEAN_FLOOR && fh.max <= BREATH_FEET_MAX_CEILING;
|
|
2341
|
+
const ok = mean >= BREATH_TORSO_MEAN_FLOOR && (fh === null || fh.max <= BREATH_FEET_MAX_CEILING);
|
|
2261
2342
|
if (mean < BREATH_TORSO_MEAN_FLOOR) {
|
|
2262
2343
|
problems.push({
|
|
2263
2344
|
code: 'CHECK_BREATH_VISIBLE',
|
|
@@ -2265,24 +2346,34 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): Ju
|
|
|
2265
2346
|
detail: `heat mean ${mean}/255 over the idle; >= ${BREATH_TORSO_MEAN_FLOOR} is required — the chest's breath track moves too little, or the torso is not weighted to the bone it keys`,
|
|
2266
2347
|
});
|
|
2267
2348
|
}
|
|
2268
|
-
if (fh.max > BREATH_FEET_MAX_CEILING) {
|
|
2349
|
+
if (fh !== null && fb !== null && fh.max > BREATH_FEET_MAX_CEILING) {
|
|
2269
2350
|
problems.push({
|
|
2270
2351
|
code: 'CHECK_BREATH_VISIBLE',
|
|
2271
|
-
object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}`,
|
|
2352
|
+
object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}${declared.length === 0 ? '' : ` (${declared.map((n) => `"${n}"`).join(', ')} declared not planted)`}`,
|
|
2272
2353
|
detail: `heat max ${fh.max}/255 over the idle; <= ${BREATH_FEET_MAX_CEILING} is required — the feet move: their slot rides, or their mesh is weighted to, a bone the idle keys`,
|
|
2273
2354
|
});
|
|
2274
2355
|
}
|
|
2275
2356
|
return {
|
|
2276
2357
|
status: ok ? 'PASS' : 'FAIL',
|
|
2358
|
+
...(declared.length === 0 ? {} : { declared_not_planted: declared }),
|
|
2277
2359
|
torso_parts: torso.map((p) => p.name),
|
|
2278
2360
|
torso_heat_mean: mean,
|
|
2279
2361
|
torso_floor: BREATH_TORSO_MEAN_FLOOR,
|
|
2280
2362
|
feet_parts: feet.map((p) => p.name),
|
|
2281
|
-
feet_heat_max: fh.max,
|
|
2363
|
+
feet_heat_max: fh === null ? null : fh.max,
|
|
2282
2364
|
feet_ceiling: BREATH_FEET_MAX_CEILING,
|
|
2283
2365
|
};
|
|
2284
2366
|
}
|
|
2285
2367
|
|
|
2368
|
+
/** The scene file's two exceptions to `TIP_OVER_ROOT` (issue #231), and the file they were read from, which a refusal names. */
|
|
2369
|
+
export interface TipExceptions {
|
|
2370
|
+
path: string;
|
|
2371
|
+
not_swinging: readonly string[];
|
|
2372
|
+
rises: readonly string[];
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [] };
|
|
2376
|
+
|
|
2286
2377
|
/**
|
|
2287
2378
|
* What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
|
|
2288
2379
|
* the idle's posed geometry, not a render of the part. Until #118 the line
|
|
@@ -2301,37 +2392,139 @@ export const TIP_INSTRUMENT = 'geometry';
|
|
|
2301
2392
|
* binary-coverage centroid per half; every root half on the three examples
|
|
2302
2393
|
* travels under a frame pixel, where that centroid scatters by up to 0.3 px
|
|
2303
2394
|
* with the grid, so the ratio was set by the grid.
|
|
2395
|
+
*
|
|
2396
|
+
* Issue #231: the scene file's `not_swinging` parts are left out, each row
|
|
2397
|
+
* saying so, and its `rises` parts are read with the halves swapped once the
|
|
2398
|
+
* chain carrying each is measured to rise ({@link chainDirection}; refused by
|
|
2399
|
+
* name otherwise); both lists are the line's first figures. Without them the
|
|
2400
|
+
* line is the one written before.
|
|
2304
2401
|
*/
|
|
2305
|
-
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[]): JudgementLine {
|
|
2402
|
+
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
|
|
2306
2403
|
const swing = partsTagged(inp.parts, SWING_TAGS);
|
|
2307
2404
|
if (swing.length === 0) return skip(`no part comes from a See-through ${tagWords(SWING_TAGS)} layer, so there is no hem or sleeve to swing`);
|
|
2308
2405
|
const poses = attachmentPoses(geometryPath, frames);
|
|
2406
|
+
// Issue #231: a part declared `rises` is read up only when the chain carrying it does rise, measured at the setup pose.
|
|
2407
|
+
if (excepted.rises.length > 0) {
|
|
2408
|
+
const geo = readGeometry(geometryPath, frames);
|
|
2409
|
+
const origins = new Map<string, { x: number; y: number }>();
|
|
2410
|
+
for (const b of Array.isArray(geo?.setupBones) ? geo.setupBones : []) {
|
|
2411
|
+
if (isRecord(b) && typeof b.name === 'string' && typeof b.worldX === 'number' && typeof b.worldY === 'number') origins.set(b.name, { x: b.worldX, y: b.worldY });
|
|
2412
|
+
}
|
|
2413
|
+
const refused: Problem[] = [];
|
|
2414
|
+
excepted.rises.forEach((name, i) => {
|
|
2415
|
+
const dir = chainDirection(inp.rig, inp.motion, name, origins);
|
|
2416
|
+
if ('toward' in dir && dir.toward === 'up') return;
|
|
2417
|
+
refused.push({
|
|
2418
|
+
code: 'REQUIREMENTS_TIP_EXCEPTION',
|
|
2419
|
+
object: `${excepted.path} field "rises"[${i}]`,
|
|
2420
|
+
detail: `names part "${name}", whose tip is not up: ${'none' in dir ? dir.none : `its chain runs ${dir.toward} — ${dir.chains.join('; ')}`}; a part declared rises must be carried by a chain whose last link's origin is above its first's at the setup pose`,
|
|
2421
|
+
});
|
|
2422
|
+
});
|
|
2423
|
+
refuseIfAny(refused);
|
|
2424
|
+
}
|
|
2309
2425
|
const rows: Array<Record<string, unknown>> = [];
|
|
2310
2426
|
let measured = 0;
|
|
2311
2427
|
let failed = false;
|
|
2312
2428
|
for (const p of swing) {
|
|
2429
|
+
if (excepted.not_swinging.includes(p.name)) {
|
|
2430
|
+
rows.push({ part: p.name, unmeasured: "declared not swinging by the scene file's not_swinging, so it is not held to the bar" });
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
const rises = excepted.rises.includes(p.name);
|
|
2434
|
+
const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
|
|
2313
2435
|
const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
|
|
2314
2436
|
const shown = poses.map((f) => f.get(p.name) ?? null);
|
|
2315
|
-
const t = halfTravels(art, p, inp.stage, shown);
|
|
2437
|
+
const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
|
|
2438
|
+
// A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
|
|
2439
|
+
const said = rises ? { tip_half: tipHalf } : {};
|
|
2316
2440
|
if (t.root === null || t.tip === null) {
|
|
2317
|
-
rows.push({ part: p.name, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ?
|
|
2441
|
+
rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? rootHalf : tipHalf} half of its box holds no art its attachment draws` });
|
|
2318
2442
|
continue;
|
|
2319
2443
|
}
|
|
2320
2444
|
measured++;
|
|
2321
2445
|
const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
|
|
2322
2446
|
const ok = ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
|
|
2323
|
-
rows.push({ part: p.name, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2447
|
+
rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2324
2448
|
if (!ok) {
|
|
2325
2449
|
failed = true;
|
|
2326
2450
|
problems.push({
|
|
2327
2451
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
2328
|
-
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry`,
|
|
2329
|
-
detail: `the art in its
|
|
2452
|
+
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
|
|
2453
|
+
detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
|
|
2330
2454
|
});
|
|
2331
2455
|
}
|
|
2332
2456
|
}
|
|
2457
|
+
const declared = {
|
|
2458
|
+
...(excepted.not_swinging.length === 0 ? {} : { declared_not_swinging: swing.filter((p) => excepted.not_swinging.includes(p.name)).map((p) => p.name) }),
|
|
2459
|
+
...(excepted.rises.length === 0 ? {} : { declared_rises: swing.filter((p) => excepted.rises.includes(p.name)).map((p) => p.name) }),
|
|
2460
|
+
};
|
|
2333
2461
|
if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
|
|
2334
|
-
return { status: failed ? 'FAIL' : 'PASS', instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2462
|
+
return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/**
|
|
2466
|
+
* Which way the chain carrying a part runs (issue #231), for a part the scene
|
|
2467
|
+
* declares `rises`: the bones its attachments are bound to in the default skin
|
|
2468
|
+
* (a mesh's weights, or its slot's bone); the leaves among them — those with
|
|
2469
|
+
* no other of them below — walked up the bone tree to the first link of a
|
|
2470
|
+
* chain `CHAIN_LAG` reads ({@link chainFigures}: two or more rotate-keyed
|
|
2471
|
+
* links, each under the one before at one period); each chain so found runs
|
|
2472
|
+
* from its first link's setup-pose origin to its last link's
|
|
2473
|
+
* ({@link chainToward}). `{ none }`, the reason, when no leaf is under a chain,
|
|
2474
|
+
* when a chain runs as far across as along, or when the part's chains run
|
|
2475
|
+
* different ways.
|
|
2476
|
+
*/
|
|
2477
|
+
export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
|
|
2478
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2479
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2480
|
+
const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
|
|
2481
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2482
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2483
|
+
const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
|
|
2484
|
+
const bound = new Set<string>();
|
|
2485
|
+
for (const a of atts) {
|
|
2486
|
+
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);
|
|
2487
|
+
if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
|
|
2488
|
+
}
|
|
2489
|
+
const under = (b: string, anc: string): boolean => {
|
|
2490
|
+
const seen = new Set<string>();
|
|
2491
|
+
for (let x = parent.get(b) ?? null; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
|
2492
|
+
if (x === anc) return true;
|
|
2493
|
+
seen.add(x);
|
|
2494
|
+
}
|
|
2495
|
+
return false;
|
|
2496
|
+
};
|
|
2497
|
+
const leaves = [...bound].filter((b) => ![...bound].some((o) => o !== b && under(o, b)));
|
|
2498
|
+
const chains = chainFigures(rig, motion).chains.filter((c) => c.length >= 2);
|
|
2499
|
+
const chainOf = new Map<string, string[]>();
|
|
2500
|
+
for (const c of chains) for (const l of c) chainOf.set(l.bone, c.map((x) => x.bone));
|
|
2501
|
+
const found: string[][] = [];
|
|
2502
|
+
for (const leaf of leaves) {
|
|
2503
|
+
const seen = new Set<string>();
|
|
2504
|
+
for (let b: string | null = leaf; b !== null && !seen.has(b); b = parent.get(b) ?? null) {
|
|
2505
|
+
seen.add(b);
|
|
2506
|
+
const c = chainOf.get(b);
|
|
2507
|
+
if (c !== undefined) {
|
|
2508
|
+
if (!found.some((f) => f[0] === c[0])) found.push(c);
|
|
2509
|
+
break;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
|
|
2514
|
+
if (found.length === 0) return { none: `it rides no chain: none of the bones it is bound to (${boundText}) is under a chain of rotate-keyed links` };
|
|
2515
|
+
const read = found.map((c) => {
|
|
2516
|
+
const a = origins.get(c[0]);
|
|
2517
|
+
const z = origins.get(c[c.length - 1]);
|
|
2518
|
+
const label = c.join(' > ');
|
|
2519
|
+
if (a === undefined || z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${a === undefined ? c[0] : c[c.length - 1]} in the idle's geometry` };
|
|
2520
|
+
const dx = z.x - a.x;
|
|
2521
|
+
const dy = z.y - a.y;
|
|
2522
|
+
const toward = chainToward(dx, dy);
|
|
2523
|
+
return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
|
|
2524
|
+
});
|
|
2525
|
+
const ways = [...new Set(read.map((r) => r.toward))];
|
|
2526
|
+
if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
|
|
2527
|
+
return { toward: ways[0], chains: read.map((r) => r.text) };
|
|
2335
2528
|
}
|
|
2336
2529
|
|
|
2337
2530
|
/**
|
|
@@ -2474,10 +2667,10 @@ interface StillHalf {
|
|
|
2474
2667
|
* features go; `footwear` on top) is reported per slot, `crossing`, with no
|
|
2475
2668
|
* bar ({@link crossingOf}). No render grid enters either half.
|
|
2476
2669
|
*/
|
|
2477
|
-
export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[]): JudgementLine {
|
|
2670
|
+
export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
|
|
2478
2671
|
const face = partsTagged(inp.parts, FACE_TAGS);
|
|
2479
|
-
const feet =
|
|
2480
|
-
if (face.length === 0 &&
|
|
2672
|
+
const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
|
|
2673
|
+
if (face.length === 0 && allFeet.length === 0) return skip(`no part comes from a See-through ${tagWords([...FACE_TAGS, ...FEET_TAGS])} layer, so there is no still region to read`);
|
|
2481
2674
|
if (geo === null) return skip(`${geometryPath} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the still regions are read off it`);
|
|
2482
2675
|
const H = inp.parts.rig_size[1];
|
|
2483
2676
|
const W = inp.parts.rig_size[0];
|
|
@@ -2618,7 +2811,8 @@ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | n
|
|
|
2618
2811
|
};
|
|
2619
2812
|
|
|
2620
2813
|
const feetHalf = (): Record<string, unknown> | string => {
|
|
2621
|
-
if (
|
|
2814
|
+
if (allFeet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
|
|
2815
|
+
if (feet.length === 0) return noneHeldText(declared);
|
|
2622
2816
|
const names = feet.map((p) => p.name);
|
|
2623
2817
|
const h = half(names, firstOf(names), poses.map(() => null), 1, 0, regionOf(new Set(feet.map((p) => index.get(p.name) as number)), []));
|
|
2624
2818
|
for (const o of h.over) {
|
|
@@ -2635,7 +2829,9 @@ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | n
|
|
|
2635
2829
|
const t = feetHalf();
|
|
2636
2830
|
if (typeof f === 'string' && typeof t === 'string') return skip(`face: ${f}; feet: ${t}`);
|
|
2637
2831
|
const failed = problems.some((q) => q.code === 'CHECK_STILL_REGIONS_DARK');
|
|
2638
|
-
|
|
2832
|
+
// Issue #231: a declaration is the feet half's first figure, measured or not.
|
|
2833
|
+
const said = declared.length === 0 ? {} : { declared_not_planted: declared };
|
|
2834
|
+
return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { ...said, unmeasured: t } : { ...said, ...t } };
|
|
2639
2835
|
}
|
|
2640
2836
|
|
|
2641
2837
|
function fmt3(x: number): string {
|
package/src/instruments.ts
CHANGED
|
@@ -186,6 +186,23 @@ export interface HalfTravels {
|
|
|
186
186
|
tip: number | null;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/** Where a chain's tip lies from its root on the screen (issue #231). */
|
|
190
|
+
export type Toward = 'down' | 'up' | 'left' | 'right';
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Where a chain's tip is from its root (issue #231): the vector from its first
|
|
194
|
+
* link's origin to its last link's at the setup pose, in the world (y up),
|
|
195
|
+
* read along whichever axis it runs further on — `down`/`up` when |dy| > |dx|,
|
|
196
|
+
* `left`/`right` when |dx| > |dy|. Null when it runs as far on both, which
|
|
197
|
+
* includes a chain whose links share one origin. `check` holds a part the
|
|
198
|
+
* scene declares `rises` to it: the chain carrying it must read `up`.
|
|
199
|
+
*/
|
|
200
|
+
export function chainToward(dx: number, dy: number): Toward | null {
|
|
201
|
+
if (Math.abs(dy) > Math.abs(dx)) return dy < 0 ? 'down' : 'up';
|
|
202
|
+
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
189
206
|
/** A polygon as x, y pairs. */
|
|
190
207
|
type Polygon = number[];
|
|
191
208
|
|
|
@@ -348,17 +365,24 @@ function artPieces(art: ArtMask, a: PosedTriangles, stage: StageBox): ArtPieces
|
|
|
348
365
|
* Art outside every rest triangle is not drawn by the runtime and is not
|
|
349
366
|
* counted. A frame on which the slot shows nothing (null) is skipped; a half
|
|
350
367
|
* with no art on frame 0 is null. Each distinct rest attachment is cut once.
|
|
368
|
+
*
|
|
369
|
+
* `toward` is where the tip is from the root (issue #231): `down` — the upper
|
|
370
|
+
* half is the root's and the lower the tip's, the reading before #231 and the
|
|
371
|
+
* default — or `up`, the two swapped, for a part the scene declares `rises`.
|
|
351
372
|
*/
|
|
352
|
-
export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number; h: number }, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null
|
|
373
|
+
export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number; h: number }, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null>, toward: 'down' | 'up' = 'down'): HalfTravels {
|
|
353
374
|
const H = stage.height;
|
|
354
375
|
const bx0 = stage.x + box.x;
|
|
355
376
|
const bx1 = stage.x + box.x + box.w;
|
|
356
377
|
const top = stage.y + cropToSpineY(box.y, H);
|
|
357
378
|
const mid = stage.y + cropToSpineY(box.y + box.h / 2, H);
|
|
358
379
|
const bottom = stage.y + cropToSpineY(box.y + box.h, H);
|
|
359
|
-
|
|
380
|
+
// Each band is a rectangle of the world, [x0, y0, x1, y1] (y up).
|
|
381
|
+
const upper = [bx0, mid, bx1, top] as const;
|
|
382
|
+
const lower = [bx0, bottom, bx1, mid] as const;
|
|
383
|
+
const bands = toward === 'down' ? { root: upper, tip: lower } : { root: lower, tip: upper };
|
|
360
384
|
const cut = new Map<readonly number[], ArtPieces>();
|
|
361
|
-
const centroidIn = (a: PosedTriangles, y0: number, y1: number): [number, number] | null => {
|
|
385
|
+
const centroidIn = (a: PosedTriangles, x0: number, y0: number, x1: number, y1: number): [number, number] | null => {
|
|
362
386
|
let pc = cut.get(a.rest);
|
|
363
387
|
if (pc === undefined) {
|
|
364
388
|
pc = artPieces(art, a, stage);
|
|
@@ -384,13 +408,13 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
|
|
|
384
408
|
const xmax = Math.max(px, px + e1x, px + e2x);
|
|
385
409
|
const ymin = Math.min(py, py + e1y, py + e2y);
|
|
386
410
|
const ymax = Math.max(py, py + e1y, py + e2y);
|
|
387
|
-
if (xmax <=
|
|
411
|
+
if (xmax <= x0 || xmin >= x1 || ymax <= y0 || ymin >= y1) continue;
|
|
388
412
|
const R = a.rest;
|
|
389
413
|
const restDet = (R[2 * j] - R[2 * i]) * (R[2 * k + 1] - R[2 * i + 1]) - (R[2 * k] - R[2 * i]) * (R[2 * j + 1] - R[2 * i + 1]);
|
|
390
414
|
const posedDet = e1x * e2y - e2x * e1y;
|
|
391
415
|
// The posed area of a rest area: |posed det / rest det| times it.
|
|
392
416
|
const scale = Math.abs(posedDet / restDet);
|
|
393
|
-
if (xmin >=
|
|
417
|
+
if (xmin >= x0 && xmax <= x1 && ymin >= y0 && ymax <= y1) {
|
|
394
418
|
const w = pc.area[t] * scale;
|
|
395
419
|
const l1 = pc.centroid[2 * t];
|
|
396
420
|
const l2 = pc.centroid[2 * t + 1];
|
|
@@ -402,7 +426,7 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
|
|
|
402
426
|
for (const bary of pc.pieces[t]) {
|
|
403
427
|
const poly: Polygon = [];
|
|
404
428
|
for (let q = 0; q < bary.length; q += 2) poly.push(px + bary[q] * e1x + bary[q + 1] * e2x, py + bary[q] * e1y + bary[q + 1] * e2y);
|
|
405
|
-
const c = clipRect(poly,
|
|
429
|
+
const c = clipRect(poly, x0, y0, x1, y1);
|
|
406
430
|
if (c.length < 6) continue;
|
|
407
431
|
const [pa, pmx, pmy] = polygonMoments(c);
|
|
408
432
|
// A triangle the pose turns over winds its pieces the other way; what is drawn is the area either way.
|
|
@@ -414,19 +438,19 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
|
|
|
414
438
|
}
|
|
415
439
|
return A > 0 ? [MX / A, MY / A] : null;
|
|
416
440
|
};
|
|
417
|
-
const travel = (
|
|
441
|
+
const travel = (band: readonly [number, number, number, number]): number | null => {
|
|
418
442
|
const first = frames[0];
|
|
419
|
-
const c0 = first === null ? null : centroidIn(first,
|
|
443
|
+
const c0 = first === null ? null : centroidIn(first, ...band);
|
|
420
444
|
if (c0 === null) return null;
|
|
421
445
|
let max = 0;
|
|
422
446
|
for (const f of frames) {
|
|
423
|
-
const c = f === null ? null : centroidIn(f,
|
|
447
|
+
const c = f === null ? null : centroidIn(f, ...band);
|
|
424
448
|
if (c === null) continue;
|
|
425
449
|
max = Math.max(max, Math.hypot(c[0] - c0[0], c[1] - c0[1]));
|
|
426
450
|
}
|
|
427
451
|
return max;
|
|
428
452
|
};
|
|
429
|
-
return { root: travel(bands.root
|
|
453
|
+
return { root: travel(bands.root), tip: travel(bands.tip) };
|
|
430
454
|
}
|
|
431
455
|
|
|
432
456
|
// ---------------------------------------------------------------------------
|
package/src/parts.ts
CHANGED
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
* issue #119). They are not in `opaque_px`: the counts above are taken
|
|
36
36
|
* after it.
|
|
37
37
|
*
|
|
38
|
+
* One field names a part rather than counting pixels: `cut_from`, written only
|
|
39
|
+
* on a piece an `assemble.cuts` entry made (issue #230) — the plan part it was
|
|
40
|
+
* cut from, which shares its `from`. The record is in draw order, and a piece
|
|
41
|
+
* drawn `"back"` comes before its base, so order cannot say which of two parts
|
|
42
|
+
* sharing a layer is the piece; this field does. A `parts.json` written before
|
|
43
|
+
* 1.5.6 has none, and still reads.
|
|
44
|
+
*
|
|
38
45
|
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
39
46
|
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
40
47
|
* is synthesis), all candidates and all taken: `visible_px =
|
|
@@ -71,6 +78,12 @@ import { readTag } from './tags.ts';
|
|
|
71
78
|
export interface PartRecord {
|
|
72
79
|
name: string;
|
|
73
80
|
from: string;
|
|
81
|
+
/**
|
|
82
|
+
* Written only on a piece an `assemble.cuts` entry made (issue #230): the
|
|
83
|
+
* name of the plan part it was cut from, which has the same `from`. Absent
|
|
84
|
+
* on every other part, and in a `parts.json` written before 1.5.6.
|
|
85
|
+
*/
|
|
86
|
+
cut_from?: string;
|
|
74
87
|
x: number;
|
|
75
88
|
y: number;
|
|
76
89
|
w: number;
|
|
@@ -132,6 +145,7 @@ const BORDER_KEYS = ['part', 'px'] as const;
|
|
|
132
145
|
export const PART_KEYS = [
|
|
133
146
|
'name',
|
|
134
147
|
'from',
|
|
148
|
+
'cut_from',
|
|
135
149
|
'x',
|
|
136
150
|
'y',
|
|
137
151
|
'w',
|
|
@@ -149,10 +163,12 @@ export const PART_KEYS = [
|
|
|
149
163
|
] as const;
|
|
150
164
|
/** The one part field that is not a count: a list of `{part, px}`, written only when it is not empty. */
|
|
151
165
|
const FRINGE_KEY = 'fringe_pushed_back';
|
|
152
|
-
|
|
166
|
+
/** The one part field that names another part: a cut's piece names its base, written only on a piece. */
|
|
167
|
+
const CUT_FROM_KEY = 'cut_from';
|
|
168
|
+
const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
|
|
153
169
|
/** The port's own counts: all three or none (none = a record the reference wrote). */
|
|
154
170
|
export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
|
|
155
|
-
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY);
|
|
171
|
+
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY);
|
|
156
172
|
|
|
157
173
|
/** Where a part came from: a See-through run and a v3 tag, or the painting itself for a patch. */
|
|
158
174
|
export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
|
|
@@ -318,6 +334,27 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
318
334
|
}
|
|
319
335
|
});
|
|
320
336
|
}
|
|
337
|
+
if (Array.isArray(o.parts)) {
|
|
338
|
+
// A cut's piece (issue #230) names its base: a part of this file, with the
|
|
339
|
+
// same "<run>:<tag>", that is not itself a piece (a cut of a cut has no
|
|
340
|
+
// single plan part behind it; the config loader refuses one).
|
|
341
|
+
const recs = o.parts.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null && !Array.isArray(p));
|
|
342
|
+
const byName = new Map<unknown, Record<string, unknown>>(recs.map((r) => [r.name, r]));
|
|
343
|
+
for (const r of recs) {
|
|
344
|
+
if (!(CUT_FROM_KEY in r)) continue;
|
|
345
|
+
const object = `part "${String(r.name)}" field "${CUT_FROM_KEY}"`;
|
|
346
|
+
const base = r[CUT_FROM_KEY];
|
|
347
|
+
if (typeof base !== 'string' || base === '') {
|
|
348
|
+
fail('PARTS_FIELD_TYPE', object, `is ${show(base)}; the name of the part the cut took it from is required (the field is absent on a part no cut made)`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const b = byName.get(base);
|
|
352
|
+
if (b === undefined) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", which is no part of this file; the plan part the cut took this piece from is required`);
|
|
353
|
+
else if (base === r.name) fail('PARTS_CUT_FROM_KNOWN', object, `names the piece itself; the plan part the cut took it from is required`);
|
|
354
|
+
else if (CUT_FROM_KEY in b) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", which is itself a piece (cut from ${show(b[CUT_FROM_KEY])}); a plan part is required, as a cut of a cut has no single layer behind it`);
|
|
355
|
+
else if (b.from !== r.from) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", whose from is ${show(b.from)}; this piece's is ${show(r.from)}, and a piece shares its base's layer`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
321
358
|
if ('recomposite' in o && sizeOk) {
|
|
322
359
|
const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
|
|
323
360
|
checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
|