@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/README.md +1 -1
- package/cli.ts +14 -8
- package/docs/AUTHORING.md +139 -34
- package/docs/MIGRATION.md +62 -0
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/build.ts +76 -17
- package/src/check.ts +496 -80
- package/src/config.ts +7 -1
- package/src/contour.ts +386 -14
- package/src/instruments.ts +6 -5
- package/src/meshdraft.ts +130 -9
- package/src/requirements.ts +68 -5
- package/src/rig.ts +4 -4
- package/src/scene.ts +2 -2
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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
|
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(
|
|
2222
|
+
rmSync(foldDir, { recursive: true, force: true });
|
|
1917
2223
|
}
|
|
1918
2224
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2386
|
-
|
|
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,
|
|
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')
|
|
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')
|
|
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
|
|
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 (
|
|
2650
|
-
* declares `rises`: the bones its attachments are bound to in
|
|
2651
|
-
* (a mesh's weights, or its slot's bone); the leaves among
|
|
2652
|
-
* no other of them below — walked up the bone tree to the
|
|
2653
|
-
* chain `CHAIN_LAG` reads ({@link chainFigures}: two or more
|
|
2654
|
-
* links, each under the one before at one period)
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
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,
|
|
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
|
|
2666
|
-
const
|
|
3066
|
+
const last = c[c.length - 1];
|
|
3067
|
+
const before = c[c.length - 2];
|
|
2667
3068
|
const label = c.join(' > ');
|
|
2668
|
-
|
|
2669
|
-
const
|
|
2670
|
-
|
|
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
|
|
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
|
}
|