@rig-c/parts 1.13.1 → 1.15.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
@@ -56,9 +56,12 @@
56
56
  * uncovered holes `assemble` recorded in `parts.json`'s `recomposite` block,
57
57
  * copied into `check.json` because the seam cannot see them — it compares the
58
58
  * setup pose with the flat stack of parts, and a pixel no part holds is
59
- * missing from both. The
60
- * part-alone renders and the shut-eye still are scratch (`_isolated/`,
61
- * `_still/`), removed before the stage returns.
59
+ * missing from both. When `motion.json` holds an animation, one more bar,
60
+ * `ANIMATIONS_DO_NOT_FOLD` (issues #320, #333): rigc's own `render --folds`
61
+ * over every animation, the idle included, physics constraints left out
62
+ * ({@link FOLD_LINE}). The part-alone renders, the shut-eye still, the keyed
63
+ * build and the fold render are scratch (`_isolated/`, `_still/`, `_loop/`,
64
+ * `_folds/`), removed before the stage returns.
62
65
  *
63
66
  * Where this port deliberately differs from the reference, each written where
64
67
  * it applies below: a gate summary is read with a pattern rather than the
@@ -130,6 +133,7 @@ import {
130
133
  rangeLine,
131
134
  readRequirements,
132
135
  type RequirementLine,
136
+ type MayFold,
133
137
  type RequirementsFile,
134
138
  requirementProblem,
135
139
  type RequirementsSummary,
@@ -313,10 +317,58 @@ export interface RigcCall {
313
317
  status: number;
314
318
  /** stdout then stderr. */
315
319
  out: string;
320
+ /**
321
+ * Which rigc ran the call, as {@link rigcIdentity} words it (`rigc <version> at <path>`), from a runner that knows
322
+ * its binary ({@link rigcRunnerAt}): set on every call that exited non-zero and on every `--version` call, so a
323
+ * refusal quoting the call names the binary and its version (issue #337). Absent from a runner that does not know.
324
+ */
325
+ ran?: string;
316
326
  }
317
327
 
318
328
  export type RigcRunner = (args: readonly string[]) => RigcCall;
319
329
 
330
+ /** The process primitive {@link rigcRunnerAt} is given: `src/` spawns nothing, so the caller's spawn is injected. */
331
+ export type RigcSpawn = (bin: string, args: readonly string[]) => RigcCall;
332
+
333
+ /**
334
+ * A rigc binary as what it says it is: `rigc <version> at <path>`, the version being the first line `--version`
335
+ * printed — the line `check` prints before it builds (`checkStage`). A `--version` run that exited non-zero or
336
+ * printed nothing is said so, with the path, rather than read as a version.
337
+ */
338
+ export function rigcIdentity(bin: string, version: RigcCall): string {
339
+ const first = version.out.trim().split('\n')[0] ?? '';
340
+ if (version.status === 0 && first !== '') return `rigc ${first} at ${bin}`;
341
+ return `rigc (\`--version\` exited ${version.status}${first === '' ? ' and printed nothing' : `, printing ${JSON.stringify(first)}`}) at ${bin}`;
342
+ }
343
+
344
+ /** The clause a refusal quoting a rigc call ends with: which rigc ran it ({@link RigcCall.ran}), or nothing when the runner did not say. */
345
+ export function rigcRanClause(call: { ran?: string }): string {
346
+ return call.ran === undefined ? '' : `; ${call.ran} ran it`;
347
+ }
348
+
349
+ /**
350
+ * The runner every stage is given (issue #337): `bin` spawned through `spawn`, each call that exited non-zero, and
351
+ * each `--version` call, carrying {@link RigcCall.ran}. The identity is read once per runner, off its first
352
+ * `--version` call or, when a call fails before any, off one `--version` run then — so a green run spawns nothing
353
+ * extra, and a failure names the binary and version that produced it rather than leaving the reader to guess
354
+ * which rigc a path resolved to.
355
+ */
356
+ export function rigcRunnerAt(bin: string, spawn: RigcSpawn): RigcRunner {
357
+ let identity: string | null = null;
358
+ return (args) => {
359
+ const call = spawn(bin, args);
360
+ const version = args.length === 1 && args[0] === '--version';
361
+ if (version) {
362
+ const own = rigcIdentity(bin, call);
363
+ identity ??= own;
364
+ return { ...call, ran: own };
365
+ }
366
+ if (call.status === 0) return call;
367
+ identity ??= rigcIdentity(bin, spawn(bin, ['--version']));
368
+ return { ...call, ran: identity };
369
+ };
370
+ }
371
+
320
372
  /** A line in which a rigc run that stopped says why: Bun's own `error: …`, rigc's `rigc <command>: …` refusals, a `usage: …` line. */
321
373
  const CAUSE = /^(error: |rigc[\w -]*: |usage: )/;
322
374
 
@@ -392,6 +444,27 @@ export function readRigcEntry(versionOut: string): RigcEntryRecord {
392
444
  throw new Error('unreachable');
393
445
  }
394
446
 
447
+ /**
448
+ * This package's own rigc: `node_modules/.bin/rigc` under `root` and nowhere else — no directory above it and no
449
+ * PATH, which {@link findRigc} walks (issue #337). The selftest's suites run rigc through it from the package root,
450
+ * so a fixture in a temp directory, or a tree nested inside another checkout (a harness worktree under
451
+ * `.claude/worktrees/`) whose own install is missing, cannot reach another tree's rigc. A miss is refused naming
452
+ * the one path looked at.
453
+ */
454
+ export function packageRigc(root: string): string {
455
+ const bin = join(resolve(root), 'node_modules', '.bin', 'rigc');
456
+ if (!existsSync(bin)) {
457
+ refuseIfAny([
458
+ {
459
+ code: 'CHECK_RIGC_PACKAGE',
460
+ object: bin,
461
+ detail: `does not exist; this package's own rigc is required here and no other is looked for — not in a directory above ${resolve(root)}, not on PATH (\`bun install\` puts it there)`,
462
+ },
463
+ ]);
464
+ }
465
+ return bin;
466
+ }
467
+
395
468
  /**
396
469
  * Where the `rigc` binary is: `node_modules/.bin/rigc` in `from` or any
397
470
  * directory above it (a clone has it beside the package; an install has it in
@@ -1642,6 +1715,8 @@ export interface CheckFigures {
1642
1715
  TIP_OVER_ROOT: JudgementLine;
1643
1716
  STILL_REGIONS_DARK: JudgementLine;
1644
1717
  TEXTURE_STRETCH: JudgementLine;
1718
+ /** Written whenever motion.json holds an animation, the idle included (issues #320, #333): {@link FoldsLine}. */
1719
+ [FOLD_LINE]?: FoldsLine;
1645
1720
  RECOMPOSITE_HOLES: ReportedLine;
1646
1721
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1647
1722
  SETUP_POSE_VS_SOURCE?: ReportedLine;
@@ -1665,22 +1740,51 @@ export interface RequirementsBlock {
1665
1740
  lines: Record<string, RequirementLine>;
1666
1741
  }
1667
1742
 
1743
+ /**
1744
+ * The bar over every animation, the idle included (issues #320, #333): no mesh
1745
+ * triangle turned over against the setup pose on any frame, on rig-c's own
1746
+ * reading —
1747
+ * `rigc render --folds`, one run over every animation the build holds — and
1748
+ * not a reading of this package's. A fold the art means is declared in the
1749
+ * requirements file's `may_fold`, by slot and animation with its why, and is
1750
+ * named and not held to the bar.
1751
+ *
1752
+ * ⭐ **Read with the physics constraints left out**, as the loop is, from the
1753
+ * same build ({@link withoutPhysics}). rig-c's render steps physics by 1/fps
1754
+ * from frame 0, so a fold a physics constraint makes is the sampling rate's:
1755
+ * on the public `scarf` example `hop` folds `bottomwear` (3 of 308 triangles,
1756
+ * frames 7-8) at 12 fps, and at none of 10, 15, 24, 30, 36, 48 and 60 fps;
1757
+ * `hair_front` at 6 fps; and nothing at 6, 12 or 24 fps with its 30 physics
1758
+ * constraints left out [measured for issue #320, rigc 2.47.1, a scratch run,
1759
+ * not tracked]. The keys and weights fold at every rate or at none that the
1760
+ * sampling resolves; the rate a consumer steps physics at is the scene's.
1761
+ *
1762
+ * The idle is read like any other animation (issue #333): `TEXTURE_STRETCH`
1763
+ * reads how far the idle's triangles stretch, and a triangle that turns inside
1764
+ * out is this bar's. Written whenever the motion holds an animation, so a rig
1765
+ * of the idle alone writes it too; on the public examples and the trailer no
1766
+ * idle folds, keys only [measured for issue #333, rigc 2.47.1, a scratch run,
1767
+ * not tracked].
1768
+ */
1769
+ export const FOLD_LINE = 'ANIMATIONS_DO_NOT_FOLD';
1770
+
1668
1771
  /** The bars `PASS` reads, in the order the summary counts them: the gate, the reference's two, then the judgement lines. */
1669
1772
  export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
1670
1773
 
1671
1774
  /**
1672
1775
  * The bars a character's own check may have failed and `compose --accept`
1673
1776
  * still take (issue #203): every bar of {@link BARS} but the gate, which is
1674
- * rig-c's and never accepted.
1777
+ * rig-c's and never accepted, and {@link FOLD_LINE} (issue #320).
1675
1778
  */
1676
- export const ACCEPTABLE_BARS: readonly string[] = BARS.filter((b) => b !== 'gate');
1779
+ export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== 'gate'), FOLD_LINE];
1677
1780
 
