@rig-c/parts 1.15.0 → 1.17.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 +4 -0
- package/cli.ts +62 -12
- package/docs/AUTHORING.md +454 -200
- package/docs/STABILITY.md +12 -8
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +11 -2
- package/src/assemble.ts +325 -23
- package/src/autoamplitude.ts +23 -3
- package/src/autoenvelope.ts +17 -5
- package/src/build.ts +81 -8
- package/src/check.ts +632 -11
- package/src/config.ts +185 -8
- package/src/errors.ts +16 -0
- package/src/guide.ts +255 -0
- package/src/limbs.ts +188 -0
- package/src/parts.ts +89 -4
- package/src/propose.ts +137 -107
- package/src/rig.ts +43 -8
- package/src/scene.ts +2 -2
package/src/check.ts
CHANGED
|
@@ -59,7 +59,10 @@
|
|
|
59
59
|
* missing from both. When `motion.json` holds an animation, one more bar,
|
|
60
60
|
* `ANIMATIONS_DO_NOT_FOLD` (issues #320, #333): rigc's own `render --folds`
|
|
61
61
|
* over every animation, the idle included, physics constraints left out
|
|
62
|
-
* ({@link FOLD_LINE}).
|
|
62
|
+
* ({@link FOLD_LINE}). Beside it, where a `footwear` part rides a bone that
|
|
63
|
+
* hangs from another, `FEET_STAY_LEVEL` (issue #348): the same render, with
|
|
64
|
+
* `--geometry`, read for how far each such part's drawing turns while a bone
|
|
65
|
+
* above it is turned ({@link LEVEL_LINE}). The part-alone renders, the shut-eye still, the keyed
|
|
63
66
|
* build and the fold render are scratch (`_isolated/`, `_still/`, `_loop/`,
|
|
64
67
|
* `_folds/`), removed before the stage returns.
|
|
65
68
|
*
|
|
@@ -77,7 +80,7 @@
|
|
|
77
80
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
78
81
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
79
82
|
import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig } from './assemble.ts';
|
|
80
|
-
import { cropToSpineY } from './coords.ts';
|
|
83
|
+
import { cropToSpineY, normaliseDegrees } from './coords.ts';
|
|
81
84
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
82
85
|
import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
|
|
83
86
|
import {
|
|
@@ -108,6 +111,7 @@ import {
|
|
|
108
111
|
setupToFrame,
|
|
109
112
|
stillReading,
|
|
110
113
|
stillTolerance,
|
|
114
|
+
type StretchAt,
|
|
111
115
|
stretchFigures,
|
|
112
116
|
stretchSeverity,
|
|
113
117
|
sweptBox,
|
|
@@ -120,6 +124,7 @@ import {
|
|
|
120
124
|
type WorldBox,
|
|
121
125
|
} from './instruments.ts';
|
|
122
126
|
import { type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
127
|
+
import { turnTwin } from './turn.ts';
|
|
123
128
|
import {
|
|
124
129
|
aimLine,
|
|
125
130
|
contactLine,
|
|
@@ -148,6 +153,7 @@ import {
|
|
|
148
153
|
type SeamPair,
|
|
149
154
|
stretchRequirementLine,
|
|
150
155
|
} from './requirements.ts';
|
|
156
|
+
import { RIG_BONE_INHERIT } from 'rig-c/src/rig.ts';
|
|
151
157
|
import { alphaComposite } from './raster/composite.ts';
|
|
152
158
|
import { readPng, writePng } from './raster/png.ts';
|
|
153
159
|
import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
|
|
@@ -1717,6 +1723,12 @@ export interface CheckFigures {
|
|
|
1717
1723
|
TEXTURE_STRETCH: JudgementLine;
|
|
1718
1724
|
/** Written whenever motion.json holds an animation, the idle included (issues #320, #333): {@link FoldsLine}. */
|
|
1719
1725
|
[FOLD_LINE]?: FoldsLine;
|
|
1726
|
+
/** Written only where a `footwear` part rides a bone with a parent and motion.json holds an animation (issue #348): {@link LevelLine}. */
|
|
1727
|
+
[LEVEL_LINE]?: LevelLine;
|
|
1728
|
+
/** Written whenever parts.json names a `handwear` part and the motion has an idle (issue #347): {@link STRESS_LINE}. */
|
|
1729
|
+
[STRESS_LINE]?: JudgementLine;
|
|
1730
|
+
/** Written only where parts.json holds a part a `config.limbs` segment made (issue #349): {@link LimbLine}. */
|
|
1731
|
+
[LIMB_LINE]?: LimbLine;
|
|
1720
1732
|
RECOMPOSITE_HOLES: ReportedLine;
|
|
1721
1733
|
/** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
|
|
1722
1734
|
SETUP_POSE_VS_SOURCE?: ReportedLine;
|
|
@@ -1768,15 +1780,97 @@ export interface RequirementsBlock {
|
|
|
1768
1780
|
*/
|
|
1769
1781
|
export const FOLD_LINE = 'ANIMATIONS_DO_NOT_FOLD';
|
|
1770
1782
|
|
|
1783
|
+
/**
|
|
1784
|
+
* The bar over every animation, the idle included, for a `footwear` part that
|
|
1785
|
+
* rides a bone hanging from another (issue #348): while a bone above the
|
|
1786
|
+
* part's bone is turned, the part's drawing stays within
|
|
1787
|
+
* {@link LEVEL_BAR_DEGREES} of its setup pose.
|
|
1788
|
+
*
|
|
1789
|
+
* ⭐ **What the rig declares, and nothing else.** The feet are the parts
|
|
1790
|
+
* parts.json says come from a See-through `footwear` layer (the feet
|
|
1791
|
+
* `BREATH_VISIBLE` and `STILL_REGIONS_DARK` read); each one's bone is the bone
|
|
1792
|
+
* rig.json's slot of the same name sits on, and "the shin" is whatever bone
|
|
1793
|
+
* that bone hangs from, read off rig.json's `parent` — nothing here knows a
|
|
1794
|
+
* foot or a shin by name. A part on a bone with no parent (`root`, as on every
|
|
1795
|
+
* public example) hangs from nothing, so it is not read; a rig with no such
|
|
1796
|
+
* part, or no animation, writes no line, and its check.json keeps the keys it
|
|
1797
|
+
* always had.
|
|
1798
|
+
*
|
|
1799
|
+
* - **Posed**: a frame on which some bone above the part's bone — its parent,
|
|
1800
|
+
* or any bone over that up to the root — has a world rotation other than
|
|
1801
|
+
* its setup pose's, read off the `geometry.json` rig-c's render writes. The
|
|
1802
|
+
* nearest such bone is named, with its turn. No threshold: a frame is posed
|
|
1803
|
+
* or it is not.
|
|
1804
|
+
* - **The sole's angle**: the part's drawing has no sole a program can find,
|
|
1805
|
+
* so what is held is the drawing itself — the turn of the least-squares
|
|
1806
|
+
* rotation carrying its setup-pose vertices onto the frame's, both centred,
|
|
1807
|
+
* from the same `geometry.json` ({@link drawingTurn}). For a region, its four
|
|
1808
|
+
* corners turn rigidly with its bone and this is the bone's turn exactly;
|
|
1809
|
+
* for a mesh it is the best rotation the skinned vertices admit. Level is
|
|
1810
|
+
* the setup pose, the painting's own: the check cannot see where the sole is
|
|
1811
|
+
* drawn, and the setup pose is the art as painted.
|
|
1812
|
+
* - **The bar** is {@link LEVEL_BAR_DEGREES}, HQ's line — not a figure measured
|
|
1813
|
+
* here, and stated as HQ's wherever it is printed.
|
|
1814
|
+
*
|
|
1815
|
+
* Read off the fold render ({@link FOLD_LINE}), which then also writes
|
|
1816
|
+
* `--geometry`: the same build, with its physics constraints left out, at the
|
|
1817
|
+
* same rate. A part no frame poses says SKIP in its row; a line every row of
|
|
1818
|
+
* which says SKIP says SKIP and is not counted as measured.
|
|
1819
|
+
*/
|
|
1820
|
+
export const LEVEL_LINE = 'FEET_STAY_LEVEL';
|
|
1821
|
+
|
|
1822
|
+
/** Issue #349: every limb part rides its one bone ({@link limbLine}). */
|
|
1823
|
+
export const LIMB_LINE = 'LIMB_ONE_BONE';
|
|
1824
|
+
|
|
1825
|
+
/** HQ's line (rig-c/parts#347 ruling 4, issue #348): the sole within ±10 degrees of level while the shin is posed. Not measured here. */
|
|
1826
|
+
export const LEVEL_BAR_DEGREES = 10;
|
|
1827
|
+
|
|
1828
|
+
/** Where the bar comes from, as the line and check.json state it. */
|
|
1829
|
+
export const LEVEL_BAR_SOURCE = "HQ's line (rig-c/parts#347 ruling 4), not a figure measured here";
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* The bar on a sleeve whose mesh is weighted to a bone off its chain (issue
|
|
1833
|
+
* #347). The chain's first link is turned through {@link STRESS_TURNS} on a
|
|
1834
|
+
* throwaway copy of the rig; rigc poses each turn and writes its
|
|
1835
|
+
* `geometry.json`; the part's worst edge, max(ratio, 1/ratio) against the
|
|
1836
|
+
* setup pose ({@link stretchFigures}, the quantity `TEXTURE_STRETCH` reads),
|
|
1837
|
+
* must read {@link STRESS_RIGID} at {@link STRESS_PLACES} places. A part
|
|
1838
|
+
* weighted to its chain alone turns rigidly with the link, so it reads exactly
|
|
1839
|
+
* that; a vertex weighted to a bone the turn does not move is pulled between
|
|
1840
|
+
* the two, and the line names that bone — the segment on it in the config's
|
|
1841
|
+
* `meshes.<part>.segments`.
|
|
1842
|
+
*
|
|
1843
|
+
* - **The bar is the rigid reading, not a margin.** Measured for issue #347
|
|
1844
|
+
* (rigc 2.47.1, scratch runs, not tracked): the `handwear_*` parts of
|
|
1845
|
+
* `demo` and of the exam-01 test rig, their chest segment dropped, read
|
|
1846
|
+
* 1.0000038 to 1.0000090 over the three turns of each sleeve — float32 bone
|
|
1847
|
+
* arithmetic — and 1.0000 at four places; with the segment, 2.41 to 1498.
|
|
1848
|
+
* - **The turns are interim** (issue #347, HQ's ruling 1): ±90° and 180° on
|
|
1849
|
+
* the first link, the card's arm raised, arm across and arm behind the head,
|
|
1850
|
+
* until a config block names each limb and its joint's declared range — the
|
|
1851
|
+
* stress check then poses each limb at its range's ends.
|
|
1852
|
+
* - **Which parts: those from a See-through `handwear` layer**
|
|
1853
|
+
* ({@link STRESS_TAGS}) whose weights ride exactly one chain the idle keys
|
|
1854
|
+
* ({@link chainsCarrying}). A part riding two chains (one mesh for both
|
|
1855
|
+
* sleeves) stretches between them by construction when one turns, and is
|
|
1856
|
+
* listed as not read with that reason. The scope is the tag and not every
|
|
1857
|
+
* one-chain part: `reference-demo`'s `hairpin` rides its tassel's chain and
|
|
1858
|
+
* the pin's bone, a pendant the idle swings a few degrees, and reads 23.7
|
|
1859
|
+
* with its tassel turned 180° (issue #347, scratch run) — a figure for the
|
|
1860
|
+
* limbs block to scope, not a defect this line was asked to name.
|
|
1861
|
+
*/
|
|
1862
|
+
export const STRESS_LINE = 'STRESS_OFF_CHAIN';
|
|
1863
|
+
|
|
1771
1864
|
/** The bars `PASS` reads, in the order the summary counts them: the gate, the reference's two, then the judgement lines. */
|
|
1772
1865
|
export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
|
|
1773
1866
|
|
|
1774
1867
|
/**
|
|
1775
1868
|
* The bars a character's own check may have failed and `compose --accept`
|
|
1776
1869
|
* still take (issue #203): every bar of {@link BARS} but the gate, which is
|
|
1777
|
-
* rig-c's and never accepted,
|
|
1870
|
+
* rig-c's and never accepted, {@link FOLD_LINE} (issue #320) and
|
|
1871
|
+
* {@link STRESS_LINE} (issue #347).
|
|
1778
1872
|
*/
|
|
1779
|
-
export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== 'gate'), FOLD_LINE];
|
|
1873
|
+
export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== 'gate'), FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE];
|
|
1780
1874
|
|
|
1781
1875
|
/**
|
|
1782
1876
|
* The bars a `check.json`, as `check` writes it, says were not met, in
|
|
@@ -1784,7 +1878,8 @@ export const ACCEPTABLE_BARS: readonly string[] = [...BARS.filter((b) => b !== '
|
|
|
1784
1878
|
* loop when `loop_max_diff` is a number other than {@link LOOP_MAX_BAR}, the
|
|
1785
1879
|
* seam when its figures fail {@link seamFails}, and each judgement line whose
|
|
1786
1880
|
* status is `FAIL`, then {@link FOLD_LINE} when it is written and `FAIL`
|
|
1787
|
-
* (issue #320)
|
|
1881
|
+
* (issue #320), then {@link LEVEL_LINE} the same way (issue #348), then {@link STRESS_LINE} likewise (issue #347), then
|
|
1882
|
+
* {@link LIMB_LINE} (issue #349). A bar that measured nothing (null figures, `SKIP`) is not
|
|
1788
1883
|
* one of them. Read off the file rather than its problems, because the file is
|
|
1789
1884
|
* all a finished build leaves (issue #203).
|
|
1790
1885
|
*/
|
|
@@ -1795,7 +1890,7 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
|
|
|
1795
1890
|
// 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.
|
|
1796
1891
|
const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
|
|
1797
1892
|
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');
|
|
1798
|
-
for (const n of [...JUDGEMENT_LINES, FOLD_LINE]) {
|
|
1893
|
+
for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE]) {
|
|
1799
1894
|
const l = fig[n];
|
|
1800
1895
|
if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
|
|
1801
1896
|
}
|
|
@@ -1845,9 +1940,10 @@ export interface CheckReport {
|
|
|
1845
1940
|
* idle (issue #183), or null when it holds none, so a rig of the idle alone
|
|
1846
1941
|
* prints what it printed before.
|
|
1847
1942
|
*/
|
|
1848
|
-
export function besideIdleLine(names: readonly string[]): string | null {
|
|
1943
|
+
export function besideIdleLine(names: readonly string[], level = false): string | null {
|
|
1849
1944
|
if (names.length === 0) return null;
|
|
1850
|
-
|
|
1945
|
+
const readers = level ? `${FOLD_LINE} reads each for folds and ${LEVEL_LINE} for how far the feet turn` : `${FOLD_LINE} reads each for folds`;
|
|
1946
|
+
return `beside the idle: ${names.length} animation(s), ${names.join(', ')} — built and gated by rigc with the rig; ${readers}, every other bar here reads the idle`;
|
|
1851
1947
|
}
|
|
1852
1948
|
|
|
1853
1949
|
// ---------------------------------------------------------------------------
|
|
@@ -2022,6 +2118,471 @@ export function foldsLine(named: readonly string[], fps: number, physicsLeftOut:
|
|
|
2022
2118
|
};
|
|
2023
2119
|
}
|
|
2024
2120
|
|
|
2121
|
+
// ---------------------------------------------------------------------------
|
|
2122
|
+
// the feet stay level while a bone above them turns (issue #348)
|
|
2123
|
+
// ---------------------------------------------------------------------------
|
|
2124
|
+
|
|
2125
|
+
/** A `footwear` part on a bone that hangs from another, as rig.json declares it. */
|
|
2126
|
+
export interface LevelFoot {
|
|
2127
|
+
part: string;
|
|
2128
|
+
/** The bone the part's slot sits on. */
|
|
2129
|
+
bone: string;
|
|
2130
|
+
/** That bone's parent in rig.json. */
|
|
2131
|
+
hangs_from: string;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/** The frame a foot's drawing turned furthest on, among the frames a bone above it was turned. */
|
|
2135
|
+
export interface LevelWorst {
|
|
2136
|
+
animation: string;
|
|
2137
|
+
frame: number;
|
|
2138
|
+
time: number;
|
|
2139
|
+
/** The drawing's turn from its setup pose, degrees, CCW, six places. */
|
|
2140
|
+
degrees: number;
|
|
2141
|
+
/** The nearest bone above the part's bone that is turned on that frame, and its turn. */
|
|
2142
|
+
turned: string;
|
|
2143
|
+
turned_degrees: number;
|
|
2144
|
+
/** The bones from the part's bone up to `turned`, `turned` left out, each with its inherit mode as rig.json writes it ("normal", rig-c's default, where it writes none). */
|
|
2145
|
+
through: Array<{ bone: string; inherit: string }>;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
export interface LevelRow extends LevelFoot {
|
|
2149
|
+
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
2150
|
+
/** Frames, over every animation, on which a bone above the part's bone is turned and the part is shown. */
|
|
2151
|
+
frames_posed: number;
|
|
2152
|
+
worst: LevelWorst | null;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/** `check.json`'s `FEET_STAY_LEVEL`. */
|
|
2156
|
+
export interface LevelLine {
|
|
2157
|
+
status: 'PASS' | 'FAIL' | 'SKIP';
|
|
2158
|
+
/** Only on a SKIP: why nothing was measured. */
|
|
2159
|
+
reason?: string;
|
|
2160
|
+
animations: string[];
|
|
2161
|
+
fps: number;
|
|
2162
|
+
physics_left_out: number;
|
|
2163
|
+
bar_degrees: number;
|
|
2164
|
+
bar: string;
|
|
2165
|
+
feet: LevelRow[];
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/** The `footwear` parts the line reads (module comment above {@link LEVEL_LINE}), in parts.json's order: each on a slot of its name whose bone has a parent. */
|
|
2169
|
+
export function levelFeet(parts: PartsFile, rig: Record<string, unknown>): LevelFoot[] {
|
|
2170
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord);
|
|
2171
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
|
|
2172
|
+
const out: LevelFoot[] = [];
|
|
2173
|
+
for (const p of partsTagged(parts, FEET_TAGS)) {
|
|
2174
|
+
const slot = slots.find((sl) => sl.name === p.name);
|
|
2175
|
+
if (slot === undefined || typeof slot.bone !== 'string') continue;
|
|
2176
|
+
const bone = bones.find((b) => b.name === slot.bone);
|
|
2177
|
+
if (bone === undefined || typeof bone.parent !== 'string') continue;
|
|
2178
|
+
out.push({ part: p.name, bone: slot.bone, hangs_from: bone.parent });
|
|
2179
|
+
}
|
|
2180
|
+
return out;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* The turn, in degrees CCW (y up), of the least-squares rotation carrying the
|
|
2185
|
+
* points `rest` onto `posed` (flat `x, y` lists, one point per pair), both
|
|
2186
|
+
* centred on their means: atan2(Σ (r × p), Σ (r · p)). Null when the sums are
|
|
2187
|
+
* both 0 — fewer than two distinct points, so no direction to turn.
|
|
2188
|
+
*/
|
|
2189
|
+
export function drawingTurn(rest: readonly number[], posed: readonly number[]): number | null {
|
|
2190
|
+
const n = rest.length / 2;
|
|
2191
|
+
let rx = 0;
|
|
2192
|
+
let ry = 0;
|
|
2193
|
+
let px = 0;
|
|
2194
|
+
let py = 0;
|
|
2195
|
+
for (let i = 0; i < n; i++) {
|
|
2196
|
+
rx += rest[2 * i];
|
|
2197
|
+
ry += rest[2 * i + 1];
|
|
2198
|
+
px += posed[2 * i];
|
|
2199
|
+
py += posed[2 * i + 1];
|
|
2200
|
+
}
|
|
2201
|
+
rx /= n;
|
|
2202
|
+
ry /= n;
|
|
2203
|
+
px /= n;
|
|
2204
|
+
py /= n;
|
|
2205
|
+
let cross = 0;
|
|
2206
|
+
let dot = 0;
|
|
2207
|
+
for (let i = 0; i < n; i++) {
|
|
2208
|
+
const ax = rest[2 * i] - rx;
|
|
2209
|
+
const ay = rest[2 * i + 1] - ry;
|
|
2210
|
+
const bx = posed[2 * i] - px;
|
|
2211
|
+
const by = posed[2 * i + 1] - py;
|
|
2212
|
+
cross += ax * by - ay * bx;
|
|
2213
|
+
dot += ax * bx + ay * by;
|
|
2214
|
+
}
|
|
2215
|
+
if (cross === 0 && dot === 0) return null;
|
|
2216
|
+
return (Math.atan2(cross, dot) * 180) / Math.PI;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/** A bone's turn from its setup pose to a frame, degrees CCW in (-180, 180], off the two world matrices. */
|
|
2220
|
+
export function boneTurn(setup: BoneWorld, frame: BoneWorld): number {
|
|
2221
|
+
return normaliseDegrees(((Math.atan2(frame.c, frame.a) - Math.atan2(setup.c, setup.a)) * 180) / Math.PI);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** One animation's frames as the fold render wrote them with `--geometry`: the set and its geometry file. */
|
|
2225
|
+
export interface LevelReading {
|
|
2226
|
+
animation: string;
|
|
2227
|
+
set: FrameSet;
|
|
2228
|
+
geometryPath: string;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* The line from the feet and the readings (module comment above
|
|
2233
|
+
* {@link LEVEL_LINE}); each FAIL row adds one `CHECK_FEET_STAY_LEVEL` problem
|
|
2234
|
+
* naming the part, its bone, the frame and the bones the turn came through.
|
|
2235
|
+
*/
|
|
2236
|
+
export function levelLine(feet: readonly LevelFoot[], rig: Record<string, unknown>, readings: readonly LevelReading[], fps: number, physicsLeftOut: number, problems: Problem[]): LevelLine {
|
|
2237
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
|
|
2238
|
+
const parentOf = (n: string): string | null => {
|
|
2239
|
+
const b = bones.find((x) => x.name === n);
|
|
2240
|
+
return b !== undefined && typeof b.parent === 'string' ? b.parent : null;
|
|
2241
|
+
};
|
|
2242
|
+
const inheritOf = (n: string): string => {
|
|
2243
|
+
const b = bones.find((x) => x.name === n);
|
|
2244
|
+
return b !== undefined && typeof b.inherit === 'string' ? b.inherit : 'normal';
|
|
2245
|
+
};
|
|
2246
|
+
const rows: LevelRow[] = feet.map((f) => ({ ...f, status: 'SKIP', frames_posed: 0, worst: null }));
|
|
2247
|
+
for (const r of readings) {
|
|
2248
|
+
const geo = readGeometry(r.geometryPath, r.set.written);
|
|
2249
|
+
if (geo === null) geometryProblem(r.geometryPath, `does not exist; \`rigc render --folds --geometry\` writes it, and ${LEVEL_LINE} is read off it`);
|
|
2250
|
+
const shown = attachmentPoses(r.geometryPath, r.set.written);
|
|
2251
|
+
const tracks = new Map<string, BoneTrack>();
|
|
2252
|
+
const track = (n: string): BoneTrack => {
|
|
2253
|
+
const got = tracks.get(n) ?? boneTrackOf(geo, r.geometryPath, r.set, n);
|
|
2254
|
+
if (typeof got === 'string') return geometryProblem(r.geometryPath, got);
|
|
2255
|
+
tracks.set(n, got);
|
|
2256
|
+
return got;
|
|
2257
|
+
};
|
|
2258
|
+
for (const row of rows) {
|
|
2259
|
+
const above: string[] = [];
|
|
2260
|
+
for (let b: string | null = row.hangs_from; b !== null; b = parentOf(b)) above.push(b);
|
|
2261
|
+
r.set.frames.forEach((_, i) => {
|
|
2262
|
+
const turned = above.find((b) => {
|
|
2263
|
+
const t = track(b);
|
|
2264
|
+
return boneTurn(t.setup, t.frames[i]) !== 0;
|
|
2265
|
+
});
|
|
2266
|
+
if (turned === undefined) return;
|
|
2267
|
+
const at = shown[i].get(row.part);
|
|
2268
|
+
if (at === undefined) return;
|
|
2269
|
+
const deg = drawingTurn(at.rest, at.posed);
|
|
2270
|
+
if (deg === null) return;
|
|
2271
|
+
row.frames_posed++;
|
|
2272
|
+
const degrees = round6(deg);
|
|
2273
|
+
if (row.worst !== null && Math.abs(degrees) <= Math.abs(row.worst.degrees)) return;
|
|
2274
|
+
const t = track(turned);
|
|
2275
|
+
const through: Array<{ bone: string; inherit: string }> = [];
|
|
2276
|
+
for (let b: string | null = row.bone; b !== null && b !== turned; b = parentOf(b)) through.push({ bone: b, inherit: inheritOf(b) });
|
|
2277
|
+
row.worst = { animation: r.animation, frame: r.set.frames[i].index, time: t.times[i], degrees, turned, turned_degrees: round6(boneTurn(t.setup, t.frames[i])), through };
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
for (const row of rows) {
|
|
2282
|
+
if (row.worst === null) continue;
|
|
2283
|
+
row.status = Math.abs(row.worst.degrees) > LEVEL_BAR_DEGREES ? 'FAIL' : 'PASS';
|
|
2284
|
+
}
|
|
2285
|
+
const physics = physicsLeftOut === 0 ? '' : `, its ${physicsLeftOut} physics constraint(s) left out`;
|
|
2286
|
+
for (const row of rows) {
|
|
2287
|
+
if (row.status !== 'FAIL' || row.worst === null) continue;
|
|
2288
|
+
const w = row.worst;
|
|
2289
|
+
const how = w.through.map((x) => `"${x.bone}" (inherit ${x.inherit})`).join(', then ');
|
|
2290
|
+
problems.push({
|
|
2291
|
+
code: `CHECK_${LEVEL_LINE}`,
|
|
2292
|
+
object: `footwear part "${row.part}" on bone "${row.bone}" (hangs from "${row.hangs_from}")`,
|
|
2293
|
+
detail:
|
|
2294
|
+
`in animation "${w.animation}" frame ${w.frame} (t = ${w.time}s, rigc render --folds --geometry at ${fps} fps${physics}), "${w.turned}" is turned ${w.turned_degrees} degrees from its setup pose and the part's drawing ${w.degrees} degrees; ` +
|
|
2295
|
+
`within ±${LEVEL_BAR_DEGREES} degrees of level (its setup pose) is required while a bone above it is turned — ${LEVEL_BAR_SOURCE}. ` +
|
|
2296
|
+
`The turn reaches the drawing through ${how}: config.bones' "inherit" on "${row.bone}" says what it takes from "${row.hangs_from}" (rig-c's modes, as its table spells them: ${RIG_BONE_INHERIT.join(', ')})`,
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
const status: LevelLine['status'] = rows.some((r) => r.status === 'FAIL') ? 'FAIL' : rows.some((r) => r.status === 'PASS') ? 'PASS' : 'SKIP';
|
|
2300
|
+
const names = readings.map((r) => r.animation);
|
|
2301
|
+
const line: LevelLine = { status, animations: names, fps, physics_left_out: physicsLeftOut, bar_degrees: LEVEL_BAR_DEGREES, bar: LEVEL_BAR_SOURCE, feet: rows };
|
|
2302
|
+
if (status === 'SKIP') {
|
|
2303
|
+
line.reason = `no frame of ${names.map((n) => `"${n}"`).join(', ')} turns a bone above ${rows.map((r) => `"${r.bone}" (which "${r.part}" rides)`).join(', ')}, so no foot was seen while its leg was posed`;
|
|
2304
|
+
}
|
|
2305
|
+
return line;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// ---------------------------------------------------------------------------
|
|
2309
|
+
// a part on one chain, posed large (issue #347)
|
|
2310
|
+
// ---------------------------------------------------------------------------
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
/** The first link's turns, degrees from its setup rotation (issue #347's card: ±90° and 180°), interim until limbs are declared. */
|
|
2314
|
+
export const STRESS_TURNS: readonly number[] = [90, -90, 180];
|
|
2315
|
+
|
|
2316
|
+
/** The parts the line reads (issue #347): from a See-through layer of these tags. */
|
|
2317
|
+
export const STRESS_TAGS: readonly BaseTag[] = ['handwear'];
|
|
2318
|
+
|
|
2319
|
+
/** The reading a part riding its chain rigidly gives: max(ratio, 1/ratio) of 1. */
|
|
2320
|
+
export const STRESS_RIGID = 1;
|
|
2321
|
+
|
|
2322
|
+
/** The places the reading is held to {@link STRESS_RIGID} at: four, where the rigid parts' float32 noise (at most 9.0e-6 measured) rounds away. */
|
|
2323
|
+
export const STRESS_PLACES = 4;
|
|
2324
|
+
|
|
2325
|
+
/** The stress render, under `--out`; removed before the stage returns. */
|
|
2326
|
+
const STRESS_SCRATCH = '_stress';
|
|
2327
|
+
|
|
2328
|
+
/** The animation one turn of one first link is posed by on the throwaway copy. */
|
|
2329
|
+
export function stressAnimationName(link: string, turn: number): string {
|
|
2330
|
+
return `stress ${link} ${turn}`;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* The stress render's geometry, by animation: `frames.json` names each set's
|
|
2335
|
+
* directory and how many frames it wrote, and each `geometry.json` is read by
|
|
2336
|
+
* {@link readGeometry}. A set or a file missing is refused
|
|
2337
|
+
* (`CHECK_GEOMETRY_FILE`), every one at once.
|
|
2338
|
+
*/
|
|
2339
|
+
export function readStressSets(dir: string, named: readonly string[]): Map<string, IdleGeometry> {
|
|
2340
|
+
const problems: Problem[] = [];
|
|
2341
|
+
const sidecar = readJson(join(dir, 'frames.json'), problems);
|
|
2342
|
+
const sets = sidecar !== null && Array.isArray(sidecar.sets) ? sidecar.sets.filter(isRecord) : [];
|
|
2343
|
+
const out = new Map<string, IdleGeometry>();
|
|
2344
|
+
for (const name of named) {
|
|
2345
|
+
const set = sets.find((x) => x.animation === name);
|
|
2346
|
+
if (set === undefined || typeof set.dir !== 'string' || typeof set.written !== 'number') {
|
|
2347
|
+
problems.push({ code: 'CHECK_GEOMETRY_FILE', object: join(dir, 'frames.json'), detail: `names no frame set for animation ${JSON.stringify(name)} with its directory and frame count; \`rigc render --geometry\` over the turned copy writes one per animation` });
|
|
2348
|
+
continue;
|
|
2349
|
+
}
|
|
2350
|
+
const path = join(dir, set.dir, GEOMETRY_FILE);
|
|
2351
|
+
const geo = readGeometry(path, set.written);
|
|
2352
|
+
if (geo === null) problems.push({ code: 'CHECK_GEOMETRY_FILE', object: path, detail: 'does not exist; `rigc render --geometry` writes it, and the stress line reads the part\'s posed mesh from it' });
|
|
2353
|
+
else out.set(name, geo);
|
|
2354
|
+
}
|
|
2355
|
+
refuseIfAny(problems);
|
|
2356
|
+
return out;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/** A part the line reads: its chain's links from the root, and the bones it is weighted to off that chain, each with how many of its vertices carry it and the largest weight. */
|
|
2360
|
+
export interface StressPart {
|
|
2361
|
+
part: string;
|
|
2362
|
+
chain: string[];
|
|
2363
|
+
off_chain: Array<{ bone: string; vertices: number; of: number; largest_weight: number }>;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/** The parts a handwear tag names that the line reads, and those it does not with the reason. */
|
|
2367
|
+
export interface StressPlan {
|
|
2368
|
+
read: StressPart[];
|
|
2369
|
+
notRead: Array<{ part: string; reason: string }>;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* Which handwear parts the line reads (see {@link STRESS_LINE}): a weighted
|
|
2374
|
+
* mesh whose bones ride exactly one chain of the idle; for each, the bones
|
|
2375
|
+
* off that chain — neither a link nor under one — with their vertex counts
|
|
2376
|
+
* and largest weight, read off `rig.json`'s default skin.
|
|
2377
|
+
*/
|
|
2378
|
+
export function stressPlan(rig: Record<string, unknown>, motion: Record<string, unknown>, parts: readonly PartRecord[]): StressPlan {
|
|
2379
|
+
const read: StressPart[] = [];
|
|
2380
|
+
const notRead: StressPlan['notRead'] = [];
|
|
2381
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2382
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2383
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2384
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2385
|
+
for (const p of parts) {
|
|
2386
|
+
// Issue #349: a limb segment is held to its one bone by LIMB_ONE_BONE, read off rig.json, so it turns rigidly by construction.
|
|
2387
|
+
if (p.limb !== undefined) {
|
|
2388
|
+
notRead.push({ part: p.name, reason: `a limb segment (config.limbs.${p.limb.name}), held to its one bone "${p.limb.bone}" by ${LIMB_LINE}, so it turns rigidly by construction` });
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
const onSlot = skin[p.name];
|
|
2392
|
+
const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
|
|
2393
|
+
const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
|
|
2394
|
+
if (weighted.length === 0) {
|
|
2395
|
+
notRead.push({ part: p.name, reason: 'it is not a weighted mesh, so it rides one bone whole and cannot stretch' });
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
const { found } = chainsCarrying(rig, motion, p.name);
|
|
2399
|
+
if (found.length !== 1) {
|
|
2400
|
+
notRead.push({
|
|
2401
|
+
part: p.name,
|
|
2402
|
+
reason:
|
|
2403
|
+
found.length === 0
|
|
2404
|
+
? 'its weights ride no chain the idle keys, so there is no chain to turn'
|
|
2405
|
+
: `its weights ride ${found.length} chains (${found.map((c) => `"${c[0]}"`).join(', ')}): turning one stretches the part toward the other by construction; a part riding one chain is read until a limbs block names each limb`,
|
|
2406
|
+
});
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
const chain = found[0];
|
|
2410
|
+
const onChain = (b: string): boolean => {
|
|
2411
|
+
const seen = new Set<string>();
|
|
2412
|
+
for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
|
2413
|
+
if (chain.includes(x)) return true;
|
|
2414
|
+
seen.add(x);
|
|
2415
|
+
}
|
|
2416
|
+
return false;
|
|
2417
|
+
};
|
|
2418
|
+
const off = new Map<string, { vertices: number; largest: number }>();
|
|
2419
|
+
let of = 0;
|
|
2420
|
+
for (const a of weighted) {
|
|
2421
|
+
for (const v of a.weights) {
|
|
2422
|
+
of++;
|
|
2423
|
+
const ws = (Array.isArray(v) ? v : []).filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') as Array<{ bone: string; weight: number }>;
|
|
2424
|
+
for (const w of ws) {
|
|
2425
|
+
if (onChain(w.bone) || w.weight <= 0) continue;
|
|
2426
|
+
const e = off.get(w.bone) ?? { vertices: 0, largest: 0 };
|
|
2427
|
+
e.vertices++;
|
|
2428
|
+
e.largest = Math.max(e.largest, w.weight);
|
|
2429
|
+
off.set(w.bone, e);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
read.push({ part: p.name, chain, off_chain: [...off].map(([bone, e]) => ({ bone, vertices: e.vertices, of, largest_weight: e.largest })) });
|
|
2434
|
+
}
|
|
2435
|
+
return { read, notRead };
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/** The throwaway copy's animations: one per first link and turn, a single `rotate` key at time 0, duration 0. */
|
|
2439
|
+
export function stressAnimations(plan: StressPlan): Record<string, { duration: number; tracks: Array<{ bone: string; property: string; keys: Array<{ t: number; v: number[] }> }> }> {
|
|
2440
|
+
const out: ReturnType<typeof stressAnimations> = {};
|
|
2441
|
+
for (const r of plan.read) {
|
|
2442
|
+
for (const turn of STRESS_TURNS) out[stressAnimationName(r.chain[0], turn)] = { duration: 0, tracks: [{ bone: r.chain[0], property: 'rotate', keys: [{ t: 0, v: [turn] }] }] };
|
|
2443
|
+
}
|
|
2444
|
+
return out;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/** {@link STRESS_PLACES} places. */
|
|
2448
|
+
function roundStress(x: number): number {
|
|
2449
|
+
const k = 10 ** STRESS_PLACES;
|
|
2450
|
+
return Math.round(x * k) / k;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* The line from the renders: `geometry` maps each stress animation to its
|
|
2455
|
+
* `geometry.json` read by {@link readGeometry}. Per part and turn, the
|
|
2456
|
+
* worst edge of the part's mesh; a reading above {@link STRESS_RIGID} at
|
|
2457
|
+
* {@link STRESS_PLACES} places is a `CHECK_STRESS_OFF_CHAIN` problem naming
|
|
2458
|
+
* the part, the link and turn, the edge, and the bones off the chain. SKIP,
|
|
2459
|
+
* with every part's reason, when no handwear part is read.
|
|
2460
|
+
*/
|
|
2461
|
+
export function stressLine(plan: StressPlan, geometry: (animation: string) => IdleGeometry, problems: Problem[]): JudgementLine {
|
|
2462
|
+
if (plan.read.length === 0) return skip(plan.notRead.map((n) => `"${n.part}": ${n.reason}`).join('; '));
|
|
2463
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
2464
|
+
let worst: { part: string; link: string; turn: number; reading: number } | null = null;
|
|
2465
|
+
let failed = false;
|
|
2466
|
+
for (const r of plan.read) {
|
|
2467
|
+
const poses: Array<Record<string, unknown>> = [];
|
|
2468
|
+
for (const turn of STRESS_TURNS) {
|
|
2469
|
+
const name = stressAnimationName(r.chain[0], turn);
|
|
2470
|
+
const geo = geometry(name);
|
|
2471
|
+
const m = stretchFigures(
|
|
2472
|
+
geo.meshes.filter((x) => x.slot === r.part),
|
|
2473
|
+
geo.frames,
|
|
2474
|
+
);
|
|
2475
|
+
const seen = m.filter((x) => x.max !== null && x.min !== null);
|
|
2476
|
+
if (seen.length === 0) {
|
|
2477
|
+
poses.push({ turn, unmeasured: `the "${name}" render shows no mesh on slot "${r.part}"` });
|
|
2478
|
+
failed = true;
|
|
2479
|
+
problems.push({ code: `CHECK_${STRESS_LINE}`, object: `mesh "${r.part}" with "${r.chain[0]}" turned ${turn}°`, detail: `the "${name}" render shows no mesh on slot "${r.part}", so its stretch was not read; a part not read is not a pass` });
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
let at: StretchAt | null = null;
|
|
2483
|
+
let reading = 0;
|
|
2484
|
+
for (const x of seen) {
|
|
2485
|
+
for (const s of [x.max as StretchAt, x.min as StretchAt]) {
|
|
2486
|
+
const v = stretchSeverity(s.ratio);
|
|
2487
|
+
if (v > reading) {
|
|
2488
|
+
reading = v;
|
|
2489
|
+
at = s;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
const shown = roundStress(reading);
|
|
2494
|
+
const a = at as StretchAt;
|
|
2495
|
+
poses.push({ turn, reading: shown, worst: { triangle: a.triangle, edge: a.edge, ratio: round3(a.ratio) } });
|
|
2496
|
+
if (worst === null || reading > worst.reading) worst = { part: r.part, link: r.chain[0], turn, reading };
|
|
2497
|
+
if (shown > STRESS_RIGID) {
|
|
2498
|
+
failed = true;
|
|
2499
|
+
const bones = r.off_chain.length === 0 ? 'no vertex is weighted off the chain, so a bone under the chain or the render moves it' : r.off_chain.map((o) => `"${o.bone}" (${o.vertices} of ${o.of} vertices, largest weight ${round3(o.largest_weight)})`).join(', ');
|
|
2500
|
+
problems.push({
|
|
2501
|
+
code: `CHECK_${STRESS_LINE}`,
|
|
2502
|
+
object: `mesh "${r.part}" with chain link "${r.chain[0]}" turned ${turn}°`,
|
|
2503
|
+
detail:
|
|
2504
|
+
`its worst edge, triangle ${a.triangle} edge ${a.edge.join('-')}, is ${round3(a.ratio)} times its rest length (max(ratio, 1/ratio) ${shown}); ${STRESS_RIGID.toFixed(STRESS_PLACES)}, the part turning rigidly with its chain, is required — ` +
|
|
2505
|
+
`it is weighted off the chain to ${bones}: a segment on ${r.off_chain.length === 1 ? `"${r.off_chain[0].bone}"` : 'each of those bones'} in meshes.${r.part}.segments pins the part while the chain turns; drop it, so the part rides its chain alone`,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
rows.push({ part: r.part, link: r.chain[0], links: r.chain.length, off_chain: r.off_chain.map((o) => ({ ...o, largest_weight: round3(o.largest_weight) })), poses });
|
|
2510
|
+
}
|
|
2511
|
+
const w = worst as { part: string; link: string; turn: number; reading: number } | null;
|
|
2512
|
+
return {
|
|
2513
|
+
status: failed ? 'FAIL' : 'PASS',
|
|
2514
|
+
tags: [...STRESS_TAGS],
|
|
2515
|
+
turns: [...STRESS_TURNS],
|
|
2516
|
+
parts_read: plan.read.length,
|
|
2517
|
+
reading: w === null ? null : roundStress(w.reading),
|
|
2518
|
+
worst: w === null ? null : { part: w.part, link: w.link, turn: w.turn },
|
|
2519
|
+
bar: STRESS_RIGID,
|
|
2520
|
+
per_part: rows,
|
|
2521
|
+
not_read: plan.notRead,
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// ---------------------------------------------------------------------------
|
|
2526
|
+
// a limb segment rides one bone (issue #349)
|
|
2527
|
+
// ---------------------------------------------------------------------------
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* `LIMB_ONE_BONE` (issue #349): every part a `config.limbs` segment made —
|
|
2531
|
+
* `parts.json`'s records that carry `limb` — is bound in `rig.json` to its
|
|
2532
|
+
* limb bone alone, with weight 1: a region on a slot whose bone is that bone,
|
|
2533
|
+
* or a mesh whose every weight names it. A bend is a rotation at the joint,
|
|
2534
|
+
* never a deformed mesh, and a second bone is the pin (a puff held by the
|
|
2535
|
+
* chest) or the noodle (a forearm blended along a chain) the cut retires.
|
|
2536
|
+
* Under `config.turn` a part rides the leaf twin of its bone
|
|
2537
|
+
* (`turnTwin`), an identity child, and the twin counts as the bone. Read off
|
|
2538
|
+
* `rig.json` and `parts.json` alone: nothing is rendered. Written only when
|
|
2539
|
+
* `parts.json` holds a limb part, so a check with none writes what it wrote.
|
|
2540
|
+
* The name is {@link LIMB_LINE}.
|
|
2541
|
+
*/
|
|
2542
|
+
|
|
2543
|
+
/** One limb part's row of the line. */
|
|
2544
|
+
export interface LimbRow {
|
|
2545
|
+
part: string;
|
|
2546
|
+
limb: string;
|
|
2547
|
+
bone: string;
|
|
2548
|
+
status: 'PASS' | 'FAIL';
|
|
2549
|
+
/** The bones besides its own the part is bound to, in rig.json's order; empty on a PASS. */
|
|
2550
|
+
others: string[];
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/** `check.json`'s `LIMB_ONE_BONE`. */
|
|
2554
|
+
export interface LimbLine {
|
|
2555
|
+
status: 'PASS' | 'FAIL';
|
|
2556
|
+
parts: LimbRow[];
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/** The line from parts.json's limb records and rig.json's slots and skins; each FAIL row adds one `CHECK_LIMB_ONE_BONE` problem naming the part and every second bone. Null when no part is a limb's. */
|
|
2560
|
+
export function limbLine(parts: PartsFile, rig: Record<string, unknown>, problems: Problem[]): LimbLine | null {
|
|
2561
|
+
const limbParts = parts.parts.filter((p) => p.limb !== undefined);
|
|
2562
|
+
if (limbParts.length === 0) return null;
|
|
2563
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord);
|
|
2564
|
+
const rows: LimbRow[] = limbParts.map((p) => {
|
|
2565
|
+
const l = p.limb as NonNullable<PartRecord['limb']>;
|
|
2566
|
+
const own = new Set([l.bone, turnTwin(p.name, l.bone)]);
|
|
2567
|
+
const slot = slots.find((sl) => sl.name === p.name);
|
|
2568
|
+
const bound = slot === undefined ? new Set<string>() : boundBones(rig, p.name);
|
|
2569
|
+
const others = [...bound].filter((b) => !own.has(b));
|
|
2570
|
+
const fail = others.length > 0 || bound.size === 0;
|
|
2571
|
+
if (fail) {
|
|
2572
|
+
problems.push({
|
|
2573
|
+
code: `CHECK_${LIMB_LINE}`,
|
|
2574
|
+
object: `limb part "${p.name}" (config.limbs.${l.name} segment ${l.segment}, bone "${l.bone}")`,
|
|
2575
|
+
detail:
|
|
2576
|
+
bound.size === 0
|
|
2577
|
+
? `is bound to no bone in rig.json (no slot of its name, or no attachment); its one bone "${l.bone}", with weight 1, is required`
|
|
2578
|
+
: `is weighted to ${others.map((b) => `"${b}"`).join(', ')} besides "${l.bone}"; a limb part rides its one bone with weight 1 — a bend is a rotation at the joint, never a deformed mesh — so a region on "${l.bone}" (config.regions.${p.name}), or a mesh whose segments name "${l.bone}" alone, is required`,
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
return { part: p.name, limb: l.name, bone: l.bone, status: fail ? 'FAIL' : 'PASS', others };
|
|
2582
|
+
});
|
|
2583
|
+
return { status: rows.some((r) => r.status === 'FAIL') ? 'FAIL' : 'PASS', parts: rows };
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2025
2586
|
/**
|
|
2026
2587
|
* One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
|
|
2027
2588
|
* its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
|
|
@@ -2159,6 +2720,13 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2159
2720
|
const named = animationNames.filter((n) => n !== 'idle');
|
|
2160
2721
|
let folds: FoldsLine | null = null;
|
|
2161
2722
|
const foldProblems: Problem[] = [];
|
|
2723
|
+
// Issue #348: the feet the level line reads, off parts.json and rig.json; none, and nothing below moves.
|
|
2724
|
+
const feet = inp.parts === null ? [] : levelFeet(inp.parts, inp.rig);
|
|
2725
|
+
let level: LevelLine | null = null;
|
|
2726
|
+
// Issue #349: the limb parts' bones, off rig.json and parts.json; none, and nothing is written.
|
|
2727
|
+
const limbProblems: Problem[] = [];
|
|
2728
|
+
const limbs = inp.parts === null ? null : limbLine(inp.parts, inp.rig, limbProblems);
|
|
2729
|
+
const levelProblems: Problem[] = [];
|
|
2162
2730
|
try {
|
|
2163
2731
|
if (inp.idleDuration !== null) {
|
|
2164
2732
|
const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
|
|
@@ -2214,10 +2782,22 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2214
2782
|
if (animationNames.length > 0) {
|
|
2215
2783
|
const fps = IDLE_FPS;
|
|
2216
2784
|
try {
|
|
2217
|
-
|
|
2218
|
-
|
|
2785
|
+
// Issue #348: with a foot to read, the same render writes each animation's geometry as well.
|
|
2786
|
+
const geometry = feet.length > 0 ? ['--geometry'] : [];
|
|
2787
|
+
const fr = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', ...geometry, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
|
|
2788
|
+
if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds${geometry.map((g) => ` ${g}`).join('')} --fps ${fps}${stripped === null ? '' : ' (physics constraint(s) left out)'}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
2219
2789
|
const readings = readFoldSets(foldDir, animationNames, fps, fr.out);
|
|
2220
2790
|
folds = foldsLine(animationNames, fps, stripped === null ? 0 : stripped.physics.length, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems);
|
|
2791
|
+
if (feet.length > 0) {
|
|
2792
|
+
const sets = readJson(join(foldDir, 'frames.json'), []);
|
|
2793
|
+
const listed = sets !== null && Array.isArray(sets.sets) ? sets.sets.filter(isRecord) : [];
|
|
2794
|
+
// readFoldSets has refused a missing set by name, so each animation has its directory here.
|
|
2795
|
+
const levelReadings = animationNames.map((name) => {
|
|
2796
|
+
const dir = join(foldDir, String((listed.find((x) => x.animation === name) as Record<string, unknown>).dir));
|
|
2797
|
+
return { animation: name, set: readFrameSet(dir), geometryPath: join(dir, GEOMETRY_FILE) };
|
|
2798
|
+
});
|
|
2799
|
+
level = levelLine(feet, inp.rig, levelReadings, fps, stripped === null ? 0 : stripped.physics.length, levelProblems);
|
|
2800
|
+
}
|
|
2221
2801
|
} finally {
|
|
2222
2802
|
rmSync(foldDir, { recursive: true, force: true });
|
|
2223
2803
|
}
|
|
@@ -2257,6 +2837,33 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2257
2837
|
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
|
|
2258
2838
|
}
|
|
2259
2839
|
|
|
2840
|
+
// 3c. a sleeve on one chain, its first link turned large (issue #347): a throwaway copy, physics left out, posed by rigc
|
|
2841
|
+
let stress: JudgementLine | null = null;
|
|
2842
|
+
const stressProblems: Problem[] = [];
|
|
2843
|
+
const stressTagged = full === null || idle === null ? [] : partsTagged(full.parts, STRESS_TAGS);
|
|
2844
|
+
if (stressTagged.length > 0) {
|
|
2845
|
+
const plan = stressPlan(inp.rig, inp.motion, stressTagged);
|
|
2846
|
+
const stressDir = join(out, STRESS_SCRATCH);
|
|
2847
|
+
rmSync(stressDir, { recursive: true, force: true });
|
|
2848
|
+
try {
|
|
2849
|
+
if (plan.read.length > 0) {
|
|
2850
|
+
mkdirSync(stressDir, { recursive: true });
|
|
2851
|
+
const base = stripped === null ? inp.rig : stripped.rig;
|
|
2852
|
+
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
2853
|
+
writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...base, images }));
|
|
2854
|
+
writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnimations(plan) }));
|
|
2855
|
+
const sb = rigc(['build', '--rig', join(stressDir, 'rig.json'), '--motion', join(stressDir, 'motion.json'), '--out', join(stressDir, 'build'), '--profile', 'spine']);
|
|
2856
|
+
if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${STRESS_LINE}'s turned copy)`, sb, buildGateLines(sb.out)));
|
|
2857
|
+
const sr = rigc(['render', '--candidate', join(stressDir, 'build'), '--fps', String(IDLE_FPS), '--max', String(FOLD_RENDER_MAX), '--geometry', '--out', join(stressDir, 'render')]);
|
|
2858
|
+
if (sr.status !== 0) refuseIfAny(rigcFailed(`render --geometry (${STRESS_LINE}'s turned copy)`, sr, sr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
2859
|
+
}
|
|
2860
|
+
const sets = plan.read.length > 0 ? readStressSets(join(stressDir, 'render'), Object.keys(stressAnimations(plan))) : new Map<string, IdleGeometry>();
|
|
2861
|
+
stress = stressLine(plan, (a) => sets.get(a) as IdleGeometry, stressProblems);
|
|
2862
|
+
} finally {
|
|
2863
|
+
rmSync(stressDir, { recursive: true, force: true });
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2260
2867
|
// 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
|
|
2261
2868
|
// the painting — and, beside it, the same pose with the eyes shut
|
|
2262
2869
|
let seam: SeamFigures | null = null;
|
|
@@ -2365,6 +2972,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2365
2972
|
STILL_REGIONS_DARK: still,
|
|
2366
2973
|
TEXTURE_STRETCH: stretch,
|
|
2367
2974
|
...(folds === null ? {} : { [FOLD_LINE]: folds }),
|
|
2975
|
+
...(level === null ? {} : { [LEVEL_LINE]: level }),
|
|
2976
|
+
...(stress === null ? {} : { [STRESS_LINE]: stress }),
|
|
2977
|
+
...(limbs === null ? {} : { [LIMB_LINE]: limbs }),
|
|
2368
2978
|
RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
|
|
2369
2979
|
...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
|
|
2370
2980
|
...(Object.keys(skipped).length === 0 ? {} : { skipped }),
|
|
@@ -2396,7 +3006,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2396
3006
|
const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
|
|
2397
3007
|
problems.sort((a, b) => rank(a) - rank(b));
|
|
2398
3008
|
problems.unshift(...barProblems);
|
|
2399
|
-
problems.push(...foldProblems, ...reqProblems);
|
|
3009
|
+
problems.push(...foldProblems, ...levelProblems, ...stressProblems, ...limbProblems, ...reqProblems);
|
|
2400
3010
|
figures.PASS = problems.length === 0;
|
|
2401
3011
|
write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
|
|
2402
3012
|
const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
|
|
@@ -2412,6 +3022,12 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
2412
3022
|
}
|
|
2413
3023
|
// Issues #320, #333: the fold bar is counted where it is written — on every rig whose motion holds an animation.
|
|
2414
3024
|
if (folds !== null) tally(FOLD_LINE, null);
|
|
3025
|
+
// Issue #348: the level bar where it is written; a line whose every foot no frame posed says SKIP and is not measured.
|
|
3026
|
+
if (level !== null) tally(LEVEL_LINE, level.status === 'SKIP' ? (level.reason ?? '') : null);
|
|
3027
|
+
// Issue #347: the stress bar is counted where it is written — on every rig with a handwear part and an idle.
|
|
3028
|
+
if (stress !== null) tally(STRESS_LINE, stress.status === 'SKIP' ? stress.reason : null);
|
|
3029
|
+
// Issue #349: the limb bar where it is written; it reads rig.json, so it always measures.
|
|
3030
|
+
if (limbs !== null) tally(LIMB_LINE, null);
|
|
2415
3031
|
return {
|
|
2416
3032
|
figures,
|
|
2417
3033
|
gateHtml,
|
|
@@ -2877,6 +3493,11 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
2877
3493
|
rows.push({ part: p.name, unmeasured: "declared not swinging by the scene file's not_swinging, so it is not held to the bar" });
|
|
2878
3494
|
continue;
|
|
2879
3495
|
}
|
|
3496
|
+
// Issue #349: a limb segment is rigid on its one bone and turns at its joint; it does not hang, so no tip trails a root.
|
|
3497
|
+
if (p.limb !== undefined) {
|
|
3498
|
+
rows.push({ part: p.name, unmeasured: `a limb segment (config.limbs.${p.limb.name}), rigid on its one bone "${p.limb.bone}": it turns at its joint and does not hang, so it is not held to the bar` });
|
|
3499
|
+
continue;
|
|
3500
|
+
}
|
|
2880
3501
|
const rises = excepted.rises.includes(p.name);
|
|
2881
3502
|
const rests = excepted.rests.includes(p.name);
|
|
2882
3503
|
const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
|