rig-c 2.22.0 → 2.23.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 +126 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-c",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.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,
|
|
@@ -694,8 +695,21 @@ function validateInput(input: MeshMeasureInput): void {
|
|
|
694
695
|
|
|
695
696
|
type Pt = readonly [number, number];
|
|
696
697
|
|
|
697
|
-
/**
|
|
698
|
-
|
|
698
|
+
/**
|
|
699
|
+
* Is `p` inside or on the closed polygon? On the boundary within `ON_BOUNDARY` counts as inside.
|
|
700
|
+
*
|
|
701
|
+
* The rule, exactly, because `closedPolygonCentres` reproduces it bit for bit:
|
|
702
|
+
* `p` is in when its `distanceToSegment` to some side `poly[i]`–`poly[i+1]`
|
|
703
|
+
* is at most `ON_BOUNDARY` (1e-9 px) — a point on an edge or at a vertex is in,
|
|
704
|
+
* whatever the ray says; otherwise by **even-odd** parity of a ray towards +x,
|
|
705
|
+
* where the side from `poly[j]` to `poly[i]` (`j` the one before `i`) crosses
|
|
706
|
+
* when exactly one of its ends has a y strictly greater than `p`'s — `yi > py`
|
|
707
|
+
* differs from `yj > py`, so the half-open rule counts a vertex at `p`'s height
|
|
708
|
+
* once and a horizontal side never — at
|
|
709
|
+
* `xc = ((xj − xi)·(py − yi)) / (yj − yi) + xi`, and the crossing counts when
|
|
710
|
+
* `px < xc` strictly.
|
|
711
|
+
*/
|
|
712
|
+
export function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
699
713
|
const n = poly.length;
|
|
700
714
|
for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
|
|
701
715
|
let inside = false;
|
|
@@ -707,6 +721,112 @@ function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
|
707
721
|
return inside;
|
|
708
722
|
}
|
|
709
723
|
|
|
724
|
+
/**
|
|
725
|
+
* A fault planted on purpose in `closedPolygonCentres`, for the mesh-quality
|
|
726
|
+
* suite's negative control: `'crossing-half-pixel'` moves every ray crossing
|
|
727
|
+
* half a pixel towards +x. `regionRows` plants none.
|
|
728
|
+
*/
|
|
729
|
+
export type ScanlinePlant = 'crossing-half-pixel';
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Which pixel centres of a `w`×`h` grid lie in or on a closed polygon given
|
|
733
|
+
* in drawing px — `inClosedPolygon` of every centre `((x + 0.5) / scale,
|
|
734
|
+
* (y + 0.5) / scale)`, 1 where it is true, by scanline (issue #1263).
|
|
735
|
+
*
|
|
736
|
+
* The same decision, not a cleaner one: each row's crossings are the sides
|
|
737
|
+
* `inClosedPolygon` counts for that row's centre height, by its own
|
|
738
|
+
* half-open test, at the `xc` it computes with the same expression in the same
|
|
739
|
+
* order, so they are the same doubles; sorted, the number of them a centre
|
|
740
|
+
* lies strictly left of is the number its ray counts, and its parity is the
|
|
741
|
+
* answer. The boundary band is then added side by side, each centre near a
|
|
742
|
+
* side tested with `distanceToSegment` against `ON_BOUNDARY` — the predicate's
|
|
743
|
+
* own test — so a centre on an edge or at a vertex is in exactly when the
|
|
744
|
+
* predicate says so. The rows and columns visited around a side are widened by
|
|
745
|
+
* a pixel past where it can reach, and only the exact tests decide.
|
|
746
|
+
*
|
|
747
|
+
* O(rows × sides + pixels) where testing every centre against every side is
|
|
748
|
+
* O(pixels × sides).
|
|
749
|
+
*/
|
|
750
|
+
export function closedPolygonCentres(poly: readonly Pt[], w: number, h: number, scale: number, plant: ScanlinePlant | null = null): Uint8Array {
|
|
751
|
+
const marks = new Uint8Array(w * h);
|
|
752
|
+
const n = poly.length;
|
|
753
|
+
if (n === 0 || w <= 0 || h <= 0) return marks;
|
|
754
|
+
const cx = (x: number): number => (x + 0.5) / scale;
|
|
755
|
+
const cy = (y: number): number => (y + 0.5) / scale;
|
|
756
|
+
const shift = plant === 'crossing-half-pixel' ? 0.5 / scale : 0;
|
|
757
|
+
/** The grid row or column of a drawing coordinate, rounded one way and widened by `slack`; NaN visits nothing. */
|
|
758
|
+
const cellOf = (v: number, round: (u: number) => number, slack: number, size: number): number => {
|
|
759
|
+
const c = round(v * scale - 0.5) + slack;
|
|
760
|
+
if (Number.isNaN(c)) return slack < 0 ? size : -1;
|
|
761
|
+
return c;
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// Even-odd: per row, the crossings of a ray towards +x from the row's centre height.
|
|
765
|
+
const crossings: Array<number[] | undefined> = new Array<number[] | undefined>(h);
|
|
766
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
767
|
+
const [xi, yi] = poly[i];
|
|
768
|
+
const [xj, yj] = poly[j];
|
|
769
|
+
const y0 = Math.max(0, cellOf(Math.min(yi, yj), Math.floor, -1, h));
|
|
770
|
+
const y1 = Math.min(h - 1, cellOf(Math.max(yi, yj), Math.ceil, 1, h));
|
|
771
|
+
for (let y = y0; y <= y1; y++) {
|
|
772
|
+
const py = cy(y);
|
|
773
|
+
if (yi > py !== yj > py) {
|
|
774
|
+
let row = crossings[y];
|
|
775
|
+
if (row === undefined) {
|
|
776
|
+
row = [];
|
|
777
|
+
crossings[y] = row;
|
|
778
|
+
}
|
|
779
|
+
row.push(((xj - xi) * (py - yi)) / (yj - yi) + xi + shift);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
for (let y = 0; y < h; y++) {
|
|
784
|
+
const row = crossings[y];
|
|
785
|
+
if (row === undefined) continue;
|
|
786
|
+
row.sort((a, b) => a - b);
|
|
787
|
+
const x0 = Math.max(0, cellOf(row[0], Math.floor, -1, w));
|
|
788
|
+
const x1 = Math.min(w - 1, cellOf(row[row.length - 1], Math.ceil, 1, w));
|
|
789
|
+
let left = 0;
|
|
790
|
+
for (let x = x0; x <= x1; x++) {
|
|
791
|
+
const px = cx(x);
|
|
792
|
+
while (left < row.length && !(px < row[left])) left++;
|
|
793
|
+
if ((row.length - left) & 1) marks[y * w + x] = 1;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// On the boundary: within ON_BOUNDARY of a side. Per side, the rows its ends span and, per row, the columns
|
|
798
|
+
// the side passes within a pixel of that row's centre height, each widened by a pixel; the exact test decides.
|
|
799
|
+
const unit = 1 / scale;
|
|
800
|
+
for (let i = 0; i < n; i++) {
|
|
801
|
+
const a = poly[i];
|
|
802
|
+
const b = poly[(i + 1) % n];
|
|
803
|
+
const y0 = Math.max(0, cellOf(Math.min(a[1], b[1]), Math.floor, -1, h));
|
|
804
|
+
const y1 = Math.min(h - 1, cellOf(Math.max(a[1], b[1]), Math.ceil, 1, h));
|
|
805
|
+
const dy = b[1] - a[1];
|
|
806
|
+
for (let y = y0; y <= y1; y++) {
|
|
807
|
+
let lo = Math.min(a[0], b[0]);
|
|
808
|
+
let hi = Math.max(a[0], b[0]);
|
|
809
|
+
if (dy !== 0) {
|
|
810
|
+
const py = cy(y);
|
|
811
|
+
const t0 = Math.max(0, Math.min(1, (py - unit - a[1]) / dy));
|
|
812
|
+
const t1 = Math.max(0, Math.min(1, (py + unit - a[1]) / dy));
|
|
813
|
+
const xa = a[0] + (b[0] - a[0]) * t0;
|
|
814
|
+
const xb = a[0] + (b[0] - a[0]) * t1;
|
|
815
|
+
lo = Math.min(xa, xb);
|
|
816
|
+
hi = Math.max(xa, xb);
|
|
817
|
+
}
|
|
818
|
+
const x0 = Math.max(0, cellOf(lo, Math.floor, -1, w));
|
|
819
|
+
const x1 = Math.min(w - 1, cellOf(hi, Math.ceil, 1, w));
|
|
820
|
+
for (let x = x0; x <= x1; x++) {
|
|
821
|
+
const k = y * w + x;
|
|
822
|
+
if (marks[k]) continue;
|
|
823
|
+
if (distanceToSegment([cx(x), cy(y)], a, b) <= ON_BOUNDARY) marks[k] = 1;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return marks;
|
|
828
|
+
}
|
|
829
|
+
|
|
710
830
|
/** Does the segment `a`–`b` meet the closed polygon — an endpoint inside or on it, or any crossing or touch? */
|
|
711
831
|
function segmentMeetsPolygon(a: Pt, b: Pt, poly: readonly Pt[]): boolean {
|
|
712
832
|
if (inClosedPolygon(a, poly) || inClosedPolygon(b, poly)) return true;
|
|
@@ -1673,8 +1793,10 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1673
1793
|
// would keep, in the same order.
|
|
1674
1794
|
const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
|
|
1675
1795
|
const inRegion = (): Int32Array => {
|
|
1796
|
+
// `inClosedPolygon` of every art pixel centre, by scanline (`closedPolygonCentres`, issue #1263).
|
|
1797
|
+
const inside = closedPolygonCentres(poly, mask.width, mask.height, scale);
|
|
1676
1798
|
const kept: number[] = [];
|
|
1677
|
-
for (let i = 0; i < artBits.length; i++) if (artBits[i] &&
|
|
1799
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && inside[i]) kept.push(i);
|
|
1678
1800
|
return Int32Array.from(kept);
|
|
1679
1801
|
};
|
|
1680
1802
|
const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
|