1678
1781
  /**
1679
1782
  * The bars a `check.json`, as `check` writes it, says were not met, in
1680
1783
  * {@link BARS} order: the gate when `gate_spine_html_green` is not true, the
1681
1784
  * loop when `loop_max_diff` is a number other than {@link LOOP_MAX_BAR}, the
1682
1785
  * seam when its figures fail {@link seamFails}, and each judgement line whose
1683
- * status is `FAIL`. A bar that measured nothing (null figures, `SKIP`) is not
1786
+ * status is `FAIL`, then {@link FOLD_LINE} when it is written and `FAIL`
1787
+ * (issue #320). A bar that measured nothing (null figures, `SKIP`) is not
1684
1788
  * one of them. Read off the file rather than its problems, because the file is
1685
1789
  * all a finished build leaves (issue #203).
1686
1790
  */
@@ -1691,7 +1795,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1691
1795
  // 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.
1692
1796
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1693
1797
  if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
1694
- for (const n of JUDGEMENT_LINES) {
1798
+ for (const n of [...JUDGEMENT_LINES, FOLD_LINE]) {
1695
1799
  const l = fig[n];
1696
1800
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
1697
1801
  }
@@ -1743,7 +1847,179 @@ export interface CheckReport {
1743
1847
  */
1744
1848
  export function besideIdleLine(names: readonly string[]): string | null {
1745
1849
  if (names.length === 0) return null;
1746
- return `beside the idle: ${names.length} animation(s), ${names.join(', ')} — built and gated by rigc with the rig, not measured: every bar here reads the idle`;
1850
+ return `beside the idle: ${names.length} animation(s), ${names.join(', ')} — built and gated by rigc with the rig; ${FOLD_LINE} reads each for folds, every other bar here reads the idle`;
1851
+ }
1852
+
1853
+ // ---------------------------------------------------------------------------
1854
+ // every animation, the idle included, read for folds (issues #320, #333)
1855
+ // ---------------------------------------------------------------------------
1856
+
1857
+ /**
1858
+ * The fold render's `--max`, rigc's smallest: `--folds` reads posed vertices
1859
+ * in world units, which the frame size does not change (rig-c AUTHORING §8.3),
1860
+ * so the frames are drawn as small as rigc draws them and thrown away. On
1861
+ * `scarf` the `hop` folds.json was byte-identical at `--max` 16 and 640
1862
+ * [measured for issue #320, a scratch run].
1863
+ */
1864
+ export const FOLD_RENDER_MAX = 16;
1865
+
1866
+ /** The fold render, under `--out`; removed before the stage returns. */
1867
+ const FOLD_SCRATCH = '_folds';
1868
+
1869
+ /** One mesh that folds in one animation: rigc's figures, the declaration that takes it if any, and rigc's own line. */
1870
+ export interface FoldRow {
1871
+ animation: string;
1872
+ slot: string;
1873
+ attachment: string;
1874
+ /** How many frames show a folded triangle. */
1875
+ frames: number;
1876
+ /** The frame with the most, the earliest on a tie (rigc's `worst`). */
1877
+ worst_frame: number;
1878
+ /** How many triangles are folded on that frame. */
1879
+ worst_triangles: number;
1880
+ /** How many triangles the mesh has. */
1881
+ triangles: number;
1882
+ /** The `may_fold` entry's why when the requirements file declares this slot and animation; null when nothing does. */
1883
+ declared: string | null;
1884
+ /** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
1885
+ line: string;
1886
+ }
1887
+
1888
+ /** `check.json`'s `ANIMATIONS_DO_NOT_FOLD`: written whenever the motion holds an animation, the idle included. */
1889
+ export interface FoldsLine {
1890
+ status: 'PASS' | 'FAIL';
1891
+ /** Every animation, the idle included, in motion.json's order: each one read. */
1892
+ animations: string[];
1893
+ fps: number;
1894
+ /** How many physics constraints the read build left out ({@link withoutPhysics}); 0 when the rig has none. */
1895
+ physics_left_out: number;
1896
+ /** Every mesh that folds in an animation read, in that animation's order and rigc's mesh order. */
1897
+ folding: FoldRow[];
1898
+ /** The rows no `may_fold` entry takes: the bar's figure. */
1899
+ folding_undeclared: number;
1900
+ /** The `may_fold` entries no row matches, each a FAIL — an exception that excepts nothing. */
1901
+ declared_not_folding: Array<{ slot: string; animation: string }>;
1902
+ /** The bar: undeclared folds, at most this many. */
1903
+ bar: number;
1904
+ }
1905
+
1906
+ /** One folding mesh as the reading states it. */
1907
+ export interface FoldingMesh {
1908
+ slot: string;
1909
+ attachment: string;
1910
+ triangleCount: number;
1911
+ frames: number;
1912
+ worstFrame: number;
1913
+ worstTriangles: number;
1914
+ line: string;
1915
+ }
1916
+
1917
+ /** The marker rigc prints before each fold line (`rigc render --folds`). */
1918
+ const FOLD_PRINTED = /^\s*\.\.\s+folds\s+(.*)$/;
1919
+
1920
+ /**
1921
+ * The folding meshes of each named animation, read off a `rigc render
1922
+ * --folds` run over every animation: `frames.json` names each set's
1923
+ * directory, each `folds.json` (`rigc-folds/1`) its meshes, and each folding
1924
+ * mesh's line is the one rigc printed for it. Refuses (`CHECK_FOLDS_READ`),
1925
+ * every problem at once, a set or file that is missing, a file that is not
1926
+ * the animation's or not at the rate asked, a field of the wrong shape, and a
1927
+ * folding mesh rigc printed no line for.
1928
+ */
1929
+ export function readFoldSets(dir: string, named: readonly string[], fps: number, printed: string): Map<string, FoldingMesh[]> {
1930
+ const problems: Problem[] = [];
1931
+ const bad = (object: string, detail: string): void => {
1932
+ problems.push({ code: 'CHECK_FOLDS_READ', object, detail });
1933
+ };
1934
+ const lines = printed.split('\n').map((l) => FOLD_PRINTED.exec(l)?.[1]).filter((l): l is string => l !== undefined);
1935
+ const out = new Map<string, FoldingMesh[]>();
1936
+ const sidecar = readJson(join(dir, 'frames.json'), problems);
1937
+ const sets = sidecar !== null && Array.isArray(sidecar.sets) ? sidecar.sets.filter(isRecord) : [];
1938
+ for (const name of named) {
1939
+ const set = sets.find((x) => x.animation === name);
1940
+ if (set === undefined || typeof set.dir !== 'string') {
1941
+ bad(join(dir, 'frames.json'), `names no frame set for animation ${JSON.stringify(name)}; \`rigc render --folds\` over the build writes one per animation`);
1942
+ continue;
1943
+ }
1944
+ const path = join(dir, set.dir, 'folds.json');
1945
+ const file = readJson(path, problems);
1946
+ if (file === null) continue;
1947
+ if (file.spec !== 'rigc-folds/1') bad(path, `spec is ${JSON.stringify(file.spec)}; "rigc-folds/1" is required`);
1948
+ if (file.animation !== name) bad(path, `animation is ${JSON.stringify(file.animation)}; ${JSON.stringify(name)} is required`);
1949
+ if (file.fps !== fps) bad(path, `fps is ${JSON.stringify(file.fps)}; ${fps}, the rate asked for, is required`);
1950
+ if (!Array.isArray(file.meshes)) {
1951
+ bad(path, `meshes is ${JSON.stringify(file.meshes)?.slice(0, 40)}; an array is required`);
1952
+ continue;
1953
+ }
1954
+ const rows: FoldingMesh[] = [];
1955
+ file.meshes.forEach((m, i) => {
1956
+ const at = `${path} meshes[${i}]`;
1957
+ if (!isRecord(m) || typeof m.slot !== 'string' || typeof m.attachment !== 'string' || typeof m.triangleCount !== 'number' || !Array.isArray(m.frames)) {
1958
+ bad(at, 'is not { slot, attachment, triangleCount, frames, worst } as rigc-folds/1 writes it');
1959
+ return;
1960
+ }
1961
+ if (m.worst === null) return;
1962
+ const worst = isRecord(m.worst) ? m.worst : null;
1963
+ if (worst === null || typeof worst.index !== 'number' || !Array.isArray(worst.triangles)) {
1964
+ bad(at, `worst is ${JSON.stringify(m.worst)?.slice(0, 40)}; null or { index, triangles, … } is required`);
1965
+ return;
1966
+ }
1967
+ const prefix = `${JSON.stringify(name)}: mesh "${m.attachment}" (slot "${m.slot}") folds on `;
1968
+ const line = lines.find((l) => l.startsWith(prefix));
1969
+ if (line === undefined) {
1970
+ bad(at, `folds on ${m.frames.length} frame(s), and rigc printed no line beginning ${prefix.trim()}`);
1971
+ return;
1972
+ }
1973
+ rows.push({ slot: m.slot, attachment: m.attachment, triangleCount: m.triangleCount, frames: m.frames.length, worstFrame: worst.index, worstTriangles: worst.triangles.length, line });
1974
+ });
1975
+ out.set(name, rows);
1976
+ }
1977
+ refuseIfAny(problems);
1978
+ return out;
1979
+ }
1980
+
1981
+ /**
1982
+ * The fold line (issue #320) from the reading: every folding mesh a row, a
1983
+ * row no `may_fold` entry takes a `CHECK_ANIMATIONS_DO_NOT_FOLD` problem
1984
+ * naming the animation, the slot and rigc's line, and an entry no row
1985
+ * matches a problem of its own — a declaration that excepts nothing.
1986
+ */
1987
+ export function foldsLine(named: readonly string[], fps: number, physicsLeftOut: number, readings: ReadonlyMap<string, readonly FoldingMesh[]>, mayFold: readonly MayFold[], requirementsPath: string | null, problems: Problem[]): FoldsLine {
1988
+ const folding: FoldRow[] = [];
1989
+ for (const name of named) {
1990
+ for (const m of readings.get(name) ?? []) {
1991
+ const entry = mayFold.find((e) => e.slot === m.slot && e.animation === name);
1992
+ folding.push({ animation: name, slot: m.slot, attachment: m.attachment, frames: m.frames, worst_frame: m.worstFrame, worst_triangles: m.worstTriangles, triangles: m.triangleCount, declared: entry === undefined ? null : entry.why, line: m.line });
1993
+ }
1994
+ }
1995
+ const physics = physicsLeftOut === 0 ? '' : `, its ${physicsLeftOut} physics constraint(s) left out`;
1996
+ for (const r of folding) {
1997
+ if (r.declared !== null) continue;
1998
+ problems.push({
1999
+ code: `CHECK_${FOLD_LINE}`,
2000
+ object: `animation "${r.animation}", mesh "${r.attachment}" (slot "${r.slot}")`,
2001
+ detail: `rigc render --folds at ${fps} fps${physics}: ${r.line}; no triangle turned over against the setup pose is required — a fold the art means is declared in the --requirements file's may_fold, { "slot": "${r.slot}", "animation": "${r.animation}", "why": … }`,
2002
+ });
2003
+ }
2004
+ const declaredNotFolding = mayFold.filter((e) => !folding.some((r) => r.slot === e.slot && r.animation === e.animation)).map((e) => ({ slot: e.slot, animation: e.animation }));
2005
+ for (const e of declaredNotFolding) {
2006
+ problems.push({
2007
+ code: `CHECK_${FOLD_LINE}`,
2008
+ object: `${requirementsPath ?? 'the requirements file'} may_fold (slot "${e.slot}", animation "${e.animation}")`,
2009
+ detail: `declares a fold, and rigc render --folds at ${fps} fps${physics} reads no folded triangle on slot "${e.slot}" in animation "${e.animation}"; an exception that excepts nothing is taken out of the file`,
2010
+ });
2011
+ }
2012
+ const undeclared = folding.filter((r) => r.declared === null).length;
2013
+ return {
2014
+ status: undeclared === 0 && declaredNotFolding.length === 0 ? 'PASS' : 'FAIL',
2015
+ animations: [...named],
2016
+ fps,
2017
+ physics_left_out: physicsLeftOut,
2018
+ folding,
2019
+ folding_undeclared: undeclared,
2020
+ declared_not_folding: declaredNotFolding,
2021
+ bar: 0,
2022
+ };
1747
2023
  }
1748
2024
 
1749
2025
  /**
@@ -1755,8 +2031,9 @@ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[
1755
2031
  const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
1756
2032
  const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
1757
2033
  const object = `\`rigc ${what}\``;
1758
- if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing` }];
1759
- return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}` }));
2034
+ const ran = rigcRanClause(call);
2035
+ if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing${ran}` }];
2036
+ return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}${ran}` }));
1760
2037
  }
1761
2038
 
1762
2039
  /**
@@ -1830,7 +2107,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1830
2107
  const loopDir = join(out, '_loop');
1831
2108
  const seamDir = join(out, SEAM_DIR);
1832
2109
  const keptDir = join(out, STILL_DIR);
1833
- for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir, seamDir, keptDir]) rmSync(d, { recursive: true, force: true });
2110
+ const foldDir = join(out, FOLD_SCRATCH);
2111
+ for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir, seamDir, keptDir, foldDir]) rmSync(d, { recursive: true, force: true });
1834
2112
  const written: string[] = [];
1835
2113
  const write = (name: string, data: string): void => {
1836
2114
  writeFileSync(join(out, name), data);
@@ -1861,43 +2139,56 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1861
2139
  let loop: { max: number; x: number; y: number } | null = null;
1862
2140
  let loopSet: FrameSet | null = null;
1863
2141
  let loopPhysics: LoopPhysics[] | null = null;
1864
- if (inp.idleDuration !== null) {
1865
- const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
1866
- if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1867
- idle = readFrameSet(idleDir);
1868
- const last = idle.frames[idle.frames.length - 1];
1869
- // Issue #244: the last frame's time and the duration are compared within TIME_SLACK, not with `!==` — a whole
1870
- // number of frames (40/12 s) is not exact in binary, and rigc writes its last frame at 40 * (1/12), one ulp off.
1871
- if (idle.stride !== 1 || idle.written !== idle.sampled || Math.abs(idle.duration - inp.idleDuration) > TIME_SLACK || last.index !== idle.sampled - 1) {
1872
- refuseIfAny([
1873
- {
1874
- code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
1875
- object: `idle frame ${last.name}`,
1876
- detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks (within ${TIME_SLACK} s) and every frame written are required`,
1877
- },
1878
- ]);
1879
- }
1880
- const contact = join(idle.dir, 'contact.png');
1881
- if (existsSync(contact)) {
1882
- copyFileSync(contact, join(out, 'contact.png'));
1883
- written.push('contact.png');
1884
- }
1885
- loop = maxRgbDiff(idle.frames[0].image, last.image);
1886
- loopSet = idle;
1887
- // The keyed loop (issue #183): with a physics constraint, the same rig and motion with every one left out, built
1888
- // the same way and rendered the same way, is what frame 0 and the frame at the duration are read from.
1889
- const stripped = withoutPhysics(inp.rig, inp.motion);
1890
- if (stripped !== null) {
1891
- loopPhysics = stripped.physics;
1892
- try {
1893
- mkdirSync(loopDir, { recursive: true });
1894
- const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
1895
- writeFileSync(join(loopDir, 'rig.json'), JSON.stringify({ ...stripped.rig, images }));
1896
- writeFileSync(join(loopDir, 'motion.json'), JSON.stringify(stripped.motion));
2142
+ // The keyed build (issues #183, #320): with a physics constraint, the same rig and motion with every one left out,
2143
+ // built once the same way, which the loop and the folds of every animation are both read from.
2144
+ const stripped = withoutPhysics(inp.rig, inp.motion);
2145
+ let keyed: string | null = null;
2146
+ const keyedBuild = (what: string): string => {
2147
+ if (stripped === null) return buildDir;
2148
+ if (keyed !== null) return keyed;
2149
+ mkdirSync(loopDir, { recursive: true });
2150
+ const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
2151
+ writeFileSync(join(loopDir, 'rig.json'), JSON.stringify({ ...stripped.rig, images }));
2152
+ writeFileSync(join(loopDir, 'motion.json'), JSON.stringify(stripped.motion));
2153
+ const lb = rigc(['build', '--rig', join(loopDir, 'rig.json'), '--motion', join(loopDir, 'motion.json'), '--out', join(loopDir, 'build'), ...packedBuildArgs(mode)]);
2154
+ if (lb.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, lb, buildGateLines(lb.out)));
2155
+ keyed = join(loopDir, 'build');
2156
+ return keyed;
2157
+ };
2158
+ const animationNames = isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations) : [];
2159
+ const named = animationNames.filter((n) => n !== 'idle');
2160
+ let folds: FoldsLine | null = null;
2161
+ const foldProblems: Problem[] = [];
2162
+ try {
2163
+ if (inp.idleDuration !== null) {
2164
+ const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
2165
+ if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
2166
+ idle = readFrameSet(idleDir);
2167
+ const last = idle.frames[idle.frames.length - 1];
2168
+ // Issue #244: the last frame's time and the duration are compared within TIME_SLACK, not with `!==` — a whole
2169
+ // number of frames (40/12 s) is not exact in binary, and rigc writes its last frame at 40 * (1/12), one ulp off.
2170
+ if (idle.stride !== 1 || idle.written !== idle.sampled || Math.abs(idle.duration - inp.idleDuration) > TIME_SLACK || last.index !== idle.sampled - 1) {
2171
+ refuseIfAny([
2172
+ {
2173
+ code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
2174
+ object: `idle frame ${last.name}`,
2175
+ detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks (within ${TIME_SLACK} s) and every frame written are required`,
2176
+ },
2177
+ ]);
2178
+ }
2179
+ const contact = join(idle.dir, 'contact.png');
2180
+ if (existsSync(contact)) {
2181
+ copyFileSync(contact, join(out, 'contact.png'));
2182
+ written.push('contact.png');
2183
+ }
2184
+ loop = maxRgbDiff(idle.frames[0].image, last.image);
2185
+ loopSet = idle;
2186
+ // The keyed loop (issue #183): with a physics constraint, the same rig and motion with every one left out, built
2187
+ // the same way and rendered the same way, is what frame 0 and the frame at the duration are read from.
2188
+ if (stripped !== null) {
2189
+ loopPhysics = stripped.physics;
1897
2190
  const what = 'the idle with its physics constraint(s) left out';
1898
- const lb = rigc(['build', '--rig', join(loopDir, 'rig.json'), '--motion', join(loopDir, 'motion.json'), '--out', join(loopDir, 'build'), ...packedBuildArgs(mode)]);
1899
- if (lb.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, lb, buildGateLines(lb.out)));
1900
- const lr = rigc(['render', '--candidate', join(loopDir, 'build'), '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(loopDir, 'frames')]);
2191
+ const lr = rigc(['render', '--candidate', keyedBuild(what), '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(loopDir, 'frames')]);
1901
2192
  if (lr.status !== 0) refuseIfAny(rigcFailed(`render --animation idle (${what})`, lr, lr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1902
2193
  const kept = readFrameSet(join(loopDir, 'frames'));
1903
2194
  const keptLast = kept.frames[kept.frames.length - 1];
@@ -1912,13 +2203,27 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1912
2203
  }
1913
2204
  loop = maxRgbDiff(kept.frames[0].image, keptLast.image);
1914
2205
  loopSet = kept;
2206
+ }
2207
+ const idleImages = idle.frames.map((f) => f.image);
2208
+ writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
2209
+ written.push('motion_heat.png');
2210
+ }
2211
+
2212
+ // 3a. every animation, the idle included, read for folds by rigc (issues #320, #333) — only when there is one; at
2213
+ // the idle's rate whatever --requirements says, so a requirements file moves only what it declares (CK78), from the keyed build
2214
+ if (animationNames.length > 0) {
2215
+ const fps = IDLE_FPS;
2216
+ try {
2217
+ const fr = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
2218
+ if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds --fps ${fps}${stripped === null ? '' : ' (physics constraint(s) left out)'}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
2219
+ const readings = readFoldSets(foldDir, animationNames, fps, fr.out);
2220
+ folds = foldsLine(animationNames, fps, stripped === null ? 0 : stripped.physics.length, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems);
1915
2221
  } finally {
1916
- rmSync(loopDir, { recursive: true, force: true });
2222
+ rmSync(foldDir, { recursive: true, force: true });
1917
2223
  }
1918
2224
  }
1919
- const idleImages = idle.frames.map((f) => f.image);
1920
- writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
1921
- written.push('motion_heat.png');
2225
+ } finally {
2226
+ rmSync(loopDir, { recursive: true, force: true });
1922
2227
  }
1923
2228
 
1924
2229
  // 3b. the parts a judgement reads alone: each rendered by its own slots, on the whole rig's grid
@@ -2059,6 +2364,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2059
2364
  TIP_OVER_ROOT: tip,
2060
2365
  STILL_REGIONS_DARK: still,
2061
2366
  TEXTURE_STRETCH: stretch,
2367
+ ...(folds === null ? {} : { [FOLD_LINE]: folds }),
2062
2368
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
2063
2369
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
2064
2370
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
@@ -2090,7 +2396,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2090
2396
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
2091
2397
  problems.sort((a, b) => rank(a) - rank(b));
2092
2398
  problems.unshift(...barProblems);
2093
- problems.push(...reqProblems);
2399
+ problems.push(...foldProblems, ...reqProblems);
2094
2400
  figures.PASS = problems.length === 0;
2095
2401
  write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
2096
2402
  const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
@@ -2104,6 +2410,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2104
2410
  const l = figures[n];
2105
2411
  tally(n, l.status === 'SKIP' ? l.reason : null);
2106
2412
  }
2413
+ // Issues #320, #333: the fold bar is counted where it is written — on every rig whose motion holds an animation.
2414
+ if (folds !== null) tally(FOLD_LINE, null);
2107
2415
  return {
2108
2416
  figures,
2109
2417
  gateHtml,
@@ -2116,7 +2424,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2116
2424
  problems,
2117
2425
  written,
2118
2426
  requirements: reqBlock,
2119
- besideIdle: isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [],
2427
+ besideIdle: named,
2120
2428
  };
2121
2429
  }
2122
2430
 
@@ -2220,7 +2528,8 @@ export function measureRequirements(inp: CheckInputs, file: RequirementsFile, bu
2220
2528
  if (r.kind === 'contact') line = contactLine(r, d.poses, facts);
2221
2529
  else if (r.kind === 'aim') line = aimLine(r, d.poses, facts);
2222
2530
  else if (r.kind === 'range') line = rangeLine(r, d.poses, facts);
2223
- else if (r.kind === 'stretch') line = stretchRequirementLine(r, d.geo.meshes, d.geo.frames, d.poses);
2531
+ // Issue #321: the worst edge's vertices named as CHECK_TEXTURE_STRETCH names them (#314), off the rig this render posed.
2532
+ else if (r.kind === 'stretch') line = stretchRequirementLine(r, d.geo.meshes, d.geo.frames, d.poses, (edge) => edgeBonesText(scene.rig, scene.motion, r.slot, r.attachment, edge));
2224
2533
  else if (r.kind === 'seam') {
2225
2534
  const shown = attachmentPoses(d.path, d.poses.indices.length);
2226
2535
  const frames: SeamFrame[] = shown.map((f) => ({ part: f.get(r.part) ?? null, neighbour: f.get(r.neighbour) ?? null }));
@@ -2378,12 +2687,24 @@ function noneHeldText(declared: readonly string[]): string {
2378
2687
  * figure, `declared_not_planted`, so the line never reads as though the feet
2379
2688
  * it names were held still. With every foot declared, the torso is measured
2380
2689
  * and the feet figures are an empty list and null.
2690
+ *
2691
+ * Issue #323: the two halves are read apart. With no `topwear` part the feet
2692
+ * half is still measured, and with no `footwear` part the torso half is; the
2693
+ * half that was measured decides the line, and the line says so first —
2694
+ * `halves_measured` `1 of 2`, then each half's own status, the unmeasured one
2695
+ * `SKIP` with its reason — so a half nobody read never passes by being silent.
2696
+ * With neither, the line is the SKIP it was. A run that measured both halves
2697
+ * writes the keys it always wrote.
2381
2698
  */
2382
2699
  function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
2383
2700
  const torso = partsTagged(inp.parts, TORSO_TAGS);
2384
2701
  const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
2385
- 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`}`);
2386
- 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`);
2702
+ const noTorso = `no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`;
2703
+ const noFeet = `no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`;
2704
+ // Issue #323: a figure without one of the two halves still has the other read; with neither, the SKIP it always was.
2705
+ if (torso.length === 0 && feet.length === 0) return skip(`${noTorso}${declared.length === 0 ? '' : `; ${declared.map((n) => `"${n}"`).join(', ')} declared not planted`}`);
2706
+ if (torso.length === 0) return oneHalf(inp, isolated, problems, 'feet', feet, noTorso, declared);
2707
+ if (allFeet.length === 0) return oneHalf(inp, isolated, problems, 'torso', torso, noFeet, []);
2387
2708
  const H = inp.parts.rig_size[1];
2388
2709
  const ts = isolated('torso', torso);
2389
2710
  const fs = feet.length === 0 ? null : isolated('feet', feet);
@@ -2420,6 +2741,62 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], not
2420
2741
  };
2421
2742
  }
2422
2743
 
2744
+ /**
2745
+ * `BREATH_VISIBLE` with one half to read (issue #323): the torso with no
2746
+ * `footwear` part, or the feet with no `topwear` part. The half read decides
2747
+ * the line by the bar it always had, its figures under the names they always
2748
+ * had; the other half is `SKIP` with `reason`, and the line's first figure,
2749
+ * `halves_measured`, says `1 of 2` — after `declared_not_planted` when the scene
2750
+ * file declares some of the feet read (issue #231), which stays the first.
2751
+ */
2752
+ function oneHalf(inp: FullInputs, isolated: Isolate, problems: Problem[], half: 'torso' | 'feet', ps: readonly PartRecord[], reason: string, declared: readonly string[]): JudgementLine {
2753
+ const H = inp.parts.rig_size[1];
2754
+ const fs = isolated(half, ps);
2755
+ const box = frameBox(ps, H, inp.stage, fs.viewport);
2756
+ if (box === null) return skip(`the ${half} box falls outside the idle frame, and ${reason}`);
2757
+ const heat = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, box);
2758
+ if (half === 'torso') {
2759
+ const mean = round3(heat.mean);
2760
+ const ok = mean >= BREATH_TORSO_MEAN_FLOOR;
2761
+ if (!ok) {
2762
+ problems.push({
2763
+ code: 'CHECK_BREATH_VISIBLE',
2764
+ object: `torso ${quoteParts(ps)} rendered alone, frame box ${boxLabel(box)}; the feet half not measured: ${reason}`,
2765
+ 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`,
2766
+ });
2767
+ }
2768
+ return {
2769
+ status: ok ? 'PASS' : 'FAIL',
2770
+ halves_measured: '1 of 2',
2771
+ torso_status: ok ? 'PASS' : 'FAIL',
2772
+ torso_parts: ps.map((p) => p.name),
2773
+ torso_heat_mean: mean,
2774
+ torso_floor: BREATH_TORSO_MEAN_FLOOR,
2775
+ feet_status: 'SKIP',
2776
+ feet_reason: reason,
2777
+ };
2778
+ }
2779
+ const ok = heat.max <= BREATH_FEET_MAX_CEILING;
2780
+ if (!ok) {
2781
+ problems.push({
2782
+ code: 'CHECK_BREATH_VISIBLE',
2783
+ object: `feet ${quoteParts(ps)} rendered alone, frame box ${boxLabel(box)}${declared.length === 0 ? '' : ` (${declared.map((n) => `"${n}"`).join(', ')} declared not planted)`}; the torso half not measured: ${reason}`,
2784
+ detail: `heat max ${heat.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`,
2785
+ });
2786
+ }
2787
+ return {
2788
+ status: ok ? 'PASS' : 'FAIL',
2789
+ ...(declared.length === 0 ? {} : { declared_not_planted: [...declared] }),
2790
+ halves_measured: '1 of 2',
2791
+ torso_status: 'SKIP',
2792
+ torso_reason: reason,
2793
+ feet_status: ok ? 'PASS' : 'FAIL',
2794
+ feet_parts: ps.map((p) => p.name),
2795
+ feet_heat_max: heat.max,
2796
+ feet_ceiling: BREATH_FEET_MAX_CEILING,
2797
+ };
2798
+ }
2799
+
2423
2800
  /** The scene file's exceptions to `TIP_OVER_ROOT` (issues #231, #245), and the file they were read from, which a refusal names. */
