@rig-c/parts 1.39.0 → 1.41.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/src/remesh.ts ADDED
@@ -0,0 +1,617 @@
1
+ /**
2
+ * `rig-parts remesh` (issue #495): re-fit ONE part's mesh of an existing, gated rig under a mesh entry the request
3
+ * declares, and write a new package revision beside the input — every other byte of the input carried over, the
4
+ * input never written. The producer side of a reviewer's mesh request (rig-c/review #45): a reviewer names a part and
5
+ * the bounds, and this package builds the mesh through its own modes and gates, so no mesh byte originates in the
6
+ * review tool.
7
+ *
8
+ * ## The request
9
+ *
10
+ * `--part <name>` and `--mesh <entry.json>`: the entry is what `config.json` would hold at `meshes.<name>` — a
11
+ * lattice (`grid`), an outline (`contour`) or an automatic mesh (`auto`) with the same `r`, `segments`, `rule`,
12
+ * `influences`… — and it is read by the config's own loader, spliced into the author's config, so every refusal it
13
+ * can draw is the config's (`CONFIG_*` at `meshes.<name>`). The declared bounds are that entry's: for `auto` its
14
+ * `sourceBounds`, `targets` (art fit, boundary deviation, smallest angle), `influences`, `budget`, `regions` and its
15
+ * `motion` gate; for `contour` its tolerance, margin, spacing and `maxEdge`; for `grid` its spacing. There is no
16
+ * second vocabulary for them, so there is no second loader.
17
+ *
18
+ * ## The steps, each one a refusal by name
19
+ *
20
+ * 1. **The input** ({@link readTree}): `--rig` holds `rig.json`, `motion.json`, `mesh_report.json` and `images/`, as
21
+ * `rig` writes them; `rig.json` and `mesh_report.json` must be the bytes this package's writer prints for what they
22
+ * parse to (`REMESH_INPUT_WRITTEN`), since the revision is spliced as text. `--out` must not exist
23
+ * (`REMESH_OUT_FRESH`): a revision is a new folder, and it is never the input.
24
+ * 2. **The target** ({@link targetProblems}): the part is a `mesh` attachment of the input rig and an entry of the
25
+ * config's `meshes` (`REMESH_TARGET_MESH`); no `linkedmesh` borrows its geometry and no animation keys a `deform`
26
+ * timeline on it (`REMESH_TARGET_CLOSED`) — either is bound to the vertex list the remesh replaces, so it is
27
+ * refused rather than left pointing into a mesh it no longer describes.
28
+ * 3. **The baseline** ({@link baselineProblems}): the config as written is built through the rig stage, and that
29
+ * build must reproduce the input's target — its attachment, its image and its `mesh_report.json` row — and the
30
+ * input's bones; for an `auto` request, its motion too, since the auto mode's motion gate is measured on the
31
+ * config's idle (`REMESH_BASELINE_MISMATCH`, naming the first difference). Nothing else is compared: the parts the
32
+ * config does not reproduce are the accepted repairs a whole rebuild would lose (issue #495's "Why"), and they are
33
+ * carried over as bytes.
34
+ * 4. **The candidate**: the config with the request spliced in at `meshes.<name>` is built through the rig stage —
35
+ * every mode's own checks and, for `auto`, its motion gate — and a refusal there is the request's
36
+ * (`REMESH_CANDIDATE_REFUSED`, quoting each problem, its measured figure with it). The candidate's bones, motion
37
+ * and target image must be the baseline's (`REMESH_REQUEST_HELD`): a request that adds a control bone or moves a
38
+ * key is no longer a remesh of one part.
39
+ * 5. **The splice**: the input's `rig.json` with the candidate's attachment at `skins.default.<name>.<name>`, and
40
+ * its `mesh_report.json` with the candidate's row in the target's place; every other file is the input's bytes.
41
+ * 6. **The preserve assertion** ({@link preservedProblems}): every file but those two byte-identical to the input's;
42
+ * `rig.json` byte-identical outside the target attachment's text; every other row of `mesh_report.json`
43
+ * identical (`REMESH_PRESERVED`, naming the file and the part that moved).
44
+ * 7. **The gate and check**: the revision goes through rigc's build gate, as `rig` writes (`REMESH_RIGC_GREEN`), and
45
+ * through `check` beside the input; every bar the input measured is measured on the revision, and no bar the
46
+ * input met is failed by it (`REMESH_CHECK_HELD`). `check`'s scope is the revision's scope, and the provenance
47
+ * quotes check's own physics line ({@link checkScope}) rather than presenting a green check as more than it read.
48
+ * 8. **The write**: `--out` receives the revision's files and {@link PROVENANCE_FILE} — spec {@link REMESH_SPEC}:
49
+ * the request, both versions, every input file's SHA-256, the target before and after, and both checks — with no
50
+ * path and no clock in it, so a second identical request writes the same bytes.
51
+ *
52
+ * Pure in the sense `src/` is held to: rigc runs as a process, but the process is injected, exactly as
53
+ * `src/build.ts` takes it.
54
+ */
55
+ import { createHash } from 'node:crypto';
56
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
57
+ import { dirname, join, relative, resolve, sep } from 'node:path';
58
+ import { checkStage, gateThroughRigc, type Log, rigStage } from './build.ts';
59
+ import { barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackShape, type PageEdges, rigcRanClause, type RigcRunner } from './check.ts';
60
+ import { loadConfigAndAnimations } from './config.ts';
61
+ import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
62
+ import { jsonValueSpan, scratchConfig } from './meshdraft.ts';
63
+ import { DEFAULT_IDLE_KEYS, type IdleKeys, rigJsonText } from './rig.ts';
64
+
65
+ /** The provenance file's spec. */
66
+ export const REMESH_SPEC = 'rig-parts-remesh/1';
67
+
68
+ /** The provenance file, at the revision's root beside `rig.json`. */
69
+ export const PROVENANCE_FILE = 'remesh.json';
70
+
71
+ /** The files `rig` writes that a revision must carry (`images/` is read as a folder). */
72
+ export const RIG_TREE_FILES = ['rig.json', 'motion.json', 'mesh_report.json'] as const;
73
+
74
+ /**
75
+ * The scope sentence the provenance carries (rig-c/review #41's addendum): `check`'s own physics line, quoted, so a
76
+ * green check is read with the scope check states for it — and nothing beyond what check states is claimed here.
77
+ */
78
+ export function checkScope(physics: string | null): string {
79
+ return physics === null ? 'check states no physics line: the rig declares no physics constraint, or has no idle' : `as check states it: ${physics}`;
80
+ }
81
+
82
+ export interface RemeshInput {
83
+ /** The input revision: the directory `rig` wrote (`rig.json`, `motion.json`, `mesh_report.json`, `images/`). */
84
+ rig: string;
85
+ /** The config the input was built from. */
86
+ config: string;
87
+ /** The directory holding `parts.json` and `parts/` the input was built from. */
88
+ parts: string;
89
+ /** The part to remesh: its slot and its attachment, as the rig stage names both. */
90
+ part: string;
91
+ /** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. */
92
+ mesh: string;
93
+ /** The new revision's directory; it must not exist. */
94
+ out: string;
95
+ idleKeys?: IdleKeys;
96
+ pageEdges?: PageEdges;
97
+ packShape?: PackShape;
98
+ requirements?: string;
99
+ }
100
+
101
+ /**
102
+ * Planted faults for the selftest (issue #495, bar 4) — never set by the CLI: `perturb` changes the first uv of the
103
+ * named part's attachment in the revision after the splice, and `skipPreserve` takes the preserve assertion out, so
104
+ * the selftest can show both that the assertion names the part and that its own independent reading does.
105
+ */
106
+ export interface RemeshPlant {
107
+ perturb?: string;
108
+ skipPreserve?: boolean;
109
+ }
110
+
111
+ /** The versions the provenance names: this package's and the rigc that gated the revision (its `--version` line). */
112
+ export interface RemeshVersions {
113
+ parts: string;
114
+ rigc: string;
115
+ }
116
+
117
+ /** A tree of files, by path relative to its root (`/`-separated), sorted by code unit. */
118
+ export type FileTree = Map<string, Uint8Array>;
119
+
120
+ /** One file of a tree, as the provenance lists it. */
121
+ export interface HashRow {
122
+ path: string;
123
+ bytes: number;
124
+ sha256: string;
125
+ }
126
+
127
+ /** The counts of one mesh attachment. */
128
+ export interface MeshCounts {
129
+ vertices: number;
130
+ triangles: number;
131
+ hull: number;
132
+ }
133
+
134
+ /** One side of the provenance's check block. */
135
+ export interface CheckSide {
136
+ pass: boolean;
137
+ measured: string[];
138
+ not_met: string[];
139
+ skipped: Array<{ bar: string; reason: string }>;
140
+ physics: string | null;
141
+ }
142
+
143
+ /** `remesh.json` (spec {@link REMESH_SPEC}). */
144
+ export interface RemeshProvenance {
145
+ spec: typeof REMESH_SPEC;
146
+ request: { part: string; mesh: unknown; mesh_sha256: string; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
147
+ versions: RemeshVersions;
148
+ input: { rig: HashRow[]; config_sha256: string; parts_json_sha256: string };
149
+ baseline: { reproduced: string[]; not_compared: string };
150
+ target: { before: MeshCounts; after: MeshCounts };
151
+ preserved: { identical: string[]; spliced: string[] };
152
+ revision: HashRow[];
153
+ check: { input: CheckSide; revision: CheckSide; scope: string };
154
+ }
155
+
156
+ export interface RemeshResult {
157
+ provenance: RemeshProvenance;
158
+ written: string[];
159
+ }
160
+
161
+ type Json = Record<string, unknown>;
162
+
163
+ const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
164
+
165
+ export function sha256(bytes: Uint8Array): string {
166
+ return createHash('sha256').update(bytes).digest('hex');
167
+ }
168
+
169
+ /** Every file under `root`, by `/`-separated relative path, in code-unit order. */
170
+ export function readTree(root: string): FileTree {
171
+ const out: Array<[string, Uint8Array]> = [];
172
+ const walk = (dir: string): void => {
173
+ for (const name of readdirSync(dir)) {
174
+ const p = join(dir, name);
175
+ if (statSync(p).isDirectory()) walk(p);
176
+ else out.push([relative(root, p).split(sep).join('/'), new Uint8Array(readFileSync(p))]);
177
+ }
178
+ };
179
+ walk(root);
180
+ out.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
181
+ return new Map(out);
182
+ }
183
+
184
+ export function hashRows(tree: FileTree): HashRow[] {
185
+ return [...tree].map(([path, bytes]) => ({ path, bytes: bytes.length, sha256: sha256(bytes) }));
186
+ }
187
+
188
+ const text = (b: Uint8Array): string => new TextDecoder().decode(b);
189
+ const bytesOf = (s: string): Uint8Array => new TextEncoder().encode(s);
190
+
191
+ /** The first leaf at which `a` and `b` differ, as `path: a vs b`, or null when they are equal as JSON. */
192
+ export function firstDifference(a: unknown, b: unknown, at = ''): string | null {
193
+ if (Array.isArray(a) && Array.isArray(b)) {
194
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
195
+ if (i >= a.length || i >= b.length) return `${at}[${i}]: ${i >= a.length ? 'absent' : JSON.stringify(a[i])} vs ${i >= b.length ? 'absent' : JSON.stringify(b[i])}`;
196
+ const d = firstDifference(a[i], b[i], `${at}[${i}]`);
197
+ if (d !== null) return d;
198
+ }
199
+ return null;
200
+ }
201
+ if (isRecord(a) && isRecord(b)) {
202
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
203
+ for (const k of keys) {
204
+ const p = at === '' ? k : `${at}.${k}`;
205
+ if (!(k in a) || !(k in b)) return `${p}: ${k in a ? JSON.stringify(a[k]) : 'absent'} vs ${k in b ? JSON.stringify(b[k]) : 'absent'}`;
206
+ const d = firstDifference(a[k], b[k], p);
207
+ if (d !== null) return d;
208
+ }
209
+ return null;
210
+ }
211
+ return JSON.stringify(a) === JSON.stringify(b) ? null : `${at === '' ? 'the value' : at}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
212
+ }
213
+
214
+ /** The target's attachment in a rig, or undefined: the rig stage names a part's slot and attachment after the part. */
215
+ export function targetAttachment(rig: Json, part: string): unknown {
216
+ const skins = rig.skins;
217
+ if (!isRecord(skins) || !isRecord(skins.default) || !isRecord(skins.default[part])) return undefined;
218
+ return (skins.default[part] as Json)[part];
219
+ }
220
+
221
+ export function meshCounts(att: unknown): MeshCounts {
222
+ const a = att as { uvs: number[]; triangles: number[]; hull: number };
223
+ return { vertices: a.uvs.length / 2, triangles: a.triangles.length / 3, hull: a.hull };
224
+ }
225
+
226
+ /**
227
+ * Step 2: the part is a mesh of the rig and an entry of the config, and nothing in the rig is bound to its vertex
228
+ * list — no `linkedmesh` borrowing it, no `deform` timeline on it. A physics constraint drives bones, which the remesh
229
+ * does not change,, so it is not a closure here; what check reads of it is check's to state ({@link checkScope}).
230
+ */
231
+ export function targetProblems(rig: Json, motion: Json, configMeshes: Readonly<Record<string, unknown>>, part: string): Problem[] {
232
+ const out: Problem[] = [];
233
+ const att = targetAttachment(rig, part);
234
+ if (!isRecord(att) || att.type !== 'mesh') {
235
+ out.push({ code: 'REMESH_TARGET_MESH', object: `rig.json skins.default.${part}.${part}`, detail: `is ${att === undefined ? 'absent' : isRecord(att) ? `a ${String(att.type)} attachment` : 'not an attachment'}; a mesh attachment is required — only a mesh is remeshed` });
236
+ }
237
+ if (!(part in configMeshes)) {
238
+ out.push({ code: 'REMESH_TARGET_MESH', object: `config meshes.${part}`, detail: `is absent (the config meshes ${Object.keys(configMeshes).join(', ') || 'nothing'}); the input's target must be a mesh the config states, so the baseline can reproduce it` });
239
+ }
240
+ const skins = isRecord(rig.skins) ? rig.skins : {};
241
+ for (const [skin, slots] of Object.entries(skins)) {
242
+ if (!isRecord(slots)) continue;
243
+ for (const [slot, atts] of Object.entries(slots)) {
244
+ if (!isRecord(atts)) continue;
245
+ for (const [name, a] of Object.entries(atts)) {
246
+ if (!isRecord(a) || a.source !== part || (a.type !== 'linkedmesh' && a.type !== 'mesh')) continue;
247
+ const from = typeof a.slot === 'string' ? a.slot : slot;
248
+ const fromSkin = typeof a.skin === 'string' ? a.skin : 'default';
249
+ if (from === part && fromSkin === 'default') {
250
+ out.push({ code: 'REMESH_TARGET_CLOSED', object: `rig.json skins.${skin}.${slot}.${name}`, detail: `is a linked mesh whose source is "${part}"; it borrows the vertex list the remesh replaces, so the request is refused rather than leaving it pointing into a mesh it no longer describes` });
251
+ }
252
+ }
253
+ }
254
+ }
255
+ const animations = isRecord(motion.animations) ? motion.animations : {};
256
+ for (const [anim, a] of Object.entries(animations)) {
257
+ const deform = isRecord(a) && Array.isArray(a.deform) ? a.deform : [];
258
+ deform.forEach((t: unknown, i: number) => {
259
+ if (isRecord(t) && t.slot === part && t.attachment === part && (t.skin === undefined || t.skin === 'default')) {
260
+ out.push({ code: 'REMESH_TARGET_CLOSED', object: `motion.json animations.${anim}.deform[${i}]`, detail: `keys a deform timeline on "${part}"; its vertices are indexed into the mesh the remesh replaces, so the request is refused rather than leaving the timeline indexing a different mesh` });
261
+ }
262
+ });
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /** A rig build's files, read off the directory `rigStage` wrote. */
268
+ export interface Built {
269
+ rig: Json;
270
+ rigText: string;
271
+ motionText: string;
272
+ report: Json[];
273
+ image: Uint8Array | null;
274
+ }
275
+
276
+ function readBuilt(dir: string, part: string): Built {
277
+ const rigText = readFileSync(join(dir, 'rig.json'), 'utf8');
278
+ const png = join(dir, 'images', `${part}.png`);
279
+ return {
280
+ rig: JSON.parse(rigText) as Json,
281
+ rigText,
282
+ motionText: readFileSync(join(dir, 'motion.json'), 'utf8'),
283
+ report: JSON.parse(readFileSync(join(dir, 'mesh_report.json'), 'utf8')) as Json[],
284
+ image: existsSync(png) ? new Uint8Array(readFileSync(png)) : null,
285
+ };
286
+ }
287
+
288
+ const rowOf = (report: readonly Json[], part: string): Json | undefined => report.find((r) => r.part === part);
289
+ const bytesEqual = (a: Uint8Array | null | undefined, b: Uint8Array | null | undefined): boolean => a !== null && a !== undefined && b !== null && b !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
290
+
291
+ /**
292
+ * Step 3: what the config as written reproduces of the input — the target's attachment, image and report row, the
293
+ * bones, and (for an `auto` request, whose motion gate reads the config's idle) the motion. Each difference names
294
+ * its first leaf.
295
+ */
296
+ export function baselineProblems(input: { rig: Json; motionText: string; report: readonly Json[]; image: Uint8Array | undefined }, base: Built, part: string, auto: boolean): Problem[] {
297
+ const out: Problem[] = [];
298
+ const miss = (object: string, what: string): void => {
299
+ out.push({ code: 'REMESH_BASELINE_MISMATCH', object, detail: `the config as written does not reproduce the input here (${what}); a target the producer cannot reproduce is not one it can re-fit with every other byte kept, so the input is refused rather than spliced` });
300
+ };
301
+ const att = firstDifference(targetAttachment(input.rig, part), targetAttachment(base.rig, part));
302
+ if (att !== null) miss(`rig.json skins.default.${part}.${part}`, `input vs rebuilt: ${att}`);
303
+ if (!bytesEqual(input.image, base.image)) miss(`images/${part}.png`, `input ${input.image === undefined ? 'absent' : `${input.image.length} bytes`} vs rebuilt ${base.image === null ? 'absent' : `${base.image.length} bytes`}${input.image !== undefined && base.image !== null ? ', not the same bytes' : ''}`);
304
+ const row = firstDifference(rowOf(input.report, part), rowOf(base.report, part));
305
+ if (row !== null) miss(`mesh_report.json row "${part}"`, `input vs rebuilt: ${row}`);
306
+ const bones = firstDifference(input.rig.bones, base.rig.bones, 'bones');
307
+ if (bones !== null) miss('rig.json bones', `input vs rebuilt: ${bones} — the target's weights bind to these bones`);
308
+ if (auto && input.motionText !== base.motionText) miss('motion.json', `input vs rebuilt: ${firstDifference(JSON.parse(input.motionText), JSON.parse(base.motionText)) ?? 'the same value, different bytes'} — an auto request's motion gate is measured on the config's idle, which must be the input's`);
309
+ return out;
310
+ }
311
+
312
+ /** Step 4's second half: what the request may not change beside the target's mesh. */
313
+ export function requestHeldProblems(base: Built, cand: Built, part: string): Problem[] {
314
+ const out: Problem[] = [];
315
+ const held = (object: string, what: string): void => {
316
+ out.push({ code: 'REMESH_REQUEST_HELD', object, detail: `the request changes it (${what}); a remesh changes one part's mesh and nothing else, so the request is refused — state it as a config change and rebuild instead` });
317
+ };
318
+ const bones = firstDifference(base.rig.bones, cand.rig.bones, 'bones');
319
+ if (bones !== null) held('rig.json bones', `rebuilt vs requested: ${bones}`);
320
+ if (base.motionText !== cand.motionText) held('motion.json', `rebuilt vs requested: ${firstDifference(JSON.parse(base.motionText), JSON.parse(cand.motionText)) ?? 'the same value, different bytes'}`);
321
+ if (!bytesEqual(base.image, cand.image)) held(`images/${part}.png`, 'the requested build draws the part from different bytes');
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * Step 5: the revision — the input tree with `rig.json`'s target attachment and `mesh_report.json`'s target row
327
+ * replaced. Every other entry is the input's own bytes, by reference.
328
+ */
329
+ export function splice(input: FileTree, part: string, attachment: unknown, row: Json): FileTree {
330
+ const rig = JSON.parse(text(input.get('rig.json') as Uint8Array)) as { skins: { default: Record<string, Record<string, unknown>> } };
331
+ rig.skins.default[part] = { ...rig.skins.default[part], [part]: attachment };
332
+ const report = (JSON.parse(text(input.get('mesh_report.json') as Uint8Array)) as Json[]).map((r) => (r.part === part ? row : r));
333
+ const out: FileTree = new Map(input);
334
+ out.set('rig.json', bytesOf(rigJsonText(rig)));
335
+ out.set('mesh_report.json', bytesOf(rigJsonText(report)));
336
+ return out;
337
+ }
338
+
339
+ /** The selftest's plant: the first uv of `part`'s attachment in the revision nudged by 1/1024. */
340
+ export function perturbed(tree: FileTree, part: string): FileTree {
341
+ const rig = JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json;
342
+ const att = targetAttachment(rig, part) as { uvs: number[] } | undefined;
343
+ if (att === undefined) throw new Error(`perturbed: the rig has no attachment "${part}"`);
344
+ att.uvs[0] += 1 / 1024;
345
+ return new Map(tree).set('rig.json', bytesOf(rigJsonText(rig)));
346
+ }
347
+
348
+ /**
349
+ * Where a rig's text differs outside the target attachment, by attachment (`skins.<skin>.<slot>.<attachment>`) or by
350
+ * top-level key, so a moved neighbour is named as the part it is.
351
+ */
352
+ function movedOutside(a: Json, b: Json, part: string): Array<{ where: string; diff: string }> {
353
+ const strip = (r: Json): Json => {
354
+ const c = JSON.parse(JSON.stringify(r)) as Json;
355
+ const def = (c.skins as Json | undefined)?.default as Record<string, Json> | undefined;
356
+ if (def?.[part] !== undefined) delete def[part][part];
357
+ return c;
358
+ };
359
+ const x = strip(a);
360
+ const y = strip(b);
361
+ const out: Array<{ where: string; diff: string }> = [];
362
+ for (const k of [...new Set([...Object.keys(x), ...Object.keys(y)])]) {
363
+ if (k !== 'skins') {
364
+ const d = firstDifference(x[k], y[k], k);
365
+ if (d !== null) out.push({ where: k, diff: d });
366
+ continue;
367
+ }
368
+ const sx = isRecord(x.skins) ? x.skins : {};
369
+ const sy = isRecord(y.skins) ? y.skins : {};
370
+ for (const skin of [...new Set([...Object.keys(sx), ...Object.keys(sy)])]) {
371
+ const px = isRecord(sx[skin]) ? (sx[skin] as Json) : {};
372
+ const py = isRecord(sy[skin]) ? (sy[skin] as Json) : {};
373
+ for (const slot of [...new Set([...Object.keys(px), ...Object.keys(py)])]) {
374
+ const ax = isRecord(px[slot]) ? (px[slot] as Json) : {};
375
+ const ay = isRecord(py[slot]) ? (py[slot] as Json) : {};
376
+ for (const name of [...new Set([...Object.keys(ax), ...Object.keys(ay)])]) {
377
+ const where = `skins.${skin}.${slot}.${name}`;
378
+ const d = firstDifference(ax[name], ay[name], where);
379
+ if (d !== null) out.push({ where, diff: d });
380
+ }
381
+ }
382
+ }
383
+ }
384
+ return out;
385
+ }
386
+
387
+ /**
388
+ * Step 6, the preserve assertion: every file of the input but `rig.json` and `mesh_report.json` byte-identical in the
389
+ * revision and none added or dropped; `rig.json` byte-identical outside the target attachment's text, every moved
390
+ * attachment named; every row of `mesh_report.json` but the target's identical, the moved part named.
391
+ */
392
+ export function preservedProblems(input: FileTree, revision: FileTree, part: string): Problem[] {
393
+ const out: Problem[] = [];
394
+ const moved = (object: string, detail: string): void => {
395
+ out.push({ code: 'REMESH_PRESERVED', object, detail: `${detail}; a remesh carries every byte but the target's mesh over from the input, so nothing is written` });
396
+ };
397
+ for (const path of [...new Set([...input.keys(), ...revision.keys()])].sort()) {
398
+ const a = input.get(path);
399
+ const b = revision.get(path);
400
+ if (a === undefined || b === undefined) {
401
+ moved(path, a === undefined ? 'is in the revision and not in the input' : 'is in the input and not in the revision');
402
+ continue;
403
+ }
404
+ if (path === 'rig.json') {
405
+ const ta = text(a);
406
+ const tb = text(b);
407
+ const sa = jsonValueSpan(ta, ['skins', 'default', part, part]);
408
+ const sb = jsonValueSpan(tb, ['skins', 'default', part, part]);
409
+ if (ta.slice(0, sa.start) === tb.slice(0, sb.start) && ta.slice(sa.end) === tb.slice(sb.end)) continue;
410
+ const where = movedOutside(JSON.parse(ta) as Json, JSON.parse(tb) as Json, part);
411
+ if (where.length === 0) moved('rig.json', `differs from the input outside skins.default.${part}.${part} in its bytes, though not in its value`);
412
+ for (const w of where) moved(`rig.json ${w.where}`, `differs from the input outside the target — input vs revision at ${w.diff}`);
413
+ continue;
414
+ }
415
+ if (path === 'mesh_report.json') {
416
+ const ra = JSON.parse(text(a)) as Json[];
417
+ const rb = JSON.parse(text(b)) as Json[];
418
+ const names = (r: Json[]): string => r.map((x) => String(x.part)).join(',');
419
+ if (names(ra) !== names(rb)) {
420
+ moved('mesh_report.json', `holds rows ${names(rb)} where the input holds ${names(ra)}`);
421
+ continue;
422
+ }
423
+ ra.forEach((x, i) => {
424
+ if (x.part === part) return;
425
+ const d = firstDifference(x, rb[i]);
426
+ if (d !== null) moved(`mesh_report.json row "${String(x.part)}"`, `differs from the input — input vs revision at ${d}`);
427
+ });
428
+ continue;
429
+ }
430
+ 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)}…)`);
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /** One side of the check block, read off a check report. */
436
+ export function checkSide(r: CheckReport): CheckSide {
437
+ return {
438
+ pass: r.figures.PASS === true,
439
+ measured: [...r.bars.measured],
440
+ not_met: barsNotMet(r.figures as unknown as Record<string, unknown>),
441
+ skipped: r.bars.skipped.map((s) => ({ bar: s.bar, reason: s.reason })),
442
+ physics: r.loopPhysics,
443
+ };
444
+ }
445
+
446
+ /** Step 7: every bar the input measured is measured on the revision, and no bar the input met is failed by it. */
447
+ export function checkHeldProblems(input: CheckSide, revision: CheckSide, problems: readonly Problem[]): Problem[] {
448
+ const out: Problem[] = [];
449
+ for (const bar of input.measured) {
450
+ if (!revision.measured.includes(bar)) {
451
+ const why = revision.skipped.find((s) => s.bar === bar)?.reason ?? 'not measured';
452
+ out.push({ code: 'REMESH_CHECK_HELD', object: `check bar ${bar}`, detail: `measured on the input and not on the revision (${why}); the revision is held to every bar the input was` });
453
+ }
454
+ }
455
+ for (const bar of revision.not_met) {
456
+ if (input.not_met.includes(bar)) continue;
457
+ const quoted = problems.filter((p) => p.code.includes(bar) || p.object.includes(bar)).map(problemLine);
458
+ out.push({ code: 'REMESH_CHECK_HELD', object: `check bar ${bar}`, detail: `met on the input and not on the revision${quoted.length > 0 ? `: ${quoted.join(' | ')}` : ''}; a remesh may not fail a bar its input meets` });
459
+ }
460
+ return out;
461
+ }
462
+
463
+ /** The request's mesh entry, read as JSON; its fields are the config loader's to judge. */
464
+ export function readMeshRequest(path: string): { entry: Json; bytes: Uint8Array } {
465
+ if (!existsSync(path)) throw new PartsError([{ code: 'REMESH_MESH_JSON', object: path, detail: 'no such file; the request\'s mesh entry (what config.json would hold at meshes.<part>) is required' }]);
466
+ const bytes = new Uint8Array(readFileSync(path));
467
+ let entry: unknown;
468
+ try {
469
+ entry = JSON.parse(text(bytes));
470
+ } catch (err) {
471
+ throw new PartsError([{ code: 'REMESH_MESH_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` }]);
472
+ }
473
+ if (!isRecord(entry)) throw new PartsError([{ code: 'REMESH_MESH_JSON', object: path, detail: `is ${Array.isArray(entry) ? 'an array' : JSON.stringify(entry)}; an object — one meshes.<part> entry — is required` }]);
474
+ return { entry, bytes };
475
+ }
476
+
477
+ /** Step 1's tree rules: the files `rig` writes are present, and the two the splice rewrites are this writer's bytes. */
478
+ export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
479
+ const out: Problem[] = [];
480
+ for (const f of RIG_TREE_FILES) {
481
+ if (!tree.has(f)) out.push({ code: 'REMESH_INPUT_PRESENT', object: `${root}/${f}`, detail: 'is absent; the input revision is the directory rig writes — rig.json, motion.json, mesh_report.json and images/' });
482
+ }
483
+ if (![...tree.keys()].some((k) => k.startsWith('images/'))) out.push({ code: 'REMESH_INPUT_PRESENT', object: `${root}/images/`, detail: 'holds no image; the input revision is the directory rig writes' });
484
+ for (const f of ['rig.json', 'mesh_report.json']) {
485
+ const b = tree.get(f);
486
+ if (b === undefined) continue;
487
+ let value: unknown;
488
+ try {
489
+ value = JSON.parse(text(b));
490
+ } catch (err) {
491
+ out.push({ code: 'REMESH_INPUT_WRITTEN', object: `${root}/${f}`, detail: `does not parse as JSON: ${(err as Error).message}` });
492
+ continue;
493
+ }
494
+ if (rigJsonText(value) !== text(b)) out.push({ code: 'REMESH_INPUT_WRITTEN', object: `${root}/${f}`, detail: 'is not the text this package\'s writer prints for its value; the revision is spliced as text, so an input reformatted by hand cannot be carried over byte for byte' });
495
+ }
496
+ return out;
497
+ }
498
+
499
+ /**
500
+ * The stage: steps 1–8 of the module header. Writes `input.out` only when every step is green; refuses with every
501
+ * problem of the first step that found one.
502
+ */
503
+ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: string, versions: RemeshVersions, scratch: string, log: Log, plant: RemeshPlant = {}): RemeshResult {
504
+ const part = input.part;
505
+ const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
506
+ const pageEdges = input.pageEdges ?? DEFAULT_PAGE_EDGES;
507
+ const packShape = input.packShape ?? DEFAULT_PACK_SHAPE;
508
+ // 1. The input, the out and the request.
509
+ const outAt = resolve(input.out);
510
+ const rigAt = resolve(input.rig);
511
+ if (existsSync(outAt)) throw new PartsError([{ code: 'REMESH_OUT_FRESH', object: input.out, detail: 'exists; a revision is written into a new directory, never over the input or an earlier revision' }]);
512
+ if (!existsSync(rigAt) || !statSync(rigAt).isDirectory()) throw new PartsError([{ code: 'REMESH_INPUT_PRESENT', object: input.rig, detail: 'is not a directory; the input revision is the directory rig writes' }]);
513
+ if (outAt.startsWith(`${rigAt}${sep}`)) throw new PartsError([{ code: 'REMESH_OUT_FRESH', object: input.out, detail: `is inside the input ${input.rig}; a revision is written beside the input, never into it` }]);
514
+ const tree = readTree(rigAt);
515
+ refuseIfAny(inputTreeProblems(tree, input.rig));
516
+ const request = readMeshRequest(input.mesh);
517
+ const configText = readFileSync(input.config, 'utf8');
518
+ const { config: cfg } = loadConfigAndAnimations(input.config);
519
+ const inRig = JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json;
520
+ const inMotionText = text(tree.get('motion.json') as Uint8Array);
521
+ const inReport = JSON.parse(text(tree.get('mesh_report.json') as Uint8Array)) as Json[];
522
+ // 2. The target.
523
+ refuseIfAny(targetProblems(inRig, JSON.parse(inMotionText) as Json, cfg.meshes, part));
524
+ // The request is the config's to judge: spliced in and loaded, before anything is built.
525
+ const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, request.entry]]));
526
+ loadConfigAndAnimations(candConfig);
527
+ const auto = 'auto' in request.entry;
528
+ const build = (name: string, config: string): Built => {
529
+ const out = join(scratch, name, 'rig');
530
+ rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
531
+ return readBuilt(out, part);
532
+ };
533
+ // 3. The baseline.
534
+ log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
535
+ let base: Built;
536
+ try {
537
+ base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
538
+ } catch (err) {
539
+ if (!(err instanceof PartsError)) throw err;
540
+ throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_BASELINE_MISMATCH', object: `${input.config} as written`, detail: `does not build, so it cannot reproduce the input: ${problemLine(p)}` })));
541
+ }
542
+ refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
543
+ log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
544
+ // 4. The candidate.
545
+ let cand: Built;
546
+ try {
547
+ cand = build('candidate', candConfig);
548
+ } catch (err) {
549
+ if (!(err instanceof PartsError)) throw err;
550
+ throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_CANDIDATE_REFUSED', object: `the request's meshes.${part} (${input.mesh})`, detail: `the producer refuses it, nothing is written: ${problemLine(p)}` })));
551
+ }
552
+ refuseIfAny(requestHeldProblems(base, cand, part));
553
+ const candRow = rowOf(cand.report, part);
554
+ if (candRow === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
555
+ const before = meshCounts(targetAttachment(inRig, part));
556
+ const after = meshCounts(targetAttachment(cand.rig, part));
557
+ // 5–6. The splice and the preserve assertion.
558
+ let revision = splice(tree, part, targetAttachment(cand.rig, part), candRow);
559
+ if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
560
+ if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision, part));
561
+ const spliced = ['rig.json', 'mesh_report.json'];
562
+ const identical = [...tree.keys()].filter((k) => !spliced.includes(k));
563
+ log(` ${part}: v=${before.vertices} t=${before.triangles} hull=${before.hull} -> v=${after.vertices} t=${after.triangles} hull=${after.hull}`);
564
+ log(` preserved: ${identical.length} file(s) byte-identical to the input; rig.json identical outside skins.default.${part}.${part}; mesh_report.json identical but the "${part}" row`);
565
+ // 7. The gate and check.
566
+ const pngs = [...revision].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
567
+ const texts = [...revision].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
568
+ const gate = gateThroughRigc(pngs, texts, rigc, join(scratch, 'gate'), { pageEdges, packShape });
569
+ for (const g of gate) {
570
+ log(` rigc ${g.label}, the revision: exit ${g.status}`);
571
+ for (const l of g.lines) log(` ${l.trim()}`);
572
+ }
573
+ refuseIfAny(gate.filter((g) => g.status !== 0).map((g) => ({ code: 'REMESH_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` })));
574
+ const staged = join(scratch, 'revision');
575
+ for (const [k, b] of revision) {
576
+ mkdirSync(dirname(join(staged, k)), { recursive: true });
577
+ writeFileSync(join(staged, k), b);
578
+ }
579
+ const checkOf = (rig: string, name: string): CheckReport =>
580
+ checkStage({ rig, parts: input.parts, out: join(scratch, name), pageEdges, packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }) }, rigc, checkBin, (l) => log(`[${name}] ${l}`));
581
+ const inCheck = checkOf(rigAt, 'check-input');
582
+ const revCheck = checkOf(staged, 'check-revision');
583
+ const sides = { input: checkSide(inCheck), revision: checkSide(revCheck) };
584
+ refuseIfAny(checkHeldProblems(sides.input, sides.revision, revCheck.problems));
585
+ const scope = checkScope(sides.revision.physics);
586
+ 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}`);
587
+ // 8. The write.
588
+ const provenance: RemeshProvenance = {
589
+ spec: REMESH_SPEC,
590
+ request: { part, mesh: request.entry, mesh_sha256: sha256(request.bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
591
+ versions,
592
+ input: { rig: hashRows(tree), config_sha256: sha256(bytesOf(configText)), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
593
+ baseline: {
594
+ reproduced: [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])],
595
+ not_compared: 'every other attachment, image and report row: carried over from the input as bytes, whether or not the config reproduces it',
596
+ },
597
+ target: { before, after },
598
+ preserved: { identical, spliced },
599
+ revision: hashRows(revision),
600
+ check: { ...sides, scope },
601
+ };
602
+ const written: string[] = [];
603
+ mkdirSync(outAt, { recursive: true });
604
+ for (const [k, b] of revision) {
605
+ mkdirSync(dirname(join(outAt, k)), { recursive: true });
606
+ writeFileSync(join(outAt, k), b);
607
+ written.push(k);
608
+ }
609
+ writeFileSync(join(outAt, PROVENANCE_FILE), rigJsonText(provenance));
610
+ written.push(PROVENANCE_FILE);
611
+ log(' file sha256 (input) sha256 (revision)');
612
+ const inHash = new Map(hashRows(tree).map((r) => [r.path, r.sha256]));
613
+ 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)'}`);
614
+ log(`rig-parts remesh: wrote ${written.length} file(s) under ${input.out}, the provenance in ${PROVENANCE_FILE}`);
615
+ rmSync(staged, { recursive: true, force: true });
616
+ return { provenance, written };
617
+ }