@rig-c/parts 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.ts CHANGED
@@ -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}). The part-alone renders, the shut-eye still, the keyed
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, and {@link FOLD_LINE} (issue #320).
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). A bar that measured nothing (null figures, `SKIP`) is not
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
- 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`;
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
- const fr = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
2218
- if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds --fps ${fps}${stripped === null ? '' : ' (physics constraint(s) left out)'}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
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'];