@rig-c/parts 1.5.5 → 1.7.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/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, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, 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, stimulusMotion, withStimulus } from './automotion.ts';
@@ -70,7 +70,7 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { BARS, besideIdleLine, causeLines, 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, 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';
73
+ import { BARS, besideIdleLine, 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, 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
74
  import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
@@ -82,7 +82,8 @@ import { type PartRecord, readParts, 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';
85
- import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
85
+ import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
86
+ import { pyRound } from './round.ts';
86
87
 
87
88
  /** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
88
89
  export type Log = (line: string) => void;
@@ -228,6 +229,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
228
229
  log(` ghost px removed: ${ghosts} over ${Object.keys(result.parts.ghost_px).length} layer(s)`);
229
230
  log(figuresLine(result.figures));
230
231
  for (const l of holeLines(result.figures)) log(l);
232
+ for (const u of result.uncovered) log(uncoveredLine(u));
231
233
  log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
232
234
  return result;
233
235
  }
@@ -818,6 +820,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
818
820
  ` bones ${rig.rig.bones.length} (${rig.controls.length} control) slots ${rig.rig.slots.length} meshes ${rig.meshReport.length} regions ${regions} vertices ${vertices}; idle ${rig.motion.animations.idle.duration} s, ${tracks.length} track(s), ${keys} key(s)`,
819
821
  );
820
822
  if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
823
+ log(faceAxisLine(rig.faceAxis));
821
824
  log(
822
825
  rig.idleKeys === 'ctl'
823
826
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
@@ -940,6 +943,24 @@ export function barsSummary(r: CheckReport): string {
940
943
  return `${measured.length} of ${BARS.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
941
944
  }
942
945
 
946
+ /**
947
+ * The rig stage's face-axis line (issue #239): the eye line's angle, the parts it runs between and the face bones
948
+ * turned to it, or why there is no line and the face bones keep the page's axes — the note a one-eyed painting gets.
949
+ * With a line it also names the `invariants.axes` entry written for rigc's A51, its tolerance, or why none was.
950
+ */
951
+ export function faceAxisLine(f: FaceAxis): string {
952
+ const own = f.ownTip.length === 0 ? '' : `; ${f.ownTip.join(', ')} ${f.ownTip.length === 1 ? 'takes its' : 'take their'} own tip`;
953
+ if (f.degrees === null) return ` face axis: none — ${f.none}; ${f.ownTip.length === 0 ? 'the face bones keep' : 'the other face bones keep'} the page's axes${own}`;
954
+ const turned = f.turned.length === 0 ? 'no face bone to turn' : `${f.turned.join(', ')} turned to it`;
955
+ const held =
956
+ f.axis !== null
957
+ ? `; invariants.axes "${f.axis.name}" holds ${f.axis.bones.join(', ')} to the line through ${f.axis.through.join(' -> ')}, tolerance ${f.axis.tolerance} degrees (rigc's A51)`
958
+ : f.noAxis !== null
959
+ ? `; no invariants.axes entry: ${f.noAxis}`
960
+ : '';
961
+ return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}`;
962
+ }
963
+
943
964
  /** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
944
965
  export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
945
966
  if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
@@ -1026,19 +1047,141 @@ export interface LoopResult {
1026
1047
  error: PaletteError | null;
1027
1048
  }
1028
1049
 
1050
+ /**
1051
+ * The idle `build --loop --lead-in <n>` renders (issue #224's follow-up):
1052
+ * `idle` played n + 1 times back to back, of which the loop keeps the last
1053
+ * period. rigc's render resets physics at frame 0, so an idle whose rig
1054
+ * carries a physics constraint does not end where it starts; played on, a
1055
+ * spring the idle drives settles into the idle's period, and the last period
1056
+ * of the run is that steady state.
1057
+ */
1058
+ export const LEAD_IN_ANIMATION = 'idle_lead_in';
1059
+
1060
+ /** Which frames of a lead-in run the loop keeps: the last of `periods + 1` periods. */
1061
+ export interface LoopWindow {
1062
+ periods: number;
1063
+ }
1064
+
1065
+ const LEAD_IN_ANIMATION_KEYS = ['duration', 'loop', 'note', 'tracks'];
1066
+
1067
+ /**
1068
+ * `motion` with {@link LEAD_IN_ANIMATION} added: every idle track's keys
1069
+ * repeated `periods + 1` times, each copy shifted by one idle duration, its
1070
+ * Bezier `curve` times with it, and the key at the end of a copy dropped for
1071
+ * the next copy's first key at the same time. Refused by name
1072
+ * (`LOOP_LEAD_IN_IDLE`), every problem at once, when the motion has no idle,
1073
+ * already has an animation of that name, or the idle is not a loop this can
1074
+ * repeat without inventing a value: a field besides duration, loop, note and
1075
+ * tracks; a track whose keys do not start at 0 and end at the duration; or one
1076
+ * whose last value is not its first.
1077
+ */
1078
+ export function repeatIdle(motion: Record<string, unknown>, periods: number): Record<string, unknown> {
1079
+ const problems: Problem[] = [];
1080
+ const fail = (object: string, detail: string): void => {
1081
+ problems.push({ code: 'LOOP_LEAD_IN_IDLE', object, detail });
1082
+ };
1083
+ const anims = motion.animations;
1084
+ const table = typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? (anims as Record<string, unknown>) : null;
1085
+ const idle = table?.idle;
1086
+ if (table === null || typeof idle !== 'object' || idle === null || Array.isArray(idle)) {
1087
+ fail('motion.json animations.idle', 'is absent; the lead-in repeats the idle');
1088
+ refuseIfAny(problems);
1089
+ }
1090
+ const t = table as Record<string, unknown>;
1091
+ const a = idle as Record<string, unknown>;
1092
+ if (LEAD_IN_ANIMATION in t) fail(`motion.json animations.${LEAD_IN_ANIMATION}`, 'exists; the lead-in writes an animation of that name and never replaces one');
1093
+ for (const k of Object.keys(a)) if (!LEAD_IN_ANIMATION_KEYS.includes(k)) fail(`motion.json animations.idle.${k}`, `is a field the lead-in does not repeat; the idle it repeats holds ${LEAD_IN_ANIMATION_KEYS.join(', ')}`);
1094
+ const d = a.duration;
1095
+ if (typeof d !== 'number' || !(d > 0)) fail('motion.json animations.idle.duration', `is ${JSON.stringify(d)}; a positive number is required`);
1096
+ const tracks = Array.isArray(a.tracks) ? a.tracks : [];
1097
+ const duration = d as number;
1098
+ const out: unknown[] = [];
1099
+ tracks.forEach((track, i) => {
1100
+ const tr = track as Record<string, unknown>;
1101
+ const keys = Array.isArray(tr.keys) ? (tr.keys as Array<Record<string, unknown>>) : [];
1102
+ const at = `motion.json animations.idle.tracks[${i}]`;
1103
+ const first = keys[0];
1104
+ const last = keys[keys.length - 1];
1105
+ if (first === undefined || first.t !== 0 || last.t !== duration) {
1106
+ fail(at, `keys run from ${String(first?.t)} to ${String(last?.t)}; a track the lead-in repeats starts at 0 and ends at the duration ${duration}`);
1107
+ return;
1108
+ }
1109
+ if (JSON.stringify(first.v) !== JSON.stringify(last.v)) {
1110
+ fail(at, `ends at ${JSON.stringify(last.v)} and starts at ${JSON.stringify(first.v)}; repeated, the idle would jump where one copy meets the next`);
1111
+ return;
1112
+ }
1113
+ const repeated: Array<Record<string, unknown>> = [];
1114
+ for (let r = 0; r <= periods; r++) {
1115
+ const shift = r * duration;
1116
+ keys.forEach((k, j) => {
1117
+ if (r < periods && j === keys.length - 1) return;
1118
+ const moved: Record<string, unknown> = { ...k, t: (k.t as number) + shift };
1119
+ if (Array.isArray(k.curve)) moved.curve = (k.curve as number[]).map((c, n) => (n % 2 === 0 ? c + shift : c));
1120
+ repeated.push(moved);
1121
+ });
1122
+ }
1123
+ out.push({ ...tr, keys: repeated });
1124
+ });
1125
+ refuseIfAny(problems);
1126
+ return { ...motion, animations: { ...t, [LEAD_IN_ANIMATION]: { duration: duration * (periods + 1), loop: false, tracks: out } } };
1127
+ }
1128
+
1129
+ /**
1130
+ * Render {@link LEAD_IN_ANIMATION} of the rig in `rigDir` as `check` renders
1131
+ * the idle (the packed build, `IDLE_FPS`, `IDLE_MAX_PX`), every other
1132
+ * animation kept so the viewport is fitted as for the idle frames. Writes
1133
+ * under `dir` and returns the frame set's directory.
1134
+ */
1135
+ export function leadInFrames(rigDir: string, dir: string, periods: number, rigc: RigcRunner, mode: PackMode): string {
1136
+ const rig = JSON.parse(readFileSync(join(rigDir, 'rig.json'), 'utf8')) as Record<string, unknown>;
1137
+ const motion = repeatIdle(JSON.parse(readFileSync(join(rigDir, 'motion.json'), 'utf8')) as Record<string, unknown>, periods);
1138
+ const images = typeof rig.images === 'string' ? resolve(rigDir, rig.images) : rig.images;
1139
+ mkdirSync(dir, { recursive: true });
1140
+ writeFileSync(join(dir, 'rig.json'), JSON.stringify({ ...rig, images }));
1141
+ writeFileSync(join(dir, 'motion.json'), JSON.stringify(motion));
1142
+ const what = `the idle played ${periods + 1} times (${LEAD_IN_ANIMATION})`;
1143
+ const b = rigc(['build', '--rig', join(dir, 'rig.json'), '--motion', join(dir, 'motion.json'), '--out', join(dir, 'build'), ...packedBuildArgs(mode)]);
1144
+ if (b.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, b, buildGateLines(b.out)));
1145
+ const r = rigc(['render', '--candidate', join(dir, 'build'), '--animation', LEAD_IN_ANIMATION, '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(dir, 'frames')]);
1146
+ if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation ${LEAD_IN_ANIMATION} (${what})`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1147
+ return join(dir, 'frames');
1148
+ }
1149
+
1150
+ /** Pixels whose largest RGB channel difference between `a` and `b` is above `level`. */
1151
+ function pixelsOver(a: Raster, b: Raster, level: number): number {
1152
+ let n = 0;
1153
+ for (let p = 0; p < a.width * a.height; p++) {
1154
+ let m = 0;
1155
+ for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(a.data[p * 4 + c] - b.data[p * 4 + c]));
1156
+ if (m > level) n++;
1157
+ }
1158
+ return n;
1159
+ }
1160
+
1029
1161
  /**
1030
1162
  * Encode a rigc frame set as a looping APNG, indexed APNG or GIF and write it.
1031
1163
  * An encoder refusal is a PartsError coded `LOOP_ENCODE`, so it prints like
1032
1164
  * every other.
1033
1165
  */
1034
- export function loopStage(frames: string, out: string, format: LoopFormat, log: Log): LoopResult {
1166
+ export function loopStage(frames: string, out: string, format: LoopFormat, log: Log, window?: LoopWindow): LoopResult {
1035
1167
  const set = readFrameSet(frames);
1036
1168
  const images = set.frames.map((fr) => fr.image);
1037
1169
  log(`rig-parts loop: ${set.dir} -> ${out}`);
1038
1170
  log(` ${images.length} frame(s) ${images[0]?.width ?? 0}x${images[0]?.height ?? 0} at ${set.fps} fps (from frames.json), animation ${set.animation ?? '(none)'}`);
1039
1171
  let used = images;
1040
1172
  const lastName = set.frames[set.frames.length - 1]?.name ?? '';
1041
- if (images.length > 1 && sameRgba(images[0].data, images[images.length - 1].data)) {
1173
+ if (window !== undefined) {
1174
+ const per = (images.length - 1) / (window.periods + 1);
1175
+ if (!Number.isInteger(per) || per < 1) {
1176
+ throw new PartsError([{ code: 'LOOP_LEAD_IN_IDLE', object: set.dir, detail: `holds ${images.length} frame(s), which is not ${window.periods + 1} whole period(s) and the end frame; the lead-in keeps the last period` }]);
1177
+ }
1178
+ const start = window.periods * per;
1179
+ const end = start + per;
1180
+ const jump = maxRgbDiff(images[start], images[end]);
1181
+ used = images.slice(start, end);
1182
+ const name = (i: number): string => set.frames[i].name.replace('.png', '');
1183
+ log(` lead-in: ${window.periods} period(s) of the idle played ahead, physics running; ${name(start)}–${name(end - 1)} kept (${per} frame(s), the last period); ${name(end)}, one period after ${name(start)}, differs from it by max |d| ${jump.max}, ${pixelsOver(images[start], images[end], 40)} px over 40 — the jump at the wrap`);
1184
+ } else if (images.length > 1 && sameRgba(images[0].data, images[images.length - 1].data)) {
1042
1185
  used = images.slice(0, -1);
1043
1186
  log(` ${lastName} equals f0000.png byte for byte, so it is dropped: the loop wraps onto frame 0, and showing it twice would hold that pose for two ticks`);
1044
1187
  } else if (images.length > 1) {
@@ -1102,6 +1245,12 @@ export interface BuildInput {
1102
1245
  requirements?: string;
1103
1246
  /** `--idle-keys`, forwarded to the rig stage as `rig` takes it (issue #95); {@link DEFAULT_IDLE_KEYS} when absent. */
1104
1247
  idleKeys?: IdleKeys;
1248
+ /**
1249
+ * `--lead-in <n>`, with `loop` only: the loop is encoded from the last of n + 1
1250
+ * idle periods played back to back ({@link leadInFrames}) instead of from
1251
+ * `check/idle_frames/`; absent, the loop is as before.
1252
+ */
1253
+ leadIn?: number;
1105
1254
  }
1106
1255
 
1107
1256
  /**
@@ -1115,7 +1264,7 @@ export interface BuildRunners {
1115
1264
  check: RigcRunner;
1116
1265
  /** Where the check's rigc binary is, for its header line. */
1117
1266
  checkBin: string;
1118
- /** A directory the rig stage may stage its gate in; emptied before and after. */
1267
+ /** A directory the rig stage may stage its gate in, and the loop its lead-in render after it; emptied before and after each. */
1119
1268
  scratch: string;
1120
1269
  }
1121
1270
 
@@ -1177,7 +1326,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1177
1326
  for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
1178
1327
  // The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
1179
1328
  const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
1180
- log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? ', with the idle loop' : ''}`);
1329
+ log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
1181
1330
 
1182
1331
  try {
1183
1332
  // build runs rig next, which needs the whole config, so the full loader is
@@ -1214,12 +1363,15 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1214
1363
  }
1215
1364
 
1216
1365
  if (input.loop) {
1217
- const frames = join(out, 'check', 'idle_frames');
1218
1366
  try {
1219
1367
  const say = prefixed('loop');
1220
- const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say);
1221
- const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say);
1222
- const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say);
1368
+ const window = input.leadIn === undefined ? undefined : { periods: input.leadIn };
1369
+ if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
1370
+ const frames = window === undefined ? join(out, 'check', 'idle_frames') : leadInFrames(join(out, 'rig'), run.scratch, window.periods, run.check, { pageEdges: input.pageEdges, packShape: input.packShape ?? DEFAULT_PACK_SHAPE });
1371
+ const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say, window);
1372
+ const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say, window);
1373
+ const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say, window);
1374
+ if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
1223
1375
  const fig = (e: PaletteError | null): string => (e === null ? 'lossless' : `max ${e.max}, mean ${e.mean.toFixed(3)}`);
1224
1376
  say(`loop: idle.png ${lossless.bytes} B (${fig(lossless.error)}); idle-indexed.png ${indexed.bytes} B (${fig(indexed.error)}); idle.gif ${gif.bytes} B (${fig(gif.error)}) — palette error per channel over all frames`);
1225
1377
  } catch (err) {
package/src/check.ts CHANGED
@@ -111,6 +111,8 @@ import {
111
111
  topmostIndex,
112
112
  TORSO_TAGS,
113
113
  halfTravels,
114
+ chainToward,
115
+ type Toward,
114
116
  type WorldBox,
115
117
  } from './instruments.ts';
116
118
  import { type PartRecord, type PartsFile, readParts } from './parts.ts';
@@ -1757,6 +1759,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1757
1759
  const file = reqFile as RequirementsFile;
1758
1760
  const ready = inp;
1759
1761
  take(() => resolveRequirements(file, ready.rig, ready.motion, ready.rootBone));
1762
+ take(() => refuseIfAny(exceptionProblems(file, ready.parts, ready.noParts)));
1760
1763
  // A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
1761
1764
  if (file.requirements.some((r) => r.kind === 'seam')) {
1762
1765
  const parts = ready.parts;
@@ -1873,12 +1876,14 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1873
1876
  if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation idle --slot ${ps.map((p) => p.name).join(',')}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1874
1877
  return readFrameSet(dir);
1875
1878
  };
1879
+ // Issue #231: the footwear parts the scene file declares not planted; none without --requirements or the field.
1880
+ const notPlanted = reqFile === null ? [] : reqFile.not_planted;
1876
1881
  let breath: JudgementLine = skip(lacking);
1877
1882
  let tip: JudgementLine = skip(lacking);
1878
1883
  if (full !== null) {
1879
1884
  try {
1880
- breath = breathLine(full, isolated, problems);
1881
- if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems);
1885
+ breath = breathLine(full, isolated, problems, notPlanted);
1886
+ if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems, reqFile === null ? NO_TIP_EXCEPTIONS : { path: reqFile.path, not_swinging: reqFile.not_swinging, rises: reqFile.rises });
1882
1887
  } finally {
1883
1888
  rmSync(isoDir, { recursive: true, force: true });
1884
1889
  }
@@ -1892,7 +1897,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1892
1897
  const geometryPath = join(idle.dir, GEOMETRY_FILE);
1893
1898
  const geometry = readGeometry(geometryPath, idle.written);
1894
1899
  stretch = stretchLine(geometry, geometryPath, problems);
1895
- if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems);
1900
+ if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
1896
1901
  }
1897
1902
 
1898
1903
  // 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
@@ -2242,22 +2247,98 @@ function quoteParts(ps: readonly PartRecord[]): string {
2242
2247
 
2243
2248
  type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
2244
2249
 
2245
- /** (1) The torso moves while the feet do not: each rendered alone, heat over its own box. */
2246
- function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): JudgementLine {
2250
+ /**
2251
+ * The scene file's three exceptions (issue #231), read against parts.json
2252
+ * before anything is built: each name must be a part parts.json lists
2253
+ * (`REQUIREMENTS_RESOLVES`), and one the bar it relaxes reads — a `footwear`
2254
+ * part for `not_planted` (`REQUIREMENTS_NOT_PLANTED`), a `handwear` or
2255
+ * `bottomwear` part for `not_swinging` and `rises`
2256
+ * (`REQUIREMENTS_TIP_EXCEPTION`): an exception that excepts nothing is a miss,
2257
+ * refused by name. With no parts.json every name is refused. Whether a `rises`
2258
+ * part's chain does rise is read off the idle's geometry by `TIP_OVER_ROOT`.
2259
+ */
2260
+ export function exceptionProblems(file: Pick<RequirementsFile, 'not_planted' | 'not_swinging' | 'rises' | 'path'>, parts: PartsFile | null, noParts: string | null): Problem[] {
2261
+ const out: Problem[] = [];
2262
+ // Issue #231: not_swinging and rises name swinging parts, the ones TIP_OVER_ROOT reads; whether a rises part's chain does rise is read off the idle's geometry, in the line.
2263
+ for (const key of ['not_swinging', 'rises'] as const) {
2264
+ file[key].forEach((name, i) => {
2265
+ const object = `${file.path} field "${key}"[${i}]`;
2266
+ if (parts === null) {
2267
+ out.push({ code: 'REQUIREMENTS_TIP_EXCEPTION', object, detail: `names part "${name}", and the swinging parts are chosen by parts.json's See-through tags, of which none was read: ${noParts ?? 'no parts.json'}` });
2268
+ return;
2269
+ }
2270
+ const rec = parts.parts.find((p) => p.name === name);
2271
+ if (rec === undefined) {
2272
+ out.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${name}", which parts.json does not list; it lists ${parts.parts.map((p) => `"${p.name}"`).join(', ') || 'none'}` });
2273
+ return;
2274
+ }
2275
+ const swing = partsTagged(parts, SWING_TAGS).map((p) => p.name);
2276
+ if (!swing.includes(name)) {
2277
+ out.push({
2278
+ code: 'REQUIREMENTS_TIP_EXCEPTION',
2279
+ object,
2280
+ detail: `names part "${name}" (from "${rec.from}"), which TIP_OVER_ROOT does not read; only a part from a See-through ${tagWords(SWING_TAGS)} layer is (${swing.map((n) => `"${n}"`).join(', ') || 'none here'}), so declaring it excepts nothing`,
2281
+ });
2282
+ }
2283
+ });
2284
+ }
2285
+ file.not_planted.forEach((name, i) => {
2286
+ const object = `${file.path} field "not_planted"[${i}]`;
2287
+ if (parts === null) {
2288
+ out.push({ code: 'REQUIREMENTS_NOT_PLANTED', object, detail: `names part "${name}", and the feet are chosen by parts.json's See-through tags, of which none was read: ${noParts ?? 'no parts.json'}` });
2289
+ return;
2290
+ }
2291
+ const rec = parts.parts.find((p) => p.name === name);
2292
+ if (rec === undefined) {
2293
+ out.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${name}", which parts.json does not list; it lists ${parts.parts.map((p) => `"${p.name}"`).join(', ') || 'none'}` });
2294
+ return;
2295
+ }
2296
+ const feet = partsTagged(parts, FEET_TAGS).map((p) => p.name);
2297
+ if (!feet.includes(name)) {
2298
+ out.push({
2299
+ code: 'REQUIREMENTS_NOT_PLANTED',
2300
+ object,
2301
+ detail: `names part "${name}" (from "${rec.from}"), which is not read as a planted foot; only a part from a See-through ${tagWords(FEET_TAGS)} layer is (${feet.map((n) => `"${n}"`).join(', ') || 'none here'}), so declaring it excepts nothing`,
2302
+ });
2303
+ }
2304
+ });
2305
+ return out;
2306
+ }
2307
+
2308
+ /** The feet a line holds still (issue #231): the `footwear` parts less those the scene file declares not planted, and the declared ones, in parts.json's order. */
2309
+ function plantedFeet(parts: PartsFile, notPlanted: readonly string[]): { all: PartRecord[]; held: PartRecord[]; declared: string[] } {
2310
+ const all = partsTagged(parts, FEET_TAGS);
2311
+ return { all, held: all.filter((p) => !notPlanted.includes(p.name)), declared: all.filter((p) => notPlanted.includes(p.name)).map((p) => p.name) };
2312
+ }
2313
+
2314
+ /** The sentence a feet half carries when every footwear part is declared not planted (issue #231). */
2315
+ function noneHeldText(declared: readonly string[]): string {
2316
+ return `every ${tagWords(FEET_TAGS)} part (${declared.map((n) => `"${n}"`).join(', ')}) is declared not planted by the scene file's not_planted, so there are no feet to hold still`;
2317
+ }
2318
+
2319
+ /**
2320
+ * (1) The torso moves while the feet do not: each rendered alone, heat over
2321
+ * its own box. The feet are the `footwear` parts less those the scene file
2322
+ * declares `not_planted` (issue #231); a declaration is the line's first
2323
+ * figure, `declared_not_planted`, so the line never reads as though the feet
2324
+ * it names were held still. With every foot declared, the torso is measured
2325
+ * and the feet figures are an empty list and null.
2326
+ */
2327
+ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
2247
2328
  const torso = partsTagged(inp.parts, TORSO_TAGS);
