rig-c 2.26.0 → 2.28.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.28.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,56 @@ 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
+ * The motion amplitude the two weight-aware allocation rows read (issue #1287) — `MeshMeasureInput.motionAmplitude`,
278
+ * the same shape and the same handling: left out, it is not declared; `null` declares it absent; anything else that
279
+ * is not a `MotionAmplitude` in full is refused `REDUCE_INPUT_MISSING` naming its path, before any work. Set, the
280
+ * result's measurement reads it, so `MQ_ALLOCATION_CONTRAST` and `MQ_DEFORM_LOAD` are measured on the report rather
281
+ * than `not-measurable`; it is echoed in `effective` (`null` included). It declares no bound — both rows stay
282
+ * `undeclared` — and no step reads it: the admission, every step and the post-pass are measured without it, so the
283
+ * mesh, `acceptedAt`, the termination and every other row are the call's without it (docs/MESH_REDUCTION.md §8).
284
+ */
285
+ motionAmplitude?: MotionAmplitude | null;
286
+ }
287
+
288
+ /**
289
+ * Issue #1283: which triangulation a reduced mesh carries, written in `ReductionChanges` only when the input set
290
+ * `retriangulate`.
291
+ */
292
+ export interface Retriangulation {
293
+ /** The input's `retriangulate`. */
294
+ method: 'delaunay';
295
+ /** Whether the returned mesh carries the pass's triangles (true) or the removals' (false, `refusedBy` says why). */
296
+ taken: boolean;
297
+ /** Edge flips the pass made — on the returned mesh when taken, on the one it refused otherwise. */
298
+ flips: number;
299
+ /** Sweeps over the interior edges, the last of them flipping none. */
300
+ sweeps: number;
301
+ /** Null when taken; else the first required row that failed on the pass's result, or the bound the pass reached. */
302
+ refusedBy: string | null;
303
+ /** What the operation does not promise about the accepted steps under the pass — the same sentence on every report. */
304
+ monotonicity: string;
255
305
  }
256
306
 
257
307
  /** Issue #1279: the opt-in to boundary runs — the most consecutive outline vertices one chord may replace. */
@@ -495,6 +545,8 @@ export interface ReductionChanges {
495
545
  * `stopAfterAccepted: k` replays to the operation at `acceptedAt[k - 1]`.
496
546
  */
497
547
  acceptedAt: AcceptedOperation[];
548
+ /** Issue #1283: which triangulation the mesh carries — present only when the input set `retriangulate`, written last. */
549
+ retriangulation?: Retriangulation;
498
550
  }
499
551
 
500
552
  export interface MeshQualityReport {
@@ -542,8 +594,12 @@ export interface EffectiveSettings {
542
594
  stopAfterAccepted?: number;
543
595
  /** A reduction's `boundaryRuns`, echoed when the input set it (issue #1279); absent otherwise. */
544
596
  boundaryRuns?: BoundaryRuns;
545
- /** A measurement's `motionAmplitude`, echoed when the input set it — `null` included (issue #1280); absent otherwise. */
597
+ /** A measurement's or a reduction's `motionAmplitude`, echoed when the input set it — `null` included (issues #1280, #1287); absent otherwise. */
546
598
  motionAmplitude?: MotionAmplitude | null;
599
+ /** A reduction's `retriangulate`, echoed when the input set it (issue #1283); absent otherwise. */
600
+ retriangulate?: 'delaunay';
601
+ /** A reduction's `removalOrder`, echoed when the input set it (issue #1283); absent otherwise — the order was ascending source index. */
602
+ removalOrder?: 'deformation-load';
547
603
  }
548
604
 
549
605
  export interface MeshCounts {
@@ -796,11 +852,16 @@ function validateInput(input: MeshMeasureInput): void {
796
852
  if (input.preset !== null && (!isObject(input.preset) || typeof input.preset.name !== 'string' || typeof input.preset.version !== 'string')) {
797
853
  refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
798
854
  }
799
- validateAmplitude(who, input.motionAmplitude);
855
+ validateMotionAmplitude(who, input.motionAmplitude);
800
856
  }
801
857
 
802
- /** Issue #1280: `motionAmplitude` left out or `null` is accepted as it stands; anything else is a `MotionAmplitude` in full. */
803
- function validateAmplitude(who: string, amplitude: unknown): void {
858
+ /**
859
+ * Issue #1280: `motionAmplitude` left out or `null` is accepted as it stands; anything else is a `MotionAmplitude` in
860
+ * full, else refused `REDUCE_INPUT_MISSING` naming the path. `reduceMesh` refuses its own field with it before any work
861
+ * (issue #1287), so a reduction and a measurement refuse one value in the same words. Internal, as
862
+ * `measureMeshQualityWith` is: on `rig-c/mesh` only through `export *`.
863
+ */
864
+ export function validateMotionAmplitude(who: string, amplitude: unknown): void {
804
865
  if (amplitude === undefined || amplitude === null) return;
805
866
  const shape = '{ tracks: [{ track, pairs: [{ bones: [a, b], theta }], epsilon }], gradation }';
806
867
  if (!isObject(amplitude) || !Array.isArray(amplitude.tracks)) refuse('REDUCE_INPUT_MISSING', `${who}: motionAmplitude is ${JSON.stringify(amplitude)}; required ${shape}, null, or the field left out`);
@@ -2259,6 +2320,8 @@ function effectiveJson(e: EffectiveSettings): Json {
2259
2320
  ...(e.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: e.stopAfterAccepted }),
2260
2321
  ...(e.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: e.boundaryRuns.maxVertices } }),
2261
2322
  ...(e.motionAmplitude === undefined ? {} : { motionAmplitude: amplitudeJson(e.motionAmplitude) }),
2323
+ ...(e.retriangulate === undefined ? {} : { retriangulate: e.retriangulate }),
2324
+ ...(e.removalOrder === undefined ? {} : { removalOrder: e.removalOrder }),
2262
2325
  };
2263
2326
  }
