@rig-c/parts 1.13.0 → 1.14.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 beside the idle,
60
+ * one more bar, `ANIMATIONS_DO_NOT_FOLD` (issue #320): rigc's own
61
+ * `render --folds` over every animation, 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,
@@ -1642,6 +1646,8 @@ export interface CheckFigures {
1642
1646
  TIP_OVER_ROOT: JudgementLine;
1643
1647
  STILL_REGIONS_DARK: JudgementLine;
1644
1648
  TEXTURE_STRETCH: JudgementLine;
1649
+ /** Written only when motion.json holds an animation beside the idle (issue #320): {@link FoldsLine}. */
1650
+ [FOLD_LINE]?: FoldsLine;
1645
1651
  RECOMPOSITE_HOLES: ReportedLine;
1646
1652
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1647
1653
  SETUP_POSE_VS_SOURCE?: ReportedLine;
@@ -1665,22 +1671,46 @@ export interface RequirementsBlock {
1665
1671
  lines: Record<string, RequirementLine>;
1666
1672
  }
1667
1673
 
1674
+ /**
1675
+ * The bar over the animations beside the idle (issue #320): no mesh triangle
1676
+ * turned over against the setup pose on any frame, on rig-c's own reading —
1677
+ * `rigc render --folds`, one run over every animation the build holds — and
1678
+ * not a reading of this package's. A fold the art means is declared in the
1679
+ * requirements file's `may_fold`, by slot and animation with its why, and is
1680
+ * named and not held to the bar.
1681
+ *
1682
+ * ⭐ **Read with the physics constraints left out**, as the loop is, from the
1683
+ * same build ({@link withoutPhysics}). rig-c's render steps physics by 1/fps
1684
+ * from frame 0, so a fold a physics constraint makes is the sampling rate's:
1685
+ * on the public `scarf` example `hop` folds `bottomwear` (3 of 308 triangles,
1686
+ * frames 7-8) at 12 fps, and at none of 10, 15, 24, 30, 36, 48 and 60 fps;
1687
+ * `hair_front` at 6 fps; and nothing at 6, 12 or 24 fps with its 30 physics
1688
+ * constraints left out [measured for issue #320, rigc 2.47.1, a scratch run,
1689
+ * not tracked]. The keys and weights fold at every rate or at none that the
1690
+ * sampling resolves; the rate a consumer steps physics at is the scene's.
1691
+ *
1692
+ * The idle is not read here: written only when the motion holds an animation
1693
+ * beside it, so a rig of the idle alone writes and prints what it did.
1694
+ */
1695
+ export const FOLD_LINE = 'ANIMATIONS_DO_NOT_FOLD';
1696
+
1668
1697
  /** The bars `PASS` reads, in the order the summary counts them: the gate, the reference's two, then the judgement lines. */
1669
1698
  export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
1670
1699
 
1671
1700
  /**
1672
1701
  * The bars a character's own check may have failed and `compose --accept`
1673
1702
  * still take (issue #203): every bar of {@link BARS} but the gate, which is
1674
- * rig-c's and never accepted.
1703
+ * rig-c's and never accepted, and {@link FOLD_LINE} (issue #320).
1675
1704
  */
1676
- export const ACCEPTABLE_BARS: readonly string[] = BARS.filter((b) => b !== 'gate');
1705
+ export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== 'gate'), FOLD_LINE];
1677
1706
 
1678
1707
  /**
1679
1708
  * The bars a `check.json`, as `check` writes it, says were not met, in
1680
1709
  * {@link BARS} order: the gate when `gate_spine_html_green` is not true, the
1681
1710
  * loop when `loop_max_diff` is a number other than {@link LOOP_MAX_BAR}, the
1682
1711
  * 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
1712
+ * status is `FAIL`, then {@link FOLD_LINE} when it is written and `FAIL`
1713
+ * (issue #320). A bar that measured nothing (null figures, `SKIP`) is not
1684
1714
  * one of them. Read off the file rather than its problems, because the file is
1685
1715
  * all a finished build leaves (issue #203).
1686
1716
  */
@@ -1691,7 +1721,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1691
1721
  // 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
