@rig-c/review 0.6.0 → 0.7.1

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 +241 -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
package/src/rebuild.ts ADDED
@@ -0,0 +1,938 @@
1
+ /**
2
+ * Apply · Rebuild (issue #99): the user's chest-region designation goes to the installed producer as one mesh entry,
3
+ * `rig-parts remesh` re-fits that one part of the package's gated rig into a scratch revision, `rigc build` compiles
4
+ * the revision the way remesh's own gate did, and the compiled scratch build is served read-only for the viewer to
5
+ * load in place. Review builds nothing itself, writes nothing into the package and takes no path from the client:
6
+ * the producer inputs are the package's resolved handoff (#101, `src/handoff.ts`: declared, else the fixed discovery
7
+ * order), the scratch directory is `<state>/rebuild/<package hash>/<designation hash>/` (no clock), and an identical
8
+ * designation reuses its build. Once per session the baseline (`src/baseline.ts`) rebuilds the whole rig from the
9
+ * handoff and says whether it reproduces the inspected rig.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync } from 'node:fs';
13
+ import { copyFile, mkdir, readFile, readdir, realpath, rm, writeFile, lstat } from 'node:fs/promises';
14
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
+ import { object, packagePath, safeFile, type ReviewPackage } from './manifest.ts';
16
+ import { compileArgs, firstRefusal, modelingProducerTooOld, REMESH_DELTA_SINCE, remeshArgs, remeshDeltaArgs, resolvePose, resolveProducer, runTool, versionAtLeast, type CompileRequest, type InstalledTool, type ProcessResult } from './producer.ts';
17
+ import { meshEditText } from './mesh-edit.ts';
18
+ import { CONFIG_NAMED_FILES, HANDOFF_DECLARATION, type Handoff, type HandoffRecord } from './handoff.ts';
19
+ import { assertWholeRigBaseline, BaselineError, baselineKey, producerDiff, runBaseline, runInspectedCheck, runningInspectedCheck, type BaselineResult, type InspectedCheck } from './baseline.ts';
20
+ import { applyConfigPatch, assertOpNames, assertPatchFresh, ConfigPatchError, parseConfigPatch, patchSha256, patchText, remeshPart, type ConfigPatch, type JsonValue } from './config-patch.ts';
21
+ import { checkSummary, filesDiffText, MAX_RETARGET_LINES, type CheckSummary, type FilesDiff } from './applied-context.ts';
22
+ import { idleKeying, idleSource, type IdleStatus } from './idle-tracks.ts';
23
+ import { CHEST_REGIONS_MAX } from './chest-regions.ts';
24
+ import { bustMeshEntry, deriveBust, parseBustSpec, BustSpecError, type BustSpec } from './chest-landmarks.ts';
25
+ import { modelMeshTriangles, remeshCounts, type RemeshCounts } from './mesh-entry.ts';
26
+ import { patchRoute } from './retune-route.ts';
27
+ import { absolutizeBuildFiles, buildLines, buildNamedFiles, buildReadout, needsBuild, newBuildFile, recompositeFigures, type RecompositeFigures } from './build-route.ts';
28
+ import { jsonDiff } from './config-patch.ts';
29
+ import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
30
+
31
+ export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
32
+ const MAX_REGIONS = CHEST_REGIONS_MAX, MAX_POINTS = 256, MAX_LINES = 4000, GRID = 256;
33
+
34
+ export interface ProducerInputs { config: string; parts: string; rig: string; configText: string; configHash: string; handoff: HandoffRecord }
35
+ const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
36
+
37
+ /** Without any handoff, Apply refuses by this name and says which flag declares one. */
38
+ export const modelingHandoffMissing = (reason: string | null) => `MODELING_HANDOFF_MISSING: no producer handoff — declare it with ${HANDOFF_DECLARATION}${reason ? ` (${reason})` : ''}`;
39
+ /** What remesh needs from the handoff, or the line naming what is missing. */
40
+ export function handoffGap(handoff: Handoff): string | null {
41
+ if (!handoff.parts) return `MODELING_INPUT_UNAVAILABLE: ${handoff.record.missing.join(', ')}`;
42
+ const missing = handoff.record.missing.filter(m => m.startsWith('parts.json') || m.startsWith('rig '));
43
+ return handoff.rig && !missing.length ? null : `MODELING_INPUT_UNAVAILABLE: ${missing.join(', ') || 'rig'}`;
44
+ }
45
+ /** The handoff's config, parts and inspected rig, the config read and checked against the bytes the session is bound to. */
46
+ export async function producerInputs(handoff: Handoff | null, reason: string | null): Promise<ProducerInputs> {
47
+ if (!handoff) throw new RebuildError('MODELING_HANDOFF_MISSING', modelingHandoffMissing(reason).slice('MODELING_HANDOFF_MISSING: '.length));
48
+ const gap = handoffGap(handoff);
49
+ if (gap) throw new RebuildError('MODELING_INPUT_UNAVAILABLE', gap.slice('MODELING_INPUT_UNAVAILABLE: '.length));
50
+ const configText = await readFile(handoff.config, 'utf8'), configHash = sha256(configText);
51
+ if (configHash !== handoff.record.config.sha256) throw new RebuildError('REBUILD_INPUTS_CHANGED', `${handoff.record.config.path} changed since the review was opened; restart review`);
52
+ return { config: handoff.config, parts: handoff.parts!, rig: handoff.rig!, configText, configHash, handoff: handoff.record };
53
+ }
54
+
55
+ /** The config's `constraints` list as written, or null when it has none. */
56
+ export function configConstraints(configText: string): unknown[] | null {
57
+ const list = object(JSON.parse(configText), 'config').constraints;
58
+ return Array.isArray(list) ? list : null;
59
+ }
60
+
61
+ /**
62
+ * The idle the Idle tracks panel edits (#104): the config's motion as written (shapes read by `idleSource`) and where
63
+ * the inspected idle keys each track (`idleKeying`, read off the handoff rig's rig.json and motion.json).
64
+ */
65
+ export async function idleStatus(inputs: Pick<ProducerInputs, 'configText' | 'rig'>): Promise<IdleStatus> {
66
+ const read = async (name: string): Promise<unknown> => { try { return JSON.parse(await readFile(join(inputs.rig, name), 'utf8')); } catch { return null; } };
67
+ return { source: idleSource(JSON.parse(inputs.configText)), keying: idleKeying(await read('rig.json'), await read('motion.json')) };
68
+ }
69
+
70
+ export type ProducerRegion =
71
+ | { name: string; shape: 'circle'; cx: number; cy: number; r: number; bone: string; spacing: number; band: number }
72
+ | { name: string; bone: string; shape: 'polygon'; points: [number, number][]; spacing: number; band: number };
73
+ export interface ContourPart { name: string; regions: unknown[]; entry: Record<string, unknown> }
74
+ /** The config's `bones` list as written (#109), or null when it states none. */
75
+ export function configBonesList(configText: string): unknown[] | null {
76
+ const list = object(JSON.parse(configText), 'config').bones;
77
+ return Array.isArray(list) ? list : null;
78
+ }
79
+ /**
80
+ * The config's sections the Rig fields block edits (#110), as written: `regions`, `motion.blink`, `stops`, `bones` and
81
+ * `turn` (an absent section is left out). The block reads them with `rigFieldsSource` (`src/rig-fields.ts`).
82
+ */
83
+ export function configFields(configText: string): Record<string, unknown> {
84
+ const config = object(JSON.parse(configText), 'config'), motion = config.motion, out: Record<string, unknown> = {};
85
+ for (const key of ['regions', 'stops', 'bones', 'turn']) if (config[key] !== undefined) out[key] = config[key];
86
+ if (motion && typeof motion === 'object' && !Array.isArray(motion) && (motion as Record<string, unknown>).blink !== undefined) out.motion = { blink: (motion as Record<string, unknown>).blink };
87
+ return out;
88
+ }
89
+ /**
90
+ * #115: what the Cuts block edits, as written — `assemble.plan`, `assemble.cuts`, `assemble.patches` (each only when
91
+ * the config states it) and `limbs` — read by `cutsSource` (`src/cut-edits.ts`).
92
+ */
93
+ export function configCuts(configText: string): Record<string, unknown> {
94
+ const config = object(JSON.parse(configText), 'config'), assemble = config.assemble, out: Record<string, unknown> = {};
95
+ if (assemble && typeof assemble === 'object' && !Array.isArray(assemble)) {
96
+ const a = assemble as Record<string, unknown>, kept: Record<string, unknown> = {};
97
+ for (const key of ['plan', 'cuts', 'patches']) if (a[key] !== undefined) kept[key] = a[key];
98
+ out.assemble = kept;
99
+ }
100
+ if (config.limbs !== undefined) out.limbs = config.limbs;
101
+ return out;
102
+ }
103
+ /** #115: why a build-routed Apply cannot run here (the handoff declares no painting and full run), or null. */
104
+ export const buildGap = (handoff: Handoff | null) => (handoff && handoff.source && handoff.full ? null : BUILD_SOURCE_UNAVAILABLE);
105
+ /** Every `meshes.<part>` entry as written (#106: the Mesh panel starts from the config's own entry). */
106
+ export function configMeshes(configText: string): Record<string, unknown> {
107
+ const meshes = object(JSON.parse(configText), 'config').meshes;
108
+ return meshes && typeof meshes === 'object' && !Array.isArray(meshes) ? meshes as Record<string, unknown> : {};
109
+ }
110
+ /** Config meshes whose entry is a contour (the only mode with regions); its regions as written. */
111
+ export function contourParts(configText: string): ContourPart[] {
112
+ const config = object(JSON.parse(configText), 'config'), meshes = config.meshes;
113
+ if (!meshes || typeof meshes !== 'object' || Array.isArray(meshes)) return [];
114
+ return Object.entries(meshes as Record<string, unknown>).flatMap(([name, entry]) => {
115
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [];
116
+ const contour = (entry as Record<string, unknown>).contour;
117
+ if (!contour || typeof contour !== 'object' || Array.isArray(contour)) return [];
118
+ const regions = (contour as Record<string, unknown>).regions;
119
+ return [{ name, regions: Array.isArray(regions) ? regions : [], entry: entry as Record<string, unknown> }];
120
+ });
121
+ }
122
+
123
+ export class RebuildError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
124
+ const bad = (detail: string): never => { throw new RebuildError('REBUILD_REQUEST_INVALID', detail); };
125
+ const finite = (value: unknown, field: string, min = -Infinity, exclusive = false): number => {
126
+ if (typeof value !== 'number' || !Number.isFinite(value) || (exclusive ? value <= min : value < min)) bad(`${field} must be a finite number${min === -Infinity ? '' : exclusive ? ` > ${min}` : ` >= ${min}`}`);
127
+ return value as number;
128
+ };
129
+ const onGrid = (value: number, field: string) => { if (!Number.isInteger(value * GRID)) bad(`${field} is off the 1/${GRID} px grid`); return value; };
130
+
131
+ /** One designated region in the producer's config shape. Geometry beyond types and the grid is the producer's to judge. */
132
+ export function parseProducerRegion(value: unknown, field: string, bones: readonly string[] | null): ProducerRegion {
133
+ const v = object(value, field), keys = Object.keys(v).sort().join(',');
134
+ const name = v.name, bone = v.bone;
135
+ if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(name)) bad(`${field}.name`);
136
+ if (typeof bone !== 'string' || !bone || bone.length > 128) bad(`${field}.bone`);
137
+ if (bones && !bones.includes(bone as string)) throw new RebuildError('REBUILD_REGION_BONE_UNKNOWN', `${field}.bone ${String(bone)} is not a bone of the loaded model`);
138
+ const spacing = finite(v.spacing, `${field}.spacing`, 0, true), band = onGrid(finite(v.band, `${field}.band`, 0), `${field}.band`);
139
+ if (v.shape === 'circle') {
140
+ if (keys !== 'band,bone,cx,cy,name,r,shape,spacing') bad(`${field}: a circle holds exactly name, shape, cx, cy, r, bone, spacing, band`);
141
+ return { name: name as string, shape: 'circle', cx: onGrid(finite(v.cx, `${field}.cx`), `${field}.cx`), cy: onGrid(finite(v.cy, `${field}.cy`), `${field}.cy`), r: finite(v.r, `${field}.r`, 0, true), bone: bone as string, spacing, band };
142
+ }
143
+ if (v.shape === 'polygon') {
144
+ if (keys !== 'band,bone,name,points,shape,spacing') bad(`${field}: a polygon holds exactly name, bone, shape, points, spacing, band`);
145
+ if (!Array.isArray(v.points) || v.points.length < 3 || v.points.length > MAX_POINTS) bad(`${field}.points must hold 3–${MAX_POINTS} points`);
146
+ const points = (v.points as unknown[]).map((p, i): [number, number] => {
147
+ if (!Array.isArray(p) || p.length !== 2) bad(`${field}.points[${i}]`);
148
+ const [x, y] = p as unknown[];
149
+ return [onGrid(finite(x, `${field}.points[${i}].x`), `${field}.points[${i}].x`), onGrid(finite(y, `${field}.points[${i}].y`), `${field}.points[${i}].y`)];
150
+ });
151
+ return { name: name as string, bone: bone as string, shape: 'polygon', points, spacing, band };
152
+ }
153
+ return bad(`${field}.shape must be circle or polygon`);
154
+ }
155
+
156
+ /**
157
+ * `{part, regions}` (#99, the regions as written) or `{part, bust}` (#105, the `contour.bust` landmarks); `regions` of a
158
+ * bust request is review's derivation of the two circles (`src/chest-landmarks.ts`), recorded beside it, never sent.
159
+ */
160
+ export interface RebuildRequest { part: string; regions: ProducerRegion[]; configHash: string; bust?: BustSpec }
161
+ /** `{part, regions, configHash}` or `{part, bust, configHash}`: no path, no flag, no command — the server composes everything else. */
162
+ export function parseRebuildRequest(input: unknown, bones: readonly string[] | null): RebuildRequest {
163
+ const v = object(input, 'rebuild'), keys = Object.keys(v).sort().join(',');
164
+ if (keys !== 'configHash,part,regions' && keys !== 'bust,configHash,part') bad('expected exactly {part, regions, configHash} or {part, bust, configHash}');
165
+ if (typeof v.part !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(v.part)) bad('part');
166
+ if (typeof v.configHash !== 'string' || !/^[a-f0-9]{64}$/.test(v.configHash)) bad('configHash');
167
+ if ('bust' in v) {
168
+ // Typed as the producer's loader types it; whether the circles clear and the bones resolve is the producer's call.
169
+ let bust: BustSpec;
170
+ try { bust = parseBustSpec(v.bust); } catch (e) { if (e instanceof BustSpecError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
171
+ return { part: v.part as string, regions: deriveBust(bust).regions.map(r => ({ ...r })), configHash: v.configHash as string, bust };
172
+ }
173
+ if (!Array.isArray(v.regions) || v.regions.length < 1 || v.regions.length > MAX_REGIONS) bad(`regions must hold 1–${MAX_REGIONS} entries`);
174
+ const regions = (v.regions as unknown[]).map((r, i) => parseProducerRegion(r, `regions[${i}]`, bones));
175
+ if (new Set(regions.map(r => r.name)).size !== regions.length) bad('regions: duplicate names');
176
+ return { part: v.part as string, regions, configHash: v.configHash as string };
177
+ }
178
+
179
+ /** The part's config entry with `contour.regions` replaced by the designation and every other field as written. */
180
+ export function meshEntryText(configText: string, part: string, regions: readonly ProducerRegion[]): string {
181
+ const found = contourParts(configText).find(p => p.name === part);
182
+ if (!found) throw new RebuildError('REBUILD_PART_NOT_CONTOUR', `config meshes.${part} is not a contour entry; regions apply to contour meshes only`);
183
+ const entry = structuredClone(found.entry), contour = entry.contour as Record<string, unknown>;
184
+ contour.regions = regions.map(r => ({ ...r }));
185
+ return JSON.stringify(entry, null, 1) + '\n';
186
+ }
187
+ /** The part's config entry with `contour.bust` in place of `contour.regions` (#105) and every other field as written. */
188
+ export function bustEntryText(configText: string, part: string, bust: BustSpec): string {
189
+ const found = contourParts(configText).find(p => p.name === part);
190
+ if (!found) throw new RebuildError('REBUILD_PART_NOT_CONTOUR', `config meshes.${part} is not a contour entry; contour.bust applies to contour meshes only`);
191
+ return JSON.stringify(bustMeshEntry(found.entry, bust), null, 1) + '\n';
192
+ }
193
+
194
+ /** Deterministic: the package, the config bytes, the part, the entry bytes and both tool versions. No clock. */
195
+ export function designationId(input: { sourceHash: string; configHash: string; part: string; entry: string; producer: string; compiler: string }): string {
196
+ return sha256(JSON.stringify([REBUILD_SCHEMA, input.sourceHash, input.configHash, input.part, input.entry, input.producer, input.compiler]));
197
+ }
198
+ export const rebuildRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild', sourceHash);
199
+ export const rebuildDir = (stateDir: string, sourceHash: string, id: string) => join(rebuildRoot(stateDir, sourceHash), id);
200
+
201
+ export type RebuildStatus = 'running' | 'built' | 'refused' | 'failed';
202
+ export interface RebuildBuild { model: string; modelUrl: string; modelHash: string; files: Record<string, string> }
203
+ export interface RebuildJob {
204
+ schema: typeof REBUILD_SCHEMA; id: string; status: RebuildStatus; reused: boolean;
205
+ request: { part: string; regions: ProducerRegion[]; configHash: string; entrySha256: string; bust?: BustSpec };
206
+ producer: { package: string; version: string }; compiler: { package: string; version: string };
207
+ lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { remesh: number | null; compile: number | null };
208
+ build: RebuildBuild | null;
209
+ }
210
+ /**
211
+ * A patch Apply (#102): the config patch, its hash and the config bytes it was made against; the op's, the compile's
212
+ * and check's wall clocks; check's summary; and every JSON Pointer at which the scratch rig.json differs from the
213
+ * inspected one (`diff`) — and, #125, every document the producer writes, per file, with the images by hash (`files`).
214
+ */
215
+ export interface PatchJob {
216
+ schema: typeof REBUILD_SCHEMA; kind: 'patch'; id: string; status: RebuildStatus; reused: boolean;
217
+ /** #136: the producer op that ran — a `rig` patch runs `retune` when `patchRoute` routes it there, else `rig`; a remesh patch `remesh`. */
218
+ op: AppliedOp;
219
+ request: { patch: ConfigPatch; patchSha256: string; configHash: string };
220
+ producer: { package: string; version: string }; compiler: { package: string; version: string };
221
+ lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null; check: number | null };
222
+ check: CheckSummary | null; diff: { count: number; paths: string[] } | null;
223
+ /** #125: the per-file diff of every producer document (`src/producer-files.ts`) and the images by hash. */
224
+ files: FilesDiff | null;
225
+ /** #106: a remesh patch's part counts from the revision's `remesh.json`, and the compiled model's triangles for the part. */
226
+ remesh: (RemeshCounts & { modelTriangles: number | null }) | null;
227
+ build: RebuildBuild | null;
228
+ /** #115, a `build` job only: what the assemble stage read and wrote (absent on every other op, so their jobs are unchanged). */
229
+ assembled?: AssembledView;
230
+ }
231
+ /** The producer op a patch job ran (#136; `build` since #115). */
232
+ export type AppliedOp = 'rig' | 'retune' | 'remesh' | 'build';
233
+ /**
234
+ * #115: a `rig-parts build` job's assemble readout — the inputs it was run with (paths relative to the package root, the
235
+ * assemble-read config files by hash), every FAIL line verbatim, the `cut:` / `limb cut:` / `limb cap:` / uncovered-hole
236
+ * lines, the setup-pose seam and its `seam pair` lines, the recomposite figures before (the handoff's parts.json) and
237
+ * after (the scratch one) with the scratch error map served read-only, and which parts changed against the handoff's.
238
+ */
239
+ export interface AssembledView {
240
+ inputs: { source: string; full: string; head: string | null; named: { field: string; path: string; sha256: string | null }[] };
241
+ /** `refusals`: the FAIL lines of a stage that refused; `checkFails`: check's FAIL lines when its bars were not met (a judgment, the build kept). */
242
+ refusals: string[]; checkFails: string[]; lines: string[]; seam: string[]; seamPairs: string[];
243
+ recomposite: { line: string | null; before: RecompositeFigures | null; after: RecompositeFigures | null; errorMap: { url: string; sha256: string } | null };
244
+ parts: { changed: string[]; unchanged: number; partsJson: { count: number; paths: string[] } } | null;
245
+ }
246
+ /** The scratch error map's served name (`/rebuild/<id>/<name>`), read from `<dir>/out/recomposite_error_rig.png`. */
247
+ export const RECOMPOSITE_ERROR_MAP = 'recomposite/recomposite_error_rig.png';
248
+ /** A build's refusal before it runs: the handoff declares no painting or See-through run for assemble to read. */
249
+ export const BUILD_SOURCE_UNAVAILABLE = 'MODELING_INPUT_UNAVAILABLE: source painting and See-through runs — rig-parts build reassembles the parts from the painting (--source) and the runs (--full, --head) before the rig, and the handoff declares none; declare them with rig-review open <dir> --config <path> --source <png> --full <dir> [--head <dir>] or the review.json "producer" block';
250
+
251
+ /**
252
+ * #114: a retarget Apply's job — `rig-pose clean` (unless skipped), `rig-pose retarget` onto the handoff rig into
253
+ * `<dir>/motion.json`, the handoff rig's files beside it as `<dir>/rig/` (motion.json the retarget's), then the shared
254
+ * tail (per-file diff, compile). `refusals` holds every `FAIL` line the failing step printed, verbatim; `readings` the
255
+ * run's FEET / STANCE / FLIP / SHORT / END-ON / LEG-SCALE / REACH lines with their frames; `motion` what the scratch
256
+ * motion holds against the inspected rig's.
257
+ */
258
+ export interface RetargetJob {
259
+ schema: typeof REBUILD_SCHEMA; kind: 'retarget'; id: string; status: RebuildStatus; reused: boolean; op: 'retarget';
260
+ request: { record: RetargetRecord; recordSha256: string; configHash: string };
261
+ producer: { package: string; version: string }; compiler: { package: string; version: string };
262
+ lines: string[]; producerLine: string | null; refusals: string[]; exitCode: number | null;
263
+ wallMs: { clean: number | null; retarget: number | null; compile: number | null };
264
+ files: FilesDiff | null; motion: MotionSummary | null; readings: RetargetLine[];
265
+ build: RebuildBuild | null;
266
+ }
267
+ /** Deterministic: the package, the config bytes, the record's bytes and both tool versions. No clock. */
268
+ export function retargetId(input: { sourceHash: string; configHash: string; record: RetargetRecord; producer: string; compiler: string }): string {
269
+ return sha256(JSON.stringify([REBUILD_SCHEMA, 'retarget', input.sourceHash, input.configHash, retargetRecordText(input.record), input.producer, input.compiler]));
270
+ }
271
+ /** A pose reference the retarget can read: a `rig-pose-sequence/1` the package declares. */
272
+ export interface RetargetSequence { id: string; label: string; path: string; cleaned: boolean; frames: number; people: string[] }
273
+
274
+ /**
275
+ * #135: Edit Mesh's Apply · Rebuild — `{part, delta, configHash}`, the delta being the `rig-review-mesh-edit/1` document
276
+ * the editor exports (`src/mesh-edit.ts`, `basis: "setup"`). Review checks only the envelope: the document's schema,
277
+ * basis, source counts and hash and every op are the producer's to judge (`rig-parts remesh --delta`), and its
278
+ * `REMESH_DELTA_*` / `REMESH_*` line comes back verbatim. The delta is written as `meshEditText` writes the download,
279
+ * so `deltaSha256` is the `mesh-edit.binding.json` file hash of the same document.
280
+ */
281
+ export interface DeltaRequest { part: string; delta: Record<string, unknown>; configHash: string }
282
+ export function parseDeltaRequest(input: unknown): DeltaRequest {
283
+ const v = object(input, 'rebuild'), keys = Object.keys(v).sort().join(',');
284
+ if (keys !== 'configHash,delta,part') bad('expected exactly {part, delta, configHash}');
285
+ if (typeof v.part !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(v.part)) bad('part');
286
+ if (typeof v.configHash !== 'string' || !/^[a-f0-9]{64}$/.test(v.configHash)) bad('configHash');
287
+ if (!v.delta || typeof v.delta !== 'object' || Array.isArray(v.delta)) bad('delta must be one rig-review-mesh-edit/1 document');
288
+ return { part: v.part as string, delta: v.delta as Record<string, unknown>, configHash: v.configHash as string };
289
+ }
290
+ /** Deterministic: the package, the config bytes, the part, the delta's bytes and both tool versions. No clock. */
291
+ export function deltaId(input: { sourceHash: string; configHash: string; part: string; deltaText: string; producer: string; compiler: string }): string {
292
+ return sha256(JSON.stringify([REBUILD_SCHEMA, 'delta', input.sourceHash, input.configHash, input.part, input.deltaText, input.producer, input.compiler]));
293
+ }
294
+ /** A delta Apply's job (#135): `remesh --delta`, then the shared tail with the per-file diff (#125); remesh's own gate holds check. */
295
+ export interface DeltaJob {
296
+ schema: typeof REBUILD_SCHEMA; kind: 'delta'; id: string; status: RebuildStatus; reused: boolean; op: 'remesh';
297
+ request: { part: string; delta: Record<string, unknown>; deltaSha256: string; configHash: string };
298
+ producer: { package: string; version: string }; compiler: { package: string; version: string };
299
+ lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null };
300
+ files: FilesDiff | null;
301
+ remesh: (RemeshCounts & { modelTriangles: number | null }) | null;
302
+ build: RebuildBuild | null;
303
+ }
304
+ /**
305
+ * Deterministic: the package, the config bytes, the patch's own bytes and both tool versions. No clock. A retune-routed
306
+ * patch adds its op (#136), so a scratch build the whole-rig path left for the same patch is never reused as retune's.
307
+ */
308
+ export function patchId(input: { sourceHash: string; configHash: string; patch: ConfigPatch; producer: string; compiler: string; op?: AppliedOp; buildInputs?: string }): string {
309
+ // #115: a build-routed patch adds its op and the hash of the files assemble reads (painting, runs, named files).
310
+ return sha256(JSON.stringify([REBUILD_SCHEMA, 'patch', input.sourceHash, input.configHash, patchText(input.patch), input.producer, input.compiler, ...(input.op === 'retune' ? ['retune'] : []), ...(input.op === 'build' ? ['build', input.buildInputs ?? ''] : [])]));
311
+ }
312
+ const namedFile = (doc: unknown, keys: readonly string[]): unknown => keys.reduce<unknown>((v, k) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>)[k] : undefined), doc);
313
+ /**
314
+ * The patched config as the producer's own scratch configs are written (rig-parts `scratchConfig`): every path the
315
+ * loader resolves against the config's directory (`motion.animations_from`, `generation.control.skeleton`) made
316
+ * absolute when it names a file there, so the scratch copy reads the same files as the handoff config.
317
+ */
318
+ export function scratchConfigText(configPath: string, doc: JsonValue): string {
319
+ const configDir = dirname(resolve(configPath)), out = structuredClone(doc) as Record<string, unknown>;
320
+ for (const keys of CONFIG_NAMED_FILES) {
321
+ const holder = namedFile(out, keys.slice(0, -1)) as Record<string, unknown> | undefined, key = keys.at(-1)!, value = holder?.[key];
322
+ if (typeof value === 'string' && value && !isAbsolute(value) && existsSync(resolve(configDir, value))) holder![key] = resolve(configDir, value);
323
+ }
324
+ return JSON.stringify(out, null, 2) + '\n';
325
+ }
326
+ /** A patch may not change which files the config names (they are the handoff's hashed files): `EDIT_PATCH_PATH_HELD`. */
327
+ export function assertNamedFilesHeld(before: unknown, after: unknown): void {
328
+ for (const keys of CONFIG_NAMED_FILES) if (JSON.stringify(namedFile(before, keys)) !== JSON.stringify(namedFile(after, keys))) throw new RebuildError('EDIT_PATCH_PATH_HELD', `${keys.join('.')} names a handoff file; a patch may not change it`);
329
+ }
330
+ /** The value of a flag in the patch's args, or the producer's default. */
331
+ const flagValue = (args: readonly string[], flag: string, fallback: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1]! : fallback; };
332
+
333
+ export interface RebuildOptions { stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null }
334
+ export const baselineDir = (stateDir: string, key: string) => join(stateDir, 'baseline', key);
335
+
336
+ const tool = (t: InstalledTool) => ({ package: t.package, version: t.version });
337
+ async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; throw e; } }
338
+
339
+ type Resolved = Extract<ReturnType<typeof resolveProducer>, { ok: true }>;
340
+ type PageEdges = CompileRequest['pageEdges'];
341
+ type PackShape = CompileRequest['packShape'];
342
+ /** A runner's producer op (#121): the one step the two Apply paths do not share. */
343
+ interface ProducerOp {
344
+ /** The producer command, for the unnamed-refusal line. */
345
+ cmd: string;
346
+ /** The producer's binary name, for the unnamed-refusal line (`rig-parts` unless stated). */
347
+ tool?: string;
348
+ /** The rig spec directory the op writes under the scratch directory. */
349
+ spec: string;
350
+ /** Writes the op's inputs into the scratch directory, says what runs, runs it. */
351
+ run: (push: (line: string) => void) => Promise<ProcessResult>;
352
+ /** Where the job records the op's wall clock. */
353
+ wallMs: (ms: number) => void;
354
+ /** After a clean op: the pack flags the build compiles with, and what the compile line adds about them. */
355
+ flags: (push: (line: string) => void) => Promise<{ pageEdges: PageEdges; packShape: PackShape; note: string }>;
356
+ }
357
+ /** The tail's optional steps; each receives what it measured for the job to record. */
358
+ interface ApplySteps {
359
+ /** #125: diff every producer document against the inspected rig, before the compile. */
360
+ diff?: (files: FilesDiff) => void;
361
+ /** #102: check the scratch rig after the compile. */
362
+ check?: (summary: CheckSummary, wallMs: number) => void;
363
+ /** After the build is hashed, before result.json is written. */
364
+ hashed?: () => Promise<void>;
365
+ }
366
+
367
+ /** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
368
+ export interface BuildPlan { source: string; full: string; head: string | null; inputsHash: string; inputs: AssembledView['inputs'] }
369
+ /**
370
+ * The build's inputs for a patch (`null` refusal line when the handoff declares no painting and full run): the painting
371
+ * and the runs, and every config-relative file the patched config names for assemble, each resolved against the handoff
372
+ * config's directory and held inside the handoff root (`BUILD_INPUT_OUTSIDE_ROOT`, `BUILD_INPUT_MISSING`) and hashed.
373
+ */
374
+ export async function buildPlan(handoff: Handoff, patched: JsonValue, show: (abs: string) => string): Promise<BuildPlan> {
375
+ if (!handoff.source || !handoff.full) throw new RebuildError('MODELING_INPUT_UNAVAILABLE', BUILD_SOURCE_UNAVAILABLE.slice('MODELING_INPUT_UNAVAILABLE: '.length));
376
+ const root = await realpath(handoff.root), configDir = dirname(handoff.config), named: AssembledView['inputs']['named'] = [];
377
+ for (const f of buildNamedFiles(patched)) {
378
+ if (isAbsolute(f.value)) throw new RebuildError('BUILD_INPUT_OUTSIDE_ROOT', `config ${f.field} is an absolute path; the handoff config names its files relative to its own directory`);
379
+ const lexical = resolve(configDir, f.value);
380
+ let real: string;
381
+ try { real = await realpath(lexical); } catch { throw new RebuildError('BUILD_INPUT_MISSING', `config ${f.field} names ${show(lexical)}, which does not exist`); }
382
+ const rel = relative(root, real);
383
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) throw new RebuildError('BUILD_INPUT_OUTSIDE_ROOT', `config ${f.field} resolves outside the package root`);
384
+ named.push({ field: f.field, path: show(real), sha256: f.kind === 'file' ? sha256(await readFile(real)) : null });
385
+ }
386
+ const inputs = { source: handoff.record.source?.path ?? show(handoff.source), full: handoff.record.full ?? show(handoff.full), head: handoff.head ? handoff.record.head ?? show(handoff.head) : null, named };
387
+ // The painting, the runs' hashed files (the handoff record's) and the named files: an identical build reuses its scratch build.
388
+ const inputsHash = sha256(JSON.stringify([handoff.record.files, named]));
389
+ return { source: handoff.source, full: handoff.full, head: handoff.head ?? null, inputsHash, inputs };
390
+ }
391
+ /** After a build ran: the recomposite figures before (the handoff's parts.json) and after, the error map, the parts diff. */
392
+ async function assembledAfter(job: PatchJob, dir: string, inputs: ProducerInputs): Promise<void> {
393
+ const read = async (path: string): Promise<unknown> => { try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; } };
394
+ const before = await read(join(inputs.parts, 'parts.json')), after = await read(join(dir, 'out', 'parts.json'));
395
+ let errorMap: { url: string; sha256: string } | null = null;
396
+ try { errorMap = { url: `/rebuild/${job.id}/${RECOMPOSITE_ERROR_MAP}`, sha256: sha256(await readFile(join(dir, 'out', 'recomposite_error_rig.png'))) }; } catch { /* assemble wrote none */ }
397
+ const pngs = async (d: string) => { const m = new Map<string, string>(); let names: string[] = []; try { names = (await readdir(d)).filter(n => n.endsWith('.png')).sort(); } catch { /* none */ } for (const n of names) m.set(n, sha256(await readFile(join(d, n)))); return m; };
398
+ const was = await pngs(join(inputs.parts, 'parts')), now = await pngs(join(dir, 'out', 'parts'));
399
+ const names = [...new Set([...was.keys(), ...now.keys()])].sort(), changed = names.filter(n => was.get(n) !== now.get(n));
400
+ const docPaths = before !== null && after !== null ? jsonDiff(before, after) : [''];
401
+ job.assembled = { ...job.assembled!, recomposite: { ...job.assembled!.recomposite, before: recompositeFigures(before), after: recompositeFigures(after), errorMap },
402
+ parts: { changed: changed.map(n => n.replace(/\.png$/, '')).slice(0, 200), unchanged: names.length - changed.length, partsJson: { count: docPaths.length, paths: docPaths.slice(0, 200) } } };
403
+ }
404
+
405
+ /** Hash every page the compiled model names and the model itself; names are literal relative paths inside the build. */
406
+ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
407
+ const dir = await realpath(build), model = 'skeleton.model.json', bytes = await readFile(await safeFile(dir, model)), doc = object(JSON.parse(bytes.toString('utf8')), 'model');
408
+ if (doc.spec !== 'rigc-compiled/3' || !Array.isArray(doc.pages)) throw new RebuildError('REBUILD_BUILD_INVALID', 'the compiled scratch model is not rigc-compiled/3 with pages');
409
+ const files: Record<string, string> = { [model]: sha256(bytes) };
410
+ for (const page of doc.pages) { const name = packagePath(object(page, 'model.pages[]').name, 'model.pages[].name'); files[name] = sha256(await readFile(await safeFile(dir, name))); }
411
+ return { model, modelUrl: `/rebuild/${id}/${model}`, modelHash: files[model]!, files };
412
+ }
413
+
414
+ /** One rebuild at a time per review instance; finished jobs stay readable by id. */
415
+ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
416
+ const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
417
+ let running: string | null = null;
418
+ const snapshot = <J extends RebuildJob | PatchJob | DeltaJob | RetargetJob>(job: J): J => ({ ...job, lines: [...job.lines] });
419
+
420
+ /** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
421
+ function assertStateOutside(): void {
422
+ for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
423
+ const inside = relative(root, options.stateDir);
424
+ 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');
425
+ }
426
+ }
427
+ /** Once per session: the first time the handoff and the producer both resolve, the baseline starts (or is reused). */
428
+ let baseline: BaselineResult | null = null, baselineRun: Promise<BaselineResult> | null = null;
429
+ /** #108 (HQ): the inspected build's check, started once the baseline reproduces; stored beside the baseline record. */
430
+ let inspectedCheck: InspectedCheck | null = null, inspectedCheckRun: Promise<InspectedCheck> | null = null;
431
+ function ensureBaseline(resolved: ReturnType<typeof resolveProducer>): BaselineResult | null {
432
+ if (baselineRun || !p.handoff || !resolved.ok) return baseline;
433
+ try { assertStateOutside(); } catch { return null; }
434
+ const handoff = p.handoff, key = baselineKey(p.sourceHash, handoff, resolved.producer, resolved.compiler), rig = handoff.rig ?? p.root;
435
+ const show = (rel: string) => relative(p.root, join(rig, rel)).split(sep).join('/');
436
+ baseline = { schema: 'rig-review-baseline/1', key, status: 'running', producer: tool(resolved.producer), firstDifference: null, hashes: null, line: `baseline: running ${resolved.producer.package} ${resolved.producer.version} rig into scratch…`, exitCode: null, wallMs: null };
437
+ const started = baseline;
438
+ baselineRun = runBaseline(baselineDir(options.stateDir, key), key, handoff, resolved.producer, show)
439
+ .catch((error): BaselineResult => ({ ...started, status: 'failed', line: `baseline: failed — ${error instanceof Error ? error.message : String(error)}` }))
440
+ .then(result => {
441
+ baseline = result;
442
+ // Only a reproduced baseline is the inspected rig; its check is the inspected build's check.
443
+ if (result.status === 'reproduced' && handoff.parts) {
444
+ inspectedCheck = runningInspectedCheck(key);
445
+ const parts = handoff.parts, dir = baselineDir(options.stateDir, key);
446
+ inspectedCheckRun = runInspectedCheck(dir, key, parts, resolved.producer)
447
+ .catch((error): InspectedCheck => ({ ...runningInspectedCheck(key), status: 'failed', line: `inspected check: failed — ${error instanceof Error ? error.message : String(error)}` }))
448
+ .then(checked => (inspectedCheck = checked));
449
+ }
450
+ return result;
451
+ });
452
+ return baseline;
453
+ }
454
+
455
+ /** `GET /api/rebuild`: whether Apply can run here, the handoff and its baseline, and what the config holds (so the editor can start from it). */
456
+ async function describe() {
457
+ await options.unchanged();
458
+ const handoff = p.handoff, gap = handoff ? handoffGap(handoff) : null;
459
+ const inputs = handoff && !gap ? await producerInputs(handoff, p.handoffReason) : null;
460
+ const resolved = resolveProducer(options.starts);
461
+ const parts = inputs ? contourParts(inputs.configText).map(({ name, regions }) => ({ name, regions })) : [];
462
+ const contour = inputs ? Object.fromEntries(contourParts(inputs.configText).map(({ name, entry }) => [name, entry])) : {};
463
+ const reason = !handoff ? (p.handoffReason ?? modelingHandoffMissing(null)) : gap ?? (!resolved.ok ? resolved.error : parts.length ? null : 'REBUILD_INPUTS_UNAVAILABLE: the config states no contour mesh');
464
+ return {
465
+ schema: 'rig-review-rebuild-status/1', available: !!inputs && resolved.ok && parts.length > 0,
466
+ handoff: handoff ? handoff.record : null,
467
+ inputs: inputs && handoff ? { config: handoff.record.config.path, parts: handoff.record.parts, rig: handoff.record.rig, configHash: inputs.configHash } : null,
468
+ baseline: ensureBaseline(resolved),
469
+ // #108 (HQ): the physics readout's BEFORE for the inspected build; null until the baseline reproduces.
470
+ inspectedCheck,
471
+ parts, contour,
472
+ // #106: every meshes entry as written, for the Mesh panel's selected part.
473
+ meshes: inputs ? configMeshes(inputs.configText) : {},
474
+ // The config's constraints as written (#103: the physics panel patches them by index and name).
475
+ constraints: inputs ? configConstraints(inputs.configText) : null,
476
+ // #109: the config's bones as written (the Bones block drags their at / points / tip).
477
+ bones: inputs ? configBonesList(inputs.configText) : null,
478
+ // #110: the sections the Rig fields block edits (regions, motion.blink, stops, bones, turn) as written.
479
+ fields: inputs ? configFields(inputs.configText) : null,
480
+ // #115: the config's cuts, patches and limbs as written, and whether the handoff can run `rig-parts build`.
481
+ cuts: inputs ? { config: configCuts(inputs.configText), build: buildGap(handoff) } : null,
482
+ // #104: the config's idle (tracks, blink, bones, chains) and where the inspected idle keys each track.
483
+ idle: inputs ? await idleStatus(inputs) : null,
484
+ producer: resolved.ok ? tool(resolved.producer) : null, compiler: resolved.ok ? tool(resolved.compiler) : null,
485
+ // #114: what the retarget editor offers — the pose producer, the declared sequences, the rig's bones and animations.
486
+ retarget: await retargetStatus(inputs),
487
+ reason,
488
+ running,
489
+ };
490
+ }
491
+ /** The inspected build's check once it settles (tests); null when the baseline did not reproduce. */
492
+ async function settledInspectedCheck(): Promise<InspectedCheck | null> { await settledBaseline(); return inspectedCheckRun ? await inspectedCheckRun : inspectedCheck; }
493
+ /** The most recent built scratch build of this session (#108 readings run on it), or null. */
494
+ let latestId: string | null = null;
495
+ function latest(): { id: string; build: string; modelHash: string; spec: string } | null {
496
+ const job = latestId ? jobs.get(latestId) : undefined;
497
+ // `spec`: the rig spec directory the job's producer op wrote (#113 checks requirements on it) — a designation's `revision`, a patch's or delta's `rig`.
498
+ const dir = job ? rebuildDir(options.stateDir, p.sourceHash, job.id) : '';
499
+ return job && job.status === 'built' && job.build ? { id: job.id, build: join(dir, 'build'), modelHash: job.build.modelHash, spec: join(dir, !('kind' in job) ? 'revision' : job.op === 'build' ? 'out/rig' : 'rig') } : null;
500
+ }
501
+ /** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
502
+ async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
503
+
504
+ async function reuse<J extends RebuildJob | PatchJob | DeltaJob | RetargetJob>(dir: string, id: string): Promise<J | null> {
505
+ const resultFile = join(dir, 'result.json');
506
+ if (!(await exists(resultFile))) return null;
507
+ try {
508
+ const stored = JSON.parse(await readFile(resultFile, 'utf8')) as J;
509
+ if (stored.schema !== REBUILD_SCHEMA || stored.id !== id || stored.status !== 'built' || !stored.build) return null;
510
+ const now = await hashBuild(join(dir, 'build'), id);
511
+ if (JSON.stringify(now.files) !== JSON.stringify(stored.build.files)) return null;
512
+ return { ...stored, reused: true };
513
+ } catch { return null; }
514
+ }
515
+
516
+ /**
517
+ * The tail every Apply shares (#121), once its producer op has written a rig spec into `<dir>/<op.spec>`: the per-file
518
+ * diff when asked (#125), the compile with the revision's own pack flags, check when asked (#102), the build hashed,
519
+ * result.json written, the package's other scratch builds replaced; anything but a built job leaves nothing behind.
520
+ * Each runner passes only its producer op and the optional steps it records.
521
+ */
522
+ async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
523
+ const push = (line: string) => { if (job.lines.length < MAX_LINES) job.lines.push(line); };
524
+ const producer = resolved.producer;
525
+ try {
526
+ await rm(dir, { recursive: true, force: true });
527
+ await mkdir(dir, { recursive: true, mode: 0o700 });
528
+ const run = await op.run(push);
529
+ job.exitCode = run.exitCode; op.wallMs(run.wallMs);
530
+ if (run.exitCode !== 0) { job.producerLine = firstRefusal(run.lines) ?? `${op.tool ?? 'rig-parts'} ${op.cmd} exited ${run.exitCode} without a named refusal`; job.status = 'refused'; return; }
531
+ job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
532
+ const { pageEdges, packShape, note } = await op.flags(push);
533
+ if (steps.diff) {
534
+ // #125: the structural diff of every document the producer writes against the inspected rig's, file by file.
535
+ const files = await producerDiff(inputs.rig, join(dir, op.spec));
536
+ steps.diff(files);
537
+ push(`review: against the inspected rig — ${filesDiffText(files)}`);
538
+ for (const d of files.documents) if (d.count) push(`review: ${d.file}: ${d.paths.slice(0, 8).join(', ')}${d.count > 8 ? ', …' : ''}`);
539
+ if (files.images.changed) push(`review: images changed: ${files.images.names.slice(0, 8).join(', ')}${files.images.changed > 8 ? ', …' : ''}`);
540
+ }
541
+ push(`review: ${resolved.compiler.package} ${resolved.compiler.version} build --profile rig-play --pack --page-edges ${pageEdges} --pack-shape ${packShape}${note}`);
542
+ const compile = await runTool(resolved.compiler.bin, compileArgs({ rig: `${op.spec}/rig.json`, motion: `${op.spec}/motion.json`, out: 'build', pageEdges, packShape }), { cwd: dir, onLine: push });
543
+ job.wallMs.compile = compile.wallMs;
544
+ if (compile.exitCode !== 0) { job.exitCode = compile.exitCode; job.producerLine = firstRefusal(compile.lines) ?? `rigc build exited ${compile.exitCode} without a named refusal`; job.status = 'refused'; return; }
545
+ if (steps.check) {
546
+ // check measures the scratch rig; a FAIL is its judgment, recorded beside the build, not a refusal of the build.
547
+ push(`review: ${producer.package} ${producer.version} check --rig ${op.spec} --out check --parts <handoff parts>`);
548
+ const check = await runTool(producer.bin, ['check', '--rig', op.spec, '--out', 'check', '--parts', inputs.parts, '--page-edges', pageEdges, '--pack-shape', packShape], { cwd: dir, onLine: push });
549
+ let checkJson: unknown = null;
550
+ try { checkJson = JSON.parse(await readFile(join(dir, 'check', 'check.json'), 'utf8')); } catch { /* check refused before writing it; its line says why */ }
551
+ steps.check(checkSummary(checkJson, check.exitCode, check.lines, firstRefusal(check.lines)), check.wallMs);
552
+ }
553
+ // The package and its producer inputs must still be the bytes the request was made against.
554
+ // The handoff files are in the reviewed identity, so this also catches a config, parts or rig change.
555
+ try { await options.unchanged(); } catch { throw new RebuildError('REBUILD_INPUTS_CHANGED', 'the package or its producer handoff changed during the rebuild'); }
556
+ job.build = await hashBuild(join(dir, 'build'), job.id);
557
+ if (steps.hashed) await steps.hashed();
558
+ latestId = job.id;
559
+ await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
560
+ // Each Apply replaces the previous scratch build of this package.
561
+ for (const sibling of await readdir(rebuildRoot(options.stateDir, p.sourceHash))) if (sibling !== job.id && /^[a-f0-9]{64}$/.test(sibling)) { await rm(join(rebuildRoot(options.stateDir, p.sourceHash), sibling), { recursive: true, force: true }); jobs.delete(sibling); }
562
+ // "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
563
+ job.status = 'built';
564
+ } catch (error) {
565
+ job.status = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
566
+ } finally {
567
+ // Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind.
568
+ if (job.status !== 'built') await rm(dir, { recursive: true, force: true }).catch(() => {});
569
+ }
570
+ }
571
+
572
+ /** #99: the designation's entry → `rig-parts remesh` → a scratch revision, compiled with the revision's own pack flags. */
573
+ function execute(job: RebuildJob, dir: string, inputs: ProducerInputs, resolved: Resolved, entry: string): Promise<void> {
574
+ return apply(job, dir, inputs, resolved, {
575
+ cmd: 'remesh', spec: 'revision',
576
+ async run(push) {
577
+ await writeFile(join(dir, 'mesh.json'), entry, { mode: 0o600 });
578
+ push(`review: ${resolved.producer.package} ${resolved.producer.version} remesh ${job.request.part} → scratch revision`);
579
+ return runTool(resolved.producer.bin, remeshArgs({ rig: inputs.rig, config: inputs.config, parts: inputs.parts, part: job.request.part, mesh: 'mesh.json', out: 'revision' }), { cwd: dir, onLine: push });
580
+ },
581
+ wallMs: ms => { job.wallMs.remesh = ms; },
582
+ async flags() {
583
+ const provenance = object(JSON.parse(await readFile(join(dir, 'revision', 'remesh.json'), 'utf8')), 'remesh.json'), request = object(provenance.request, 'remesh.json request');
584
+ return { pageEdges: request.page_edges === 'pot' ? 'pot' : 'free', packShape: request.pack_shape === 'rect' ? 'rect' : 'polygon', note: ' (the revision\'s own pack flags)' };
585
+ },
586
+ });
587
+ }
588
+
589
+ /**
590
+ * #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
591
+ * inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
592
+ */
593
+ function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null): Promise<void> {
594
+ const args = job.request.patch.op.args, producer = resolved.producer;
595
+ let counts: RemeshCounts | null = null;
596
+ let built: ProcessResult | null = null;
597
+ return apply(job, dir, inputs, resolved, {
598
+ cmd: job.op, spec: job.op === 'build' ? 'out/rig' : 'rig',
599
+ async run(push) {
600
+ if (job.op === 'build' && plan) {
601
+ // #115: assemble → rig → check from the painting and the runs, the patched config's assemble-read files absolute.
602
+ const doc = JSON.parse(scratchConfigText(inputs.config, patched)) as Record<string, unknown>;
603
+ absolutizeBuildFiles(doc, value => (isAbsolute(value) ? value : resolve(dirname(inputs.config), value)));
604
+ await writeFile(join(dir, 'config.json'), JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
605
+ push(`review: ${producer.package} ${producer.version} build --config config.json (the handoff config with ${job.request.patch.ops.length} patch op(s)) --source <handoff source> --full <handoff full>${plan.head ? ' --head <handoff head>' : ''} --seam-pairs → scratch out (assemble → rig → check)`);
606
+ // `--seam-pairs` (forwarded to check): the setup-pose seam split by pair on a pass too, so every Apply shows its `seam pair` lines.
607
+ built = await runTool(producer.bin, ['build', '--config', 'config.json', '--source', plan.source, '--full', plan.full, ...(plan.head ? ['--head', plan.head] : []), '--out', 'out', '--seam-pairs', ...args], { cwd: dir, onLine: push });
608
+ const readout = buildReadout(built.lines);
609
+ // A check that ran and found a bar not met is check's judgment, kept beside the build (as for a `rig` patch):
610
+ // build prints "stopped at check; N bar(s) not met" (a check that refused its input says "no later stage ran").
611
+ const stoppedAtCheck = built.exitCode !== 0 && built.lines.some(l => /^build: stopped at check; \d+ bar\(s\) not met/.test(l.trim())) && (await exists(join(dir, 'out', 'rig', 'rig.json')));
612
+ job.assembled = { ...job.assembled!, refusals: stoppedAtCheck ? [] : readout.refusals, checkFails: stoppedAtCheck ? readout.refusals : [], lines: readout.assemble, seam: readout.seam, seamPairs: readout.seamPairs, recomposite: { ...job.assembled!.recomposite, line: readout.recomposite } };
613
+ const lines = buildLines(built.lines);
614
+ return { ...built, exitCode: stoppedAtCheck ? 0 : built.exitCode, lines, stdout: lines };
615
+ }
616
+ if (job.op === 'retune') {
617
+ // retune reads the inspected rig and writes only --out; what it holds it refuses by name (RETUNE_REQUEST_HELD).
618
+ await writeFile(join(dir, 'config.json'), scratchConfigText(inputs.config, patched), { mode: 0o600 });
619
+ push(`review: ${producer.package} ${producer.version} retune --rig <handoff rig> --config config.json (the handoff config with ${job.request.patch.ops.length} patch op(s)) → scratch rig`);
620
+ return runTool(producer.bin, ['retune', '--rig', inputs.rig, '--config', 'config.json', '--parts', inputs.parts, '--out', 'rig', ...args], { cwd: dir, onLine: push });
621
+ }
622
+ if (part === null) {
623
+ await writeFile(join(dir, 'config.json'), scratchConfigText(inputs.config, patched), { mode: 0o600 });
624
+ push(`review: ${producer.package} ${producer.version} rig --config config.json (the handoff config with ${job.request.patch.ops.length} patch op(s)) → scratch rig`);
625
+ return runTool(producer.bin, ['rig', '--config', 'config.json', '--parts', inputs.parts, '--out', 'rig', ...args], { cwd: dir, onLine: push });
626
+ }
627
+ // remesh takes the one entry and reads the handoff config as written (REMESH_BASELINE_MISMATCH otherwise).
628
+ const entry = (patched as Record<string, Record<string, JsonValue>>).meshes?.[part];
629
+ if (entry === undefined) throw new RebuildError('EDIT_PATCH_PATH_MISSING', `the patched config has no meshes.${part}`);
630
+ await writeFile(join(dir, 'mesh.json'), JSON.stringify(entry, null, 1) + '\n', { mode: 0o600 });
631
+ const rest = args.filter((_, i) => args[i] !== '--part' && args[i - 1] !== '--part');
632
+ push(`review: ${producer.package} ${producer.version} remesh ${part} (its patched meshes entry) → scratch rig`);
633
+ return runTool(producer.bin, [...remeshArgs({ rig: inputs.rig, config: inputs.config, parts: inputs.parts, part, mesh: 'mesh.json', out: 'rig' }), ...rest], { cwd: dir, onLine: push });
634
+ },
635
+ wallMs: ms => { job.wallMs.op = ms; },
636
+ async flags(push) {
637
+ if (job.op === 'build' && built) {
638
+ job.exitCode = built.exitCode;
639
+ const lines = buildLines(built.lines);
640
+ job.producerLine = lines.filter(l => /^build:\s/.test(l)).at(-1) ?? job.producerLine;
641
+ let checkJson: unknown = null;
642
+ try { checkJson = JSON.parse(await readFile(join(dir, 'out', 'check', 'check.json'), 'utf8')); } catch { /* check wrote none; its line says why */ }
643
+ job.check = checkSummary(checkJson, built.exitCode, lines, firstRefusal(lines));
644
+ await assembledAfter(job, dir, inputs);
645
+ for (const l of job.assembled!.seamPairs.slice(0, 8)) push(`review: ${l}`);
646
+ return { pageEdges: flagValue(args, '--page-edges', 'free') as PageEdges, packShape: flagValue(args, '--pack-shape', 'polygon') as PackShape, note: ' (the build\'s pack flags)' };
647
+ }
648
+ if (job.op === 'retune') {
649
+ const request = object(object(JSON.parse(await readFile(join(dir, 'rig', 'retune.json'), 'utf8')) as unknown, 'retune.json').request, 'retune.json request');
650
+ return { pageEdges: request.page_edges === 'pot' ? 'pot' : 'free', packShape: request.pack_shape === 'rect' ? 'rect' : 'polygon', note: ' (retune.json\'s pack flags)' };
651
+ }
652
+ if (part === null) return { pageEdges: flagValue(args, '--page-edges', 'free') as PageEdges, packShape: flagValue(args, '--pack-shape', 'polygon') as PackShape, note: '' };
653
+ const provenance = JSON.parse(await readFile(join(dir, 'rig', 'remesh.json'), 'utf8')) as unknown;
654
+ const request = object(object(provenance, 'remesh.json').request, 'remesh.json request');
655
+ counts = remeshCounts(provenance);
656
+ if (counts) push(`review: remesh.json ${counts.part}: v ${counts.before.vertices} t ${counts.before.triangles} → v ${counts.after.vertices} t ${counts.after.triangles}`);
657
+ return { pageEdges: request.page_edges === 'pot' ? 'pot' : 'free', packShape: request.pack_shape === 'rect' ? 'rect' : 'polygon', note: '' };
658
+ },
659
+ }, {
660
+ diff: files => { const rigJson = files.documents.find(d => d.file === 'rig.json'); job.files = files; job.diff = rigJson ? { count: rigJson.count, paths: rigJson.paths } : null; },
661
+ // #115: build runs check itself, on the parts it assembled; its check.json is read after the op (flags).
662
+ ...(job.op === 'build' ? {} : { check: (summary: CheckSummary, wallMs: number) => { job.wallMs.check = wallMs; job.check = summary; } }),
663
+ hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
664
+ });
665
+ }
666
+
667
+ /**
668
+ * `POST /api/rebuild` with `{patch}`: the patch is checked against the handoff config's bytes (`EDIT_PATCH_STALE`),
669
+ * applied to a scratch copy, and the op it names runs on that copy. A `rig` patch inside retune's scope runs `retune`
670
+ * on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
671
+ * the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
672
+ */
673
+ async function startPatch(input: unknown): Promise<{ status: number; body: unknown }> {
674
+ let patch: ConfigPatch;
675
+ try { patch = parseConfigPatch(input); } catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
676
+ assertStateOutside();
677
+ await options.unchanged();
678
+ const inputs = await producerInputs(p.handoff, p.handoffReason);
679
+ let patched: JsonValue, part: string | null = null;
680
+ const original = JSON.parse(inputs.configText) as JsonValue;
681
+ try {
682
+ assertPatchFresh(patch, inputs.configHash);
683
+ assertOpNames(original, patch.ops);
684
+ patched = applyConfigPatch(original, patch.ops);
685
+ if (patch.op.cmd === 'remesh') part = remeshPart(patch);
686
+ } catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
687
+ assertNamedFilesHeld(original, patched);
688
+ const resolved = resolveProducer(options.starts);
689
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
690
+ if (patch.producer.name !== resolved.producer.package || patch.producer.version !== resolved.producer.version) throw new RebuildError('EDIT_PRODUCER_MISMATCH', `the patch was made for ${patch.producer.name} ${patch.producer.version}; the installed producer is ${resolved.producer.package} ${resolved.producer.version}`);
691
+ // #136: the whole-rig rebuild is the fast path (~18 s) and gives the same bytes when the baseline is reproduced,
692
+ // so it stays the default. Only when the config cannot reproduce the inspected rig (a repaired build) does a
693
+ // patch inside retune's scope run retune, which holds the rest of the inspected rig itself (~52 s).
694
+ // #115: a cut, patch or limb edit (or a bone edit on a config with limbs) is read by assemble, so it runs `build`.
695
+ const building = patch.op.cmd === 'rig' && needsBuild(patch.ops, original);
696
+ let plan: BuildPlan | null = null;
697
+ if (building) {
698
+ const held = newBuildFile(original, patched);
699
+ if (held) throw new RebuildError('EDIT_PATCH_PATH_HELD', `${held} names a file the handoff config does not; a patch may not name a new file (review sends no path)`);
700
+ plan = await buildPlan(p.handoff!, patched, abs => relative(p.root, abs).split(sep).join('/'));
701
+ }
702
+ const route = patch.op.cmd === 'remesh' ? 'remesh' : building ? 'build' : patchRoute(patch.ops, resolved.producer.version);
703
+ const baselineNow = route === 'retune' ? await settledBaseline() : null;
704
+ const op: AppliedOp = route === 'retune' && baselineNow?.status === 'reproduced' ? 'rig' : route;
705
+ if (op === 'rig' || op === 'build') {
706
+ try { assertWholeRigBaseline(await settledBaseline()); } catch (e) { if (e instanceof BaselineError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
707
+ }
708
+ const id = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}`, op, ...(plan ? { buildInputs: plan.inputsHash } : {}) });
709
+ const current = jobs.get(id);
710
+ if (current?.status === 'running') return { status: 202, body: snapshot(current) };
711
+ if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
712
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
713
+ const reused = await reuse<PatchJob>(dir, id);
714
+ if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
715
+ const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id, status: 'running', reused: false, op, request: { patch, patchSha256: patchSha256(patch), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null, check: null }, check: null, diff: null, files: null, remesh: null, build: null,
716
+ ...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}) };
717
+ jobs.set(id, job); running = id;
718
+ void executePatch(job, dir, inputs, resolved, patched, part, plan).finally(() => { if (running === id) running = null; });
719
+ return { status: 202, body: snapshot(job) };
720
+ }
721
+
722
+ /** #135: the delta → `rig-parts remesh --delta mesh-edit.json --part <part>` → `<dir>/rig`; the tail adds the per-file diff. */
723
+ function executeDelta(job: DeltaJob, dir: string, inputs: ProducerInputs, resolved: Resolved, text: string): Promise<void> {
724
+ const producer = resolved.producer, part = job.request.part;
725
+ let counts: RemeshCounts | null = null;
726
+ return apply(job, dir, inputs, resolved, {
727
+ cmd: 'remesh', spec: 'rig',
728
+ async run(push) {
729
+ await writeFile(join(dir, 'mesh-edit.json'), text, { mode: 0o600 });
730
+ const ops = Array.isArray(job.request.delta.ops) ? job.request.delta.ops.length : '?';
731
+ push(`review: ${producer.package} ${producer.version} remesh --delta mesh-edit.json --part ${part} (Edit Mesh's delta, ${ops} op(s), sha256 ${job.request.deltaSha256.slice(0, 12)}…) → scratch rig`);
732
+ return runTool(producer.bin, remeshDeltaArgs({ rig: inputs.rig, config: inputs.config, parts: inputs.parts, part, delta: 'mesh-edit.json', out: 'rig' }), { cwd: dir, onLine: push });
733
+ },
734
+ wallMs: ms => { job.wallMs.op = ms; },
735
+ async flags(push) {
736
+ const provenance = JSON.parse(await readFile(join(dir, 'rig', 'remesh.json'), 'utf8')) as unknown;
737
+ const request = object(object(provenance, 'remesh.json').request, 'remesh.json request');
738
+ counts = remeshCounts(provenance);
739
+ if (counts) push(`review: remesh.json ${counts.part}: v ${counts.before.vertices} t ${counts.before.triangles} → v ${counts.after.vertices} t ${counts.after.triangles}`);
740
+ return { pageEdges: request.page_edges === 'pot' ? 'pot' : 'free', packShape: request.pack_shape === 'rect' ? 'rect' : 'polygon', note: ' (remesh.json\'s pack flags)' };
741
+ },
742
+ }, {
743
+ diff: files => { job.files = files; },
744
+ hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
745
+ });
746
+ }
747
+
748
+ /**
749
+ * `POST /api/rebuild` with `{part, delta, configHash}` (#135): the config hash the editor read must still be the
750
+ * handoff's (`REBUILD_CONFIG_STALE`), and the installed producer must read `--delta` (`MODELING_PRODUCER_TOO_OLD`,
751
+ * the version from its own manifest). Everything about the delta itself is the producer's judgment, verbatim.
752
+ */
753
+ async function startDelta(input: unknown): Promise<{ status: number; body: unknown }> {
754
+ const request = parseDeltaRequest(input);
755
+ assertStateOutside();
756
+ await options.unchanged();
757
+ const inputs = await producerInputs(p.handoff, p.handoffReason);
758
+ if (inputs.configHash !== request.configHash) throw new RebuildError('REBUILD_CONFIG_STALE', 'the producer config changed since the editor read it; reload');
759
+ const resolved = resolveProducer(options.starts);
760
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
761
+ if (!versionAtLeast(resolved.producer.version, REMESH_DELTA_SINCE)) {
762
+ const error = modelingProducerTooOld(resolved.producer.version || 'unknown', REMESH_DELTA_SINCE, 'rig-parts remesh --delta');
763
+ return { status: 200, body: { status: 'refused', error, producerLine: error } };
764
+ }
765
+ const text = meshEditText(request.delta);
766
+ const id = deltaId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, deltaText: text, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
767
+ const current = jobs.get(id);
768
+ if (current?.status === 'running') return { status: 202, body: snapshot(current) };
769
+ if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
770
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
771
+ const reused = await reuse<DeltaJob>(dir, id);
772
+ if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
773
+ const job: DeltaJob = { schema: REBUILD_SCHEMA, kind: 'delta', id, status: 'running', reused: false, op: 'remesh', request: { part: request.part, delta: request.delta, deltaSha256: sha256(text), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null }, files: null, remesh: null, build: null };
774
+ jobs.set(id, job); running = id;
775
+ void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
776
+ return { status: 202, body: snapshot(job) };
777
+ }
778
+
779
+ /** The package's declared `rig-pose-sequence/1` references (validated at load), with whether clean's record is on them. */
780
+ async function retargetSequences(): Promise<RetargetSequence[]> {
781
+ const out: RetargetSequence[] = [];
782
+ for (const r of p.manifest.references.filter(r => r.type === 'pose')) {
783
+ let doc: Record<string, unknown>;
784
+ try { doc = object(JSON.parse(await readFile(await safeFile(p.root, r.path), 'utf8')), r.id); } catch { continue; }
785
+ if (doc.spec !== 'rig-pose-sequence/1' || !Array.isArray(doc.frames)) continue;
786
+ const people = [...new Set((doc.frames as { people?: { id?: unknown }[] }[]).flatMap(f => (f.people ?? []).map(x => String(x.id))))];
787
+ out.push({ id: r.id, label: r.label, path: r.path, cleaned: doc.cleaned !== undefined, frames: doc.frames.length, people });
788
+ }
789
+ return out;
790
+ }
791
+ /** `GET /api/rebuild`'s retarget section (#114): whether a retarget can run here and what the editor picks from. */
792
+ async function retargetStatus(inputs: ProducerInputs | null) {
793
+ const resolved = resolvePose(options.starts), sequences = await retargetSequences();
794
+ const read = async (name: string): Promise<Record<string, unknown>> => { if (!inputs) return {}; try { const v = JSON.parse(await readFile(join(inputs.rig, name), 'utf8')); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { return {}; } };
795
+ const rig = await read('rig.json'), motion = await read('motion.json');
796
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).map(b => (b && typeof b === 'object' ? (b as Record<string, unknown>).name : null)).filter((n): n is string => typeof n === 'string');
797
+ const animations = motion.animations && typeof motion.animations === 'object' && !Array.isArray(motion.animations) ? Object.keys(motion.animations) : [];
798
+ const reason = !inputs ? (p.handoff ? handoffGap(p.handoff) : p.handoffReason ?? modelingHandoffMissing(null)) : !resolved.ok ? resolved.error : sequences.length ? null : 'RETARGET_SEQUENCE_UNAVAILABLE: the package declares no rig-pose-sequence/1 reference (a pose reference in review.json, or a *pose*.json in the opened folder)';
799
+ return { available: reason === null, reason, producer: resolved.ok ? tool(resolved.producer) : null, compiler: resolved.ok ? tool(resolved.compiler) : null, rig: p.handoff?.record.rig ?? null, sequences, bones, animations };
800
+ }
801
+
802
+ /**
803
+ * #114: clean (unless skipped) → retarget onto the handoff rig → `<dir>/motion.json`; the handoff rig's files are
804
+ * copied beside it into `<dir>/rig/` so the shared tail diffs and compiles a whole rig spec. Nothing is read from or
805
+ * written to the package but the declared sequence, read once into scratch.
806
+ */
807
+ function executeRetarget(job: RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, sequence: Uint8Array): Promise<void> {
808
+ const pose = resolved.producer, record = job.request.record;
809
+ let retargetRun: ProcessResult | null = null;
810
+ const op: ProducerOp = {
811
+ cmd: 'retarget', tool: 'rig-pose', spec: 'rig',
812
+ async run(push) {
813
+ await writeFile(join(dir, 'pose.json'), sequence, { mode: 0o600 });
814
+ await writeFile(join(dir, 'map.json'), mapText(record.map), { mode: 0o600 });
815
+ let input = 'pose.json';
816
+ if (record.op.clean) {
817
+ const args = cleanArgs({ pose: 'pose.json', out: 'pose.clean.json', argv: record.op.clean });
818
+ push(`review: ${pose.package} ${pose.version} ${args.join(' ')}`);
819
+ const clean = await runTool(pose.bin, args, { cwd: dir, onLine: push });
820
+ job.wallMs.clean = clean.wallMs;
821
+ if (clean.exitCode !== 0) { op.cmd = 'clean'; job.refusals = clean.lines.filter(l => /^\s*FAIL\s/.test(l)).map(l => l.trim()); return clean; }
822
+ input = 'pose.clean.json';
823
+ } else push(`review: clean skipped — the sequence goes to retarget as the package holds it`);
824
+ const args = retargetArgs({ rig: inputs.rig, map: 'map.json', sequence: input, out: 'motion.json', argv: record.op.args });
825
+ push(`review: ${pose.package} ${pose.version} retarget --rig <handoff rig> ${args.slice(3).join(' ')}`);
826
+ retargetRun = await runTool(pose.bin, args, { cwd: dir, onLine: push });
827
+ if (retargetRun.exitCode !== 0) job.refusals = retargetRun.lines.filter(l => /^\s*FAIL\s/.test(l)).map(l => l.trim());
828
+ return retargetRun;
829
+ },
830
+ wallMs: ms => { if (retargetRun) job.wallMs.retarget = ms; },
831
+ async flags(push) {
832
+ // The handoff rig as a rig spec beside the retarget's motion: every regular file but motion.json, and images/.
833
+ const out = join(dir, 'rig');
834
+ await mkdir(join(out, 'images'), { recursive: true, mode: 0o700 });
835
+ for (const [from, to] of [[inputs.rig, out], [join(inputs.rig, 'images'), join(out, 'images')]] as const) {
836
+ let entries: import('node:fs').Dirent[] = [];
837
+ try { entries = await readdir(from, { withFileTypes: true }); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
838
+ for (const e of entries) if (e.isFile() && !(from === inputs.rig && e.name === 'motion.json')) await copyFile(join(from, e.name), join(to, e.name));
839
+ }
840
+ const text = await readFile(join(dir, 'motion.json'), 'utf8');
841
+ await writeFile(join(out, 'motion.json'), text, { mode: 0o600 });
842
+ let frameTimes: number[] | null = null;
843
+ try { const report = JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }; if (Array.isArray(report.frameTimes) && report.frameTimes.every(t => typeof t === 'number')) frameTimes = report.frameTimes as number[]; } catch { /* no report: the lines' own t= */ }
844
+ job.readings = retargetLines(retargetRun?.lines ?? [], frameTimes).slice(0, MAX_RETARGET_LINES);
845
+ let inspected: unknown = null;
846
+ try { inspected = JSON.parse(await readFile(join(inputs.rig, 'motion.json'), 'utf8')); } catch { /* a rig without motion.json: nothing to keep */ }
847
+ job.motion = motionSummary(inspected, JSON.parse(text), text, record.flags.animation);
848
+ push(`review: motion.json holds ${job.motion.animations.length} animation(s), "${record.flags.animation}" added${job.motion.kept ? '; the rig\'s own animations unchanged' : `; changed: ${job.motion.changed.join(', ')}`}`);
849
+ return { pageEdges: 'free', packShape: 'polygon', note: ' (Apply\'s default pack flags)' };
850
+ },
851
+ };
852
+ return apply(job, dir, inputs, resolved, op, { diff: files => { job.files = files; } });
853
+ }
854
+
855
+ /**
856
+ * `POST /api/rebuild` with `{retarget: {sequence, map, flags, configHash}}` (#114): the config hash the editor read must
857
+ * still be the handoff's, the sequence a declared `rig-pose-sequence/1` reference, and `@rig-c/pose` installed. The
858
+ * map's resolution against the rig, the flags' combinations and the pose are the producer's to judge, verbatim.
859
+ */
860
+ async function startRetarget(input: unknown): Promise<{ status: number; body: unknown }> {
861
+ let request: ReturnType<typeof parseRetargetRequest>;
862
+ try { request = parseRetargetRequest(input); } catch (e) { if (e instanceof RetargetError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
863
+ assertStateOutside();
864
+ await options.unchanged();
865
+ const inputs = await producerInputs(p.handoff, p.handoffReason);
866
+ if (inputs.configHash !== request.configHash) throw new RebuildError('REBUILD_CONFIG_STALE', 'the producer config changed since the editor read it; reload');
867
+ const reference = p.manifest.references.find(r => r.id === request.sequence && r.type === 'pose');
868
+ if (!reference) throw new RebuildError('RETARGET_SEQUENCE_UNKNOWN', `${request.sequence} is not a pose reference the package declares`);
869
+ const sequence = await readFile(await safeFile(p.root, reference.path));
870
+ let spec: unknown;
871
+ try { spec = (JSON.parse(Buffer.from(sequence).toString('utf8')) as { spec?: unknown }).spec; } catch { spec = null; }
872
+ if (spec !== 'rig-pose-sequence/1') throw new RebuildError('RETARGET_SEQUENCE_UNSUPPORTED', `${reference.id} is not a rig-pose-sequence/1 (a static pose cannot be retargeted)`);
873
+ const resolved = resolvePose(options.starts);
874
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
875
+ const rigJson = await readFile(join(inputs.rig, 'rig.json'));
876
+ const record = retargetRecord(request, { rig: { path: inputs.handoff.rig ?? '', sha256: sha256(rigJson) }, sequence: { path: reference.path, sha256: sha256(sequence) }, producer: { name: resolved.producer.package, version: resolved.producer.version } });
877
+ const id = retargetId({ sourceHash: p.sourceHash, configHash: inputs.configHash, record, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
878
+ const current = jobs.get(id);
879
+ if (current?.status === 'running') return { status: 202, body: snapshot(current) };
880
+ if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
881
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
882
+ const reused = await reuse<RetargetJob>(dir, id);
883
+ if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
884
+ const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], build: null };
885
+ jobs.set(id, job); running = id;
886
+ void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
887
+ return { status: 202, body: snapshot(job) };
888
+ }
889
+
890
+ /** `POST /api/rebuild`: refusals are returned before anything is written or run. */
891
+ async function start(input: unknown): Promise<{ status: number; body: unknown }> {
892
+ if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'retarget')) {
893
+ if (Object.keys(input).length !== 1) bad('expected exactly {retarget}');
894
+ return startRetarget((input as { retarget: unknown }).retarget);
895
+ }
896
+ if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
897
+ if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
898
+ if (Object.keys(input).length !== 1) bad('expected exactly {patch} or {part, regions, configHash}');
899
+ return startPatch((input as { patch: unknown }).patch);
900
+ }
901
+ const request = parseRebuildRequest(input, options.bones);
902
+ assertStateOutside();
903
+ await options.unchanged();
904
+ const inputs = await producerInputs(p.handoff, p.handoffReason);
905
+ if (inputs.configHash !== request.configHash) throw new RebuildError('REBUILD_CONFIG_STALE', 'the producer config changed since the editor read it; reload');
906
+ const entry = request.bust ? bustEntryText(inputs.configText, request.part, request.bust) : meshEntryText(inputs.configText, request.part, request.regions);
907
+ const resolved = resolveProducer(options.starts);
908
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
909
+ const id = designationId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, entry, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
910
+ const current = jobs.get(id);
911
+ if (current?.status === 'running') return { status: 202, body: snapshot(current) };
912
+ if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
913
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
914
+ const reused = await reuse(dir, id);
915
+ if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
916
+ const job: RebuildJob = { schema: REBUILD_SCHEMA, id, status: 'running', reused: false, request: { ...request, entrySha256: sha256(entry) }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { remesh: null, compile: null }, build: null };
917
+ jobs.set(id, job); running = id;
918
+ void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
919
+ return { status: 202, body: snapshot(job) };
920
+ }
921
+
922
+ function read(id: string): RebuildJob | PatchJob | DeltaJob | RetargetJob {
923
+ const job = jobs.get(id);
924
+ if (!job) throw new RebuildError('REBUILD_NOT_FOUND', id);
925
+ return snapshot(job);
926
+ }
927
+
928
+ /** A served scratch file: only a built job's hashed model and pages, read-only. */
929
+ async function file(id: string, name: string): Promise<string> {
930
+ const job = jobs.get(id);
931
+ // #115: a built build job's scratch error map, read-only, by its one served name.
932
+ if (name === RECOMPOSITE_ERROR_MAP && job && job.status === 'built' && 'kind' in job && job.kind === 'patch' && job.assembled?.recomposite.errorMap) return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'out')), 'recomposite_error_rig.png');
933
+ if (!job || job.status !== 'built' || !job.build || !Object.hasOwn(job.build.files, name)) throw new RebuildError('REBUILD_NOT_FOUND', `${id}/${name}`);
934
+ return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
935
+ }
936
+
937
+ return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest };
938
+ }