2264
2327
 
@@ -2360,6 +2423,18 @@ function candidateJson(c: CandidateReport): Json {
2360
2423
  deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
2361
2424
  linkedMeshes: k.linkedMeshes.map(attachmentJson),
2362
2425
  acceptedAt: k.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
2426
+ ...(k.retriangulation === undefined
2427
+ ? {}
2428
+ : {
2429
+ retriangulation: {
2430
+ method: k.retriangulation.method,
2431
+ taken: k.retriangulation.taken,
2432
+ flips: k.retriangulation.flips,
2433
+ sweeps: k.retriangulation.sweeps,
2434
+ refusedBy: k.retriangulation.refusedBy,
2435
+ monotonicity: k.retriangulation.monotonicity,
2436
+ },
2437
+ }),
2363
2438
  };
2364
2439
  }
2365
2440
  return out;
package/src/meshreduce.ts CHANGED
@@ -55,6 +55,30 @@
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
+ *
73
+ * ## The amplitude (issue #1287, opt-in)
74
+ *
75
+ * With `motionAmplitude`, the result's own measurement reads it, so the
76
+ * report's `MQ_ALLOCATION_CONTRAST` and `MQ_DEFORM_LOAD` are measured rather
77
+ * than `not-measurable`. No other measurement of the call is handed it — no
78
+ * step is decided by either row, and no report carries the other
79
+ * measurements' rows (`amplitudeOf`) — so the mesh and every step are the
80
+ * call's without it.
81
+ *
58
82
  * A removal whose deviation floor — the furthest removed source-hull vertex
59
83
  * from the candidate's outline, which `MQ_BOUNDARY_DEVIATION` can only exceed —
60
84
  * is over the bound is refused without the measurement; its name, when read,
