@rig-c/parts 1.39.0 → 1.40.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 CHANGED
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
18
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, proposeSinglePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
19
19
  import { type AlternateCliInput, assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage, suppliedCheckFlag } from './src/build.ts';
20
- import { ACCEPTABLE_BARS, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, rigcRunnerAt, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, NO_PARTS_SENTENCE, SEAM_MEAN_BAR, SOURCE_SENTENCE, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
20
+ import { ACCEPTABLE_BARS, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, rigcRunnerAt, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, rigcRanClause, type RigcRunner, NO_PARTS_SENTENCE, SEAM_MEAN_BAR, SOURCE_SENTENCE, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
21
21
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
22
22
  import { type CharacterConfig, loadClothConfig, loadConfig, loadEarlyConfig, loadPaintConfig } from './src/config.ts';
23
23
  import { failLine, guidePointer, PartsError } from './src/errors.ts';
@@ -30,6 +30,7 @@ import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJo
30
30
  import { basisLines, branchSqueezeLine, checkProposal, compare, compareLines, DEFAULT_GRID_RULE, draftTurn, drawLandmarks, FIGURE_GRID_DIVISOR, FRINGE_STEP_PX, GRID_RULES, type GridRule, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
31
31
  import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
32
32
  import { BEND_DEGREES, BEND_WORST_SHARE, DUST_MAX_PX, DUST_MAX_SHARE, IDLE_BOUND_PX, MESH_DRAFT_REPORT, MESH_DRAFT_SPEC, meshDraftStage, MORE_TRIANGLES_FLAG, TOLERANCE_FIGURE_DIVISOR } from './src/meshdraft.ts';
33
+ import { PROVENANCE_FILE, REMESH_SPEC, remeshStage } from './src/remesh.ts';
33
34
  import { checkPainting } from './src/paintcheck.ts';
34
35
  import { readPng, writePng } from './src/raster/png.ts';
35
36
  import { REQUIREMENTS_SENTENCE } from './src/requirements.ts';
@@ -336,6 +337,35 @@ usage:
336
337
  Written only when the drafted config's check is PASS. --requirements, --idle-keys,
337
338
  --page-edges and --pack-shape are as for rig and check.
338
339
 
340
+ rig-parts remesh --rig <dir> --config <config.json> --parts <dir> --part <name>
341
+ --mesh <entry.json> --out <dir> [--requirements <file.json>]
342
+ [--idle-keys ctl|direct] [--page-edges pot|free] [--pack-shape rect|polygon]
343
+ Re-fit one part's mesh of a gated rig (the directory rig writes) under
344
+ the mesh entry --mesh holds — what config.json would hold at
345
+ meshes.<name>: a grid, a contour or an auto entry, its bounds the
346
+ entry's own — and write a new revision into --out, which must not
347
+ exist (REMESH_OUT_FRESH); the input is never written. --config and
348
+ --parts are what the input was built from: the config as written must
349
+ reproduce the input's part (its attachment, image and mesh_report.json
350
+ row) and its bones, and for an auto entry its motion
351
+ (REMESH_BASELINE_MISMATCH); every other part is carried over as bytes,
352
+ whether or not the config reproduces it. The part must be a mesh nothing
353
+ else is bound to — no linked mesh, no deform timeline
354
+ (REMESH_TARGET_MESH, REMESH_TARGET_CLOSED). The entry is built through
355
+ the rig stage, its mode's own checks and an auto entry's motion gate
356
+ (REMESH_CANDIDATE_REFUSED, quoting each), and may not move a bone, a
357
+ motion key or the part's image (REMESH_REQUEST_HELD). Every file but
358
+ rig.json and mesh_report.json is the input's bytes, rig.json is the
359
+ input's outside the part's attachment, and every other report row is
360
+ the input's (REMESH_PRESERVED); rigc's build gate passes over the
361
+ revision (REMESH_RIGC_GREEN); check measures every bar on it that it
362
+ measures on the input and fails none the input meets
363
+ (REMESH_CHECK_HELD). --out receives the revision and ${PROVENANCE_FILE}
364
+ (spec "${REMESH_SPEC}": the request, both versions, every input file's
365
+ SHA-256, the part's counts before and after, both checks and their
366
+ scope) — no path and no clock, so the same request writes the same bytes.
367
+ Prints the part's counts and a hash table of every file.
368
+
339
369
  rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
340
370
  [--page-edges pot|free] [--pack-shape rect|polygon]
341
371
  [--requirements <file.json>] [--seam-pairs] [--keep-stills]
@@ -799,6 +829,7 @@ const TAKES = {
799
829
  build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in', '--alt', '--set'], switches: ['--loop', '--seam-pairs', '--keep-stills', '--all-sets'] },
800
830
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
801
831
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
832
+ remesh: { valued: ['--rig', '--config', '--parts', '--part', '--mesh', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
802
833
  painting: { valued: ['--keypoints', '--person'], switches: [] },
803
834
  } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
804
835
 
@@ -1611,6 +1642,45 @@ function cmdMeshDraft(args: string[]): number {
1611
1642
  }
1612
1643
  }
1613
1644
 
1645
+ /** `remesh` (issue #495): one part of a gated rig re-fitted under a requested mesh entry, into a new revision. */
1646
+ function cmdRemesh(args: string[]): number {
1647
+ const f = flags(args, 'remesh', ['--rig', '--config', '--parts', '--part', '--mesh', '--out']);
1648
+ if (typeof f === 'string') return usage(f);
1649
+ const keys = idleKeysOf(f.get('--idle-keys'));
1650
+ if (!isIdleKeys(keys)) return usage(keys);
1651
+ const edges = pageEdgesOf(f.get('--page-edges'));
1652
+ if (!isPageEdges(edges)) return usage(edges);
1653
+ const shape = packShapeOf(f.get('--pack-shape'));
1654
+ if (!isPackShape(shape)) return usage(shape);
1655
+ let bin: string;
1656
+ try {
1657
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
1658
+ } catch (err) {
1659
+ return printRefusal(err);
1660
+ }
1661
+ const runner = rigcRunner(bin);
1662
+ const said = runner(['--version']);
1663
+ const rigcVersion = said.status === 0 ? (said.out.trim().split('\n')[0] ?? '') : '';
1664
+ if (rigcVersion === '') return printRefusal(new PartsError([{ code: 'REMESH_RIGC_GREEN', object: 'rigc --version', detail: `exited ${said.status}${rigcRanClause(said)}; the provenance names the rigc that gated the revision, so its version is required` }]));
1665
+ const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-remesh-'));
1666
+ try {
1667
+ const req = f.get('--requirements');
1668
+ remeshStage(
1669
+ { rig: f.get('--rig') as string, config: f.get('--config') as string, parts: f.get('--parts') as string, part: f.get('--part') as string, mesh: f.get('--mesh') as string, out: f.get('--out') as string, idleKeys: keys, pageEdges: edges, packShape: shape, ...(req === undefined ? {} : { requirements: req }) },
1670
+ runner,
1671
+ bin,
1672
+ { parts: version(), rigc: rigcVersion },
1673
+ scratch,
1674
+ console.log,
1675
+ );
1676
+ return EXIT_OK;
1677
+ } catch (err) {
1678
+ return printRefusal(err);
1679
+ } finally {
1680
+ rmSync(scratch, { recursive: true, force: true });
1681
+ }
1682
+ }
1683
+
1614
1684
  /**
1615
1685
  * `painting --keypoints <k.json> [--person <id>] <painting.png>` (issue #350): what the painting must be before
1616
1686
  * See-through runs on it, read off its pixels and the body-18 joints `propose --keypoints` reads. Writes nothing.
@@ -1680,6 +1750,7 @@ function main(argv: string[]): number | Promise<number> {
1680
1750
  if (command === 'build') return cmdBuild(rest);
1681
1751
  if (command === 'compose') return cmdCompose(rest);
1682
1752
  if (command === 'mesh-draft') return cmdMeshDraft(rest);
1753
+ if (command === 'remesh') return cmdRemesh(rest);
1683
1754
  if (command === 'painting') return cmdPainting(rest);
1684
1755
  if (command === 'guide') return cmdGuide(rest);
1685
1756
  const later = LATER.find(([name]) => name === command);
package/docs/AUTHORING.md CHANGED
@@ -2904,6 +2904,31 @@ says why each requirement exists and what breaks without it.
2904
2904
  A part that fails a reading is not a refusal: it keeps its lattice, the command
2905
2905
  exits 0, and `mesh_draft.json` and the console name the reading and the amount.
2906
2906
 
2907
+ ### remesh
2908
+
2909
+ | rule | means | change |
2910
+ | --- | --- | --- |
2911
+ | <a id="REMESH_OUT_FRESH"></a>`REMESH_OUT_FRESH` | `--out` exists, or lies inside `--rig`: a revision is a new directory beside the input, never written over it | name a directory that does not exist yet |
2912
+ | <a id="REMESH_INPUT_PRESENT"></a>`REMESH_INPUT_PRESENT` | `--rig` is not the directory `rig` writes: `rig.json`, `motion.json`, `mesh_report.json` or `images/` is missing | point `--rig` at a `rig` output (`build`'s `rig/`) |
2913
+ | <a id="REMESH_INPUT_WRITTEN"></a>`REMESH_INPUT_WRITTEN` | `rig.json` or `mesh_report.json` is not the text this package's writer prints for its value (reformatted, or written by something else); the revision is spliced as text, so it could not carry every other byte over | use the files as `rig` wrote them |
2914
+ | <a id="REMESH_MESH_JSON"></a>`REMESH_MESH_JSON` | `--mesh` is missing, does not parse, or is not one JSON object | write the entry `config.json` would hold at `meshes.<part>`; its fields are then the config loader's to judge (`CONFIG_*` at `meshes.<part>`) |
2915
+ | <a id="REMESH_TARGET_MESH"></a>`REMESH_TARGET_MESH` | `--part` is not a `mesh` attachment of the input rig (`skins.default.<part>.<part>`), or not an entry of the config's `meshes` | name a part the rig meshes and the config states |
2916
+ | <a id="REMESH_TARGET_CLOSED"></a>`REMESH_TARGET_CLOSED` | a `linkedmesh` borrows the part's geometry, or an animation keys a `deform` timeline on it: both index the vertex list the remesh replaces | remesh the source and its links together by rebuilding from the config |
2917
+ | <a id="REMESH_BASELINE_MISMATCH"></a>`REMESH_BASELINE_MISMATCH` | the config as written does not build, or does not reproduce the input's part (its attachment, image or `mesh_report.json` row) or its bones — or, for an `auto` entry, its motion; the first differing leaf is named. A part the producer cannot reproduce is not one it can re-fit with every other byte kept | pass the config and parts the input was built from |
2918
+ | <a id="REMESH_CANDIDATE_REFUSED"></a>`REMESH_CANDIDATE_REFUSED` | the rig stage refused the requested entry — its mode's own checks, an `auto` entry's bounds or its motion gate; each problem is quoted with its measured figure, and nothing was written | loosen the bound the quoted problem names, or ask for another mode |
2919
+ | <a id="REMESH_REQUEST_HELD"></a>`REMESH_REQUEST_HELD` | the requested entry moves a bone (a `<bone>_ctl` added or dropped), a motion key, or the part's image: no longer a remesh of one part | make it a config change and rebuild |
2920
+ | <a id="REMESH_PRESERVED"></a>`REMESH_PRESERVED` | the spliced revision differs from the input outside the part's attachment and its report row; the file and the part that moved are named | a defect in this package: report it with the line |
2921
+ | <a id="REMESH_RIGC_GREEN"></a>`REMESH_RIGC_GREEN` | rigc's build gate refused the revision (its lines quoted), or `rigc --version` named no version for the provenance | read rigc's line; nothing was written |
2922
+ | <a id="REMESH_CHECK_HELD"></a>`REMESH_CHECK_HELD` | `check` on the revision did not measure a bar it measured on the input, or failed a bar the input met; the revision's own FAIL line is quoted | ask for a finer entry, or a different mode, for the part the line names |
2923
+
2924
+ A remesh changes one part's mesh and nothing else: every file of the input but
2925
+ `rig.json` and `mesh_report.json` is copied as bytes, `rig.json` keeps every byte
2926
+ outside the part's attachment, and the parts the config does not reproduce — repairs
2927
+ accepted after the config was written — are carried over as they are. `remesh.json`
2928
+ names the input's SHA-256 per file, the request, both versions, the part's counts
2929
+ before and after and both checks, and quotes `check`'s own physics line as the scope
2930
+ of the revision's check.
2931
+
2907
2932
  ### rig-c's mesh refusals, quoted in a line
2908
2933
 
2909
2934
  rig-c's mesh library refuses a malformed input by throwing with a code of its own. This
package/docs/STABILITY.md CHANGED
@@ -61,6 +61,7 @@ defaults are the help's too, and none is guessed from the art.
61
61
  | `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills`, `--alt` (as for `assemble`, issue #454), `--set` or `--all-sets` (as for `check`, issue #454) |
62
62
  | `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
63
63
  | `mesh-draft` | `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon), `--more-triangles` |
64
+ | `remesh` | `--rig`, `--config`, `--parts`, `--part`, `--mesh`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
64
65
  | `painting` | one positional path, the painting; `--keypoints`; optional `--person` |
65
66
  | `guide` | one positional argument: a rule name, `§<n>`, `§<n>.<m>` or `§<n>.<m>.<k>` |
66
67
  | (none) | `--version`, `--help` |
@@ -196,6 +197,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
196
197
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `constraints` with `--cloth` (issue #364: a physics constraint per tree link named `<bone>_physics`, a dangling end's `<bone>_translate`, and a skirt swing's transforms `<swing>_<tree>`), `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes` |
197
198
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
198
199
  | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
200
+ | `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `remesh.json` | `remesh` | the input revision's files, `rig.json` and `mesh_report.json` with the part's attachment and row replaced, every other file byte for byte; `remesh.json` is spec `rig-parts-remesh/1`: `request` (`part`, `mesh`, `mesh_sha256`, `idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `baseline` (`reproduced`, `not_compared`), `target` (`before`, `after`: `vertices`, `triangles`, `hull`), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`; `scope`) |
199
201
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
200
202
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
201
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.39.0",
3
+ "version": "1.40.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/meshdraft.ts CHANGED
@@ -575,6 +575,45 @@ export function splicedConfig(text: string, drafted: ReadonlyMap<string, unknown
575
575
  return out;
576
576
  }
577
577
 
578
+ /** A path in a config the loader resolves against the config's directory, and how to set it. */
579
+ export interface RelativeConfigPath {
580
+ at: string;
581
+ value: string;
582
+ set: (v: string) => void;
583
+ }
584
+
585
+ /**
586
+ * Each path in `o` (a parsed config) the loader resolves against the config's directory — `motion.animations_from`,
587
+ * `generation.control.skeleton` — where it is relative and names a file there.
588
+ */
589
+ export function relativeConfigPaths(configDir: string, o: Json): RelativeConfigPath[] {
590
+ const out: RelativeConfigPath[] = [];
591
+ const motion = o.motion as Json | undefined;
592
+ if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
593
+ out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
594
+ }
595
+ const control = (o.generation as Json | undefined)?.control as Json | undefined;
596
+ if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
597
+ out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
598
+ }
599
+ return out;
600
+ }
601
+
602
+ /**
603
+ * A config the stage builds, written as `<dir>/config.json`: `text` (the author's config at `configPath`) with
604
+ * `meshes.<part>` replaced for every part in `meshes`, and each of its {@link relativeConfigPaths} made absolute, so
605
+ * it resolves to the file the author's config names. `remesh` (issue #495) builds through the same door.
606
+ */
607
+ export function scratchConfig(configPath: string, text: string, dir: string, meshes: ReadonlyMap<string, unknown>): string {
608
+ const configDir = dirname(resolve(configPath));
609
+ const o = JSON.parse(text) as Json;
610
+ for (const r of relativeConfigPaths(configDir, o)) r.set(resolve(configDir, r.value));
611
+ o.meshes = { ...(o.meshes as Json), ...Object.fromEntries(meshes) };
612
+ mkdirSync(dir, { recursive: true });
613
+ writeFileSync(join(dir, 'config.json'), `${JSON.stringify(o, null, 2)}\n`);
614
+ return join(dir, 'config.json');
615
+ }
616
+
578
617
  // ---------------------------------------------------------------------------
579
618
  // the stage
580
619
  // ---------------------------------------------------------------------------
@@ -691,34 +730,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
691
730
  }
