@rig-c/review 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -7
- package/docs/DESIGN.md +33 -5
- package/docs/REVIEW_PACKAGE.md +115 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +65 -6
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +226 -0
- package/src/feedback-export.ts +25 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +317 -0
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +12 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +294 -0
- package/web/chest-regions-tool.ts +10 -4
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +2 -2
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +813 -24
- package/web/mesh-editor.ts +303 -0
- package/web/mesh-panel.ts +273 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +171 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +52 -5
- package/web/weights-view.ts +159 -0
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +5 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Physics tuning (#103), the pure half: which constraint rows of the inspected model the handoff config declares (so a
|
|
3
|
+
* field can be patched at `/constraints/<i>/<field>`), the pending edits as config patch ops that record the constraint's
|
|
4
|
+
* name beside its index, the constraint chains read from bone parentage with their root → tip ramp, and the check
|
|
5
|
+
* readout lines. No DOM, no IO, no clock; the producer judges every value (a mass of 0 is rigc's `A23` to refuse).
|
|
6
|
+
*/
|
|
7
|
+
import { formatPointer, type PatchOp } from './config-patch.ts';
|
|
8
|
+
import type { CheckSummary } from './applied-context.ts';
|
|
9
|
+
|
|
10
|
+
/** rig-c's `RigPhysicsConstraint` tuning fields (rig-c `src/rig.ts`; defaults 0.5 / 100 / 0.85 / 1 / 0 / 0 / 1). */
|
|
11
|
+
export const TUNING_FIELDS = ['strength', 'damping', 'inertia', 'mass', 'wind', 'gravity', 'mix'] as const;
|
|
12
|
+
export type TuningField = typeof TUNING_FIELDS[number];
|
|
13
|
+
/** The components the panel toggles; rig-c stores each as a number (0 = off). `scaleX` and `shearX` are not offered. */
|
|
14
|
+
export const COMPONENT_FIELDS = ['x', 'y', 'rotate'] as const;
|
|
15
|
+
export type ComponentField = typeof COMPONENT_FIELDS[number];
|
|
16
|
+
export type PhysicsField = TuningField | ComponentField;
|
|
17
|
+
/** Slider extents; the number field beside each slider takes any finite value and the slider widens to hold it. */
|
|
18
|
+
export const FIELD_RANGE: Readonly<Record<TuningField, { min: number; max: number; step: number }>> = {
|
|
19
|
+
strength: { min: 0, max: 1000, step: 1 }, damping: { min: 0, max: 1, step: 0.01 }, inertia: { min: 0, max: 1, step: 0.01 },
|
|
20
|
+
mass: { min: 0, max: 5, step: 0.05 }, wind: { min: -20, max: 20, step: 0.1 }, gravity: { min: -20, max: 20, step: 0.1 }, mix: { min: 0, max: 1, step: 0.01 },
|
|
21
|
+
};
|
|
22
|
+
/** The check bars whose lines the readout shows before and after an Apply. */
|
|
23
|
+
export const READOUT_BARS = ['ANIMATIONS_DO_NOT_FOLD', 'TEXTURE_STRETCH'] as const;
|
|
24
|
+
|
|
25
|
+
export interface TuningRow {
|
|
26
|
+
name: string; kind: string; bone: string | null;
|
|
27
|
+
/** Index in the inspected model's `constraints`, or null for a config entry the model does not have. */
|
|
28
|
+
modelIndex: number | null;
|
|
29
|
+
/** Index in the handoff config's `constraints`: the pointer's index. Null when the config does not declare the row. */
|
|
30
|
+
configIndex: number | null;
|
|
31
|
+
editable: boolean;
|
|
32
|
+
/** Why a row is not editable, by name; null when it is. */
|
|
33
|
+
reason: string | null;
|
|
34
|
+
/** The config's numeric values for the panel's fields, exactly as written; an absent field stays absent. */
|
|
35
|
+
values: Partial<Record<PhysicsField, number>>;
|
|
36
|
+
}
|
|
37
|
+
export type TuningTable = { available: true; rows: TuningRow[] } | { available: false; reason: string };
|
|
38
|
+
|
|
39
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
40
|
+
const FIELDS: readonly PhysicsField[] = [...TUNING_FIELDS, ...COMPONENT_FIELDS];
|
|
41
|
+
const numberOf = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The inspected model's constraints, each matched to the config entry of the same name and kind (the config's `type`
|
|
45
|
+
* is the model's `kind`). A row is editable only when it is a physics constraint the config declares and every field
|
|
46
|
+
* this panel edits (and the bone) reads the same on both sides — the config field maps 1:1 onto what the model holds.
|
|
47
|
+
*/
|
|
48
|
+
export function tuningRows(configConstraints: unknown, modelDocument: unknown): TuningTable {
|
|
49
|
+
if (!isObject(modelDocument) || !Array.isArray(modelDocument.constraints)) return { available: false, reason: 'PHYSICS_TUNING_UNAVAILABLE: the inspected model declares no constraints list' };
|
|
50
|
+
if (!Array.isArray(configConstraints)) return { available: false, reason: 'PHYSICS_TUNING_UNAVAILABLE: the handoff config has no constraints list' };
|
|
51
|
+
const config = configConstraints.map((entry, index) => ({ entry: isObject(entry) ? entry : {}, index }));
|
|
52
|
+
const used = new Set<number>();
|
|
53
|
+
const rows: TuningRow[] = [];
|
|
54
|
+
modelDocument.constraints.forEach((raw, modelIndex) => {
|
|
55
|
+
const m = isObject(raw) ? raw : {};
|
|
56
|
+
const name = typeof m.name === 'string' ? m.name : `#${modelIndex}`, kind = typeof m.kind === 'string' ? m.kind : 'unknown';
|
|
57
|
+
const bone = typeof m.bone === 'string' ? m.bone : null;
|
|
58
|
+
const found = config.find(c => !used.has(c.index) && c.entry.name === name && c.entry.type === kind);
|
|
59
|
+
if (!found) {
|
|
60
|
+
rows.push({ name, kind, bone, modelIndex, configIndex: null, editable: false, values: {}, reason: `PHYSICS_ROW_UNDECLARED: the model has ${kind} "${name}" but config.constraints does not declare it; it was added at the rig stage (e.g. the \`turn\` twins), so there is no config field to patch` });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
used.add(found.index);
|
|
64
|
+
const values: Partial<Record<PhysicsField, number>> = {};
|
|
65
|
+
for (const field of FIELDS) { const v = numberOf(found.entry[field]); if (v !== undefined) values[field] = v; }
|
|
66
|
+
if (kind !== 'physics') { rows.push({ name, kind, bone, modelIndex, configIndex: found.index, editable: false, values, reason: `PHYSICS_ROW_NOT_PHYSICS: config.constraints[${found.index}] is a ${kind} constraint; this panel tunes physics constraints only` }); return; }
|
|
67
|
+
const differs = [...FIELDS, 'bone'].find(field => JSON.stringify(found.entry[field]) !== JSON.stringify(m[field]));
|
|
68
|
+
if (differs) { rows.push({ name, kind, bone, modelIndex, configIndex: found.index, editable: false, values, reason: `PHYSICS_ROW_UNMAPPED: config.constraints[${found.index}].${differs} is ${JSON.stringify(found.entry[differs]) ?? 'absent'}, the model holds ${JSON.stringify(m[differs]) ?? 'absent'}; the config does not map 1:1 onto this row` }); return; }
|
|
69
|
+
rows.push({ name, kind, bone, modelIndex, configIndex: found.index, editable: true, reason: null, values });
|
|
70
|
+
});
|
|
71
|
+
for (const c of config) if (!used.has(c.index)) {
|
|
72
|
+
const name = typeof c.entry.name === 'string' ? c.entry.name : `#${c.index}`, kind = typeof c.entry.type === 'string' ? c.entry.type : 'unknown';
|
|
73
|
+
rows.push({ name, kind, bone: typeof c.entry.bone === 'string' ? c.entry.bone : null, modelIndex: null, configIndex: c.index, editable: false, values: {}, reason: `PHYSICS_ROW_NOT_IN_MODEL: config.constraints[${c.index}] ${kind} "${name}" is not in the inspected model` });
|
|
74
|
+
}
|
|
75
|
+
return { available: true, rows };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One pending edit: the patch op it becomes, with the field, the config's value before it and the constraint's name. */
|
|
79
|
+
export interface PendingEdit { op: 'replace' | 'add'; path: string; value: number; name: string; configIndex: number; field: PhysicsField; from: number | null }
|
|
80
|
+
export const pointerFor = (row: Pick<TuningRow, 'configIndex'>, field: PhysicsField) => formatPointer(['constraints', row.configIndex!, field]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The edit setting `field` of an editable row to `value`: `replace` when the config declares the field, `add` when it
|
|
84
|
+
* does not. Null when the value is the config's own (the edit reverts). Refused by name for a row that is not editable.
|
|
85
|
+
*/
|
|
86
|
+
export function editFor(row: TuningRow, field: PhysicsField, value: number): PendingEdit | null {
|
|
87
|
+
if (!row.editable || row.configIndex === null) throw new Error(row.reason ?? `PHYSICS_ROW_NOT_EDITABLE: ${row.name}`);
|
|
88
|
+
if (!FIELDS.includes(field)) throw new Error(`PHYSICS_FIELD_UNKNOWN: ${field}`);
|
|
89
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`PHYSICS_VALUE_INVALID: ${row.name}.${field} must be a finite number`);
|
|
90
|
+
const from = row.values[field] ?? null;
|
|
91
|
+
if (from === value) return null;
|
|
92
|
+
return { op: from === null ? 'add' : 'replace', path: pointerFor(row, field), value, name: row.name, configIndex: row.configIndex, field, from };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Pending edits by pointer: a later edit of the same field replaces the earlier one; an edit back to the config's value drops it. */
|
|
96
|
+
export function setEdit(edits: ReadonlyMap<string, PendingEdit>, row: TuningRow, field: PhysicsField, value: number): Map<string, PendingEdit> {
|
|
97
|
+
const next = new Map(edits), edit = editFor(row, field, value), path = pointerFor(row, field);
|
|
98
|
+
if (edit) next.set(path, edit); else next.delete(path);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The ops a patch carries, in edit order, each naming its constraint so the server resolves it by name as well as index. */
|
|
103
|
+
export const patchOps = (edits: ReadonlyMap<string, PendingEdit>): PatchOp[] => [...edits.values()].map(e => ({ op: e.op, path: e.path, value: e.value, name: e.name }));
|
|
104
|
+
|
|
105
|
+
/** A component's toggle: on keeps (or restores) the config's own weight, else 1; off is 0. */
|
|
106
|
+
export function componentValue(row: TuningRow, field: ComponentField, on: boolean): number {
|
|
107
|
+
const declared = row.values[field];
|
|
108
|
+
return on ? (declared !== undefined && declared > 0 ? declared : 1) : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Which components a row moves (non-zero x, y, rotate), as a chain groups links: "rotate", "x+y", … */
|
|
112
|
+
export const componentSignature = (row: TuningRow) => COMPONENT_FIELDS.filter(f => (row.values[f] ?? 0) !== 0).join('+') || 'none';
|
|
113
|
+
|
|
114
|
+
export interface PhysicsChain { id: string; label: string; signature: string; links: TuningRow[] }
|
|
115
|
+
/**
|
|
116
|
+
* Chains of editable physics rows read from bone parentage: a link's parent link is the row on its bone's parent bone
|
|
117
|
+
* that moves the same components. Every root → tip path of two or more links is a chain (a fork gives one per branch).
|
|
118
|
+
*/
|
|
119
|
+
export function physicsChains(rows: readonly TuningRow[], bones: readonly { name: string; parent?: string | null }[]): PhysicsChain[] {
|
|
120
|
+
const parent = new Map(bones.map(b => [b.name, b.parent ?? null]));
|
|
121
|
+
const links = rows.filter(r => r.editable && r.kind === 'physics' && r.bone !== null);
|
|
122
|
+
const at = new Map<string, TuningRow[]>();
|
|
123
|
+
for (const row of links) { const key = `${componentSignature(row)}|${row.bone}`; at.set(key, [...(at.get(key) ?? []), row]); }
|
|
124
|
+
const one = (signature: string, bone: string | null) => { if (bone === null) return null; const found = at.get(`${signature}|${bone}`); return found && found.length === 1 ? found[0]! : null; };
|
|
125
|
+
const parentLink = (row: TuningRow) => one(componentSignature(row), parent.get(row.bone!) ?? null);
|
|
126
|
+
const children = new Map<TuningRow, TuningRow[]>();
|
|
127
|
+
for (const row of links) { const up = parentLink(row); if (up) children.set(up, [...(children.get(up) ?? []), row]); }
|
|
128
|
+
const chains: PhysicsChain[] = [];
|
|
129
|
+
const walk = (path: TuningRow[]) => {
|
|
130
|
+
const next = children.get(path.at(-1)!) ?? [];
|
|
131
|
+
if (!next.length) { if (path.length >= 2) { const signature = componentSignature(path[0]!); chains.push({ id: path.map(r => r.name).join('>'), signature, label: `${path[0]!.bone} → ${path.at(-1)!.bone} · ${signature} · ${path.length} links`, links: path }); } return; }
|
|
132
|
+
for (const child of next) walk([...path, child]);
|
|
133
|
+
};
|
|
134
|
+
for (const row of links) if (!parentLink(row)) walk([row]);
|
|
135
|
+
return chains;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Link i of n (0 at the root) takes root + (tip − root)·i/(n − 1): exactly one edit per link, linear root → tip. */
|
|
139
|
+
export function rampValues(n: number, root: number, tip: number): number[] {
|
|
140
|
+
if (!Number.isInteger(n) || n < 2) throw new Error('PHYSICS_CHAIN_SHORT: a ramp needs two or more links');
|
|
141
|
+
return Array.from({ length: n }, (_, i) => root + (tip - root) * i / (n - 1));
|
|
142
|
+
}
|
|
143
|
+
/** The ramp as edits over the pending set: every link's `field` set once, root → tip. */
|
|
144
|
+
export function applyRamp(edits: ReadonlyMap<string, PendingEdit>, chain: PhysicsChain, field: PhysicsField, root: number, tip: number): Map<string, PendingEdit> {
|
|
145
|
+
const values = rampValues(chain.links.length, root, tip);
|
|
146
|
+
let next = new Map(edits);
|
|
147
|
+
chain.links.forEach((row, i) => { next = setEdit(next, row, field, values[i]!); });
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One readout line for a bar: check's own printed line for it (` NAME: STATUS — …`) from the job's lines, else the
|
|
153
|
+
* bar's status from the summary, else "not in check summary" by name. Nothing is inferred.
|
|
154
|
+
*/
|
|
155
|
+
export function barLine(name: string, lines: readonly string[] | null, check: CheckSummary | null): string {
|
|
156
|
+
const printed = lines?.map(l => l.trim()).find(l => l.startsWith(`${name}: `));
|
|
157
|
+
if (printed) return printed;
|
|
158
|
+
const status = check?.bars[name];
|
|
159
|
+
return status ? `${name}: ${status} (status from check.json; check printed no line for it)` : `${name}: not in check summary`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The inspected build's stored check (#108, HQ ruling) as `GET /api/rebuild` returns it. */
|
|
163
|
+
export interface InspectedCheckView { status: 'running' | 'checked' | 'refused' | 'failed'; lines: string[]; check: CheckSummary | null; line: string }
|
|
164
|
+
export const INSPECTED_NOT_CHECKED = (name: string) => `${name}: not in check summary (the inspected build; review ran no check on it)`;
|
|
165
|
+
/**
|
|
166
|
+
* BEFORE: the build that was on the stage when Apply was pressed. A scratch build: its own check lines. The inspected
|
|
167
|
+
* build: the stored background check of it — labelled "check running…" while it runs (never a value), its line
|
|
168
|
+
* verbatim when check refused, and "not in check summary" only when no check exists (the baseline did not reproduce).
|
|
169
|
+
*/
|
|
170
|
+
export function beforeLine(name: string, stage: { lines: readonly string[]; check: CheckSummary | null } | null, inspected: InspectedCheckView | null): string {
|
|
171
|
+
if (stage) return barLine(name, stage.lines, stage.check);
|
|
172
|
+
if (!inspected) return INSPECTED_NOT_CHECKED(name);
|
|
173
|
+
if (inspected.status === 'running') return `${name}: check running… (rig-parts check of the inspected build, in the background)`;
|
|
174
|
+
if (inspected.status !== 'checked') return inspected.line;
|
|
175
|
+
return barLine(name, inspected.lines, inspected.check);
|
|
176
|
+
}
|
|
177
|
+
/** The readout rows after an Apply: BEFORE per `beforeLine`, AFTER from the job just built; `bars` defaults to the physics panel's. */
|
|
178
|
+
export function readoutRows(stage: { lines: readonly string[]; check: CheckSummary | null } | null, inspected: InspectedCheckView | null, after: { lines: readonly string[]; check: CheckSummary | null }, bars: readonly string[] = READOUT_BARS) {
|
|
179
|
+
return bars.map(name => ({ name, before: beforeLine(name, stage, inspected), after: barLine(name, after.lines, after.check) }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A control the row editor draws: a slider with its number field (a tuning field) or a toggle (a component). */
|
|
183
|
+
export type RowControl =
|
|
184
|
+
| { kind: 'slider'; field: TuningField; value: number; base: number; min: number; max: number; step: number; pending: boolean }
|
|
185
|
+
| { kind: 'toggle'; field: ComponentField; on: boolean; base: number | null; pending: boolean };
|
|
186
|
+
/**
|
|
187
|
+
* What the row editor shows for a row: for an editable row a slider per tuning field the config declares (an absent
|
|
188
|
+
* field is the producer's default, shown as absent and not offered) and the x / y / rotate toggles; for a row that is
|
|
189
|
+
* not editable no control at all, only the reason.
|
|
190
|
+
*/
|
|
191
|
+
export function rowControls(row: TuningRow, edits: ReadonlyMap<string, PendingEdit>): { editable: true; controls: RowControl[] } | { editable: false; reason: string } {
|
|
192
|
+
if (!row.editable || row.configIndex === null) return { editable: false, reason: row.reason ?? `PHYSICS_ROW_NOT_EDITABLE: ${row.name}` };
|
|
193
|
+
const current = (field: PhysicsField) => edits.get(pointerFor(row, field))?.value;
|
|
194
|
+
const controls: RowControl[] = [];
|
|
195
|
+
for (const field of TUNING_FIELDS) {
|
|
196
|
+
const base = row.values[field];
|
|
197
|
+
if (base === undefined && current(field) === undefined) continue;
|
|
198
|
+
const value = current(field) ?? base!, range = FIELD_RANGE[field];
|
|
199
|
+
controls.push({ kind: 'slider', field, value, base: base ?? value, min: Math.min(range.min, value), max: Math.max(range.max, value), step: range.step, pending: current(field) !== undefined });
|
|
200
|
+
}
|
|
201
|
+
for (const field of COMPONENT_FIELDS) {
|
|
202
|
+
const value = current(field) ?? row.values[field] ?? 0;
|
|
203
|
+
controls.push({ kind: 'toggle', field, on: value !== 0, base: row.values[field] ?? null, pending: current(field) !== undefined });
|
|
204
|
+
}
|
|
205
|
+
return { editable: true, controls };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The chain whose link bone lies nearest a click on the stage, within `radius` px; null when none does. */
|
|
209
|
+
export function nearestChain(chains: readonly PhysicsChain[], points: ReadonlyMap<string, { x: number; y: number }>, click: { x: number; y: number }, radius = 24): PhysicsChain | null {
|
|
210
|
+
let best: { chain: PhysicsChain; d: number } | null = null;
|
|
211
|
+
for (const chain of chains) for (const link of chain.links) {
|
|
212
|
+
const p = link.bone ? points.get(link.bone) : undefined;
|
|
213
|
+
if (!p) continue;
|
|
214
|
+
const d = Math.hypot(p.x - click.x, p.y - click.y);
|
|
215
|
+
if (d <= radius && (!best || d < best.d)) best = { chain, d };
|
|
216
|
+
}
|
|
217
|
+
return best?.chain ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The draft's record of the panel: the pending edits (in order) as the patch ops they become. */
|
|
221
|
+
export const PHYSICS_DRAFT_SCHEMA = 'rig-review-physics-draft/1';
|
|
222
|
+
export function draftText(edits: ReadonlyMap<string, PendingEdit>): string { return JSON.stringify({ schema: PHYSICS_DRAFT_SCHEMA, edits: [...edits.values()] }); }
|
|
223
|
+
/** Edits back from a draft, kept only where the row still resolves (same name at the same index, still editable). */
|
|
224
|
+
export function draftEdits(text: string, rows: readonly TuningRow[]): Map<string, PendingEdit> {
|
|
225
|
+
let doc: unknown;
|
|
226
|
+
try { doc = JSON.parse(text); } catch { return new Map(); }
|
|
227
|
+
if (!isObject(doc) || doc.schema !== PHYSICS_DRAFT_SCHEMA || !Array.isArray(doc.edits)) return new Map();
|
|
228
|
+
let edits = new Map<string, PendingEdit>();
|
|
229
|
+
for (const raw of doc.edits) {
|
|
230
|
+
if (!isObject(raw) || typeof raw.value !== 'number' || typeof raw.field !== 'string') continue;
|
|
231
|
+
const row = rows.find(r => r.editable && r.configIndex === raw.configIndex && r.name === raw.name);
|
|
232
|
+
if (!row || !FIELDS.includes(raw.field as PhysicsField)) continue;
|
|
233
|
+
try { edits = setEdit(edits, row, raw.field as PhysicsField, raw.value); } catch { /* a value the panel would refuse is not restored */ }
|
|
234
|
+
}
|
|
235
|
+
return edits;
|
|
236
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `rig-parts rig` (and `remesh`) writes into a rig directory, as review compares it (#125): the JSON documents by
|
|
3
|
+
* name, and the part images under `images/` by hash. The baseline (`src/baseline.ts`, does the handoff reproduce the
|
|
4
|
+
* inspected rig?) and the applied-state diff (`src/rebuild.ts`, what did the patch change?) both read this one list, so a
|
|
5
|
+
* document the producer starts writing is one line here. Pure: no imports, no IO.
|
|
6
|
+
*/
|
|
7
|
+
export const PRODUCER_DOCUMENTS = ['rig.json', 'motion.json', 'sets.json', 'mesh_report.json'] as const;
|
|
8
|
+
export type ProducerDocument = typeof PRODUCER_DOCUMENTS[number];
|
|
9
|
+
/** The part images' directory inside a rig directory; compared by hash, file by file. */
|
|
10
|
+
export const PRODUCER_IMAGES_DIR = 'images';
|
|
11
|
+
export const isProducerFile = (path: string): boolean => (PRODUCER_DOCUMENTS as readonly string[]).includes(path) || path.startsWith(`${PRODUCER_IMAGES_DIR}/`);
|
package/src/producer.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installed producer (`@rig-c/parts`' `rig-parts`) and the compiler it gates with (`rig-c`'s `rigc`), resolved the
|
|
3
|
+
* way `rigc review` resolves `rig-review`: the package's own `package.json` under the nearest `node_modules` walking
|
|
4
|
+
* up from each start directory, its `bin` entry read from that manifest. No PATH lookup, no install, no prompt.
|
|
5
|
+
* Review builds nothing itself: it runs these binaries as child processes with arguments it composes from a typed
|
|
6
|
+
* request, and reports what they printed.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const PRODUCER_PACKAGE = '@rig-c/parts';
|
|
12
|
+
export const PRODUCER_BIN = 'rig-parts';
|
|
13
|
+
export const COMPILER_PACKAGE = 'rig-c';
|
|
14
|
+
export const COMPILER_BIN = 'rigc';
|
|
15
|
+
/** The first `@rig-c/parts` release with `rig-parts remesh` (its CHANGELOG: 1.40.0, "remesh: re-fit one part of a gated rig"). */
|
|
16
|
+
export const REMESH_SINCE = '1.40.0';
|
|
17
|
+
/**
|
|
18
|
+
* The first `@rig-c/parts` release whose `rig-parts remesh` reads `--delta <mesh-edit.json>` in place of `--mesh` (its
|
|
19
|
+
* CHANGELOG: 1.43.0, "remesh: --delta replays rig-c/review's mesh edit over one part's mesh", parts#528). Edit Mesh's
|
|
20
|
+
* Apply · Rebuild (#135) needs it; the version is read off the installed producer's own `package.json`.
|
|
21
|
+
*/
|
|
22
|
+
export const REMESH_DELTA_SINCE = '1.43.0';
|
|
23
|
+
export const MODELING_PRODUCER_NOT_INSTALLED = `MODELING_PRODUCER_NOT_INSTALLED: ${PRODUCER_PACKAGE} is not installed beside the package or beside review, and review does not install it — bun add -d ${PRODUCER_PACKAGE}`;
|
|
24
|
+
export const modelingProducerTooOld = (found: string, required: string, what = 'rig-parts remesh') => `MODELING_PRODUCER_TOO_OLD ${found} ${required}: ${what} needs ${PRODUCER_PACKAGE} ${required} or later — bun add -d ${PRODUCER_PACKAGE}@latest`;
|
|
25
|
+
export const MODELING_COMPILER_NOT_INSTALLED = `MODELING_COMPILER_NOT_INSTALLED: ${COMPILER_PACKAGE} is not installed beside ${PRODUCER_PACKAGE}, which gates its rigs with it — reinstall ${PRODUCER_PACKAGE} (bun add -d ${PRODUCER_PACKAGE})`;
|
|
26
|
+
|
|
27
|
+
export interface InstalledTool { package: string; version: string; root: string; bin: string }
|
|
28
|
+
export type ProducerResolution = { ok: true; producer: InstalledTool; compiler: InstalledTool } | { ok: false; error: string };
|
|
29
|
+
|
|
30
|
+
/** The nearest `node_modules/<name>/package.json` walking up from each start, first start first. */
|
|
31
|
+
export function findPackage(name: string, starts: readonly string[], exists: (path: string) => boolean = existsSync): string | null {
|
|
32
|
+
for (const start of starts) {
|
|
33
|
+
for (let dir = resolve(start); ; dir = dirname(dir)) {
|
|
34
|
+
const manifest = join(dir, 'node_modules', name, 'package.json');
|
|
35
|
+
if (exists(manifest)) return manifest;
|
|
36
|
+
if (dirname(dir) === dir) break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `X.Y.Z` → [X, Y, Z]; anything else is null (a pre-release tag is not compared, it is refused as unread). */
|
|
43
|
+
export function parseVersion(text: unknown): [number, number, number] | null {
|
|
44
|
+
const match = typeof text === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(text.trim()) : null;
|
|
45
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
46
|
+
}
|
|
47
|
+
export function versionAtLeast(found: string, required: string): boolean {
|
|
48
|
+
const a = parseVersion(found), b = parseVersion(required);
|
|
49
|
+
if (!a || !b) return false;
|
|
50
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readTool(manifest: string, binName: string): InstalledTool | { error: string } {
|
|
55
|
+
let value: Record<string, unknown>;
|
|
56
|
+
try { value = JSON.parse(readFileSync(manifest, 'utf8')); } catch { return { error: `${manifest} is not readable JSON` }; }
|
|
57
|
+
const bins = value.bin, relative = typeof bins === 'object' && bins !== null ? (bins as Record<string, unknown>)[binName] : undefined;
|
|
58
|
+
const root = dirname(manifest), version = typeof value.version === 'string' ? value.version : '';
|
|
59
|
+
if (typeof relative !== 'string') return { error: `${String(value.name)} at ${root} declares no "${binName}" bin` };
|
|
60
|
+
const bin = resolve(root, relative);
|
|
61
|
+
try { if (!statSync(bin).isFile()) throw new Error(); } catch { return { error: `${String(value.name)} at ${root} declares the "${binName}" bin ${relative}, which is not there` }; }
|
|
62
|
+
return { package: typeof value.name === 'string' ? value.name : '', version, root, bin };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Producer: walking up from the inspected package, then from review's working directory. Compiler: walking up from
|
|
67
|
+
* the producer's own package directory, which is where `rig-parts` itself finds the `rigc` it gates remesh with.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveProducer(starts: readonly string[], exists: (path: string) => boolean = existsSync): ProducerResolution {
|
|
70
|
+
const manifest = findPackage(PRODUCER_PACKAGE, starts, exists);
|
|
71
|
+
if (!manifest) return { ok: false, error: MODELING_PRODUCER_NOT_INSTALLED };
|
|
72
|
+
const producer = readTool(manifest, PRODUCER_BIN);
|
|
73
|
+
if ('error' in producer) return { ok: false, error: `${MODELING_PRODUCER_NOT_INSTALLED} (${producer.error})` };
|
|
74
|
+
if (!versionAtLeast(producer.version, REMESH_SINCE)) return { ok: false, error: modelingProducerTooOld(producer.version || 'unknown', REMESH_SINCE) };
|
|
75
|
+
const compilerManifest = findPackage(COMPILER_PACKAGE, [producer.root], exists);
|
|
76
|
+
if (!compilerManifest) return { ok: false, error: MODELING_COMPILER_NOT_INSTALLED };
|
|
77
|
+
const compiler = readTool(compilerManifest, COMPILER_BIN);
|
|
78
|
+
if ('error' in compiler) return { ok: false, error: `${MODELING_COMPILER_NOT_INSTALLED} (${compiler.error})` };
|
|
79
|
+
return { ok: true, producer, compiler };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RemeshRequest { rig: string; config: string; parts: string; part: string; mesh: string; out: string }
|
|
83
|
+
/** `rig-parts remesh` flags as its help states them; every value a path the server composed, never a client string. */
|
|
84
|
+
export const remeshArgs = (r: RemeshRequest): string[] => ['remesh', '--rig', r.rig, '--config', r.config, '--parts', r.parts, '--part', r.part, '--mesh', r.mesh, '--out', r.out];
|
|
85
|
+
export interface RemeshDeltaRequest { rig: string; config: string; parts: string; part: string; delta: string; out: string }
|
|
86
|
+
/** `rig-parts remesh … --delta <mesh-edit.json>` (#135): the same flags with `--delta` in place of `--mesh`, as its help states. */
|
|
87
|
+
export const remeshDeltaArgs = (r: RemeshDeltaRequest): string[] => ['remesh', '--rig', r.rig, '--config', r.config, '--parts', r.parts, '--part', r.part, '--delta', r.delta, '--out', r.out];
|
|
88
|
+
export interface CompileRequest { rig: string; motion: string; out: string; pageEdges: 'pot' | 'free'; packShape: 'rect' | 'polygon' }
|
|
89
|
+
/** remesh writes a rig spec (rig.json + motion.json); this is the compile its own gate ran, with the revision's pack flags. */
|
|
90
|
+
export const compileArgs = (r: CompileRequest): string[] => ['build', '--rig', r.rig, '--motion', r.motion, '--out', r.out, '--profile', 'rig-play', '--pack', '--page-edges', r.pageEdges, '--pack-shape', r.packShape];
|
|
91
|
+
|
|
92
|
+
/** The producer's first named refusal line, verbatim (trimmed): `FAIL CODE: …`, or a `CODE: …` line, or `refused: …`. */
|
|
93
|
+
export function firstRefusal(lines: readonly string[]): string | null {
|
|
94
|
+
for (const line of lines) if (/^\s*FAIL\s+[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/.test(line)) return line.trim();
|
|
95
|
+
for (const line of lines) if (/^\s*[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+:\s/.test(line)) return line.trim();
|
|
96
|
+
for (const line of lines) if (/^\s*refused:/.test(line)) return line.trim();
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ProcessResult { exitCode: number; lines: string[]; stdout: string[]; wallMs: number }
|
|
101
|
+
/**
|
|
102
|
+
* One child process: `bun <bin> …args` with cwd = the scratch directory, stdout and stderr read line by line (each
|
|
103
|
+
* line handed to `onLine` as it arrives), the exit code and a wall-clock duration. Nothing is read from a manifest.
|
|
104
|
+
*/
|
|
105
|
+
export async function runTool(bin: string, args: readonly string[], options: { cwd: string; onLine?: (line: string) => void; env?: Record<string, string | undefined> }): Promise<ProcessResult> {
|
|
106
|
+
const started = performance.now(), lines: string[] = [], stdout: string[] = [];
|
|
107
|
+
const child = Bun.spawn([process.execPath, bin, ...args], { cwd: options.cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env: options.env ?? process.env });
|
|
108
|
+
const pump = async (stream: ReadableStream<Uint8Array>, own: string[] | null) => {
|
|
109
|
+
const decoder = new TextDecoder(); let rest = '';
|
|
110
|
+
const reader = stream.getReader();
|
|
111
|
+
for (let next = await reader.read(); !next.done; next = await reader.read()) {
|
|
112
|
+
rest += decoder.decode(next.value, { stream: true });
|
|
113
|
+
let at: number;
|
|
114
|
+
while ((at = rest.indexOf('\n')) >= 0) { const line = rest.slice(0, at).replace(/\r$/, ''); rest = rest.slice(at + 1); lines.push(line); own?.push(line); options.onLine?.(line); }
|
|
115
|
+
}
|
|
116
|
+
rest += decoder.decode();
|
|
117
|
+
if (rest) { lines.push(rest); own?.push(rest); options.onLine?.(rest); }
|
|
118
|
+
};
|
|
119
|
+
await Promise.all([pump(child.stdout, stdout), pump(child.stderr, null)]);
|
|
120
|
+
const exitCode = await child.exited;
|
|
121
|
+
// `lines` is the merged stream in arrival order (what the status area shows); `stdout` keeps the tool's own
|
|
122
|
+
// report channel apart so a last line is never a stderr line that happened to arrive late.
|
|
123
|
+
return { exitCode, lines, stdout, wallMs: Math.round(performance.now() - started) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** #108: the compiler alone (its readings need no producer op) — beside `@rig-c/parts` when that resolves, else walking up from each start. */
|
|
127
|
+
export const READINGS_COMPILER_NOT_INSTALLED = `READINGS_COMPILER_NOT_INSTALLED: ${COMPILER_PACKAGE} is not installed beside the package, beside ${PRODUCER_PACKAGE} or beside review, and review does not install it — bun add -d ${COMPILER_PACKAGE}`;
|
|
128
|
+
export const readingsCompilerTooOld = (found: string, required: string) => `READINGS_COMPILER_TOO_OLD ${found} ${required}: rigc render's readings (--folds, --pass-through, --seam, --overlaps, --angle-range) need ${COMPILER_PACKAGE} ${required} or later — bun add -d ${COMPILER_PACKAGE}@latest`;
|
|
129
|
+
export function resolveCompiler(starts: readonly string[], since: string, exists: (path: string) => boolean = existsSync): { ok: true; compiler: InstalledTool } | { ok: false; error: string } {
|
|
130
|
+
const pair = resolveProducer(starts, exists);
|
|
131
|
+
let compiler: InstalledTool | { error: string } | null = pair.ok ? pair.compiler : null;
|
|
132
|
+
if (!compiler) { const manifest = findPackage(COMPILER_PACKAGE, starts, exists); compiler = manifest ? readTool(manifest, COMPILER_BIN) : null; }
|
|
133
|
+
if (!compiler) return { ok: false, error: READINGS_COMPILER_NOT_INSTALLED };
|
|
134
|
+
if ('error' in compiler) return { ok: false, error: `${READINGS_COMPILER_NOT_INSTALLED} (${compiler.error})` };
|
|
135
|
+
if (!versionAtLeast(compiler.version, since)) return { ok: false, error: readingsCompilerTooOld(compiler.version || 'unknown', since) };
|
|
136
|
+
return { ok: true, compiler };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* #114: the installed pose producer (`@rig-c/pose`' `rig-pose`), resolved as `@rig-c/parts` is — walking up from the
|
|
141
|
+
* package, then from review's working directory — and the `rigc` beside it, which gates and poses its retarget and
|
|
142
|
+
* compiles its scratch motion here. Refused by name when absent; review never installs it.
|
|
143
|
+
*/
|
|
144
|
+
export const POSE_PACKAGE = '@rig-c/pose';
|
|
145
|
+
export const POSE_BIN = 'rig-pose';
|
|
146
|
+
export const RETARGET_PRODUCER_NOT_INSTALLED = `RETARGET_PRODUCER_NOT_INSTALLED: ${POSE_PACKAGE} is not installed beside the package or beside review, and review does not install it — bun add -d ${POSE_PACKAGE}`;
|
|
147
|
+
export const RETARGET_COMPILER_NOT_INSTALLED = `RETARGET_COMPILER_NOT_INSTALLED: ${COMPILER_PACKAGE} is not installed beside ${POSE_PACKAGE}, which gates and poses its retarget with it — reinstall ${POSE_PACKAGE} (bun add -d ${POSE_PACKAGE})`;
|
|
148
|
+
export function resolvePose(starts: readonly string[], exists: (path: string) => boolean = existsSync): ProducerResolution {
|
|
149
|
+
const manifest = findPackage(POSE_PACKAGE, starts, exists);
|
|
150
|
+
if (!manifest) return { ok: false, error: RETARGET_PRODUCER_NOT_INSTALLED };
|
|
151
|
+
const producer = readTool(manifest, POSE_BIN);
|
|
152
|
+
if ('error' in producer) return { ok: false, error: `${RETARGET_PRODUCER_NOT_INSTALLED} (${producer.error})` };
|
|
153
|
+
const compilerManifest = findPackage(COMPILER_PACKAGE, [producer.root], exists);
|
|
154
|
+
if (!compilerManifest) return { ok: false, error: RETARGET_COMPILER_NOT_INSTALLED };
|
|
155
|
+
const compiler = readTool(compilerManifest, COMPILER_BIN);
|
|
156
|
+
if ('error' in compiler) return { ok: false, error: `${RETARGET_COMPILER_NOT_INSTALLED} (${compiler.error})` };
|
|
157
|
+
return { ok: true, producer, compiler };
|
|
158
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/readings` (issue #108): the requested readings run, one `rigc render` each, on the inspected build (the
|
|
3
|
+
* package's compiled model directory, read only) and on the session's latest scratch build (if any), each into
|
|
4
|
+
* `<state>/readings/<package hash>/<build>/<reading key>/` — no clock in any name, and an identical reading on the same
|
|
5
|
+
* build and compiler reuses its stored result. Only the reading's JSON and frames.json are kept; the rendered frames are
|
|
6
|
+
* removed. Nothing is written into the package (checked again after the run); no path comes from the client.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { copyFile, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
11
|
+
import { safeFile, type ReviewPackage } from './manifest.ts';
|
|
12
|
+
import { resolveCompiler, runTool, type InstalledTool } from './producer.ts';
|
|
13
|
+
import { environmentSchedule, helpHasEnvironment, parseReadingsRequest, READING_FILE, readingLabel, readingResult, READINGS_SCHEMA, READINGS_SINCE, ReadingsError, renderArgs, type ReadingBuild, type ReadingRequest, type ReadingResult, type ReadingsRequest } from './readings.ts';
|
|
14
|
+
|
|
15
|
+
const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
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; } }
|
|
17
|
+
|
|
18
|
+
export interface ReadingsBuildRun { build: ReadingBuild; id: string; modelHash: string; results: ReadingResult[] }
|
|
19
|
+
export interface ReadingsJob {
|
|
20
|
+
schema: typeof READINGS_SCHEMA; id: string; status: 'running' | 'done' | 'failed'; error: string | null;
|
|
21
|
+
request: ReadingsRequest;
|
|
22
|
+
/** Whether the schedule went to rigc: only when one was asked for and this rigc's `render --help` lists `--environment`. */
|
|
23
|
+
environment: { requested: boolean; flag: boolean; passed: boolean };
|
|
24
|
+
compiler: { package: string; version: string };
|
|
25
|
+
runs: ReadingsBuildRun[];
|
|
26
|
+
}
|
|
27
|
+
export interface ReadingsOptions {
|
|
28
|
+
stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>;
|
|
29
|
+
/** The session's latest built scratch build: its id, its build directory and its model hash. */
|
|
30
|
+
latest: () => { id: string; build: string; modelHash: string } | null;
|
|
31
|
+
}
|
|
32
|
+
export const readingsRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'readings', sourceHash);
|
|
33
|
+
/** Deterministic: the reading, the animation, the schedule passed (or none), the compiler and the build's model bytes. */
|
|
34
|
+
export const readingKey = (r: ReadingRequest, animation: string, schedule: unknown, compiler: string, modelHash: string) => sha256(JSON.stringify([READINGS_SCHEMA, readingLabel(r), animation, schedule, compiler, modelHash]));
|
|
35
|
+
|
|
36
|
+
/** One reading on one build: reuse its stored result, or run `rigc render` and keep its JSON beside the result. */
|
|
37
|
+
export async function runReading(r: ReadingRequest, animation: string, candidate: string, dir: string, key: string, compiler: InstalledTool, schedule: unknown): Promise<ReadingResult> {
|
|
38
|
+
const resultFile = join(dir, 'result.json');
|
|
39
|
+
try { const stored = JSON.parse(await readFile(resultFile, 'utf8')) as { key: string; result: ReadingResult }; if (stored.key === key) return stored.result; } catch { /* not run yet */ }
|
|
40
|
+
await rm(dir, { recursive: true, force: true });
|
|
41
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
if (schedule) await writeFile(join(dir, 'environment.json'), JSON.stringify(schedule, null, 1) + '\n', { mode: 0o600 });
|
|
43
|
+
const run = await runTool(compiler.bin, renderArgs(r, animation, { candidate, out: 'render', environment: schedule ? 'environment.json' : null }), { cwd: dir });
|
|
44
|
+
let json: unknown = null, fps: number | null = null;
|
|
45
|
+
if (run.exitCode === 0) {
|
|
46
|
+
try {
|
|
47
|
+
const frames = JSON.parse(await readFile(join(dir, 'render', 'frames.json'), 'utf8')) as { sets?: { dir: string; animation: string | null; fps: number }[] };
|
|
48
|
+
const set = frames.sets?.find(s => s.animation === animation);
|
|
49
|
+
if (set) {
|
|
50
|
+
fps = set.fps;
|
|
51
|
+
const file = READING_FILE[r.kind];
|
|
52
|
+
if (file) { json = JSON.parse(await readFile(join(dir, 'render', set.dir, file), 'utf8')); await copyFile(join(dir, 'render', set.dir, file), join(dir, file)); }
|
|
53
|
+
}
|
|
54
|
+
await copyFile(join(dir, 'render', 'frames.json'), join(dir, 'frames.json'));
|
|
55
|
+
} catch { /* readingResult says what it could not read */ }
|
|
56
|
+
}
|
|
57
|
+
const result = readingResult(r, run, json, fps);
|
|
58
|
+
// The frames and plates are rigc's pictures of the reading, not its result; the JSON is kept.
|
|
59
|
+
await rm(join(dir, 'render'), { recursive: true, force: true });
|
|
60
|
+
await writeFile(resultFile, JSON.stringify({ key, result }, null, 1) + '\n', { mode: 0o600 });
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createReadings(p: ReviewPackage, options: ReadingsOptions) {
|
|
65
|
+
const jobs = new Map<string, ReadingsJob>(), help = new Map<string, boolean>();
|
|
66
|
+
let running: string | null = null;
|
|
67
|
+
const snapshot = (job: ReadingsJob): ReadingsJob => JSON.parse(JSON.stringify(job)) as ReadingsJob;
|
|
68
|
+
function assertStateOutside(): void {
|
|
69
|
+
for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
|
|
70
|
+
const inside = relative(root, options.stateDir);
|
|
71
|
+
if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new ReadingsError('READINGS_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Whether this rigc's `render --help` lists `--environment`, asked once per compiler. */
|
|
75
|
+
async function environmentFlag(compiler: InstalledTool): Promise<boolean> {
|
|
76
|
+
const key = `${compiler.bin}@${compiler.version}`;
|
|
77
|
+
if (!help.has(key)) { await mkdir(options.stateDir, { recursive: true, mode: 0o700 }); const run = await runTool(compiler.bin, ['render', '--help'], { cwd: options.stateDir }); help.set(key, helpHasEnvironment(run.lines)); }
|
|
78
|
+
return help.get(key)!;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function execute(job: ReadingsJob, compiler: InstalledTool, builds: { build: ReadingBuild; key: string; candidate: string }[], schedule: unknown): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
for (const [i, b] of builds.entries()) {
|
|
84
|
+
const run = job.runs[i]!;
|
|
85
|
+
for (const r of job.request.readings) {
|
|
86
|
+
const key = readingKey(r, job.request.animation, schedule, `${compiler.package}@${compiler.version}`, run.modelHash);
|
|
87
|
+
run.results.push(await runReading(r, job.request.animation, b.candidate, join(readingsRoot(options.stateDir, p.sourceHash), b.key, key), key, compiler, schedule));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try { await options.unchanged(); } catch { throw new ReadingsError('READINGS_INPUTS_CHANGED', 'the package changed while the readings ran'); }
|
|
91
|
+
job.status = 'done';
|
|
92
|
+
} catch (error) { job.status = 'failed'; job.error = error instanceof Error ? error.message : String(error); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Refusals come before anything is run; a refusal of the compiler's is an outcome (200), a malformed request a 400. */
|
|
96
|
+
async function start(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
97
|
+
const request = parseReadingsRequest(input);
|
|
98
|
+
assertStateOutside();
|
|
99
|
+
await options.unchanged();
|
|
100
|
+
if (!p.manifest.model) throw new ReadingsError('READINGS_NO_MODEL', 'the package names no compiled model; readings run on a compiled build');
|
|
101
|
+
const resolved = resolveCompiler(options.starts, READINGS_SINCE);
|
|
102
|
+
if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error } };
|
|
103
|
+
const compiler = resolved.compiler;
|
|
104
|
+
const modelPath = await safeFile(p.root, p.manifest.model), modelHash = sha256(await readFile(modelPath));
|
|
105
|
+
const latest = options.latest();
|
|
106
|
+
const flag = await environmentFlag(compiler), passed = !!request.environment && flag, schedule = passed ? environmentSchedule(request.environment!) : null;
|
|
107
|
+
const id = sha256(JSON.stringify([READINGS_SCHEMA, p.sourceHash, request, modelHash, latest?.id ?? null, `${compiler.package}@${compiler.version}`, passed]));
|
|
108
|
+
const current = jobs.get(id);
|
|
109
|
+
if (current && current.status !== 'failed') return { status: current.status === 'running' ? 202 : 200, body: snapshot(current) };
|
|
110
|
+
if (running) return { status: 200, body: { status: 'refused', error: `READINGS_BUSY: readings ${running} are still running` } };
|
|
111
|
+
const builds: { build: ReadingBuild; key: string; candidate: string }[] = [{ build: 'inspected', key: `inspected-${modelHash}`, candidate: dirname(modelPath) }];
|
|
112
|
+
if (latest && await exists(join(latest.build, 'skeleton.json'))) builds.push({ build: 'scratch', key: `scratch-${latest.id}`, candidate: latest.build });
|
|
113
|
+
const job: ReadingsJob = {
|
|
114
|
+
schema: READINGS_SCHEMA, id, status: 'running', error: null, request,
|
|
115
|
+
environment: { requested: !!request.environment, flag, passed },
|
|
116
|
+
compiler: { package: compiler.package, version: compiler.version },
|
|
117
|
+
runs: builds.map(b => ({ build: b.build, id: b.build === 'inspected' ? modelHash : latest!.id, modelHash: b.build === 'inspected' ? modelHash : latest!.modelHash, results: [] })),
|
|
118
|
+
};
|
|
119
|
+
jobs.set(id, job); running = id;
|
|
120
|
+
void execute(job, compiler, builds, schedule).finally(() => { if (running === id) running = null; });
|
|
121
|
+
return { status: 202, body: snapshot(job) };
|
|
122
|
+
}
|
|
123
|
+
function read(id: string): ReadingsJob {
|
|
124
|
+
const job = jobs.get(id);
|
|
125
|
+
if (!job) throw new ReadingsError('READINGS_NOT_FOUND', id);
|
|
126
|
+
return snapshot(job);
|
|
127
|
+
}
|
|
128
|
+
return { start, read, running: () => running };
|
|
129
|
+
}
|