@@ -104,6 +128,7 @@ import {
104
128
  edgeIsHeldByRegion,
105
129
  measureMeshQualityStep,
106
130
  measureMeshQualityWith,
131
+ validateMotionAmplitude,
107
132
  type AcceptedOperation,
108
133
  type AttachmentRef,
109
134
  type ArtFitBounds,
@@ -115,8 +140,10 @@ import {
115
140
  type MeshMeasureInput,
116
141
  type MeshQualityReport,
117
142
  type MeshReductionInput,
143
+ type MotionAmplitude,
118
144
  type RefinementRegion,
119
145
  type ReductionChanges,
146
+ type Retriangulation,
120
147
  type SourceMesh,
121
148
  type StopNotReached,
122
149
  type Termination,
@@ -258,7 +285,7 @@ function artBoundAbsent(fit: ArtFitBounds, row: MeasureRow): boolean {
258
285
  * by `measureMeshQuality`'s own validation when the source is first measured,
259
286
  * with the same codes.
260
287
  */
261
- function validateReduction(input: MeshReductionInput): void {
288
+ function validateReduction(input: MeshReductionInput, plant: ReductionPlant | null = null): void {
262
289
  if (!isObject(input)) refuse('REDUCE_INPUT_MISSING', `the input is ${JSON.stringify(input)}; required a MeshReductionInput object`);
263
290
  if (!isRef(input.attachment)) refuse('REDUCE_INPUT_MISSING', `attachment is ${JSON.stringify(input.attachment)}; required { skin: string | null, slot: string, attachment: string }`);
264
291
  const who = `attachment ${nameOf(input.attachment)}`;
@@ -283,6 +310,15 @@ function validateReduction(input: MeshReductionInput): void {
283
310
  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
311
  }
285
312
  }
313
+ if ('retriangulate' in input && input.retriangulate !== undefined && input.retriangulate !== 'delaunay') {
314
+ 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)`);
315
+ }
316
+ if ('removalOrder' in input && input.removalOrder !== undefined && input.removalOrder !== 'deformation-load') {
317
+ 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)`);
318
+ }
319
+ // Issue #1287: refused here, before the admission measurement and any step, in the measurement's own words — the
320
+ // admission does not carry the field, and a call whose source is refused never reaches the one measurement that does.
321
+ if (plant !== 'amplitude-unvalidated') validateMotionAmplitude(who, input.motionAmplitude);
286
322
  const src = input.source;
287
323
  if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
288
324
  const n = src.points.length;
@@ -769,8 +805,11 @@ interface Run {
769
805
  stepRasters: StepRasters | null;
770
806
  }
771
807
 
772
- /** A measurement of a canonical mesh against the result's full contract. */
773
- function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQualityReport {
808
+ /**
809
+ * A measurement of a canonical mesh against the result's full contract. `final` marks the result's own measurement —
810
+ * the one whose rows the report carries, and the only one handed the input's `motionAmplitude` (`amplitudeOf`).
811
+ */
812
+ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string, final = false): MeshQualityReport {
774
813
  const { input } = run;
775
814
  const targets = input.targets;
776
815
  const measureInput: MeshMeasureInput = {
@@ -786,10 +825,33 @@ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQual
786
825
  influences: input.influences,
787
826
  boneOrder: input.boneOrder,
788
827
  preset: input.preset,
828
+ ...amplitudeOf(run.input, run.plant, final),
789
829
  };
790
830
  return run.stepRasters === null ? measureMeshQualityWith(measureInput, run.rasters) : measureMeshQualityStep(measureInput, run.stepRasters);
791
831
  }
792
832
 
833
+ /**
834
+ * Issue #1287: what one measurement of a reduction is handed as `motionAmplitude` — the input's, on the result's own
835
+ * measurement (`final`) only. The admission, every refinement and removal step and the post-pass are measured without
836
+ * it: no row it adds is read by a step (both are `undeclared`, so `firstBlockingRow` never names one) and no report
837
+ * carries those measurements' rows, so carrying it there would buy nothing and cost Δ's own silhouette search at every
838
+ * step (docs/MESH_REDUCTION.md §8, *Stage B — the amplitude on a reduction*, measured). A field left out is passed as
839
+ * left out and `null` as `null`, so the rows' reasons say which.
840
+ */
841
+ function amplitudeOf(input: MeshReductionInput, plant: ReductionPlant | null, final: boolean): { motionAmplitude?: MotionAmplitude | null } {
842
+ const amplitude = input.motionAmplitude;
843
+ if (!final && plant !== 'amplitude-every-measurement' && plant !== 'amplitude-gates-steps') return {};
844
+ if (plant === 'amplitude-not-carried') return {};
845
+ if (plant === 'amplitude-invented' && (amplitude === undefined || amplitude === null)) {
846
+ const bones = [...new Set((input.source.weights ?? []).flatMap((v) => v.map((b) => b.bone)))].sort();
847
+ const pairs: Array<{ bones: [string, string]; theta: number }> = [];
848
+ for (let i = 0; i < bones.length; i++) for (let j = i + 1; j < bones.length; j++) pairs.push({ bones: [bones[i], bones[j]], theta: 1 });
849
+ return { motionAmplitude: { tracks: [{ track: 'invented', pairs, epsilon: 1 }], gradation: 0 } };
850
+ }
851
+ if (plant === 'null-read-as-left-out' && amplitude === null) return {};
852
+ return amplitude === undefined ? {} : { motionAmplitude: amplitude };
853
+ }
854
+
793
855
  /** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
794
856
  const BLOCKING_ORDER = ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_BOUNDARY_DEVIATION', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT', 'MQ_MIN_ANGLE', 'MQ_MAX_EDGE', 'MQ_TRANSITION'];
795
857
 
@@ -1021,7 +1083,45 @@ export type ReplayPlant = 'stop-one-early' | 'stop-one-late';
1021
1083
  * measured, with no deviation floor (the path the floor is held equal to); and the floor read half a pixel short
1022
1084
  * of the bound, so it refuses steps the rows would take.
1023
1085
  */
1024
- export type ReductionPlant = ReplayPlant | 'runs-without-opt-in' | 'run-split-per-vertex' | 'run-skips-rows' | 'measure-every-candidate' | 'floor-half-a-pixel-short';
1086
+ export type ReductionPlant =
1087
+ | ReplayPlant
1088
+ | 'runs-without-opt-in'
1089
+ | 'run-split-per-vertex'
1090
+ | 'run-skips-rows'
1091
+ | 'measure-every-candidate'
1092
+ | 'floor-half-a-pixel-short'
1093
+ | RetriangulationPlant
1094
+ | OrderPlant
1095
+ | AmplitudePlant;
1096
+
1097
+ /**
1098
+ * Issue #1283's faults in the triangulation post-pass: the pass run on a call that did not opt in; the pass skipped
1099
+ * when a replay stops the run, so a replay is not the budget cut at the same step; the flip
1100
+ * criterion inverted, so the pass flips edges that are locally Delaunay — the flips a declared row can refuse — with
1101
+ * its result measured as the pass's is, or taken without the measurement; and the pass flipping edges a region holds.
1102
+ */
1103
+ export type RetriangulationPlant = 'flips-without-opt-in' | 'flips-not-on-replay' | 'flips-against-delaunay' | 'flips-against-delaunay-unmeasured' | 'flips-ignore-regions';
1104
+
1105
+ /** 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. */
1106
+ export type OrderPlant = 'order-without-opt-in' | 'order-descending';
1107
+
1108
+ /**
1109
+ * Issue #1287's faults in carrying `motionAmplitude`: the amplitude not handed to the result's measurement; an
1110
+ * amplitude invented (every pair of the source's bones at θ 1, ε 1, gradation 0) when the field is left out or
1111
+ * `null`; `null` passed to the measurement as the field left out; the field not validated by the reduction; a
1112
+ * measured allocation row read as blocking a step; and `effective` echoing `null` for a field left out. And one path
1113
+ * that is not a fault, held equal to the call: the amplitude handed to every measurement of the call — the admission,
1114
+ * each step, the post-pass — which is what the decision to carry it on the result's measurement only is measured
1115
+ * against.
1116
+ */
1117
+ export type AmplitudePlant =
1118
+ | 'amplitude-not-carried'
1119
+ | 'amplitude-invented'
1120
+ | 'null-read-as-left-out'
1121
+ | 'amplitude-unvalidated'
1122
+ | 'amplitude-gates-steps'
1123
+ | 'echo-when-unset'
1124
+ | 'amplitude-every-measurement';
1025
1125
 
1026
1126
  /** One removal-phase attempt as a control reads it (issue #1279): what was tried, and what refused it or null when taken. */
1027
1127
  export interface AttemptRecord {
@@ -1032,6 +1132,10 @@ export interface AttemptRecord {
1032
1132
  refusedBy: (() => string) | null;
1033
1133
  /** Whether the deviation floor refused it without a measurement. */
1034
1134
  decidedByFloor: boolean;
1135
+ /** The removal pass it was tried in, 1-based (issue #1283). */
1136
+ pass: number;
1137
+ /** A single removal under `removalOrder: 'deformation-load'`: the load it was ranked by (Infinity when the removal cannot be made); else null (issue #1283). */
1138
+ predictedLoad: number | null;
1035
1139
  }
1036
1140
 
1037
1141
  /** Called once per removal-phase attempt, after it. */
@@ -1179,6 +1283,46 @@ function weightJump(work: Work, a: number, b: number): number {
1179
1283
  return sum;
1180
1284
  }
1181
1285
 
1286
+ /**
1287
+ * Issue #1283, §8's deformation load of one edge: its length in px times half the L1 difference of its ends' weight
1288
+ * vectors (Δshare, the share of the field that changes along it). 0 on an unweighted mesh.
1289
+ */
1290
+ function edgeLoad(work: Work, a: number, b: number): number {
1291
+ const dx = work.pos[a][0] - work.pos[b][0];
1292
+ const dy = work.pos[a][1] - work.pos[b][1];
1293
+ return Math.sqrt(dx * dx + dy * dy) * (weightJump(work, a, b) / 2);
1294
+ }
1295
+
1296
+ /**
1297
+ * Issue #1283 (`removalOrder: 'deformation-load'`): the surviving, unprotected source vertices in the order a pass
1298
+ * attempts their single removals — ascending by the predicted load of each removal, the largest `edgeLoad` over the
1299
+ * edges its re-triangulated hole adds (0 when it adds none), a removal `removalOf` cannot make ranked Infinity; ties
1300
+ * by source index. Nothing is measured and nothing is counted as a candidate: one `removalOf` per vertex and one
1301
+ * sort. The plant `order-descending` ranks the loads the other way.
1302
+ */
1303
+ function loadOrder(run: Run): { order: number[]; load: Map<number, number> } {
1304
+ const { work } = run;
1305
+ const load = new Map<number, number>();
1306
+ const order: number[] = [];
1307
+ for (let v = 0; v < work.nSource; v++) {
1308
+ if (!work.alive[v] || run.protectedVertices.has(v)) continue;
1309
+ const step = removalOf(work, v);
1310
+ let most = 0;
1311
+ if ('blocked' in step) most = Infinity;
1312
+ else for (const [a, b] of step.added) most = Math.max(most, edgeLoad(work, a, b));
1313
+ load.set(v, most);
1314
+ order.push(v);
1315
+ }
1316
+ const sign = run.plant === 'order-descending' ? -1 : 1;
1317
+ order.sort((p, q) => {
1318
+ const lp = load.get(p)!;
1319
+ const lq = load.get(q)!;
1320
+ if (lp !== lq) return lp < lq ? -sign : sign;
1321
+ return p - q;
1322
+ });
1323
+ return { order, load };
1324
+ }
1325
+
1182
1326
  /**
1183
1327
  * Several boundary vertices removed one after another as one operation (issue #1279): the working triangles after
1184
1328
  * the last and the edges they hold that the triangles before the first did not, in the order they appear — or the
@@ -1250,12 +1394,15 @@ function outlineWalk(run: Run): number[] {
1250
1394
  function removeVertices(run: Run): PhaseEnd {
1251
1395
  const { work, input } = run;
1252
1396
  const runs = run.plant === 'runs-without-opt-in' ? { maxVertices: 2 } : input.boundaryRuns;
1397
+ const byLoad = input.removalOrder === 'deformation-load' || run.plant === 'order-without-opt-in';
1253
1398
  const hull = input.source.hull;
1254
1399
  let lastBlock: { refusal: Refusal; what: string } | null = null;
1255
1400
  const lastName = (): string => (lastBlock === null ? '' : `${refusalText(lastBlock.refusal)}, ${lastBlock.what}`);
1401
+ let pass = 0;
1256
1402
  for (;;) {
1257
1403
  let taken = 0;
1258
1404
  let tried = 0;
1405
+ pass++;
1259
1406
  if (runs !== undefined) {
1260
1407
  let walk: number[] | null = null;
1261
1408
  for (let s = 0; s < hull; s++) {
@@ -1275,7 +1422,7 @@ function removeVertices(run: Run): PhaseEnd {
1275
1422
  run.steps++;
1276
1423
  tried++;
1277
1424
  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' });
1425
+ 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
1426
  if (block === null) {
1280
1427
  taken++;
1281
1428
  walk = null;
@@ -1286,13 +1433,18 @@ function removeVertices(run: Run): PhaseEnd {
1286
1433
  }
1287
1434
  }
1288
1435
  }
1289
- for (let v = 0; v < work.nSource; v++) {
1436
+ // Issue #1283: with the load order the pass's singles are ranked once, here, over the mesh the runs left; without
1437
+ // it they are every source index ascending, exactly as before the field.
1438
+ const ranked = byLoad ? loadOrder(run) : null;
1439
+ const singles = ranked === null ? null : ranked.order;
1440
+ for (let i = 0, end = singles === null ? work.nSource : singles.length; i < end; i++) {
1441
+ const v = singles === null ? i : singles[i];
1290
1442
  if (!work.alive[v] || run.protectedVertices.has(v)) continue;
1291
1443
  if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
1292
1444
  run.steps++;
1293
1445
  tried++;
1294
1446
  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' });
1447
+ 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
1448
  if (block === null) {
1297
1449
  taken++;
1298
1450
  if (accept(run, 'removal', [v])) return { kind: 'replayed' };
@@ -1391,7 +1543,10 @@ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Ref
1391
1543
  };
1392
1544
  }
1393
1545
  }
1394
- const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'), run.input.targets.artFit);
1546
+ const measured = measureAgainstTargets(run, canon.mesh, 'candidate');
1547
+ const blocking =
1548
+ firstBlockingRow(measured, run.input.targets.artFit) ??
1549
+ (run.plant === 'amplitude-gates-steps' ? ((measured.candidates[0]?.geometry?.rows ?? []).find((r) => (r.code === 'MQ_DEFORM_LOAD' || r.code === 'MQ_ALLOCATION_CONTRAST') && r.state === 'undeclared') ?? null) : null);
1395
1550
  if (blocking !== null) {
1396
1551
  undo();
1397
1552
  return rowName(blocking);
@@ -1399,6 +1554,178 @@ function tryOperation(run: Run, vertices: readonly number[], full: boolean): Ref
1399
1554
  return null;
1400
1555
  }
1401
1556
 
1557
+ // ---------------------------------------------------------------------------
1558
+ // the triangulation post-pass (issue #1283)
1559
+ // ---------------------------------------------------------------------------
1560
+
1561
+ /**
1562
+ * How far past π the two angles opposite an edge have to sum before the edge is flipped, read as the sine of their
1563
+ * sum: the flip is made when that sine is below `-FLIP_MARGIN`. The flip turns the sum into 2π minus it, so its sine
1564
+ * changes sign and the new edge is never flipped back in the same or a later sweep; cocircular quads (sine within the
1565
+ * margin) are left as they are.
1566
+ */
1567
+ const FLIP_MARGIN = 1e-9;
1568
+
1569
+ /** Twice the signed area of p, q, r in drawing px (y down), the orientation test the flip reads. */
1570
+ function orient(p: Pt, q: Pt, r: Pt): number {
1571
+ return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
1572
+ }
1573
+
1574
+ /**
1575
+ * sin(α + β) for the angles α at `c` and β at `d` opposite the edge a–b — negative exactly when α + β > π, the edge
1576
+ * is then not locally Delaunay. From cross and dot products and square roots only, so it reads the same wherever IEEE
1577
+ * arithmetic does (no libm call).
1578
+ */
1579
+ function oppositeAngleSine(a: Pt, b: Pt, c: Pt, d: Pt): number {
1580
+ const angle = (o: Pt): { sin: number; cos: number } => {
1581
+ const ux = a[0] - o[0];
1582
+ const uy = a[1] - o[1];
1583
+ const vx = b[0] - o[0];
1584
+ const vy = b[1] - o[1];
1585
+ const lengths = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy);
1586
+ return { sin: Math.abs(ux * vy - uy * vx) / lengths, cos: (ux * vx + uy * vy) / lengths };
1587
+ };
1588
+ const p = angle(c);
1589
+ const q = angle(d);
1590
+ return p.sin * q.cos + p.cos * q.sin;
1591
+ }
1592
+
1593
+ /** The sentence every report of a call with `retriangulate` carries (§8 Q4, [agreed, rig-parts#126]). */
1594
+ const MONOTONICITY_NOT_PROMISED =
1595
+ '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)';
1596
+
1597
+ type FlipResult = { triangles: Array<[number, number, number]>; flips: number; sweeps: number } | { bound: string; flips: number; sweeps: number };
1598
+
1599
+ /**
1600
+ * Lawson's edge flips over the working mesh's interior edges, toward the Delaunay triangulation of the same vertices
1601
+ * constrained by what the mesh must keep. A sweep visits every interior edge (two triangles) in ascending (smaller id,
1602
+ * larger id), skipping an edge whose triangles a flip of this sweep already replaced, and flips it when:
1603
+ *
1604
+ * - the quad its two triangles form is strictly convex, so both new triangles keep the winding;
1605
+ * - the angles opposite it sum past π by `FLIP_MARGIN` (`oppositeAngleSine`) — the Delaunay criterion;
1606
+ * - it is not a protected edge (`protect.edges`, and every source edge over `protect.weightJump`);
1607
+ * - no declared region holds it, and none would hold the new edge (`edgeIsHeldByRegion`), so every region row reads
1608
+ * the same edges and lengths after the pass as before it;
1609
+ * - the new edge is a source edge, or its ends' weight vectors are within `protect.weightJump` (condition (b), as a
1610
+ * removal's new edges are held).
1611
+ *
1612
+ * The outline is never touched (an outline edge has one triangle) and no vertex is added, moved or removed. Sweeps
1613
+ * continue until one flips nothing. **Bounded work:** every flip made is one the criterion allows, which lowers the
1614
+ * triangulation's lifting onto the paraboloid strictly, so an edge flipped away never comes back and a pass makes at
1615
+ * 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
1616
+ * sweeps than flips, and each visits at most 3n interior edges. The bound is also held by count: reaching it ends the
1617
+ * pass with the bound named rather than the triangles, which the criterion makes unreachable.
1618
+ */
1619
+ function delaunayFlips(run: Run, plant: ReductionPlant | null): FlipResult {
1620
+ const { work, input } = run;
1621
+ const P = work.pos;
1622
+ const tris = work.triangles.map((t): [number, number, number] => [t[0], t[1], t[2]]);
1623
+ const alive = work.alive.reduce((n, a) => n + (a ? 1 : 0), 0);
1624
+ const maxFlips = (alive * (alive - 1)) / 2;
1625
+ const guarded = new Set(run.protectedEdges.map(([a, b]) => edgeKey(a, b)));
1626
+ const jump = input.protect.weightJump;
1627
+ const regions = plant === 'flips-ignore-regions' ? [] : input.targets.regions;
1628
+ const heldCache = new Map<string, boolean>();
1629
+ const held = (a: number, b: number): boolean => {
1630
+ if (regions.length === 0) return false;
1631
+ const key = edgeKey(a, b);
1632
+ let hit = heldCache.get(key);
1633
+ if (hit === undefined) {
1634
+ hit = regions.some((r) => edgeIsHeldByRegion(P[a], P[b], r));
1635
+ heldCache.set(key, hit);
1636
+ }
1637
+ return hit;
1638
+ };
1639
+ const against = plant === 'flips-against-delaunay' || plant === 'flips-against-delaunay-unmeasured';
1640
+ let flips = 0;
1641
+ let sweeps = 0;
1642
+ for (;;) {
1643
+ sweeps++;
1644
+ const sides = new Map<string, number[]>();
1645
+ tris.forEach((t, i) => {
1646
+ for (let k = 0; k < 3; k++) {
1647
+ const key = edgeKey(t[k], t[(k + 1) % 3]);
1648
+ const list = sides.get(key);
1649
+ if (list === undefined) sides.set(key, [i]);
1650
+ else list.push(i);
1651
+ }
1652
+ });
1653
+ const interior: Array<[number, number]> = [];
1654
+ for (const [key, list] of sides) {
1655
+ if (list.length !== 2) continue;
1656
+ const [a, b] = key.split(',').map(Number);
1657
+ interior.push([a, b]);
1658
+ }
1659
+ interior.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
1660
+ const replaced = new Set<number>();
1661
+ let flipped = 0;
1662
+ for (const [u, w] of interior) {
1663
+ const [i, j] = sides.get(edgeKey(u, w))!;
1664
+ if (replaced.has(i) || replaced.has(j)) continue;
1665
+ if (guarded.has(edgeKey(u, w))) continue;
1666
+ // Rotate so the edge is t1's a → b in its own winding; t2 then holds b → a.
1667
+ const t1 = tris[i];
1668
+ const k1 = t1.findIndex((x, k) => (x === u && t1[(k + 1) % 3] === w) || (x === w && t1[(k + 1) % 3] === u));
1669
+ const a = t1[k1];
1670
+ const b = t1[(k1 + 1) % 3];
1671
+ const c = t1[(k1 + 2) % 3];
1672
+ const t2 = tris[j];
1673
+ const k2 = t2.findIndex((x, k) => x === b && t2[(k + 1) % 3] === a);
1674
+ if (k2 === -1) continue;
1675
+ const d = t2[(k2 + 2) % 3];
1676
+ if (c === d) continue;
1677
+ const turn = Math.sign(orient(P[a], P[b], P[c]));
1678
+ const o1 = orient(P[a], P[d], P[c]);
1679
+ const o2 = orient(P[d], P[b], P[c]);
1680
+ if (Math.sign(o1) !== turn || Math.sign(o2) !== turn || Math.abs(o1) < 1e-9 || Math.abs(o2) < 1e-9) continue;
1681
+ const sine = oppositeAngleSine(P[a], P[b], P[c], P[d]);
1682
+ if (against ? !(sine > FLIP_MARGIN) : !(sine < -FLIP_MARGIN)) continue;
1683
+ if (held(a, b) || held(c, d)) continue;
1684
+ if (jump !== null && !(c < work.nSource && d < work.nSource && run.sourceEdges.has(edgeKey(c, d))) && weightJump(work, c, d) > jump) continue;
1685
+ if (flips >= maxFlips) return { bound: `the flip bound n(n - 1)/2 = ${maxFlips} over ${alive} vertices was reached`, flips, sweeps };
1686
+ tris[i] = [a, d, c];
1687
+ tris[j] = [d, b, c];
1688
+ replaced.add(i);
1689
+ replaced.add(j);
1690
+ flips++;
1691
+ flipped++;
1692
+ }
1693
+ // The inverted criterion is a plant: one sweep, since flipping a Delaunay edge makes a non-Delaunay one it would
1694
+ // flip straight back.
1695
+ if (flipped === 0 || against) return { triangles: tris, flips, sweeps };
1696
+ }
1697
+ }
1698
+
1699
+ /**
1700
+ * Issue #1283: the post-pass on the working mesh the reduction ended on, taken whole or refused whole — the working
1701
+ * triangles are the pass's only when every required row passes on its result (the measurement reads the canonical
1702
+ * mesh exactly as the result's own does). Undefined when the call did not opt in. One flip pass and at most one
1703
+ * measurement, never counted as a candidate: a replay and the budget cut at the same step end on the same state and
1704
+ * both are followed by it.
1705
+ *
1706
+ * Why whole rather than flip by flip: the rows a flip can move are the region rows (held edges, which the pass never
1707
+ * flips nor makes) and the minimum angle, which a Delaunay flip only raises; the art rows and the boundary deviation
1708
+ * read the outline and the union of the triangles, which no flip changes. A measurement per flip would buy nothing a
1709
+ * row can see and cost one measurement per flip — up to n(n − 1)/2 — where the whole pass costs one.
1710
+ */
1711
+ function retriangulateResult(run: Run, termination: Termination): Retriangulation | undefined {
1712
+ const plant = run.plant;
1713
+ if (run.input.retriangulate === undefined && plant !== 'flips-without-opt-in') return undefined;
1714
+ if (plant === 'flips-not-on-replay' && termination.reason === 'replayed-to-accepted-step') return undefined;
1715
+ const result = delaunayFlips(run, plant);
1716
+ const out = (taken: boolean, refusedBy: string | null): Retriangulation => ({ method: 'delaunay', taken, flips: result.flips, sweeps: result.sweeps, refusedBy, monotonicity: MONOTONICITY_NOT_PROMISED });
1717
+ if (!('triangles' in result)) return out(false, result.bound);
1718
+ if (result.flips === 0) return out(true, null);
1719
+ const was = run.work.triangles;
1720
+ run.work.triangles = result.triangles;
1721
+ if (plant === 'flips-against-delaunay-unmeasured') return out(true, null);
1722
+ const canon = canonicalise(run.work, run.sourceTurn, run.boneRank);
1723
+ const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'retriangulated'), run.input.targets.artFit);
1724
+ if (blocking === null) return out(true, null);
1725
+ run.work.triangles = was;
1726
+ return out(false, rowName(blocking));
1727
+ }
1728
+
1402
1729
  // ---------------------------------------------------------------------------
