@rig-c/parts 1.13.1 → 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/README.md +1 -1
- package/cli.ts +7 -2
- package/docs/AUTHORING.md +121 -30
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/build.ts +68 -11
- package/src/check.ts +417 -77
- package/src/config.ts +7 -1
- package/src/contour.ts +350 -14
- package/src/instruments.ts +6 -5
- package/src/meshdraft.ts +107 -8
- package/src/requirements.ts +67 -5
- package/src/rig.ts +3 -3
- 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 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
2146
|
+
rmSync(foldDir, { recursive: true, force: true });
|
|
1917
2147
|
}
|
|
1918
2148
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2386
|
-
|
|
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,
|
|
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')
|
|
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')
|
|
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
|
|
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 (
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
2666
|
-
const
|
|
2990
|
+
const last = c[c.length - 1];
|
|
2991
|
+
const before = c[c.length - 2];
|
|
2667
2992
|
const label = c.join(' > ');
|
|
2668
|
-
|
|
2669
|
-
const
|
|
2670
|
-
|
|
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
|
|
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
|
|