@rig-c/review 0.5.0 → 0.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.
Files changed (63) hide show
  1. package/README.md +242 -7
  2. package/docs/DESIGN.md +33 -5
  3. package/docs/REVIEW_PACKAGE.md +115 -0
  4. package/package.json +1 -1
  5. package/src/applied-context.ts +285 -0
  6. package/src/baseline.ts +175 -0
  7. package/src/bone-anchors.ts +123 -0
  8. package/src/bones-check.ts +105 -0
  9. package/src/build-route.ts +109 -0
  10. package/src/chest-landmarks.ts +122 -0
  11. package/src/chest-regions.ts +65 -6
  12. package/src/cli.ts +19 -8
  13. package/src/config-patch.ts +213 -0
  14. package/src/cut-edits.ts +184 -0
  15. package/src/environment.ts +226 -0
  16. package/src/feedback-export.ts +25 -1
  17. package/src/front.ts +11 -0
  18. package/src/handoff.ts +208 -0
  19. package/src/idle-tracks.ts +401 -0
  20. package/src/manifest.ts +15 -6
  21. package/src/mesh-edit.ts +317 -0
  22. package/src/mesh-entry.ts +351 -0
  23. package/src/open.ts +12 -5
  24. package/src/physics-tuning.ts +236 -0
  25. package/src/producer-files.ts +11 -0
  26. package/src/producer.ts +158 -0
  27. package/src/readings-run.ts +129 -0
  28. package/src/readings.ts +235 -0
  29. package/src/rebuild.ts +938 -0
  30. package/src/reference.ts +4 -3
  31. package/src/requirements-run.ts +160 -0
  32. package/src/requirements.ts +230 -0
  33. package/src/retarget.ts +211 -0
  34. package/src/retune-route.ts +52 -0
  35. package/src/rig-fields.ts +295 -0
  36. package/src/server.ts +59 -5
  37. package/src/session.ts +12 -0
  38. package/src/setup-mesh.ts +144 -0
  39. package/src/weights.ts +74 -0
  40. package/web/bones-panel.ts +230 -0
  41. package/web/chest-editor.ts +294 -0
  42. package/web/chest-regions-tool.ts +10 -4
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +2 -2
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +813 -24
  48. package/web/mesh-editor.ts +303 -0
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/part-selection.ts +13 -5
  51. package/web/part-thumbnails.ts +76 -0
  52. package/web/physics-panel.ts +151 -0
  53. package/web/readings-panel.ts +105 -0
  54. package/web/rebuild-client.ts +216 -0
  55. package/web/requirements-panel.ts +238 -0
  56. package/web/retarget-panel.ts +203 -0
  57. package/web/rig-fields-panel.ts +269 -0
  58. package/web/rig-overlays.ts +171 -14
  59. package/web/setup-pose.ts +30 -0
  60. package/web/styles.css +52 -5
  61. package/web/weights-view.ts +159 -0
  62. package/web/workspace-preferences.ts +17 -12
  63. package/web/workspace-ui.ts +5 -3
package/src/reference.ts CHANGED
@@ -5,6 +5,7 @@ import {open,realpath,stat} from 'node:fs/promises';
5
5
  import {basename,resolve} from 'node:path';
6
6
  import {isDeepStrictEqual} from 'node:util';
7
7
  import {loadReview,type ReviewPackage} from './manifest.ts';
8
+ import type {HandoffFlags} from './handoff.ts';
8
9
  export interface ReferenceBinding {packageSourceHash:string;reference:{id:string;name:string;sha256:string}}
9
10
  export interface PinnedReference {path:string;binding:ReferenceBinding}
10
11
  const pinned=Symbol('explicit CLI reference');
