spine-rigc 2.19.1 → 2.20.1
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 +2 -1
- package/src/areaband.ts +64 -1
- package/src/cli/shared.ts +4 -31
- package/src/deformsurvey.ts +6 -64
- package/src/mesh.ts +52 -12
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +165 -35
- package/src/meshrasters.ts +156 -0
- package/src/meshreduce.ts +54 -25
- package/src/package_meta.ts +48 -0
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 } from './meshrasters.ts';
|
|
77
73
|
import { cropToSpineY } from './transform.ts';
|
|
78
74
|
|
|
79
75
|
// ---------------------------------------------------------------------------
|
|
@@ -299,6 +295,8 @@ export interface MeasureRow {
|
|
|
299
295
|
raster?: RasterSensitivity;
|
|
300
296
|
/** Sampled rows — correction 2. */
|
|
301
297
|
sampling?: { domain: string; count: number };
|
|
298
|
+
/** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
|
|
299
|
+
motion?: MotionRowDetail;
|
|
302
300
|
}
|
|
303
301
|
|
|
304
302
|
/** Correction 2: the spatial grid and the value's own granularity are two fields. */
|
|
@@ -353,9 +351,10 @@ export interface MotionSchedule {
|
|
|
353
351
|
|
|
354
352
|
/**
|
|
355
353
|
* The schedule a motion section was measured under. The contract names this
|
|
356
|
-
* type without defining it;
|
|
354
|
+
* type without defining it; stage C (issue #1230) defines it as the schedule as
|
|
355
|
+
* given plus what was walked from it (`ScheduleWalked`, at the end of this file).
|
|
357
356
|
*/
|
|
358
|
-
export type ScheduleUsed = MotionSchedule;
|
|
357
|
+
export type ScheduleUsed = MotionSchedule & ScheduleWalked;
|
|
359
358
|
|
|
360
359
|
/** One candidate's evidence — correction 1. `reduce` and `measure` carry exactly one. */
|
|
361
360
|
export interface CandidateReport {
|
|
@@ -421,8 +420,8 @@ export interface EffectiveSettings {
|
|
|
421
420
|
frame: SourceFrame;
|
|
422
421
|
maskSize: [number, number];
|
|
423
422
|
minArtSamples: number;
|
|
424
|
-
/** P9: each region's own floor, in the order the regions were given. */
|
|
425
|
-
regions: Array<{ name: string; minArtSamples: number }>;
|
|
423
|
+
/** P9: each region's own floor, in the order the regions were given — and, on a `compare`, its polygon. */
|
|
424
|
+
regions: Array<{ name: string; minArtSamples: number; polygon?: Array<[number, number]> }>;
|
|
426
425
|
}>;
|
|
427
426
|
sourceBounds: ArtFitBounds | null;
|
|
428
427
|
referenceArtFit: ArtFitBounds | null;
|
|
@@ -1009,6 +1008,53 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
|
|
|
1009
1008
|
*/
|
|
1010
1009
|
export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
1011
1010
|
validateInput(input);
|
|
1011
|
+
return measureValidated(input, artRastersOf(input.art));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* `measureMeshQuality` over art rasters the caller already holds
|
|
1016
|
+
* (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
|
|
1017
|
+
* one call, so the quantities of the art alone are computed once per call
|
|
1018
|
+
* rather than once per step (issue #1240). The report is the one
|
|
1019
|
+
* `measureMeshQuality` returns for the same input, byte for byte; rasters
|
|
1020
|
+
* taken from another art are refused (`REDUCE_ART_RASTERS_MISMATCH`), never
|
|
1021
|
+
* read.
|
|
1022
|
+
*
|
|
1023
|
+
* Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
|
|
1024
|
+
* module with `export *`, and a symbol that is merely exported is not promised
|
|
1025
|
+
* (RELEASING.md, *The import surface*).
|
|
1026
|
+
*/
|
|
1027
|
+
export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
|
|
1028
|
+
validateInput(input);
|
|
1029
|
+
checkArtRasters(input, rasters);
|
|
1030
|
+
return measureValidated(input, rasters);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Rasters are read only for the art they were taken from: the same mask
|
|
1035
|
+
* array, mask size, threshold and frame. The first field that differs is
|
|
1036
|
+
* refused by name, with the value the rasters were taken at and the value the
|
|
1037
|
+
* input requires.
|
|
1038
|
+
*/
|
|
1039
|
+
function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
|
|
1040
|
+
const want = input.art;
|
|
1041
|
+
const got = rasters.art;
|
|
1042
|
+
if (got === want) return;
|
|
1043
|
+
const who = `attachment ${nameOf(input.attachment)}`;
|
|
1044
|
+
const differs = (field: string, found: string, required: string): never =>
|
|
1045
|
+
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`);
|
|
1046
|
+
const size = (m: AlphaMask | undefined): string => (m === undefined ? 'undefined' : `${m.width}x${m.height}`);
|
|
1047
|
+
if (size(got.mask) !== size(want.mask)) differs('art.mask size', size(got.mask), size(want.mask));
|
|
1048
|
+
if (got.threshold !== want.threshold) differs('art.threshold', JSON.stringify(got.threshold), JSON.stringify(want.threshold));
|
|
1049
|
+
for (const field of ['pageScale', 'width', 'height'] as const) {
|
|
1050
|
+
if (got.frame?.[field] !== want.frame[field]) differs(`art.frame.${field}`, JSON.stringify(got.frame?.[field]), JSON.stringify(want.frame[field]));
|
|
1051
|
+
}
|
|
1052
|
+
if (got.mask.alpha !== want.mask.alpha) differs('art.mask.alpha', 'another array', 'the input\'s own array (the same object)');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
|
|
1056
|
+
function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
|
|
1057
|
+
rasters.tally.uses++;
|
|
1012
1058
|
const { attachment, art, source, targets } = input;
|
|
1013
1059
|
const { mask, threshold, frame } = art;
|
|
1014
1060
|
const scale = frame.pageScale;
|
|
@@ -1050,9 +1096,8 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1050
1096
|
const w = mask.width;
|
|
1051
1097
|
const h = mask.height;
|
|
1052
1098
|
const grid = { width: w, height: h, pageScale: scale };
|
|
1053
|
-
const artBits =
|
|
1054
|
-
|
|
1055
|
-
for (const bit of artBits) artCount += bit;
|
|
1099
|
+
const artBits = rasters.artBits();
|
|
1100
|
+
const artCount = rasters.artCount();
|
|
1056
1101
|
const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
|
|
1057
1102
|
const covered = rasteriseTriangles(onGrid, source.triangles, w, h);
|
|
1058
1103
|
const fit = targets.artFit;
|
|
@@ -1105,17 +1150,9 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1105
1150
|
|
|
1106
1151
|
// Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
|
|
1107
1152
|
// where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
|
|
1108
|
-
const fill8
|
|
1109
|
-
const fill4 = fillEnclosed(artBits, w, h, 4).filled;
|
|
1110
|
-
let fillsDiffer = false;
|
|
1111
|
-
for (let i = 0; i < fill8.length; i++) {
|
|
1112
|
-
if (fill8[i] !== fill4[i]) {
|
|
1113
|
-
fillsDiffer = true;
|
|
1114
|
-
break;
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1153
|
+
const { fill8, fill4, differ: fillsDiffer } = rasters.fills();
|
|
1117
1154
|
const silhouetteRows = (filled: Uint8Array, connectivity: 4 | 8, gated: boolean): void => {
|
|
1118
|
-
const toFilled =
|
|
1155
|
+
const toFilled = rasters.toFilled(connectivity);
|
|
1119
1156
|
let overAt = -1;
|
|
1120
1157
|
let overSq = 0;
|
|
1121
1158
|
let holes = 0;
|
|
@@ -1147,7 +1184,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1147
1184
|
if (fillsDiffer) silhouetteRows(fill4, 4, false);
|
|
1148
1185
|
|
|
1149
1186
|
// Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
|
|
1150
|
-
const { label } =
|
|
1187
|
+
const { label } = rasters.islands();
|
|
1151
1188
|
const touched = new Set<number>();
|
|
1152
1189
|
for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
|
|
1153
1190
|
let joinedAt = -1;
|
|
@@ -1176,14 +1213,12 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1176
1213
|
rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
|
|
1177
1214
|
}
|
|
1178
1215
|
const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1182
|
-
const hd = hausdorff(hullPolygon, outlinePx);
|
|
1216
|
+
const traced = rasters.traced();
|
|
1217
|
+
if ('outline' in traced) {
|
|
1218
|
+
const hd = hausdorff(hullPolygon, traced.outline);
|
|
1183
1219
|
rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${err.message}`, false));
|
|
1220
|
+
} else {
|
|
1221
|
+
rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
|
|
1187
1222
|
}
|
|
1188
1223
|
|
|
1189
1224
|
// --- triangles: sign, degeneracy, angle --------------------------------------------------
|
|
@@ -1230,7 +1265,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1230
1265
|
);
|
|
1231
1266
|
|
|
1232
1267
|
// --- regions (§5) ------------------------------------------------------------------------
|
|
1233
|
-
rows.push(...regionRows(input, outline, hullPolygon));
|
|
1268
|
+
rows.push(...regionRows(input, outline, hullPolygon, rasters));
|
|
1234
1269
|
|
|
1235
1270
|
const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
|
|
1236
1271
|
const geometry = sectionOf(ordered);
|
|
@@ -1328,7 +1363,7 @@ interface Edge {
|
|
|
1328
1363
|
* length most exceeds its own bound — so the row fails exactly when some edge
|
|
1329
1364
|
* it covers is over — and the row names that edge and that bound.
|
|
1330
1365
|
*/
|
|
1331
|
-
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[]): Built[] {
|
|
1366
|
+
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
|
|
1332
1367
|
const { attachment, source, art } = input;
|
|
1333
1368
|
const who = `attachment ${nameOf(attachment)}`;
|
|
1334
1369
|
const scale = art.frame.pageScale;
|
|
@@ -1404,7 +1439,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1404
1439
|
|
|
1405
1440
|
const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
|
|
1406
1441
|
const { mask, threshold } = art;
|
|
1407
|
-
const artBits =
|
|
1442
|
+
const artBits = rasters.artBits();
|
|
1408
1443
|
|
|
1409
1444
|
for (const region of input.targets.regions) {
|
|
1410
1445
|
const refused = refusals.get(region.name);
|
|
@@ -1576,7 +1611,7 @@ function effectiveJson(e: EffectiveSettings): Json {
|
|
|
1576
1611
|
frame: frameJson(a.frame),
|
|
1577
1612
|
maskSize: pair(a.maskSize),
|
|
1578
1613
|
minArtSamples: a.minArtSamples,
|
|
1579
|
-
regions: a.regions.map((r) => ({ name: r.name, minArtSamples: r.minArtSamples })),
|
|
1614
|
+
regions: a.regions.map((r): Json => (r.polygon === undefined ? { name: r.name, minArtSamples: r.minArtSamples } : { name: r.name, minArtSamples: r.minArtSamples, polygon: r.polygon.map(pair) })),
|
|
1580
1615
|
})),
|
|
1581
1616
|
sourceBounds: fitJson(e.sourceBounds),
|
|
1582
1617
|
referenceArtFit: fitJson(e.referenceArtFit),
|
|
@@ -1631,6 +1666,7 @@ function rowJson(r: MeasureRow): Json {
|
|
|
1631
1666
|
};
|
|
1632
1667
|
}
|
|
1633
1668
|
if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
|
|
1669
|
+
if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
|
|
1634
1670
|
return out;
|
|
1635
1671
|
}
|
|
1636
1672
|
|
|
@@ -1651,7 +1687,7 @@ function candidateJson(c: CandidateReport): Json {
|
|
|
1651
1687
|
id: c.id,
|
|
1652
1688
|
counts: countsJson(c.counts),
|
|
1653
1689
|
geometry: c.geometry === null ? null : sectionJson(c.geometry),
|
|
1654
|
-
motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule:
|
|
1690
|
+
motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleUsedJson(c.motion.schedule) },
|
|
1655
1691
|
accepted: c.accepted,
|
|
1656
1692
|
};
|
|
1657
1693
|
if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
|
|
@@ -1702,3 +1738,97 @@ export function writeMeshQualityReport(report: MeshQualityReport): string {
|
|
|
1702
1738
|
};
|
|
1703
1739
|
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
1704
1740
|
}
|
|
1741
|
+
|
|
1742
|
+
// ---------------------------------------------------------------------------
|
|
1743
|
+
// stage C — the motion section's own fields (issue #1230, `src/meshcompare.ts`)
|
|
1744
|
+
// ---------------------------------------------------------------------------
|
|
1745
|
+
//
|
|
1746
|
+
// Additive: nothing above reads these, a `measure` and a `reduce` never write
|
|
1747
|
+
// them, and every one is written only when present.
|
|
1748
|
+
|
|
1749
|
+
/** One group of frames a motion row was also taken over — a phase, or a role (P11). */
|
|
1750
|
+
export interface MotionReading {
|
|
1751
|
+
/** The group's worst value; null when no frame of the group drew the attachment. */
|
|
1752
|
+
value: number | null;
|
|
1753
|
+
/** The value against the row's bound, by the row's own rule; `undeclared` with no bound, `not-measurable` with no value. */
|
|
1754
|
+
state: MeasureState;
|
|
1755
|
+
/** The frame the group's worst value was taken at, by `FrameRef.id`. */
|
|
1756
|
+
frame: string | null;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* How a motion row's value was taken over the schedule (stage C). The row's own
|
|
1761
|
+
* `value` is the worst over every frame measured; these keep apart what the
|
|
1762
|
+
* contract says must not be folded together.
|
|
1763
|
+
*/
|
|
1764
|
+
export interface MotionRowDetail {
|
|
1765
|
+
/** Frames at which the attachment was drawn and measured, and the ids of those at which its slot drew something else or nothing. */
|
|
1766
|
+
frames: { measured: number; notDrawn: string[] };
|
|
1767
|
+
/**
|
|
1768
|
+
* §4 *Transition in time*: the row taken per sample phase — `grid` and `irr`
|
|
1769
|
+
* for the frames a rate generated, `null` for the setup frame and for frames
|
|
1770
|
+
* the caller gave as explicit times (no phase applies to those).
|
|
1771
|
+
*/
|
|
1772
|
+
byPhase: Array<{ phase: 'grid' | 'irr' | null } & MotionReading>;
|
|
1773
|
+
/** When one phase passes and another fails: the worst frame of each, by id, and a sentence naming both. Null otherwise. */
|
|
1774
|
+
phasesDisagree: { pass: string; fail: string; sentence: string } | null;
|
|
1775
|
+
/** P11: the row per role. `heldOut` is null when no held-out frame was measured — no held-out claim is made. */
|
|
1776
|
+
byRole: { baseline: MotionReading | null; selection: MotionReading | null; heldOut: MotionReading | null };
|
|
1777
|
+
/**
|
|
1778
|
+
* Correction 4, world rows: the declared setup map of the attachment's slot
|
|
1779
|
+
* bone — its world 2×2 at the setup pose, `[a, b, c, d]` as the core poses it
|
|
1780
|
+
* from the bones' declared fields — and its two singular scales. Never a
|
|
1781
|
+
* single world-per-pixel ratio, and never fitted to output vertices.
|
|
1782
|
+
*/
|
|
1783
|
+
setupMap?: { bone: string; linear: [number, number, number, number]; singularScales: [number, number] };
|
|
1784
|
+
/** §3: samples no non-degenerate UV triangle carries, listed by UV — in the reference's mesh and in this candidate's. */
|
|
1785
|
+
uncarried?: { reference: Array<[number, number]>; candidate: Array<[number, number]> };
|
|
1786
|
+
/** §3, `MQ_INVERSION` on a slot `invariants.deformMayFold` names: every reversed triangle with its frame — listed, never zeroed. */
|
|
1787
|
+
folds?: Array<{ triangle: number; frame: string }>;
|
|
1788
|
+
/** Triangles that have no area at the setup pose (the A39 band), so no sign and no stretch is read off them. */
|
|
1789
|
+
degenerateAtSetup?: number;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** What a motion section walked from the schedule it was given (`ScheduleUsed`). */
|
|
1793
|
+
export interface ScheduleWalked {
|
|
1794
|
+
/** Every frame the schedule names, in walk order, each with its role (P11). */
|
|
1795
|
+
walked: FrameRef[];
|
|
1796
|
+
/** The roles among those frames — what the section has evidence for. */
|
|
1797
|
+
roles: Array<FrameRef['role']>;
|
|
1798
|
+
/** P11: true only when at least one held-out frame, disjoint from `selection`, was walked. */
|
|
1799
|
+
heldOutClaim: boolean;
|
|
1800
|
+
/** P10: every walk resets physics at time 0 and steps from there, the same steps for the reference and every candidate. */
|
|
1801
|
+
reset: 'physics reset at time 0';
|
|
1802
|
+
/** Each walk: the animation, the phase (null for explicit times), and how many steps reached its last frame. */
|
|
1803
|
+
walks: Array<{ animation: string; phase: 'grid' | 'irr' | null; steps: number }>;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function readingJson(r: MotionReading | null): Json {
|
|
1807
|
+
return r === null ? null : { value: r.value, state: r.state, frame: r.frame };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function motionDetailJson(m: MotionRowDetail): Json {
|
|
1811
|
+
const out: { [key: string]: Json } = {
|
|
1812
|
+
frames: { measured: m.frames.measured, notDrawn: [...m.frames.notDrawn] },
|
|
1813
|
+
byPhase: m.byPhase.map((p) => ({ phase: p.phase, value: p.value, state: p.state, frame: p.frame })),
|
|
1814
|
+
phasesDisagree: m.phasesDisagree === null ? null : { pass: m.phasesDisagree.pass, fail: m.phasesDisagree.fail, sentence: m.phasesDisagree.sentence },
|
|
1815
|
+
byRole: { baseline: readingJson(m.byRole.baseline), selection: readingJson(m.byRole.selection), heldOut: readingJson(m.byRole.heldOut) },
|
|
1816
|
+
};
|
|
1817
|
+
if (m.setupMap !== undefined) out.setupMap = { bone: m.setupMap.bone, linear: [...m.setupMap.linear], singularScales: pair(m.setupMap.singularScales) };
|
|
1818
|
+
if (m.uncarried !== undefined) out.uncarried = { reference: m.uncarried.reference.map(pair), candidate: m.uncarried.candidate.map(pair) };
|
|
1819
|
+
if (m.folds !== undefined) out.folds = m.folds.map((f) => ({ triangle: f.triangle, frame: f.frame }));
|
|
1820
|
+
if (m.degenerateAtSetup !== undefined) out.degenerateAtSetup = m.degenerateAtSetup;
|
|
1821
|
+
return out;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function scheduleUsedJson(s: ScheduleUsed): Json {
|
|
1825
|
+
const given = scheduleJson(s) as { [key: string]: Json };
|
|
1826
|
+
return {
|
|
1827
|
+
...given,
|
|
1828
|
+
walked: s.walked.map(frameRefJson),
|
|
1829
|
+
roles: [...s.roles],
|
|
1830
|
+
heldOutClaim: s.heldOutClaim,
|
|
1831
|
+
reset: s.reset,
|
|
1832
|
+
walks: s.walks.map((w) => ({ animation: w.animation, phase: w.phase, steps: w.steps })),
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The art-derived rasters one mesh measurement reads — every quantity of
|
|
3
|
+
* `measureMeshQuality`'s raster rows (`src/meshquality.ts`) whose only input is
|
|
4
|
+
* the art: the art bits at the threshold, the filled silhouette at both
|
|
5
|
+
* background connectivities and each one's distance transform, the
|
|
6
|
+
* 4-connected island labels, and the traced outline in drawing pixels.
|
|
7
|
+
*
|
|
8
|
+
* ## Why it is a value and not a cache
|
|
9
|
+
*
|
|
10
|
+
* `reduceMesh` measures one candidate per step against art that never changes
|
|
11
|
+
* within the call, and recomputing these per step was nearly all of its cost
|
|
12
|
+
* (issue #1240: 98.98 % of a 1101-step call's samples in the measurement, at
|
|
13
|
+
* least 40 % in functions of the art alone). So the call takes them once, at
|
|
14
|
+
* admission, and hands this object to every measurement it makes. There is no
|
|
15
|
+
* module-level cache and no hidden state: the object is created by the caller,
|
|
16
|
+
* passed explicitly, and dropped with the call. `measureMeshQuality` itself
|
|
17
|
+
* makes a fresh one per call, so its behaviour is the uncached one.
|
|
18
|
+
*
|
|
19
|
+
* Each quantity is computed on first read and kept, so a measurement that
|
|
20
|
+
* never reaches a row (a source that is not one loop, art under its sample
|
|
21
|
+
* floor) computes no more than it did before. `tally` counts every
|
|
22
|
+
* computation and every measurement handed the object — the count a control
|
|
23
|
+
* reads to see that a reduction computed each quantity once.
|
|
24
|
+
*
|
|
25
|
+
* ## What it is taken at, and the refusal
|
|
26
|
+
*
|
|
27
|
+
* The object holds the `ArtInput` it was made from. A measurement handed it
|
|
28
|
+
* for a different art is refused, not served stale numbers: the mask array,
|
|
29
|
+
* the mask's size, the threshold and the frame's scale and size are each
|
|
30
|
+
* compared, and a difference is `REDUCE_ART_RASTERS_MISMATCH` naming the field,
|
|
31
|
+
* the value the rasters were taken at and the value the input requires
|
|
32
|
+
* (`checkArtRasters`, `src/meshquality.ts`).
|
|
33
|
+
*
|
|
34
|
+
* Not re-exported by `src/mesh.ts`: nothing here is on `spine-rigc/mesh`.
|
|
35
|
+
*/
|
|
36
|
+
import { artOf, fillEnclosed, labelIslands, MeshError, prunePolygon, squaredDistanceToSet, traceAlphaOutline } from './mesh.ts';
|
|
37
|
+
import type { ArtInput } from './meshquality.ts';
|
|
38
|
+
|
|
39
|
+
/** How many times each quantity was computed, and how many measurements were handed the object. */
|
|
40
|
+
export interface ArtRasterTally {
|
|
41
|
+
/** Measurements that read the object (each after its own check). */
|
|
42
|
+
uses: number;
|
|
43
|
+
artBits: number;
|
|
44
|
+
fills: number;
|
|
45
|
+
toFilled8: number;
|
|
46
|
+
toFilled4: number;
|
|
47
|
+
islands: number;
|
|
48
|
+
traced: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The traced art outline in drawing pixels, pruned — or the tracer's refusal, as its message. */
|
|
52
|
+
export type TracedArt = { outline: Array<[number, number]> } | { refused: string };
|
|
53
|
+
|
|
54
|
+
export interface ArtRasters {
|
|
55
|
+
/** The art these were taken from: its mask array, size, threshold and frame. */
|
|
56
|
+
readonly art: ArtInput;
|
|
57
|
+
readonly tally: ArtRasterTally;
|
|
58
|
+
/** `artOf(mask, threshold)`: 1 where alpha >= threshold. */
|
|
59
|
+
artBits(): Uint8Array;
|
|
60
|
+
/** The number of art pixels in `artBits()`. */
|
|
61
|
+
artCount(): number;
|
|
62
|
+
/** `fillEnclosed` of the art at both background connectivities, and whether they differ anywhere. */
|
|
63
|
+
fills(): { fill8: Uint8Array; fill4: Uint8Array; differ: boolean };
|
|
64
|
+
/** `squaredDistanceToSet` of the filled silhouette at one connectivity. */
|
|
65
|
+
toFilled(connectivity: 4 | 8): Float64Array;
|
|
66
|
+
/** `labelIslands` of the art: 4-connected labels, 1-based, and each island's size. */
|
|
67
|
+
islands(): { label: Int32Array; sizes: number[] };
|
|
68
|
+
/** `traceAlphaOutline` at the threshold, carried to drawing pixels by `pageScale` and pruned. */
|
|
69
|
+
traced(): TracedArt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The rasters of one art, each computed on first read. Reads nothing at
|
|
74
|
+
* construction, so it may be made before the art is validated; every reader
|
|
75
|
+
* checks it against a validated input first.
|
|
76
|
+
*/
|
|
77
|
+
export function artRastersOf(art: ArtInput): ArtRasters {
|
|
78
|
+
const tally: ArtRasterTally = { uses: 0, artBits: 0, fills: 0, toFilled8: 0, toFilled4: 0, islands: 0, traced: 0 };
|
|
79
|
+
let bits: { bits: Uint8Array; count: number } | null = null;
|
|
80
|
+
let fills: { fill8: Uint8Array; fill4: Uint8Array; differ: boolean } | null = null;
|
|
81
|
+
let toFilled8: Float64Array | null = null;
|
|
82
|
+
let toFilled4: Float64Array | null = null;
|
|
83
|
+
let islands: { label: Int32Array; sizes: number[] } | null = null;
|
|
84
|
+
let traced: TracedArt | null = null;
|
|
85
|
+
const artBitsOf = (): { bits: Uint8Array; count: number } => {
|
|
86
|
+
if (bits === null) {
|
|
87
|
+
tally.artBits++;
|
|
88
|
+
const b = artOf(art.mask, art.threshold);
|
|
89
|
+
let count = 0;
|
|
90
|
+
for (const bit of b) count += bit;
|
|
91
|
+
bits = { bits: b, count };
|
|
92
|
+
}
|
|
93
|
+
return bits;
|
|
94
|
+
};
|
|
95
|
+
const fillsOf = (): { fill8: Uint8Array; fill4: Uint8Array; differ: boolean } => {
|
|
96
|
+
if (fills === null) {
|
|
97
|
+
tally.fills++;
|
|
98
|
+
const { width: w, height: h } = art.mask;
|
|
99
|
+
const b = artBitsOf().bits;
|
|
100
|
+
const fill8 = fillEnclosed(b, w, h, 8).filled;
|
|
101
|
+
const fill4 = fillEnclosed(b, w, h, 4).filled;
|
|
102
|
+
let differ = false;
|
|
103
|
+
for (let i = 0; i < fill8.length; i++) {
|
|
104
|
+
if (fill8[i] !== fill4[i]) {
|
|
105
|
+
differ = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
fills = { fill8, fill4, differ };
|
|
110
|
+
}
|
|
111
|
+
return fills;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
art,
|
|
115
|
+
tally,
|
|
116
|
+
artBits: () => artBitsOf().bits,
|
|
117
|
+
artCount: () => artBitsOf().count,
|
|
118
|
+
fills: fillsOf,
|
|
119
|
+
toFilled: (connectivity) => {
|
|
120
|
+
const { width: w, height: h } = art.mask;
|
|
121
|
+
if (connectivity === 8) {
|
|
122
|
+
if (toFilled8 === null) {
|
|
123
|
+
tally.toFilled8++;
|
|
124
|
+
toFilled8 = squaredDistanceToSet(fillsOf().fill8, w, h);
|
|
125
|
+
}
|
|
126
|
+
return toFilled8;
|
|
127
|
+
}
|
|
128
|
+
if (toFilled4 === null) {
|
|
129
|
+
tally.toFilled4++;
|
|
130
|
+
toFilled4 = squaredDistanceToSet(fillsOf().fill4, w, h);
|
|
131
|
+
}
|
|
132
|
+
return toFilled4;
|
|
133
|
+
},
|
|
134
|
+
islands: () => {
|
|
135
|
+
if (islands === null) {
|
|
136
|
+
tally.islands++;
|
|
137
|
+
islands = labelIslands(artBitsOf().bits, art.mask.width, art.mask.height);
|
|
138
|
+
}
|
|
139
|
+
return islands;
|
|
140
|
+
},
|
|
141
|
+
traced: () => {
|
|
142
|
+
if (traced === null) {
|
|
143
|
+
tally.traced++;
|
|
144
|
+
const scale = art.frame.pageScale;
|
|
145
|
+
try {
|
|
146
|
+
const t = traceAlphaOutline(art.mask, art.threshold);
|
|
147
|
+
traced = { outline: prunePolygon(t.outline.map(([x, y]): [number, number] => [x / scale, y / scale])) };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (!(err instanceof MeshError)) throw err;
|
|
150
|
+
traced = { refused: err.message };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return traced;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/meshreduce.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Mesh reduction and local refinement — stage B2 of the contract in
|
|
3
3
|
* docs/MESH_REDUCTION.md (issue #1221, stage B: issue #1224).
|
|
4
4
|
*
|
|
5
|
-
* One operation is exported, `reduceMesh
|
|
5
|
+
* One operation is exported, `reduceMesh` — `reduceMeshWith` is the same
|
|
6
|
+
* operation over art rasters the caller made — and it is two operations
|
|
7
|
+
* composed:
|
|
6
8
|
*
|
|
7
9
|
* 1. **Refinement** (`refineRegions`) inserts vertices inside the union of the
|
|
8
10
|
* declared regions and their bands until §5's `L(R)` holds on every edge
|
|
@@ -22,7 +24,9 @@
|
|
|
22
24
|
* ## What every step is held to
|
|
23
25
|
*
|
|
24
26
|
* Each candidate step is measured by `measureMeshQuality` (`src/meshquality.ts`)
|
|
25
|
-
* — no row is re-implemented here
|
|
27
|
+
* — no row is re-implemented here; the call reads it as `measureMeshQualityWith`
|
|
28
|
+
* over the art's rasters taken once at admission (`src/meshrasters.ts`, issue
|
|
29
|
+
* #1240), which returns the same report — and taken only when every row the caller's
|
|
26
30
|
* contract requires is `pass`: the art fit at `targets.artFit`, the boundary
|
|
27
31
|
* deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
|
|
28
32
|
* angle when declared, orientation and degeneracy at 0, and every region's
|
|
@@ -71,8 +75,6 @@ import {
|
|
|
71
75
|
distanceToSegment,
|
|
72
76
|
earClip,
|
|
73
77
|
findSelfIntersection,
|
|
74
|
-
labelIslands,
|
|
75
|
-
artOf,
|
|
76
78
|
MeshError,
|
|
77
79
|
MeshReductionError,
|
|
78
80
|
meshEdges,
|
|
@@ -85,7 +87,7 @@ import {
|
|
|
85
87
|
import {
|
|
86
88
|
BAND_CONTACT_TOLERANCE,
|
|
87
89
|
edgeIsHeldByRegion,
|
|
88
|
-
|
|
90
|
+
measureMeshQualityWith,
|
|
89
91
|
type AttachmentRef,
|
|
90
92
|
type ArtFitBounds,
|
|
91
93
|
type CandidateReport,
|
|
@@ -101,6 +103,7 @@ import {
|
|
|
101
103
|
type SourceMesh,
|
|
102
104
|
type Termination,
|
|
103
105
|
} from './meshquality.ts';
|
|
106
|
+
import { artRastersOf, type ArtRasters } from './meshrasters.ts';
|
|
104
107
|
|
|
105
108
|
// ---------------------------------------------------------------------------
|
|
106
109
|
// the result
|
|
@@ -711,6 +714,8 @@ interface Run {
|
|
|
711
714
|
protectedVertices: Set<number>;
|
|
712
715
|
protectedEdges: Array<[number, number]>;
|
|
713
716
|
sourceEdges: Set<string>;
|
|
717
|
+
/** The art's rasters, taken once for the call and read by every measurement in it (issue #1240). */
|
|
718
|
+
rasters: ArtRasters;
|
|
714
719
|
}
|
|
715
720
|
|
|
716
721
|
/** A measurement of a canonical mesh against the result's full contract. */
|
|
@@ -731,7 +736,7 @@ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQual
|
|
|
731
736
|
boneOrder: input.boneOrder,
|
|
732
737
|
preset: input.preset,
|
|
733
738
|
};
|
|
734
|
-
return
|
|
739
|
+
return measureMeshQualityWith(measureInput, run.rasters);
|
|
735
740
|
}
|
|
736
741
|
|
|
737
742
|
/** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
|
|
@@ -1167,26 +1172,50 @@ function tryRemoval(run: Run, v: number): string | null {
|
|
|
1167
1172
|
*/
|
|
1168
1173
|
export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
1169
1174
|
validateReduction(input);
|
|
1175
|
+
return reduceValidated(input, artRastersOf(input.art));
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* `reduceMesh` over art rasters the caller made (`artRastersOf`,
|
|
1180
|
+
* `src/meshrasters.ts`) — the result is `reduceMesh`'s for the same input, byte
|
|
1181
|
+
* for byte, and every measurement of the call reads the one object, so its
|
|
1182
|
+
* `tally` is the count of what the call computed. Rasters taken from another
|
|
1183
|
+
* art are refused at admission (`REDUCE_ART_RASTERS_MISMATCH`).
|
|
1184
|
+
*
|
|
1185
|
+
* Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
|
|
1186
|
+
* module with `export *`, and a symbol that is merely exported is not promised
|
|
1187
|
+
* (RELEASING.md, *The import surface*).
|
|
1188
|
+
*/
|
|
1189
|
+
export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters): MeshReductionResult {
|
|
1190
|
+
validateReduction(input);
|
|
1191
|
+
return reduceValidated(input, rasters);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** The operation, over an input `validateReduction` accepted; the art's rasters are computed at most once, in `rasters`. */
|
|
1195
|
+
function reduceValidated(input: MeshReductionInput, rasters: ArtRasters): MeshReductionResult {
|
|
1170
1196
|
const who = `attachment ${nameOf(input.attachment)}`;
|
|
1171
1197
|
const src = input.source;
|
|
1172
1198
|
const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
|
|
1173
1199
|
|
|
1174
1200
|
// The source, measured against its own admissibility bounds (correction 3) — this is also what refuses a
|
|
1175
1201
|
// malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes.
|
|
1176
|
-
const admit =
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1202
|
+
const admit = measureMeshQualityWith(
|
|
1203
|
+
{
|
|
1204
|
+
id: 'source',
|
|
1205
|
+
attachment: input.attachment,
|
|
1206
|
+
art: input.art,
|
|
1207
|
+
source: src,
|
|
1208
|
+
targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
|
|
1209
|
+
referenceHull: null,
|
|
1210
|
+
minArtSamples: input.minArtSamples,
|
|
1211
|
+
regionArtSamples: input.regionArtSamples,
|
|
1212
|
+
protect: input.protect,
|
|
1213
|
+
influences: input.influences,
|
|
1214
|
+
boneOrder: input.boneOrder,
|
|
1215
|
+
preset: input.preset,
|
|
1216
|
+
},
|
|
1217
|
+
rasters,
|
|
1218
|
+
);
|
|
1190
1219
|
const effective = effectiveOf(input, admit.effective, sourceHull);
|
|
1191
1220
|
const sourceCounts = admit.sourceCounts;
|
|
1192
1221
|
const noMesh = (termination: Termination): MeshReductionResult => ({
|
|
@@ -1215,7 +1244,7 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
|
1215
1244
|
);
|
|
1216
1245
|
}
|
|
1217
1246
|
}
|
|
1218
|
-
checkIslands(input, who);
|
|
1247
|
+
checkIslands(input, who, rasters);
|
|
1219
1248
|
checkDeformBeforeWork(input);
|
|
1220
1249
|
|
|
1221
1250
|
const boneRank = new Map((input.boneOrder ?? []).map((b, i) => [b, i]));
|
|
@@ -1243,6 +1272,7 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
|
1243
1272
|
keyed: keyedSourceVertices(input),
|
|
1244
1273
|
...protectionOf(input, work, sourceEdges),
|
|
1245
1274
|
sourceEdges,
|
|
1275
|
+
rasters,
|
|
1246
1276
|
};
|
|
1247
1277
|
|
|
1248
1278
|
const refined = refineRegions(run);
|
|
@@ -1268,11 +1298,10 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
|
1268
1298
|
}
|
|
1269
1299
|
|
|
1270
1300
|
/** §4: full coverage asked of a source that touches no pixel of some art island is refused, never met by deleting art. */
|
|
1271
|
-
function checkIslands(input: MeshReductionInput, who: string): void {
|
|
1301
|
+
function checkIslands(input: MeshReductionInput, who: string, rasters: ArtRasters): void {
|
|
1272
1302
|
if (input.targets.artFit.minCoverage !== 1) return;
|
|
1273
|
-
const { mask,
|
|
1274
|
-
const
|
|
1275
|
-
const { label, sizes } = labelIslands(bits, mask.width, mask.height);
|
|
1303
|
+
const { mask, frame } = input.art;
|
|
1304
|
+
const { label, sizes } = rasters.islands();
|
|
1276
1305
|
const onGrid = input.source.points.map(([x, y]): [number, number] => [x * frame.pageScale, y * frame.pageScale]);
|
|
1277
1306
|
const covered = rasteriseTriangles(onGrid, input.source.triangles, mask.width, mask.height);
|
|
1278
1307
|
const touched = new Set<number>();
|