@rig-c/review 0.6.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 (59) hide show
  1. package/README.md +206 -12
  2. package/docs/DESIGN.md +26 -4
  3. package/docs/REVIEW_PACKAGE.md +113 -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 +10 -4
  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 +19 -6
  16. package/src/feedback-export.ts +17 -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 +45 -15
  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 +9 -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 +128 -13
  42. package/web/chest-regions-tool.ts +2 -1
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +1 -1
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +701 -49
  48. package/web/mesh-editor.ts +73 -38
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/physics-panel.ts +151 -0
  51. package/web/readings-panel.ts +105 -0
  52. package/web/rebuild-client.ts +216 -0
  53. package/web/requirements-panel.ts +238 -0
  54. package/web/retarget-panel.ts +203 -0
  55. package/web/rig-fields-panel.ts +269 -0
  56. package/web/rig-overlays.ts +108 -14
  57. package/web/setup-pose.ts +30 -0
  58. package/web/styles.css +28 -5
  59. package/web/weights-view.ts +159 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Baseline hold (#101): once per session, when the handoff and the producer both resolve, `rig-parts rig --config
3
+ * <config> --parts <parts> --out <scratch>/baseline` rebuilds the whole rig from the handoff into scratch under the review
4
+ * state directory, and what it writes (`PRODUCER_DOCUMENTS` and images/*, `src/producer-files.ts` — the same list the
5
+ * applied-state diff reads) is compared with the inspected rig directory, file by file. Reproduced means the handoff is what the inspected rig was built from, so an op that
6
+ * rebuilds the whole rig may run; otherwise the first differing path is named. Nothing is written into the package.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import type { Handoff } from './handoff.ts';
12
+ import { isProducerFile, PRODUCER_DOCUMENTS, PRODUCER_IMAGES_DIR } from './producer-files.ts';
13
+ import { firstRefusal, runTool, type InstalledTool } from './producer.ts';
14
+ import { checkSummary, MAX_DIFF_PATHS, type CheckSummary, type FilesDiff } from './applied-context.ts';
15
+ import { jsonDiff } from './config-patch.ts';
16
+
17
+ export const BASELINE_SCHEMA = 'rig-review-baseline/1';
18
+ export type BaselineStatus = 'running' | 'reproduced' | 'not-reproduced' | 'refused' | 'failed';
19
+ export interface BaselineResult {
20
+ schema: typeof BASELINE_SCHEMA; key: string; status: BaselineStatus;
21
+ producer: { package: string; version: string };
22
+ /** The first path (relative to the package root) whose bytes differ or that only one side has; null when reproduced. */
23
+ firstDifference: string | null;
24
+ /** Both sides: the rig.json SHA-256 and the SHA-256 of the sorted {path: sha256} table of every compared file. */
25
+ hashes: { baseline: { rigJson: string | null; tree: string }; inspected: { rigJson: string | null; tree: string } } | null;
26
+ line: string; exitCode: number | null; wallMs: number | null;
27
+ }
28
+ const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
29
+ async function present(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; throw e; } }
30
+
31
+ /** A rig directory as {relative path: sha256}: its regular files and images/* (fixed places, not a search). */
32
+ export async function rigTree(dir: string): Promise<Record<string, string>> {
33
+ const out: Record<string, string> = {};
34
+ for (const e of (await readdir(dir, { withFileTypes: true })).filter(e => e.isFile()).map(e => e.name).sort()) out[e] = sha256(await readFile(join(dir, e)));
35
+ const images = join(dir, PRODUCER_IMAGES_DIR);
36
+ if (await present(images)) for (const e of (await readdir(images, { withFileTypes: true })).filter(e => e.isFile()).map(e => e.name).sort()) out[`${PRODUCER_IMAGES_DIR}/${e}`] = sha256(await readFile(join(images, e)));
37
+ return out;
38
+ }
39
+ export const treeHash = (tree: Record<string, string>) => sha256(JSON.stringify(Object.keys(tree).sort().map(k => [k, tree[k]])));
40
+ /**
41
+ * The first path, in sorted order, whose hash differs or that one side lacks; null when the two match. Compared: every
42
+ * file the baseline wrote, and on the inspected side the producer's documents and images (`isProducerFile`; a file there
43
+ * that rig never writes, such as a note, is not the producer's to reproduce).
44
+ */
45
+ export function firstDifference(baseline: Record<string, string>, inspected: Record<string, string>): string | null {
46
+ const own = Object.keys(inspected).filter(isProducerFile);
47
+ for (const path of [...new Set([...Object.keys(baseline), ...own])].sort()) if (baseline[path] !== inspected[path]) return path;
48
+ if (!Object.keys(baseline).length) return 'rig.json';
49
+ return null;
50
+ }
51
+ /**
52
+ * The applied-state diff (#125): every producer document (`PRODUCER_DOCUMENTS`, the baseline's list) of a scratch rig
53
+ * directory against the inspected one, as JSON Pointers per file, and the images by hash only. A document neither side
54
+ * has is left out; one only one side has, or that does not parse as JSON on a side, differs at `""` (the whole
55
+ * document) unless its bytes are equal. Paths are capped at `MAX_DIFF_PATHS` per file; `count` is the full number.
56
+ */
57
+ export async function producerDiff(inspectedDir: string, scratchDir: string): Promise<FilesDiff> {
58
+ const read = async (dir: string, name: string): Promise<{ bytes: Buffer | null; doc: unknown; parsed: boolean }> => {
59
+ let bytes: Buffer;
60
+ try { bytes = await readFile(join(dir, name)); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { bytes: null, doc: undefined, parsed: false }; throw e; }
61
+ try { return { bytes, doc: JSON.parse(bytes.toString('utf8')) as unknown, parsed: true }; } catch { return { bytes, doc: undefined, parsed: false }; }
62
+ };
63
+ const documents: FilesDiff['documents'] = [];
64
+ for (const file of PRODUCER_DOCUMENTS) {
65
+ const before = await read(inspectedDir, file), after = await read(scratchDir, file);
66
+ if (!before.bytes && !after.bytes) continue;
67
+ const paths = before.parsed && after.parsed ? jsonDiff(before.doc, after.doc) : before.bytes && after.bytes && before.bytes.equals(after.bytes) ? [] : [''];
68
+ documents.push({ file, count: paths.length, paths: paths.slice(0, MAX_DIFF_PATHS) });
69
+ }
70
+ const images = async (dir: string) => Object.fromEntries(Object.entries(await rigTree(dir)).filter(([k]) => k.startsWith(`${PRODUCER_IMAGES_DIR}/`)));
71
+ const a = await images(inspectedDir), b = await images(scratchDir);
72
+ const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(), changed = names.filter(n => a[n] !== b[n]);
73
+ return { documents, images: { changed: changed.length, unchanged: names.length - changed.length, names: changed.slice(0, MAX_DIFF_PATHS) } };
74
+ }
75
+ /** The editor bar's line. */
76
+ export function baselineLine(r: Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'line'>): string {
77
+ if (r.status === 'reproduced' && r.hashes) return `baseline: reproduced — rig.json ${r.hashes.baseline.rigJson?.slice(0, 12)}… = inspected ${r.hashes.inspected.rigJson?.slice(0, 12)}…`;
78
+ if (r.status === 'not-reproduced') return `baseline: not reproduced — ${r.firstDifference}`;
79
+ return r.line;
80
+ }
81
+ /**
82
+ * For an op that rebuilds the whole rig (#102/#103): refuses unless the baseline reproduced the inspected rig.
83
+ * `remesh` does not call this; it holds its own REMESH_BASELINE_MISMATCH.
84
+ */
85
+ export class BaselineError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
86
+ export function assertWholeRigBaseline(result: BaselineResult | null): void {
87
+ if (!result) throw new BaselineError('EDIT_BASELINE_MISMATCH', 'no baseline was run: the handoff or the producer did not resolve');
88
+ if (result.status === 'running') throw new BaselineError('EDIT_BASELINE_PENDING', 'the baseline rebuild is still running');
89
+ if (result.status !== 'reproduced') throw new BaselineError('EDIT_BASELINE_MISMATCH', result.firstDifference ?? result.line);
90
+ }
91
+
92
+ /** Deterministic: the package, the handoff record and the producer. No clock. */
93
+ export const baselineKey = (sourceHash: string, handoff: Handoff, producer: InstalledTool, compiler: InstalledTool) =>
94
+ sha256(JSON.stringify([BASELINE_SCHEMA, sourceHash, handoff.record, `${producer.package}@${producer.version}`, `${compiler.package}@${compiler.version}`]));
95
+
96
+ /** Compare a finished `<dir>/baseline` with the inspected rig; `show` turns a rig-relative path into the package-relative one. */
97
+ export async function compareBaseline(out: string, inspectedRig: string, show: (rel: string) => string): Promise<Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes'>> {
98
+ const baseline = await rigTree(out), inspected = await rigTree(inspectedRig), first = firstDifference(baseline, inspected);
99
+ return { status: first === null ? 'reproduced' : 'not-reproduced', firstDifference: first === null ? null : show(first), hashes: { baseline: { rigJson: baseline['rig.json'] ?? null, tree: treeHash(baseline) }, inspected: { rigJson: inspected['rig.json'] ?? null, tree: treeHash(inspected) } } };
100
+ }
101
+
102
+ /**
103
+ * Run (or reuse) the baseline into `dir`. `dir/result.json` holds a finished result; it is reused while the baseline
104
+ * output still hashes to what it recorded. The inspected rig is re-read every time.
105
+ */
106
+ export async function runBaseline(dir: string, key: string, handoff: Handoff, producer: InstalledTool, show: (rel: string) => string): Promise<BaselineResult> {
107
+ const base = { schema: BASELINE_SCHEMA, key, producer: { package: producer.package, version: producer.version } } as const;
108
+ const out = join(dir, 'baseline'), resultFile = join(dir, 'result.json');
109
+ if (!handoff.parts || !handoff.rig) return { ...base, status: 'refused', firstDifference: null, hashes: null, line: `baseline: unavailable — MODELING_INPUT_UNAVAILABLE: ${handoff.record.missing.join(', ')}`, exitCode: null, wallMs: null };
110
+ try {
111
+ const stored = JSON.parse(await readFile(resultFile, 'utf8')) as BaselineResult;
112
+ if (stored.schema === BASELINE_SCHEMA && stored.key === key && (stored.status === 'reproduced' || stored.status === 'not-reproduced')) {
113
+ const now = await compareBaseline(out, handoff.rig, show);
114
+ if (now.hashes?.baseline.tree === stored.hashes?.baseline.tree) return { ...stored, ...now, line: baselineLine({ ...stored, ...now }) };
115
+ }
116
+ } catch { /* no reusable result */ }
117
+ await rm(dir, { recursive: true, force: true });
118
+ await mkdir(dir, { recursive: true, mode: 0o700 });
119
+ const run = await runTool(producer.bin, ['rig', '--config', handoff.config, '--parts', handoff.parts, '--out', out], { cwd: dir });
120
+ if (run.exitCode !== 0) {
121
+ const named = firstRefusal(run.lines) ?? `rig-parts rig exited ${run.exitCode} without a named refusal`;
122
+ return { ...base, status: 'refused', firstDifference: null, hashes: null, line: `baseline: not run — ${named}`, exitCode: run.exitCode, wallMs: run.wallMs };
123
+ }
124
+ const compared = await compareBaseline(out, handoff.rig, show);
125
+ const result: BaselineResult = { ...base, ...compared, line: '', exitCode: 0, wallMs: run.wallMs };
126
+ result.line = baselineLine(result);
127
+ await writeFile(resultFile, JSON.stringify(result, null, 1) + '\n', { mode: 0o600 });
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * The inspected build's own check (#108, HQ ruling): once the baseline reproduces the inspected rig, `rig-parts check`
133
+ * runs on that baseline output — byte-identical to the inspected rig, so nothing reads or writes the package — into
134
+ * `<baseline dir>/inspected-check/`, with Apply's default pack flags (`--page-edges free --pack-shape polygon`). The
135
+ * summary is stored beside the baseline record (`inspected-check.json`) so a reload reads it instead of re-running.
136
+ * It is what the physics readout's BEFORE line quotes while the inspected build is on the stage.
137
+ */
138
+ export const INSPECTED_CHECK_SCHEMA = 'rig-review-inspected-check/1';
139
+ export type InspectedCheckStatus = 'running' | 'checked' | 'refused' | 'failed';
140
+ export interface InspectedCheck {
141
+ schema: typeof INSPECTED_CHECK_SCHEMA; key: string; status: InspectedCheckStatus;
142
+ /** check's printed lines (the per-bar lines the readout quotes), capped. */
143
+ lines: string[]; check: CheckSummary | null;
144
+ /** The line to show: check's summary line, its named refusal verbatim, or "check running…". */
145
+ line: string; exitCode: number | null; wallMs: number | null;
146
+ }
147
+ export const INSPECTED_CHECK_FILE = 'inspected-check.json';
148
+ export const INSPECTED_CHECK_RUNNING = 'check running… (rig-parts check of the inspected build, in the background)';
149
+ export const inspectedCheckArgs = (parts: string): string[] => ['check', '--rig', 'baseline', '--out', 'inspected-check', '--parts', parts, '--page-edges', 'free', '--pack-shape', 'polygon'];
150
+ export const runningInspectedCheck = (key: string): InspectedCheck => ({ schema: INSPECTED_CHECK_SCHEMA, key, status: 'running', lines: [], check: null, line: INSPECTED_CHECK_RUNNING, exitCode: null, wallMs: null });
151
+ /** The stored record when it belongs to this baseline key and finished; null otherwise. */
152
+ export async function storedInspectedCheck(dir: string, key: string): Promise<InspectedCheck | null> {
153
+ try {
154
+ const stored = JSON.parse(await readFile(join(dir, INSPECTED_CHECK_FILE), 'utf8')) as InspectedCheck;
155
+ return stored.schema === INSPECTED_CHECK_SCHEMA && stored.key === key && (stored.status === 'checked' || stored.status === 'refused') ? stored : null;
156
+ } catch { return null; }
157
+ }
158
+ /** Run (or reuse) the inspected build's check in `dir` (the baseline's directory, whose `baseline/` reproduced the inspected rig). */
159
+ export async function runInspectedCheck(dir: string, key: string, parts: string, producer: InstalledTool): Promise<InspectedCheck> {
160
+ const stored = await storedInspectedCheck(dir, key);
161
+ if (stored) return stored;
162
+ await rm(join(dir, 'inspected-check'), { recursive: true, force: true });
163
+ const run = await runTool(producer.bin, inspectedCheckArgs(parts), { cwd: dir });
164
+ let json: unknown = null;
165
+ try { json = JSON.parse(await readFile(join(dir, 'inspected-check', 'check.json'), 'utf8')); } catch { /* check refused before writing it; its line says why */ }
166
+ const refusal = firstRefusal(run.lines), check = checkSummary(json, run.exitCode, run.lines, refusal);
167
+ const refused = json === null && run.exitCode !== 0;
168
+ const result: InspectedCheck = {
169
+ schema: INSPECTED_CHECK_SCHEMA, key, status: refused ? 'refused' : 'checked', lines: run.lines.slice(-4000), check: refused ? null : check,
170
+ line: refused ? refusal ?? `rig-parts check exited ${run.exitCode} without a named refusal` : check.line ?? `rig-parts check exited ${run.exitCode}`,
171
+ exitCode: run.exitCode, wallMs: run.wallMs,
172
+ };
173
+ await writeFile(join(dir, INSPECTED_CHECK_FILE), JSON.stringify(result, null, 1) + '\n', { mode: 0o600 });
174
+ return result;
175
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Bone anchors (#109): the producer config's `bones` as the Setup Bones block edits them. A single bone is
3
+ * `{name, parent, at, tip?}` and a chain `{chain, parent, points, tip}` whose links are `<chain>0 … <chain>n`
4
+ * (rig-parts AUTHORING, `bones`); every coordinate is rig px, y down, the crop frame the chest and weights views map
5
+ * to the rig world. Each origin, chain point and tip is one anchor addressed by its JSON Pointer
6
+ * (`/bones/<i>/at`, `/bones/<i>/points/<k>`, `/bones/<i>/tip`); an edit is one `replace` op at that pointer naming the
7
+ * entry (a chain is named by its `chain`). A drag moves an anchor by a whole number of rig px, so a value the config
8
+ * writes in integers stays an integer. Pure: no IO, no DOM.
9
+ */
10
+ import type {PatchOp} from './config-patch.ts';
11
+
12
+ export type RigPoint=[number,number];
13
+ export type AnchorKind='at'|'point'|'tip';
14
+ /** One config `bones` entry as read: its index, its name (`name`, or `chain` for a chain) and its stated points. */
15
+ export interface ConfigBone {index:number;name:string;chain:boolean;parent:string|null;at:RigPoint|null;points:RigPoint[];tip:RigPoint|null}
16
+ /** One draggable anchor: where it sits in the config, which bone it belongs to and the value the config holds. */
17
+ export interface BoneAnchor {path:string;index:number;name:string;kind:AnchorKind;link:number|null;value:RigPoint;label:string}
18
+ /** A pending edit: one anchor's new value, with the config's value it was made from. */
19
+ export interface BoneEdit {path:string;index:number;name:string;label:string;from:RigPoint;value:RigPoint}
20
+ export class BoneAnchorError extends Error {constructor(readonly code:string,detail:string){super(`${code}: ${detail}`);}}
21
+
22
+ const isObject=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
23
+ const asPoint=(v:unknown):RigPoint|null=>Array.isArray(v)&&v.length===2&&v.every(n=>typeof n==='number'&&Number.isFinite(n))?[v[0] as number,v[1] as number]:null;
24
+
25
+ /** The config's `bones` list read entry by entry; an entry with no name, or a malformed point, is named in `skipped`. */
26
+ export function configBones(list:unknown):{bones:ConfigBone[];skipped:string[]} {
27
+ const bones:ConfigBone[]=[],skipped:string[]=[];
28
+ if(!Array.isArray(list))return {bones,skipped:list===undefined||list===null?[]:['bones is not a list']};
29
+ list.forEach((raw,index)=>{
30
+ if(!isObject(raw)){skipped.push(`bones[${index}] is not an object`);return;}
31
+ const chain=typeof raw.chain==='string'&&typeof raw.name!=='string',name=typeof raw.name==='string'?raw.name:typeof raw.chain==='string'?raw.chain:null;
32
+ if(!name){skipped.push(`bones[${index}] names no bone (no name or chain)`);return;}
33
+ const at=raw.at===undefined?null:asPoint(raw.at),tip=raw.tip===undefined?null:asPoint(raw.tip);
34
+ const points=Array.isArray(raw.points)?raw.points.map(asPoint):[];
35
+ if((raw.at!==undefined&&!at)||(raw.tip!==undefined&&!tip)||points.some(p=>!p)||(raw.points!==undefined&&!Array.isArray(raw.points))){skipped.push(`bones[${index}] "${name}" holds a point that is not [x, y]`);return;}
36
+ bones.push({index,name,chain,parent:typeof raw.parent==='string'?raw.parent:null,at,points:points as RigPoint[],tip});
37
+ });
38
+ return {bones,skipped};
39
+ }
40
+
41
+ /** Every anchor of every bone, in config order: a bone's origin then its tip; a chain's points then its tip. */
42
+ export function boneAnchors(bones:readonly ConfigBone[]):BoneAnchor[] {
43
+ return bones.flatMap(b=>{
44
+ const out:BoneAnchor[]=[],base=`/bones/${b.index}`;
45
+ if(b.at)out.push({path:`${base}/at`,index:b.index,name:b.name,kind:'at',link:null,value:[...b.at],label:b.name});
46
+ b.points.forEach((p,k)=>out.push({path:`${base}/points/${k}`,index:b.index,name:b.name,kind:'point',link:k,value:[...p],label:`${b.name}${k}`}));
47
+ if(b.tip)out.push({path:`${base}/tip`,index:b.index,name:b.name,kind:'tip',link:null,value:[...b.tip],label:`${b.name} tip`});
48
+ return out;
49
+ });
50
+ }
51
+
52
+ /** Decimal places a value is written with (382.7 → 1, 416 → 0). */
53
+ const places=(v:number)=>{const s=String(v),dot=s.indexOf('.');return dot<0||/e/i.test(s)?0:s.length-dot-1;};
54
+ /** A pointer delta in rig px snapped to whole px (never -0). */
55
+ export const snapDelta=(d:number)=>{const r=Math.round(d);return r===0?0:r;};
56
+ /**
57
+ * A drag from `start` to `now` (both rig px) moves `from` by the delta snapped to whole px, written with the decimals
58
+ * `from` already has: an integer stays an integer, 382.7 + 7 is 389.7.
59
+ */
60
+ export function dragAnchor(from:RigPoint,start:RigPoint,now:RigPoint):RigPoint {
61
+ return [0,1].map(i=>{const v=from[i]!+snapDelta(now[i]!-start[i]!);return Number(v.toFixed(places(from[i]!)));}) as RigPoint;
62
+ }
63
+
64
+ const same=(a:RigPoint,b:RigPoint)=>a[0]===b[0]&&a[1]===b[1];
65
+ /** Set one anchor's value; a value equal to the config's removes its pending edit. */
66
+ export function setAnchor(edits:ReadonlyMap<string,BoneEdit>,anchor:BoneAnchor,value:RigPoint):Map<string,BoneEdit> {
67
+ if(!value.every(Number.isFinite))throw new BoneAnchorError('BONES_VALUE_INVALID',`${anchor.label} must be two finite numbers`);
68
+ const next=new Map(edits);
69
+ if(same(value,anchor.value))next.delete(anchor.path);
70
+ else next.set(anchor.path,{path:anchor.path,index:anchor.index,name:anchor.name,label:anchor.label,from:[...anchor.value],value:[...value]});
71
+ return next;
72
+ }
73
+ /** Where each anchor stands now: its pending value, else the config's. */
74
+ export const anchorAt=(anchor:BoneAnchor,edits:ReadonlyMap<string,BoneEdit>):RigPoint=>edits.get(anchor.path)?.value??anchor.value;
75
+ /** The pending edits as config patch ops: `replace` at the anchor's pointer, naming the entry (bone resolved by name, index recorded). */
76
+ export const boneOps=(edits:ReadonlyMap<string,BoneEdit>):PatchOp[]=>[...edits.values()].map(e=>({op:'replace',path:e.path,value:[...e.value],name:e.name}));
77
+
78
+ export const BONES_DRAFT_SCHEMA='rig-review-bones-draft/1';
79
+ export const draftText=(edits:ReadonlyMap<string,BoneEdit>)=>JSON.stringify({schema:BONES_DRAFT_SCHEMA,edits:[...edits.values()]});
80
+ /** Edits back from a draft, kept only where the anchor still resolves (same pointer, same name, same config value). */
81
+ export function draftEdits(text:string,anchors:readonly BoneAnchor[]):Map<string,BoneEdit> {
82
+ let doc:unknown;try{doc=JSON.parse(text);}catch{return new Map();}
83
+ if(!isObject(doc)||doc.schema!==BONES_DRAFT_SCHEMA||!Array.isArray(doc.edits))return new Map();
84
+ let edits=new Map<string,BoneEdit>();
85
+ for(const raw of doc.edits){
86
+ if(!isObject(raw))continue;const anchor=anchors.find(a=>a.path===raw.path),from=asPoint(raw.from),value=asPoint(raw.value);
87
+ if(!anchor||!from||!value||raw.name!==anchor.name||!same(from,anchor.value))continue;
88
+ edits=setAnchor(edits,anchor,value);
89
+ }
90
+ return edits;
91
+ }
92
+
93
+ const BONE_PATH=/^\/bones\/(0|[1-9][0-9]*)\/(at|tip|points\/(0|[1-9][0-9]*))$/;
94
+ /** The Bones block's scope: every op replaces one anchor with a finite [x, y] and names its entry. */
95
+ export function assertBoneOps(ops:readonly PatchOp[]):void {
96
+ for(const [i,op] of ops.entries()){
97
+ if(op.op!=='replace'||!BONE_PATH.test(op.path))throw new BoneAnchorError('BONES_OP_OUT_OF_SCOPE',`ops[${i}] ${op.op} ${op.path}: the Bones block replaces /bones/<i>/at, /bones/<i>/points/<k> or /bones/<i>/tip only (adding, removing or re-parenting bones is out of scope)`);
98
+ if(!asPoint(op.value))throw new BoneAnchorError('BONES_OP_OUT_OF_SCOPE',`ops[${i}] ${op.path}: the value must be [x, y] in rig px`);
99
+ if(typeof op.name!=='string')throw new BoneAnchorError('BONES_OP_OUT_OF_SCOPE',`ops[${i}] ${op.path}: the op must name its bone entry`);
100
+ }
101
+ }
102
+
103
+ /** Apply stays a choice: a LINT line blocks nothing, so only a running job or no pending edit disables it. */
104
+ export const bonesApplyEnabled=(state:{busy:boolean;pending:number;lint:readonly string[]})=>!state.busy&&state.pending>0;
105
+
106
+ /** `propose --from-config`'s lines, sorted: each LINT line, the LINT count line and the coverage lines, as printed. */
107
+ export function proposeLines(lines:readonly string[]):{lint:string[];summary:string|null;coverage:string[]} {
108
+ const trimmed=lines.map(l=>l.trim());
109
+ return {lint:trimmed.filter(l=>/^LINT\s/.test(l)),summary:trimmed.find(l=>/^\d+ LINT line\(s\)/.test(l))??null,coverage:trimmed.filter(l=>/^coverage[\s:]/.test(l))};
110
+ }
111
+ export interface OriginRow {bone:string;distance:number|null;tip:number|null;text:string}
112
+ /** `compare`'s per-bone origin and tip distances (null for a SKIP) and its summary lines (origin, tip, parent, length, direction). */
113
+ export function compareOrigins(lines:readonly string[]):{origins:OriginRow[];summary:string[]} {
114
+ const origins:OriginRow[]=[],summary:string[]=[];
115
+ for(const raw of lines){
116
+ const line=raw.trim(),row=/^([^\s:]+): origin (?:(\d+(?:\.\d+)?) px|SKIP)/.exec(line);
117
+ if(row){const tip=/ · tip (\d+(?:\.\d+)?) px/.exec(line);origins.push({bone:row[1]!,distance:row[2]===undefined?null:Number(row[2]),tip:tip?Number(tip[1]):null,text:line});continue;}
118
+ if(/^(origin|tip|parent|length|direction): /.test(line))summary.push(line);
119
+ }
120
+ return {origins,summary};
121
+ }
122
+ /** The compare rows a reviewer reads after a move: every bone whose origin or tip moved, as printed. */
123
+ export const movedRows=(rows:readonly OriginRow[])=>rows.filter(r=>(r.distance??0)>0||(r.tip??0)>0);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `POST /api/bones-check` (#109): the Bones block's pending ops, checked before any Apply. The ops are applied to a
3
+ * scratch copy of the handoff config (written the way the patch runner writes it, `scratchConfigText`) under
4
+ * `<state>/bones-check/<package hash>/<ops hash>/`, then the installed producer reads it twice:
5
+ * - `rig-parts propose --parts <handoff parts> --source <handoff source> --out propose --from-config config.json`:
6
+ * its LINT lines, LINT count line and coverage lines, shown as printed (it exits 1 on any LINT; that blocks nothing);
7
+ * - `rig-parts compare --left <handoff config> --right config.json`: each bone's origin distance and the summary lines.
8
+ * The client sends ops and the config hash only — never a path or a flag. Nothing is written into the package; the
9
+ * scratch directory is removed after the run.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { isAbsolute, join, relative } from 'node:path';
13
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
14
+ import type { ReviewPackage } from './manifest.ts';
15
+ import { firstRefusal, resolveProducer, runTool, type InstalledTool } from './producer.ts';
16
+ import { applyConfigPatch, assertOpNames, assertPatchFresh, ConfigPatchError, CONFIG_PATCH_SCHEMA, parseConfigPatch, type JsonValue, type PatchOp } from './config-patch.ts';
17
+ import { assertNamedFilesHeld, producerInputs, RebuildError, scratchConfigText } from './rebuild.ts';
18
+ import { assertBoneOps, BoneAnchorError, compareOrigins, proposeLines, type OriginRow } from './bone-anchors.ts';
19
+
20
+ export const BONES_CHECK_SCHEMA = 'rig-review-bones-check/1';
21
+ export const BONES_CHECK_LIMIT = 64 * 1024;
22
+ const MAX_LINES = 2000;
23
+ const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
24
+
25
+ /** What `propose --from-config` needs beyond the handoff config and parts, refused by name when the handoff lacks it. */
26
+ export const BONES_SOURCE_UNAVAILABLE = 'MODELING_INPUT_UNAVAILABLE: source painting — propose --from-config reads the painting (--source) and the handoff declares none; declare it with rig-review open <dir> --config <path> --source <png> --full <dir> or the review.json "producer" block';
27
+ export const proposeArgs = (r: { parts: string; source: string; config: string; out: string }): string[] => ['propose', '--parts', r.parts, '--source', r.source, '--out', r.out, '--from-config', r.config];
28
+ export const compareArgs = (left: string, right: string): string[] => ['compare', '--left', left, '--right', right];
29
+
30
+ export interface BonesLintResult {
31
+ status: 'ran' | 'unavailable';
32
+ exitCode: number | null; wallMs: number | null;
33
+ /** Each LINT line, the LINT count line and the coverage lines, as printed. */
34
+ lint: string[]; summary: string | null; coverage: string[];
35
+ /** The count line, a named refusal verbatim, or the unavailable line. */
36
+ line: string;
37
+ }
38
+ export interface BonesCompareResult { exitCode: number; wallMs: number; origins: OriginRow[]; summary: string[]; line: string }
39
+ export interface BonesCheckResult {
40
+ schema: typeof BONES_CHECK_SCHEMA; status: 'checked';
41
+ request: { configHash: string; opsSha256: string; ops: PatchOp[] };
42
+ producer: { package: string; version: string };
43
+ propose: BonesLintResult; compare: BonesCompareResult;
44
+ }
45
+
46
+ /** `{ops, configHash}` only; each op a Bones-block op (`assertBoneOps`), typed as a config patch op. */
47
+ export function parseBonesCheck(input: unknown): { ops: PatchOp[]; configHash: string } {
48
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).sort().join(',') !== 'configHash,ops') throw new RebuildError('REBUILD_REQUEST_INVALID', 'expected exactly {ops, configHash}');
49
+ const v = input as Record<string, unknown>;
50
+ if (typeof v.configHash !== 'string' || !/^[a-f0-9]{64}$/.test(v.configHash)) throw new RebuildError('REBUILD_REQUEST_INVALID', 'configHash');
51
+ let ops: PatchOp[];
52
+ try { ops = parseConfigPatch({ schema: CONFIG_PATCH_SCHEMA, config_sha256: v.configHash, ops: v.ops, producer: { name: 'check', version: 'check' }, op: { cmd: 'rig', args: [] } }).ops; }
53
+ catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
54
+ try { assertBoneOps(ops); } catch (e) { if (e instanceof BoneAnchorError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
55
+ return { ops, configHash: v.configHash };
56
+ }
57
+
58
+ /** One check at a time per review instance. */
59
+ export function createBonesCheck(p: ReviewPackage, options: { stateDir: string; starts: readonly string[]; unchanged: () => Promise<void> }) {
60
+ let running = false;
61
+ async function run(input: unknown): Promise<BonesCheckResult> {
62
+ const { ops, configHash } = parseBonesCheck(input);
63
+ for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
64
+ const inside = relative(root, options.stateDir);
65
+ if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
66
+ }
67
+ await options.unchanged();
68
+ const inputs = await producerInputs(p.handoff, p.handoffReason);
69
+ const original = JSON.parse(inputs.configText) as JsonValue;
70
+ let patched: JsonValue;
71
+ try { assertPatchFresh({ config_sha256: configHash }, inputs.configHash); assertOpNames(original, ops); patched = applyConfigPatch(original, ops); }
72
+ catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
73
+ assertNamedFilesHeld(original, patched);
74
+ const resolved = resolveProducer(options.starts);
75
+ if (!resolved.ok) { const refusal = new RebuildError(resolved.error.split(/[ :]/)[0]!, ''); refusal.message = resolved.error; throw refusal; }
76
+ if (running) throw new RebuildError('BONES_CHECK_BUSY', 'a bones check is still running');
77
+ running = true;
78
+ const opsSha256 = sha256(JSON.stringify(ops)), dir = join(options.stateDir, 'bones-check', p.sourceHash, opsSha256);
79
+ try {
80
+ await rm(dir, { recursive: true, force: true });
81
+ await mkdir(dir, { recursive: true, mode: 0o700 });
82
+ await writeFile(join(dir, 'config.json'), scratchConfigText(inputs.config, patched), { mode: 0o600 });
83
+ const propose = await lint(resolved.producer, dir, inputs.parts, p.handoff?.source ?? null);
84
+ const cmp = await runTool(resolved.producer.bin, compareArgs(inputs.config, 'config.json'), { cwd: dir });
85
+ const read = compareOrigins(cmp.lines);
86
+ const compare: BonesCompareResult = { exitCode: cmp.exitCode, wallMs: cmp.wallMs, ...read, line: cmp.exitCode === 0 ? read.summary.find(l => l.startsWith('origin:')) ?? 'compare: no origin line' : firstRefusal(cmp.lines) ?? `rig-parts compare exited ${cmp.exitCode} without a named refusal` };
87
+ try { await options.unchanged(); } catch { throw new RebuildError('REBUILD_INPUTS_CHANGED', 'the package or its producer handoff changed during the check'); }
88
+ return { schema: BONES_CHECK_SCHEMA, status: 'checked', request: { configHash, opsSha256, ops }, producer: { package: resolved.producer.package, version: resolved.producer.version }, propose, compare };
89
+ } finally {
90
+ running = false;
91
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
92
+ }
93
+ }
94
+ return { run };
95
+ }
96
+
97
+ /** `propose --from-config` on the scratch config, or the line naming the missing source painting. */
98
+ async function lint(producer: InstalledTool, dir: string, parts: string, source: string | null): Promise<BonesLintResult> {
99
+ if (!source) return { status: 'unavailable', exitCode: null, wallMs: null, lint: [], summary: null, coverage: [], line: BONES_SOURCE_UNAVAILABLE };
100
+ const run = await runTool(producer.bin, proposeArgs({ parts, source, config: 'config.json', out: 'propose' }), { cwd: dir });
101
+ const read = proposeLines(run.lines.slice(0, MAX_LINES));
102
+ // Exit 1 with LINT lines is propose's verdict on the bones, not a refusal; exit 1 with none is a refusal, quoted.
103
+ const refusal = run.exitCode !== 0 && !read.lint.length ? firstRefusal(run.lines) ?? `rig-parts propose exited ${run.exitCode} without a named refusal` : null;
104
+ return { status: 'ran', exitCode: run.exitCode, wallMs: run.wallMs, ...read, line: refusal ?? read.summary ?? `rig-parts propose exited ${run.exitCode}` };
105
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Which config patches need `rig-parts build` (#115): the cut, patch and limb sections are read by the producer's
3
+ * assemble stage, which makes the parts from the painting and its See-through runs before the rig is built, so an edit
4
+ * there is invisible to `rig --config --parts` (it reads the parts the inspected build already assembled). rig-parts
5
+ * AUTHORING §3: `assemble.patches` and `assemble.cuts` are read by "assemble", and `limbs` by "assemble (the cut and the
6
+ * caps), rig (through `regions`), check (`LIMB_ONE_BONE`)"; with `limbs`, "`assemble` reads `bones` too" — a limb's
7
+ * joint is its lower bone's origin, so a bone edit on a config that declares limbs moves a cut and needs assemble as
8
+ * well. `rig-parts build` runs assemble, rig and check in one process and needs the painting (`--source`) and the
9
+ * See-through runs (`--full`, `--head`) the handoff declares.
10
+ *
11
+ * The assemble stage also reads files the config names relative to its own directory (rig-parts `src/build.ts`:
12
+ * `readUnders`, `readCaps`, and the alternates' `source` / `full` / `head` / `skeleton`). A patch may not name a new one
13
+ * (`EDIT_PATCH_PATH_HELD`): review never sends a path, so an `under` or a `cap` can only be one the config already
14
+ * names. Pure: no IO.
15
+ */
16
+ import { parsePointer, type JsonValue, type PatchOp } from './config-patch.ts';
17
+
18
+ /** The pointer prefixes whose ops only `build` carries. */
19
+ export const BUILD_PATHS: readonly (readonly string[])[] = [['assemble'], ['limbs']];
20
+ /** With `limbs` declared, a bone edit is also a cut edit (the joint is the bone's origin). */
21
+ export const LIMB_BONE_PATH: readonly string[] = ['bones'];
22
+
23
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
24
+ const under = (tokens: readonly string[], prefix: readonly string[]) => prefix.length <= tokens.length && prefix.every((t, i) => tokens[i] === t);
25
+
26
+ /** True when a `rig` patch needs the build: any op under `/assemble` or `/limbs`, or under `/bones` on a config that declares `limbs`. */
27
+ export function needsBuild(ops: readonly Pick<PatchOp, 'path'>[], config: unknown): boolean {
28
+ const limbs = isObject(config) && config.limbs !== undefined;
29
+ return ops.some(op => { const t = parsePointer(op.path); return BUILD_PATHS.some(p => under(t, p)) || (limbs && under(t, LIMB_BONE_PATH)); });
30
+ }
31
+
32
+ /** One config-relative file the assemble stage reads: where it is named and the value as written. */
33
+ export interface BuildNamedFile { field: string; role: 'under' | 'cap' | 'source' | 'full' | 'head' | 'skeleton'; value: string; kind: 'file' | 'dir' }
34
+
35
+ /** Every config-relative path the build's assemble stage reads (besides the two `rig` reads, `CONFIG_NAMED_FILES`). */
36
+ export function buildNamedFiles(config: unknown): BuildNamedFile[] {
37
+ const out: BuildNamedFile[] = [];
38
+ if (!isObject(config)) return out;
39
+ const assemble = isObject(config.assemble) ? config.assemble : {};
40
+ const cuts = (list: unknown, at: string) => { if (Array.isArray(list)) list.forEach((q, i) => { if (isObject(q) && typeof q.under === 'string' && q.under) out.push({ field: `${at}[${i}].under`, role: 'under', value: q.under, kind: 'file' }); }); };
41
+ cuts(assemble.cuts, 'assemble.cuts');
42
+ if (Array.isArray(assemble.alternates)) assemble.alternates.forEach((a, i) => {
43
+ if (!isObject(a)) return;
44
+ for (const [role, kind] of [['source', 'file'], ['full', 'dir'], ['head', 'dir'], ['skeleton', 'file']] as const) if (typeof a[role] === 'string' && a[role]) out.push({ field: `assemble.alternates[${i}].${role}`, role, value: a[role] as string, kind });
45
+ cuts(a.cuts, `assemble.alternates[${i}].cuts`);
46
+ });
47
+ if (isObject(config.limbs)) for (const [limb, entry] of Object.entries(config.limbs)) {
48
+ if (!isObject(entry) || !Array.isArray(entry.segments)) continue;
49
+ entry.segments.forEach((sg, k) => { if (isObject(sg) && typeof sg.cap === 'string' && sg.cap) out.push({ field: `limbs.${limb}.segments[${k}].cap`, role: 'cap', value: sg.cap, kind: 'file' }); });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * `null` when every file the patched config names for the assemble stage is one the handoff config already names in the
56
+ * same role (a cut may drop its `under`, a cut may go, an `under` may move to another cut — but no new path); else the
57
+ * first field that names a new one.
58
+ */
59
+ export function newBuildFile(original: JsonValue, patched: JsonValue): string | null {
60
+ const held = new Set(buildNamedFiles(original).map(f => `${f.role}\u0000${f.value}`));
61
+ return buildNamedFiles(patched).find(f => !held.has(`${f.role}\u0000${f.value}`))?.field ?? null;
62
+ }
63
+
64
+ /** The patched config with every assemble-read path made absolute against the handoff config's directory (`resolve`). */
65
+ export function absolutizeBuildFiles(doc: Record<string, unknown>, resolve: (value: string) => string): void {
66
+ const fix = (holder: unknown, key: string) => { if (isObject(holder) && typeof holder[key] === 'string' && holder[key]) holder[key] = resolve(holder[key] as string); };
67
+ const assemble = isObject(doc.assemble) ? doc.assemble : null;
68
+ const cuts = (list: unknown) => { if (Array.isArray(list)) for (const q of list) fix(q, 'under'); };
69
+ if (assemble) {
70
+ cuts(assemble.cuts);
71
+ if (Array.isArray(assemble.alternates)) for (const a of assemble.alternates) { for (const k of ['source', 'full', 'head', 'skeleton']) fix(a, k); if (isObject(a)) cuts(a.cuts); }
72
+ }
73
+ if (isObject(doc.limbs)) for (const entry of Object.values(doc.limbs)) if (isObject(entry) && Array.isArray(entry.segments)) for (const sg of entry.segments) fix(sg, 'cap');
74
+ }
75
+
76
+ /**
77
+ * The build's own lines, read for review (prefix `[assemble]`, `[rig]`, `[check]` stripped): every `FAIL` line (the
78
+ * refusals, verbatim), the `cut:` / `limb cut:` / `limb cap:` lines, `recomposite vs source`, the uncovered holes, the
79
+ * `seam:` and `seam pair` lines, and check's last `check: …` line.
80
+ */
81
+ export interface BuildReadout { refusals: string[]; assemble: string[]; recomposite: string | null; seam: string[]; seamPairs: string[]; check: string | null }
82
+ export const MAX_BUILD_READOUT = 64;
83
+ const stripStage = (line: string) => line.replace(/^\s*\[[a-z]+\]\s?/, '').trim();
84
+ export function buildReadout(lines: readonly string[]): BuildReadout {
85
+ const out: BuildReadout = { refusals: [], assemble: [], recomposite: null, seam: [], seamPairs: [], check: null };
86
+ for (const raw of lines) {
87
+ const line = stripStage(raw);
88
+ if (/^FAIL\s+[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/.test(line)) { if (out.refusals.length < MAX_BUILD_READOUT) out.refusals.push(line); continue; }
89
+ if (/^(cut|limb cut|limb cap):\s/.test(line) || /^uncovered hole \d+:/.test(line)) { if (out.assemble.length < MAX_BUILD_READOUT) out.assemble.push(line); continue; }
90
+ if (/^recomposite vs source:/.test(line)) { out.recomposite = line; continue; }
91
+ // The base set's pairs only; an alternate set's own pairs read `seam pair (<set>) …`.
92
+ if (/^seam pair \d+ of \d+:/.test(line)) { if (out.seamPairs.length < MAX_BUILD_READOUT) out.seamPairs.push(line); continue; }
93
+ if (/^seam:\s/.test(line)) { out.seam.push(line); continue; }
94
+ if (/^check:\s/.test(line)) out.check = line;
95
+ }
96
+ return out;
97
+ }
98
+ /** The lines with the stage prefix stripped, for check's summary and the first-refusal reader. */
99
+ export const buildLines = (lines: readonly string[]) => lines.map(stripStage);
100
+
101
+ /** `parts.json`'s `recomposite` block figures (rig-parts AUTHORING §5), or null without one. */
102
+ export interface RecompositeFigures { mean_abs: number; within_share: number; error_px: number; uncovered_error_px: number; hole_count: number }
103
+ export function recompositeFigures(partsJson: unknown): RecompositeFigures | null {
104
+ const block = isObject(partsJson) && isObject(partsJson.recomposite) ? partsJson.recomposite : null;
105
+ if (!block) return null;
106
+ const keys = ['mean_abs', 'within_share', 'error_px', 'uncovered_error_px', 'hole_count'] as const;
107
+ if (!keys.every(k => typeof block[k] === 'number' && Number.isFinite(block[k]))) return null;
108
+ return Object.fromEntries(keys.map(k => [k, block[k] as number])) as unknown as RecompositeFigures;
109
+ }