spine-rigc 2.22.0 → 2.24.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/mesh.ts +57 -8
- package/src/meshquality.ts +163 -8
- package/src/meshreduce.ts +63 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.24.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/mesh.ts
CHANGED
|
@@ -1253,8 +1253,9 @@ export function traceAlphaOutline(
|
|
|
1253
1253
|
* `background` is how the flood steps: 4 through edges, 8 through corners too.
|
|
1254
1254
|
* The trace floods 8 — the dual of its 4-connected island, so background that
|
|
1255
1255
|
* meets the outside at a corner is outside — and says why where it calls this.
|
|
1256
|
-
* The authored-mesh measurement floods 4, as it always has
|
|
1257
|
-
* every mask the trace
|
|
1256
|
+
* The authored-mesh measurement floods 4 by default, as it always has, and 8
|
|
1257
|
+
* when its caller asks (issue #1262); the two agree on every mask the trace
|
|
1258
|
+
* accepts.
|
|
1258
1259
|
*/
|
|
1259
1260
|
export function fillEnclosed(
|
|
1260
1261
|
inside: Uint8Array,
|
|
@@ -1479,16 +1480,57 @@ export interface MeshFitReport {
|
|
|
1479
1480
|
* is never refused, so every figure needs a number — hence the exact distance
|
|
1480
1481
|
* transform below rather than a neighbourhood search that would have to stop
|
|
1481
1482
|
* somewhere.
|
|
1483
|
+
*
|
|
1484
|
+
* ## Which background is enclosed — `connectivity` (issue #1262)
|
|
1485
|
+
*
|
|
1486
|
+
* The background is flooded 4-connected by default, as it always has been, so
|
|
1487
|
+
* every call that passes four arguments reads exactly what it read before.
|
|
1488
|
+
* `8` floods through corners too — the fill `measureMeshQuality`'s
|
|
1489
|
+
* `MQ_OVERSHOOT` and `MQ_HOLES` rows are taken against (P12), by the same
|
|
1490
|
+
* `fillEnclosed` call — so a background pocket joined to the outside only
|
|
1491
|
+
* diagonally is read as outside, and a caller gating with this figure reads
|
|
1492
|
+
* the number the rows read. The two fills agree on every mask without such a
|
|
1493
|
+
* pocket. Any other value is refused by name: a silent fallback to one of the
|
|
1494
|
+
* two would answer a question the caller did not ask.
|
|
1482
1495
|
*/
|
|
1483
1496
|
export function measureAuthoredMeshFit(
|
|
1484
1497
|
mask: AlphaMask,
|
|
1485
1498
|
threshold: number,
|
|
1486
1499
|
points: Array<[number, number]>,
|
|
1487
1500
|
triangles: number[],
|
|
1501
|
+
connectivity: 4 | 8 = 4,
|
|
1488
1502
|
): MeshFitReport {
|
|
1503
|
+
return authoredMeshFitOf(mask, threshold, points, triangles, connectivity, null).report;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* A fault planted on purpose in `authoredMeshFitOf`, for the mesh-quality
|
|
1508
|
+
* suite's negative control: `'fill-4-under-8'` takes the connectivity it is
|
|
1509
|
+
* passed and floods 4 whatever it was. `measureAuthoredMeshFit` plants none.
|
|
1510
|
+
*/
|
|
1511
|
+
export type AuthoredFitPlant = 'fill-4-under-8';
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* `measureAuthoredMeshFit`'s measurement, with the pixel its overshoot is
|
|
1515
|
+
* read at — the first covered pixel outside the filled silhouette at the
|
|
1516
|
+
* largest distance, by the tie rule `MQ_OVERSHOOT` names its pixel with, or
|
|
1517
|
+
* -1 when no covered pixel lies outside — so a control can name where two
|
|
1518
|
+
* readings part.
|
|
1519
|
+
*/
|
|
1520
|
+
export function authoredMeshFitOf(
|
|
1521
|
+
mask: AlphaMask,
|
|
1522
|
+
threshold: number,
|
|
1523
|
+
points: Array<[number, number]>,
|
|
1524
|
+
triangles: number[],
|
|
1525
|
+
connectivity: 4 | 8,
|
|
1526
|
+
plant: AuthoredFitPlant | null,
|
|
1527
|
+
): { report: MeshFitReport; overshootAt: number } {
|
|
1528
|
+
if (connectivity !== 4 && connectivity !== 8) {
|
|
1529
|
+
throw new MeshError(`measureAuthoredMeshFit: connectivity is ${JSON.stringify(connectivity)}; required 4 (the default, the legacy fill) or 8 (the fill MQ_OVERSHOOT reads, P12)`);
|
|
1530
|
+
}
|
|
1489
1531
|
const { width: w, height: h } = mask;
|
|
1490
1532
|
const art = artOf(mask, threshold);
|
|
1491
|
-
const { filled } = fillEnclosed(art, w, h, 4);
|
|
1533
|
+
const { filled } = fillEnclosed(art, w, h, plant === 'fill-4-under-8' ? 4 : connectivity);
|
|
1492
1534
|
const covered = rasteriseTriangles(points, triangles, w, h);
|
|
1493
1535
|
let artPixels = 0;
|
|
1494
1536
|
let coveredArt = 0;
|
|
@@ -1499,15 +1541,22 @@ export function measureAuthoredMeshFit(
|
|
|
1499
1541
|
}
|
|
1500
1542
|
const squared = squaredDistanceToSet(filled, w, h);
|
|
1501
1543
|
let worst = 0;
|
|
1544
|
+
let overshootAt = -1;
|
|
1502
1545
|
for (let i = 0; i < covered.length; i++) {
|
|
1503
1546
|
if (!covered[i] || filled[i]) continue;
|
|
1504
|
-
if (squared[i] > worst)
|
|
1547
|
+
if (overshootAt === -1 || squared[i] > worst) {
|
|
1548
|
+
overshootAt = i;
|
|
1549
|
+
worst = squared[i];
|
|
1550
|
+
}
|
|
1505
1551
|
}
|
|
1506
1552
|
return {
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1553
|
+
report: {
|
|
1554
|
+
artPixels,
|
|
1555
|
+
coveredArt,
|
|
1556
|
+
coverage: artPixels === 0 ? 0 : coveredArt / artPixels,
|
|
1557
|
+
overshoot: r6(Math.sqrt(worst)),
|
|
1558
|
+
},
|
|
1559
|
+
overshootAt,
|
|
1511
1560
|
};
|
|
1512
1561
|
}
|
|
1513
1562
|
|
package/src/meshquality.ts
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* taken at (`MeasureRow.art.threshold`); a measurement claims nothing at any
|
|
33
33
|
* other.
|
|
34
34
|
* - **Change the legacy fit.** `measureAuthoredMeshFit` (`src/mesh.ts`) keeps
|
|
35
|
-
* its 4-connected fill for every existing caller
|
|
35
|
+
* its 4-connected fill for every existing caller (its `connectivity: 8`,
|
|
36
|
+
* issue #1262, is the fill the rows read). The rows here fill with the
|
|
36
37
|
* tracer's 8-connected background flood over all art (P12), and where the two
|
|
37
38
|
* fills differ — a diagonal pinch — both labelled results are rows.
|
|
38
39
|
* - **Pose, or link the runtime.** Pure: no clock, no randomness, no network,
|
|
@@ -234,6 +235,14 @@ export interface MeshReductionInput {
|
|
|
234
235
|
deform: DeformTimelineInput[];
|
|
235
236
|
/** Every linked mesh of the source (they inherit the new topology); empty when none. */
|
|
236
237
|
linkedMeshes: AttachmentRef[];
|
|
238
|
+
/**
|
|
239
|
+
* Replay (issue #1268, §7 mechanism 2): stop after the n-th accepted step — a refinement insertion or a removal
|
|
240
|
+
* taken, counted as `ReductionChanges.acceptedAt` counts them — and return the mesh the same call without this field
|
|
241
|
+
* had at that step, byte for byte, terminated `replayed-to-accepted-step`. Left out = today's behaviour. A whole
|
|
242
|
+
* number, 0 or more: 0 takes no step. A number the run never reaches leaves the run to end as it would have, and
|
|
243
|
+
* that termination says so (`stopAfterAccepted` on it).
|
|
244
|
+
*/
|
|
245
|
+
stopAfterAccepted?: number;
|
|
237
246
|
}
|
|
238
247
|
|
|
239
248
|
/**
|
|
@@ -401,6 +410,12 @@ export interface ReductionChanges {
|
|
|
401
410
|
deformReevaluated: Array<{ animation: string; attachment: AttachmentRef; key: number }>;
|
|
402
411
|
/** Every linked mesh of the source: each inherits the new topology. */
|
|
403
412
|
linkedMeshes: AttachmentRef[];
|
|
413
|
+
/**
|
|
414
|
+
* Issue #1268: the attempt number — `candidatesTried` as it stood when the step was taken, 1-based — of every
|
|
415
|
+
* accepted step, ascending: each refinement insertion and each removal, so its length is
|
|
416
|
+
* `insertedVertices + removedVertices`. `stopAfterAccepted: k` replays to the step at `acceptedAt[k - 1]`.
|
|
417
|
+
*/
|
|
418
|
+
acceptedAt: number[];
|
|
404
419
|
}
|
|
405
420
|
|
|
406
421
|
export interface MeshQualityReport {
|
|
@@ -444,6 +459,8 @@ export interface EffectiveSettings {
|
|
|
444
459
|
boneOrder: string[] | null;
|
|
445
460
|
schedule: MotionSchedule | null;
|
|
446
461
|
budget: { maxCandidates: number } | null;
|
|
462
|
+
/** A reduction's `stopAfterAccepted`, echoed when the input set it (issue #1268); absent otherwise. */
|
|
463
|
+
stopAfterAccepted?: number;
|
|
447
464
|
}
|
|
448
465
|
|
|
449
466
|
export interface MeshCounts {
|
|
@@ -456,10 +473,20 @@ export interface MeshCounts {
|
|
|
456
473
|
maxInfluences: number;
|
|
457
474
|
}
|
|
458
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Issue #1268: carried by a run's own termination when the input asked for `stopAfterAccepted` and the run ended
|
|
478
|
+
* before that many steps were accepted — `requested` the input's n, `acceptedSteps` how many the run took.
|
|
479
|
+
*/
|
|
480
|
+
export interface StopNotReached {
|
|
481
|
+
requested: number;
|
|
482
|
+
acceptedSteps: number;
|
|
483
|
+
}
|
|
484
|
+
|
|
459
485
|
/** *Termination reasons*. Every `reduce` report carries exactly one; a `measure` carries one only when it could not read the mesh. */
|
|
460
486
|
export type Termination =
|
|
461
|
-
| { reason: 'no-further-valid-reduction'; candidatesTried: number; blockingConstraint: string }
|
|
462
|
-
| { reason: 'budget-exhausted'; candidatesTried: number; budget: number; result: 'best-meeting-every-bound' | 'none-met-the-targets' }
|
|
487
|
+
| { reason: 'no-further-valid-reduction'; candidatesTried: number; blockingConstraint: string; stopAfterAccepted?: StopNotReached }
|
|
488
|
+
| { reason: 'budget-exhausted'; candidatesTried: number; budget: number; result: 'best-meeting-every-bound' | 'none-met-the-targets'; stopAfterAccepted?: StopNotReached }
|
|
489
|
+
| { reason: 'replayed-to-accepted-step'; acceptedSteps: number; candidatesTried: number }
|
|
463
490
|
| { reason: 'invalid-input'; code: string; detail: string }
|
|
464
491
|
| { reason: 'unsupported-topology'; code: string; detail: string };
|
|
465
492
|
|
|
@@ -694,8 +721,21 @@ function validateInput(input: MeshMeasureInput): void {
|
|
|
694
721
|
|
|
695
722
|
type Pt = readonly [number, number];
|
|
696
723
|
|
|
697
|
-
/**
|
|
698
|
-
|
|
724
|
+
/**
|
|
725
|
+
* Is `p` inside or on the closed polygon? On the boundary within `ON_BOUNDARY` counts as inside.
|
|
726
|
+
*
|
|
727
|
+
* The rule, exactly, because `closedPolygonCentres` reproduces it bit for bit:
|
|
728
|
+
* `p` is in when its `distanceToSegment` to some side `poly[i]`–`poly[i+1]`
|
|
729
|
+
* is at most `ON_BOUNDARY` (1e-9 px) — a point on an edge or at a vertex is in,
|
|
730
|
+
* whatever the ray says; otherwise by **even-odd** parity of a ray towards +x,
|
|
731
|
+
* where the side from `poly[j]` to `poly[i]` (`j` the one before `i`) crosses
|
|
732
|
+
* when exactly one of its ends has a y strictly greater than `p`'s — `yi > py`
|
|
733
|
+
* differs from `yj > py`, so the half-open rule counts a vertex at `p`'s height
|
|
734
|
+
* once and a horizontal side never — at
|
|
735
|
+
* `xc = ((xj − xi)·(py − yi)) / (yj − yi) + xi`, and the crossing counts when
|
|
736
|
+
* `px < xc` strictly.
|
|
737
|
+
*/
|
|
738
|
+
export function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
699
739
|
const n = poly.length;
|
|
700
740
|
for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
|
|
701
741
|
let inside = false;
|
|
@@ -707,6 +747,112 @@ function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
|
707
747
|
return inside;
|
|
708
748
|
}
|
|
709
749
|
|
|
750
|
+
/**
|
|
751
|
+
* A fault planted on purpose in `closedPolygonCentres`, for the mesh-quality
|
|
752
|
+
* suite's negative control: `'crossing-half-pixel'` moves every ray crossing
|
|
753
|
+
* half a pixel towards +x. `regionRows` plants none.
|
|
754
|
+
*/
|
|
755
|
+
export type ScanlinePlant = 'crossing-half-pixel';
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Which pixel centres of a `w`×`h` grid lie in or on a closed polygon given
|
|
759
|
+
* in drawing px — `inClosedPolygon` of every centre `((x + 0.5) / scale,
|
|
760
|
+
* (y + 0.5) / scale)`, 1 where it is true, by scanline (issue #1263).
|
|
761
|
+
*
|
|
762
|
+
* The same decision, not a cleaner one: each row's crossings are the sides
|
|
763
|
+
* `inClosedPolygon` counts for that row's centre height, by its own
|
|
764
|
+
* half-open test, at the `xc` it computes with the same expression in the same
|
|
765
|
+
* order, so they are the same doubles; sorted, the number of them a centre
|
|
766
|
+
* lies strictly left of is the number its ray counts, and its parity is the
|
|
767
|
+
* answer. The boundary band is then added side by side, each centre near a
|
|
768
|
+
* side tested with `distanceToSegment` against `ON_BOUNDARY` — the predicate's
|
|
769
|
+
* own test — so a centre on an edge or at a vertex is in exactly when the
|
|
770
|
+
* predicate says so. The rows and columns visited around a side are widened by
|
|
771
|
+
* a pixel past where it can reach, and only the exact tests decide.
|
|
772
|
+
*
|
|
773
|
+
* O(rows × sides + pixels) where testing every centre against every side is
|
|
774
|
+
* O(pixels × sides).
|
|
775
|
+
*/
|
|
776
|
+
export function closedPolygonCentres(poly: readonly Pt[], w: number, h: number, scale: number, plant: ScanlinePlant | null = null): Uint8Array {
|
|
777
|
+
const marks = new Uint8Array(w * h);
|
|
778
|
+
const n = poly.length;
|
|
779
|
+
if (n === 0 || w <= 0 || h <= 0) return marks;
|
|
780
|
+
const cx = (x: number): number => (x + 0.5) / scale;
|
|
781
|
+
const cy = (y: number): number => (y + 0.5) / scale;
|
|
782
|
+
const shift = plant === 'crossing-half-pixel' ? 0.5 / scale : 0;
|
|
783
|
+
/** The grid row or column of a drawing coordinate, rounded one way and widened by `slack`; NaN visits nothing. */
|
|
784
|
+
const cellOf = (v: number, round: (u: number) => number, slack: number, size: number): number => {
|
|
785
|
+
const c = round(v * scale - 0.5) + slack;
|
|
786
|
+
if (Number.isNaN(c)) return slack < 0 ? size : -1;
|
|
787
|
+
return c;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
// Even-odd: per row, the crossings of a ray towards +x from the row's centre height.
|
|
791
|
+
const crossings: Array<number[] | undefined> = new Array<number[] | undefined>(h);
|
|
792
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
793
|
+
const [xi, yi] = poly[i];
|
|
794
|
+
const [xj, yj] = poly[j];
|
|
795
|
+
const y0 = Math.max(0, cellOf(Math.min(yi, yj), Math.floor, -1, h));
|
|
796
|
+
const y1 = Math.min(h - 1, cellOf(Math.max(yi, yj), Math.ceil, 1, h));
|
|
797
|
+
for (let y = y0; y <= y1; y++) {
|
|
798
|
+
const py = cy(y);
|
|
799
|
+
if (yi > py !== yj > py) {
|
|
800
|
+
let row = crossings[y];
|
|
801
|
+
if (row === undefined) {
|
|
802
|
+
row = [];
|
|
803
|
+
crossings[y] = row;
|
|
804
|
+
}
|
|
805
|
+
row.push(((xj - xi) * (py - yi)) / (yj - yi) + xi + shift);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
for (let y = 0; y < h; y++) {
|
|
810
|
+
const row = crossings[y];
|
|
811
|
+
if (row === undefined) continue;
|
|
812
|
+
row.sort((a, b) => a - b);
|
|
813
|
+
const x0 = Math.max(0, cellOf(row[0], Math.floor, -1, w));
|
|
814
|
+
const x1 = Math.min(w - 1, cellOf(row[row.length - 1], Math.ceil, 1, w));
|
|
815
|
+
let left = 0;
|
|
816
|
+
for (let x = x0; x <= x1; x++) {
|
|
817
|
+
const px = cx(x);
|
|
818
|
+
while (left < row.length && !(px < row[left])) left++;
|
|
819
|
+
if ((row.length - left) & 1) marks[y * w + x] = 1;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// On the boundary: within ON_BOUNDARY of a side. Per side, the rows its ends span and, per row, the columns
|
|
824
|
+
// the side passes within a pixel of that row's centre height, each widened by a pixel; the exact test decides.
|
|
825
|
+
const unit = 1 / scale;
|
|
826
|
+
for (let i = 0; i < n; i++) {
|
|
827
|
+
const a = poly[i];
|
|
828
|
+
const b = poly[(i + 1) % n];
|
|
829
|
+
const y0 = Math.max(0, cellOf(Math.min(a[1], b[1]), Math.floor, -1, h));
|
|
830
|
+
const y1 = Math.min(h - 1, cellOf(Math.max(a[1], b[1]), Math.ceil, 1, h));
|
|
831
|
+
const dy = b[1] - a[1];
|
|
832
|
+
for (let y = y0; y <= y1; y++) {
|
|
833
|
+
let lo = Math.min(a[0], b[0]);
|
|
834
|
+
let hi = Math.max(a[0], b[0]);
|
|
835
|
+
if (dy !== 0) {
|
|
836
|
+
const py = cy(y);
|
|
837
|
+
const t0 = Math.max(0, Math.min(1, (py - unit - a[1]) / dy));
|
|
838
|
+
const t1 = Math.max(0, Math.min(1, (py + unit - a[1]) / dy));
|
|
839
|
+
const xa = a[0] + (b[0] - a[0]) * t0;
|
|
840
|
+
const xb = a[0] + (b[0] - a[0]) * t1;
|
|
841
|
+
lo = Math.min(xa, xb);
|
|
842
|
+
hi = Math.max(xa, xb);
|
|
843
|
+
}
|
|
844
|
+
const x0 = Math.max(0, cellOf(lo, Math.floor, -1, w));
|
|
845
|
+
const x1 = Math.min(w - 1, cellOf(hi, Math.ceil, 1, w));
|
|
846
|
+
for (let x = x0; x <= x1; x++) {
|
|
847
|
+
const k = y * w + x;
|
|
848
|
+
if (marks[k]) continue;
|
|
849
|
+
if (distanceToSegment([cx(x), cy(y)], a, b) <= ON_BOUNDARY) marks[k] = 1;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return marks;
|
|
854
|
+
}
|
|
855
|
+
|
|
710
856
|
/** Does the segment `a`–`b` meet the closed polygon — an endpoint inside or on it, or any crossing or touch? */
|
|
711
857
|
function segmentMeetsPolygon(a: Pt, b: Pt, poly: readonly Pt[]): boolean {
|
|
712
858
|
if (inClosedPolygon(a, poly) || inClosedPolygon(b, poly)) return true;
|
|
@@ -1673,8 +1819,10 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1673
1819
|
// would keep, in the same order.
|
|
1674
1820
|
const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
|
|
1675
1821
|
const inRegion = (): Int32Array => {
|
|
1822
|
+
// `inClosedPolygon` of every art pixel centre, by scanline (`closedPolygonCentres`, issue #1263).
|
|
1823
|
+
const inside = closedPolygonCentres(poly, mask.width, mask.height, scale);
|
|
1676
1824
|
const kept: number[] = [];
|
|
1677
|
-
for (let i = 0; i < artBits.length; i++) if (artBits[i] &&
|
|
1825
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && inside[i]) kept.push(i);
|
|
1678
1826
|
return Int32Array.from(kept);
|
|
1679
1827
|
};
|
|
1680
1828
|
const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
|
|
@@ -1841,6 +1989,7 @@ function effectiveJson(e: EffectiveSettings): Json {
|
|
|
1841
1989
|
boneOrder: e.boneOrder === null ? null : [...e.boneOrder],
|
|
1842
1990
|
schedule: scheduleJson(e.schedule),
|
|
1843
1991
|
budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
|
|
1992
|
+
...(e.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: e.stopAfterAccepted }),
|
|
1844
1993
|
};
|
|
1845
1994
|
}
|
|
1846
1995
|
|
|
@@ -1918,18 +2067,24 @@ function candidateJson(c: CandidateReport): Json {
|
|
|
1918
2067
|
deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
|
|
1919
2068
|
deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
|
|
1920
2069
|
linkedMeshes: k.linkedMeshes.map(attachmentJson),
|
|
2070
|
+
acceptedAt: [...k.acceptedAt],
|
|
1921
2071
|
};
|
|
1922
2072
|
}
|
|
1923
2073
|
return out;
|
|
1924
2074
|
}
|
|
1925
2075
|
|
|
2076
|
+
const notReachedJson = (s: StopNotReached | undefined): { [key: string]: Json } =>
|
|
2077
|
+
s === undefined ? {} : { stopAfterAccepted: { requested: s.requested, acceptedSteps: s.acceptedSteps } };
|
|
2078
|
+
|
|
1926
2079
|
function terminationJson(t: Termination | null): Json {
|
|
1927
2080
|
if (t === null) return null;
|
|
1928
2081
|
switch (t.reason) {
|
|
1929
2082
|
case 'no-further-valid-reduction':
|
|
1930
|
-
return { reason: t.reason, candidatesTried: t.candidatesTried, blockingConstraint: t.blockingConstraint };
|
|
2083
|
+
return { reason: t.reason, candidatesTried: t.candidatesTried, blockingConstraint: t.blockingConstraint, ...notReachedJson(t.stopAfterAccepted) };
|
|
1931
2084
|
case 'budget-exhausted':
|
|
1932
|
-
return { reason: t.reason, candidatesTried: t.candidatesTried, budget: t.budget, result: t.result };
|
|
2085
|
+
return { reason: t.reason, candidatesTried: t.candidatesTried, budget: t.budget, result: t.result, ...notReachedJson(t.stopAfterAccepted) };
|
|
2086
|
+
case 'replayed-to-accepted-step':
|
|
2087
|
+
return { reason: t.reason, acceptedSteps: t.acceptedSteps, candidatesTried: t.candidatesTried };
|
|
1933
2088
|
case 'invalid-input':
|
|
1934
2089
|
case 'unsupported-topology':
|
|
1935
2090
|
return { reason: t.reason, code: t.code, detail: t.detail };
|
package/src/meshreduce.ts
CHANGED
|
@@ -105,6 +105,7 @@ import {
|
|
|
105
105
|
type RefinementRegion,
|
|
106
106
|
type ReductionChanges,
|
|
107
107
|
type SourceMesh,
|
|
108
|
+
type StopNotReached,
|
|
108
109
|
type Termination,
|
|
109
110
|
} from './meshquality.ts';
|
|
110
111
|
import { artRastersOf, stepRastersOf, type ArtRasters, type StepRasters } from './meshrasters.ts';
|
|
@@ -260,6 +261,9 @@ function validateReduction(input: MeshReductionInput): void {
|
|
|
260
261
|
if (!isObject(b) || !Number.isInteger(b.maxCandidates) || (b.maxCandidates as number) < 0) {
|
|
261
262
|
refuse('REDUCE_INPUT_MISSING', `${who}: budget is ${JSON.stringify(b)}; required { maxCandidates: a whole number, 0 or more } — the work bound has no default`);
|
|
262
263
|
}
|
|
264
|
+
if ('stopAfterAccepted' in input && input.stopAfterAccepted !== undefined && (!Number.isInteger(input.stopAfterAccepted) || input.stopAfterAccepted < 0)) {
|
|
265
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: stopAfterAccepted is ${JSON.stringify(input.stopAfterAccepted)}; required a whole number of accepted steps, 0 or more, or the field left out (issue #1268: replay to that step)`);
|
|
266
|
+
}
|
|
263
267
|
const src = input.source;
|
|
264
268
|
if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
|
|
265
269
|
const n = src.points.length;
|
|
@@ -729,6 +733,10 @@ interface Run {
|
|
|
729
733
|
sourceHull: Array<[number, number]>;
|
|
730
734
|
/** Steps tried so far, refinement insertions and removal attempts alike. */
|
|
731
735
|
steps: number;
|
|
736
|
+
/** Issue #1268: `steps` as it stood at each accepted step — every insertion made and every removal taken — in order. */
|
|
737
|
+
acceptedAt: number[];
|
|
738
|
+
/** The replay fault planted for a control, or null. */
|
|
739
|
+
plant: ReplayPlant | null;
|
|
732
740
|
tally: Tally;
|
|
733
741
|
keyed: Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }>;
|
|
734
742
|
protectedVertices: Set<number>;
|
|
@@ -959,7 +967,27 @@ function insertPoint(run: Run, p: Pt): boolean {
|
|
|
959
967
|
type PhaseEnd =
|
|
960
968
|
| { kind: 'done' }
|
|
961
969
|
| { kind: 'budget' }
|
|
962
|
-
| { kind: 'stuck'; constraint: string }
|
|
970
|
+
| { kind: 'stuck'; constraint: string }
|
|
971
|
+
| { kind: 'replayed' };
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Record an accepted step (issue #1268) and say whether the call's `stopAfterAccepted` is now reached — checked
|
|
975
|
+
* straight after the step, before the budget is read again, so the replay stops in exactly the state the
|
|
976
|
+
* unreplayed run held after that step.
|
|
977
|
+
*/
|
|
978
|
+
function accept(run: Run): boolean {
|
|
979
|
+
run.acceptedAt.push(run.steps);
|
|
980
|
+
const stop = run.input.stopAfterAccepted;
|
|
981
|
+
if (stop === undefined) return false;
|
|
982
|
+
const at = run.plant === 'stop-one-early' ? stop - 1 : run.plant === 'stop-one-late' ? stop + 1 : stop;
|
|
983
|
+
return run.acceptedAt.length >= at;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* A fault planted on purpose in the replay, for the mesh-quality suite's controls (issue #1268): the stop taken
|
|
988
|
+
* one accepted step before or after the one asked for. `reduceMesh` plants none.
|
|
989
|
+
*/
|
|
990
|
+
export type ReplayPlant = 'stop-one-early' | 'stop-one-late';
|
|
963
991
|
|
|
964
992
|
/**
|
|
965
993
|
* Refine the working mesh until every region's `MQ_MAX_EDGE` and
|
|
@@ -983,6 +1011,7 @@ function refineRegions(run: Run): PhaseEnd {
|
|
|
983
1011
|
if (target.state === 'not-measurable') {
|
|
984
1012
|
const p: Pt = [r6(region.polygon[0][0]), r6(region.polygon[0][1])];
|
|
985
1013
|
if (!insertPoint(run, p)) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no triangle holding the region's first vertex)` };
|
|
1014
|
+
if (accept(run)) return { kind: 'replayed' };
|
|
986
1015
|
continue;
|
|
987
1016
|
}
|
|
988
1017
|
const [ra, rb] = target.worst!.at.edge!;
|
|
@@ -1000,7 +1029,10 @@ function refineRegions(run: Run): PhaseEnd {
|
|
|
1000
1029
|
exited = true;
|
|
1001
1030
|
break;
|
|
1002
1031
|
}
|
|
1003
|
-
if (exited)
|
|
1032
|
+
if (exited) {
|
|
1033
|
+
if (accept(run)) return { kind: 'replayed' };
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1004
1036
|
// An end outside the region and its band further beyond it than the edge's own bound, with no split on
|
|
1005
1037
|
// the band's outer boundary that frees the piece to it — always so with transition 0, where a contact
|
|
1006
1038
|
// with the authored boundary stays held: every split keeps a held edge from a vertex inside the region
|
|
@@ -1023,6 +1055,7 @@ function refineRegions(run: Run): PhaseEnd {
|
|
|
1023
1055
|
const p = splitPoint(run.work.pos[a], run.work.pos[b], region);
|
|
1024
1056
|
if (p === null) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no point of edge ${ra}–${rb} strictly between its ends inside region "${region.name}" or its band)` };
|
|
1025
1057
|
splitEdge(run, a, b, p);
|
|
1058
|
+
if (accept(run)) return { kind: 'replayed' };
|
|
1026
1059
|
}
|
|
1027
1060
|
}
|
|
1028
1061
|
|
|
@@ -1115,8 +1148,10 @@ function removeVertices(run: Run): PhaseEnd {
|
|
|
1115
1148
|
run.steps++;
|
|
1116
1149
|
tried++;
|
|
1117
1150
|
const block = tryRemoval(run, v);
|
|
1118
|
-
if (block === null)
|
|
1119
|
-
|
|
1151
|
+
if (block === null) {
|
|
1152
|
+
taken++;
|
|
1153
|
+
if (accept(run)) return { kind: 'replayed' };
|
|
1154
|
+
} else lastBlock = `${block}, removing source vertex ${v}`;
|
|
1120
1155
|
}
|
|
1121
1156
|
if (taken === 0) {
|
|
1122
1157
|
if (tried === 0) lastBlock = 'protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge)';
|
|
@@ -1193,6 +1228,11 @@ function tryRemoval(run: Run, v: number): string | null {
|
|
|
1193
1228
|
* returned when it meets every required bound, and none is when it does not;
|
|
1194
1229
|
* - `no-further-valid-reduction`: a pass took no step, naming what blocked the
|
|
1195
1230
|
* last one. A local stop: nothing here says the result is the smallest.
|
|
1231
|
+
* - `replayed-to-accepted-step`: the input's `stopAfterAccepted` was reached —
|
|
1232
|
+
* the mesh is the one the same call without it held after that many accepted
|
|
1233
|
+
* steps, byte for byte (issue #1268). Never reported as an exhausted budget; a
|
|
1234
|
+
* stop the run does not reach leaves the run's own termination, which then
|
|
1235
|
+
* carries `stopAfterAccepted: { requested, acceptedSteps }`.
|
|
1196
1236
|
*/
|
|
1197
1237
|
export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
1198
1238
|
validateReduction(input);
|
|
@@ -1215,19 +1255,19 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
|
1215
1255
|
* module with `export *`, and a symbol that is merely exported is not promised
|
|
1216
1256
|
* (RELEASING.md, *The import surface*).
|
|
1217
1257
|
*/
|
|
1218
|
-
export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null = stepRastersOf(rasters)): MeshReductionResult {
|
|
1258
|
+
export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null = stepRastersOf(rasters), plant: ReplayPlant | null = null): MeshReductionResult {
|
|
1219
1259
|
validateReduction(input);
|
|
1220
1260
|
if (steps !== null && steps.rasters !== rasters) {
|
|
1221
1261
|
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))`);
|
|
1222
1262
|
}
|
|
1223
|
-
return reduceValidated(input, rasters, steps);
|
|
1263
|
+
return reduceValidated(input, rasters, steps, plant);
|
|
1224
1264
|
}
|
|
1225
1265
|
|
|
1226
1266
|
/**
|
|
1227
1267
|
* The operation, over an input `validateReduction` accepted; the art's rasters are computed at most once, in
|
|
1228
1268
|
* `rasters`, and every refinement and removal step is measured through `steps` when it is given (issue #1246).
|
|
1229
1269
|
*/
|
|
1230
|
-
function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null): MeshReductionResult {
|
|
1270
|
+
function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null, plant: ReplayPlant | null = null): MeshReductionResult {
|
|
1231
1271
|
const who = `attachment ${nameOf(input.attachment)}`;
|
|
1232
1272
|
const src = input.source;
|
|
1233
1273
|
const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
|
|
@@ -1302,6 +1342,8 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
|
|
|
1302
1342
|
sourceTurn: Math.sign(signedArea(sourceHull)),
|
|
1303
1343
|
sourceHull,
|
|
1304
1344
|
steps: 0,
|
|
1345
|
+
acceptedAt: [],
|
|
1346
|
+
plant,
|
|
1305
1347
|
tally: { sharesDroppedOnGrid: 0, sharesPruned: 0 },
|
|
1306
1348
|
keyed: keyedSourceVertices(input),
|
|
1307
1349
|
...protectionOf(input, work, sourceEdges),
|
|
@@ -1310,20 +1352,28 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
|
|
|
1310
1352
|
stepRasters: steps,
|
|
1311
1353
|
};
|
|
1312
1354
|
|
|
1355
|
+
// Issue #1268: a replay ends in its own termination, never as an exhausted budget; a stop the run never reaches
|
|
1356
|
+
// leaves the run's own termination, which then carries `stopAfterAccepted` saying so.
|
|
1357
|
+
const replayed = (): Termination => ({ reason: 'replayed-to-accepted-step', acceptedSteps: run.acceptedAt.length, candidatesTried: run.steps });
|
|
1358
|
+
const stop = input.stopAfterAccepted;
|
|
1359
|
+
const notReached = (): { stopAfterAccepted?: StopNotReached } => (stop === undefined ? {} : { stopAfterAccepted: { requested: stop, acceptedSteps: run.acceptedAt.length } });
|
|
1360
|
+
if (stop === 0) return finish(run, effective, sourceCounts, replayed());
|
|
1313
1361
|
const refined = refineRegions(run);
|
|
1314
|
-
if (refined.kind === '
|
|
1362
|
+
if (refined.kind === 'replayed') return finish(run, effective, sourceCounts, replayed());
|
|
1363
|
+
if (refined.kind === 'budget') return noMesh({ reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'none-met-the-targets', ...notReached() });
|
|
1315
1364
|
const startCanon = canonicalise(work, run.sourceTurn, boneRank);
|
|
1316
1365
|
const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'), input.targets.artFit);
|
|
1317
1366
|
let termination: Termination;
|
|
1318
1367
|
if (startBlock !== null) {
|
|
1319
1368
|
const constraint = refined.kind === 'stuck' ? refined.constraint : rowName(startBlock);
|
|
1320
|
-
termination = { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: `${constraint} — before any removal: the refined source does not meet its targets, so no step from it can
|
|
1369
|
+
termination = { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: `${constraint} — before any removal: the refined source does not meet its targets, so no step from it can`, ...notReached() };
|
|
1321
1370
|
} else {
|
|
1322
1371
|
const reduced = removeVertices(run);
|
|
1372
|
+
if (reduced.kind === 'replayed') return finish(run, effective, sourceCounts, replayed());
|
|
1323
1373
|
termination =
|
|
1324
1374
|
reduced.kind === 'budget'
|
|
1325
|
-
? { reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'best-meeting-every-bound' }
|
|
1326
|
-
: { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: reduced.kind === 'stuck' ? reduced.constraint : '' };
|
|
1375
|
+
? { reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'best-meeting-every-bound', ...notReached() }
|
|
1376
|
+
: { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: reduced.kind === 'stuck' ? reduced.constraint : '', ...notReached() };
|
|
1327
1377
|
}
|
|
1328
1378
|
return finish(run, effective, sourceCounts, termination);
|
|
1329
1379
|
} catch (err) {
|
|
@@ -1403,6 +1453,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
|
|
|
1403
1453
|
deformRemapped: deform.remapped.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key, droppedVertices: d.droppedVertices })),
|
|
1404
1454
|
deformReevaluated: deform.reevaluated.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key })),
|
|
1405
1455
|
linkedMeshes: input.linkedMeshes,
|
|
1456
|
+
acceptedAt: [...run.acceptedAt],
|
|
1406
1457
|
};
|
|
1407
1458
|
const counts = candidate.counts!;
|
|
1408
1459
|
const mesh: ReducedMesh = {
|
|
@@ -1440,5 +1491,6 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
|
|
|
1440
1491
|
targets: input.targets,
|
|
1441
1492
|
referenceHull: sourceHull,
|
|
1442
1493
|
budget: { maxCandidates: input.budget.maxCandidates },
|
|
1494
|
+
...(input.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: input.stopAfterAccepted }),
|
|
1443
1495
|
};
|
|
1444
1496
|
}
|