1722
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1693
1723
  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) {
1724
+ for (const n of [...JUDGEMENT_LINES, FOLD_LINE]) {
1695
1725
  const l = fig[n];
1696
1726
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
1697
1727
  }
@@ -1743,7 +1773,179 @@ export interface CheckReport {
1743
1773
  */
1744
1774
  export function besideIdleLine(names: readonly string[]): string | null {
1745
1775
  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`;
1776
+ 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`;
1777
+ }
1778
+
1779
+ // ---------------------------------------------------------------------------
1780
+ // the animations beside the idle, read for folds (issue #320)
1781
+ // ---------------------------------------------------------------------------
1782
+
1783
+ /**
1784
+ * The fold render's `--max`, rigc's smallest: `--folds` reads posed vertices
1785
+ * in world units, which the frame size does not change (rig-c AUTHORING §8.3),
1786
+ * so the frames are drawn as small as rigc draws them and thrown away. On
1787
+ * `scarf` the `hop` folds.json was byte-identical at `--max` 16 and 640
1788
+ * [measured for issue #320, a scratch run].
1789
+ */
1790
+ export const FOLD_RENDER_MAX = 16;
1791
+
1792
+ /** The fold render, under `--out`; removed before the stage returns. */
1793
+ const FOLD_SCRATCH = '_folds';
1794
+
1795
+ /** One mesh that folds in one animation beside the idle: rigc's figures, the declaration that takes it if any, and rigc's own line. */
1796
+ export interface FoldRow {
1797
+ animation: string;
1798
+ slot: string;
1799
+ attachment: string;
1800
+ /** How many frames show a folded triangle. */
1801
+ frames: number;
1802
+ /** The frame with the most, the earliest on a tie (rigc's `worst`). */
1803
+ worst_frame: number;
1804
+ /** How many triangles are folded on that frame. */
1805
+ worst_triangles: number;
1806
+ /** How many triangles the mesh has. */
1807
+ triangles: number;
1808
+ /** The `may_fold` entry's why when the requirements file declares this slot and animation; null when nothing does. */
1809
+ declared: string | null;
1810
+ /** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
1811
+ line: string;
1812
+ }
1813
+
1814
+ /** `check.json`'s `ANIMATIONS_DO_NOT_FOLD`: written only when the motion holds an animation beside the idle. */
1815
+ export interface FoldsLine {
1816
+ status: 'PASS' | 'FAIL';
1817
+ /** Every animation beside the idle, in motion.json's order: each one read. */
1818
+ animations: string[];
1819
+ fps: number;
1820
+ /** How many physics constraints the read build left out ({@link withoutPhysics}); 0 when the rig has none. */
1821
+ physics_left_out: number;
1822
+ /** Every mesh that folds in an animation read, in that animation's order and rigc's mesh order. */
1823
+ folding: FoldRow[];
1824
+ /** The rows no `may_fold` entry takes: the bar's figure. */
1825
+ folding_undeclared: number;
1826
+ /** The `may_fold` entries no row matches, each a FAIL — an exception that excepts nothing. */
1827
+ declared_not_folding: Array<{ slot: string; animation: string }>;
1828
+ /** The bar: undeclared folds, at most this many. */
1829
+ bar: number;
1830
+ }
1831
+
1832
+ /** One folding mesh as the reading states it. */
1833
+ export interface FoldingMesh {
1834
+ slot: string;
1835
+ attachment: string;
1836
+ triangleCount: number;
1837
+ frames: number;
1838
+ worstFrame: number;
1839
+ worstTriangles: number;
1840
+ line: string;
1841
+ }
1842
+
1843
+ /** The marker rigc prints before each fold line (`rigc render --folds`). */
1844
+ const FOLD_PRINTED = /^\s*\.\.\s+folds\s+(.*)$/;
1845
+
1846
+ /**
1847
+ * The folding meshes of each named animation, read off a `rigc render
1848
+ * --folds` run over every animation: `frames.json` names each set's
1849
+ * directory, each `folds.json` (`rigc-folds/1`) its meshes, and each folding
1850
+ * mesh's line is the one rigc printed for it. Refuses (`CHECK_FOLDS_READ`),
1851
+ * every problem at once, a set or file that is missing, a file that is not
1852
+ * the animation's or not at the rate asked, a field of the wrong shape, and a
1853
+ * folding mesh rigc printed no line for.
1854
+ */
1855
+ export function readFoldSets(dir: string, named: readonly string[], fps: number, printed: string): Map<string, FoldingMesh[]> {
1856
+ const problems: Problem[] = [];
1857
+ const bad = (object: string, detail: string): void => {
1858
+ problems.push({ code: 'CHECK_FOLDS_READ', object, detail });
1859
+ };
1860
+ const lines = printed.split('\n').map((l) => FOLD_PRINTED.exec(l)?.[1]).filter((l): l is string => l !== undefined);
1861
+ const out = new Map<string, FoldingMesh[]>();
1862
+ const sidecar = readJson(join(dir, 'frames.json'), problems);
1863
+ const sets = sidecar !== null && Array.isArray(sidecar.sets) ? sidecar.sets.filter(isRecord) : [];
1864
+ for (const name of named) {
1865
+ const set = sets.find((x) => x.animation === name);
1866
+ if (set === undefined || typeof set.dir !== 'string') {
1867
+ bad(join(dir, 'frames.json'), `names no frame set for animation ${JSON.stringify(name)}; \`rigc render --folds\` over the build writes one per animation`);
1868
+ continue;
1869
+ }
1870
+ const path = join(dir, set.dir, 'folds.json');
1871
+ const file = readJson(path, problems);
1872
+ if (file === null) continue;
1873
+ if (file.spec !== 'rigc-folds/1') bad(path, `spec is ${JSON.stringify(file.spec)}; "rigc-folds/1" is required`);
1874
+ if (file.animation !== name) bad(path, `animation is ${JSON.stringify(file.animation)}; ${JSON.stringify(name)} is required`);
1875
+ if (file.fps !== fps) bad(path, `fps is ${JSON.stringify(file.fps)}; ${fps}, the rate asked for, is required`);
1876
+ if (!Array.isArray(file.meshes)) {
1877
+ bad(path, `meshes is ${JSON.stringify(file.meshes)?.slice(0, 40)}; an array is required`);
1878
+ continue;
1879
+ }
1880
+ const rows: FoldingMesh[] = [];
1881
+ file.meshes.forEach((m, i) => {
1882
+ const at = `${path} meshes[${i}]`;
1883
+ if (!isRecord(m) || typeof m.slot !== 'string' || typeof m.attachment !== 'string' || typeof m.triangleCount !== 'number' || !Array.isArray(m.frames)) {
1884
+ bad(at, 'is not { slot, attachment, triangleCount, frames, worst } as rigc-folds/1 writes it');
1885
+ return;
1886
+ }
1887
+ if (m.worst === null) return;
1888
+ const worst = isRecord(m.worst) ? m.worst : null;
1889
+ if (worst === null || typeof worst.index !== 'number' || !Array.isArray(worst.triangles)) {
1890
+ bad(at, `worst is ${JSON.stringify(m.worst)?.slice(0, 40)}; null or { index, triangles, … } is required`);
1891
+ return;
1892
+ }
1893
+ const prefix = `${JSON.stringify(name)}: mesh "${m.attachment}" (slot "${m.slot}") folds on `;
1894
+ const line = lines.find((l) => l.startsWith(prefix));
1895
+ if (line === undefined) {
1896
+ bad(at, `folds on ${m.frames.length} frame(s), and rigc printed no line beginning ${prefix.trim()}`);
1897
+ return;
1898
+ }
1899
+ rows.push({ slot: m.slot, attachment: m.attachment, triangleCount: m.triangleCount, frames: m.frames.length, worstFrame: worst.index, worstTriangles: worst.triangles.length, line });
1900
+ });
1901
+ out.set(name, rows);
1902
+ }
1903
+ refuseIfAny(problems);
1904
+ return out;
1905
+ }
1906
+
1907
+ /**
1908
+ * The fold line (issue #320) from the reading: every folding mesh a row, a
1909
+ * row no `may_fold` entry takes a `CHECK_ANIMATIONS_DO_NOT_FOLD` problem
1910
+ * naming the animation, the slot and rigc's line, and an entry no row
1911
+ * matches a problem of its own — a declaration that excepts nothing.
1912
+ */
1913
+ export function foldsLine(named: readonly string[], fps: number, physicsLeftOut: number, readings: ReadonlyMap<string, readonly FoldingMesh[]>, mayFold: readonly MayFold[], requirementsPath: string | null, problems: Problem[]): FoldsLine {
1914
+ const folding: FoldRow[] = [];
1915
+ for (const name of named) {
1916
+ for (const m of readings.get(name) ?? []) {
1917
+ const entry = mayFold.find((e) => e.slot === m.slot && e.animation === name);
1918
+ 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 });
1919
+ }
1920
+ }
1921
+ const physics = physicsLeftOut === 0 ? '' : `, its ${physicsLeftOut} physics constraint(s) left out`;
1922
+ for (const r of folding) {
1923
+ if (r.declared !== null) continue;
1924
+ problems.push({
1925
+ code: `CHECK_${FOLD_LINE}`,
1926
+ object: `animation "${r.animation}", mesh "${r.attachment}" (slot "${r.slot}")`,
1927
+ 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": … }`,
1928
+ });
1929
+ }
1930
+ const declaredNotFolding = mayFold.filter((e) => !folding.some((r) => r.slot === e.slot && r.animation === e.animation)).map((e) => ({ slot: e.slot, animation: e.animation }));
1931
+ for (const e of declaredNotFolding) {
1932
+ problems.push({
1933
+ code: `CHECK_${FOLD_LINE}`,
1934
+ object: `${requirementsPath ?? 'the requirements file'} may_fold (slot "${e.slot}", animation "${e.animation}")`,
1935
+ 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`,
1936
+ });
1937
+ }
1938
+ const undeclared = folding.filter((r) => r.declared === null).length;
1939
+ return {
1940
+ status: undeclared === 0 && declaredNotFolding.length === 0 ? 'PASS' : 'FAIL',
1941
+ animations: [...named],
1942
+ fps,
1943
+ physics_left_out: physicsLeftOut,
1944
+ folding,
1945
+ folding_undeclared: undeclared,
1946
+ declared_not_folding: declaredNotFolding,
1947
+ bar: 0,
1948
+ };
1747
1949
  }
1748
1950
 
1749
1951
  /**
@@ -1830,7 +2032,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1830
2032
  const loopDir = join(out, '_loop');
1831
2033
  const seamDir = join(out, SEAM_DIR);
1832
2034
  const keptDir = join(out, STILL_DIR);
1833
- for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir, seamDir, keptDir]) rmSync(d, { recursive: true, force: true });
2035
+ const foldDir = join(out, FOLD_SCRATCH);
2036
+ for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir, seamDir, keptDir, foldDir]) rmSync(d, { recursive: true, force: true });
1834
2037
  const written: string[] = [];
1835
2038
  const write = (name: string, data: string): void => {
1836
2039
  writeFileSync(join(out, name), data);
@@ -1861,43 +2064,55 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1861
2064
  let loop: { max: number; x: number; y: number } | null = null;
1862
2065
  let loopSet: FrameSet | null = null;
1863
2066
  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));
2067
+ // The keyed build (issues #183, #320): with a physics constraint, the same rig and motion with every one left out,
2068
+ // built once the same way, which the loop and the folds of the animations beside the idle are both read from.
2069
+ const stripped = withoutPhysics(inp.rig, inp.motion);
2070
+ let keyed: string | null = null;
2071
+ const keyedBuild = (what: string): string => {
2072
+ if (stripped === null) return buildDir;
2073
+ if (keyed !== null) return keyed;
2074
+ mkdirSync(loopDir, { recursive: true });
2075
+ const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
2076
+ writeFileSync(join(loopDir, 'rig.json'), JSON.stringify({ ...stripped.rig, images }));
2077
+ writeFileSync(join(loopDir, 'motion.json'), JSON.stringify(stripped.motion));
2078
+ const lb = rigc(['build', '--rig', join(loopDir, 'rig.json'), '--motion', join(loopDir, 'motion.json'), '--out', join(loopDir, 'build'), ...packedBuildArgs(mode)]);
2079
+ if (lb.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, lb, buildGateLines(lb.out)));
2080
+ keyed = join(loopDir, 'build');
2081
+ return keyed;
2082
+ };
2083
+ const named = isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [];
2084
+ let folds: FoldsLine | null = null;
2085
+ const foldProblems: Problem[] = [];
2086
+ try {
2087
+ if (inp.idleDuration !== null) {
2088
+ const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
2089
+ if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
2090
+ idle = readFrameSet(idleDir);
2091
+ const last = idle.frames[idle.frames.length - 1];
2092
+ // Issue #244: the last frame's time and the duration are compared within TIME_SLACK, not with `!==` — a whole
2093
+ // number of frames (40/12 s) is not exact in binary, and rigc writes its last frame at 40 * (1/12), one ulp off.
2094
+ if (idle.stride !== 1 || idle.written !== idle.sampled || Math.abs(idle.duration - inp.idleDuration) > TIME_SLACK || last.index !== idle.sampled - 1) {
2095
+ refuseIfAny([
2096
+ {
2097
+ code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
2098
+ object: `idle frame ${last.name}`,
2099
+ 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`,
2100
+ },
2101
+ ]);
2102
+ }
2103
+ const contact = join(idle.dir, 'contact.png');
2104
+ if (existsSync(contact)) {
2105
+ copyFileSync(contact, join(out, 'contact.png'));
2106
+ written.push('contact.png');
2107
+ }
2108
+ loop = maxRgbDiff(idle.frames[0].image, last.image);
2109
+ loopSet = idle;
2110
+ // The keyed loop (issue #183): with a physics constraint, the same rig and motion with every one left out, built
2111
+ // the same way and rendered the same way, is what frame 0 and the frame at the duration are read from.
2112
+ if (stripped !== null) {
2113
+ loopPhysics = stripped.physics;
1897
2114
  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')]);
2115
+ const lr = rigc(['render', '--candidate', keyedBuild(what), '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(loopDir, 'frames')]);
1901
2116
  if (lr.status !== 0) refuseIfAny(rigcFailed(`render --animation idle (${what})`, lr, lr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1902
2117
  const kept = readFrameSet(join(loopDir, 'frames'));
1903
2118
  const keptLast = kept.frames[kept.frames.length - 1];
@@ -1912,13 +2127,27 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1912
2127
  }
1913
2128
  loop = maxRgbDiff(kept.frames[0].image, keptLast.image);
1914
2129
  loopSet = kept;
2130
+ }
2131
+ const idleImages = idle.frames.map((f) => f.image);
2132
+ writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
2133
+ written.push('motion_heat.png');
2134
+ }
2135
+
2136
+ // 3a. the animations beside the idle, read for folds by rigc (issue #320) — only when there is one; at the idle's
2137
+ // rate whatever --requirements says, so a requirements file moves only what it declares (CK78), from the keyed build
2138
+ if (named.length > 0) {
2139
+ const fps = IDLE_FPS;
2140
+ try {
2141
+ const fr = rigc(['render', '--candidate', keyedBuild('the animations beside the idle with their physics constraint(s) left out'), '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
2142
+ 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:'))));
2143
+ const readings = readFoldSets(foldDir, named, fps, fr.out);
2144
+ folds = foldsLine(named, fps, stripped === null ? 0 : stripped.physics.length, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems);
1915
2145
  } finally {
1916
- rmSync(loopDir, { recursive: true, force: true });
2146
+ rmSync(foldDir, { recursive: true, force: true });
1917
2147
  }
1918
2148
  }
1919
- const idleImages = idle.frames.map((f) => f.image);
1920
- writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
1921
- written.push('motion_heat.png');
2149
+ } finally {
2150
+ rmSync(loopDir, { recursive: true, force: true });
1922
2151
  }
1923
2152
 
1924
2153
  // 3b. the parts a judgement reads alone: each rendered by its own slots, on the whole rig's grid
@@ -2059,6 +2288,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2059
2288
  TIP_OVER_ROOT: tip,
2060
2289
  STILL_REGIONS_DARK: still,
2061
2290
  TEXTURE_STRETCH: stretch,
2291
+ ...(folds === null ? {} : { [FOLD_LINE]: folds }),
2062
2292
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
2063
2293
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
2064
2294
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
@@ -2090,7 +2320,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2090
2320
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
2091
2321
  problems.sort((a, b) => rank(a) - rank(b));
2092
2322
  problems.unshift(...barProblems);
2093
- problems.push(...reqProblems);
2323
+ problems.push(...foldProblems, ...reqProblems);
2094
2324
  figures.PASS = problems.length === 0;
2095
2325
  write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
2096
2326
  const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
@@ -2104,6 +2334,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2104
2334
  const l = figures[n];
2105
2335
  tally(n, l.status === 'SKIP' ? l.reason : null);
2106
2336
  }
2337
+ // Issue #320: the fold bar is counted only where it is written, so a rig of the idle alone counts what it counted.
2338
+ if (folds !== null) tally(FOLD_LINE, null);
2107
2339
  return {
2108
2340
  figures,
2109
2341
  gateHtml,
@@ -2116,7 +2348,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
2116
2348
  problems,
2117
2349
  written,
2118
2350
  requirements: reqBlock,
2119
- besideIdle: isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [],
2351
+ besideIdle: named,
2120
2352
  };
2121
2353
  }
2122
2354
 
@@ -2220,7 +2452,8 @@ export function measureRequirements(inp: CheckInputs, file: RequirementsFile, bu
2220
2452
  if (r.kind === 'contact') line = contactLine(r, d.poses, facts);
2221
2453
  else if (r.kind === 'aim') line = aimLine(r, d.poses, facts);
2222
2454
  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);
2455
+ // Issue #321: the worst edge's vertices named as CHECK_TEXTURE_STRETCH names them (#314), off the rig this render posed.
2456
+ 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
2457
  else if (r.kind === 'seam') {
2225
2458
  const shown = attachmentPoses(d.path, d.poses.indices.length);
2226
2459
  const frames: SeamFrame[] = shown.map((f) => ({ part: f.get(r.part) ?? null, neighbour: f.get(r.neighbour) ?? null }));
@@ -2378,12 +2611,24 @@ function noneHeldText(declared: readonly string[]): string {
2378
2611
  * figure, `declared_not_planted`, so the line never reads as though the feet
2379
2612
  * it names were held still. With every foot declared, the torso is measured
2380
2613
  * and the feet figures are an empty list and null.
2614
+ *
2615
+ * Issue #323: the two halves are read apart. With no `topwear` part the feet
2616
+ * half is still measured, and with no `footwear` part the torso half is; the
2617
+ * half that was measured decides the line, and the line says so first —
2618
+ * `halves_measured` `1 of 2`, then each half's own status, the unmeasured one
2619
+ * `SKIP` with its reason — so a half nobody read never passes by being silent.
2620
+ * With neither, the line is the SKIP it was. A run that measured both halves
2621
+ * writes the keys it always wrote.
2381
2622
  */
2382
2623
  function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
2383
2624
  const torso = partsTagged(inp.parts, TORSO_TAGS);
2384
2625
  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`);
2626
+ const noTorso = `no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`;
2627
+ const noFeet = `no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`;
2628
+ // Issue #323: a figure without one of the two halves still has the other read; with neither, the SKIP it always was.
2629
+ if (torso.length === 0 && feet.length === 0) return skip(`${noTorso}${declared.length === 0 ? '' : `; ${declared.map((n) => `"${n}"`).join(', ')} declared not planted`}`);
2630
+ if (torso.length === 0) return oneHalf(inp, isolated, problems, 'feet', feet, noTorso, declared);
2631
+ if (allFeet.length === 0) return oneHalf(inp, isolated, problems, 'torso', torso, noFeet, []);
2387
2632
  const H = inp.parts.rig_size[1];
2388
2633
  const ts = isolated('torso', torso);
2389
2634
  const fs = feet.length === 0 ? null : isolated('feet', feet);
@@ -2420,6 +2665,62 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], not
2420
2665
  };
2421
2666
  }
2422
2667
 
2668
+ /**
2669
+ * `BREATH_VISIBLE` with one half to read (issue #323): the torso with no
2670
+ * `footwear` part, or the feet with no `topwear` part. The half read decides
2671
+ * the line by the bar it always had, its figures under the names they always
2672
+ * had; the other half is `SKIP` with `reason`, and the line's first figure,
2673
+ * `halves_measured`, says `1 of 2` — after `declared_not_planted` when the scene
2674
+ * file declares some of the feet read (issue #231), which stays the first.
2675
+ */
2676
+ function oneHalf(inp: FullInputs, isolated: Isolate, problems: Problem[], half: 'torso' | 'feet', ps: readonly PartRecord[], reason: string, declared: readonly string[]): JudgementLine {
2677
+ const H = inp.parts.rig_size[1];
2678
+ const fs = isolated(half, ps);
2679
+ const box = frameBox(ps, H, inp.stage, fs.viewport);
2680
+ if (box === null) return skip(`the ${half} box falls outside the idle frame, and ${reason}`);
2681
+ const heat = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, box);
2682
+ if (half === 'torso') {
2683
+ const mean = round3(heat.mean);
2684
+ const ok = mean >= BREATH_TORSO_MEAN_FLOOR;
2685
+ if (!ok) {
2686
+ problems.push({
2687
+ code: 'CHECK_BREATH_VISIBLE',
2688
+ object: `torso ${quoteParts(ps)} rendered alone, frame box ${boxLabel(box)}; the feet half not measured: ${reason}`,
2689
+ 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`,
2690
+ });
2691
+ }
2692
+ return {
2693
+ status: ok ? 'PASS' : 'FAIL',
2694
+ halves_measured: '1 of 2',
2695
+ torso_status: ok ? 'PASS' : 'FAIL',
2696
+ torso_parts: ps.map((p) => p.name),
2697
+ torso_heat_mean: mean,
2698
+ torso_floor: BREATH_TORSO_MEAN_FLOOR,
2699
+ feet_status: 'SKIP',
2700
+ feet_reason: reason,
2701
+ };
2702
+ }
2703
+ const ok = heat.max <= BREATH_FEET_MAX_CEILING;
2704
+ if (!ok) {
2705
+ problems.push({
2706
+ code: 'CHECK_BREATH_VISIBLE',
2707
+ 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}`,
2708
+ 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`,
2709
+ });
2710
+ }
2711
+ return {
2712
+ status: ok ? 'PASS' : 'FAIL',
2713
+ ...(declared.length === 0 ? {} : { declared_not_planted: [...declared] }),
2714
+ halves_measured: '1 of 2',
2715
+ torso_status: 'SKIP',
2716
+ torso_reason: reason,
2717
+ feet_status: ok ? 'PASS' : 'FAIL',
2718
+ feet_parts: ps.map((p) => p.name),
2719
+ feet_heat_max: heat.max,
2720
+ feet_ceiling: BREATH_FEET_MAX_CEILING,
2721
+ };
2722
+ }
2723
+
2423
2724
  /** The scene file's exceptions to `TIP_OVER_ROOT` (issues #231, #245), and the file they were read from, which a refusal names. */
