@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/src/remesh.ts CHANGED
@@ -15,6 +15,11 @@
15
15
  * `motion` gate; for `contour` its tolerance, margin, spacing and `maxEdge`; for `grid` its spacing. There is no
16
16
  * second vocabulary for them, so there is no second loader.
17
17
  *
18
+ * `--delta <mesh-edit.json>` (issue #526), in place of `--mesh`: rig-c/review's mesh edit, ops over the input's own
19
+ * mesh, replayed by `src/remeshdelta.ts`. That path builds no config entry — steps 3 and 4 become the source checks
20
+ * (the edit's counts and `source.hash`, the SHA-256 of the part's entry in rigc's model document over the input) and
21
+ * the replay — and steps 5 to 8 are the same.
22
+ *
18
23
  * ## The steps, each one a refusal by name
19
24
  *
20
25
  * 1. **The input** ({@link readTree}): `--rig` holds `rig.json`, `motion.json`, `mesh_report.json` and `images/`, as
@@ -55,12 +60,16 @@
55
60
  import { createHash } from 'node:crypto';
56
61
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
57
62
  import { dirname, join, relative, resolve, sep } from 'node:path';
58
- import { checkStage, gateThroughRigc, type Log, rigStage } from './build.ts';
63
+ import { checkStage, gateThroughRigc, type Log, RIGC_MODEL_DOCUMENT, rigStage } from './build.ts';
59
64
  import { barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackShape, type PageEdges, rigcRanClause, type RigcRunner } from './check.ts';
60
65
  import { loadConfigAndAnimations } from './config.ts';
61
66
  import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
62
67
  import { jsonValueSpan, scratchConfig } from './meshdraft.ts';
63
- import { DEFAULT_IDLE_KEYS, type IdleKeys, rigJsonText } from './rig.ts';
68
+ import { computeExactFrameTransforms } from './coords.ts';
69
+ import { type PartsFile } from './parts.ts';
70
+ import { applyMeshEdit, type DeltaApplied, type DeltaPlant, deltaSourceProblems, type MeshEditDocument, readMeshEdit } from './remeshdelta.ts';
71
+ import { DEFAULT_IDLE_KEYS, type IdleKeys, type MeshAttachment, rigJsonText, type RigSpec } from './rig.ts';
72
+ import { pyRound } from './round.ts';
64
73
 
65
74
  /** The provenance file's spec. */
66
75
  export const REMESH_SPEC = 'rig-parts-remesh/1';
@@ -88,8 +97,10 @@ export interface RemeshInput {
88
97
  parts: string;
89
98
  /** The part to remesh: its slot and its attachment, as the rig stage names both. */
90
99
  part: string;
91
- /** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. */
92
- mesh: string;
100
+ /** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. Exactly one of `mesh`, `delta`. */
101
+ mesh?: string;
102
+ /** The request as rig-c/review's mesh edit (`rig-review-mesh-edit/1`, issue #526): ops over the input's own mesh. Exactly one of `mesh`, `delta`. */
103
+ delta?: string;
93
104
  /** The new revision's directory; it must not exist. */
94
105
  out: string;
95
106
  idleKeys?: IdleKeys;
@@ -106,6 +117,8 @@ export interface RemeshInput {
106
117
  export interface RemeshPlant {
107
118
  perturb?: string;
108
119
  skipPreserve?: boolean;
120
+ /** Issue #526's mutants, handed to the delta's replay (`src/remeshdelta.ts`). */
121
+ delta?: DeltaPlant;
109
122
  }
110
123
 
111
124
  /** The versions the provenance names: this package's and the rigc that gated the revision (its `--version` line). */
@@ -143,7 +156,9 @@ export interface CheckSide {
143
156
  /** `remesh.json` (spec {@link REMESH_SPEC}). */
144
157
  export interface RemeshProvenance {
145
158
  spec: typeof REMESH_SPEC;
146
- request: { part: string; mesh: unknown; mesh_sha256: string; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
159
+ request:
160
+ | { part: string; mesh: unknown; mesh_sha256: string; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape }
161
+ | { part: string; delta: MeshEditDocument; delta_sha256: string; ops: number; applied: Omit<DeltaApplied, 'ops'>; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
147
162
  versions: RemeshVersions;
148
163
  input: { rig: HashRow[]; config_sha256: string; parts_json_sha256: string };
149
164
  baseline: { reproduced: string[]; not_compared: string };
@@ -232,7 +247,7 @@ export function targetProblems(rig: Json, motion: Json, configMeshes: Readonly<R
232
247
  const out: Problem[] = [];
233
248
  const att = targetAttachment(rig, part);
234
249
  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` });
250
+ out.push({ code: 'REMESH_TARGET_MESH', object: `rig.json skins.default.${part}.${part}`, detail: `is ${att === undefined ? 'absent' : isRecord(att) ? `a ${typeof att.type === 'string' ? att.type : 'region'} attachment` : 'not an attachment'}; a mesh attachment is required — only a mesh is remeshed` });
236
251
  }
237
252
  if (!(part in configMeshes)) {
238
253
  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` });
@@ -432,6 +447,12 @@ export function preservedProblems(input: FileTree, revision: FileTree, part: str
432
447
  return out;
433
448
  }
434
449
 
450
+ /** The codes the shared readings below refuse with, by the revising command that runs them (`remesh`, `retune`). */
451
+ export const REVISION_CODES = {
452
+ remesh: { inputPresent: 'REMESH_INPUT_PRESENT', inputWritten: 'REMESH_INPUT_WRITTEN', checkHeld: 'REMESH_CHECK_HELD' },
453
+ retune: { inputPresent: 'RETUNE_INPUT_PRESENT', inputWritten: 'RETUNE_INPUT_WRITTEN', checkHeld: 'RETUNE_CHECK_HELD' },
454
+ } as const;
455
+
435
456
  /** One side of the check block, read off a check report. */
436
457
  export function checkSide(r: CheckReport): CheckSide {
437
458
  return {
@@ -443,19 +464,23 @@ export function checkSide(r: CheckReport): CheckSide {
443
464
  };
444
465
  }
445
466
 
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[] {
467
+ /**
468
+ * Step 7: every bar the input measured is measured on the revision, and no bar the input met is failed by it. `op`
469
+ * names the revising command (`retune` reuses this reading under its own code, `RETUNE_CHECK_HELD`).
470
+ */
471
+ export function checkHeldProblems(input: CheckSide, revision: CheckSide, problems: readonly Problem[], op: 'remesh' | 'retune' = 'remesh'): Problem[] {
472
+ const code = REVISION_CODES[op].checkHeld;
448
473
  const out: Problem[] = [];
449
474
  for (const bar of input.measured) {
450
475
  if (!revision.measured.includes(bar)) {
451
476
  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` });
477
+ out.push({ code, 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
478
  }
454
479
  }
455
480
  for (const bar of revision.not_met) {
456
481
  if (input.not_met.includes(bar)) continue;
457
482
  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` });
483
+ out.push({ code, object: `check bar ${bar}`, detail: `met on the input and not on the revision${quoted.length > 0 ? `: ${quoted.join(' | ')}` : ''}; a ${op} may not fail a bar its input meets` });
459
484
  }
460
485
  return out;
461
486
  }
@@ -474,28 +499,84 @@ export function readMeshRequest(path: string): { entry: Json; bytes: Uint8Array
474
499
  return { entry, bytes };
475
500
  }
476
501
 
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[] {
502
+ /** The request as rig-c/review's mesh edit (issue #526), read and held to its shape (`src/remeshdelta.ts`). */
503
+ export function readDeltaRequest(path: string): { doc: MeshEditDocument; bytes: Uint8Array } {
504
+ if (!existsSync(path)) throw new PartsError([{ code: 'REMESH_DELTA_JSON', object: path, detail: 'no such file; the request\'s mesh edit (rig-c/review\'s rig-review-mesh-edit/1 document) is required' }]);
505
+ const bytes = new Uint8Array(readFileSync(path));
506
+ const { doc, problems } = readMeshEdit(text(bytes), path);
507
+ refuseIfAny(problems);
508
+ return { doc: doc as MeshEditDocument, bytes };
509
+ }
510
+
511
+ /**
512
+ * The hash rig-c/review binds a mesh edit to (`source.hash`): SHA-256 of `JSON.stringify` of the attachment's entry in
513
+ * rigc's model document (`skins[name "default"].attachments.<part>.<part>`), or null when the document holds none.
514
+ */
515
+ export function modelAttachmentHash(modelText: string, part: string): string | null {
516
+ const model = JSON.parse(modelText) as { skins?: unknown };
517
+ const skin = Array.isArray(model.skins) ? (model.skins as unknown[]).find((s) => isRecord(s) && s.name === 'default') : undefined;
518
+ const atts = isRecord(skin) && isRecord(skin.attachments) ? skin.attachments : undefined;
519
+ const slot = atts !== undefined && isRecord(atts[part]) ? (atts[part] as Json) : undefined;
520
+ const entry = slot?.[part];
521
+ return entry === undefined ? null : createHash('sha256').update(JSON.stringify(entry)).digest('hex');
522
+ }
523
+
524
+ /**
525
+ * Step 1's tree rules: the files `rig` writes are present, and the ones the splice rewrites (`spliced`) are this
526
+ * writer's bytes. `op` names the revising command, whose codes these are (`retune` splices `rig.json` and `motion.json`).
527
+ */
528
+ export function inputTreeProblems(tree: FileTree, root: string, op: 'remesh' | 'retune' = 'remesh', spliced: readonly string[] = ['rig.json', 'mesh_report.json']): Problem[] {
529
+ const present = REVISION_CODES[op].inputPresent;
530
+ const writtenCode = REVISION_CODES[op].inputWritten;
479
531
  const out: Problem[] = [];
480
532
  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/' });
533
+ if (!tree.has(f)) out.push({ code: 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
534
  }
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']) {
535
+ if (![...tree.keys()].some((k) => k.startsWith('images/'))) out.push({ code: present, object: `${root}/images/`, detail: 'holds no image; the input revision is the directory rig writes' });
536
+ for (const f of spliced) {
485
537
  const b = tree.get(f);
486
538
  if (b === undefined) continue;
487
539
  let value: unknown;
488
540
  try {
489
541
  value = JSON.parse(text(b));
490
542
  } catch (err) {
491
- out.push({ code: 'REMESH_INPUT_WRITTEN', object: `${root}/${f}`, detail: `does not parse as JSON: ${(err as Error).message}` });
543
+ out.push({ code: writtenCode, object: `${root}/${f}`, detail: `does not parse as JSON: ${(err as Error).message}` });
492
544
  continue;
493
545
  }
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' });
546
+ if (rigJsonText(value) !== text(b)) out.push({ code: writtenCode, 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
547
  }
496
548
  return out;
497
549
  }
498
550
 
551
+ /** A tree's images as the gate takes them (name under `images/`, bytes). */
552
+ const pngsOf = (tree: FileTree): Array<readonly [string, Uint8Array]> => [...tree].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
553
+ /** A tree's other files as text. */
554
+ const textsOf = (tree: FileTree): Array<readonly [string, string]> => [...tree].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
555
+
556
+ /**
557
+ * The delta's `mesh_report.json` row (issue #526): the input's row, key for key, with `vertices`, `triangles`, `hull`,
558
+ * `bones` (the sorted set the new weights name), `max_influences` and `mean_influences` recomputed from the new
559
+ * attachment and `mode` "delta" (in place, or last when the row had none). Every other key is the input's and
560
+ * describes the mesh the delta was drawn over.
561
+ */
562
+ export function deltaRow(row: Json, att: MeshAttachment): Json {
563
+ const n = att.uvs.length / 2;
564
+ const entries = att.weights.reduce((s, w) => s + w.length, 0);
565
+ const fresh: Json = {
566
+ vertices: n,
567
+ triangles: att.triangles.length / 3,
568
+ hull: att.hull,
569
+ bones: [...new Set(att.weights.flatMap((w) => w.map((e) => e.bone)))].sort(),
570
+ max_influences: Math.max(...att.weights.map((w) => w.length)),
571
+ mean_influences: pyRound(entries / n, 2),
572
+ mode: 'delta',
573
+ };
574
+ const out: Json = {};
575
+ for (const [k, v] of Object.entries(row)) out[k] = k in fresh ? fresh[k] : v;
576
+ if (!('mode' in out)) out.mode = 'delta';
577
+ return out;
578
+ }
579
+
499
580
  /**
500
581
  * The stage: steps 1–8 of the module header. Writes `input.out` only when every step is green; refuses with every
501
582
  * problem of the first step that found one.
@@ -513,7 +594,9 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
513
594
  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
595
  const tree = readTree(rigAt);
515
596
  refuseIfAny(inputTreeProblems(tree, input.rig));
516
- const request = readMeshRequest(input.mesh);
597
+ if ((input.mesh === undefined) === (input.delta === undefined)) throw new Error('remeshStage: exactly one of mesh, delta is required');
598
+ const meshReq = input.mesh === undefined ? null : readMeshRequest(input.mesh);
599
+ const deltaReq = input.delta === undefined ? null : readDeltaRequest(input.delta);
517
600
  const configText = readFileSync(input.config, 'utf8');
518
601
  const { config: cfg } = loadConfigAndAnimations(input.config);
519
602
  const inRig = JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json;
@@ -521,41 +604,85 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
521
604
  const inReport = JSON.parse(text(tree.get('mesh_report.json') as Uint8Array)) as Json[];
522
605
  // 2. The target.
523
606
  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
607
  const before = meshCounts(targetAttachment(inRig, part));
556
- const after = meshCounts(targetAttachment(cand.rig, part));
608
+ let candAtt: unknown;
609
+ let candRow: Json;
610
+ let reproduced: string[];
611
+ let deltaRequest: RemeshProvenance['request'] | null = null;
612
+ if (meshReq !== null) {
613
+ const meshPath = input.mesh as string;
614
+ // The request is the config's to judge: spliced in and loaded, before anything is built.
615
+ const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, meshReq.entry]]));
616
+ loadConfigAndAnimations(candConfig);
617
+ const auto = 'auto' in meshReq.entry;
618
+ const build = (name: string, config: string): Built => {
619
+ const out = join(scratch, name, 'rig');
620
+ rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
621
+ return readBuilt(out, part);
622
+ };
623
+ // 3. The baseline.
624
+ log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
625
+ let base: Built;
626
+ try {
627
+ base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
628
+ } catch (err) {
629
+ if (!(err instanceof PartsError)) throw err;
630
+ 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)}` })));
631
+ }
632
+ refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
633
+ log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
634
+ // 4. The candidate.
635
+ let cand: Built;
636
+ try {
637
+ cand = build('candidate', candConfig);
638
+ } catch (err) {
639
+ if (!(err instanceof PartsError)) throw err;
640
+ throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_CANDIDATE_REFUSED', object: `the request's meshes.${part} (${meshPath})`, detail: `the producer refuses it, nothing is written: ${problemLine(p)}` })));
641
+ }
642
+ refuseIfAny(requestHeldProblems(base, cand, part));
643
+ const row = rowOf(cand.report, part);
644
+ if (row === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
645
+ candAtt = targetAttachment(cand.rig, part);
646
+ candRow = row;
647
+ reproduced = [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])];
648
+ } else {
649
+ // 3–4 on the delta path (issue #526): no config entry to build, so the baseline is the input itself, held to the
650
+ // delta by its source counts and its source.hash, and the candidate is the delta replayed over the input's mesh.
651
+ const deltaPath = input.delta as string;
652
+ const { doc, bytes } = deltaReq as { doc: MeshEditDocument; bytes: Uint8Array };
653
+ const inAtt = targetAttachment(inRig, part) as MeshAttachment;
654
+ refuseIfAny(deltaSourceProblems(doc, part, inAtt, deltaPath));
655
+ log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the request is a mesh edit of ${doc.ops.length} op(s), the baseline is the input itself`);
656
+ let model = null as string | null;
657
+ const inGate = gateThroughRigc(pngsOf(tree), textsOf(tree), rigc, join(scratch, 'gate-input'), { pageEdges, packShape }, (dir) => {
658
+ const p = join(dir, RIGC_MODEL_DOCUMENT);
659
+ model = existsSync(p) ? readFileSync(p, 'utf8') : null;
660
+ });
661
+ refuseIfAny(inGate.filter((g) => g.status !== 0).map((g) => ({ code: 'REMESH_RIGC_GREEN', object: `rigc ${g.label} over the input`, detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(g)}; the delta's source.hash is read off the input's own build, and nothing was written` })));
662
+ if (model === null) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `rigc's build over the input`, detail: `wrote no ${RIGC_MODEL_DOCUMENT}, so the delta's source.hash cannot be verified; nothing was written` }]);
663
+ const hash = modelAttachmentHash(model, part);
664
+ if (hash !== doc.source.hash) {
665
+ throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${deltaPath} source.hash`, detail: `is ${doc.source.hash.slice(0, 16)}…; the input's ${part} in its ${RIGC_MODEL_DOCUMENT} hashes to ${hash === null ? 'nothing (no such entry)' : `${hash.slice(0, 16)}…`} — the delta was drawn over another mesh` }]);
666
+ }
667
+ log(` source: ${part}'s entry in the input's ${RIGC_MODEL_DOCUMENT} hashes to ${hash.slice(0, 16)}…, the delta's source.hash; counts v=${before.vertices} t=${before.triangles} hull=${before.hull} as it states`);
668
+ const partsFile = JSON.parse(readFileSync(join(input.parts, 'parts.json'), 'utf8')) as PartsFile;
669
+ const rec = partsFile.parts.find((p) => p.name === part);
670
+ if (rec === undefined) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${input.parts}/parts.json`, detail: `names no part "${part}"; its box is where rig placed the image, which the delta's crop px are read against` }]);
671
+ const world = computeExactFrameTransforms((inRig as unknown as RigSpec).bones);
672
+ const r = applyMeshEdit({ part, attachment: inAtt, box: [rec.x, rec.y], rigSize: [partsFile.rig_size[0], partsFile.rig_size[1]], world }, doc, deltaPath, plant.delta);
673
+ if (Array.isArray(r)) throw new PartsError(r);
674
+ const row = rowOf(inReport, part);
675
+ if (row === undefined) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${input.rig}/mesh_report.json`, detail: `holds no row "${part}"; the delta's row is the input's with its counts recomputed` }]);
676
+ candAtt = r.attachment;
677
+ candRow = deltaRow(row, r.attachment);
678
+ const { ops, ...applied } = r.applied;
679
+ log(` delta: ${ops} op(s) — moved ${applied.moved}, added ${applied.added}, removed ${applied.removed}, constrained ${applied.constrained}; ${r.untouched.length} source vertex(es) untouched; interior edges not locally Delaunay ${r.delaunay.before} -> ${r.delaunay.after}`);
680
+ reproduced = ['none built: the delta path builds no config entry, so the baseline is the input itself, held to the delta by source.vertexCount, source.triangleCount, source.hullLength and source.hash'];
681
+ deltaRequest = { part, delta: doc, delta_sha256: sha256(bytes), ops, applied, idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape };
682
+ }
683
+ const after = meshCounts(candAtt);
557
684
  // 5–6. The splice and the preserve assertion.
558
- let revision = splice(tree, part, targetAttachment(cand.rig, part), candRow);
685
+ let revision = splice(tree, part, candAtt, candRow);
559
686
  if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
560
687
  if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision, part));
561
688
  const spliced = ['rig.json', 'mesh_report.json'];
@@ -563,9 +690,7 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
563
690
  log(` ${part}: v=${before.vertices} t=${before.triangles} hull=${before.hull} -> v=${after.vertices} t=${after.triangles} hull=${after.hull}`);
564
691
  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
692
  // 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 });
693
+ const gate = gateThroughRigc(pngsOf(revision), textsOf(revision), rigc, join(scratch, 'gate'), { pageEdges, packShape });
569
694
  for (const g of gate) {
570
695
  log(` rigc ${g.label}, the revision: exit ${g.status}`);
571
696
  for (const l of g.lines) log(` ${l.trim()}`);
@@ -587,11 +712,11 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
587
712
  // 8. The write.
588
713
  const provenance: RemeshProvenance = {
589
714
  spec: REMESH_SPEC,
590
- request: { part, mesh: request.entry, mesh_sha256: sha256(request.bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
715
+ request: deltaRequest ?? { part, mesh: (meshReq as { entry: Json }).entry, mesh_sha256: sha256((meshReq as { bytes: Uint8Array }).bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
591
716
  versions,
592
717
  input: { rig: hashRows(tree), config_sha256: sha256(bytesOf(configText)), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
593
718
  baseline: {
594
- reproduced: [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])],
719
+ reproduced,
595
720
  not_compared: 'every other attachment, image and report row: carried over from the input as bytes, whether or not the config reproduces it',
596
721
  },
597
722
  target: { before, after },