spine-rigc 2.20.0 → 2.20.2
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 +84 -23
- package/src/meshquality.ts +275 -81
- package/src/meshrasters.ts +621 -0
- package/src/meshreduce.ts +71 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.20.
|
|
3
|
+
"version": "2.20.2",
|
|
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
|
@@ -1319,23 +1319,60 @@ export function rasteriseTriangles(points: Array<[number, number]>, triangles: n
|
|
|
1319
1319
|
const a = points[triangles[t]];
|
|
1320
1320
|
const b = points[triangles[t + 1]];
|
|
1321
1321
|
const c = points[triangles[t + 2]];
|
|
1322
|
-
const
|
|
1323
|
-
if (
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
const maxX = Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1));
|
|
1327
|
-
const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1));
|
|
1328
|
-
const maxY = Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1));
|
|
1329
|
-
for (let y = minY; y <= maxY; y++) {
|
|
1330
|
-
for (let x = minX; x <= maxX; x++) {
|
|
1322
|
+
const box = triangleRasterBox(a, b, c, w, h);
|
|
1323
|
+
if (box === null) continue;
|
|
1324
|
+
for (let y = box.minY; y <= box.maxY; y++) {
|
|
1325
|
+
for (let x = box.minX; x <= box.maxX; x++) {
|
|
1331
1326
|
if (covered[y * w + x]) continue;
|
|
1332
|
-
if (
|
|
1327
|
+
if (pixelCentreInTriangle(x, y, a, b, c, box.orient)) covered[y * w + x] = 1;
|
|
1333
1328
|
}
|
|
1334
1329
|
}
|
|
1335
1330
|
}
|
|
1336
1331
|
return covered;
|
|
1337
1332
|
}
|
|
1338
1333
|
|
|
1334
|
+
/**
|
|
1335
|
+
* The pixels of a `w`x`h` grid one triangle can cover under `rasteriseTriangles`'s
|
|
1336
|
+
* rule — the box of its corners widened by a pixel and clamped to the grid —
|
|
1337
|
+
* and the orientation its centre test reads; null for a degenerate triangle
|
|
1338
|
+
* (zero doubled area), which covers nothing. `rasteriseTriangles` and the
|
|
1339
|
+
* per-step coverage of `src/meshrasters.ts` both read the rule from here and
|
|
1340
|
+
* from `pixelCentreInTriangle`, so the two cannot drift apart.
|
|
1341
|
+
*/
|
|
1342
|
+
export function triangleRasterBox(
|
|
1343
|
+
a: readonly [number, number],
|
|
1344
|
+
b: readonly [number, number],
|
|
1345
|
+
c: readonly [number, number],
|
|
1346
|
+
w: number,
|
|
1347
|
+
h: number,
|
|
1348
|
+
): { orient: number; minX: number; maxX: number; minY: number; maxY: number } | null {
|
|
1349
|
+
const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
1350
|
+
if (Math.abs(twice) < 1e-12) return null;
|
|
1351
|
+
return {
|
|
1352
|
+
orient: twice > 0 ? 1 : -1,
|
|
1353
|
+
minX: Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1)),
|
|
1354
|
+
maxX: Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1)),
|
|
1355
|
+
minY: Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1)),
|
|
1356
|
+
maxY: Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1)),
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Is the centre of pixel (`x`, `y`) in or on the triangle — `rasteriseTriangles`'s
|
|
1362
|
+
* test, with the orientation `triangleRasterBox` gave. `pointInTriangle` at
|
|
1363
|
+
* `[x + 0.5, y + 0.5]`, written out without the point array: the same three
|
|
1364
|
+
* products, in the same order, against the same tolerance.
|
|
1365
|
+
*/
|
|
1366
|
+
export function pixelCentreInTriangle(x: number, y: number, a: readonly [number, number], b: readonly [number, number], c: readonly [number, number], orient: number): boolean {
|
|
1367
|
+
const px = x + 0.5;
|
|
1368
|
+
const py = y + 0.5;
|
|
1369
|
+
return (
|
|
1370
|
+
((b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0])) * orient >= -1e-9 &&
|
|
1371
|
+
((c[0] - b[0]) * (py - b[1]) - (c[1] - b[1]) * (px - b[0])) * orient >= -1e-9 &&
|
|
1372
|
+
((a[0] - c[0]) * (py - c[1]) - (a[1] - c[1]) * (px - c[0])) * orient >= -1e-9
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1339
1376
|
/**
|
|
1340
1377
|
* Rasterise a triangle set over the part's pixel grid and measure two things the
|
|
1341
1378
|
* skeleton file cannot say: how much of the art the mesh covers, and how far past
|
|
@@ -1492,10 +1529,33 @@ export function measureAuthoredMeshFit(
|
|
|
1492
1529
|
* never asks (a mask with no art has no covered-outside pixel to ask about).
|
|
1493
1530
|
*/
|
|
1494
1531
|
export function squaredDistanceToSet(inside: Uint8Array, w: number, h: number): Float64Array {
|
|
1495
|
-
const
|
|
1532
|
+
const passes = distancePassesOf(w, h);
|
|
1496
1533
|
const dist = new Float64Array(w * h);
|
|
1497
|
-
for (let
|
|
1534
|
+
for (let x = 0; x < w; x++) {
|
|
1535
|
+
const out = passes.column(inside, x);
|
|
1536
|
+
for (let y = 0; y < h; y++) dist[y * w + x] = out[y];
|
|
1537
|
+
}
|
|
1538
|
+
for (let y = 0; y < h; y++) {
|
|
1539
|
+
const out = passes.row(dist, y);
|
|
1540
|
+
for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
|
|
1541
|
+
}
|
|
1542
|
+
return dist;
|
|
1543
|
+
}
|
|
1498
1544
|
|
|
1545
|
+
/**
|
|
1546
|
+
* `squaredDistanceToSet`'s two passes over one `w`x`h` grid, one column or one
|
|
1547
|
+
* row per call, so a caller holding the previous grid's passes can redo only
|
|
1548
|
+
* the columns whose set changed and the rows whose column values changed.
|
|
1549
|
+
* `column(inside, x)` is column `x`'s pass over the set (`INF` where the
|
|
1550
|
+
* column has no set pixel); `row(columns, y)` is row `y`'s pass over the
|
|
1551
|
+
* column results. Each returns a scratch array whose first `h` (column) or
|
|
1552
|
+
* `w` (row) entries are the result, valid until the next call. A column's
|
|
1553
|
+
* result depends on that column of `inside` alone and a row's on that row of
|
|
1554
|
+
* `columns` alone, which is what makes redoing a subset exact: every output
|
|
1555
|
+
* is this one function of the same input.
|
|
1556
|
+
*/
|
|
1557
|
+
export function distancePassesOf(w: number, h: number): { column(inside: Uint8Array, x: number): Float64Array; row(columns: Float64Array, y: number): Float64Array } {
|
|
1558
|
+
const INF = w * w + h * h + 1;
|
|
1499
1559
|
const span = Math.max(w, h);
|
|
1500
1560
|
const f = new Float64Array(span);
|
|
1501
1561
|
const out = new Float64Array(span);
|
|
@@ -1526,17 +1586,18 @@ export function squaredDistanceToSet(inside: Uint8Array, w: number, h: number):
|
|
|
1526
1586
|
}
|
|
1527
1587
|
};
|
|
1528
1588
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1589
|
+
return {
|
|
1590
|
+
column: (inside, x) => {
|
|
1591
|
+
for (let y = 0; y < h; y++) f[y] = inside[y * w + x] ? 0 : INF;
|
|
1592
|
+
envelope(h);
|
|
1593
|
+
return out;
|
|
1594
|
+
},
|
|
1595
|
+
row: (columns, y) => {
|
|
1596
|
+
for (let x = 0; x < w; x++) f[x] = columns[y * w + x];
|
|
1597
|
+
envelope(w);
|
|
1598
|
+
return out;
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1540
1601
|
}
|
|
1541
1602
|
|
|
1542
1603
|
/**
|
package/src/meshquality.ts
CHANGED
|
@@ -54,26 +54,22 @@
|
|
|
54
54
|
* `MeshReductionError` is defined in `src/mesh.ts` for exactly that reason.
|
|
55
55
|
*/
|
|
56
56
|
import {
|
|
57
|
-
artOf,
|
|
58
57
|
checkHullOrder,
|
|
59
58
|
distanceToSegment,
|
|
60
|
-
fillEnclosed,
|
|
61
59
|
findSelfIntersection,
|
|
62
|
-
labelIslands,
|
|
63
60
|
MeshError,
|
|
64
61
|
MeshReductionError,
|
|
65
62
|
meshEdges,
|
|
66
|
-
prunePolygon,
|
|
67
63
|
r6,
|
|
68
64
|
rasteriseTriangles,
|
|
69
65
|
segmentsMeet,
|
|
70
66
|
squaredDistanceToSet,
|
|
71
|
-
traceAlphaOutline,
|
|
72
67
|
traceOutline,
|
|
73
68
|
type AlphaMask,
|
|
74
69
|
type MeshOutline,
|
|
75
70
|
} from './mesh.ts';
|
|
76
71
|
import { areaBand, triangleAreas } from './areaband.ts';
|
|
72
|
+
import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
|
|
77
73
|
import { cropToSpineY } from './transform.ts';
|
|
78
74
|
|
|
79
75
|
// ---------------------------------------------------------------------------
|
|
@@ -856,13 +852,15 @@ function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number
|
|
|
856
852
|
* value already found is dropped; the rest are halved. No sampling density is
|
|
857
853
|
* chosen, so nothing here can miss a maximum between two samples.
|
|
858
854
|
*/
|
|
859
|
-
function directedHausdorff(a: readonly Pt[], b: readonly Pt[]): { d: number; edge: number; at: Pt } {
|
|
855
|
+
function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt) => Float64Array) | null = null): { d: number; edge: number; at: Pt } {
|
|
860
856
|
const m = b.length;
|
|
861
|
-
const toEach =
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
857
|
+
const toEach =
|
|
858
|
+
carried ??
|
|
859
|
+
((p: Pt): Float64Array => {
|
|
860
|
+
const out = new Float64Array(m);
|
|
861
|
+
for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
|
|
862
|
+
return out;
|
|
863
|
+
});
|
|
866
864
|
const minOf = (g: Float64Array): number => {
|
|
867
865
|
let v = Infinity;
|
|
868
866
|
for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
|
|
@@ -909,13 +907,136 @@ function directedHausdorff(a: readonly Pt[], b: readonly Pt[]): { d: number; edg
|
|
|
909
907
|
* taken on when the worst point is the candidate's, else the candidate edge
|
|
910
908
|
* nearest the reference's worst point.
|
|
911
909
|
*/
|
|
912
|
-
function hausdorff(candidate: readonly Pt[], reference: readonly Pt[]): { d: number; candidateEdge: number } {
|
|
913
|
-
const forward = directedHausdorff(candidate, reference);
|
|
914
|
-
const backward = directedHausdorff(reference, candidate);
|
|
910
|
+
function hausdorff(candidate: readonly Pt[], reference: readonly Pt[], carried: { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array } | null = null): { d: number; candidateEdge: number } {
|
|
911
|
+
const forward = directedHausdorff(candidate, reference, carried?.forward ?? null);
|
|
912
|
+
const backward = directedHausdorff(reference, candidate, carried?.backward ?? null);
|
|
915
913
|
if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
|
|
916
914
|
return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
|
|
917
915
|
}
|
|
918
916
|
|
|
917
|
+
/** A point as a key: both coordinates' shortest round-trip decimals, a negative zero told from a positive one. */
|
|
918
|
+
function pointKey(p: Pt): string {
|
|
919
|
+
const k = (v: number): string => (v === 0 && 1 / v < 0 ? '-0' : String(v));
|
|
920
|
+
return `${k(p[0])},${k(p[1])}`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* `hausdorff`'s point-to-edge distance lists for one outline slot of a
|
|
925
|
+
* reduction, carried from the slot's last reading (`OutlineMemo`, issue #1246).
|
|
926
|
+
* Every list is the one `directedHausdorff` computes — `distanceToSegment(p,
|
|
927
|
+
* b[j], b[(j + 1) % m])` for every edge `j` — had in one of three ways, each
|
|
928
|
+
* the same function of the same input:
|
|
929
|
+
*
|
|
930
|
+
* - **toward the polygon read against** (fixed in a reduction): the list
|
|
931
|
+
* kept for the same point, handed back whole while that polygon is equal;
|
|
932
|
+
* - **toward the outline**, which a step changes: the last reading's list for
|
|
933
|
+
* the same point, each distance copied when its edge — both ends, exactly —
|
|
934
|
+
* is an edge of the last outline, and computed when it is not;
|
|
935
|
+
* - anything else: computed.
|
|
936
|
+
*/
|
|
937
|
+
function carriedDistances(
|
|
938
|
+
memo: OutlineMemo,
|
|
939
|
+
outline: readonly Pt[],
|
|
940
|
+
against: readonly Pt[],
|
|
941
|
+
steps: StepRasters,
|
|
942
|
+
): { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array; commit: () => void } {
|
|
943
|
+
const { tally, plant } = steps;
|
|
944
|
+
memo.readings++;
|
|
945
|
+
const reading = memo.readings;
|
|
946
|
+
// The polygon read against: keep the lists while it is equal, coordinate by coordinate.
|
|
947
|
+
const againstFlat = new Float64Array(against.length * 2);
|
|
948
|
+
against.forEach((p, i) => {
|
|
949
|
+
againstFlat[i * 2] = p[0];
|
|
950
|
+
againstFlat[i * 2 + 1] = p[1];
|
|
951
|
+
});
|
|
952
|
+
const sameAgainst = memo.against !== null && memo.against.length === againstFlat.length && memo.against.every((v, i) => Object.is(v, againstFlat[i]));
|
|
953
|
+
if (!sameAgainst) {
|
|
954
|
+
memo.toAgainst.clear();
|
|
955
|
+
memo.against = againstFlat;
|
|
956
|
+
}
|
|
957
|
+
const ma = against.length;
|
|
958
|
+
const forward = (p: Pt): Float64Array => {
|
|
959
|
+
tally.distanceLists++;
|
|
960
|
+
const key = pointKey(p);
|
|
961
|
+
const kept = memo.toAgainst.get(key);
|
|
962
|
+
if (kept !== undefined) {
|
|
963
|
+
kept.used = reading;
|
|
964
|
+
tally.distanceListsReused++;
|
|
965
|
+
return kept.list;
|
|
966
|
+
}
|
|
967
|
+
const list = new Float64Array(ma);
|
|
968
|
+
for (let j = 0; j < ma; j++) list[j] = distanceToSegment(p, against[j], against[(j + 1) % ma]);
|
|
969
|
+
tally.edgesComputed += ma;
|
|
970
|
+
memo.toAgainst.set(key, { list, used: reading });
|
|
971
|
+
return list;
|
|
972
|
+
};
|
|
973
|
+
// The outline's edges, and where each sat in the last outline.
|
|
974
|
+
const mo = outline.length;
|
|
975
|
+
const edges = new Map<string, number>();
|
|
976
|
+
const from = new Int32Array(mo);
|
|
977
|
+
const lastEdges = memo.outlineEdges;
|
|
978
|
+
let plantPending = plant === 'skip-an-edge';
|
|
979
|
+
for (let j = 0; j < mo; j++) {
|
|
980
|
+
const key = `${pointKey(outline[j])};${pointKey(outline[(j + 1) % mo])}`;
|
|
981
|
+
edges.set(key, j);
|
|
982
|
+
const was = lastEdges?.get(key);
|
|
983
|
+
from[j] = was ?? -1;
|
|
984
|
+
if (was === undefined && plantPending && lastEdges !== null && j < lastEdges.size) {
|
|
985
|
+
// The plant: one new edge read as if it were the last outline's edge at the same index.
|
|
986
|
+
plantPending = false;
|
|
987
|
+
from[j] = j;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const last = memo.toOutline;
|
|
991
|
+
const next = new Map<string, Float64Array>();
|
|
992
|
+
const backward = (p: Pt): Float64Array => {
|
|
993
|
+
tally.distanceLists++;
|
|
994
|
+
const key = pointKey(p);
|
|
995
|
+
const done = next.get(key);
|
|
996
|
+
if (done !== undefined) {
|
|
997
|
+
tally.distanceListsReused++;
|
|
998
|
+
return done;
|
|
999
|
+
}
|
|
1000
|
+
const before = last.get(key);
|
|
1001
|
+
const list = new Float64Array(mo);
|
|
1002
|
+
if (before === undefined) {
|
|
1003
|
+
for (let j = 0; j < mo; j++) list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
|
|
1004
|
+
tally.edgesComputed += mo;
|
|
1005
|
+
} else {
|
|
1006
|
+
tally.distanceListsCarried++;
|
|
1007
|
+
for (let j = 0; j < mo; j++) {
|
|
1008
|
+
if (from[j] >= 0) {
|
|
1009
|
+
list[j] = before[from[j]];
|
|
1010
|
+
tally.edgesCarried++;
|
|
1011
|
+
} else {
|
|
1012
|
+
list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
|
|
1013
|
+
tally.edgesComputed++;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
next.set(key, list);
|
|
1018
|
+
return list;
|
|
1019
|
+
};
|
|
1020
|
+
return {
|
|
1021
|
+
forward,
|
|
1022
|
+
backward,
|
|
1023
|
+
commit: () => {
|
|
1024
|
+
// Keep this reading's outline lists for the next one; drop the lists toward the fixed polygon the last two readings did not ask for.
|
|
1025
|
+
memo.toOutline = next;
|
|
1026
|
+
memo.outlineEdges = edges;
|
|
1027
|
+
for (const [key, entry] of memo.toAgainst) if (entry.used < reading - 1) memo.toAgainst.delete(key);
|
|
1028
|
+
},
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** `hausdorff` through a reduction's carried distances (`carriedDistances`), dropping what the last two readings of the slot did not ask for. */
|
|
1033
|
+
function hausdorffCarried(memo: OutlineMemo, candidate: readonly Pt[], reference: readonly Pt[], steps: StepRasters): { d: number; candidateEdge: number } {
|
|
1034
|
+
const carried = carriedDistances(memo, candidate, reference, steps);
|
|
1035
|
+
const value = hausdorff(candidate, reference, carried);
|
|
1036
|
+
carried.commit();
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
919
1040
|
// ---------------------------------------------------------------------------
|
|
920
1041
|
// rows
|
|
921
1042
|
// ---------------------------------------------------------------------------
|
|
@@ -1012,6 +1133,120 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
|
|
|
1012
1133
|
*/
|
|
1013
1134
|
export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
1014
1135
|
validateInput(input);
|
|
1136
|
+
return measureValidated(input, artRastersOf(input.art), null);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* `measureMeshQuality` over art rasters the caller already holds
|
|
1141
|
+
* (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
|
|
1142
|
+
* one call, so the quantities of the art alone are computed once per call
|
|
1143
|
+
* rather than once per step (issue #1240). The report is the one
|
|
1144
|
+
* `measureMeshQuality` returns for the same input, byte for byte; rasters
|
|
1145
|
+
* taken from another art are refused (`REDUCE_ART_RASTERS_MISMATCH`), never
|
|
1146
|
+
* read.
|
|
1147
|
+
*
|
|
1148
|
+
* Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
|
|
1149
|
+
* module with `export *`, and a symbol that is merely exported is not promised
|
|
1150
|
+
* (RELEASING.md, *The import surface*).
|
|
1151
|
+
*/
|
|
1152
|
+
export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
|
|
1153
|
+
validateInput(input);
|
|
1154
|
+
checkArtRasters(input, rasters);
|
|
1155
|
+
return measureValidated(input, rasters, null);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* `measureMeshQualityWith` carried from the last measurement the same
|
|
1160
|
+
* `StepRasters` made (`src/meshrasters.ts`, issue #1246) — what `reduceMesh`
|
|
1161
|
+
* calls for each step, so a step redraws only the triangles its removal
|
|
1162
|
+
* changed, redoes the distance transform only where the coverage flipped, and
|
|
1163
|
+
* reads the outline rows again only when the outline moved. The report is the
|
|
1164
|
+
* one `measureMeshQuality` returns for the same input, byte for byte; rasters
|
|
1165
|
+
* taken from another art are refused as `measureMeshQualityWith` refuses them.
|
|
1166
|
+
*
|
|
1167
|
+
* Internal, as `measureMeshQualityWith` is.
|
|
1168
|
+
*/
|
|
1169
|
+
export function measureMeshQualityStep(input: MeshMeasureInput, steps: StepRasters): MeshQualityReport {
|
|
1170
|
+
validateInput(input);
|
|
1171
|
+
checkArtRasters(input, steps.rasters);
|
|
1172
|
+
return measureValidated(input, steps.rasters, steps);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* The coverage reading of a triangle set measured on its own — the full path:
|
|
1177
|
+
* every triangle drawn (`rasteriseTriangles`), the whole distance transform,
|
|
1178
|
+
* and the covered art and islands counted over every pixel.
|
|
1179
|
+
*/
|
|
1180
|
+
function coverageOf(onGrid: Array<[number, number]>, triangles: number[], w: number, h: number, rasters: ArtRasters): CoverageReading {
|
|
1181
|
+
const artBits = rasters.artBits();
|
|
1182
|
+
const covered = rasteriseTriangles(onGrid, triangles, w, h);
|
|
1183
|
+
let coveredArt = 0;
|
|
1184
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
|
|
1185
|
+
const toCovered = squaredDistanceToSet(covered, w, h);
|
|
1186
|
+
const anyCovered = covered.some((c) => c === 1);
|
|
1187
|
+
let undercutAt = -1;
|
|
1188
|
+
let undercutSq = 0;
|
|
1189
|
+
for (let i = 0; i < artBits.length; i++) {
|
|
1190
|
+
if (!artBits[i] || covered[i]) continue;
|
|
1191
|
+
if (undercutAt === -1 || toCovered[i] > undercutSq) {
|
|
1192
|
+
undercutAt = i;
|
|
1193
|
+
undercutSq = toCovered[i];
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const { label } = rasters.islands();
|
|
1197
|
+
const touched = new Set<number>();
|
|
1198
|
+
for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
|
|
1199
|
+
const secondIsland = touched.size >= 2 ? [...touched].sort((p, q) => p - q)[1] : 0;
|
|
1200
|
+
const silhouette = (connectivity: 4 | 8): SilhouetteReading => {
|
|
1201
|
+
const fills = rasters.fills();
|
|
1202
|
+
const filled = connectivity === 8 ? fills.fill8 : fills.fill4;
|
|
1203
|
+
const toFilled = rasters.toFilled(connectivity);
|
|
1204
|
+
let overAt = -1;
|
|
1205
|
+
let overSq = 0;
|
|
1206
|
+
let holes = 0;
|
|
1207
|
+
let holeAt = -1;
|
|
1208
|
+
for (let i = 0; i < covered.length; i++) {
|
|
1209
|
+
if (!covered[i]) continue;
|
|
1210
|
+
if (!filled[i]) {
|
|
1211
|
+
if (overAt === -1 || toFilled[i] > overSq) {
|
|
1212
|
+
overAt = i;
|
|
1213
|
+
overSq = toFilled[i];
|
|
1214
|
+
}
|
|
1215
|
+
} else if (!artBits[i]) {
|
|
1216
|
+
holes++;
|
|
1217
|
+
if (holeAt === -1) holeAt = i;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return { overAt, overSq, holes, holeAt };
|
|
1221
|
+
};
|
|
1222
|
+
return { covered, toCovered, coveredArt, anyCovered, undercut: { at: undercutAt, sq: undercutSq }, silhouette, islandsTouched: touched.size, secondIsland };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Rasters are read only for the art they were taken from: the same mask
|
|
1227
|
+
* array, mask size, threshold and frame. The first field that differs is
|
|
1228
|
+
* refused by name, with the value the rasters were taken at and the value the
|
|
1229
|
+
* input requires.
|
|
1230
|
+
*/
|
|
1231
|
+
function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
|
|
1232
|
+
const want = input.art;
|
|
1233
|
+
const got = rasters.art;
|
|
1234
|
+
if (got === want) return;
|
|
1235
|
+
const who = `attachment ${nameOf(input.attachment)}`;
|
|
1236
|
+
const differs = (field: string, found: string, required: string): never =>
|
|
1237
|
+
refuse('REDUCE_ART_RASTERS_MISMATCH', `${who}: the art rasters were taken at ${field} ${found}; required ${field} ${required}, the input's — rasters are read only for the art they were taken from`);
|
|
1238
|
+
const size = (m: AlphaMask | undefined): string => (m === undefined ? 'undefined' : `${m.width}x${m.height}`);
|
|
1239
|
+
if (size(got.mask) !== size(want.mask)) differs('art.mask size', size(got.mask), size(want.mask));
|
|
1240
|
+
if (got.threshold !== want.threshold) differs('art.threshold', JSON.stringify(got.threshold), JSON.stringify(want.threshold));
|
|
1241
|
+
for (const field of ['pageScale', 'width', 'height'] as const) {
|
|
1242
|
+
if (got.frame?.[field] !== want.frame[field]) differs(`art.frame.${field}`, JSON.stringify(got.frame?.[field]), JSON.stringify(want.frame[field]));
|
|
1243
|
+
}
|
|
1244
|
+
if (got.mask.alpha !== want.mask.alpha) differs('art.mask.alpha', 'another array', 'the input\'s own array (the same object)');
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
|
|
1248
|
+
function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
|
|
1249
|
+
rasters.tally.uses++;
|
|
1015
1250
|
const { attachment, art, source, targets } = input;
|
|
1016
1251
|
const { mask, threshold, frame } = art;
|
|
1017
1252
|
const scale = frame.pageScale;
|
|
@@ -1053,11 +1288,9 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1053
1288
|
const w = mask.width;
|
|
1054
1289
|
const h = mask.height;
|
|
1055
1290
|
const grid = { width: w, height: h, pageScale: scale };
|
|
1056
|
-
const artBits =
|
|
1057
|
-
|
|
1058
|
-
for (const bit of artBits) artCount += bit;
|
|
1291
|
+
const artBits = rasters.artBits();
|
|
1292
|
+
const artCount = rasters.artCount();
|
|
1059
1293
|
const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
|
|
1060
|
-
const covered = rasteriseTriangles(onGrid, source.triangles, w, h);
|
|
1061
1294
|
const fit = targets.artFit;
|
|
1062
1295
|
const pxIncrement = 1 / scale;
|
|
1063
1296
|
const rasterArt = (connectivity: 4 | 8 | null): NonNullable<MeasureRow['art']> => ({ threshold, connectivity, samples: artCount });
|
|
@@ -1070,21 +1303,12 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1070
1303
|
rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
|
|
1071
1304
|
rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
|
|
1072
1305
|
} else {
|
|
1073
|
-
// Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers.
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
const anyCovered = covered.some((c) => c === 1);
|
|
1306
|
+
// Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers. A reduction step
|
|
1307
|
+
// carries it from its last measurement (`StepRasters`), which reads the same values.
|
|
1308
|
+
const reading = steps === null ? coverageOf(onGrid, source.triangles, w, h, rasters) : steps.coverage(onGrid, source.triangles);
|
|
1309
|
+
const { coveredArt, anyCovered } = reading;
|
|
1078
1310
|
// Undercut: the furthest uncovered art pixel from the covered set. Its pixel is coverage's worst too.
|
|
1079
|
-
|
|
1080
|
-
let undercutSq = 0;
|
|
1081
|
-
for (let i = 0; i < artBits.length; i++) {
|
|
1082
|
-
if (!artBits[i] || covered[i]) continue;
|
|
1083
|
-
if (undercutAt === -1 || toCovered[i] > undercutSq) {
|
|
1084
|
-
undercutAt = i;
|
|
1085
|
-
undercutSq = toCovered[i];
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1311
|
+
const { at: undercutAt, sq: undercutSq } = reading.undercut;
|
|
1088
1312
|
const missingWorst: WorstSample = undercutAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(undercutAt) } };
|
|
1089
1313
|
rows.push(
|
|
1090
1314
|
withRaster(
|
|
@@ -1108,33 +1332,9 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1108
1332
|
|
|
1109
1333
|
// Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
|
|
1110
1334
|
// where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
|
|
1111
|
-
const
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
for (let i = 0; i < fill8.length; i++) {
|
|
1115
|
-
if (fill8[i] !== fill4[i]) {
|
|
1116
|
-
fillsDiffer = true;
|
|
1117
|
-
break;
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
const silhouetteRows = (filled: Uint8Array, connectivity: 4 | 8, gated: boolean): void => {
|
|
1121
|
-
const toFilled = squaredDistanceToSet(filled, w, h);
|
|
1122
|
-
let overAt = -1;
|
|
1123
|
-
let overSq = 0;
|
|
1124
|
-
let holes = 0;
|
|
1125
|
-
let holeAt = -1;
|
|
1126
|
-
for (let i = 0; i < covered.length; i++) {
|
|
1127
|
-
if (!covered[i]) continue;
|
|
1128
|
-
if (!filled[i]) {
|
|
1129
|
-
if (overAt === -1 || toFilled[i] > overSq) {
|
|
1130
|
-
overAt = i;
|
|
1131
|
-
overSq = toFilled[i];
|
|
1132
|
-
}
|
|
1133
|
-
} else if (!artBits[i]) {
|
|
1134
|
-
holes++;
|
|
1135
|
-
if (holeAt === -1) holeAt = i;
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1335
|
+
const fillsDiffer = rasters.fills().differ;
|
|
1336
|
+
const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
|
|
1337
|
+
const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
|
|
1138
1338
|
const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
|
|
1139
1339
|
rows.push(
|
|
1140
1340
|
withRaster(
|
|
@@ -1146,21 +1346,15 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1146
1346
|
);
|
|
1147
1347
|
rows.push(withRaster(measuredRow(spec('MQ_HOLES', 'count'), holes, null, holeAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(holeAt) } }, false), rasterArt(connectivity), grid, 1));
|
|
1148
1348
|
};
|
|
1149
|
-
silhouetteRows(
|
|
1150
|
-
if (fillsDiffer) silhouetteRows(
|
|
1349
|
+
silhouetteRows(8, true);
|
|
1350
|
+
if (fillsDiffer) silhouetteRows(4, false);
|
|
1151
1351
|
|
|
1152
1352
|
// Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
|
|
1153
|
-
const { label } =
|
|
1154
|
-
const
|
|
1155
|
-
for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
|
|
1156
|
-
let joinedAt = -1;
|
|
1157
|
-
if (touched.size >= 2) {
|
|
1158
|
-
const second = [...touched].sort((p, q) => p - q)[1];
|
|
1159
|
-
joinedAt = label.indexOf(second);
|
|
1160
|
-
}
|
|
1353
|
+
const { label } = rasters.islands();
|
|
1354
|
+
const joinedAt = reading.islandsTouched >= 2 ? label.indexOf(reading.secondIsland) : -1;
|
|
1161
1355
|
rows.push(
|
|
1162
1356
|
withRaster(
|
|
1163
|
-
measuredRow(spec('MQ_ISLANDS', 'count'),
|
|
1357
|
+
measuredRow(spec('MQ_ISLANDS', 'count'), reading.islandsTouched, null, joinedAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(joinedAt) } }, false),
|
|
1164
1358
|
{ threshold, connectivity: 4, samples: artCount },
|
|
1165
1359
|
grid,
|
|
1166
1360
|
1,
|
|
@@ -1174,19 +1368,19 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1174
1368
|
if (input.referenceHull === null) {
|
|
1175
1369
|
rows.push(unmeasuredRow(boundarySpec, 'not-measurable', `attachment ${nameOf(attachment)}: no referenceHull was given, so there is no polygon to measure the hull's deviation from`, targets.maxBoundaryDeviation !== null));
|
|
1176
1370
|
} else {
|
|
1177
|
-
const
|
|
1371
|
+
const reference = input.referenceHull;
|
|
1372
|
+
const hd = steps === null ? hausdorff(hullPolygon, reference) : steps.outline('reference', hullPolygon, reference, (memo) => hausdorffCarried(memo, hullPolygon, reference, steps));
|
|
1178
1373
|
const bound = targets.maxBoundaryDeviation === null ? null : { op: '<=' as const, value: targets.maxBoundaryDeviation };
|
|
1179
1374
|
rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
|
|
1180
1375
|
}
|
|
1181
1376
|
const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
const hd = hausdorff(hullPolygon,
|
|
1377
|
+
const traced = rasters.traced();
|
|
1378
|
+
if ('outline' in traced) {
|
|
1379
|
+
const tracedOutline = traced.outline;
|
|
1380
|
+
const hd = steps === null ? hausdorff(hullPolygon, tracedOutline) : steps.outline('traced', hullPolygon, tracedOutline, (memo) => hausdorffCarried(memo, hullPolygon, tracedOutline, steps));
|
|
1186
1381
|
rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${err.message}`, false));
|
|
1382
|
+
} else {
|
|
1383
|
+
rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
|
|
1190
1384
|
}
|
|
1191
1385
|
|
|
1192
1386
|
// --- triangles: sign, degeneracy, angle --------------------------------------------------
|
|
@@ -1233,7 +1427,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1233
1427
|
);
|
|
1234
1428
|
|
|
1235
1429
|
// --- regions (§5) ------------------------------------------------------------------------
|
|
1236
|
-
rows.push(...regionRows(input, outline, hullPolygon));
|
|
1430
|
+
rows.push(...regionRows(input, outline, hullPolygon, rasters));
|
|
1237
1431
|
|
|
1238
1432
|
const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
|
|
1239
1433
|
const geometry = sectionOf(ordered);
|
|
@@ -1331,7 +1525,7 @@ interface Edge {
|
|
|
1331
1525
|
* length most exceeds its own bound — so the row fails exactly when some edge
|
|
1332
1526
|
* it covers is over — and the row names that edge and that bound.
|
|
1333
1527
|
*/
|
|
1334
|
-
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[]): Built[] {
|
|
1528
|
+
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
|
|
1335
1529
|
const { attachment, source, art } = input;
|
|
1336
1530
|
const who = `attachment ${nameOf(attachment)}`;
|
|
1337
1531
|
const scale = art.frame.pageScale;
|
|
@@ -1407,7 +1601,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1407
1601
|
|
|
1408
1602
|
const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
|
|
1409
1603
|
const { mask, threshold } = art;
|
|
1410
|
-
const artBits =
|
|
1604
|
+
const artBits = rasters.artBits();
|
|
1411
1605
|
|
|
1412
1606
|
for (const region of input.targets.regions) {
|
|
1413
1607
|
const refused = refusals.get(region.name);
|