2424
2725
  export interface TipExceptions {
2425
2726
  path: string;
@@ -2453,7 +2754,8 @@ export const TIP_INSTRUMENT = 'geometry';
2453
2754
  * saying so, and its `rises` parts are read with the halves swapped once the
2454
2755
  * chain carrying each is measured to rise ({@link chainDirection}; refused by
2455
2756
  * name otherwise); both lists are the line's first figures. Without them the
2456
- * line is the one written before.
2757
+ * line is the one written before. Issue #322: the chain is read at its last
2758
+ * link, and a declared row carries `tip_read`, the reading that let it rise.
2457
2759
  *
2458
2760
  * Issue #245: a part the scene declares `rests` — its tip at rest while its
2459
2761
  * root moves — is measured with the same halves and held to the opposite
@@ -2466,20 +2768,27 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2466
2768
  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
2769
  const poses = attachmentPoses(geometryPath, frames);
2468
2770
  // Issue #231: a part declared `rises` is read up only when the chain carrying it does rise, measured at the setup pose.
2771
+ // Issue #322: at its last link, the end the tip half is the art of; the row says what was read.
2772
+ const risesRead = new Map<string, string>();
2469
2773
  if (excepted.rises.length > 0) {
2470
2774
  const geo = readGeometry(geometryPath, frames);
2471
- const origins = new Map<string, { x: number; y: number }>();
2775
+ const origins = new Map<string, SetupBone>();
2472
2776
  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 });