1403
1730
  // the operation
1404
1731
  // ---------------------------------------------------------------------------
@@ -1457,7 +1784,7 @@ export function reduceMeshWith(
1457
1784
  plant: ReductionPlant | null = null,
1458
1785
  observe: AttemptObserver | null = null,
1459
1786
  ): MeshReductionResult {
1460
- validateReduction(input);
1787
+ validateReduction(input, plant);
1461
1788
  if (steps !== null && steps.rasters !== rasters) {
1462
1789
  refuse('REDUCE_ART_RASTERS_MISMATCH', `attachment ${nameOf(input.attachment)}: the step rasters were made over another rasters object; required step rasters made over the rasters passed beside them (stepRastersOf(rasters))`);
1463
1790
  }
@@ -1489,9 +1816,10 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1489
1816
  influences: input.influences,
1490
1817
  boneOrder: input.boneOrder,
1491
1818
  preset: input.preset,
1819
+ ...amplitudeOf(input, plant, false),
1492
1820
  };
1493
1821
  const admit = steps === null ? measureMeshQualityWith(admitInput, rasters) : measureMeshQualityStep(admitInput, steps);
1494
- const effective = effectiveOf(input, admit.effective, sourceHull);
1822
+ const effective = effectiveOf(input, admit.effective, sourceHull, plant);
1495
1823
  const sourceCounts = admit.sourceCounts;