2424
2801
  export interface TipExceptions {
2425
2802
  path: string;
@@ -2453,7 +2830,8 @@ export const TIP_INSTRUMENT = 'geometry';
2453
2830
  * saying so, and its `rises` parts are read with the halves swapped once the
2454
2831
  * chain carrying each is measured to rise ({@link chainDirection}; refused by
2455
2832
  * name otherwise); both lists are the line's first figures. Without them the
2456
- * line is the one written before.
2833
+ * line is the one written before. Issue #322: the chain is read at its last
2834
+ * link, and a declared row carries `tip_read`, the reading that let it rise.
2457
2835
  *
2458
2836
  * Issue #245: a part the scene declares `rests` — its tip at rest while its
2459
2837
  * root moves — is measured with the same halves and held to the opposite
@@ -2466,20 +2844,27 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2466
2844
  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`);
2467
2845
  const poses = attachmentPoses(geometryPath, frames);
2468
2846
  // Issue #231: a part declared `rises` is read up only when the chain carrying it does rise, measured at the setup pose.
2847
+ // Issue #322: at its last link, the end the tip half is the art of; the row says what was read.
2848
+ const risesRead = new Map<string, string>();
2469
2849
  if (excepted.rises.length > 0) {
2470
2850
  const geo = readGeometry(geometryPath, frames);
2471
- const origins = new Map<string, { x: number; y: number }>();
2851
+ const origins = new Map<string, SetupBone>();
2472
2852
  for (const b of Array.isArray(geo?.setupBones) ? geo.setupBones : []) {
2473
- 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 });
2853
+ if (isRecord(b) && typeof b.name === 'string' && typeof b.worldX === 'number' && typeof b.worldY === 'number') {
2854
+ origins.set(b.name, { x: b.worldX, y: b.worldY, ...(typeof b.a === 'number' && typeof b.c === 'number' ? { a: b.a, c: b.c } : {}) });
2855
+ }
2474
2856
  }
2475
2857
  const refused: Problem[] = [];
2476
2858
  excepted.rises.forEach((name, i) => {
2477
2859
  const dir = chainDirection(inp.rig, inp.motion, name, origins);
2478
- if ('toward' in dir && dir.toward === 'up') return;
2860
+ if ('toward' in dir && dir.toward === 'up') {
2861
+ risesRead.set(name, dir.chains.join('; '));
2862
+ return;
2863
+ }
2479
2864
  refused.push({
2480
2865
  code: 'REQUIREMENTS_TIP_EXCEPTION',
2481
2866
  object: `${excepted.path} field "rises"[${i}]`,
2482
- 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`,
2867
+ 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 runs up at the setup pose — from its origin along its length, or, a last link with no length, from the link before's origin to its own`,
2483
2868
  });