2248
- const feet = partsTagged(inp.parts, FEET_TAGS);
2249
- if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`);
2250
- if (feet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
2329
+ const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
2330
+ if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe${declared.length === 0 ? '' : `; ${declared.map((n) => `"${n}"`).join(', ')} declared not planted`}`);
2331
+ if (allFeet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
2251
2332
  const H = inp.parts.rig_size[1];
2252
2333
  const ts = isolated('torso', torso);
2253
- const fs = isolated('feet', feet);
2334
+ const fs = feet.length === 0 ? null : isolated('feet', feet);
2254
2335
  const tb = frameBox(torso, H, inp.stage, ts.viewport);
2255
- const fb = frameBox(feet, H, inp.stage, fs.viewport);
2256
- if (tb === null || fb === null) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
2336
+ const fb = fs === null ? null : frameBox(feet, H, inp.stage, fs.viewport);
2337
+ if (tb === null || (fs !== null && fb === null)) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
2257
2338
  const th = regionHeat(heatField(ts.frames.map((f) => f.image)), ts.viewport.pixelWidth, tb);
2258
- const fh = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
2339
+ const fh = fs === null || fb === null ? null : regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
2259
2340
  const mean = round3(th.mean);
2260
- const ok = mean >= BREATH_TORSO_MEAN_FLOOR && fh.max <= BREATH_FEET_MAX_CEILING;
2341
+ const ok = mean >= BREATH_TORSO_MEAN_FLOOR && (fh === null || fh.max <= BREATH_FEET_MAX_CEILING);
2261
2342
  if (mean < BREATH_TORSO_MEAN_FLOOR) {
2262
2343
  problems.push({
2263
2344
  code: 'CHECK_BREATH_VISIBLE',
@@ -2265,24 +2346,34 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): Ju
2265
2346
  detail: `heat mean ${mean}/255 over the idle; >= ${BREATH_TORSO_MEAN_FLOOR} is required — the chest's breath track moves too little, or the torso is not weighted to the bone it keys`,
2266
2347
  });
2267
2348
  }