2777
+ if (isRecord(b) && typeof b.name === 'string' && typeof b.worldX === 'number' && typeof b.worldY === 'number') {
2778
+ origins.set(b.name, { x: b.worldX, y: b.worldY, ...(typeof b.a === 'number' && typeof b.c === 'number' ? { a: b.a, c: b.c } : {}) });
2779
+ }
2474
2780
  }
2475
2781
  const refused: Problem[] = [];
2476
2782
  excepted.rises.forEach((name, i) => {
2477
2783
  const dir = chainDirection(inp.rig, inp.motion, name, origins);
2478
- if ('toward' in dir && dir.toward === 'up') return;
2784
+ if ('toward' in dir && dir.toward === 'up') {
2785
+ risesRead.set(name, dir.chains.join('; '));
2786
+ return;
2787
+ }
2479
2788
  refused.push({
2480
2789
  code: 'REQUIREMENTS_TIP_EXCEPTION',
2481
2790
  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`,
2791
+ 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
2792
  });
2484
2793
  });
2485
2794
  refuseIfAny(refused);
@@ -2499,7 +2808,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2499
2808
  const shown = poses.map((f) => f.get(p.name) ?? null);
2500
2809
  const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
2501
2810
  // 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 } : {}) };
2811
+ const said = { ...(rests ? { declared: 'rests' } : {}), ...(rises ? { tip_half: tipHalf, tip_read: risesRead.get(p.name) as string } : {}) };
2503
2812
  if (t.root === null || t.tip === null) {
2504
2813
  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
2814
  continue;
@@ -2645,34 +2954,65 @@ function breathCarrier(
2645
2954
  return null;
2646
2955
  }
2647
2956
 
2957
+ /** 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. */
2958
+ export interface SetupBone {
2959
+ x: number;
2960
+ y: number;
2961
+ a?: number;
2962
+ c?: number;
2963
+ }
2964
+
2648
2965
  /**
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
2966
+ * Which way the chain carrying a part runs at its tip (issues #231, #322), for
2967
+ * a part the scene declares `rises`: the bones its attachments are bound to in
2968
+ * the default skin (a mesh's weights, or its slot's bone); the leaves among
2969
+ * them — those with no other of them below — walked up the bone tree to the
2970
+ * first link of a chain `CHAIN_LAG` reads ({@link chainFigures}: two or more
2971
+ * rotate-keyed links, each under the one before at one period). Each chain so
2972
+ * found is read at its **last link** ({@link chainToward}), the end
2973
+ * `TIP_OVER_ROOT`'s tip half is the art of: from the link's setup-pose origin
2974
+ * along its `length` in rig.json on its world x axis — the extent the rig
2975
+ * stage turns a link to (issue #73) — or, a last link with no length, from the
2976
+ * link before's origin to its own, the last extent the skeleton states. Until
2977
+ * #322 it was the first link's origin to the last's, which reads a chain that
2978
+ * hangs and then rises — a folded arm — as down, and on a two-link chain read
2979
+ * the first link only. `{ none }`, the reason, when no leaf is under a chain,
2980
+ * when a last link runs as far across as along, or when the part's chains run
2658
2981
  * different ways.
2659
2982
  */
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 } {
2983
+ export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, SetupBone>): { toward: Toward; chains: string[] } | { none: string } {
2661
2984
  const { found, bound } = chainsCarrying(rig, motion, part);
2662
2985
  const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
2663
2986
  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` };
2987
+ const lengths = new Map<string, number>();
2988
+ 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
2989
  const read = found.map((c) => {
2665
- const a = origins.get(c[0]);
2666
- const z = origins.get(c[c.length - 1]);
2990
+ const last = c[c.length - 1];
2991
+ const before = c[c.length - 2];
2667
2992
  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;
2993
+ const z = origins.get(last);
2994
+ const len = lengths.get(last);
2995
+ if (z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${last} in the idle's geometry` };
2996
+ let dx: number;
2997
+ let dy: number;
2998
+ let how: string;
2999
+ if (len !== undefined) {
3000
+ 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` };
3001
+ dx = len * z.a;
3002
+ dy = len * z.c;
3003
+ how = `its last link ${last}, from its origin along its length ${len},`;
3004
+ } else {
3005
+ const a = origins.get(before);
3006
+ if (a === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${before} in the idle's geometry` };
3007
+ dx = z.x - a.x;
3008
+ dy = z.y - a.y;
3009
+ how = `its last link ${last}, which has no length, from ${before}'s origin to its own,`;
3010
+ }
2671
3011
  const toward = chainToward(dx, dy);
2672
- return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
3012
+ return { label, toward, text: `${label}: ${how} ${toward === null ? 'runs as far across as along' : `runs ${toward}`} (${round3(dx)}, ${round3(dy)} world px)` };
2673
3013
  });
2674
3014
  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('; ')}` };
3015
+ 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
3016
  return { toward: ways[0], chains: read.map((r) => r.text) };
2677
3017
  }
2678
3018