rig-c 2.26.0 → 2.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-c",
3
- "version": "2.26.0",
3
+ "version": "2.27.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -252,6 +252,46 @@ export interface MeshReductionInput {
252
252
  * 2 or more, and has no default; `null` is refused like any value that is not that object.
253
253
  */
254
254
  boundaryRuns?: BoundaryRuns;
255
+ /**
256
+ * The triangulation post-pass (issue #1283, §8 Q3/Q9) — opt-in. Left out = the returned mesh carries the triangles
257
+ * the removals left, and the call is the one it was before the field existed. `'delaunay'`: once the reduction has
258
+ * ended (or a replay has stopped), the kept vertices are re-triangulated by Delaunay edge flips — no vertex added,
259
+ * moved or removed; no outline edge, protected edge or edge a region holds is flipped, and no flip makes an edge a
260
+ * region holds or one over `protect.weightJump` — and the pass is taken whole only when every required row still
261
+ * passes on its result, else the mesh is returned as the removals left it and the report names the row
262
+ * (`ReductionChanges.retriangulation`). It is not a step: it tries no candidate and is not counted in the budget.
263
+ * Any other value, `null` included, is refused.
264
+ */
265
+ retriangulate?: 'delaunay';
266
+ /**
267
+ * The order single removals are attempted in (issue #1283, §8 Q11) — opt-in. Left out = ascending source index.
268
+ * `'deformation-load'`: at the start of each pass's single removals, every candidate is ranked by the deformation
269
+ * load its removal would add — the largest L · Δshare over the edges the re-triangulated hole adds, L the edge's
270
+ * length in px and Δshare half the L1 difference of its ends' weight vectors — and attempted in ascending load, ties
271
+ * by source index; a removal that cannot be made ranks last. Boundary runs keep their own order. Reads weights only:
272
+ * on an unweighted source every load is 0 and the order is the default's. Any other value, `null` included, is
273
+ * refused.
274
+ */
275
+ removalOrder?: 'deformation-load';
276
+ }
277
+
278
+ /**
279
+ * Issue #1283: which triangulation a reduced mesh carries, written in `ReductionChanges` only when the input set
280
+ * `retriangulate`.
281
+ */
282
+ export interface Retriangulation {
283
+ /** The input's `retriangulate`. */
284
+ method: 'delaunay';
285
+ /** Whether the returned mesh carries the pass's triangles (true) or the removals' (false, `refusedBy` says why). */
286
+ taken: boolean;
287
+ /** Edge flips the pass made — on the returned mesh when taken, on the one it refused otherwise. */
288
+ flips: number;
289
+ /** Sweeps over the interior edges, the last of them flipping none. */
290
+ sweeps: number;
291
+ /** Null when taken; else the first required row that failed on the pass's result, or the bound the pass reached. */
292
+ refusedBy: string | null;
293
+ /** What the operation does not promise about the accepted steps under the pass — the same sentence on every report. */
294
+ monotonicity: string;
255
295
  }
256
296
 
257
297
  /** Issue #1279: the opt-in to boundary runs — the most consecutive outline vertices one chord may replace. */
@@ -495,6 +535,8 @@ export interface ReductionChanges {
495
535
  * `stopAfterAccepted: k` replays to the operation at `acceptedAt[k - 1]`.
496
536
  */
497
537
  acceptedAt: AcceptedOperation[];
538
+ /** Issue #1283: which triangulation the mesh carries — present only when the input set `retriangulate`, written last. */
539
+ retriangulation?: Retriangulation;
498
540
  }
499
541
 
500
542
  export interface MeshQualityReport {
@@ -544,6 +586,10 @@ export interface EffectiveSettings {
544
586
  boundaryRuns?: BoundaryRuns;
545
587
  /** A measurement's `motionAmplitude`, echoed when the input set it — `null` included (issue #1280); absent otherwise. */
546
588
  motionAmplitude?: MotionAmplitude | null;
589
+ /** A reduction's `retriangulate`, echoed when the input set it (issue #1283); absent otherwise. */
590
+ retriangulate?: 'delaunay';
591
+ /** A reduction's `removalOrder`, echoed when the input set it (issue #1283); absent otherwise — the order was ascending source index. */
592
+ removalOrder?: 'deformation-load';
547
593
  }
548
594
 
549
595
  export interface MeshCounts {
@@ -2259,6 +2305,8 @@ function effectiveJson(e: EffectiveSettings): Json {
2259
2305
  ...(e.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: e.stopAfterAccepted }),
2260
2306
  ...(e.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: e.boundaryRuns.maxVertices } }),