2484
2869
  });
2485
2870
  refuseIfAny(refused);
@@ -2499,7 +2884,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2499
2884
  const shown = poses.map((f) => f.get(p.name) ?? null);
2500
2885
  const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
2501
2886
  // A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
2502
- const said = { ...(rests ? { declared: 'rests' } : {}), ...(rises ? { tip_half: tipHalf } : {}) };
2887
+ const said = { ...(rests ? { declared: 'rests' } : {}), ...(rises ? { tip_half: tipHalf, tip_read: risesRead.get(p.name) as string } : {}) };
2503
2888
  if (t.root === null || t.tip === null) {
2504
2889
  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` });
2505
2890
  continue;
@@ -2645,34 +3030,65 @@ function breathCarrier(
2645
3030
  return null;
2646
3031
  }
2647
3032
 
3033
+ /** A bone at the setup pose off the idle's geometry: its world origin, and its world x axis (a, c) when the file gives it. */
3034
+ export interface SetupBone {
3035
+ x: number;
3036
+ y: number;
3037
+ a?: number;
3038
+ c?: number;
3039
+ }
3040
+
2648
3041
  /**
2649
- * Which way the chain carrying a part runs (issue #231), for a part the scene
2650
- * declares `rises`: the bones its attachments are bound to in the default skin
2651
- * (a mesh's weights, or its slot's bone); the leaves among them — those with
2652
- * no other of them below — walked up the bone tree to the first link of a
2653
- * chain `CHAIN_LAG` reads ({@link chainFigures}: two or more rotate-keyed
2654
- * links, each under the one before at one period); each chain so found runs
2655
- * from its first link's setup-pose origin to its last link's
2656
- * ({@link chainToward}). `{ none }`, the reason, when no leaf is under a chain,
2657
- * when a chain runs as far across as along, or when the part's chains run
3042
+ * Which way the chain carrying a part runs at its tip (issues #231, #322), for
3043
+ * a part the scene declares `rises`: the bones its attachments are bound to in
3044
+ * the default skin (a mesh's weights, or its slot's bone); the leaves among
3045
+ * them — those with no other of them below — walked up the bone tree to the
3046
+ * first link of a chain `CHAIN_LAG` reads ({@link chainFigures}: two or more
3047
+ * rotate-keyed links, each under the one before at one period). Each chain so
3048
+ * found is read at its **last link** ({@link chainToward}), the end
3049
+ * `TIP_OVER_ROOT`'s tip half is the art of: from the link's setup-pose origin
3050
+ * along its `length` in rig.json on its world x axis — the extent the rig
3051
+ * stage turns a link to (issue #73) — or, a last link with no length, from the
3052
+ * link before's origin to its own, the last extent the skeleton states. Until
3053
+ * #322 it was the first link's origin to the last's, which reads a chain that
3054
+ * hangs and then rises — a folded arm — as down, and on a two-link chain read
3055
+ * the first link only. `{ none }`, the reason, when no leaf is under a chain,
3056
+ * when a last link runs as far across as along, or when the part's chains run
2658
3057
  * different ways.
2659
3058
  */
2660
- 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 } {
3059
+ export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, SetupBone>): { toward: Toward; chains: string[] } | { none: string } {
2661
3060
  const { found, bound } = chainsCarrying(rig, motion, part);
2662
3061
  const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
2663
3062
  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` };
3063
+ const lengths = new Map<string, number>();
3064
+ for (const b of (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord)) if (typeof b.name === 'string' && typeof b.length === 'number' && b.length > 0) lengths.set(b.name, b.length);
2664
3065
  const read = found.map((c) => {
2665
- const a = origins.get(c[0]);
2666
- const z = origins.get(c[c.length - 1]);
3066
+ const last = c[c.length - 1];
3067
+ const before = c[c.length - 2];
2667
3068
  const label = c.join(' > ');
2668
- 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` };
2669
- const dx = z.x - a.x;
2670
- const dy = z.y - a.y;
3069
+ const z = origins.get(last);
3070
+ const len = lengths.get(last);
3071
+ if (z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${last} in the idle's geometry` };
3072
+ let dx: number;
3073
+ let dy: number;
3074
+ let how: string;
3075
+ if (len !== undefined) {
3076
+ if (z.a === undefined || z.c === undefined) return { label, toward: null, text: `${label}: no setup-pose axis (a, c) for its last link ${last} in the idle's geometry` };
3077
+ dx = len * z.a;
3078
+ dy = len * z.c;
3079
+ how = `its last link ${last}, from its origin along its length ${len},`;
3080
+ } else {
3081
+ const a = origins.get(before);
3082
+ if (a === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${before} in the idle's geometry` };
3083
+ dx = z.x - a.x;
3084
+ dy = z.y - a.y;
3085
+ how = `its last link ${last}, which has no length, from ${before}'s origin to its own,`;
3086
+ }
2671
3087
  const toward = chainToward(dx, dy);
2672
- return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
3088
+ return { label, toward, text: `${label}: ${how} ${toward === null ? 'runs as far across as along' : `runs ${toward}`} (${round3(dx)}, ${round3(dy)} world px)` };
2673
3089
  });
