@rig-c/parts 1.42.0 → 1.43.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
@@ -344,6 +344,7 @@ usage:
344
344
 
345
345
  rig-parts remesh --rig <dir> --config <config.json> --parts <dir> --part <name>
346
346
  --mesh <entry.json> --out <dir> [--requirements <file.json>]
347
+ (or --delta <mesh-edit.json> in place of --mesh)
347
348
  [--idle-keys ctl|direct] [--page-edges pot|free] [--pack-shape rect|polygon]
348
349
  Re-fit one part's mesh of a gated rig (the directory rig writes) under
349
350
  the mesh entry --mesh holds — what config.json would hold at
@@ -370,6 +371,18 @@ usage:
370
371
  SHA-256, the part's counts before and after, both checks and their
371
372
  scope) — no path and no clock, so the same request writes the same bytes.
372
373
  Prints the part's counts and a hash table of every file.
374
+ --delta, in place of --mesh (exactly one of the two): rig-c/review's
375
+ mesh edit (rig-review-mesh-edit/1, basis "setup", at most 300 ops of
376
+ move, add, remove, edge and unedge, crop px of the rig canvas), whose
377
+ source counts and source.hash must be the input part's
378
+ (REMESH_DELTA_SOURCE). The ops are replayed in order over the input's
379
+ own mesh — moved vertices rebound, added ones weighted from their
380
+ edge's or triangle's corners, removed ones' rings re-triangulated,
381
+ Delaunay restored by flips that keep the hull and every constraint —
382
+ and the result is held to the outline rules (REMESH_DELTA_JSON,
383
+ REMESH_DELTA_BASIS, REMESH_DELTA_OPS, REMESH_DELTA_OP naming ops[i],
384
+ REMESH_DELTA_TOPOLOGY). No config entry is built: the baseline is the
385
+ input itself; the splice, the gate and check are as for --mesh.
373
386
 
374
387
  rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
375
388
  [--page-edges pot|free] [--pack-shape rect|polygon]