2268
- if (fh.max > BREATH_FEET_MAX_CEILING) {
2349
+ if (fh !== null && fb !== null && fh.max > BREATH_FEET_MAX_CEILING) {
2269
2350
  problems.push({
2270
2351
  code: 'CHECK_BREATH_VISIBLE',
2271
- object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}`,
2352
+ object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}${declared.length === 0 ? '' : ` (${declared.map((n) => `"${n}"`).join(', ')} declared not planted)`}`,
2272
2353
  detail: `heat max ${fh.max}/255 over the idle; <= ${BREATH_FEET_MAX_CEILING} is required — the feet move: their slot rides, or their mesh is weighted to, a bone the idle keys`,
2273
2354
  });
2274
2355
  }
2275
2356
  return {
2276
2357
  status: ok ? 'PASS' : 'FAIL',
2358
+ ...(declared.length === 0 ? {} : { declared_not_planted: declared }),
2277
2359
  torso_parts: torso.map((p) => p.name),
2278
2360
  torso_heat_mean: mean,
2279
2361
  torso_floor: BREATH_TORSO_MEAN_FLOOR,
2280
2362
  feet_parts: feet.map((p) => p.name),
2281
- feet_heat_max: fh.max,
2363
+ feet_heat_max: fh === null ? null : fh.max,
2282
2364
  feet_ceiling: BREATH_FEET_MAX_CEILING,
2283
2365
  };