2261
2307
  ...(e.motionAmplitude === undefined ? {} : { motionAmplitude: amplitudeJson(e.motionAmplitude) }),
2308
+ ...(e.retriangulate === undefined ? {} : { retriangulate: e.retriangulate }),
2309
+ ...(e.removalOrder === undefined ? {} : { removalOrder: e.removalOrder }),
2262
2310
  };
2263
2311
  }
2264
2312
 
@@ -2360,6 +2408,18 @@ function candidateJson(c: CandidateReport): Json {
2360
2408
  deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
2361
2409
  linkedMeshes: k.linkedMeshes.map(attachmentJson),
2362
2410
  acceptedAt: k.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
2411
+ ...(k.retriangulation === undefined
2412
+ ? {}
2413
+ : {
2414
+ retriangulation: {
2415
+ method: k.retriangulation.method,
2416
+ taken: k.retriangulation.taken,
2417
+ flips: k.retriangulation.flips,
2418
+ sweeps: k.retriangulation.sweeps,
2419
+ refusedBy: k.retriangulation.refusedBy,
2420
+ monotonicity: k.retriangulation.monotonicity,
2421
+ },
2422
+ }),
2363
2423
  };
2364
2424
  }
2365
2425
  return out;
package/src/meshreduce.ts CHANGED
@@ -55,6 +55,21 @@
55
55
  * Every accepted operation is one `acceptedAt` entry and one unit of
56
56
  * `stopAfterAccepted`.
57
57
  *