1496
1824
  const noMesh = (termination: Termination): MeshReductionResult => ({
1497
1825
  mesh: null,
@@ -1632,6 +1960,7 @@ function protectionOf(input: MeshReductionInput, work: Work, sourceEdges: Set<st
1632
1960
  /** The result, canonical, with its deform keys carried and its own measurement as the report's one candidate. */
1633
1961
  function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts | null, termination: Termination): MeshReductionResult {
1634
1962
  const { input, work } = run;
1963
+ const retriangulation = retriangulateResult(run, termination);
1635
1964
  const canon = canonicalise(work, run.sourceTurn, run.boneRank);
1636
1965
  const indexMap: Array<number | null> = [];
1637
1966
  for (let v = 0; v < work.nSource; v++) indexMap.push(work.alive[v] ? canon.indexOf.get(v)! : null);
@@ -1645,7 +1974,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
1645
1974
  }
1646
1975
  throw err;
1647
1976
  }
1648
- const measured = measureAgainstTargets(run, canon.mesh, 'result');
1977
+ const measured = measureAgainstTargets(run, canon.mesh, 'result', true);
1649
1978
  const candidate = measured.candidates[0];
1650
1979
  const changes: ReductionChanges = {
1651
1980
  removedVertices: indexMap.filter((r) => r === null).length,
@@ -1656,6 +1985,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
1656
1985
  deformReevaluated: deform.reevaluated.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key })),