2284
2366
  }
2285
2367
 
2368
+ /** The scene file's two exceptions to `TIP_OVER_ROOT` (issue #231), and the file they were read from, which a refusal names. */
2369
+ export interface TipExceptions {
2370
+ path: string;
2371
+ not_swinging: readonly string[];
2372
+ rises: readonly string[];
2373
+ }
2374
+
2375
+ const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [] };
2376
+
2286
2377
  /**
2287
2378
  * What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
2288
2379
  * the idle's posed geometry, not a render of the part. Until #118 the line
@@ -2301,37 +2392,139 @@ export const TIP_INSTRUMENT = 'geometry';
2301
2392
  * binary-coverage centroid per half; every root half on the three examples
2302
2393
  * travels under a frame pixel, where that centroid scatters by up to 0.3 px
2303
2394
  * with the grid, so the ratio was set by the grid.
2395
+ *
2396
+ * Issue #231: the scene file's `not_swinging` parts are left out, each row
2397
+ * saying so, and its `rises` parts are read with the halves swapped once the
2398
+ * chain carrying each is measured to rise ({@link chainDirection}; refused by
2399
+ * name otherwise); both lists are the line's first figures. Without them the
2400
+ * line is the one written before.
2304
2401
  */
2305
- function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[]): JudgementLine {
2402
+ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
2306
2403
  const swing = partsTagged(inp.parts, SWING_TAGS);
2307
2404
  if (swing.length === 0) return skip(`no part comes from a See-through ${tagWords(SWING_TAGS)} layer, so there is no hem or sleeve to swing`);
2308
2405
  const poses = attachmentPoses(geometryPath, frames);
2406
+ // Issue #231: a part declared `rises` is read up only when the chain carrying it does rise, measured at the setup pose.
2407
+ if (excepted.rises.length > 0) {
2408
+ const geo = readGeometry(geometryPath, frames);
2409
+ const origins = new Map<string, { x: number; y: number }>();
2410
+ for (const b of Array.isArray(geo?.setupBones) ? geo.setupBones : []) {
2411
+ if (isRecord(b) && typeof b.name === 'string' && typeof b.worldX === 'number' && typeof b.worldY === 'number') origins.set(b.name, { x: b.worldX, y: b.worldY });
2412
+ }
2413
+ const refused: Problem[] = [];
2414
+ excepted.rises.forEach((name, i) => {
2415
+ const dir = chainDirection(inp.rig, inp.motion, name, origins);
2416
+ if ('toward' in dir && dir.toward === 'up') return;
2417
+ refused.push({
2418
+ code: 'REQUIREMENTS_TIP_EXCEPTION',
2419
+ object: `${excepted.path} field "rises"[${i}]`,
2420
+ detail: `names part "${name}", whose tip is not up: ${'none' in dir ? dir.none : `its chain runs ${dir.toward} — ${dir.chains.join('; ')}`}; a part declared rises must be carried by a chain whose last link's origin is above its first's at the setup pose`,
2421
+ });
2422
+ });
2423
+ refuseIfAny(refused);
2424
+ }
2309
2425
  const rows: Array<Record<string, unknown>> = [];
2310
2426
  let measured = 0;
2311
2427
  let failed = false;
2312
2428
  for (const p of swing) {
2429
+ if (excepted.not_swinging.includes(p.name)) {
2430
+ rows.push({ part: p.name, unmeasured: "declared not swinging by the scene file's not_swinging, so it is not held to the bar" });
2431
+ continue;
2432
+ }
2433
+ const rises = excepted.rises.includes(p.name);
2434
+ const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
2313
2435
  const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
2314
2436
  const shown = poses.map((f) => f.get(p.name) ?? null);
2315
- const t = halfTravels(art, p, inp.stage, shown);
2437
+ const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
2438
+ // A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
2439
+ const said = rises ? { tip_half: tipHalf } : {};
2316
2440
  if (t.root === null || t.tip === null) {
2317
- rows.push({ part: p.name, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? 'upper' : 'lower'} half of its box holds no art its attachment draws` });
2441
+ rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? rootHalf : tipHalf} half of its box holds no art its attachment draws` });
2318
2442
  continue;
2319
2443
  }
2320
2444
  measured++;
2321
2445
  const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
2322
2446
  const ok = ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
2323
- rows.push({ part: p.name, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
2447
+ rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
2324
2448
  if (!ok) {
2325
2449
  failed = true;
2326
2450
  problems.push({
2327
2451
  code: 'CHECK_TIP_OVER_ROOT',
2328
- object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry`,
2329
- detail: `the art in its lower half travels ${round3(t.tip)} rig px and in its upper half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
2452
+ object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
2453
+ detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
2330
2454
  });
2331
2455
  }
2332
2456
  }
2457
+ const declared = {
2458
+ ...(excepted.not_swinging.length === 0 ? {} : { declared_not_swinging: swing.filter((p) => excepted.not_swinging.includes(p.name)).map((p) => p.name) }),
2459
+ ...(excepted.rises.length === 0 ? {} : { declared_rises: swing.filter((p) => excepted.rises.includes(p.name)).map((p) => p.name) }),
2460
+ };
2333
2461
  if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
2334
- return { status: failed ? 'FAIL' : 'PASS', instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
2462
+ return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
2463
+ }
2464
+
2465
+ /**
2466
+ * Which way the chain carrying a part runs (issue #231), for a part the scene
2467
+ * declares `rises`: the bones its attachments are bound to in the default skin
2468
+ * (a mesh's weights, or its slot's bone); the leaves among them — those with
2469
+ * no other of them below — walked up the bone tree to the first link of a
2470
+ * chain `CHAIN_LAG` reads ({@link chainFigures}: two or more rotate-keyed
2471
+ * links, each under the one before at one period); each chain so found runs
2472
+ * from its first link's setup-pose origin to its last link's
2473
+ * ({@link chainToward}). `{ none }`, the reason, when no leaf is under a chain,
2474
+ * when a chain runs as far across as along, or when the part's chains run
2475
+ * different ways.
2476
+ */
2477
+ export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
2478
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2479
+ const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2480
+ const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
2481
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2482
+ const skin = isRecord(skins.default) ? skins.default : {};
2483
+ const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2484
+ const bound = new Set<string>();
2485
+ for (const a of atts) {
2486
+ if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
2487
+ if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
2488
+ }
2489
+ const under = (b: string, anc: string): boolean => {
2490
+ const seen = new Set<string>();
2491
+ for (let x = parent.get(b) ?? null; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
2492
+ if (x === anc) return true;
2493
+ seen.add(x);
2494
+ }
2495
+ return false;
2496
+ };
2497
+ const leaves = [...bound].filter((b) => ![...bound].some((o) => o !== b && under(o, b)));
2498
+ const chains = chainFigures(rig, motion).chains.filter((c) => c.length >= 2);
2499
+ const chainOf = new Map<string, string[]>();
2500
+ for (const c of chains) for (const l of c) chainOf.set(l.bone, c.map((x) => x.bone));
2501
+ const found: string[][] = [];
2502
+ for (const leaf of leaves) {
2503
+ const seen = new Set<string>();
2504
+ for (let b: string | null = leaf; b !== null && !seen.has(b); b = parent.get(b) ?? null) {
2505
+ seen.add(b);
2506
+ const c = chainOf.get(b);
2507
+ if (c !== undefined) {
2508
+ if (!found.some((f) => f[0] === c[0])) found.push(c);
2509
+ break;
2510
+ }
2511
+ }
2512
+ }
2513
+ const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
2514
+ if (found.length === 0) return { none: `it rides no chain: none of the bones it is bound to (${boundText}) is under a chain of rotate-keyed links` };
2515
+ const read = found.map((c) => {
2516
+ const a = origins.get(c[0]);
2517
+ const z = origins.get(c[c.length - 1]);
2518
+ const label = c.join(' > ');
2519
+ if (a === undefined || z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${a === undefined ? c[0] : c[c.length - 1]} in the idle's geometry` };
2520
+ const dx = z.x - a.x;
2521
+ const dy = z.y - a.y;
2522
+ const toward = chainToward(dx, dy);
2523
+ return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
2524
+ });
2525
+ const ways = [...new Set(read.map((r) => r.toward))];
2526
+ if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
2527
+ return { toward: ways[0], chains: read.map((r) => r.text) };
2335
2528
  }
2336
2529
 
2337
2530
  /**
@@ -2474,10 +2667,10 @@ interface StillHalf {
2474
2667
  * features go; `footwear` on top) is reported per slot, `crossing`, with no
2475
2668
  * bar ({@link crossingOf}). No render grid enters either half.
2476
2669
  */
2477
- export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[]): JudgementLine {
2670
+ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
2478
2671
  const face = partsTagged(inp.parts, FACE_TAGS);
2479
- const feet = partsTagged(inp.parts, FEET_TAGS);
2480
- if (face.length === 0 && feet.length === 0) return skip(`no part comes from a See-through ${tagWords([...FACE_TAGS, ...FEET_TAGS])} layer, so there is no still region to read`);
2672
+ const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
2673
+ if (face.length === 0 && allFeet.length === 0) return skip(`no part comes from a See-through ${tagWords([...FACE_TAGS, ...FEET_TAGS])} layer, so there is no still region to read`);
2481
2674
  if (geo === null) return skip(`${geometryPath} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the still regions are read off it`);
2482
2675
  const H = inp.parts.rig_size[1];
2483
2676
  const W = inp.parts.rig_size[0];
@@ -2618,7 +2811,8 @@ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | n
2618
2811
  };
2619
2812
 
2620
2813
  const feetHalf = (): Record<string, unknown> | string => {
2621
- if (feet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
2814
+ if (allFeet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
2815
+ if (feet.length === 0) return noneHeldText(declared);
2622
2816
  const names = feet.map((p) => p.name);
2623
2817
  const h = half(names, firstOf(names), poses.map(() => null), 1, 0, regionOf(new Set(feet.map((p) => index.get(p.name) as number)), []));
2624
2818
  for (const o of h.over) {
@@ -2635,7 +2829,9 @@ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | n
2635
2829
  const t = feetHalf();
2636
2830
  if (typeof f === 'string' && typeof t === 'string') return skip(`face: ${f}; feet: ${t}`);
2637
2831
  const failed = problems.some((q) => q.code === 'CHECK_STILL_REGIONS_DARK');
2638
- return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { unmeasured: t } : t };
2832
+ // Issue #231: a declaration is the feet half's first figure, measured or not.
2833
+ const said = declared.length === 0 ? {} : { declared_not_planted: declared };
2834
+ return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { ...said, unmeasured: t } : { ...said, ...t } };
2639
2835
  }
2640
2836
 
2641
2837
  function fmt3(x: number): string {