@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.
@@ -22,7 +22,7 @@
22
22
  * undeclared, which would leave both rows `not-measurable` (§8: "a pair no
23
23
  * track declares makes both rows not-measurable"). A track moves bone `b`
24
24
  * when a bone it keys is `b` or an ancestor of `b`: Spine bones inherit
25
- * their parent's rotation and scale (the rig writes no inherit mode), so a
25
+ * their parent's rotation and scale (where the rig writes no inherit mode), so a
26
26
  * pair the track moves together — both under one keyed bone — or not at all
27
27
  * keeps its relative linear part, θ 0 (§8: "a bone that does not move is
28
28
  * declared with θ 0"); a pair it moves on one side only gets the track's θ.
@@ -57,6 +57,11 @@
57
57
  * the author left it out — rig-c 2.29.0 then measures `MQ_DEFORM_LOAD`, which
58
58
  * never read G, and reads `MQ_ALLOCATION_CONTRAST` `not-measurable` naming
59
59
  * the gradation. No value is derived or defaulted here.
60
+ * - **A declared inherit mode** (issue #348): a bone between a keyed bone and
61
+ * a bound one — the bound bone itself included — whose `inherit` is not
62
+ * `normal` takes less than the key's rotation or scale, by its mode, so the
63
+ * track's θ for that pair is not composed here: the term stops, naming the
64
+ * bone and its mode, and the amplitude is not sent.
60
65
  * - **Constraints** move bones by what they solve, not by a key: a rig with
61
66
  * any constraint is refused by name, because no declared amplitude bounds
62
67
  * what a constraint does.
@@ -73,7 +78,7 @@ import type { MotionKey, MotionSpec } from './motion.ts';
73
78
  * — `rig.json`'s bones, `motion.json`, the config's constraints — so both build the one input `reductionKey` matches.
74
79
  */
75
80
  export interface AmplitudeBasis {
76
- bones: ReadonlyArray<{ name: string; parent?: string }>;
81
+ bones: ReadonlyArray<{ name: string; parent?: string; inherit?: string }>;
77
82
  motion: MotionSpec;
78
83
  constraints: number;
79
84
  }
@@ -133,7 +138,7 @@ export type AmplitudeDerivation = { amplitude: MotionAmplitude } | { stops: Ampl
133
138
  * reason to stop (rig-c 2.29.0 admits null).
134
139
  */
135
140
  export function deriveMotionAmplitude(args: {
136
- bones: ReadonlyArray<{ name: string; parent?: string }>;
141
+ bones: ReadonlyArray<{ name: string; parent?: string; inherit?: string }>;
137
142
  motion: MotionSpec;
138
143
  constraints: number;
139
144
  bound: readonly string[];
@@ -142,6 +147,13 @@ export function deriveMotionAmplitude(args: {
142
147
  }): AmplitudeDerivation {
143
148
  const stops: AmplitudeStop[] = [];
144
149
  const parent = new Map(args.bones.map((b) => [b.name, b.parent ?? null]));
150
+ const mode = new Map(args.bones.filter((b) => b.inherit !== undefined && b.inherit !== 'normal').map((b) => [b.name, b.inherit as string]));
151
+ /** The bones from `bone` up to `k`, `k` left out, that declare an inherit mode other than normal (issue #348). */
152
+ const declared = (bone: string, k: string): string[] => {
153
+ const out: string[] = [];
154
+ for (let b: string | null = bone; b !== null && b !== k; b = parent.get(b) ?? null) if (mode.has(b)) out.push(b);
155
+ return out;
156
+ };
145
157
  const under = (bone: string, k: string): boolean => {
146
158
  for (let b: string | null = bone; b !== null; b = parent.get(b) ?? null) if (b === k) return true;
147
159
  return false;
@@ -159,6 +171,14 @@ export function deriveMotionAmplitude(args: {
159
171
  const name = `${t.bone ?? t.group ?? '?'}.${t.property}#${i}`;
160
172
  const moverOf = (b: string): string[] => keyed.filter((k) => under(b, k));
161
173
  if (!bound.some((b) => moverOf(b).length > 0)) return;
174
+ const through = [...new Set(bound.flatMap((b) => moverOf(b).flatMap((k) => declared(b, k))))];
175
+ if (through.length > 0) {
176
+ stops.push({
177
+ term: `tracks[${name}].pairs[].theta`,
178
+ detail: `the track keys [${keyed.join(', ')}], and ${through.map((b) => `"${b}" declares inherit "${mode.get(b)}"`).join(', ')} below it, so what the key does to the bones under it is not the key's own rotation or scale; θ is composed here only through bones that inherit normally, and none is invented`,
179
+ });
180
+ return;
181
+ }
162
182
  const th = trackTheta(t.property, t.keys);
163
183
  if ('refused' in th) {
164
184
  stops.push({ term: `tracks[${name}].pairs[].theta`, detail: th.refused });
@@ -58,7 +58,10 @@
58
58
  * setup by; an unkeyed property is [0, 0], [1, 1] or 0. `pivot` is the
59
59
  * bone's setup joint in the part's drawing frame (crop px less the part's
60
60
  * padded origin, y down). `setup` is the rig's as written: this package
61
- * writes no bone scale, shear or inherit mode, so `{ 1, 1, 0, 0, 'normal' }`.
61
+ * writes no bone scale or shear, so `{ 1, 1, 0, 0, 'normal' }` — with the
62
+ * bone's own `inherit` where config.bones declares one (issue #348), handed
63
+ * to rig-c as written, which refuses any mode but `normal` by name
64
+ * (`SKINNING_RANGE_UNSUPPORTED`): the residual is then not measurable.
62
65
  * A shear key has no field in `BoneMotionRange`, so a bone keyed by one
63
66
  * makes the residual not measurable (`ENVELOPE_SHEAR_KEYED`); a property
64
67
  * the idle writes and none of the above names is refused the same way.
@@ -86,7 +89,7 @@ import { edgeLength } from './length.ts';
86
89
 
87
90
  /** What the derivation reads off the rig besides the part: the bones (name, parent), each bone's setup joint in crop px, the idle as written, the constraints. */
88
91
  export interface EnvelopeBasis {
89
- bones: ReadonlyArray<{ name: string; parent?: string }>;
92
+ bones: ReadonlyArray<{ name: string; parent?: string; inherit?: string }>;
90
93
  joints: ReadonlyMap<string, readonly [number, number]>;
91
94
  motion: MotionSpec;
92
95
  constraints: readonly ConfigConstraint[];
@@ -105,8 +108,11 @@ export type EnvelopeDerivation = { envelope: SkinningEnvelope } | { stops: Envel
105
108
  /** Where a range comes from: the bone's keys, or the kind of the constraint that drives it — which rig-c refuses by name. */
106
109
  type RangeSource = 'keys' | 'ik' | 'transform' | 'path' | 'physics';
107
110
 
108
- /** A `BoneMotionRange` whose `source` may name a constraint kind: rig-c reads `source` at run time and certifies `'keys'` alone. */
109
- type DeclaredRange = Omit<BoneMotionRange, 'source'> & { source: RangeSource };
111
+ /**
112
+ * A `BoneMotionRange` whose `source` may name a constraint kind, and whose `setup.inherit` may name a declared mode
113
+ * (issue #348): rig-c reads both at run time and certifies `'keys'` and `'normal'` alone.
114
+ */
115
+ type DeclaredRange = Omit<BoneMotionRange, 'source' | 'setup'> & { source: RangeSource; setup: Omit<BoneMotionRange['setup'], 'inherit'> & { inherit: string } };
110
116
 
111
117
  /**
112
118
  * rig-c's helper over ranges whose `source` may be a constraint kind. rig-c's type admits only the one value it
@@ -136,6 +142,12 @@ function trackValues(keys: readonly MotionKey[]): number[] {
136
142
 
137
143
  const SETUP: BoneMotionRange['setup'] = { scaleX: 1, scaleY: 1, shearX: 0, shearY: 0, inherit: 'normal' };
138
144
 
145
+ /** The bone's declared inherit mode as the rig writes it (issue #348), or nothing, so {@link SETUP}'s `normal` stands. */
146
+ function inheritOf(basis: EnvelopeBasis, bone: string): { inherit: string } | Record<string, never> {
147
+ const mode = basis.bones.find((b) => b.name === bone)?.inherit;
148
+ return mode === undefined ? {} : { inherit: mode };
149
+ }
150
+
139
151
  /** One bone's declared range off the idle, or the stop that names why it has none. */
140
152
  export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [number, number], driven: ReadonlyMap<string, RangeSource>): DeclaredRange | EnvelopeStop {
141
153
  const joint = basis.joints.get(bone);
@@ -170,7 +182,7 @@ export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [
170
182
  scaleX,
171
183
  scaleY,
172
184
  translate: edgeLength(tx, ty),
173
- setup: { ...SETUP },
185
+ setup: { ...SETUP, ...inheritOf(basis, bone) },
174
186
  };
175
187
  }
176
188
 
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, driftLine, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -70,15 +70,15 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { besideIdleLine, FOLD_LINE, type FoldsLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
- import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
73
+ import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
+ import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
77
77
  import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
- import { type PartRecord, readParts, tagOf, writeParts } from './parts.ts';
81
+ import { type PartRecord, readParts, type SingleRunPad, tagOf, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
@@ -106,8 +106,12 @@ export function readSource(path: string): Raster {
106
106
  }
107
107
  }
108
108
 
109
- /** Read both runs, collecting the refusals of both before throwing. */
110
- export function readRuns(full: string, head: string): { full: LayerSet; head: LayerSet } {
109
+ /**
110
+ * Read both runs, collecting the refusals of both before throwing. With no
111
+ * head run (`head` null, issue #359) the full run is read once and stands for
112
+ * both, and `single` says so: the stage then holds it to `checkSingleRun`.
113
+ */
114
+ export function readRuns(full: string, head: string | null): { full: LayerSet; head: LayerSet; single: boolean } {
111
115
  const problems: Problem[] = [];
112
116
  const one = (path: string): LayerSet | null => {
113
117
  try {
@@ -121,15 +125,16 @@ export function readRuns(full: string, head: string): { full: LayerSet; head: La
121
125
  }
122
126
  };
123
127
  const f = one(full);
124
- const h = one(head);
128
+ const h = head === null ? f : one(head);
125
129
  if (problems.length > 0) throw new PartsError(problems);
126
- return { full: f as LayerSet, head: h as LayerSet };
130
+ return { full: f as LayerSet, head: h as LayerSet, single: head === null };
127
131
  }
128
132
 
129
133
  export interface AssembleStageInput {
130
134
  source: string;
131
135
  full: string;
132
- head: string;
136
+ /** The head run, or null for one run that holds the face (issue #359). */
137
+ head: string | null;
133
138
  config: string;
134
139
  seam: SeamRule;
135
140
  project: ProjectRule;
@@ -200,15 +205,46 @@ export function readUnders(configPath: string, cuts: readonly Cut[]): Array<Rast
200
205
  return out;
201
206
  }
202
207
 
208
+ /**
209
+ * Each limb segment's `cap` image (issue #349), read from its path relative to
210
+ * the config's directory and keyed `<limb>/<segment index>`. Every one is read
211
+ * before one refusal names them all: no such file
212
+ * (`ASSEMBLE_LIMB_CAP_PRESENT`), or a file that does not decode as a PNG
213
+ * (`ASSEMBLE_LIMB_CAP_DECODES`). The stage holds the image to the cap's box.
214
+ */
215
+ export function readCaps(configPath: string, limbs: Readonly<Record<string, Limb>>): Map<string, Raster> {
216
+ const problems: Problem[] = [];
217
+ const out = new Map<string, Raster>();
218
+ for (const [l, limb] of Object.entries(limbs)) {
219
+ limb.segments.forEach((sg, k) => {
220
+ if (sg.cap === undefined) return;
221
+ const file = resolve(dirname(resolve(configPath)), sg.cap);
222
+ const object = `config.limbs.${l}.segments[${k}].cap (part "${sg.part}")`;
223
+ if (!existsSync(file) || !statSync(file).isFile()) {
224
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_PRESENT', object, detail: `names ${file}, which is no such file; a PNG of the cap, its path relative to the config's directory, is required` });
225
+ return;
226
+ }
227
+ try {
228
+ out.set(`${l}/${k}`, readPng(file));
229
+ } catch (err) {
230
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_DECODES', object, detail: `${file}: ${(err as Error).message.split('\n')[0]}; a PNG is required` });
231
+ }
232
+ });
233
+ }
234
+ refuseIfAny(problems);
235
+ return out;
236
+ }
237
+
203
238
  /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
204
239
  export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
205
240
  const src = readSource(input.source);
206
241
  const runs = readRuns(input.full, input.head);
207
242
  // The early door: assemble runs before propose has drafted bones, meshes,
208
243
  // regions and motion, so it requires only what it reads (issue #20).
209
- const fields = stageFields(loadEarlyConfig(input.config, 'assemble'));
244
+ const fields = stageFields(loadEarlyConfig(input.config, 'assemble'), runs.single ? { w: src.width, h: src.height } : null);
210
245
  const under = readUnders(input.config, fields.cuts);
211
- const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, seamRule: input.seam, projectRule: input.project });
246
+ const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
247
+ const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
212
248
  // Emit only after green: every refusal above has already thrown.
213
249
  mkdirSync(outs.partsDir, { recursive: true });
214
250
  mkdirSync(dirname(outs.partsJson), { recursive: true });
@@ -223,6 +259,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
223
259
  if (figurePath !== null) writePng(figurePath, result.figure as Raster);
224
260
  const [W, H] = result.parts.rig_size;
225
261
  log(`rig-parts assemble: ${result.images.length} part(s) on a ${W}x${H} rig (${result.parts.scale_rig_per_source} rig px per source px), seam rule ${result.seamRule}, projection rule ${result.projectRule}`);
262
+ // Issue #359: one run, said, so a reader of the lines knows no head run was placed.
263
+ if (runs.single) log(` one run: ${input.full} holds the face (${SINGLE_RUN_FACE_TAGS.join(', ')}), so no head run is read; every part comes from the full run`);
264
+ // Issue #366: a file that is not the resolution square was padded to it; said, with where parts.json records it. None for a square file.
265
+ const pad = result.parts.single_run_pad;
266
+ if (pad !== undefined) log(singleRunPadLine(input.full, pad));
226
267
  for (const p of result.parts.parts) {
227
268
  log(
228
269
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
@@ -234,7 +275,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
234
275
  for (const l of authorPartLines(result.parts.parts)) log(l);
235
276
  // One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
236
277
  for (const l of cutLines(result.cuts)) log(l);
278
+ // Issue #349: one line per part a limb took, and one per joint with its cap's pixels. None without limbs.
279
+ for (const l of limbLines(result.limbs)) log(l);
237
280
  for (const l of fringeLines(result.parts.parts)) log(l);
281
+ // Issue #359: one line per hidden-art layer and what read it. None without one.
282
+ for (const l of hiddenLines(result.hidden)) log(l);
238
283
  const total = (k: 'opaque_px' | 'visible_px' | 'occluded_px' | 'source_px_taken' | 'visible_not_projected_px'): number => result.parts.parts.reduce((a, p) => a + (p[k] ?? 0), 0);
239
284
  const [op, vis, occ, taken, unproj] = (['opaque_px', 'visible_px', 'occluded_px', 'source_px_taken', 'visible_not_projected_px'] as const).map(total);
240
285
  const pct = (n: number): string => `${((100 * n) / op).toFixed(1)}%`;
@@ -253,6 +298,16 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
253
298
  return result;
254
299
  }
255
300
 
301
+ /**
302
+ * The line `assemble` prints under `one run:` when the file was padded (issue
303
+ * #366): its size, the square and the four margins, and that `parts.json`'s
304
+ * `single_run_pad` records them.
305
+ */
306
+ export function singleRunPadLine(file: string, pad: SingleRunPad): string {
307
+ const [w, h] = pad.file_size;
308
+ return ` padded: ${file} is ${w}x${h}, centred on the ${pad.side}x${pad.side} run square (left ${pad.left}, right ${pad.side - w - pad.left}, top ${pad.top}, bottom ${pad.side - h - pad.top}); parts.json single_run_pad records it, and every part's box is in the file's pixels times scale_rig_per_source`;
309
+ }
310
+
256
311
  /**
257
312
  * The scale the per-part line prints `drift=` with (issue #314): `refused_drift_px` counts the part's projection
258
313
  * candidates (`projected_core_px`, the `src=` column's second figure) refused because See-through's colour sits more
@@ -1046,6 +1101,40 @@ export function foldsLines(line: FoldsLine): string[] {
1046
1101
  return [head, ...line.folding.map((r) => ` folds ${r.line}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`)];
1047
1102
  }
1048
1103
 
1104
+ /**
1105
+ * The feet-level line (issue #348) as the console prints it: the status, what was read, the bar and whose it is, then
1106
+ * each foot on a line of its own — its bone, what that hangs from, how many frames posed it, and the worst turn with
1107
+ * the bone that was turned and the bones it came through. A SKIP prints its reason.
1108
+ */
1109
+ export function levelLines(line: LevelLine): string[] {
1110
+ const physics = line.physics_left_out === 0 ? '' : `, its ${line.physics_left_out} physics constraint(s) left out`;
1111
+ const read = `${line.animations.length} animation(s) read by rigc render --folds --geometry at ${line.fps} fps${physics} (${line.animations.join(', ')})`;
1112
+ const head =
1113
+ line.status === 'SKIP'
1114
+ ? `${LEVEL_LINE}: SKIP — ${String(line.reason)}; ${read}`
1115
+ : `${LEVEL_LINE}: ${line.status} — ${read}: each foot's drawing within ±${line.bar_degrees} degrees of its setup pose while a bone above it is turned, ${line.bar}`;
1116
+ return [
1117
+ head,
1118
+ ...line.feet.map((f) => {
1119
+ const at = `foot "${f.part}" on "${f.bone}" (hangs from "${f.hangs_from}"): ${f.status}, ${f.frames_posed} posed frame(s)`;
1120
+ if (f.worst === null) return `${at}, so nothing was measured`;
1121
+ const w = f.worst;
1122
+ return `${at}; worst ${w.degrees} degrees in "${w.animation}" frame ${w.frame} (t = ${w.time}s), "${w.turned}" turned ${w.turned_degrees}, through ${w.through.map((x) => `${x.bone} (inherit ${x.inherit})`).join(', ')}`;
1123
+ }),
1124
+ ];
1125
+ }
1126
+
1127
+ /**
1128
+ * The limb line (issue #349) as the console prints it: the status and the bar, then each limb part on a line of its
1129
+ * own — its limb, its bone, and on a FAIL every other bone it is bound to.
1130
+ */
1131
+ export function limbCheckLines(line: LimbLine): string[] {
1132
+ return [
1133
+ `${LIMB_LINE}: ${line.status} — ${line.parts.length} limb part(s), each bound in rig.json to its one bone with weight 1 (a region on it, or a mesh weighted to it alone)`,
1134
+ ...line.parts.map((r) => `limb "${r.part}" (${r.limb}) on "${r.bone}": ${r.status}${r.others.length === 0 ? '' : `, also weighted to ${r.others.map((b) => `"${b}"`).join(', ')}`}`),
1135
+ ];
1136
+ }
1137
+
1049
1138
  /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
1050
1139
  export function requirementText(name: string, line: RequirementLine): string {
1051
1140
  const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
@@ -1072,7 +1161,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1072
1161
  if (r.idle === null) log(` loop: SKIP — ${fig.skipped?.loop ?? ''}`);
1073
1162
  else log(` ${loopCheckLine(r.idle, fig.loop_max_diff, fig.loop_physics?.length ?? 0)}`);
1074
1163
  if (r.loopPhysics !== null) log(` ${r.loopPhysics}`);
1075
- const beside = besideIdleLine(r.besideIdle);
1164
+ const beside = besideIdleLine(r.besideIdle, r.figures[LEVEL_LINE] !== undefined);
1076
1165
  if (beside !== null) log(` ${beside}`);
1077
1166
  if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
1078
1167
  else {
@@ -1086,6 +1175,12 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1086
1175
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
1087
1176
  const folds = fig[FOLD_LINE];
1088
1177
  if (folds !== undefined) for (const l of foldsLines(folds)) log(` ${l}`);
1178
+ const level = fig[LEVEL_LINE];
1179
+ if (level !== undefined) for (const [i, l] of levelLines(level).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1180
+ const stress = fig[STRESS_LINE];
1181
+ if (stress !== undefined) log(` ${judgementLine(STRESS_LINE, stress)}`);
1182
+ const limbs = fig[LIMB_LINE];
1183
+ if (limbs !== undefined) for (const [i, l] of limbCheckLines(limbs).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1089
1184
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1090
1185
  const vsSource = fig[SOURCE_LINE];
1091
1186
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
@@ -1348,7 +1443,8 @@ export interface BuildInput {
1348
1443
  config: string;
1349
1444
  source: string;
1350
1445
  full: string;
1351
- head: string;
1446
+ /** The head run, or null for one run that holds the face (issue #359). */
1447
+ head: string | null;
1352
1448
  out: string;
1353
1449
  seam: SeamRule;
1354
1450
  project: ProjectRule;