@@ -19,8 +20,8 @@ export async function hashLocalVideo(input:string):Promise<{path:string;name:str
19
20
  const after=await stat(path);if(await realpath(input)!==path||before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new Error('Reference inputs changed while hashing');
20
21
  return {path,name:basename(path),sha256:hash.digest('hex')};
21
22
  }
22
- export async function loadReferencedReview(input:string,reference?:string):Promise<ReviewPackage>{
23
- const base=await loadReview(input);if(reference===undefined)return base;
23
+ export async function loadReferencedReview(input:string,reference?:string,handoff?:HandoffFlags):Promise<ReviewPackage>{
24
+ const base=await loadReview(input,{handoff});if(reference===undefined)return base;
24
25
  const video=await hashLocalVideo(reference),id='open-reference-'+video.sha256;
25
26
  if(base.manifest.references.some(r=>r.id===id))throw new Error('reference: selected reference id collides with package reference');
26
27
  const binding:ReferenceBinding={packageSourceHash:base.sourceHash,reference:{id,name:video.name,sha256:video.sha256}};
@@ -28,7 +29,7 @@ export async function loadReferencedReview(input:string,reference?:string):Promi
28
29
  return {...base,sourceHash,manifest:{...base.manifest,references:[{id,label:video.name+' · selected reference',type:'video',path:'__review_selected_reference__.mp4'},...base.manifest.references]},[pinned]:{path:video.path,binding}} as ReferencedPackage;
29
30
  }
30
31
  export async function assertReviewUnchanged(p:ReviewPackage):Promise<void>{
31
- try{const current=await loadReferencedReview(p.manifestPath,selectedReference(p)?.path);if(current.sourceHash!==p.sourceHash||!isDeepStrictEqual(current.reviewedSource,p.reviewedSource)||!isDeepStrictEqual(referenceBinding(current),referenceBinding(p)))throw new Error('source binding differs');}
32
+ try{const current=await loadReferencedReview(p.manifestPath,selectedReference(p)?.path,p.handoffInputs);if(current.sourceHash!==p.sourceHash||!isDeepStrictEqual(current.reviewedSource,p.reviewedSource)||!isDeepStrictEqual(referenceBinding(current),referenceBinding(p)))throw new Error('source binding differs');}
32
33
  catch{throw new Error('Package inputs changed or are unavailable. Restart review to bind feedback to current inputs.');}
33
34
  }
34
35
  export async function safeSelectedReference(p:ReviewPackage):Promise<string>{
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `POST /api/requirements` (issue #113): the page's declared requirements, written by review as a
3
+ * `spine-parts-requirements/1` file into `<state>/requirements/<package hash>/<run id>/requirements.json`, then
4
+ * `rig-parts check --rig <rig> --out out --parts <handoff parts> --requirements requirements.json` with the installed
5
+ * producer, on the inspected rig spec (the handoff's) or on the session's latest scratch revision. The job keeps what
6
+ * check said (its requirements block, its lines, its refusals verbatim); the scratch directory is removed after the run.
7
+ * `GET /api/requirements` says what the stage offers the panel: the rig spec's bones, slots and their mesh attachments,
8
+ * its ik and transform constraints, motion.json's animations, and whether a scratch revision exists.
9
+ * The client sends typed requirements only — never a path or a flag. Nothing is written into the package.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { isAbsolute, join, relative } from 'node:path';
13
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
14
+ import type { ReviewPackage } from './manifest.ts';
15
+ import { resolveProducer, runTool } from './producer.ts';
16
+ import { producerInputs, RebuildError } from './rebuild.ts';
17
+ import { parseRequirementsRequest, readRequirements, requirementsCheckArgs, requirementsDocument, requirementsFileText, RequirementsError, REQUIREMENTS_RUN_SCHEMA, type RequirementRow, type RequirementsBuild } from './requirements.ts';
18
+
19
+ const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
20
+ const MAX_LINES = 4000;
21
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
22
+
23
+ export interface RequirementsJob {
24
+ schema: typeof REQUIREMENTS_RUN_SCHEMA; id: string; status: 'running' | 'checked' | 'refused' | 'failed';
25
+ build: RequirementsBuild; buildId: string | null;
26
+ /** The file as written: its sha256, its byte length and its text. */
27
+ file: { sha256: string; size: number; text: string };
28
+ producer: { package: string; version: string };
29
+ exitCode: number | null; wallMs: number | null;
30
+ rows: RequirementRow[]; summary: string | null; checkLine: string | null;
31
+ /** `REQUIREMENTS_*` lines verbatim; non-empty means the file was refused before anything was built. */
32
+ refusals: string[];
33
+ /** `CHECK_REQUIREMENT_MET` / `CHECK_REQUIREMENT_MEASURABLE` lines verbatim. */
34
+ problems: string[];
35
+ /** The line to show when nothing was measured: a named refusal verbatim, or why the run failed. */
36
+ error: string | null;
37
+ lines: string[];
38
+ }
39
+ export interface RequirementsCatalog {
40
+ status: 'ready' | 'unavailable'; reason: string | null;
41
+ bones: string[]; slots: string[]; meshes: Record<string, string[]>;
42
+ /** Each slot's bone, so a slot picked on the stage names its bone. */
43
+ slotBones: Record<string, string>;
44
+ constraints: { name: string; type: 'ik' | 'transform'; bones: string[]; drives: string[] }[];
45
+ animations: { name: string; duration: number | null }[];
46
+ scratch: string | null;
47
+ }
48
+ export interface RequirementsOptions {
49
+ stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>;
50
+ /** The session's latest built scratch build and the rig spec directory its producer op wrote. */
51
+ latest: () => { id: string; spec: string } | null;
52
+ }
53
+ export const requirementsRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'requirements', sourceHash);
54
+
55
+ /** The mesh attachments each slot holds in any skin (short form, or the long form's `attachments`). */
56
+ function meshesOf(rig: Record<string, unknown>): Record<string, string[]> {
57
+ const out: Record<string, Set<string>> = {};
58
+ for (const skin of Object.values(isRecord(rig.skins) ? rig.skins : {})) {
59
+ if (!isRecord(skin)) continue;
60
+ const attachments = isRecord(skin.attachments) ? skin.attachments : skin;
61
+ for (const [slot, held] of Object.entries(attachments)) {
62
+ if (!isRecord(held)) continue;
63
+ for (const [name, a] of Object.entries(held)) if (isRecord(a) && a.type === 'mesh') (out[slot] ??= new Set()).add(name);
64
+ }
65
+ }
66
+ return Object.fromEntries(Object.entries(out).map(([slot, names]) => [slot, [...names].sort()]));
67
+ }
68
+ function drivesOf(c: Record<string, unknown>): string[] {
69
+ if (c.type === 'ik') return ['rotate'];
70
+ const to = new Set<string>();
71
+ if (isRecord(c.properties)) for (const p of Object.values(c.properties)) if (isRecord(p) && isRecord(p.to)) for (const k of Object.keys(p.to)) to.add(k);
72
+ return [...(to.has('rotate') ? ['rotate'] : []), ...(to.has('x') || to.has('y') ? ['translate'] : [])];
73
+ }
74
+ /** What a rig spec directory offers the panel; names only. */
75
+ export async function rigCatalog(rigDir: string): Promise<Omit<RequirementsCatalog, 'status' | 'reason' | 'scratch'>> {
76
+ const read = async (name: string): Promise<Record<string, unknown>> => { try { const v = JSON.parse(await readFile(join(rigDir, name), 'utf8')); return isRecord(v) ? v : {}; } catch { return {}; } };
77
+ const rig = await read('rig.json'), motion = await read('motion.json');
78
+ const names = (list: unknown) => (Array.isArray(list) ? list : []).filter(isRecord).map(x => x.name).filter((n): n is string => typeof n === 'string');
79
+ const constraints = (Array.isArray(rig.constraints) ? rig.constraints : []).filter(isRecord).filter(c => (c.type === 'ik' || c.type === 'transform') && typeof c.name === 'string')
80
+ .map(c => ({ name: c.name as string, type: c.type as 'ik' | 'transform', bones: (Array.isArray(c.bones) ? c.bones : []).filter((b): b is string => typeof b === 'string'), drives: drivesOf(c) }));
81
+ const animations = Object.entries(isRecord(motion.animations) ? motion.animations : {}).map(([name, a]) => ({ name, duration: isRecord(a) && typeof a.duration === 'number' ? a.duration : null }));
82
+ const slotBones = Object.fromEntries((Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).filter(x => typeof x.name === 'string' && typeof x.bone === 'string').map(x => [x.name as string, x.bone as string]));
83
+ return { bones: names(rig.bones), slots: names(rig.slots), meshes: meshesOf(rig), slotBones, constraints, animations };
84
+ }
85
+
86
+ /** One check at a time per review instance; finished jobs stay readable by id. */
87
+ export function createRequirementsCheck(p: ReviewPackage, options: RequirementsOptions) {
88
+ const jobs = new Map<string, RequirementsJob>();
89
+ let running: string | null = null;
90
+ const snapshot = (job: RequirementsJob): RequirementsJob => JSON.parse(JSON.stringify(job)) as RequirementsJob;
91
+ function assertStateOutside(): void {
92
+ for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
93
+ const inside = relative(root, options.stateDir);
94
+ if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new RequirementsError('REQUIREMENTS_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
95
+ }
96
+ }
97
+ const asRequirements = (e: unknown): never => { if (e instanceof RebuildError) throw new RequirementsError(e.code, e.message.slice(e.code.length + 2)); throw e; };
98
+
99
+ async function describe(): Promise<RequirementsCatalog> {
100
+ await options.unchanged();
101
+ const empty = { bones: [], slots: [], meshes: {}, slotBones: {}, constraints: [], animations: [] };
102
+ let rig: string;
103
+ try { rig = (await producerInputs(p.handoff, p.handoffReason)).rig; } catch (e) { if (e instanceof RebuildError) return { status: 'unavailable', reason: e.message, ...empty, scratch: null }; throw e; }
104
+ return { status: 'ready', reason: null, ...await rigCatalog(rig), scratch: options.latest()?.id ?? null };
105
+ }
106
+
107
+ async function execute(job: RequirementsJob, dir: string, bin: string, rig: string, parts: string, names: string[]): Promise<void> {
108
+ try {
109
+ await rm(dir, { recursive: true, force: true });
110
+ await mkdir(dir, { recursive: true, mode: 0o700 });
111
+ await writeFile(join(dir, 'requirements.json'), job.file.text, { mode: 0o600 });
112
+ const run = await runTool(bin, requirementsCheckArgs({ rig, parts, out: 'out', requirements: 'requirements.json' }), { cwd: dir, onLine: line => { if (job.lines.length < MAX_LINES) job.lines.push(line); } });
113
+ let checkJson: unknown = null;
114
+ try { checkJson = JSON.parse(await readFile(join(dir, 'out', 'check.json'), 'utf8')); } catch { /* refused before check.json; the lines say why */ }
115
+ const read = readRequirements(names, checkJson, run.lines);
116
+ Object.assign(job, { exitCode: run.exitCode, wallMs: run.wallMs, rows: read.rows, summary: read.summary, checkLine: read.checkLine, refusals: read.refusals, problems: read.problems });
117
+ try { await options.unchanged(); } catch { throw new RequirementsError('REQUIREMENTS_INPUTS_CHANGED', 'the package or its producer handoff changed during the check'); }
118
+ if (read.rows.length) job.status = 'checked';
119
+ else { job.status = 'refused'; job.error = read.refusal ?? `rig-parts check exited ${run.exitCode} without a named refusal`; }
120
+ } catch (error) { job.status = 'failed'; job.error = error instanceof Error ? error.message : String(error); }
121
+ finally { await rm(dir, { recursive: true, force: true }).catch(() => {}); }
122
+ }
123
+
124
+ /** Refusals before anything runs; a malformed request is REQUIREMENTS_REQUEST_INVALID (400 at the route). */
125
+ async function start(input: unknown): Promise<{ status: number; body: unknown }> {
126
+ const request = parseRequirementsRequest(input);
127
+ assertStateOutside();
128
+ await options.unchanged();
129
+ const inputs = await producerInputs(p.handoff, p.handoffReason).catch(asRequirements);
130
+ let rig = inputs.rig, buildId: string | null = null;
131
+ if (request.build === 'scratch') {
132
+ const latest = options.latest();
133
+ if (!latest) return { status: 200, body: { status: 'refused', error: 'REQUIREMENTS_NO_SCRATCH: no scratch build in this session yet; Apply builds one, or check the inspected rig' } };
134
+ rig = latest.spec; buildId = latest.id;
135
+ }
136
+ const resolved = resolveProducer(options.starts);
137
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error } };
138
+ const text = requirementsFileText(requirementsDocument(request.fps, request.requirements)), fileSha = sha256(text);
139
+ const producer = { package: resolved.producer.package, version: resolved.producer.version };
140
+ const id = sha256(JSON.stringify([REQUIREMENTS_RUN_SCHEMA, p.sourceHash, fileSha, request.build, buildId, inputs.configHash, `${producer.package}@${producer.version}`]));
141
+ const current = jobs.get(id);
142
+ if (current && current.status !== 'failed') return { status: current.status === 'running' ? 202 : 200, body: snapshot(current) };
143
+ if (running) return { status: 200, body: { status: 'refused', error: `REQUIREMENTS_BUSY: requirements check ${running} is still running` } };
144
+ const job: RequirementsJob = {
145
+ schema: REQUIREMENTS_RUN_SCHEMA, id, status: 'running', build: request.build, buildId,
146
+ file: { sha256: fileSha, size: Buffer.byteLength(text), text }, producer,
147
+ exitCode: null, wallMs: null, rows: [], summary: null, checkLine: null, refusals: [], problems: [], error: null, lines: [],
148
+ };
149
+ jobs.set(id, job); running = id;
150
+ const names = request.requirements.map(r => String(r.name ?? ''));
151
+ void execute(job, join(requirementsRoot(options.stateDir, p.sourceHash), id), resolved.producer.bin, rig, inputs.parts, names).finally(() => { if (running === id) running = null; });
152
+ return { status: 202, body: snapshot(job) };
153
+ }
154
+ function read(id: string): RequirementsJob {
155
+ const job = jobs.get(id);
156
+ if (!job) throw new RequirementsError('REQUIREMENTS_NOT_FOUND', id);
157
+ return snapshot(job);
158
+ }
159
+ return { start, read, describe, running: () => running };
160
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Declared requirements (issue #113): the scene's bars on the rig's motion, written by review as a
3
+ * `spine-parts-requirements/1` file and measured by the installed producer's `rig-parts check --requirements`.
4
+ * Pure: the typed requirement for each kind (the fields the producer's reader reads, nothing else), the file text,
5
+ * the request envelope the page sends, the check's `requirements` block (or its printed lines) read back into one row
6
+ * per requirement with its measured figure and worst frame, and the feedback context that carries the file by hash and
7
+ * content. Review judges nothing here: whether a field, a name or a bar holds is the producer's to say, and its
8
+ * `REQUIREMENTS_*`, `CHECK_REQUIREMENT_MET` and `CHECK_REQUIREMENT_MEASURABLE` lines are shown verbatim.
9
+ * Not written: scene targets, the exception lists (`not_planted`, `not_swinging`, `rises`, `rests`) and waivers
10
+ * (`may_fold`) — the envelope carries `spec`, `fps` and `requirements` only.
11
+ */
12
+ import { firstRefusal } from './producer.ts';
13
+
14
+ export const REQUIREMENTS_SPEC = 'spine-parts-requirements/1';
15
+ export const REQUIREMENTS_RUN_SCHEMA = 'rig-review-requirements/1';
16
+ export const REQUIREMENTS_CONTEXT_SCHEMA = 'rig-review-requirements-context/1';
17
+ /** Request body cap, file text cap and requirement count cap. */
18
+ export const REQUIREMENTS_LIMIT = 64 * 1024;
19
+ export const MAX_REQUIREMENTS = 64;
20
+ /** The six kinds the producer's reader reads, in its order. */
21
+ export const REQUIREMENT_KINDS = ['contact', 'follow', 'aim', 'range', 'stretch', 'seam'] as const;
22
+ export type RequirementKind = (typeof REQUIREMENT_KINDS)[number];
23
+ /** Each kind's fields as the producer's reader lists them (`KIND_FIELDS`); every one is required. */
24
+ export const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
25
+ contact: ['bone', 'point', 'target', 'within_px'],
26
+ follow: ['constraint', 'constraint_type', 'bone', 'property', 'fraction', 'tolerance', 'least_drive'],
27
+ aim: ['bone', 'target', 'within_degrees'],
28
+ range: ['bone', 'lo_degrees', 'hi_degrees'],
29
+ stretch: ['slot', 'attachment', 'within_ratio'],
30
+ seam: ['part', 'neighbour', 'within_px'],
31
+ };
32
+ /** The reader's name rule: a letter, then letters, digits and underscores. */
33
+ export const REQUIREMENT_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
34
+
35
+ export type BonePoint = 'origin' | 'tip';
36
+ export type TargetRef = { bone: string; point: BonePoint } | { stage: [number, number] };
37
+ interface Common { name: string; animation: string }
38
+ export type Requirement =
39
+ | (Common & { kind: 'contact'; bone: string; point: BonePoint; target: TargetRef; within_px: number })
40
+ | (Common & { kind: 'follow'; constraint: string; constraint_type: 'ik' | 'transform'; bone: string; property: 'rotate' | 'translate'; fraction: number; tolerance: number; least_drive: number })
41
+ | (Common & { kind: 'aim'; bone: string; target: TargetRef; within_degrees: number })
42
+ | (Common & { kind: 'range'; bone: string; lo_degrees: number; hi_degrees: number })
43
+ | (Common & { kind: 'stretch'; slot: string; attachment: string; within_ratio: number })
44
+ | (Common & { kind: 'seam'; part: string; neighbour: string; within_px: number });
45
+
46
+ /** The file as review writes it: `spec`, `fps` and the requirements, nothing else. */
47
+ export interface RequirementsDocument { spec: typeof REQUIREMENTS_SPEC; fps: number; requirements: Record<string, unknown>[] }
48
+ export function requirementsDocument(fps: number, requirements: readonly Record<string, unknown>[]): RequirementsDocument {
49
+ return { spec: REQUIREMENTS_SPEC, fps, requirements: requirements.map(r => ({ ...r })) };
50
+ }
51
+ /** The file's bytes: one stable serialization, so its sha256 names exactly what the producer read. */
52
+ export function requirementsFileText(doc: RequirementsDocument): string {
53
+ return JSON.stringify({ spec: doc.spec, fps: doc.fps, requirements: doc.requirements }, null, 1) + '\n';
54
+ }
55
+ /** One requirement with exactly its kind's fields, in the reader's order (`name`, `kind`, `animation`, then the fields). */
56
+ export function requirementEntry(r: Requirement): Record<string, unknown> {
57
+ const entry: Record<string, unknown> = { name: r.name, kind: r.kind, animation: r.animation };
58
+ for (const field of KIND_FIELDS[r.kind]) {
59
+ const value = (r as unknown as Record<string, unknown>)[field];
60
+ entry[field] = field === 'target' && value && typeof value === 'object' ? ('stage' in value ? { stage: [...(value as { stage: [number, number] }).stage] } : { bone: (value as { bone: string }).bone, point: (value as { point: BonePoint }).point }) : value;
61
+ }
62
+ return entry;
63
+ }
64
+
65
+ export class RequirementsError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
66
+ const bad = (detail: string): never => { throw new RequirementsError('REQUIREMENTS_REQUEST_INVALID', detail); };
67
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
68
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
69
+ const KEY = /^[a-z][a-z_]{0,31}$/;
70
+ const TEXT = (v: unknown) => typeof v === 'string' && v.length > 0 && v.length <= 200 && !/[\u0000-\u001f]/.test(v);
71
+
72
+ /** Which build the check reads: the inspected rig spec (the handoff's) or the session's latest scratch revision. */
73
+ export type RequirementsBuild = 'inspected' | 'scratch';
74
+ export interface RequirementsRequest { fps: number; build: RequirementsBuild; requirements: Record<string, unknown>[] }
75
+ /**
76
+ * `{fps, build, requirements}` only. The envelope is bounded and typed (each requirement an object of short keys whose
77
+ * values are names, finite numbers or a `{bone, point}` / `{stage: [x, y]}` target); which kind, field, name or bar
78
+ * reads is the producer's reader's to say, so a misspelt kind reaches it and comes back refused by name.
79
+ */
80
+ export function parseRequirementsRequest(input: unknown): RequirementsRequest {
81
+ if (!isRecord(input) || Object.keys(input).sort().join(',') !== 'build,fps,requirements') bad('expected exactly {fps, build, requirements}');
82
+ const v = input as Record<string, unknown>;
83
+ if (!finite(v.fps) || v.fps <= 0 || v.fps > 240) bad('fps must be a number above 0, at most 240');
84
+ if (v.build !== 'inspected' && v.build !== 'scratch') bad('build must be "inspected" or "scratch"');
85
+ if (!Array.isArray(v.requirements) || v.requirements.length === 0 || v.requirements.length > MAX_REQUIREMENTS) bad(`requirements must hold 1 to ${MAX_REQUIREMENTS} entries`);
86
+ const requirements = (v.requirements as unknown[]).map((r, i) => {
87
+ if (!isRecord(r) || Object.keys(r).length > 12) return bad(`requirements[${i}] must be an object of at most 12 fields`);
88
+ const out: Record<string, unknown> = {};
89
+ for (const [key, value] of Object.entries(r)) {
90
+ if (!KEY.test(key)) bad(`requirements[${i}] key ${JSON.stringify(key).slice(0, 40)} is not a field name`);
91
+ if (key === 'may_fold') bad(`requirements[${i}].may_fold: waivers are not written by review`);
92
+ if (TEXT(value) || finite(value)) out[key] = value;
93
+ else if (key === 'target' && isRecord(value) && Object.keys(value).sort().join(',') === 'bone,point' && TEXT(value.bone) && TEXT(value.point)) out[key] = { bone: value.bone, point: value.point };
94
+ else if (key === 'target' && isRecord(value) && Object.keys(value).join(',') === 'stage' && Array.isArray(value.stage) && value.stage.length === 2 && value.stage.every(n => finite(n) && Math.abs(n) <= 1e5)) out[key] = { stage: [...value.stage] };
95
+ else bad(`requirements[${i}].${key} must be a name, a finite number or a target`);
96
+ }
97
+ return out;
98
+ });
99
+ return { fps: v.fps as number, build: v.build as RequirementsBuild, requirements };
100
+ }
101
+
102
+ /** `rig-parts check --rig <rig> --out out --requirements requirements.json [--parts <parts>]`; every path the server's. */
103
+ export const requirementsCheckArgs = (r: { rig: string; parts: string | null; out: string; requirements: string }): string[] => ['check', '--rig', r.rig, '--out', r.out, ...(r.parts ? ['--parts', r.parts] : []), '--requirements', r.requirements];
104
+
105
+ export type RequirementStatus = 'PASS' | 'FAIL' | 'NOT MEASURABLE';
106
+ export interface WorstFrame { frame: number; time: number | null; text: string }
107
+ /** One requirement's row: what check measured, the figure the bar is read against, and the worst frame. */
108
+ export interface RequirementRow {
109
+ name: string; kind: string; animation: string; status: RequirementStatus;
110
+ /** The measured figure (`largest_px`, `largest_degrees`, `severity`, `fraction_measured`), or, for a range, the least and the greatest. */
111
+ measured: number | null; least: number | null; greatest: number | null;
112
+ worst: WorstFrame | null;
113
+ /** The NOT MEASURABLE reason, verbatim. */
114
+ reason: string | null;
115
+ /** The console line `NAME: STATUS — …` as printed, or one built from check.json's figures in the same words. */
116
+ line: string;
117
+ }
118
+ /** "frame 4 (t = 0.333333s)" → frame 4, 0.333333 s. */
119
+ export function worstFrame(text: unknown): WorstFrame | null {
120
+ if (typeof text !== 'string') return null;
121
+ const m = /^frame (\d+)(?: \(t = (-?[0-9.]+(?:e-?\d+)?)s\))?/.exec(text);
122
+ return m ? { frame: Number(m[1]), time: m[2] === undefined ? null : Number(m[2]), text } : null;
123
+ }
124
+ const num = (v: unknown) => (finite(v) ? v : null);
125
+ /** The figure a kind's bar is held against; a range's two ends; and where its worst frame is named. */
126
+ export function rowOf(name: string, line: Record<string, unknown>, printed: string | null): RequirementRow {
127
+ const kind = String(line.kind ?? ''), status = String(line.status ?? '') as RequirementStatus;
128
+ let measured: number | null = null, least: number | null = null, greatest: number | null = null, worst: WorstFrame | null = null;
129
+ if (kind === 'contact' || kind === 'seam') { measured = num(line.largest_px); worst = worstFrame(line.at); }
130
+ else if (kind === 'aim') { measured = num(line.largest_degrees); worst = worstFrame(line.at); }
131
+ else if (kind === 'stretch') { measured = num(line.severity); worst = worstFrame(line.at); }
132
+ else if (kind === 'range') {
133
+ least = num(line.least_degrees); greatest = num(line.greatest_degrees);
134
+ // The producer prints the bar as `range_degrees: [lo, hi]`.
135
+ const bar = Array.isArray(line.range_degrees) ? line.range_degrees : [line.lo_degrees, line.hi_degrees];
136
+ const lo = num(bar[0]), hi = num(bar[1]);
137
+ // The end that breaks its bar, else the end with less room to its bar.
138
+ const lowSide = least !== null && lo !== null && (least < lo || (greatest === null || hi === null || least - lo < hi - greatest));
139
+ worst = worstFrame(lowSide ? line.least_at : line.greatest_at) ?? worstFrame(line.least_at);
140
+ } else if (kind === 'follow') { measured = num(line.fraction_measured); worst = worstFrame(line.largest_residual_at) ?? worstFrame(line.largest_drive_at); }
141
+ const reason = typeof line.reason === 'string' ? line.reason : null;
142
+ const built = `${name}: ${status} — ${[...(reason ? [reason] : []), ...Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason').map(([k, v]) => `${k} ${typeof v === 'string' ? v : JSON.stringify(v)}`)].join('; ')}`;
143
+ return { name, kind, animation: String(line.animation ?? ''), status, measured, least, greatest, worst, reason, line: printed ?? built };
144
+ }
145
+
146
+ export interface RequirementsReadout {
147
+ rows: RequirementRow[];
148
+ /** `requirements: N declared — …`, verbatim. */
149
+ summary: string | null;
150
+ /** check's last `check: …` line. */
151
+ checkLine: string | null;
152
+ /** Every `REQUIREMENTS_*` refusal line, verbatim (trimmed). Non-empty means the file was refused before anything was built. */
153
+ refusals: string[];
154
+ /** Every `CHECK_REQUIREMENT_MET` / `CHECK_REQUIREMENT_MEASURABLE` line, verbatim (trimmed). */
155
+ problems: string[];
156
+ /** check's first named refusal when it measured nothing (any code), else null. */
157
+ refusal: string | null;
158
+ }
159
+ const REFUSAL_LINE = /^\s*FAIL\s+(REQUIREMENTS_[A-Z_]+)\b/, PROBLEM_LINE = /^\s*FAIL\s+(CHECK_REQUIREMENT_(?:MET|MEASURABLE))\b/;
160
+ /**
161
+ * check's requirements, from `check.json`'s `requirements` block when check wrote one (its lines by name, in the file's
162
+ * order), else from the printed `NAME: STATUS — …` lines of the names the file declares. The printed line is kept
163
+ * verbatim beside the JSON's figures; the refusal and problem lines are kept verbatim.
164
+ */
165
+ export function readRequirements(names: readonly string[], checkJson: unknown, lines: readonly string[]): RequirementsReadout {
166
+ const trimmed = lines.map(l => l.trim());
167
+ const printed = (name: string) => trimmed.find(l => l.startsWith(`${name}: PASS`) || l.startsWith(`${name}: FAIL`) || l.startsWith(`${name}: NOT MEASURABLE`)) ?? null;
168
+ const block = isRecord(checkJson) && isRecord(checkJson.requirements) && isRecord(checkJson.requirements.lines) ? checkJson.requirements.lines : null;
169
+ const rows: RequirementRow[] = [];
170
+ for (const name of names) {
171
+ const line = block && isRecord(block[name]) ? block[name] as Record<string, unknown> : null;
172
+ const text = printed(name);
173
+ if (line) rows.push(rowOf(name, line, text));
174
+ else if (text) {
175
+ const status = (/^[^:]+: (PASS|FAIL|NOT MEASURABLE)/.exec(text)?.[1] ?? 'FAIL') as RequirementStatus;
176
+ const at = / at (frame \d+ \(t = [^)]*\))/.exec(text)?.[1] ?? null;
177
+ rows.push({ name, kind: / kind (\w+)/.exec(text)?.[1] ?? '', animation: / animation ([^;]+)/.exec(text)?.[1] ?? '', status, measured: null, least: null, greatest: null, worst: worstFrame(at), reason: null, line: text });
178
+ }
179
+ }
180
+ const refusals = trimmed.filter(l => REFUSAL_LINE.test(l)), problems = trimmed.filter(l => PROBLEM_LINE.test(l));
181
+ return {
182
+ rows, summary: trimmed.find(l => l.startsWith('requirements: ')) ?? null,
183
+ checkLine: [...trimmed].reverse().find(l => l.startsWith('check: ')) ?? null,
184
+ refusals, problems, refusal: rows.length ? null : refusals[0] ?? firstRefusal(lines),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * `context.requirements` (#113): the requirements file the note was written against, by hash and content — its bytes
190
+ * (`text`, exactly what the producer read), their sha256 and size — and the last check of exactly those bytes, or null.
191
+ */
192
+ export interface RequirementsContext {
193
+ schema: typeof REQUIREMENTS_CONTEXT_SCHEMA;
194
+ sha256: string; size: number; text: string;
195
+ checked: { build: RequirementsBuild; producer: { package: string; version: string }; summary: string | null; refusals: string[]; rows: { name: string; status: RequirementStatus; worst: WorstFrame | null }[] } | null;
196
+ }
197
+ const ctx = (detail: string): never => { throw new Error(`feedback.context.requirements: ${detail}`); };
198
+ /** The context's exact shape, its sha256 recomputed over `text` (`sha256` is the caller's hash function). */
199
+ export function parseRequirementsContext(value: unknown, sha256: (text: string) => string): RequirementsContext | null {
200
+ if (value === null) return null;
201
+ if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'checked,schema,sha256,size,text') return ctx('expected exactly {schema, sha256, size, text, checked}');
202
+ if (value.schema !== REQUIREMENTS_CONTEXT_SCHEMA) ctx(`schema must be ${REQUIREMENTS_CONTEXT_SCHEMA}`);
203
+ if (typeof value.text !== 'string' || value.text.length > REQUIREMENTS_LIMIT) ctx(`text must be the file's text, at most ${REQUIREMENTS_LIMIT} characters`);
204
+ const text = value.text as string;
205
+ if (typeof value.sha256 !== 'string' || value.sha256 !== sha256(text)) ctx('sha256 is not the sha256 of text');
206
+ if (value.size !== new TextEncoder().encode(text).length) ctx('size is not the byte length of text');
207
+ let doc: unknown;
208
+ try { doc = JSON.parse(text); } catch { return ctx('text does not parse as JSON'); }
209
+ if (!isRecord(doc) || doc.spec !== REQUIREMENTS_SPEC || Object.keys(doc).sort().join(',') !== 'fps,requirements,spec') ctx(`text must be a ${REQUIREMENTS_SPEC} file of spec, fps and requirements`);
210
+ const checked = value.checked;
211
+ if (checked !== null) {
212
+ if (!isRecord(checked) || Object.keys(checked).sort().join(',') !== 'build,producer,refusals,rows,summary') ctx('checked: expected null or exactly {build, producer, summary, refusals, rows}');
213
+ const c = checked as Record<string, unknown>;
214
+ if (c.build !== 'inspected' && c.build !== 'scratch') ctx('checked.build');
215
+ if (!isRecord(c.producer) || typeof c.producer.package !== 'string' || typeof c.producer.version !== 'string') ctx('checked.producer');
216
+ if (c.summary !== null && typeof c.summary !== 'string') ctx('checked.summary');
217
+ if (!Array.isArray(c.refusals) || !c.refusals.every(l => typeof l === 'string')) ctx('checked.refusals');
218
+ if (!Array.isArray(c.rows) || c.rows.length > MAX_REQUIREMENTS || !c.rows.every(r => isRecord(r) && typeof r.name === 'string' && ['PASS', 'FAIL', 'NOT MEASURABLE'].includes(r.status as string) && (r.worst === null || (isRecord(r.worst) && Number.isSafeInteger(r.worst.frame))))) ctx('checked.rows');
219
+ }
220
+ return value as unknown as RequirementsContext;
221
+ }
222
+ /** "3 declared · sha256 ab12… · 2 PASS, 1 FAIL": one line for the issue export. */
223
+ export function requirementsSummaryLine(value: unknown): string | null {
224
+ if (!isRecord(value) || value.schema !== REQUIREMENTS_CONTEXT_SCHEMA || typeof value.sha256 !== 'string' || typeof value.text !== 'string') return null;
225
+ let count = 0;
226
+ try { const doc = JSON.parse(value.text) as { requirements?: unknown[] }; count = Array.isArray(doc.requirements) ? doc.requirements.length : 0; } catch { return null; }
227
+ const checked = isRecord(value.checked) ? value.checked : null;
228
+ const result = checked ? (Array.isArray(checked.refusals) && checked.refusals.length ? ' · refused by the producer' : typeof checked.summary === 'string' ? ` · ${checked.summary}` : '') : ' · not checked';
229
+ return `${count} declared · requirements file sha256 ${value.sha256}${result}`;
230
+ }