@rig-c/parts 1.42.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }