@rig-c/parts 1.42.0 → 1.44.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/cli.ts +83 -3
- package/docs/AUTHORING.md +78 -0
- package/docs/STABILITY.md +5 -3
- package/package.json +1 -1
- package/src/remesh.ts +181 -56
- package/src/remeshdelta.ts +669 -0
- package/src/retune.ts +418 -0
package/src/retune.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-parts retune` (issue #527): splice the constraints and the idle a config states onto an existing, gated rig,
|
|
3
|
+
* and write a new package revision beside the input — every other byte of the input carried over, the input never
|
|
4
|
+
* written. The remesh (`src/remesh.ts`) of physics and motion: a reviewer's damping, wind, strength or
|
|
5
|
+
* idle-track change reaches the rig without a whole rebuild, which would drop every repair the config does not
|
|
6
|
+
* reproduce.
|
|
7
|
+
*
|
|
8
|
+
* ## The steps, each one a refusal by name
|
|
9
|
+
*
|
|
10
|
+
* 1. **The input** ({@link readTree}): `--rig` holds `rig.json`, `motion.json`, `mesh_report.json` and `images/`, as
|
|
11
|
+
* `rig` writes them; `rig.json` and `motion.json` must be the bytes this package's writer prints for what they
|
|
12
|
+
* parse to (`RETUNE_INPUT_WRITTEN`), since the revision is spliced as text. `--out` must not exist
|
|
13
|
+
* (`RETUNE_OUT_FRESH`).
|
|
14
|
+
* 2. **The rebuild**: the config as written is built through the rig stage — one build, every check it owns — and a
|
|
15
|
+
* refusal there is quoted (`RETUNE_CONFIG_REFUSED`).
|
|
16
|
+
* 3. **The hold** ({@link holdProblems}): the rebuild must state the input's `stops`, `bones` (names, parents,
|
|
17
|
+
* transforms, `inherit`), `slots`, `skeleton` and `images`, every image file, every `invariants` entry but
|
|
18
|
+
* `detached`, and every byte of `motion.json` but `animations.idle` (`RETUNE_REQUEST_HELD`, naming the first
|
|
19
|
+
* difference). A `stops` change rewires bones, slots and weights onto drawn links, so it is no retune. No
|
|
20
|
+
* attachment is compared: the input's meshes may be repairs the config does not reproduce, and they are carried
|
|
21
|
+
* over as bytes — so a `meshes` change in the config is never read into the revision (`remesh` is its producer).
|
|
22
|
+
* 4. **The splice** ({@link spliceRevision}): the input's `rig.json` with `constraints` and `invariants.detached`
|
|
23
|
+
* (derived from the constraints, rig.ts's `detachedRules`) the rebuild's, and its `motion.json` with
|
|
24
|
+
* `animations.idle` the rebuild's; every other file is the input's bytes.
|
|
25
|
+
* 5. **The preserve assertion** ({@link preservedProblems}): every file but those two byte-identical to the input's;
|
|
26
|
+
* `rig.json` identical outside `constraints` and `invariants.detached`, `motion.json` outside `animations.idle`
|
|
27
|
+
* (`RETUNE_PRESERVED`, naming the first path that moved).
|
|
28
|
+
* 6. **The gate and check**: the revision goes through rigc's build gate (`RETUNE_RIGC_GREEN`) and through `check`
|
|
29
|
+
* beside the input; every bar the input measured is measured, none the input met is failed (`RETUNE_CHECK_HELD`).
|
|
30
|
+
* 7. **The write**: `--out` receives the revision's files and {@link RETUNE_FILE} — spec {@link RETUNE_SPEC} — with
|
|
31
|
+
* no path and no clock in it, so a second identical request writes the same bytes.
|
|
32
|
+
*
|
|
33
|
+
* Pure in the sense `src/` is held to: rigc runs as a process, but the process is injected, as `src/build.ts` takes it.
|
|
34
|
+
*/
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
36
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
37
|
+
import { checkStage, gateThroughRigc, type Log, rigStage } from './build.ts';
|
|
38
|
+
import { type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackShape, type PageEdges, rigcRanClause, type RigcRunner } from './check.ts';
|
|
39
|
+
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
40
|
+
import { type CheckSide, checkHeldProblems, checkSide, checkScope, type FileTree, firstDifference, type HashRow, hashRows, inputTreeProblems, perturbed, readTree, sha256 } from './remesh.ts';
|
|
41
|
+
import { DEFAULT_IDLE_KEYS, type IdleKeys, rigJsonText } from './rig.ts';
|
|
42
|
+
|
|
43
|
+
/** The provenance file's spec. */
|
|
44
|
+
export const RETUNE_SPEC = 'rig-parts-retune/1';
|
|
45
|
+
|
|
46
|
+
/** The provenance file, at the revision's root beside `rig.json`. */
|
|
47
|
+
export const RETUNE_FILE = 'retune.json';
|
|
48
|
+
|
|
49
|
+
/** The two files the splice rewrites; every other file of the input is carried over as bytes. */
|
|
50
|
+
export const RETUNE_SPLICED = ['rig.json', 'motion.json'] as const;
|
|
51
|
+
|
|
52
|
+
/** What the hold compares between the input and the rebuild (step 3), as the provenance lists it. */
|
|
53
|
+
export const RETUNE_HELD = ['rig.json stops', 'rig.json bones', 'rig.json slots', 'rig.json skeleton', 'rig.json images', 'rig.json invariants but detached', 'images/', 'motion.json but animations.idle'] as const;
|
|
54
|
+
|
|
55
|
+
/** The provenance's sentence for what the hold does not compare. */
|
|
56
|
+
export const RETUNE_NOT_COMPARED =
|
|
57
|
+
'every attachment and every mesh_report.json row: carried over from the input as bytes, whether or not the config reproduces it; a meshes change in the config is never read into the revision (remesh is the producer of a mesh)';
|
|
58
|
+
|
|
59
|
+
export interface RetuneInput {
|
|
60
|
+
/** The input revision: the directory `rig` wrote (`rig.json`, `motion.json`, `mesh_report.json`, `images/`). */
|
|
61
|
+
rig: string;
|
|
62
|
+
/** The config whose constraints and idle the revision takes; every other field must reproduce the input. */
|
|
63
|
+
config: string;
|
|
64
|
+
/** The directory holding `parts.json` and `parts/` the input was built from. */
|
|
65
|
+
parts: string;
|
|
66
|
+
/** The new revision's directory; it must not exist. */
|
|
67
|
+
out: string;
|
|
68
|
+
idleKeys?: IdleKeys;
|
|
69
|
+
pageEdges?: PageEdges;
|
|
70
|
+
packShape?: PackShape;
|
|
71
|
+
requirements?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Planted faults for the selftest (issue #527) — never set by the CLI: `perturb` changes the first uv of the named
|
|
76
|
+
* part's attachment in the revision after the splice, `skipPreserve` takes the preserve assertion out and `skipHold`
|
|
77
|
+
* the hold, so the selftest can show what each one stops and what stops the revision without it.
|
|
78
|
+
*/
|
|
79
|
+
export interface RetunePlant {
|
|
80
|
+
perturb?: string;
|
|
81
|
+
skipPreserve?: boolean;
|
|
82
|
+
skipHold?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One constraint the request changed: its name and type, and each field whose value moved, in the rebuild's order. */
|
|
86
|
+
export interface ConstraintChange {
|
|
87
|
+
name: string;
|
|
88
|
+
type: string;
|
|
89
|
+
fields: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `retune.json` (spec {@link RETUNE_SPEC}). */
|
|
93
|
+
export interface RetuneProvenance {
|
|
94
|
+
spec: typeof RETUNE_SPEC;
|
|
95
|
+
request: { idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
|
|
96
|
+
versions: { parts: string; rigc: string };
|
|
97
|
+
input: { rig: HashRow[]; config_sha256: string; parts_json_sha256: string };
|
|
98
|
+
held: { compared: string[]; not_compared: string };
|
|
99
|
+
changed: { constraints: ConstraintChange[]; added: string[]; removed: string[]; idle: { tracks_changed: number; duration: [number, number] } };
|
|
100
|
+
preserved: { identical: string[]; spliced: string[] };
|
|
101
|
+
revision: HashRow[];
|
|
102
|
+
check: { input: CheckSide; revision: CheckSide; scope: string };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RetuneResult {
|
|
106
|
+
provenance: RetuneProvenance;
|
|
107
|
+
written: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type Json = Record<string, unknown>;
|
|
111
|
+
|
|
112
|
+
const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
113
|
+
const text = (b: Uint8Array): string => new TextDecoder().decode(b);
|
|
114
|
+
const bytesOf = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
115
|
+
const bytesEqual = (a: Uint8Array | undefined, b: Uint8Array | undefined): boolean => a !== undefined && b !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
|
|
116
|
+
|
|
117
|
+
/** A rig's files as the hold and the splice read them: `rig.json` and `motion.json` parsed, `images/` by file. */
|
|
118
|
+
export interface RigFiles {
|
|
119
|
+
rig: Json;
|
|
120
|
+
motion: Json;
|
|
121
|
+
images: Map<string, Uint8Array>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** {@link RigFiles} off a tree read by {@link readTree}. */
|
|
125
|
+
export function rigFiles(tree: FileTree): RigFiles {
|
|
126
|
+
const images = new Map<string, Uint8Array>();
|
|
127
|
+
for (const [k, b] of tree) if (k.startsWith('images/')) images.set(k, b);
|
|
128
|
+
return { rig: JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json, motion: JSON.parse(text(tree.get('motion.json') as Uint8Array)) as Json, images };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `invariants` without `detached`, or undefined when nothing else is in it. */
|
|
132
|
+
function invariantsBut(inv: unknown): Json | undefined {
|
|
133
|
+
if (!isRecord(inv)) return undefined;
|
|
134
|
+
const rest: Json = {};
|
|
135
|
+
for (const [k, v] of Object.entries(inv)) if (k !== 'detached') rest[k] = v;
|
|
136
|
+
return Object.keys(rest).length === 0 ? undefined : rest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `rig.json` without `constraints` and `invariants.detached` — what the preserve assertion holds byte for byte. */
|
|
140
|
+
export function rigOutsideRetune(rig: Json): Json {
|
|
141
|
+
const out: Json = {};
|
|
142
|
+
for (const [k, v] of Object.entries(rig)) {
|
|
143
|
+
if (k === 'constraints') continue;
|
|
144
|
+
if (k === 'invariants') {
|
|
145
|
+
const rest = invariantsBut(v);
|
|
146
|
+
if (rest !== undefined) out[k] = rest;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
out[k] = v;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `motion.json` without `animations.idle` — what the hold and the preserve assertion hold byte for byte. */
|
|
155
|
+
export function motionOutsideIdle(motion: Json): Json {
|
|
156
|
+
const out: Json = {};
|
|
157
|
+
for (const [k, v] of Object.entries(motion)) {
|
|
158
|
+
if (k !== 'animations' || !isRecord(v)) {
|
|
159
|
+
out[k] = v;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out[k] = Object.fromEntries(Object.entries(v).filter(([name]) => name !== 'idle'));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Step 3: what the rebuild must state as the input does. `stops` first, so a stops change is named as one rather than
|
|
169
|
+
* as the bones it rewires. Each difference names its first leaf; every one is collected.
|
|
170
|
+
*/
|
|
171
|
+
export function holdProblems(input: RigFiles, rebuilt: RigFiles): Problem[] {
|
|
172
|
+
const out: Problem[] = [];
|
|
173
|
+
const held = (object: string, what: string, why: string): void => {
|
|
174
|
+
out.push({ code: 'RETUNE_REQUEST_HELD', object, detail: `the config changes it (${what}); ${why}, so nothing is written — make it a rebuild with rig --config` });
|
|
175
|
+
};
|
|
176
|
+
const top = 'a retune replaces constraints and the idle and holds every other byte of the input';
|
|
177
|
+
const stops = firstDifference(input.rig.stops, rebuilt.rig.stops, 'stops');
|
|
178
|
+
if (stops !== null) held('rig.json stops', `input vs rebuilt: ${stops}`, 'a stops change rewires bones, slots and weights onto drawn links (<link>_d), which a retune holds');
|
|
179
|
+
for (const key of ['bones', 'slots', 'skeleton', 'images']) {
|
|
180
|
+
const d = firstDifference(input.rig[key], rebuilt.rig[key], key);
|
|
181
|
+
if (d !== null) held(`rig.json ${key}`, `input vs rebuilt: ${d}`, key === 'bones' ? `${top}; a moved, renamed, added or re-inherited bone (a <bone>_ctl among them) moves every weight bound to it` : top);
|
|
182
|
+
}
|
|
183
|
+
const inv = firstDifference(invariantsBut(input.rig.invariants), invariantsBut(rebuilt.rig.invariants), 'invariants');
|
|
184
|
+
if (inv !== null) held('rig.json invariants', `input vs rebuilt: ${inv}`, `${top}; only invariants.detached derives from the constraints`);
|
|
185
|
+
for (const path of [...new Set([...input.images.keys(), ...rebuilt.images.keys()])].sort()) {
|
|
186
|
+
const a = input.images.get(path);
|
|
187
|
+
const b = rebuilt.images.get(path);
|
|
188
|
+
if (bytesEqual(a, b)) continue;
|
|
189
|
+
held(path, a === undefined ? 'the rebuild writes it and the input has none' : b === undefined ? 'the input has it and the rebuild writes none' : `input ${a.length} bytes ${sha256(a).slice(0, 12)}… vs rebuilt ${b.length} bytes ${sha256(b).slice(0, 12)}…`, top);
|
|
190
|
+
}
|
|
191
|
+
const mi = motionOutsideIdle(input.motion);
|
|
192
|
+
const mr = motionOutsideIdle(rebuilt.motion);
|
|
193
|
+
if (rigJsonText(mi) !== rigJsonText(mr)) {
|
|
194
|
+
held('motion.json', `input vs rebuilt outside animations.idle: ${firstDifference(mi, mr) ?? 'the same value, different bytes'}`, `${top}; spec, archetype, cut, easings, groups and every animation but idle (animations_from, the face deform) are held`);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Step 4: the revision — the input tree with `rig.json`'s `constraints` and `invariants.detached` and `motion.json`'s
|
|
201
|
+
* `animations.idle` the rebuild's, each key in the place the rig stage writes it. Every other entry is the input's
|
|
202
|
+
* own bytes, by reference.
|
|
203
|
+
*/
|
|
204
|
+
export function spliceRevision(input: FileTree, rebuilt: RigFiles): FileTree {
|
|
205
|
+
const inRig = JSON.parse(text(input.get('rig.json') as Uint8Array)) as Json;
|
|
206
|
+
const inMotion = JSON.parse(text(input.get('motion.json') as Uint8Array)) as Json;
|
|
207
|
+
const detached = isRecord(rebuilt.rig.invariants) ? rebuilt.rig.invariants.detached : undefined;
|
|
208
|
+
const invariantsOf = (inv: unknown): Json | undefined => {
|
|
209
|
+
// The rig stage writes `detached` after `idleDrivesMeshes` and before `deformMayFold` and `axes` (src/rig.ts).
|
|
210
|
+
const rest = invariantsBut(inv) ?? {};
|
|
211
|
+
const out: Json = {};
|
|
212
|
+
if ('idleDrivesMeshes' in rest) out.idleDrivesMeshes = rest.idleDrivesMeshes;
|
|
213
|
+
if (detached !== undefined) out.detached = detached;
|
|
214
|
+
for (const [k, v] of Object.entries(rest)) if (k !== 'idleDrivesMeshes') out[k] = v;
|
|
215
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
216
|
+
};
|
|
217
|
+
const rig: Json = {};
|
|
218
|
+
const put = (k: string, v: unknown): void => {
|
|
219
|
+
if (v !== undefined) rig[k] = v;
|
|
220
|
+
};
|
|
221
|
+
for (const [k, v] of Object.entries(inRig)) {
|
|
222
|
+
if (k === 'constraints') put(k, rebuilt.rig.constraints);
|
|
223
|
+
else if (k === 'invariants') put(k, invariantsOf(v));
|
|
224
|
+
else rig[k] = v;
|
|
225
|
+
if (k === 'skins' && !('constraints' in inRig)) put('constraints', rebuilt.rig.constraints);
|
|
226
|
+
}
|
|
227
|
+
if (!('invariants' in inRig)) put('invariants', invariantsOf(undefined));
|
|
228
|
+
const animations = inMotion.animations as Json;
|
|
229
|
+
const motion: Json = { ...inMotion, animations: Object.fromEntries(Object.entries(animations).map(([k, v]) => [k, k === 'idle' ? (rebuilt.motion.animations as Json).idle : v])) };
|
|
230
|
+
const out: FileTree = new Map(input);
|
|
231
|
+
out.set('rig.json', bytesOf(rigJsonText(rig)));
|
|
232
|
+
out.set('motion.json', bytesOf(rigJsonText(motion)));
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Where two values differ by top-level key, or by attachment under `skins`, so a moved part is named as the part it is. */
|
|
237
|
+
function movedPaths(a: Json, b: Json): Array<{ where: string; diff: string }> {
|
|
238
|
+
const out: Array<{ where: string; diff: string }> = [];
|
|
239
|
+
for (const k of [...new Set([...Object.keys(a), ...Object.keys(b)])]) {
|
|
240
|
+
if (k !== 'skins' || !isRecord(a.skins) || !isRecord(b.skins)) {
|
|
241
|
+
const d = firstDifference(a[k], b[k], k);
|
|
242
|
+
if (d !== null) out.push({ where: k, diff: d });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const sa = a.skins;
|
|
246
|
+
const sb = b.skins;
|
|
247
|
+
for (const skin of [...new Set([...Object.keys(sa), ...Object.keys(sb)])]) {
|
|
248
|
+
const pa = isRecord(sa[skin]) ? (sa[skin] as Json) : {};
|
|
249
|
+
const pb = isRecord(sb[skin]) ? (sb[skin] as Json) : {};
|
|
250
|
+
for (const slot of [...new Set([...Object.keys(pa), ...Object.keys(pb)])]) {
|
|
251
|
+
const xa = isRecord(pa[slot]) ? (pa[slot] as Json) : {};
|
|
252
|
+
const xb = isRecord(pb[slot]) ? (pb[slot] as Json) : {};
|
|
253
|
+
for (const name of [...new Set([...Object.keys(xa), ...Object.keys(xb)])]) {
|
|
254
|
+
const where = `skins.${skin}.${slot}.${name}`;
|
|
255
|
+
const d = firstDifference(xa[name], xb[name], where);
|
|
256
|
+
if (d !== null) out.push({ where, diff: d });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Step 5, the preserve assertion: every file but `rig.json` and `motion.json` byte-identical in the revision and none
|
|
266
|
+
* added or dropped; `rig.json` identical outside `constraints` and `invariants.detached`, `motion.json` outside
|
|
267
|
+
* `animations.idle`, each moved path named.
|
|
268
|
+
*/
|
|
269
|
+
export function preservedProblems(input: FileTree, revision: FileTree): Problem[] {
|
|
270
|
+
const out: Problem[] = [];
|
|
271
|
+
const moved = (object: string, detail: string): void => {
|
|
272
|
+
out.push({ code: 'RETUNE_PRESERVED', object, detail: `${detail}; a retune carries every byte but the constraints and the idle over from the input, so nothing is written` });
|
|
273
|
+
};
|
|
274
|
+
const outside: Record<string, { strip: (v: Json) => Json; what: string }> = {
|
|
275
|
+
'rig.json': { strip: rigOutsideRetune, what: 'constraints and invariants.detached' },
|
|
276
|
+
'motion.json': { strip: motionOutsideIdle, what: 'animations.idle' },
|
|
277
|
+
};
|
|
278
|
+
for (const path of [...new Set([...input.keys(), ...revision.keys()])].sort()) {
|
|
279
|
+
const a = input.get(path);
|
|
280
|
+
const b = revision.get(path);
|
|
281
|
+
if (a === undefined || b === undefined) {
|
|
282
|
+
moved(path, a === undefined ? 'is in the revision and not in the input' : 'is in the input and not in the revision');
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const o = outside[path];
|
|
286
|
+
if (o === undefined) {
|
|
287
|
+
if (!bytesEqual(a, b)) moved(path, `is not the input's bytes (input ${a.length} bytes ${sha256(a).slice(0, 12)}…, revision ${b.length} bytes ${sha256(b).slice(0, 12)}…)`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const x = o.strip(JSON.parse(text(a)) as Json);
|
|
291
|
+
const y = o.strip(JSON.parse(text(b)) as Json);
|
|
292
|
+
if (rigJsonText(x) === rigJsonText(y)) continue;
|
|
293
|
+
const where = movedPaths(x, y);
|
|
294
|
+
if (where.length === 0) moved(path, `differs from the input outside ${o.what} in its bytes, though not in its value`);
|
|
295
|
+
for (const w of where) moved(`${path} ${w.where}`, `differs from the input outside ${o.what} — input vs revision at ${w.diff}`);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** What the request changed, for the provenance: constraints by name and field, and the idle's tracks and duration. */
|
|
301
|
+
export function changedOf(input: RigFiles, rebuilt: RigFiles): RetuneProvenance['changed'] {
|
|
302
|
+
const list = (r: Json): Json[] => (Array.isArray(r.constraints) ? (r.constraints as unknown[]).filter(isRecord) : []);
|
|
303
|
+
const before = new Map(list(input.rig).map((c) => [String(c.name), c]));
|
|
304
|
+
const after = list(rebuilt.rig);
|
|
305
|
+
const afterNames = new Set(after.map((c) => String(c.name)));
|
|
306
|
+
const constraints: ConstraintChange[] = [];
|
|
307
|
+
for (const c of after) {
|
|
308
|
+
const was = before.get(String(c.name));
|
|
309
|
+
if (was === undefined) continue;
|
|
310
|
+
const keys = [...new Set([...Object.keys(c), ...Object.keys(was)])];
|
|
311
|
+
const fields = keys.filter((k) => JSON.stringify(c[k]) !== JSON.stringify(was[k]));
|
|
312
|
+
if (fields.length > 0) constraints.push({ name: String(c.name), type: String(c.type), fields });
|
|
313
|
+
}
|
|
314
|
+
const idleOf = (m: Json): { duration: number; tracks: unknown[] } => (m.animations as Json).idle as { duration: number; tracks: unknown[] };
|
|
315
|
+
const ia = idleOf(input.motion);
|
|
316
|
+
const ib = idleOf(rebuilt.motion);
|
|
317
|
+
let tracks = 0;
|
|
318
|
+
for (let i = 0; i < Math.max(ia.tracks.length, ib.tracks.length); i++) if (JSON.stringify(ia.tracks[i]) !== JSON.stringify(ib.tracks[i])) tracks++;
|
|
319
|
+
return {
|
|
320
|
+
constraints,
|
|
321
|
+
added: after.map((c) => String(c.name)).filter((n) => !before.has(n)),
|
|
322
|
+
removed: [...before.keys()].filter((n) => !afterNames.has(n)),
|
|
323
|
+
idle: { tracks_changed: tracks, duration: [ia.duration, ib.duration] },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The stage: steps 1–7 of the module header. Writes `input.out` only when every step is green; refuses with every
|
|
329
|
+
* problem of the first step that found one.
|
|
330
|
+
*/
|
|
331
|
+
export function retuneStage(input: RetuneInput, rigc: RigcRunner, checkBin: string, versions: { parts: string; rigc: string }, scratch: string, log: Log, plant: RetunePlant = {}): RetuneResult {
|
|
332
|
+
const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
|
|
333
|
+
const pageEdges = input.pageEdges ?? DEFAULT_PAGE_EDGES;
|
|
334
|
+
const packShape = input.packShape ?? DEFAULT_PACK_SHAPE;
|
|
335
|
+
// 1. The input and the out.
|
|
336
|
+
const outAt = resolve(input.out);
|
|
337
|
+
const rigAt = resolve(input.rig);
|
|
338
|
+
if (existsSync(outAt)) throw new PartsError([{ code: 'RETUNE_OUT_FRESH', object: input.out, detail: 'exists; a revision is written into a new directory, never over the input or an earlier revision' }]);
|
|
339
|
+
if (!existsSync(rigAt) || !statSync(rigAt).isDirectory()) throw new PartsError([{ code: 'RETUNE_INPUT_PRESENT', object: input.rig, detail: 'is not a directory; the input revision is the directory rig writes' }]);
|
|
340
|
+
if (outAt.startsWith(`${rigAt}${sep}`)) throw new PartsError([{ code: 'RETUNE_OUT_FRESH', object: input.out, detail: `is inside the input ${input.rig}; a revision is written beside the input, never into it` }]);
|
|
341
|
+
const tree = readTree(rigAt);
|
|
342
|
+
refuseIfAny(inputTreeProblems(tree, input.rig, 'retune', RETUNE_SPLICED));
|
|
343
|
+
const inFiles = rigFiles(tree);
|
|
344
|
+
// 2. The rebuild.
|
|
345
|
+
log(`rig-parts retune: ${input.rig}, ${tree.size} file(s); the rebuild is the config as written`);
|
|
346
|
+
const rebuiltAt = join(scratch, 'rebuild', 'rig');
|
|
347
|
+
try {
|
|
348
|
+
rigStage({ config: input.config, parts: input.parts, out: rebuiltAt, idleKeys, pageEdges, packShape }, rigc, join(scratch, 'rebuild', 'gate'), (l) => log(`[rebuild:rig] ${l}`));
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (!(err instanceof PartsError)) throw err;
|
|
351
|
+
throw new PartsError(err.problems.map((p) => ({ code: 'RETUNE_CONFIG_REFUSED', object: `${input.config} as written`, detail: `the rig stage refuses it, nothing is written: ${problemLine(p)}` })));
|
|
352
|
+
}
|
|
353
|
+
const rebuilt = rigFiles(readTree(rebuiltAt));
|
|
354
|
+
// 3. The hold.
|
|
355
|
+
if (plant.skipHold !== true) refuseIfAny(holdProblems(inFiles, rebuilt));
|
|
356
|
+
log(` held: ${RETUNE_HELD.join(', ')} — the rebuild states each as the input does; no attachment is compared`);
|
|
357
|
+
const changed = changedOf(inFiles, rebuilt);
|
|
358
|
+
// 4–5. The splice and the preserve assertion.
|
|
359
|
+
let revision = spliceRevision(tree, rebuilt);
|
|
360
|
+
if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
|
|
361
|
+
if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision));
|
|
362
|
+
const spliced: string[] = [...RETUNE_SPLICED];
|
|
363
|
+
const identical = [...tree.keys()].filter((k) => !spliced.includes(k));
|
|
364
|
+
for (const c of changed.constraints) log(` constraint ${c.name} (${c.type}): ${c.fields.join(', ')} changed`);
|
|
365
|
+
if (changed.added.length > 0) log(` constraints added: ${changed.added.join(', ')}`);
|
|
366
|
+
if (changed.removed.length > 0) log(` constraints removed: ${changed.removed.join(', ')}`);
|
|
367
|
+
log(` idle: ${changed.idle.tracks_changed} track(s) changed, duration ${changed.idle.duration[0]} -> ${changed.idle.duration[1]}`);
|
|
368
|
+
log(` preserved: ${identical.length} file(s) byte-identical to the input; rig.json identical outside constraints and invariants.detached; motion.json identical outside animations.idle`);
|
|
369
|
+
// 6. The gate and check.
|
|
370
|
+
const pngs = [...revision].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
|
|
371
|
+
const texts = [...revision].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
|
|
372
|
+
const gate = gateThroughRigc(pngs, texts, rigc, join(scratch, 'gate'), { pageEdges, packShape });
|
|
373
|
+
for (const g of gate) {
|
|
374
|
+
log(` rigc ${g.label}, the revision: exit ${g.status}`);
|
|
375
|
+
for (const l of g.lines) log(` ${l.trim()}`);
|
|
376
|
+
}
|
|
377
|
+
refuseIfAny(gate.filter((g) => g.status !== 0).map((g) => ({ code: 'RETUNE_RIGC_GREEN', object: `rigc ${g.label} over the revision`, detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(g)}; exit 0 is required before anything is written, and nothing was` })));
|
|
378
|
+
const staged = join(scratch, 'revision');
|
|
379
|
+
for (const [k, b] of revision) {
|
|
380
|
+
mkdirSync(dirname(join(staged, k)), { recursive: true });
|
|
381
|
+
writeFileSync(join(staged, k), b);
|
|
382
|
+
}
|
|
383
|
+
const checkOf = (rig: string, name: string): CheckReport =>
|
|
384
|
+
checkStage({ rig, parts: input.parts, out: join(scratch, name), pageEdges, packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }) }, rigc, checkBin, (l) => log(`[${name}] ${l}`));
|
|
385
|
+
const inCheck = checkOf(rigAt, 'check-input');
|
|
386
|
+
const revCheck = checkOf(staged, 'check-revision');
|
|
387
|
+
const sides = { input: checkSide(inCheck), revision: checkSide(revCheck) };
|
|
388
|
+
refuseIfAny(checkHeldProblems(sides.input, sides.revision, revCheck.problems, 'retune'));
|
|
389
|
+
const scope = checkScope(sides.revision.physics);
|
|
390
|
+
log(` check: input ${sides.input.pass ? 'PASS' : `FAIL (${sides.input.not_met.join(', ')})`}, revision ${sides.revision.pass ? 'PASS' : `FAIL (${sides.revision.not_met.join(', ')})`}, the same ${sides.revision.measured.length} bar(s) measured; ${scope}`);
|
|
391
|
+
// 7. The write.
|
|
392
|
+
const provenance: RetuneProvenance = {
|
|
393
|
+
spec: RETUNE_SPEC,
|
|
394
|
+
request: { idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
|
|
395
|
+
versions,
|
|
396
|
+
input: { rig: hashRows(tree), config_sha256: sha256(new Uint8Array(readFileSync(input.config))), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
|
|
397
|
+
held: { compared: [...RETUNE_HELD], not_compared: RETUNE_NOT_COMPARED },
|
|
398
|
+
changed,
|
|
399
|
+
preserved: { identical, spliced },
|
|
400
|
+
revision: hashRows(revision),
|
|
401
|
+
check: { ...sides, scope },
|
|
402
|
+
};
|
|
403
|
+
const written: string[] = [];
|
|
404
|
+
mkdirSync(outAt, { recursive: true });
|
|
405
|
+
for (const [k, b] of revision) {
|
|
406
|
+
mkdirSync(dirname(join(outAt, k)), { recursive: true });
|
|
407
|
+
writeFileSync(join(outAt, k), b);
|
|
408
|
+
written.push(k);
|
|
409
|
+
}
|
|
410
|
+
writeFileSync(join(outAt, RETUNE_FILE), rigJsonText(provenance));
|
|
411
|
+
written.push(RETUNE_FILE);
|
|
412
|
+
log(' file sha256 (input) sha256 (revision)');
|
|
413
|
+
const inHash = new Map(hashRows(tree).map((r) => [r.path, r.sha256]));
|
|
414
|
+
for (const r of provenance.revision) log(` ${r.path.padEnd(40)} ${(inHash.get(r.path) ?? 'absent').slice(0, 16)} ${r.sha256.slice(0, 16)}${inHash.get(r.path) === r.sha256 ? '' : ' (spliced)'}`);
|
|
415
|
+
log(`rig-parts retune: wrote ${written.length} file(s) under ${input.out}, the provenance in ${RETUNE_FILE}`);
|
|
416
|
+
rmSync(staged, { recursive: true, force: true });
|
|
417
|
+
return { provenance, written };
|
|
418
|
+
}
|