58
+ * With `removalOrder: 'deformation-load'` (issue #1283, opt-in) each pass's
59
+ * single removals are attempted in ascending predicted deformation load, ties
60
+ * by source index, ranked once at the start of those singles (`loadOrder`);
61
+ * every attempt is still a candidate, held exactly as before.
62
+ *
63
+ * ## The triangulation post-pass (issue #1283, opt-in)
64
+ *
65
+ * With `retriangulate: 'delaunay'`, once the reduction ends — a replay's stop
66
+ * included — the kept vertices are re-triangulated by Delaunay edge flips that
67
+ * never touch the outline, a protected edge or an edge a region holds
68
+ * (`delaunayFlips`), and the result is taken whole only when every required
69
+ * row passes on it, else refused whole and named (`retriangulateResult`). It
70
+ * is not a step: no candidate, no `acceptedAt` entry, so a replay and the
71
+ * budget cut at the same step end on the same state and the same pass.
72
+ *
58
73
  * A removal whose deviation floor — the furthest removed source-hull vertex
59
74
  * from the candidate's outline, which `MQ_BOUNDARY_DEVIATION` can only exceed —
60
75
  * is over the bound is refused without the measurement; its name, when read,
@@ -117,6 +132,7 @@ import {
117
132
  type MeshReductionInput,
118
133
  type RefinementRegion,
119
134
  type ReductionChanges,
135
+ type Retriangulation,
120
136
  type SourceMesh,
121
137
  type StopNotReached,
122
138
  type Termination,
@@ -283,6 +299,12 @@ function validateReduction(input: MeshReductionInput): void {
283
299
  refuse('REDUCE_INPUT_MISSING', `${who}: boundaryRuns is ${JSON.stringify(runs)}; required { maxVertices: a whole number, 2 or more } — the longest run one chord may replace, which has no default — or the field left out (issue #1279: no run is tried)`);
284
300
  }
285
301
  }
302
+ if ('retriangulate' in input && input.retriangulate !== undefined && input.retriangulate !== 'delaunay') {
303
+ refuse('REDUCE_INPUT_MISSING', `${who}: retriangulate is ${JSON.stringify(input.retriangulate)}; required "delaunay" — the kept vertices re-triangulated by Delaunay edge flips once the reduction ends — or the field left out (issue #1283: the triangles the removals leave)`);
304
+ }
305
+ if ('removalOrder' in input && input.removalOrder !== undefined && input.removalOrder !== 'deformation-load') {
306
+ refuse('REDUCE_INPUT_MISSING', `${who}: removalOrder is ${JSON.stringify(input.removalOrder)}; required "deformation-load" — single removals in ascending predicted deformation load, ties by source index — or the field left out (issue #1283: ascending source index)`);
307
+ }
286
308
  const src = input.source;
287
309
  if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
288
310
  const n = src.points.length;
@@ -1021,7 +1043,26 @@ export type ReplayPlant = 'stop-one-early' | 'stop-one-late';
1021
1043
  * measured, with no deviation floor (the path the floor is held equal to); and the floor read half a pixel short
1022
1044
  * of the bound, so it refuses steps the rows would take.
1023
1045
  */
1024
- export type ReductionPlant = ReplayPlant | 'runs-without-opt-in' | 'run-split-per-vertex' | 'run-skips-rows' | 'measure-every-candidate' | 'floor-half-a-pixel-short';
1046
+ export type ReductionPlant =
1047
+ | ReplayPlant
1048
+ | 'runs-without-opt-in'
1049
+ | 'run-split-per-vertex'
1050
+ | 'run-skips-rows'
1051
+ | 'measure-every-candidate'
1052
+ | 'floor-half-a-pixel-short'
1053
+ | RetriangulationPlant
1054
+ | OrderPlant;
1055
+
1056
+ /**
1057
+ * Issue #1283's faults in the triangulation post-pass: the pass run on a call that did not opt in; the pass skipped
1058
+ * when a replay stops the run, so a replay is not the budget cut at the same step; the flip
1059
+ * criterion inverted, so the pass flips edges that are locally Delaunay — the flips a declared row can refuse — with
1060
+ * its result measured as the pass's is, or taken without the measurement; and the pass flipping edges a region holds.
1061
+ */
1062
+ export type RetriangulationPlant = 'flips-without-opt-in' | 'flips-not-on-replay' | 'flips-against-delaunay' | 'flips-against-delaunay-unmeasured' | 'flips-ignore-regions';
1063
+
1064
+ /** Issue #1283's faults in the removal order: the load order used by a call that did not opt in, and the loads ranked descending. */
1065
+ export type OrderPlant = 'order-without-opt-in' | 'order-descending';
1025
1066
 
1026
1067
  /** One removal-phase attempt as a control reads it (issue #1279): what was tried, and what refused it or null when taken. */
1027
1068
  export interface AttemptRecord {
@@ -1032,6 +1073,10 @@ export interface AttemptRecord {
1032
1073
  refusedBy: (() => string) | null;
1033
1074
  /** Whether the deviation floor refused it without a measurement. */
1034
1075
  decidedByFloor: boolean;
1076
+ /** The removal pass it was tried in, 1-based (issue #1283). */
1077
+ pass: number;
1078
+ /** A single removal under `removalOrder: 'deformation-load'`: the load it was ranked by (Infinity when the removal cannot be made); else null (issue #1283). */
1079
+ predictedLoad: number | null;
1035
1080
  }
1036
1081
 
1037
1082
  /** Called once per removal-phase attempt, after it. */
@@ -1179,6 +1224,46 @@ function weightJump(work: Work, a: number, b: number): number {
1179
1224
  return sum;
1180
1225
  }
1181
1226
 
1227
+ /**
1228
+ * Issue #1283, §8's deformation load of one edge: its length in px times half the L1 difference of its ends' weight
1229
+ * vectors (Δshare, the share of the field that changes along it). 0 on an unweighted mesh.
1230
+ */
1231
+ function edgeLoad(work: Work, a: number, b: number): number {
1232
+ const dx = work.pos[a][0] - work.pos[b][0];
1233
+ const dy = work.pos[a][1] - work.pos[b][1];
1234
+ return Math.sqrt(dx * dx + dy * dy) * (weightJump(work, a, b) / 2);
1235
+ }
1236
+
1237
+ /**
1238
+ * Issue #1283 (`removalOrder: 'deformation-load'`): the surviving, unprotected source vertices in the order a pass
1239
+ * attempts their single removals — ascending by the predicted load of each removal, the largest `edgeLoad` over the
1240
+ * edges its re-triangulated hole adds (0 when it adds none), a removal `removalOf` cannot make ranked Infinity; ties
1241
+ * by source index. Nothing is measured and nothing is counted as a candidate: one `removalOf` per vertex and one
1242
+ * sort. The plant `order-descending` ranks the loads the other way.
1243
+ */
1244
+ function loadOrder(run: Run): { order: number[]; load: Map<number, number> } {
1245
+ const { work } = run;
1246
+ const load = new Map<number, number>();
1247
+ const order: number[] = [];
1248
+ for (let v = 0; v < work.nSource; v++) {
1249
+ if (!work.alive[v] || run.protectedVertices.has(v)) continue;
1250
+ const step = removalOf(work, v);
1251
+ let most = 0;
1252
+ if ('blocked' in step) most = Infinity;
1253
+ else for (const [a, b] of step.added) most = Math.max(most, edgeLoad(work, a, b));
1254
+ load.set(v, most);
1255
+ order.push(v);
1256
+ }
1257
+ const sign = run.plant === 'order-descending' ? -1 : 1;
1258
+ order.sort((p, q) => {
1259
+ const lp = load.get(p)!;
1260
+ const lq = load.get(q)!;
1261
+ if (lp !== lq) return lp < lq ? -sign : sign;
1262
+ return p - q;
1263
+ });
1264
+ return { order, load };
1265
+ }
1266
+
1182
1267
  /**
1183
1268
  * Several boundary vertices removed one after another as one operation (issue #1279): the working triangles after
1184
1269
  * the last and the edges they hold that the triangles before the first did not, in the order they appear — or the
@@ -1250,12 +1335,15 @@ function outlineWalk(run: Run): number[] {
1250
1335
  function removeVertices(run: Run): PhaseEnd {
1251
1336
  const { work, input } = run;
1252
1337
  const runs = run.plant === 'runs-without-opt-in' ? { maxVertices: 2 } : input.boundaryRuns;
1338
+ const byLoad = input.removalOrder === 'deformation-load' || run.plant === 'order-without-opt-in';
1253
1339
  const hull = input.source.hull;
1254
1340
  let lastBlock: { refusal: Refusal; what: string } | null = null;
1255
1341
  const lastName = (): string => (lastBlock === null ? '' : `${refusalText(lastBlock.refusal)}, ${lastBlock.what}`);
1342
+ let pass = 0;
1256
1343
  for (;;) {
1257
1344
  let taken = 0;
1258
1345
  let tried = 0;
1346
+ pass++;
1259
1347
  if (runs !== undefined) {
1260
1348
  let walk: number[] | null = null;
1261
1349
  for (let s = 0; s < hull; s++) {
@@ -1275,7 +1363,7 @@ function removeVertices(run: Run): PhaseEnd {
1275
1363
  run.steps++;
1276
1364
  tried++;
1277
1365
  const block = tryOperation(run, members, false);
1278
- if (run.observe !== null) run.observe({ step: run.steps, kind: 'boundary-run', sourceVertices: [...members], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' });
1366
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'boundary-run', sourceVertices: [...members], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function', pass, predictedLoad: null });
1279
1367
  if (block === null) {
1280
1368
  taken++;
1281
1369
  walk = null;
@@ -1286,13 +1374,18 @@ function removeVertices(run: Run): PhaseEnd {
1286
1374
  }
1287
1375
  }
1288
1376
  }
1289
- for (let v = 0; v < work.nSource; v++) {
1377
+ // Issue #1283: with the load order the pass's singles are ranked once, here, over the mesh the runs left; without
1378
+ // it they are every source index ascending, exactly as before the field.
1379
+ const ranked = byLoad ? loadOrder(run) : null;
1380
+ const singles = ranked === null ? null : ranked.order;
1381
+ for (let i = 0, end = singles === null ? work.nSource : singles.length; i < end; i++) {
1382
+ const v = singles === null ? i : singles[i];
1290
1383
  if (!work.alive[v] || run.protectedVertices.has(v)) continue;
1291
1384
  if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
1292
1385
  run.steps++;
1293
1386
  tried++;
1294
1387
  const block = tryOperation(run, [v], false);
1295
- if (run.observe !== null) run.observe({ step: run.steps, kind: 'removal', sourceVertices: [v], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function' });
1388
+ if (run.observe !== null) run.observe({ step: run.steps, kind: 'removal', sourceVertices: [v], refusedBy: block === null ? null : () => refusalText(block), decidedByFloor: typeof block === 'function', pass, predictedLoad: ranked === null ? null : ranked.load.get(v)! });
1296
1389
  if (block === null) {
1297
1390
  taken++;
1298
1391
  if (accept(run, 'removal', [v])) return { kind: 'replayed' };
@@ -1399,6 +1492,178 @@ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Ref
1399
1492
  return null;
1400
1493
  }
1401
1494
 
1495
+ // ---------------------------------------------------------------------------
1496
+ // the triangulation post-pass (issue #1283)
1497
+ // ---------------------------------------------------------------------------
1498
+
1499
+ /**
1500
+ * How far past π the two angles opposite an edge have to sum before the edge is flipped, read as the sine of their
1501
+ * sum: the flip is made when that sine is below `-FLIP_MARGIN`. The flip turns the sum into 2π minus it, so its sine
1502
+ * changes sign and the new edge is never flipped back in the same or a later sweep; cocircular quads (sine within the
1503
+ * margin) are left as they are.
1504
+ */
1505
+ const FLIP_MARGIN = 1e-9;
1506
+
1507
+ /** Twice the signed area of p, q, r in drawing px (y down), the orientation test the flip reads. */
1508
+ function orient(p: Pt, q: Pt, r: Pt): number {
1509
+ return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
1510
+ }
1511
+
1512
+ /**
1513
+ * sin(α + β) for the angles α at `c` and β at `d` opposite the edge a–b — negative exactly when α + β > π, the edge
1514
+ * is then not locally Delaunay. From cross and dot products and square roots only, so it reads the same wherever IEEE
1515
+ * arithmetic does (no libm call).
1516
+ */
1517
+ function oppositeAngleSine(a: Pt, b: Pt, c: Pt, d: Pt): number {
1518
+ const angle = (o: Pt): { sin: number; cos: number } => {
1519
+ const ux = a[0] - o[0];
1520
+ const uy = a[1] - o[1];
1521
+ const vx = b[0] - o[0];
1522
+ const vy = b[1] - o[1];
1523
+ const lengths = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy);
1524
+ return { sin: Math.abs(ux * vy - uy * vx) / lengths, cos: (ux * vx + uy * vy) / lengths };
1525
+ };
1526
+ const p = angle(c);
1527
+ const q = angle(d);
1528
+ return p.sin * q.cos + p.cos * q.sin;
1529
+ }
1530
+
1531
+ /** The sentence every report of a call with `retriangulate` carries (§8 Q4, [agreed, rig-parts#126]). */
1532
+ const MONOTONICITY_NOT_PROMISED =
1533
+ 'not promised: the pass re-triangulates whatever mesh the removals leave, so along acceptedAt a step whose result passes a comparison may be followed by one that fails and then by one that passes again; a bisection over acceptedAt finds a passing step, not necessarily the last (§8 Q4, agreed on rig-parts#126)';
1534
+
1535
+ type FlipResult = { triangles: Array<[number, number, number]>; flips: number; sweeps: number } | { bound: string; flips: number; sweeps: number };
1536
+
1537
+ /**
1538
+ * Lawson's edge flips over the working mesh's interior edges, toward the Delaunay triangulation of the same vertices
1539
+ * constrained by what the mesh must keep. A sweep visits every interior edge (two triangles) in ascending (smaller id,
1540
+ * larger id), skipping an edge whose triangles a flip of this sweep already replaced, and flips it when:
1541
+ *
1542
+ * - the quad its two triangles form is strictly convex, so both new triangles keep the winding;
1543
+ * - the angles opposite it sum past π by `FLIP_MARGIN` (`oppositeAngleSine`) — the Delaunay criterion;
1544
+ * - it is not a protected edge (`protect.edges`, and every source edge over `protect.weightJump`);
1545
+ * - no declared region holds it, and none would hold the new edge (`edgeIsHeldByRegion`), so every region row reads
1546
+ * the same edges and lengths after the pass as before it;
1547
+ * - the new edge is a source edge, or its ends' weight vectors are within `protect.weightJump` (condition (b), as a
1548
+ * removal's new edges are held).
1549
+ *
1550
+ * The outline is never touched (an outline edge has one triangle) and no vertex is added, moved or removed. Sweeps
1551
+ * continue until one flips nothing. **Bounded work:** every flip made is one the criterion allows, which lowers the
1552
+ * triangulation's lifting onto the paraboloid strictly, so an edge flipped away never comes back and a pass makes at
1553
+ * most n(n − 1)/2 flips over n vertices; a sweep that continues has flipped at least one, so there are at most one more
1554
+ * sweeps than flips, and each visits at most 3n interior edges. The bound is also held by count: reaching it ends the
1555
+ * pass with the bound named rather than the triangles, which the criterion makes unreachable.
1556
+ */
1557
+ function delaunayFlips(run: Run, plant: ReductionPlant | null): FlipResult {
1558
+ const { work, input } = run;
1559
+ const P = work.pos;
1560
+ const tris = work.triangles.map((t): [number, number, number] => [t[0], t[1], t[2]]);
1561
+ const alive = work.alive.reduce((n, a) => n + (a ? 1 : 0), 0);
1562
+ const maxFlips = (alive * (alive - 1)) / 2;
1563
+ const guarded = new Set(run.protectedEdges.map(([a, b]) => edgeKey(a, b)));
1564
+ const jump = input.protect.weightJump;
1565
+ const regions = plant === 'flips-ignore-regions' ? [] : input.targets.regions;
1566
+ const heldCache = new Map<string, boolean>();
1567
+ const held = (a: number, b: number): boolean => {
1568
+ if (regions.length === 0) return false;
1569
+ const key = edgeKey(a, b);
1570
+ let hit = heldCache.get(key);
1571
+ if (hit === undefined) {
1572
+ hit = regions.some((r) => edgeIsHeldByRegion(P[a], P[b], r));
1573
+ heldCache.set(key, hit);
1574
+ }
1575
+ return hit;
1576
+ };
1577
+ const against = plant === 'flips-against-delaunay' || plant === 'flips-against-delaunay-unmeasured';
1578
+ let flips = 0;
1579
+ let sweeps = 0;
1580
+ for (;;) {
1581
+ sweeps++;
1582
+ const sides = new Map<string, number[]>();
1583
+ tris.forEach((t, i) => {
1584
+ for (let k = 0; k < 3; k++) {
1585
+ const key = edgeKey(t[k], t[(k + 1) % 3]);
1586
+ const list = sides.get(key);
1587
+ if (list === undefined) sides.set(key, [i]);
1588
+ else list.push(i);
1589
+ }
1590
+ });
1591
+ const interior: Array<[number, number]> = [];
1592
+ for (const [key, list] of sides) {
1593
+ if (list.length !== 2) continue;
1594
+ const [a, b] = key.split(',').map(Number);
1595
+ interior.push([a, b]);
1596
+ }
1597
+ interior.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
1598
+ const replaced = new Set<number>();
1599
+ let flipped = 0;
1600
+ for (const [u, w] of interior) {
1601
+ const [i, j] = sides.get(edgeKey(u, w))!;
1602
+ if (replaced.has(i) || replaced.has(j)) continue;
1603
+ if (guarded.has(edgeKey(u, w))) continue;
1604
+ // Rotate so the edge is t1's a → b in its own winding; t2 then holds b → a.
1605
+ const t1 = tris[i];
1606
+ const k1 = t1.findIndex((x, k) => (x === u && t1[(k + 1) % 3] === w) || (x === w && t1[(k + 1) % 3] === u));
1607
+ const a = t1[k1];
1608
+ const b = t1[(k1 + 1) % 3];
1609
+ const c = t1[(k1 + 2) % 3];
1610
+ const t2 = tris[j];
1611
+ const k2 = t2.findIndex((x, k) => x === b && t2[(k + 1) % 3] === a);
1612
+ if (k2 === -1) continue;
1613
+ const d = t2[(k2 + 2) % 3];
1614
+ if (c === d) continue;
1615
+ const turn = Math.sign(orient(P[a], P[b], P[c]));
1616
+ const o1 = orient(P[a], P[d], P[c]);
1617
+ const o2 = orient(P[d], P[b], P[c]);
1618
+ if (Math.sign(o1) !== turn || Math.sign(o2) !== turn || Math.abs(o1) < 1e-9 || Math.abs(o2) < 1e-9) continue;
1619
+ const sine = oppositeAngleSine(P[a], P[b], P[c], P[d]);
1620
+ if (against ? !(sine > FLIP_MARGIN) : !(sine < -FLIP_MARGIN)) continue;
1621
+ if (held(a, b) || held(c, d)) continue;
1622
+ if (jump !== null && !(c < work.nSource && d < work.nSource && run.sourceEdges.has(edgeKey(c, d))) && weightJump(work, c, d) > jump) continue;
1623
+ if (flips >= maxFlips) return { bound: `the flip bound n(n - 1)/2 = ${maxFlips} over ${alive} vertices was reached`, flips, sweeps };
1624
+ tris[i] = [a, d, c];
1625
+ tris[j] = [d, b, c];
1626
+ replaced.add(i);
1627
+ replaced.add(j);
1628
+ flips++;
1629
+ flipped++;
1630
+ }
1631
+ // The inverted criterion is a plant: one sweep, since flipping a Delaunay edge makes a non-Delaunay one it would
1632
+ // flip straight back.
1633
+ if (flipped === 0 || against) return { triangles: tris, flips, sweeps };
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * Issue #1283: the post-pass on the working mesh the reduction ended on, taken whole or refused whole — the working
1639
+ * triangles are the pass's only when every required row passes on its result (the measurement reads the canonical
1640
+ * mesh exactly as the result's own does). Undefined when the call did not opt in. One flip pass and at most one
1641
+ * measurement, never counted as a candidate: a replay and the budget cut at the same step end on the same state and
1642
+ * both are followed by it.
1643
+ *
1644
+ * Why whole rather than flip by flip: the rows a flip can move are the region rows (held edges, which the pass never
1645
+ * flips nor makes) and the minimum angle, which a Delaunay flip only raises; the art rows and the boundary deviation
1646
+ * read the outline and the union of the triangles, which no flip changes. A measurement per flip would buy nothing a
1647
+ * row can see and cost one measurement per flip — up to n(n − 1)/2 — where the whole pass costs one.
1648
+ */
1649
+ function retriangulateResult(run: Run, termination: Termination): Retriangulation | undefined {
1650
+ const plant = run.plant;
1651
+ if (run.input.retriangulate === undefined && plant !== 'flips-without-opt-in') return undefined;
1652
+ if (plant === 'flips-not-on-replay' && termination.reason === 'replayed-to-accepted-step') return undefined;
1653
+ const result = delaunayFlips(run, plant);
1654
+ const out = (taken: boolean, refusedBy: string | null): Retriangulation => ({ method: 'delaunay', taken, flips: result.flips, sweeps: result.sweeps, refusedBy, monotonicity: MONOTONICITY_NOT_PROMISED });
1655
+ if (!('triangles' in result)) return out(false, result.bound);
1656
+ if (result.flips === 0) return out(true, null);
1657
+ const was = run.work.triangles;
1658
+ run.work.triangles = result.triangles;
1659
+ if (plant === 'flips-against-delaunay-unmeasured') return out(true, null);
1660
+ const canon = canonicalise(run.work, run.sourceTurn, run.boneRank);
1661
+ const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'retriangulated'), run.input.targets.artFit);
1662
+ if (blocking === null) return out(true, null);
1663
+ run.work.triangles = was;
1664
+ return out(false, rowName(blocking));
1665
+ }
1666
+
1402
1667
  // ---------------------------------------------------------------------------
1403
1668
  // the operation
1404
1669
  // ---------------------------------------------------------------------------
@@ -1632,6 +1897,7 @@ function protectionOf(input: MeshReductionInput, work: Work, sourceEdges: Set<st
1632
1897
  /** The result, canonical, with its deform keys carried and its own measurement as the report's one candidate. */
1633
1898
  function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts | null, termination: Termination): MeshReductionResult {
1634
1899
  const { input, work } = run;
1900
+ const retriangulation = retriangulateResult(run, termination);
1635
1901
  const canon = canonicalise(work, run.sourceTurn, run.boneRank);
1636
1902
  const indexMap: Array<number | null> = [];
1637
1903
  for (let v = 0; v < work.nSource; v++) indexMap.push(work.alive[v] ? canon.indexOf.get(v)! : null);
@@ -1656,6 +1922,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
1656
1922
  deformReevaluated: deform.reevaluated.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key })),
1657
1923
  linkedMeshes: input.linkedMeshes,
1658
1924
  acceptedAt: run.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
1925
+ ...(retriangulation === undefined ? {} : { retriangulation }),
1659
1926
  };
1660
1927
  const counts = candidate.counts!;
1661
1928
  const mesh: ReducedMesh = {
@@ -1695,5 +1962,7 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
1695
1962
  budget: { maxCandidates: input.budget.maxCandidates },
1696
1963
  ...(input.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: input.stopAfterAccepted }),
1697
1964
  ...(input.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: input.boundaryRuns.maxVertices } }),
1965
+ ...(input.retriangulate === undefined ? {} : { retriangulate: input.retriangulate }),
1966
+ ...(input.removalOrder === undefined ? {} : { removalOrder: input.removalOrder }),
1698
1967
  };
1699
1968
  }