@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/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
- /** (1) The torso moves while the feet do not: each rendered alone, heat over its own box. */
2246
- function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): JudgementLine {
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 = partsTagged(inp.parts, FEET_TAGS);
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 (feet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
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 ? 'upper' : 'lower'} half of its box holds no art its attachment draws` });
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 lower half travels ${round3(t.tip)} rig px and in its upper 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`,
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 = partsTagged(inp.parts, FEET_TAGS);
2480
- if (face.length === 0 && feet.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`);
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 (feet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
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
- return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { unmeasured: t } : t };
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 {
@@ -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>): HalfTravels {
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
- const bands = { root: [mid, top], tip: [bottom, mid] } as const;
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 <= bx0 || xmin >= bx1 || ymax <= y0 || ymin >= y1) continue;
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 >= bx0 && xmax <= bx1 && ymin >= y0 && ymax <= y1) {
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, bx0, y0, bx1, y1);
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 = (y0: number, y1: number): number | null => {
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, y0, y1);
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, y0, y1);
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[0], bands.root[1]), tip: travel(bands.tip[0], bands.tip[1]) };
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
- const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
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);