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 +1 -1
- package/src/meshquality.ts +60 -0
- package/src/meshreduce.ts +273 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-c",
|
|
3
|
-
"version": "2.
|
|
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": {
|
package/src/meshquality.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
}
|