1657
1986
  linkedMeshes: input.linkedMeshes,
1658
1987
  acceptedAt: run.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
1988
+ ...(retriangulation === undefined ? {} : { retriangulation }),
1659
1989
  };
1660
1990
  const counts = candidate.counts!;
1661
1991
  const mesh: ReducedMesh = {
@@ -1685,7 +2015,8 @@ function reportOf(effective: EffectiveSettings, sourceCounts: MeshCounts | null,
1685
2015
  }
1686
2016
 
1687
2017
  /** Correction 1: the reduction's inputs echoed with their structure — the measurement's echo, with what a reduction adds. */
1688
- function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sourceHull: Array<[number, number]>): EffectiveSettings {
2018
+ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sourceHull: Array<[number, number]>, plant: ReductionPlant | null): EffectiveSettings {
2019
+ const amplitude = plant === 'echo-when-unset' && input.motionAmplitude === undefined ? null : input.motionAmplitude;
1689
2020
  const fit = (f: ArtFitBounds): ArtFitBounds => ({ minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
1690
2021
  return {
1691
2022
  ...measured,
@@ -1695,5 +2026,8 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
1695
2026
  budget: { maxCandidates: input.budget.maxCandidates },
1696
2027
  ...(input.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: input.stopAfterAccepted }),
1697
2028
  ...(input.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: input.boundaryRuns.maxVertices } }),
2029
+ ...(amplitude === undefined ? {} : { motionAmplitude: amplitude }),
2030
+ ...(input.retriangulate === undefined ? {} : { retriangulate: input.retriangulate }),
2031
+ ...(input.removalOrder === undefined ? {} : { removalOrder: input.removalOrder }),
1698
2032
  };
1699
2033
  }