2674
3090
  const ways = [...new Set(read.map((r) => r.toward))];
2675
- if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
3091
+ if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way at the tip: ${read.map((r) => r.text).join('; ')}` };
2676
3092
  return { toward: ways[0], chains: read.map((r) => r.text) };
2677
3093
  }
2678
3094
 
@@ -3171,7 +3587,7 @@ export function requireRigcVersion(rigc: RigcRunner): string {
3171
3587
  {
3172
3588
  code: 'CHECK_RIGC_VERSION',
3173
3589
  object: '`rigc --version`',
3174
- detail: `${got === null ? `exit ${v.status}, printed ${JSON.stringify(v.out.trim().split('\n')[0] ?? '')} — no version` : `is ${got.join('.')}`}; rig-c ${RIGC_GEOMETRY_VERSION} or later is required — its \`render --geometry\` writes the skinned mesh vertices TEXTURE_STRETCH is measured from (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
3590
+ detail: `${got === null ? `exit ${v.status}, printed ${JSON.stringify(v.out.trim().split('\n')[0] ?? '')} — no version` : `is ${got.join('.')}`}${rigcRanClause(v)}; rig-c ${RIGC_GEOMETRY_VERSION} or later is required — its \`render --geometry\` writes the skinned mesh vertices TEXTURE_STRETCH is measured from (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
3175
3591
  },
3176
3592
  ]);
3177
3593
  }