@@ -834,7 +847,7 @@ const TAKES = {
834
847
  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'] },
835
848
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
836
849
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
837
- remesh: { valued: ['--rig', '--config', '--parts', '--part', '--mesh', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
850
+ remesh: { valued: ['--rig', '--config', '--parts', '--part', '--mesh', '--delta', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
838
851
  painting: { valued: ['--keypoints', '--person'], switches: [] },
839
852
  } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
840
853
 
@@ -1649,8 +1662,9 @@ function cmdMeshDraft(args: string[]): number {
1649
1662
 
1650
1663
  /** `remesh` (issue #495): one part of a gated rig re-fitted under a requested mesh entry, into a new revision. */
1651
1664
  function cmdRemesh(args: string[]): number {
1652
- const f = flags(args, 'remesh', ['--rig', '--config', '--parts', '--part', '--mesh', '--out']);
1665
+ const f = flags(args, 'remesh', ['--rig', '--config', '--parts', '--part', '--out']);
1653
1666
  if (typeof f === 'string') return usage(f);
1667
+ if (f.has('--mesh') === f.has('--delta')) return usage('remesh needs exactly one of --mesh, --delta');
1654
1668
  const keys = idleKeysOf(f.get('--idle-keys'));
1655
1669
  if (!isIdleKeys(keys)) return usage(keys);
1656
1670
  const edges = pageEdgesOf(f.get('--page-edges'));
@@ -1671,7 +1685,7 @@ function cmdRemesh(args: string[]): number {
1671
1685
  try {
1672
1686
  const req = f.get('--requirements');
1673
1687
  remeshStage(
1674
- { 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 }) },
1688
+ { rig: f.get('--rig') as string, config: f.get('--config') as string, parts: f.get('--parts') as string, part: f.get('--part') as string, ...(f.has('--mesh') ? { mesh: f.get('--mesh') as string } : { delta: f.get('--delta') as string }), out: f.get('--out') as string, idleKeys: keys, pageEdges: edges, packShape: shape, ...(req === undefined ? {} : { requirements: req }) },
1675
1689
  runner,
1676
1690
  bin,
1677
1691
  { parts: version(), rigc: rigcVersion },
package/docs/AUTHORING.md CHANGED
@@ -2924,6 +2924,12 @@ exits 0, and `mesh_draft.json` and the console name the reading and the amount.
2924
2924
  | <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 |
2925
2925
  | <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 |
2926
2926
  | <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 |
2927
+ | <a id="REMESH_DELTA_JSON"></a>`REMESH_DELTA_JSON` | `--delta` is missing, does not parse, is not one object, its `schema` is not `rig-review-mesh-edit/1`, or `slot`, `skin`, `attachment` or `source` is not of its shape | export the mesh edit from rig-c/review as it writes it |
2928
+ | <a id="REMESH_DELTA_BASIS"></a>`REMESH_DELTA_BASIS` | the mesh edit's `basis` is not `"setup"`: it was drawn over a pose, and the producer binds setup positions, so it is refused rather than posed back (the basis found is quoted) | edit the mesh in setup space (rig-c/review #100) and export again |
2929
+ | <a id="REMESH_DELTA_OPS"></a>`REMESH_DELTA_OPS` | `ops` is not an array, or holds more than 300 ops — review's own cap per attachment | split the edit: apply the first part, then edit the revision |
2930
+ | <a id="REMESH_DELTA_SOURCE"></a>`REMESH_DELTA_SOURCE` | the mesh edit was not drawn over this part's mesh: its `slot` or `attachment` is not `--part`, its skin is not `default`, a `source` count (`vertexCount`, `triangleCount`, `hullLength`) or `source.hash` is not the input's (both figures quoted; the hash is SHA-256 of the part's entry in rigc's `skeleton.model.json` over the input); or the input's bindings are not where its uvs and `parts.json` place the image, so no position can be bound | export the edit from the revision you pass as `--rig` |
2931
+ | <a id="REMESH_DELTA_OP"></a>`REMESH_DELTA_OP` | one op, named `ops[i]` and quoted, does not apply at its place in the replay: an unknown kind, a non-finite number, an id out of range or already removed, a `from` that is not the live position within 0.001 px, a position outside the part's image, a split of an edge the producer's triangulation does not hold or outside its triangles, a free `add` outside every triangle, a move that folds its ring, a removal that leaves fewer than 3 vertices, ends a constraint or leaves a ring no ear clips, an `edge` that duplicates a constraint, is a hull edge, runs through a vertex or crosses the hull or a constraint, or an `unedge` of any edge this edit did not constrain | read the op the line names; an edge the producer flipped is not one review can unedge — redraw it with `edge`, or move the vertices |
2932
+ | <a id="REMESH_DELTA_TOPOLOGY"></a>`REMESH_DELTA_TOPOLOGY` | the replayed mesh breaks an outline rule (`contourTopologyProblems`' code quoted: one loop, the hull first, every triangle wound one way, no self-intersection, no coincident vertices), or holds more interior edges that are not locally Delaunay than the input did; or the input's own triangles are not wound one way | read the quoted code; the ops that made it are the edit's last ones |
2927
2933
 
2928
2934
  A remesh changes one part's mesh and nothing else: every file of the input but
2929
2935
  `rig.json` and `mesh_report.json` is copied as bytes, `rig.json` keeps every byte
@@ -2933,6 +2939,46 @@ names the input's SHA-256 per file, the request, both versions, the part's count
2933
2939
  before and after and both checks, and quotes `check`'s own physics line as the scope
2934
2940
  of the revision's check.
2935
2941
 
2942
+ **`--delta <mesh-edit.json>`** (in place of `--mesh`, exactly one of the two; issue
2943
+ #526) reads rig-c/review's mesh edit, `rig-review-mesh-edit/1`: `slot`, `skin`,
2944
+ `attachment`, `source` (`vertexCount`, `triangleCount`, `hullLength`, `hash`), `units`,
2945
+ `basis` and up to 300 `ops`, each one of `move {v, from, to}`, `add {at, onEdge?}`,
2946
+ `remove {v}`, `edge {a, b}` and `unedge {a, b}`. Positions are crop px of the rig canvas
2947
+ (x right, y down): a vertex's position is its part-local px (`uv × (width, height)`)
2948
+ plus the image's origin, `parts.json`'s `x, y` less the pad of 4. Vertex ids are the
2949
+ attachment's indices; an added vertex takes the next id; a removed id is never reused.
2950
+ `basis` must be `"setup"`. Review only records the ops; the producer retriangulates,
2951
+ replaying them in order over the input part's own mesh:
2952
+
2953
+ - `move`: `from` must be the live position within 0.001 px; the uv and every binding's
2954
+ bone-local `x, y` are recomputed from `to` through the setup frames, the shares kept.
2955
+ - `add` with `onEdge`: the edge is split, two triangles into four (one into two on a
2956
+ hull edge, whose loop gains the vertex); its shares are the two ends' blended by the
2957
+ parameter along the edge. Without `onEdge`: the triangle holding the point is split in
2958
+ three (on an edge, as `onEdge`), its shares the corners' barycentric blend. Both are
2959
+ rounded as the lattice rounds shares (5 places, the heaviest closing the sum).
2960
+ - `remove`: the vertex and its triangles go, and the ring is ear-clipped by exact
2961
+ orientation; a hull vertex's two hull neighbours become adjacent.
2962
+ - `edge`: a constraint — an existing edge is marked, a missing one is made by flipping
2963
+ the edges its segment crosses.
2964
+ - `unedge`: only an edge this edit constrained. Every other edge belongs to the
2965
+ producer's triangulation, which differs from the one review drew as soon as a flip
2966
+ runs, so an `unedge` of it is refused rather than guessed at.
2967
+
2968
+ After every op the Delaunay condition is restored by edge flips that never flip a hull
2969
+ edge or a constraint, and the result is measured: the outline rules and no more
2970
+ non-Delaunay interior edges than the input had. The output writes the hull loop first,
2971
+ the other vertices after in id order, uvs to 6 places and bindings as `rig` writes them;
2972
+ every vertex the edit neither moves nor removes keeps its uv pair and weight entries
2973
+ byte for byte. No config entry is built — the baseline is the input itself, held to the
2974
+ edit by `source`'s counts and `source.hash` (rigc's build over the input) — and the
2975
+ part's `mesh_report.json` row is the input's with `vertices`, `triangles`, `hull`,
2976
+ `bones`, `max_influences` and `mean_influences` recomputed and `mode` "delta"; its other
2977
+ keys describe the mesh the edit was drawn over. The splice, the preserve assertion, the
2978
+ gate and `check` are the `--mesh` path's. A constraint is a statement about this
2979
+ revision's triangulation only: the rig spec has no field for it, so a later edit of the
2980
+ revision reads it as any other edge.
2981
+
2936
2982
  ### rig-c's mesh refusals, quoted in a line
2937
2983
 
2938
2984
  rig-c's mesh library refuses a malformed input by throwing with a code of its own. This
package/docs/STABILITY.md CHANGED
@@ -61,7 +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
+ | `remesh` | `--rig`, `--config`, `--parts`, `--part`, `--out`, and one of `--mesh` or `--delta` (issue #526); optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
65
65
  | `painting` | one positional path, the painting; `--keypoints`; optional `--person` |
66
66
  | `guide` | one positional argument: a rule name, `§<n>`, `§<n>.<m>` or `§<n>.<m>.<k>` |
67
67
  | (none) | `--version`, `--help` |
@@ -198,14 +198,14 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
198
198
  | `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` |
199
199
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
200
200
  | `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 |
201
- | `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`) |
201
+ | `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 (under `--delta` the row is the input's with its counts, `bones` and influences recomputed and `mode` "delta"), 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`; under `--delta`, issue #526: `part`, `delta` — the mesh edit as read — `delta_sha256`, `ops`, `applied` — `moved`, `added`, `removed`, `constrained` — `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`; under `--delta` `reproduced` holds one sentence: no config is built, the input is the baseline), `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`) |
202
202
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
203
203
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
204
204
 
205
205
  Files authors write for this package, each with a spec id the reader holds:
206
206
  `spine-parts-requirements/1` (`check --requirements`), `spine-parts-scene/1`
207
207
  (`compose --scene`), `spine-parts-keypoints/1` (`propose --keypoints`),
208
- `spine-parts-bonemap/1` (`compare --map`). The spec ids keep the package's former
208
+ `spine-parts-bonemap/1` (`compare --map`); and one another tool writes, read as that tool defines it: rig-c/review's `rig-review-mesh-edit/1` (`remesh --delta`). The spec ids keep the package's former
209
209
  name on purpose: they name a file format, not the tool, and a file written before
210
210
  the rename still reads. They do not change at 1.0.
211
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.42.0",
3
+ "version": "1.43.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/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` });
@@ -474,6 +489,28 @@ export function readMeshRequest(path: string): { entry: Json; bytes: Uint8Array
474
489
  return { entry, bytes };
475
490
  }
476
491
 
492
+ /** The request as rig-c/review's mesh edit (issue #526), read and held to its shape (`src/remeshdelta.ts`). */
493
+ export function readDeltaRequest(path: string): { doc: MeshEditDocument; bytes: Uint8Array } {
494
+ 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' }]);
495
+ const bytes = new Uint8Array(readFileSync(path));
496
+ const { doc, problems } = readMeshEdit(text(bytes), path);
497
+ refuseIfAny(problems);
498
+ return { doc: doc as MeshEditDocument, bytes };
499
+ }
500
+
501
+ /**
502
+ * The hash rig-c/review binds a mesh edit to (`source.hash`): SHA-256 of `JSON.stringify` of the attachment's entry in
503
+ * rigc's model document (`skins[name "default"].attachments.<part>.<part>`), or null when the document holds none.
504
+ */
505
+ export function modelAttachmentHash(modelText: string, part: string): string | null {
506
+ const model = JSON.parse(modelText) as { skins?: unknown };
507
+ const skin = Array.isArray(model.skins) ? (model.skins as unknown[]).find((s) => isRecord(s) && s.name === 'default') : undefined;
508
+ const atts = isRecord(skin) && isRecord(skin.attachments) ? skin.attachments : undefined;
509
+ const slot = atts !== undefined && isRecord(atts[part]) ? (atts[part] as Json) : undefined;
510
+ const entry = slot?.[part];
511
+ return entry === undefined ? null : createHash('sha256').update(JSON.stringify(entry)).digest('hex');
512
+ }
513
+
477
514
  /** Step 1's tree rules: the files `rig` writes are present, and the two the splice rewrites are this writer's bytes. */
478
515
  export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
479
516
  const out: Problem[] = [];
@@ -496,6 +533,35 @@ export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
496
533
  return out;
497
534
  }
498
535
 
536
+ /** A tree's images as the gate takes them (name under `images/`, bytes). */
537
+ const pngsOf = (tree: FileTree): Array<readonly [string, Uint8Array]> => [...tree].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
538
+ /** A tree's other files as text. */
539
+ const textsOf = (tree: FileTree): Array<readonly [string, string]> => [...tree].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
540
+
541
+ /**
542
+ * The delta's `mesh_report.json` row (issue #526): the input's row, key for key, with `vertices`, `triangles`, `hull`,
543
+ * `bones` (the sorted set the new weights name), `max_influences` and `mean_influences` recomputed from the new
544
+ * attachment and `mode` "delta" (in place, or last when the row had none). Every other key is the input's and
545
+ * describes the mesh the delta was drawn over.
546
+ */
547
+ export function deltaRow(row: Json, att: MeshAttachment): Json {
548
+ const n = att.uvs.length / 2;
549
+ const entries = att.weights.reduce((s, w) => s + w.length, 0);
550
+ const fresh: Json = {
551
+ vertices: n,
552
+ triangles: att.triangles.length / 3,
553
+ hull: att.hull,
554
+ bones: [...new Set(att.weights.flatMap((w) => w.map((e) => e.bone)))].sort(),
555
+ max_influences: Math.max(...att.weights.map((w) => w.length)),
556
+ mean_influences: pyRound(entries / n, 2),
557
+ mode: 'delta',
558
+ };
559
+ const out: Json = {};
560
+ for (const [k, v] of Object.entries(row)) out[k] = k in fresh ? fresh[k] : v;
561
+ if (!('mode' in out)) out.mode = 'delta';
562
+ return out;
563
+ }
564
+
499
565
  /**
500
566
  * The stage: steps 1–8 of the module header. Writes `input.out` only when every step is green; refuses with every
501
567
  * problem of the first step that found one.
@@ -513,7 +579,9 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
513
579
  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
580
  const tree = readTree(rigAt);
515
581
  refuseIfAny(inputTreeProblems(tree, input.rig));
516
- const request = readMeshRequest(input.mesh);
582
+ if ((input.mesh === undefined) === (input.delta === undefined)) throw new Error('remeshStage: exactly one of mesh, delta is required');
583
+ const meshReq = input.mesh === undefined ? null : readMeshRequest(input.mesh);
584
+ const deltaReq = input.delta === undefined ? null : readDeltaRequest(input.delta);
517
585
  const configText = readFileSync(input.config, 'utf8');
518
586
  const { config: cfg } = loadConfigAndAnimations(input.config);
519
587
  const inRig = JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json;
@@ -521,41 +589,85 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
521
589
  const inReport = JSON.parse(text(tree.get('mesh_report.json') as Uint8Array)) as Json[];
522
590
  // 2. The target.
523
591
  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
592
  const before = meshCounts(targetAttachment(inRig, part));
556
- const after = meshCounts(targetAttachment(cand.rig, part));
593
+ let candAtt: unknown;
594
+ let candRow: Json;
595
+ let reproduced: string[];
596
+ let deltaRequest: RemeshProvenance['request'] | null = null;
597
+ if (meshReq !== null) {
598
+ const meshPath = input.mesh as string;
599
+ // The request is the config's to judge: spliced in and loaded, before anything is built.
600
+ const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, meshReq.entry]]));
601
+ loadConfigAndAnimations(candConfig);
602
+ const auto = 'auto' in meshReq.entry;
603
+ const build = (name: string, config: string): Built => {
604
+ const out = join(scratch, name, 'rig');
605
+ rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
606
+ return readBuilt(out, part);
607
+ };
608
+ // 3. The baseline.
609
+ log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
610
+ let base: Built;
611
+ try {
612
+ base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
613
+ } catch (err) {
614
+ if (!(err instanceof PartsError)) throw err;
615
+ 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)}` })));
616
+ }
617
+ refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
618
+ log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
619
+ // 4. The candidate.
620
+ let cand: Built;
621
+ try {
622
+ cand = build('candidate', candConfig);
623
+ } catch (err) {
624
+ if (!(err instanceof PartsError)) throw err;
625
+ 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)}` })));
626
+ }
627
+ refuseIfAny(requestHeldProblems(base, cand, part));
628
+ const row = rowOf(cand.report, part);
629
+ if (row === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
630
+ candAtt = targetAttachment(cand.rig, part);
631
+ candRow = row;
632
+ reproduced = [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])];
633
+ } else {
634
+ // 3–4 on the delta path (issue #526): no config entry to build, so the baseline is the input itself, held to the
635
+ // delta by its source counts and its source.hash, and the candidate is the delta replayed over the input's mesh.
636
+ const deltaPath = input.delta as string;
637
+ const { doc, bytes } = deltaReq as { doc: MeshEditDocument; bytes: Uint8Array };
638
+ const inAtt = targetAttachment(inRig, part) as MeshAttachment;
639
+ refuseIfAny(deltaSourceProblems(doc, part, inAtt, deltaPath));
640
+ 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`);
641
+ let model = null as string | null;
642
+ const inGate = gateThroughRigc(pngsOf(tree), textsOf(tree), rigc, join(scratch, 'gate-input'), { pageEdges, packShape }, (dir) => {
643
+ const p = join(dir, RIGC_MODEL_DOCUMENT);
644
+ model = existsSync(p) ? readFileSync(p, 'utf8') : null;
645
+ });
646
+ 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` })));
647
+ 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` }]);
648
+ const hash = modelAttachmentHash(model, part);
649
+ if (hash !== doc.source.hash) {
650
+ 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` }]);
651
+ }
652
+ 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`);
653
+ const partsFile = JSON.parse(readFileSync(join(input.parts, 'parts.json'), 'utf8')) as PartsFile;
654
+ const rec = partsFile.parts.find((p) => p.name === part);
655
+ 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` }]);
656
+ const world = computeExactFrameTransforms((inRig as unknown as RigSpec).bones);
657
+ 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);
658
+ if (Array.isArray(r)) throw new PartsError(r);
659
+ const row = rowOf(inReport, part);
660
+ 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` }]);
661
+ candAtt = r.attachment;
662
+ candRow = deltaRow(row, r.attachment);
663
+ const { ops, ...applied } = r.applied;
664
+ 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}`);
665
+ 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'];
666
+ deltaRequest = { part, delta: doc, delta_sha256: sha256(bytes), ops, applied, idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape };
667
+ }
668
+ const after = meshCounts(candAtt);
557
669
  // 5–6. The splice and the preserve assertion.
558
- let revision = splice(tree, part, targetAttachment(cand.rig, part), candRow);
670
+ let revision = splice(tree, part, candAtt, candRow);
559
671
  if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
560
672
  if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision, part));
561
673
  const spliced = ['rig.json', 'mesh_report.json'];
@@ -563,9 +675,7 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
563
675
  log(` ${part}: v=${before.vertices} t=${before.triangles} hull=${before.hull} -> v=${after.vertices} t=${after.triangles} hull=${after.hull}`);
564
676
  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
677
  // 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 });
678
+ const gate = gateThroughRigc(pngsOf(revision), textsOf(revision), rigc, join(scratch, 'gate'), { pageEdges, packShape });
569
679
  for (const g of gate) {
570
680
  log(` rigc ${g.label}, the revision: exit ${g.status}`);
571
681
  for (const l of g.lines) log(` ${l.trim()}`);
@@ -587,11 +697,11 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
587
697
  // 8. The write.
588
698
  const provenance: RemeshProvenance = {
589
699
  spec: REMESH_SPEC,
590
- request: { part, mesh: request.entry, mesh_sha256: sha256(request.bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
700
+ 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
701
  versions,
592
702
  input: { rig: hashRows(tree), config_sha256: sha256(bytesOf(configText)), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
593
703
  baseline: {
594
- reproduced: [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])],
704
+ reproduced,
595
705
  not_compared: 'every other attachment, image and report row: carried over from the input as bytes, whether or not the config reproduces it',
596
706
  },
597
707
  target: { before, after },
@@ -0,0 +1,669 @@
1
+ /**
2
+ * `rig-parts remesh --delta` (issue #526): the reader of rig-c/review's mesh edit, `rig-review-mesh-edit/1` — a typed
3
+ * list of vertex and edge ops a person drew over one part's mesh in review's Edit Mesh — and the producer half of that
4
+ * loop: the ops replayed, in order, over the input part's own triangulation, retriangulated here (review never
5
+ * retriangulates; its document says "the producer retriangulates"), and written back as a `mesh` attachment in this
6
+ * package's form. Pure: no I/O; `src/remesh.ts` reads the files and splices the result.
7
+ *
8
+ * ## The document
9
+ *
10
+ * `{ schema, slot, skin, attachment, source: { vertexCount, triangleCount, hullLength, hash }, units, basis, preview,
11
+ * ops }` as review writes it (`src/mesh-edit.ts`, `meshEditDocument`). Positions are crop px of the rig canvas, x
12
+ * right, y down: a vertex's crop position is its part-local px (`uv × (width, height)`) plus the part's origin
13
+ * (`parts.json` x − {@link PAD}, y − PAD, the origin `rig` placed the image at). Vertex ids are the attachment's
14
+ * indices; an added vertex takes the next id; a removed id is never reused. `basis` must be `"setup"` (rig-c/review
15
+ * #100): a posed basis would put setup vertices where a pose drew them, so it is refused rather than posed back.
16
+ *
17
+ * ## The ops, as the producer applies them
18
+ *
19
+ * - `move {v, from, to}`: `from` must be the live position (within {@link FROM_TOLERANCE_PX}); the uv and every
20
+ * binding's bone-local x, y are recomputed from `to` through the setup frames; the shares are kept. Then the
21
+ * Delaunay condition is restored by edge flips around it, never flipping a hull or constraint edge.
22
+ * - `add {at, onEdge}`: the edge split (two triangles to four; one to two on a hull edge, the hull loop gaining the
23
+ * vertex), weights the two ends' shares blended by the parameter along the edge; `add {at}` without `onEdge`: the
24
+ * triangle that holds it split in three (on an edge, as `onEdge`), weights the corners' shares by barycentric blend.
25
+ * - `remove {v}`: the vertex and its triangles dropped and the ring ear-clipped by exact orientation, then flipped;
26
+ * a hull vertex's two hull neighbours become adjacent.
27
+ * - `edge {a, b}`: a constraint — the edge marked, or made by flipping the edges it crosses (edge recovery); one that
28
+ * crosses the hull or another constraint is refused.
29
+ * - `unedge {a, b}`: only an edge this delta constrained; the producer owns every other edge of the triangulation.
30
+ *
31
+ * Every op that does not apply is refused by name with its index (`REMESH_DELTA_OP`), and the result is held to the
32
+ * outline rules and the Delaunay count by {@link contourTopologyProblems} and {@link delaunayViolations}
33
+ * (`REMESH_DELTA_TOPOLOGY`) — measured on the result, not trusted from the replay.
34
+ *
35
+ * ## Coordinates
36
+ *
37
+ * Two copies of every position are kept: the exact one (part-local px, a double), which the uv, the bindings and the
38
+ * `from` comparison read, and its snap to {@link GRID} units, which every predicate reads, so orientation and the
39
+ * in-circle test are exact (`src/contour.ts`). A vertex the delta neither moves nor removes keeps its uv pair and its
40
+ * weight entries byte for byte; only its index may change, since the hull loop is written first.
41
+ */
42
+ import { contourTopologyProblems, delaunayViolations, GRID, inCircle } from './contour.ts';
43
+ import { type BoneTransform, cropToSpineY, toBoneLocal } from './coords.ts';
44
+ import type { Problem } from './errors.ts';
45
+ import { type MeshAttachment, OFFSET_PLACES, PAD, roundShares, type WeightEntry } from './rig.ts';
46
+ import { pyRound } from './round.ts';
47
+ import type { Influence } from './weights.ts';
48
+
49
+ /** The schema review writes (rig-c/review `src/mesh-edit.ts`, `MESH_EDIT_SCHEMA`). */
50
+ export const MESH_EDIT_SCHEMA = 'rig-review-mesh-edit/1';
51
+
52
+ /** The basis the reader accepts (rig-c/review #100: the editor works in setup space). */
53
+ export const MESH_EDIT_BASIS = 'setup';
54
+
55
+ /** Review's own cap on one attachment's ops (`MESH_EDIT_MAX_OPS`: the draft rides in one form field). */
56
+ export const MESH_EDIT_MAX_OPS = 300;
57
+
58
+ /**
59
+ * How far `move.from` may sit from the live position, in crop px: the uv is written to 6 places, which moves a
60
+ * vertex by up to 2.5e-4 px on the parts measured (the public demo's 9 meshes, 1,192 vertices), so 1e-3 holds every
61
+ * honest `from` and refuses a delta drawn over another mesh. The same tolerance holds the input's own bindings to
62
+ * the position its uv states ({@link applyMeshEdit}'s reading of the part's origin).
63
+ */
64
+ export const FROM_TOLERANCE_PX = 1e-3;
65
+
66
+ export type Vec = [number, number];
67
+ export type MeshEditOp =
68
+ | { op: 'move'; v: number; from: Vec; to: Vec }
69
+ | { op: 'add'; at: Vec; onEdge?: [number, number] }
70
+ | { op: 'remove'; v: number }
71
+ | { op: 'edge'; a: number; b: number }
72
+ | { op: 'unedge'; a: number; b: number };
73
+
74
+ export interface MeshEditDocument {
75
+ schema: typeof MESH_EDIT_SCHEMA;
76
+ slot: string;
77
+ skin: string;
78
+ attachment: string;
79
+ source: { vertexCount: number; triangleCount: number; hullLength: number; hash: string };
80
+ units: string;
81
+ basis: string;
82
+ preview: string;
83
+ ops: MeshEditOp[];
84
+ }
85
+
86
+ type Json = Record<string, unknown>;
87
+ const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
88
+ const isVec = (v: unknown): v is Vec => Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
89
+ const isId = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
90
+ const OP_FIELDS: Record<MeshEditOp['op'], readonly string[]> = { move: ['op', 'v', 'from', 'to'], add: ['op', 'at', 'onEdge'], remove: ['op', 'v'], edge: ['op', 'a', 'b'], unedge: ['op', 'a', 'b'] };
91
+
92
+ /**
93
+ * The document, read and held to its shape: `schema` (`REMESH_DELTA_JSON`), `basis` (`REMESH_DELTA_BASIS`), `ops` an
94
+ * array of at most {@link MESH_EDIT_MAX_OPS} (`REMESH_DELTA_OPS`), each op of a known kind with finite numbers and
95
+ * whole ids (`REMESH_DELTA_OP`). Every problem is collected and thrown once; whether an id is live is the replay's.
96
+ */
97
+ export function readMeshEdit(text: string, object: string): { doc: MeshEditDocument | null; problems: Problem[] } {
98
+ const problems: Problem[] = [];
99
+ let value: unknown;
100
+ try {
101
+ value = JSON.parse(text);
102
+ } catch (err) {
103
+ return { doc: null, problems: [{ code: 'REMESH_DELTA_JSON', object, detail: `does not parse as JSON: ${(err as Error).message}` }] };
104
+ }
105
+ if (!isRecord(value)) return { doc: null, problems: [{ code: 'REMESH_DELTA_JSON', object, detail: `is ${Array.isArray(value) ? 'an array' : JSON.stringify(value)}; one ${MESH_EDIT_SCHEMA} document is required` }] };
106
+ if (value.schema !== MESH_EDIT_SCHEMA) problems.push({ code: 'REMESH_DELTA_JSON', object: `${object} schema`, detail: `is ${JSON.stringify(value.schema)}; "${MESH_EDIT_SCHEMA}" (rig-c/review's mesh edit) is required` });
107
+ for (const k of ['slot', 'skin', 'attachment']) if (typeof value[k] !== 'string') problems.push({ code: 'REMESH_DELTA_JSON', object: `${object} ${k}`, detail: `is ${JSON.stringify(value[k])}; a string is required` });
108
+ const src = value.source;
109
+ if (!isRecord(src) || !isId(src.vertexCount) || !isId(src.triangleCount) || !isId(src.hullLength) || typeof src.hash !== 'string') {
110
+ problems.push({ code: 'REMESH_DELTA_JSON', object: `${object} source`, detail: `is ${JSON.stringify(src)}; { vertexCount, triangleCount, hullLength: whole numbers, hash: a string } is required` });
111
+ }
112
+ if (value.basis !== MESH_EDIT_BASIS) {
113
+ problems.push({ code: 'REMESH_DELTA_BASIS', object: `${object} basis`, detail: `is ${JSON.stringify(value.basis)}; "${MESH_EDIT_BASIS}" is required — the producer binds setup positions, and a delta drawn over a pose (review before rig-c/review #100) is refused rather than posed back` });
114
+ }
115
+ const ops = value.ops;
116
+ if (!Array.isArray(ops)) problems.push({ code: 'REMESH_DELTA_OPS', object: `${object} ops`, detail: `is ${JSON.stringify(ops)}; an array of ops is required` });
117
+ else if (ops.length > MESH_EDIT_MAX_OPS) problems.push({ code: 'REMESH_DELTA_OPS', object: `${object} ops`, detail: `holds ${ops.length} ops; at most ${MESH_EDIT_MAX_OPS} (review's own cap per attachment) are read` });
118
+ else {
119
+ ops.forEach((op: unknown, i: number) => {
120
+ const bad = (why: string): void => {
121
+ problems.push({ code: 'REMESH_DELTA_OP', object: `${object} ops[${i}]`, detail: `${JSON.stringify(op)} ${why}` });
122
+ };
123
+ if (!isRecord(op) || typeof op.op !== 'string' || !(op.op in OP_FIELDS)) {
124
+ bad(`has an unknown op kind ${isRecord(op) ? JSON.stringify(op.op) : ''}; one of move, add, remove, edge, unedge is required`);
125
+ return;
126
+ }
127
+ const kind = op.op as MeshEditOp['op'];
128
+ const extra = Object.keys(op).filter((k) => !OP_FIELDS[kind].includes(k));
129
+ if (extra.length > 0) bad(`carries ${extra.join(', ')}, which a ${kind} op does not take`);
130
+ if (kind === 'move') {
131
+ if (!isId(op.v)) bad('needs a whole vertex id v');
132
+ if (!isVec(op.from) || !isVec(op.to)) bad('needs finite from and to pairs');
133
+ } else if (kind === 'add') {
134
+ if (!isVec(op.at)) bad('needs a finite at pair');
135
+ if (op.onEdge !== undefined && !(Array.isArray(op.onEdge) && op.onEdge.length === 2 && op.onEdge.every(isId))) bad('needs onEdge as a pair of whole vertex ids, when it is given');
136
+ } else if (kind === 'remove') {
137
+ if (!isId(op.v)) bad('needs a whole vertex id v');
138
+ } else if (!isId(op.a) || !isId(op.b)) bad('needs whole vertex ids a and b');
139
+ else if (op.a === op.b) bad('joins a vertex to itself');
140
+ });
141
+ }
142
+ return { doc: problems.length === 0 ? (value as unknown as MeshEditDocument) : null, problems };
143
+ }
144
+
145
+ /** The input the replay reads: the part's attachment, where `rig` placed it, and the setup frames of the rig's bones. */
146
+ export interface DeltaInput {
147
+ part: string;
148
+ attachment: MeshAttachment;
149
+ /** `parts.json`'s box corner (x, y) for the part: the image sits at (x − PAD, y − PAD). */
150
+ box: Vec;
151
+ /** `parts.json`'s `rig_size`. */
152
+ rigSize: Vec;
153
+ /** Every bone's setup frame, as `computeExactFrameTransforms` gives it over `rig.json`'s bones. */
154
+ world: ReadonlyMap<string, BoneTransform>;
155
+ }
156
+
157
+ /** Planted faults for the selftest (issue #526) — never set by the CLI. */
158
+ export interface DeltaPlant {
159
+ /** A move keeps its bindings' x, y (the bone-local recompute skipped). */
160
+ skipBind?: boolean;
161
+ /** The output keeps id order instead of writing the hull loop first. */
162
+ skipHullOrder?: boolean;
163
+ /** The result's topology gate is skipped. */
164
+ skipTopology?: boolean;
165
+ }
166
+
167
+ export interface DeltaApplied {
168
+ ops: number;
169
+ moved: number;
170
+ added: number;
171
+ removed: number;
172
+ constrained: number;
173
+ }
174
+
175
+ export interface DeltaResult {
176
+ attachment: MeshAttachment;
177
+ counts: { vertices: number; triangles: number; hull: number };
178
+ applied: DeltaApplied;
179
+ /** Source ids the delta neither moved nor removed: their uv pair and weight entries are the input's bytes. */
180
+ untouched: number[];
181
+ /** Interior edges not locally Delaunay, before and after (constraint edges not asked). */
182
+ delaunay: { before: number; after: number };
183
+ /** The output index of every live id, in id order (removed ids absent). */
184
+ index: Array<[number, number]>;
185
+ }
186
+
187
+ const placed6 = (v: number): number => {
188
+ const r = pyRound(v, OFFSET_PLACES);
189
+ return r === 0 ? 0 : r;
190
+ };
191
+ const ekey = (a: number, b: number): string => (a < b ? `${a}:${b}` : `${b}:${a}`);
192
+
193
+ /** Thrown inside the replay; caught once by {@link applyMeshEdit} and turned into its problem. */
194
+ class OpRefused extends Error {
195
+ constructor(
196
+ readonly code: string,
197
+ readonly at: string,
198
+ readonly why: string,
199
+ ) {
200
+ super(why);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The source checks a document is held to before any op is replayed: it names this part's attachment in the default
206
+ * skin, and its source counts are this attachment's (`REMESH_DELTA_SOURCE`, naming the field and both figures).
207
+ */
208
+ export function deltaSourceProblems(doc: MeshEditDocument, part: string, att: MeshAttachment, object: string): Problem[] {
209
+ const out: Problem[] = [];
210
+ for (const k of ['slot', 'attachment'] as const) if (doc[k] !== part) out.push({ code: 'REMESH_DELTA_SOURCE', object: `${object} ${k}`, detail: `is "${doc[k]}"; the delta must name --part "${part}", whose slot and attachment the rig stage names after the part` });
211
+ if (doc.skin !== 'default') out.push({ code: 'REMESH_DELTA_SOURCE', object: `${object} skin`, detail: `is "${doc.skin}"; this package writes every mesh into the default skin, so "default" is required` });
212
+ const counts: Array<[string, number, number]> = [
213
+ ['vertexCount', doc.source.vertexCount, att.uvs.length / 2],
214
+ ['triangleCount', doc.source.triangleCount, att.triangles.length / 3],
215
+ ['hullLength', doc.source.hullLength, att.hull],
216
+ ];
217
+ for (const [k, said, is] of counts) if (said !== is) out.push({ code: 'REMESH_DELTA_SOURCE', object: `${object} source.${k}`, detail: `is ${said}; the input's ${part} has ${is} — the delta was drawn over another mesh` });
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * The replay: the document's ops over the input's mesh, in order, and the result held to the outline and Delaunay
223
+ * rules. Returns the problems (the first op that does not apply ends the replay, since every later op reads its
224
+ * state) or the new attachment.
225
+ */
226
+ export function applyMeshEdit(input: DeltaInput, doc: MeshEditDocument, object: string, plant: DeltaPlant = {}): DeltaResult | Problem[] {
227
+ const att = input.attachment;
228
+ const { width: w, height: h } = att;
229
+ const ox = input.box[0] - PAD;
230
+ const oy = input.box[1] - PAD;
231
+ const CX = input.rigSize[0] / 2;
232
+ const H = input.rigSize[1];
233
+ const V0 = att.uvs.length / 2;
234
+ // Exact positions (part px) and their grid snaps, by id; the uv and weights to write, by id.
235
+ const PX: number[] = [];
236
+ const PY: number[] = [];
237
+ const X: number[] = [];
238
+ const Y: number[] = [];
239
+ const uv: Vec[] = [];
240
+ const wt: WeightEntry[][] = [];
241
+ const live: boolean[] = [];
242
+ const moved = new Set<number>();
243
+ const added = new Set<number>();
244
+ const removed = new Set<number>();
245
+ const snap = (v: number): number => Math.round(v * GRID);
246
+ const setPos = (id: number, x: number, y: number): void => {
247
+ PX[id] = x;
248
+ PY[id] = y;
249
+ X[id] = snap(x);
250
+ Y[id] = snap(y);
251
+ };
252
+ const bind = (shares: ReadonlyArray<{ bone: string; weight: number }>, x: number, y: number): WeightEntry[] =>
253
+ shares.map(({ bone, weight }) => {
254
+ const m = input.world.get(bone);
255
+ if (m === undefined) throw new OpRefused('REMESH_DELTA_SOURCE', `${input.part}'s weights`, `name bone "${bone}", which rig.json does not declare`);
256
+ const [bx, by] = toBoneLocal(m, ox + x - CX, cropToSpineY(oy + y, H));
257
+ return { bone, x: placed6(bx), y: placed6(by), weight };
258
+ });
259
+ const problems: Problem[] = [];
260
+ for (let i = 0; i < V0; i++) {
261
+ setPos(i, att.uvs[2 * i] * w, att.uvs[2 * i + 1] * h);
262
+ uv.push([att.uvs[2 * i], att.uvs[2 * i + 1]]);
263
+ wt.push(att.weights[i]);
264
+ live.push(true);
265
+ }
266
+ // The part's origin is read, not trusted: every input binding must be what its uv states at (ox, oy).
267
+ try {
268
+ for (let i = 0; i < V0 && problems.length === 0; i++) {
269
+ const again = bind(att.weights[i], PX[i], PY[i]);
270
+ const off = again.findIndex((e, k) => Math.abs(e.x - att.weights[i][k].x) > FROM_TOLERANCE_PX || Math.abs(e.y - att.weights[i][k].y) > FROM_TOLERANCE_PX);
271
+ if (off >= 0) {
272
+ const e = att.weights[i][off];
273
+ problems.push({ code: 'REMESH_DELTA_SOURCE', object: `rig.json skins.default.${input.part}.${input.part} vertex ${i}`, detail: `binds to "${e.bone}" at (${e.x}, ${e.y}); its uv at the part's origin (${ox}, ${oy}) binds at (${again[off].x}, ${again[off].y}) — the input's placement is not the one parts.json states, so no position the delta names can be bound` });
274
+ }
275
+ }
276
+ } catch (err) {
277
+ if (!(err instanceof OpRefused)) throw err;
278
+ problems.push({ code: err.code, object: err.at, detail: err.why });
279
+ }
280
+ if (problems.length > 0) return problems;
281
+
282
+ // Triangles in the replay's winding: orient > 0 in y-down grid units. The input's winding is measured and kept.
283
+ const orient = (a: number, b: number, c: number): number => (X[b] - X[a]) * (Y[c] - Y[a]) - (Y[b] - Y[a]) * (X[c] - X[a]);
284
+ let sign = 0;
285
+ for (let t = 0; t < att.triangles.length; t += 3) {
286
+ const s = Math.sign(orient(att.triangles[t], att.triangles[t + 1], att.triangles[t + 2]));
287
+ if (s === 0 || (sign !== 0 && s !== sign)) {
288
+ return [{ code: 'REMESH_DELTA_TOPOLOGY', object: `rig.json skins.default.${input.part}.${input.part} triangle ${t / 3}`, detail: s === 0 ? 'has zero area in the input; the replay needs every input triangle wound one way' : 'is wound against the triangles before it in the input; the replay needs every input triangle wound one way' }];
289
+ }
290
+ sign = s;
291
+ }
292
+ const tris: Array<[number, number, number] | null> = [];
293
+ const owner = new Map<string, number>();
294
+ const dkey = (a: number, b: number): string => `${a},${b}`;
295
+ const link = (t: number): void => {
296
+ const r = tris[t] as [number, number, number];
297
+ owner.set(dkey(r[0], r[1]), t);
298
+ owner.set(dkey(r[1], r[2]), t);
299
+ owner.set(dkey(r[2], r[0]), t);
300
+ };
301
+ const unlink = (t: number): void => {
302
+ const r = tris[t];
303
+ if (r === null || r === undefined) return;
304
+ // a key the triangle no longer owns (a flip's partner took it first) is left to its owner.
305
+ for (const k of [dkey(r[0], r[1]), dkey(r[1], r[2]), dkey(r[2], r[0])]) if (owner.get(k) === t) owner.delete(k);
306
+ };
307
+ const setTri = (t: number, a: number, b: number, c: number): void => {
308
+ unlink(t);
309
+ tris[t] = [a, b, c];
310
+ link(t);
311
+ };
312
+ for (let t = 0; t < att.triangles.length; t += 3) {
313
+ const [a, b, c] = [att.triangles[t], att.triangles[t + 1], att.triangles[t + 2]];
314
+ if (sign > 0) setTri(t / 3, a, b, c);
315
+ else setTri(t / 3, a, c, b);
316
+ }
317
+ const freeSlots: number[] = [];
318
+ const newTri = (a: number, b: number, c: number): number => {
319
+ const t = freeSlots.length > 0 ? (freeSlots.shift() as number) : tris.length;
320
+ setTri(t, a, b, c);
321
+ return t;
322
+ };
323
+ const dropTri = (t: number): void => {
324
+ unlink(t);
325
+ tris[t] = null;
326
+ freeSlots.push(t);
327
+ freeSlots.sort((p, q) => p - q);
328
+ };
329
+ const third = (t: number, a: number, b: number): number => {
330
+ const r = tris[t] as [number, number, number];
331
+ return r.find((x) => x !== a && x !== b) as number;
332
+ };
333
+ const hull: number[] = Array.from({ length: att.hull }, (_, i) => i);
334
+ const constrained = new Set<string>();
335
+ const isEdge = (a: number, b: number): boolean => owner.has(dkey(a, b)) || owner.has(dkey(b, a));
336
+ const isHullEdge = (a: number, b: number): boolean => isEdge(a, b) && (!owner.has(dkey(a, b)) || !owner.has(dkey(b, a)));
337
+ const starOf = (v: number): number[] => tris.flatMap((r, t) => (r !== null && r.includes(v) ? [t] : []));
338
+ const violations = (): number => {
339
+ const n = (r: readonly number[]): Vec[] => r.map((id) => [X[id] / GRID, Y[id] / GRID] as Vec);
340
+ const ids = X.map((_, i) => i);
341
+ const flat = tris.flatMap((r) => (r === null ? [] : r));
342
+ return delaunayViolations(n(ids), flat, [...constrained].map((k) => k.split(':').map(Number) as [number, number]));
343
+ };
344
+ const before = violations();
345
+
346
+ /** Lawson flips from the queued edges: an interior, unconstrained edge whose far vertex lies inside the circle is flipped. */
347
+ const legalize = (queue: Array<[number, number]>): void => {
348
+ let guard = 0;
349
+ while (queue.length > 0) {
350
+ if (++guard > 1_000_000) throw new Error('remeshdelta: edge flips did not settle');
351
+ const [a, b] = queue.pop() as [number, number];
352
+ if (constrained.has(ekey(a, b))) continue;
353
+ const t = owner.get(dkey(a, b));
354
+ const u = owner.get(dkey(b, a));
355
+ if (t === undefined || u === undefined) continue;
356
+ const c = third(t, a, b);
357
+ const d = third(u, b, a);
358
+ if (inCircle(X[a], Y[a], X[b], Y[b], X[c], Y[c], X[d], Y[d]) <= 0n) continue;
359
+ if (!(orient(a, d, c) > 0 && orient(b, c, d) > 0)) continue;
360
+ setTri(t, a, d, c);
361
+ setTri(u, b, c, d);
362
+ queue.push([a, d], [d, b], [b, c], [c, a]);
363
+ }
364
+ };
365
+ const edgesOf = (ts: readonly number[]): Array<[number, number]> =>
366
+ ts.flatMap((t) => {
367
+ const r = tris[t];
368
+ return r === null || r === undefined ? [] : ([[r[0], r[1]], [r[1], r[2]], [r[2], r[0]]] as Array<[number, number]>);
369
+ });
370
+ const liveCount = (): number => live.filter((x) => x).length;
371
+ const crop = (id: number): Vec => [PX[id] + ox, PY[id] + oy];
372
+ const inImage = (x: number, y: number): boolean => x >= 0 && x <= w && y >= 0 && y <= h;
373
+ const mustLive = (id: number, what: string): void => {
374
+ if (id >= live.length) throw new OpRefused('REMESH_DELTA_OP', '', `names ${what} ${id}, which is out of range (ids 0..${live.length - 1} exist at this op)`);
375
+ if (!live[id]) throw new OpRefused('REMESH_DELTA_OP', '', `names ${what} ${id}, which an earlier op removed`);
376
+ };
377
+ const blend = (parts: ReadonlyArray<readonly [number, number]>): Influence[] => {
378
+ const by = new Map<string, number>();
379
+ for (const [id, f] of parts) for (const e of wt[id]) by.set(e.bone, (by.get(e.bone) ?? 0) + e.weight * f);
380
+ const sum = [...by.values()].reduce((s, x) => s + x, 0);
381
+ return roundShares([...by].map(([bone, weight]) => ({ bone, weight: weight / sum })));
382
+ };
383
+ const addVertex = (x: number, y: number, shares: Influence[]): number => {
384
+ const id = live.length;
385
+ setPos(id, x, y);
386
+ live.push(true);
387
+ uv.push([pyRound(x / w, 6), pyRound(y / h, 6)]);
388
+ wt.push(bind(shares, x, y));
389
+ added.add(id);
390
+ return id;
391
+ };
392
+ /** The split of edge a–b at (x, y): its one or two triangles become two or four. */
393
+ const splitEdge = (a: number, b: number, x: number, y: number): void => {
394
+ const t = owner.get(dkey(a, b));
395
+ const u = owner.get(dkey(b, a));
396
+ const dx = PX[b] - PX[a];
397
+ const dy = PY[b] - PY[a];
398
+ const s = Math.min(1, Math.max(0, ((x - PX[a]) * dx + (y - PY[a]) * dy) / (dx * dx + dy * dy)));
399
+ const sx = snap(x);
400
+ const sy = snap(y);
401
+ const sides: Array<[number, number, number]> = [];
402
+ if (t !== undefined) sides.push([a, b, third(t, a, b)]);
403
+ if (u !== undefined) sides.push([b, a, third(u, b, a)]);
404
+ for (const [p, q, c] of sides) {
405
+ // the new point must be strictly inside the triangles' union on each side: (p, new, c) and (new, q, c) wound > 0.
406
+ const o1 = (sx - X[p]) * (Y[c] - Y[p]) - (sy - Y[p]) * (X[c] - X[p]);
407
+ const o2 = (X[q] - sx) * (Y[c] - sy) - (Y[q] - sy) * (X[c] - sx);
408
+ if (!(o1 > 0 && o2 > 0)) throw new OpRefused('REMESH_DELTA_OP', '', `puts the vertex at crop (${pyRound(x + ox, 4)}, ${pyRound(y + oy, 4)}), outside the triangles on edge ${a}-${b} (it would fold triangle ${p}, ${q}, ${c})`);
409
+ }
410
+ const id = addVertex(x, y, blend([[a, 1 - s], [b, s]]));
411
+ const made: number[] = [];
412
+ if (t !== undefined) {
413
+ const c = third(t, a, b);
414
+ setTri(t, a, id, c);
415
+ made.push(t, newTri(id, b, c));
416
+ }
417
+ if (u !== undefined) {
418
+ const d = third(u, b, a);
419
+ setTri(u, b, id, d);
420
+ made.push(u, newTri(id, a, d));
421
+ }
422
+ if (t === undefined || u === undefined) {
423
+ const ia = hull.indexOf(a);
424
+ const ib = hull.indexOf(b);
425
+ const n = hull.length;
426
+ if (ia < 0 || ib < 0 || ((ia + 1) % n !== ib && (ib + 1) % n !== ia)) throw new Error(`remeshdelta: boundary edge ${a}-${b} is not consecutive on the hull loop`);
427
+ const first = (ia + 1) % n === ib ? ia : ib;
428
+ hull.splice(first + 1, 0, id);
429
+ }
430
+ legalize(edgesOf(made));
431
+ };
432
+
433
+ const ops = doc.ops;
434
+ let constrainedCount = 0;
435
+ for (let i = 0; i < ops.length; i++) {
436
+ const op = ops[i];
437
+ try {
438
+ if (op.op === 'move') {
439
+ mustLive(op.v, 'vertex');
440
+ const [fx, fy] = crop(op.v);
441
+ if (Math.abs(fx - op.from[0]) > FROM_TOLERANCE_PX || Math.abs(fy - op.from[1]) > FROM_TOLERANCE_PX) {
442
+ throw new OpRefused('REMESH_DELTA_OP', '', `moves vertex ${op.v} from (${op.from[0]}, ${op.from[1]}); its live position is (${pyRound(fx, 4)}, ${pyRound(fy, 4)}) — from does not match within ${FROM_TOLERANCE_PX} px, so the delta was made over another mesh`);
443
+ }
444
+ const x = op.to[0] - ox;
445
+ const y = op.to[1] - oy;
446
+ if (!inImage(x, y)) throw new OpRefused('REMESH_DELTA_OP', '', `moves vertex ${op.v} to crop (${op.to[0]}, ${op.to[1]}), outside the part's image (crop ${ox}..${ox + w} x ${oy}..${oy + h}); the texture has no sample there`);
447
+ const keep: Vec = [PX[op.v], PY[op.v]];
448
+ const keepG: Vec = [X[op.v], Y[op.v]];
449
+ setPos(op.v, x, y);
450
+ const star = starOf(op.v);
451
+ const folded = star.find((t) => {
452
+ const r = tris[t] as [number, number, number];
453
+ return orient(r[0], r[1], r[2]) <= 0;
454
+ });
455
+ if (folded !== undefined) {
456
+ PX[op.v] = keep[0];
457
+ PY[op.v] = keep[1];
458
+ X[op.v] = keepG[0];
459
+ Y[op.v] = keepG[1];
460
+ throw new OpRefused('REMESH_DELTA_OP', '', `moves vertex ${op.v} across an edge of its own ring: triangle ${(tris[folded] as number[]).join(', ')} would fold; move it in smaller steps, or remove and add it`);
461
+ }
462
+ uv[op.v] = [pyRound(x / w, 6), pyRound(y / h, 6)];
463
+ if (plant.skipBind !== true) wt[op.v] = bind(wt[op.v], x, y);
464
+ moved.add(op.v);
465
+ legalize(edgesOf(star));
466
+ } else if (op.op === 'add') {
467
+ const x = op.at[0] - ox;
468
+ const y = op.at[1] - oy;
469
+ if (!inImage(x, y)) throw new OpRefused('REMESH_DELTA_OP', '', `adds a vertex at crop (${op.at[0]}, ${op.at[1]}), outside the part's image (crop ${ox}..${ox + w} x ${oy}..${oy + h}); the texture has no sample there`);
470
+ const sx = snap(x);
471
+ const sy = snap(y);
472
+ if (op.onEdge !== undefined) {
473
+ const [a, b] = op.onEdge;
474
+ mustLive(a, 'vertex');
475
+ mustLive(b, 'vertex');
476
+ if (a === b) throw new OpRefused('REMESH_DELTA_OP', '', `splits edge ${a}-${b}, which joins a vertex to itself`);
477
+ if (!isEdge(a, b)) throw new OpRefused('REMESH_DELTA_OP', '', `splits a missing edge ${a}-${b}: the producer's triangulation has no such edge at this op`);
478
+ splitEdge(a, b, x, y);
479
+ } else {
480
+ let placedIn = false;
481
+ for (let t = 0; t < tris.length && !placedIn; t++) {
482
+ const r = tris[t];
483
+ if (r === null) continue;
484
+ const [a, b, c] = r;
485
+ const ob = (p: number, q: number): number => (X[q] - X[p]) * (sy - Y[p]) - (Y[q] - Y[p]) * (sx - X[p]);
486
+ const o1 = ob(a, b);
487
+ const o2 = ob(b, c);
488
+ const o3 = ob(c, a);
489
+ if (o1 < 0 || o2 < 0 || o3 < 0) continue;
490
+ const zeros = [o1, o2, o3].filter((z) => z === 0).length;
491
+ if (zeros >= 2) {
492
+ const at = [a, b, c].find((id) => X[id] === sx && Y[id] === sy);
493
+ throw new OpRefused('REMESH_DELTA_OP', '', `adds a vertex at crop (${op.at[0]}, ${op.at[1]}), on vertex ${at ?? a}; distinct positions are required`);
494
+ }
495
+ placedIn = true;
496
+ if (zeros === 1) {
497
+ const [p, q] = o1 === 0 ? [a, b] : o2 === 0 ? [b, c] : [c, a];
498
+ splitEdge(p, q, x, y);
499
+ break;
500
+ }
501
+ const area = orient(a, b, c);
502
+ const id = addVertex(x, y, blend([[a, o2 / area], [b, o3 / area], [c, o1 / area]]));
503
+ setTri(t, a, b, id);
504
+ const t2 = newTri(b, c, id);
505
+ const t3 = newTri(c, a, id);
506
+ legalize(edgesOf([t, t2, t3]));
507
+ }
508
+ if (!placedIn) throw new OpRefused('REMESH_DELTA_OP', '', `adds a free vertex at crop (${op.at[0]}, ${op.at[1]}), outside every triangle of the mesh; a vertex outside the mesh is added on a hull edge (onEdge)`);
509
+ }
510
+ } else if (op.op === 'remove') {
511
+ const v = op.v;
512
+ mustLive(v, 'vertex');
513
+ const held = [...constrained].find((k) => k.split(':').map(Number).includes(v));
514
+ if (held !== undefined) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, an end of constraint edge ${held.replace(':', '-')}; unedge it first`);
515
+ const onHull = hull.includes(v);
516
+ if (liveCount() - 1 < 3 || (onHull && hull.length - 1 < 3)) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, which would leave ${onHull && hull.length - 1 < 3 ? `a hull of ${hull.length - 1}` : `${liveCount() - 1} vertices`}; at least 3 are required`);
517
+ const star = starOf(v);
518
+ const next = new Map<number, number>();
519
+ for (const t of star) {
520
+ const r = tris[t] as [number, number, number];
521
+ const k = r.indexOf(v);
522
+ next.set(r[(k + 1) % 3], r[(k + 2) % 3]);
523
+ }
524
+ const ys = new Set(next.values());
525
+ const starts = [...next.keys()].filter((x) => !ys.has(x));
526
+ const start = onHull ? starts[0] : (tris[star[0]] as number[])[((tris[star[0]] as number[]).indexOf(v) + 1) % 3];
527
+ if (start === undefined || (onHull && starts.length !== 1) || (!onHull && starts.length !== 0)) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, whose ring is not one ${onHull ? 'open' : 'closed'} fan`);
528
+ const ring: number[] = [start];
529
+ for (let cur = start; next.has(cur); ) {
530
+ cur = next.get(cur) as number;
531
+ if (cur === start) break;
532
+ ring.push(cur);
533
+ if (ring.length > star.length + 1) throw new Error(`remeshdelta: the ring of vertex ${v} does not close`);
534
+ }
535
+ if (onHull) {
536
+ const n = hull.length;
537
+ const k = hull.indexOf(v);
538
+ if (ring[0] !== hull[(k + 1) % n] || ring[ring.length - 1] !== hull[(k - 1 + n) % n]) throw new OpRefused('REMESH_DELTA_OP', '', `removes hull vertex ${v}, whose fan does not run from one hull neighbour to the other`);
539
+ }
540
+ // ear clipping, exact: the polygon is wound > 0; the first ear in ring order is cut each time.
541
+ const poly = [...ring];
542
+ const made: Array<[number, number, number]> = [];
543
+ while (poly.length > 3) {
544
+ let cut = -1;
545
+ for (let j = 0; j < poly.length && cut < 0; j++) {
546
+ const p = poly[(j - 1 + poly.length) % poly.length];
547
+ const c = poly[j];
548
+ const q = poly[(j + 1) % poly.length];
549
+ if (orient(p, c, q) <= 0) continue;
550
+ const blocked = poly.some((r) => r !== p && r !== c && r !== q && orient(p, c, r) >= 0 && orient(c, q, r) >= 0 && orient(q, p, r) >= 0);
551
+ if (!blocked) cut = j;
552
+ }
553
+ if (cut < 0) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, whose ring (${poly.join(', ')}) has no ear to clip: it cannot be triangulated without the vertex`);
554
+ made.push([poly[(cut - 1 + poly.length) % poly.length], poly[cut], poly[(cut + 1) % poly.length]]);
555
+ poly.splice(cut, 1);
556
+ }
557
+ if (poly.length === 3) {
558
+ if (orient(poly[0], poly[1], poly[2]) <= 0) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, whose ring closes on a flat triangle (${poly.join(', ')})`);
559
+ made.push([poly[0], poly[1], poly[2]]);
560
+ }
561
+ for (const t of star) dropTri(t);
562
+ live[v] = false;
563
+ removed.add(v);
564
+ moved.delete(v);
565
+ if (onHull) hull.splice(hull.indexOf(v), 1);
566
+ const ts = made.map(([a, b, c]) => newTri(a, b, c));
567
+ legalize(edgesOf(ts));
568
+ } else if (op.op === 'edge') {
569
+ const { a, b } = op;
570
+ mustLive(a, 'vertex');
571
+ mustLive(b, 'vertex');
572
+ const key = ekey(a, b);
573
+ if (constrained.has(key)) throw new OpRefused('REMESH_DELTA_OP', '', `duplicates constraint edge ${a}-${b}`);
574
+ if (isHullEdge(a, b)) throw new OpRefused('REMESH_DELTA_OP', '', `constrains hull edge ${a}-${b}, which no flip ever removes`);
575
+ if (!isEdge(a, b)) {
576
+ const on = live.findIndex((l, k) => l && k !== a && k !== b && orient(a, b, k) === 0 && (X[k] - X[a]) * (X[b] - X[k]) + (Y[k] - Y[a]) * (Y[b] - Y[k]) > 0);
577
+ if (on >= 0) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, whose segment runs through vertex ${on}; constrain ${a}-${on} and ${on}-${b}`);
578
+ const changed = new Set<number>();
579
+ let guard = 0;
580
+ for (;;) {
581
+ if (isEdge(a, b)) break;
582
+ if (++guard > 4 * tris.length + 16) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, which edge flips could not recover`);
583
+ let crossing: { t: number; u: number; p: number; q: number } | null = null;
584
+ let flipped = false;
585
+ for (let t = 0; t < tris.length && !flipped; t++) {
586
+ const r = tris[t];
587
+ if (r === null) continue;
588
+ for (let k = 0; k < 3 && !flipped; k++) {
589
+ const p = r[k];
590
+ const q = r[(k + 1) % 3];
591
+ if (p === a || p === b || q === a || q === b) continue;
592
+ if (!(Math.sign(orient(a, b, p)) * Math.sign(orient(a, b, q)) < 0 && Math.sign(orient(p, q, a)) * Math.sign(orient(p, q, b)) < 0)) continue;
593
+ const u = owner.get(dkey(q, p));
594
+ if (u === undefined) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, whose segment crosses hull edge ${p}-${q}: it would leave the mesh`);
595
+ if (constrained.has(ekey(p, q))) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, whose segment crosses constraint edge ${p}-${q}`);
596
+ crossing ??= { t, u, p, q };
597
+ const c = third(t, p, q);
598
+ const d = third(u, q, p);
599
+ if (!(orient(p, d, c) > 0 && orient(q, c, d) > 0)) continue;
600
+ setTri(t, p, d, c);
601
+ setTri(u, q, c, d);
602
+ changed.add(t).add(u);
603
+ flipped = true;
604
+ }
605
+ }
606
+ if (!flipped) throw new OpRefused('REMESH_DELTA_OP', '', crossing === null ? `constrains ${a}-${b}, whose segment crosses no edge and is no edge: it runs outside the mesh` : `constrains ${a}-${b}: no edge it crosses can be flipped`);
607
+ }
608
+ constrained.add(key);
609
+ legalize(edgesOf([...changed]));
610
+ } else constrained.add(key);
611
+ constrainedCount++;
612
+ } else {
613
+ const { a, b } = op;
614
+ mustLive(a, 'vertex');
615
+ mustLive(b, 'vertex');
616
+ if (isHullEdge(a, b)) throw new OpRefused('REMESH_DELTA_OP', '', `unedges hull edge ${a}-${b}; the hull is the mesh's outline and is never removed`);
617
+ if (!constrained.has(ekey(a, b))) throw new OpRefused('REMESH_DELTA_OP', '', `unedges ${a}-${b}: unedge names an edge this delta did not constrain; the producer owns the triangulation`);
618
+ constrained.delete(ekey(a, b));
619
+ constrainedCount--;
620
+ legalize([[a, b]]);
621
+ }
622
+ } catch (err) {
623
+ if (!(err instanceof OpRefused)) throw err;
624
+ return [{ code: err.code, object: err.at === '' ? `${object} ops[${i}]` : err.at, detail: `${JSON.stringify(op)} ${err.why}` }];
625
+ }
626
+ }
627
+
628
+ // The output: the hull loop first, in order, then every other live vertex in id order.
629
+ const ids = X.map((_, i) => i).filter((i) => live[i]);
630
+ const order = plant.skipHullOrder === true ? ids : [...hull, ...ids.filter((i) => !hull.includes(i))];
631
+ const at = new Map(order.map((id, k) => [id, k]));
632
+ const triangles: number[] = [];
633
+ for (const r of tris) {
634
+ if (r === null) continue;
635
+ const [a, b, c] = r.map((id) => at.get(id) as number);
636
+ if (sign > 0) triangles.push(a, b, c);
637
+ else triangles.push(a, c, b);
638
+ }
639
+ const attachment: MeshAttachment = {
640
+ type: 'mesh',
641
+ image: att.image,
642
+ width: w,
643
+ height: h,
644
+ uvs: order.flatMap((id) => uv[id]),
645
+ triangles,
646
+ hull: hull.length,
647
+ weights: order.map((id) => wt[id]),
648
+ };
649
+ const after = violations();
650
+ if (plant.skipTopology !== true) {
651
+ const verts = order.map((id) => [X[id] / GRID, Y[id] / GRID] as Vec);
652
+ const topo = contourTopologyProblems(input.part, verts, triangles, hull.length);
653
+ const out: Problem[] = topo.map((p) => ({ code: 'REMESH_DELTA_TOPOLOGY', object: `the replayed ${input.part}`, detail: `${p.code}: ${p.detail}; the result is measured, not trusted, and nothing is written` }));
654
+ if (after > before) out.push({ code: 'REMESH_DELTA_TOPOLOGY', object: `the replayed ${input.part}`, detail: `has ${after} interior edge(s) not locally Delaunay (constraint edges not asked) where the input has ${before}; the replay may not add one, and nothing is written` });
655
+ if (out.length > 0) return out;
656
+ }
657
+ const untouched = X.map((_, i) => i).filter((i) => i < V0 && live[i] && !moved.has(i));
658
+ for (const id of untouched) {
659
+ if (JSON.stringify(uv[id]) !== JSON.stringify([att.uvs[2 * id], att.uvs[2 * id + 1]]) || JSON.stringify(wt[id]) !== JSON.stringify(att.weights[id])) throw new Error(`remeshdelta: untouched vertex ${id} changed`);
660
+ }
661
+ return {
662
+ attachment,
663
+ counts: { vertices: order.length, triangles: triangles.length / 3, hull: hull.length },
664
+ applied: { ops: ops.length, moved: moved.size, added: added.size, removed: removed.size, constrained: constrainedCount },
665
+ untouched,
666
+ delaunay: { before, after },
667
+ index: ids.map((id) => [id, at.get(id) as number]),
668
+ };
669
+ }