@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/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 {
|
|
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
|
|
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:
|
|
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 ${
|
|
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
|
-
/**
|
|
447
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
478
|
-
export function
|
|
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:
|
|
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:
|
|
484
|
-
for (const f of
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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 },
|