692
731
  log(`rig-parts mesh-draft: ${cfg.key}, figure ${rules.height} px (rows ${rules.top}..${rules.bottom}): tolerance and margin T = max(1, round(${rules.height} / ${TOLERANCE_FIGURE_DIVISOR})) = ${rules.tolerance}, spacing and longest outline edge S = round(${rules.height} / ${FIGURE_GRID_DIVISOR}) = ${rules.spacing}`);
693
732
 
694
- // The configs the stage builds are written in the scratch directory: each path the loader resolves against the
695
- // config's directory (motion.animations_from, generation.control.skeleton) is made absolute where it names a file
696
- // there, so it resolves to the file the author's config names.
697
- const configDir = dirname(resolve(input.config));
698
- const relativePaths = (o: Json): Array<{ at: string; value: string; set: (v: string) => void }> => {
699
- const out: Array<{ at: string; value: string; set: (v: string) => void }> = [];
700
- const motion = o.motion as Json | undefined;
701
- if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
702
- out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
703
- }
704
- const control = (o.generation as Json | undefined)?.control as Json | undefined;
705
- if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
706
- out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
707
- }
708
- return out;
709
- };
710
- const absolute = (o: Json): Json => {
711
- for (const r of relativePaths(o)) r.set(resolve(configDir, r.value));
712
- return o;
713
- };
714
- const configWith = (name: string, meshes: ReadonlyMap<string, unknown>): string => {
715
- const o = absolute(JSON.parse(text) as Json);
716
- o.meshes = { ...(o.meshes as Json), ...Object.fromEntries(meshes) };
717
- const dir = join(scratch, name);
718
- mkdirSync(dir, { recursive: true });
719
- writeFileSync(join(dir, 'config.json'), `${JSON.stringify(o, null, 2)}\n`);
720
- return join(dir, 'config.json');
721
- };
733
+ const configWith = (name: string, meshes: ReadonlyMap<string, unknown>): string => scratchConfig(input.config, text, join(scratch, name), meshes);
722
734
  function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: false): { rigDir: string } | { refused: Problem[] };
723
735
  function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: true): { rigDir: string; report: CheckReport } | { refused: Problem[] };
724
736
  function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: boolean): { rigDir: string; report?: CheckReport } | { refused: Problem[] } {
@@ -1003,8 +1015,8 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
1003
1015
  const nDrafted = rowsOut.filter((r) => r.mode === 'contour').length;
1004
1016
  const tri = (pick: (r: PartRow) => number): number => rowsOut.reduce((a, r) => a + pick(r), 0);
1005
1017
  log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}${others.length === 0 ? '' : `; ${others.length} part(s) not a lattice, not drafted and not compared: ${others.map((o) => `${o.part} (${o.mode})`).join(', ')}`}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
1006
- if (resolve(configDir) !== resolve(input.out)) {
1007
- for (const r of relativePaths(raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
1018
+ if (dirname(resolve(input.config)) !== resolve(input.out)) {
1019
+ for (const r of relativeConfigPaths(dirname(resolve(input.config)), raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
1008
1020
  }
1009
1021
  log(`rig-parts mesh-draft: wrote ${written.join(' and ')}`);
1010
1022
  return { rules, rows: rowsOut, notCompared: others, written };
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
+ }