spine-rigc 2.20.3 → 2.21.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/meshcompare.ts +5 -2
- package/src/meshquality.ts +83 -28
- package/src/meshrasters.ts +326 -3
- package/src/meshreduce.ts +43 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.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/meshcompare.ts
CHANGED
|
@@ -191,8 +191,11 @@ function missing(field: string, found: unknown, required: string): never {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
function validateFit(field: string, f: unknown): void {
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// `null` on maxOvershoot or maxUndercut is a bound declared absent (issue #1254): measured, reported `undeclared`,
|
|
195
|
+
// never refusing a reference or failing a candidate. A field left out is still refused.
|
|
196
|
+
const distance = (v: unknown): boolean => v === null || (isFiniteNumber(v) && v >= 0);
|
|
197
|
+
if (!isObject(f) || !isFiniteNumber(f.minCoverage) || f.minCoverage < 0 || f.minCoverage > 1 || !distance(f.maxOvershoot) || !distance(f.maxUndercut)) {
|
|
198
|
+
missing(field, f, '{ minCoverage in 0..1, maxOvershoot >= 0 or null, maxUndercut >= 0 or null }');
|
|
196
199
|
}
|
|
197
200
|
}
|
|
198
201
|
|
package/src/meshquality.ts
CHANGED
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
type MeshOutline,
|
|
70
70
|
} from './mesh.ts';
|
|
71
71
|
import { areaBand, triangleAreas } from './areaband.ts';
|
|
72
|
-
import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
|
|
72
|
+
import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
|
|
73
73
|
import { cropToSpineY } from './transform.ts';
|
|
74
74
|
|
|
75
75
|
// ---------------------------------------------------------------------------
|
|
@@ -116,14 +116,23 @@ export interface SourceMesh {
|
|
|
116
116
|
weights: Array<Array<{ bone: string; weight: number }>> | null;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* All distances in the drawing's pixels; converted to texels by `pageScale`.
|
|
121
|
+
*
|
|
122
|
+
* `maxOvershoot` and `maxUndercut` may be `null` — a bound **declared absent**
|
|
123
|
+
* (issue #1254): the row is measured and reported `undeclared`, with its value
|
|
124
|
+
* and worst sample, and never passes, fails, blocks a step or refuses a source.
|
|
125
|
+
* A field left out (`undefined`) is still refused by name: an omission cannot be
|
|
126
|
+
* told from a caller that forgot, and only the explicit `null` says "measured,
|
|
127
|
+
* not bounded". `minCoverage` has no such form and is always required.
|
|
128
|
+
*/
|
|
120
129
|
export interface ArtFitBounds {
|
|
121
130
|
/** Share of art pixel centres the triangles cover, 0..1. */
|
|
122
131
|
minCoverage: number;
|
|
123
|
-
/** Furthest a covered pixel may sit outside the filled silhouette, px. */
|
|
124
|
-
maxOvershoot: number;
|
|
125
|
-
/** Furthest an uncovered art pixel may sit from the covered set, px. */
|
|
126
|
-
maxUndercut: number;
|
|
132
|
+
/** Furthest a covered pixel may sit outside the filled silhouette, px; null = declared absent. */
|
|
133
|
+
maxOvershoot: number | null;
|
|
134
|
+
/** Furthest an uncovered art pixel may sit from the covered set, px; null = declared absent. */
|
|
135
|
+
maxUndercut: number | null;
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
/** What a reduction's RESULT must satisfy (correction 3). Read by `reduceMesh`, stage B2. */
|
|
@@ -607,7 +616,7 @@ function validateInput(input: MeshMeasureInput): void {
|
|
|
607
616
|
if (!isFiniteNumber(coverage) || coverage < 0 || coverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.minCoverage is ${JSON.stringify(coverage)}; required a fraction in 0..1`);
|
|
608
617
|
for (const field of ['maxOvershoot', 'maxUndercut'] as const) {
|
|
609
618
|
const v = fit[field];
|
|
610
|
-
if (!isFiniteNumber(v) || v < 0) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more`);
|
|
619
|
+
if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
|
|
611
620
|
}
|
|
612
621
|
}
|
|
613
622
|
present(who, 'targets.maxBoundaryDeviation', targets.maxBoundaryDeviation, 'a number of px or null');
|
|
@@ -1298,8 +1307,8 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
|
|
|
1298
1307
|
if (artCount < input.minArtSamples) {
|
|
1299
1308
|
const why = `attachment ${nameOf(attachment)} has ${artCount} art sample(s) at alpha >= ${threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`;
|
|
1300
1309
|
rows.push(withRaster(unmeasuredRow(spec('MQ_COVERAGE', 'fraction'), 'not-measurable', why, fit !== null), rasterArt(null), grid, artCount === 0 ? 1 : 1 / artCount));
|
|
1301
|
-
rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null), rasterArt(8), grid, pxIncrement));
|
|
1302
|
-
rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null), rasterArt(null), grid, pxIncrement));
|
|
1310
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null && fit.maxOvershoot !== null), rasterArt(8), grid, pxIncrement));
|
|
1311
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null && fit.maxUndercut !== null), rasterArt(null), grid, pxIncrement));
|
|
1303
1312
|
rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
|
|
1304
1313
|
rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
|
|
1305
1314
|
} else {
|
|
@@ -1322,8 +1331,8 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
|
|
|
1322
1331
|
rows.push(
|
|
1323
1332
|
withRaster(
|
|
1324
1333
|
!anyCovered
|
|
1325
|
-
? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null)
|
|
1326
|
-
: measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
|
|
1334
|
+
? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null && fit.maxUndercut !== null)
|
|
1335
|
+
: measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null || fit.maxUndercut === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
|
|
1327
1336
|
rasterArt(null),
|
|
1328
1337
|
grid,
|
|
1329
1338
|
pxIncrement,
|
|
@@ -1335,7 +1344,7 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
|
|
|
1335
1344
|
const fillsDiffer = rasters.fills().differ;
|
|
1336
1345
|
const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
|
|
1337
1346
|
const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
|
|
1338
|
-
const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
|
|
1347
|
+
const bound = gated && fit !== null && fit.maxOvershoot !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
|
|
1339
1348
|
rows.push(
|
|
1340
1349
|
withRaster(
|
|
1341
1350
|
measuredRow(spec('MQ_OVERSHOOT', 'px'), r6(Math.sqrt(overSq) / scale), bound, overAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(overAt) } }, gated),
|
|
@@ -1427,7 +1436,7 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
|
|
|
1427
1436
|
);
|
|
1428
1437
|
|
|
1429
1438
|
// --- regions (§5) ------------------------------------------------------------------------
|
|
1430
|
-
rows.push(...regionRows(input, outline, hullPolygon, rasters));
|
|
1439
|
+
rows.push(...regionRows(input, outline, hullPolygon, rasters, steps));
|
|
1431
1440
|
|
|
1432
1441
|
const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
|
|
1433
1442
|
const geometry = sectionOf(ordered);
|
|
@@ -1477,7 +1486,7 @@ function sectionOf(built: readonly Built[]): EvidenceSection {
|
|
|
1477
1486
|
// ---------------------------------------------------------------------------
|
|
1478
1487
|
|
|
1479
1488
|
/** A region's own refusal (§2's `refused`: this measurement's input is invalid), or null when it is measurable. */
|
|
1480
|
-
function regionRefusal(region: RefinementRegion, pageScale: number, who: string): { code: string; message: string } | null {
|
|
1489
|
+
function regionRefusal(region: RefinementRegion, pageScale: number, who: string, steps: StepRasters | null): { code: string; message: string } | null {
|
|
1481
1490
|
const numbers: Array<[string, unknown]> = [
|
|
1482
1491
|
['maxEdgeLength', region.maxEdgeLength],
|
|
1483
1492
|
['transition', region.transition],
|
|
@@ -1495,7 +1504,8 @@ function regionRefusal(region: RefinementRegion, pageScale: number, who: string)
|
|
|
1495
1504
|
message: `${who}, region "${region.name}": maxEdgeLength is ${region.maxEdgeLength} px; required at least one texel, ${r6(1 / pageScale)} px at pageScale ${pageScale}`,
|
|
1496
1505
|
};
|
|
1497
1506
|
}
|
|
1498
|
-
|
|
1507
|
+
// A function of the polygon alone: a reduction step has it handed back by its `StepRasters` (issue #1253).
|
|
1508
|
+
const crossing = steps === null ? findSelfIntersection(region.polygon) : steps.polygonCrossing(region.polygon, () => findSelfIntersection(region.polygon));
|
|
1499
1509
|
if (crossing !== null) {
|
|
1500
1510
|
return {
|
|
1501
1511
|
code: 'REGION_SELF_INTERSECTS',
|
|
@@ -1524,8 +1534,13 @@ interface Edge {
|
|
|
1524
1534
|
* distance from that region. A row's value is the edge whose
|
|
1525
1535
|
* length most exceeds its own bound — so the row fails exactly when some edge
|
|
1526
1536
|
* it covers is over — and the row names that edge and that bound.
|
|
1537
|
+
*
|
|
1538
|
+
* `MQ_FILL_DISTANCE` reads the art pixels whose centre lies in the region's
|
|
1539
|
+
* closed polygon, a function of the art and the polygon alone: a reduction
|
|
1540
|
+
* step has it handed back by its `StepRasters` (`regionPixels`, issue #1253)
|
|
1541
|
+
* rather than testing every art pixel against the polygon again.
|
|
1527
1542
|
*/
|
|
1528
|
-
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
|
|
1543
|
+
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters, steps: StepRasters | null): Built[] {
|
|
1529
1544
|
const { attachment, source, art } = input;
|
|
1530
1545
|
const who = `attachment ${nameOf(attachment)}`;
|
|
1531
1546
|
const scale = art.frame.pageScale;
|
|
@@ -1541,14 +1556,35 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1541
1556
|
}
|
|
1542
1557
|
edges.sort((p, q) => p.a - q.a || p.b - q.b);
|
|
1543
1558
|
|
|
1559
|
+
// Each edge's answers for a region: computed here, or — on a reduction step — carried by its `StepRasters` from
|
|
1560
|
+
// the last reading of the region for an edge with the same two ends (`regionEdges`, issue #1253).
|
|
1561
|
+
const readings = new Map<RefinementRegion, RegionEdgeReading | null>();
|
|
1562
|
+
const readingOf = (region: RefinementRegion): RegionEdgeReading | null => {
|
|
1563
|
+
if (!readings.has(region)) readings.set(region, steps === null ? null : steps.regionEdges(region.polygon, region.transition));
|
|
1564
|
+
return readings.get(region)!;
|
|
1565
|
+
};
|
|
1566
|
+
const meetsOf = (region: RefinementRegion, e: Edge): boolean => {
|
|
1567
|
+
const record = readingOf(region)?.entry(points[e.a], points[e.b]);
|
|
1568
|
+
if (record === undefined) return segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
|
|
1569
|
+
if (record.meets === undefined) record.meets = segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
|
|
1570
|
+
return record.meets;
|
|
1571
|
+
};
|
|
1572
|
+
const heldOf = (region: RefinementRegion, e: Edge): number | null => {
|
|
1573
|
+
const held = (): number | null => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null);
|
|
1574
|
+
const record = readingOf(region)?.entry(points[e.a], points[e.b]);
|
|
1575
|
+
if (record === undefined) return held();
|
|
1576
|
+
if (record.held === undefined) record.held = held();
|
|
1577
|
+
return record.held;
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1544
1580
|
// Which regions are measurable, and the one refusal of each that is not.
|
|
1545
1581
|
const live: RefinementRegion[] = [];
|
|
1546
1582
|
const refusals = new Map<string, string>();
|
|
1547
1583
|
for (const region of input.targets.regions) {
|
|
1548
|
-
let refusal = regionRefusal(region, scale, who);
|
|
1584
|
+
let refusal = regionRefusal(region, scale, who, steps);
|
|
1549
1585
|
if (refusal === null) {
|
|
1550
1586
|
const poly: Pt[] = region.polygon;
|
|
1551
|
-
const meets = edges.some((e) =>
|
|
1587
|
+
const meets = edges.some((e) => meetsOf(region, e)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
|
|
1552
1588
|
if (!meets) {
|
|
1553
1589
|
refusal = {
|
|
1554
1590
|
code: 'REGION_OUTSIDE_ART',
|
|
@@ -1569,7 +1605,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1569
1605
|
for (const region of live) {
|
|
1570
1606
|
holds.set(
|
|
1571
1607
|
region,
|
|
1572
|
-
edges.map((e) => (
|
|
1608
|
+
edges.map((e) => heldOf(region, e)),
|
|
1573
1609
|
);
|
|
1574
1610
|
}
|
|
1575
1611
|
const edgeIndex = new Map(edges.map((e, i) => [e, i]));
|
|
@@ -1632,23 +1668,42 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
1632
1668
|
}
|
|
1633
1669
|
}
|
|
1634
1670
|
|
|
1635
|
-
// h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex.
|
|
1671
|
+
// h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex. The art
|
|
1672
|
+
// pixels inside the region come in ascending order, so the scan below visits what a scan of every art pixel
|
|
1673
|
+
// would keep, in the same order.
|
|
1674
|
+
const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
|
|
1675
|
+
const inRegion = (): Int32Array => {
|
|
1676
|
+
const kept: number[] = [];
|
|
1677
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && inClosedPolygon(centreOf(i), poly)) kept.push(i);
|
|
1678
|
+
return Int32Array.from(kept);
|
|
1679
|
+
};
|
|
1680
|
+
const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
|
|
1636
1681
|
let count = 0;
|
|
1637
1682
|
let worst = -1;
|
|
1638
1683
|
let worstAt = -1;
|
|
1639
|
-
|
|
1640
|
-
if (!artBits[i]) continue;
|
|
1641
|
-
const x = i % mask.width;
|
|
1642
|
-
const y = Math.floor(i / mask.width);
|
|
1643
|
-
const centre: Pt = [(x + 0.5) / scale, (y + 0.5) / scale];
|
|
1644
|
-
if (!inClosedPolygon(centre, poly) || !inClosedPolygon(centre, hullPolygon)) continue;
|
|
1684
|
+
const visit = (i: number, nearest: number): void => {
|
|
1645
1685
|
count++;
|
|
1646
|
-
let nearest = Infinity;
|
|
1647
|
-
for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
|
|
1648
1686
|
if (nearest > worst) {
|
|
1649
1687
|
worst = nearest;
|
|
1650
1688
|
worstAt = i;
|
|
1651
1689
|
}
|
|
1690
|
+
};
|
|
1691
|
+
const distance = (i: number, p: Pt): number => {
|
|
1692
|
+
const centre = centreOf(i);
|
|
1693
|
+
return Math.hypot(p[0] - centre[0], p[1] - centre[1]);
|
|
1694
|
+
};
|
|
1695
|
+
if (steps === null) {
|
|
1696
|
+
for (const i of regionPixels) {
|
|
1697
|
+
const centre = centreOf(i);
|
|
1698
|
+
if (!inClosedPolygon(centre, hullPolygon)) continue;
|
|
1699
|
+
let nearest = Infinity;
|
|
1700
|
+
for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
|
|
1701
|
+
visit(i, nearest);
|
|
1702
|
+
}
|
|
1703
|
+
} else {
|
|
1704
|
+
// A reduction step: the pixels in the hull and their nearest distances carried from the last reading (issue #1253).
|
|
1705
|
+
const carried = steps.regionFill(poly, { pixels: regionPixels, centre: centreOf, hull: hullPolygon, points, inHull: (i) => inClosedPolygon(centreOf(i), hullPolygon), distance });
|
|
1706
|
+
for (let j = 0; j < carried.inside.length; j++) visit(carried.inside[j], carried.nearest[j]);
|
|
1652
1707
|
}
|
|
1653
1708
|
const fillSpec = spec('MQ_FILL_DISTANCE', 'px', region.name);
|
|
1654
1709
|
const floor = floorOf(region.name);
|
package/src/meshrasters.ts
CHANGED
|
@@ -215,15 +215,38 @@ export interface StepRasterTally {
|
|
|
215
215
|
/** Edges a carried list took from the last list of its point, and edges it computed because the edge is new. */
|
|
216
216
|
edgesCarried: number;
|
|
217
217
|
edgesComputed: number;
|
|
218
|
+
/** Region pixel sets (`regionPixels`) computed, and handed back because the polygon equals one already read. */
|
|
219
|
+
regionSetsComputed: number;
|
|
220
|
+
regionSetsReused: number;
|
|
221
|
+
/** Region polygons checked for a self-intersection (`polygonCrossing`), and checks handed back for an equal polygon. */
|
|
222
|
+
polygonChecksComputed: number;
|
|
223
|
+
polygonChecksReused: number;
|
|
224
|
+
/** Region edge readings (`regionEdges`): an edge's answers carried from the last reading of its region, or computed. */
|
|
225
|
+
regionEdgesCarried: number;
|
|
226
|
+
regionEdgesComputed: number;
|
|
227
|
+
/** Fill readings (`regionFill`): the region pixels in the hull handed back for an equal hull, carried by the hull edges that changed, or measured from nothing; and pixels measured again inside a changed hull's box. */
|
|
228
|
+
fillHullsReused: number;
|
|
229
|
+
fillHullsCarried: number;
|
|
230
|
+
fillHullsMeasured: number;
|
|
231
|
+
fillHullPixelsMeasured: number;
|
|
232
|
+
/** Fill readings whose nearest-vertex distances were built from nothing, and pixels carried or measured again in the others. */
|
|
233
|
+
fillNearestRebuilt: number;
|
|
234
|
+
fillNearestCarried: number;
|
|
235
|
+
fillNearestMeasured: number;
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
/**
|
|
221
239
|
* A fault planted on purpose in the carried path, for the mesh-quality
|
|
222
240
|
* controls to show they catch it: one added triangle not drawn, one changed
|
|
223
|
-
* column or row of the distance transform not redone,
|
|
224
|
-
* reused when only the vertex counts match
|
|
241
|
+
* column or row of the distance transform not redone, an outline reading
|
|
242
|
+
* reused when only the vertex counts match, or a region's art pixels handed
|
|
243
|
+
* back, or a region polygon's self-intersection check handed back, for a
|
|
244
|
+
* polygon that only has as many vertices as one read before, or a region
|
|
245
|
+
* edge's answers carried from an edge that shares only its first end,
|
|
246
|
+
* the region pixels near a changed hull edge not measured again, or a pixel's
|
|
247
|
+
* nearest vertex kept after that vertex was removed. `reduceMesh` plants none.
|
|
225
248
|
*/
|
|
226
|
-
export type StepRasterPlant = 'skip-a-triangle' | 'skip-a-column' | 'skip-a-row' | 'stale-outline' | 'skip-an-edge';
|
|
249
|
+
export type StepRasterPlant = 'skip-a-triangle' | 'skip-a-column' | 'skip-a-row' | 'stale-outline' | 'skip-an-edge' | 'stale-region' | 'stale-polygon-check' | 'stale-region-edge' | 'stale-fill-hull' | 'stale-fill-nearest';
|
|
227
250
|
|
|
228
251
|
/**
|
|
229
252
|
* One outline slot's carried distances (`src/meshquality.ts`, `hausdorff`): a
|
|
@@ -283,6 +306,38 @@ export interface OutlineMemo {
|
|
|
283
306
|
* with a zero's sign, to the last ones of that slot — the same pure function
|
|
284
307
|
* of the same input.
|
|
285
308
|
*
|
|
309
|
+
* - **A region's art pixels** (`regionPixels`, issue #1253) — the art pixels
|
|
310
|
+
* whose centre lies in a region's closed polygon, which `MQ_FILL_DISTANCE`
|
|
311
|
+
* reads — are a function of the art and the polygon alone, so a set is
|
|
312
|
+
* handed back whenever the polygon equals, coordinate by coordinate with a
|
|
313
|
+
* zero's sign, one already read through this object. A region polygon's
|
|
314
|
+
* self-intersection check (`polygonCrossing`), a function of the polygon
|
|
315
|
+
* alone, is handed back the same way.
|
|
316
|
+
* - **A region's edge answers** (`regionEdges`, issue #1253) — whether an
|
|
317
|
+
* edge meets the closed polygon, and whether the region holds it and how far
|
|
318
|
+
* it lies — are functions of the edge's two ends and the region's polygon and
|
|
319
|
+
* band alone, so each is carried from the last reading of that region for an
|
|
320
|
+
* edge with both ends equal, coordinate by coordinate, in the same order.
|
|
321
|
+
* - **A region's fill distances** (`regionFill`, issue #1253) — which of its
|
|
322
|
+
* art pixels lie in the hull, and each one's distance to the nearest vertex.
|
|
323
|
+
* Whether a pixel lies in the hull is measured again only inside the box of
|
|
324
|
+
* the hull edges that changed (directed, both ends exact), widened by
|
|
325
|
+
* `HULL_CARRY_MARGIN`: outside it the edges the two hulls share answer the
|
|
326
|
+
* same, a changed edge is further than the boundary tolerance, and the
|
|
327
|
+
* changed edges cross a horizontal ray from the pixel an even number of
|
|
328
|
+
* times between them — none when the pixel is right of, above or below the
|
|
329
|
+
* box, and when it is left of it, every changed edge that spans its row,
|
|
330
|
+
* whose count is even because the removed and added edges meet each vertex
|
|
331
|
+
* an even number of times — so the crossing parity is the last one's.
|
|
332
|
+
* A pixel's nearest distance is carried by the vertices that differ
|
|
333
|
+
* from the last reading's, compared as a multiset of exact coordinates: when
|
|
334
|
+
* the vertex that attained it is still there, the minimum over the vertices
|
|
335
|
+
* kept is the last minimum, and the new one is the smaller of it and the
|
|
336
|
+
* distances to the vertices added; when that vertex is gone, the pixel is
|
|
337
|
+
* measured again over every vertex. A minimum is exact, so either way it is
|
|
338
|
+
* the same number. A reading whose vertices differ from the last in more than
|
|
339
|
+
* they share is built from nothing.
|
|
340
|
+
*
|
|
286
341
|
* A reading whose triangles differ from the last in more triangles than they
|
|
287
342
|
* share is built from nothing, as the first one is; which way a reading is
|
|
288
343
|
* built changes its cost and never its values.
|
|
@@ -300,6 +355,64 @@ export interface StepRasters {
|
|
|
300
355
|
* distances, which `measure` reads and keeps current.
|
|
301
356
|
*/
|
|
302
357
|
outline<T>(slot: string, outline: ReadonlyArray<readonly [number, number]>, against: ReadonlyArray<readonly [number, number]>, measure: (memo: OutlineMemo) => T): T;
|
|
358
|
+
/**
|
|
359
|
+
* `compute()`'s value — the art pixels, ascending, whose centre lies in the
|
|
360
|
+
* closed `polygon` — or the set already computed for a polygon equal to it
|
|
361
|
+
* coordinate by coordinate. The art is this object's, so the polygon is the
|
|
362
|
+
* whole of the key.
|
|
363
|
+
*/
|
|
364
|
+
regionPixels(polygon: ReadonlyArray<readonly [number, number]>, compute: () => Int32Array): Int32Array;
|
|
365
|
+
/** `compute()`'s value — `findSelfIntersection` of `polygon` — or the one already computed for a polygon equal to it coordinate by coordinate. */
|
|
366
|
+
polygonCrossing(polygon: ReadonlyArray<readonly [number, number]>, compute: () => [number, number] | null): [number, number] | null;
|
|
367
|
+
/**
|
|
368
|
+
* One reading's answers per edge for the region with this polygon and band:
|
|
369
|
+
* `entry(a, b)` is the edge's record, carried from the last reading of the
|
|
370
|
+
* region when an edge with the same two ends (exact coordinates, this order)
|
|
371
|
+
* was read there, else empty for the caller to fill. Records not read again
|
|
372
|
+
* are dropped at the next reading.
|
|
373
|
+
*/
|
|
374
|
+
regionEdges(polygon: ReadonlyArray<readonly [number, number]>, transition: number): RegionEdgeReading;
|
|
375
|
+
/** The region's fill reading, carried from the last one of the region with this polygon; valid until the next. */
|
|
376
|
+
regionFill(polygon: ReadonlyArray<readonly [number, number]>, question: RegionFillQuestion): RegionFillReading;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A region edge's answers, as `src/meshquality.ts`'s `regionRows` reads them; a field absent is not yet computed. */
|
|
380
|
+
export interface RegionEdgeRecord {
|
|
381
|
+
/** `segmentMeetsPolygon` of the edge against the closed polygon. */
|
|
382
|
+
meets?: boolean;
|
|
383
|
+
/** The edge's distance from the closed polygon when the region holds it (`edgeIsHeldByRegion`), else null. */
|
|
384
|
+
held?: number | null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface RegionEdgeReading {
|
|
388
|
+
entry(a: readonly [number, number], b: readonly [number, number]): RegionEdgeRecord;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* How far beyond the box of a hull's changed edges a pixel centre must lie for
|
|
393
|
+
* its answer to be carried, in the hull's units. Far larger than any rounding
|
|
394
|
+
* of a crossing's abscissa and than the boundary tolerance the test reads.
|
|
395
|
+
*/
|
|
396
|
+
const HULL_CARRY_MARGIN = 1;
|
|
397
|
+
|
|
398
|
+
/** What `regionFill` is handed: the definitions it reads, which stay `src/meshquality.ts`'s. */
|
|
399
|
+
export interface RegionFillQuestion {
|
|
400
|
+
/** The region's art pixels, ascending (`regionPixels`). */
|
|
401
|
+
pixels: Int32Array;
|
|
402
|
+
/** The centre of art pixel `i`, in the hull's units. */
|
|
403
|
+
centre(i: number): readonly [number, number];
|
|
404
|
+
hull: ReadonlyArray<readonly [number, number]>;
|
|
405
|
+
points: ReadonlyArray<readonly [number, number]>;
|
|
406
|
+
/** Does the centre of art pixel `i` lie in the closed hull? */
|
|
407
|
+
inHull(i: number): boolean;
|
|
408
|
+
/** The distance from the centre of art pixel `i` to `p`. */
|
|
409
|
+
distance(i: number, p: readonly [number, number]): number;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The region pixels in the hull, ascending, and each one's distance to its nearest vertex. */
|
|
413
|
+
export interface RegionFillReading {
|
|
414
|
+
inside: Int32Array;
|
|
415
|
+
nearest: Float64Array;
|
|
303
416
|
}
|
|
304
417
|
|
|
305
418
|
/** A coordinate as a key: its shortest round-trip decimal, with a negative zero told from a positive one. */
|
|
@@ -325,6 +438,19 @@ function flat(p: ReadonlyArray<readonly [number, number]>): Float64Array {
|
|
|
325
438
|
|
|
326
439
|
type Corners = [readonly [number, number], readonly [number, number], readonly [number, number]];
|
|
327
440
|
|
|
441
|
+
/** One region's carried fill state: the pixels it was read for, the hull and the positions inside it, and per pixel the nearest vertex. */
|
|
442
|
+
interface FillSlot {
|
|
443
|
+
polygon: Float64Array;
|
|
444
|
+
pixels: Int32Array;
|
|
445
|
+
/** The last hull's directed edges by both ends' exact coordinates, with their counts, and per pixel whether it lay in that hull. */
|
|
446
|
+
hullEdges: Map<string, { a: readonly [number, number]; b: readonly [number, number]; n: number }> | null;
|
|
447
|
+
inHull: Uint8Array;
|
|
448
|
+
points: Map<string, { p: readonly [number, number]; n: number }> | null;
|
|
449
|
+
nearest: Float64Array;
|
|
450
|
+
nearX: Float64Array;
|
|
451
|
+
nearY: Float64Array;
|
|
452
|
+
}
|
|
453
|
+
|
|
328
454
|
interface HeldTriangles {
|
|
329
455
|
corners: Corners;
|
|
330
456
|
n: number;
|
|
@@ -367,11 +493,28 @@ export function stepRastersOf(rasters: ArtRasters, plant: StepRasterPlant | null
|
|
|
367
493
|
distanceListsCarried: 0,
|
|
368
494
|
edgesCarried: 0,
|
|
369
495
|
edgesComputed: 0,
|
|
496
|
+
regionSetsComputed: 0,
|
|
497
|
+
regionSetsReused: 0,
|
|
498
|
+
polygonChecksComputed: 0,
|
|
499
|
+
polygonChecksReused: 0,
|
|
500
|
+
regionEdgesCarried: 0,
|
|
501
|
+
regionEdgesComputed: 0,
|
|
502
|
+
fillHullsReused: 0,
|
|
503
|
+
fillHullsCarried: 0,
|
|
504
|
+
fillHullsMeasured: 0,
|
|
505
|
+
fillHullPixelsMeasured: 0,
|
|
506
|
+
fillNearestRebuilt: 0,
|
|
507
|
+
fillNearestCarried: 0,
|
|
508
|
+
fillNearestMeasured: 0,
|
|
370
509
|
};
|
|
371
510
|
const { width: w, height: h } = rasters.art.mask;
|
|
372
511
|
let state: CoverageState | null = null;
|
|
373
512
|
const outlines = new Map<string, { outline: Float64Array; against: Float64Array; value: unknown }>();
|
|
374
513
|
const memos = new Map<string, OutlineMemo>();
|
|
514
|
+
const regionSets: Array<{ polygon: Float64Array; pixels: Int32Array }> = [];
|
|
515
|
+
const polygonChecks: Array<{ polygon: Float64Array; crossing: [number, number] | null }> = [];
|
|
516
|
+
const regionEdgeSlots: Array<{ polygon: Float64Array; transition: number; last: Map<string, RegionEdgeRecord> }> = [];
|
|
517
|
+
const fillSlots: FillSlot[] = [];
|
|
375
518
|
|
|
376
519
|
/** Add `sign` to the count of every centre the triangle covers, pushing each such centre to `touched`. */
|
|
377
520
|
const draw = (count: Int32Array, corners: Corners, sign: number, touched: number[] | null): void => {
|
|
@@ -617,5 +760,185 @@ export function stepRastersOf(rasters: ArtRasters, plant: StepRasterPlant | null
|
|
|
617
760
|
outlines.set(slot, { outline: flat(outline), against: flat(against), value });
|
|
618
761
|
return value;
|
|
619
762
|
},
|
|
763
|
+
regionPixels: (polygon, compute) => {
|
|
764
|
+
const kept = regionSets.find((r) => (plant === 'stale-region' ? r.polygon.length === polygon.length * 2 : samePolygon(polygon, r.polygon)));
|
|
765
|
+
if (kept !== undefined) {
|
|
766
|
+
tally.regionSetsReused++;
|
|
767
|
+
return kept.pixels;
|
|
768
|
+
}
|
|
769
|
+
tally.regionSetsComputed++;
|
|
770
|
+
const pixels = compute();
|
|
771
|
+
regionSets.push({ polygon: flat(polygon), pixels });
|
|
772
|
+
return pixels;
|
|
773
|
+
},
|
|
774
|
+
polygonCrossing: (polygon, compute) => {
|
|
775
|
+
const kept = polygonChecks.find((r) => (plant === 'stale-polygon-check' ? r.polygon.length === polygon.length * 2 : samePolygon(polygon, r.polygon)));
|
|
776
|
+
if (kept !== undefined) {
|
|
777
|
+
tally.polygonChecksReused++;
|
|
778
|
+
return kept.crossing;
|
|
779
|
+
}
|
|
780
|
+
tally.polygonChecksComputed++;
|
|
781
|
+
const crossing = compute();
|
|
782
|
+
polygonChecks.push({ polygon: flat(polygon), crossing });
|
|
783
|
+
return crossing;
|
|
784
|
+
},
|
|
785
|
+
regionFill: (polygon, q) => {
|
|
786
|
+
let slot = fillSlots.find((r) => samePolygon(polygon, r.polygon));
|
|
787
|
+
if (slot === undefined) {
|
|
788
|
+
slot = { polygon: flat(polygon), pixels: q.pixels, hullEdges: null, inHull: new Uint8Array(0), points: null, nearest: new Float64Array(0), nearX: new Float64Array(0), nearY: new Float64Array(0) };
|
|
789
|
+
fillSlots.push(slot);
|
|
790
|
+
}
|
|
791
|
+
const n = q.pixels.length;
|
|
792
|
+
// The vertices, as a multiset of exact coordinates, against the last reading's.
|
|
793
|
+
const keyOf = (p: readonly [number, number]): string => `${coordKey(p[0])},${coordKey(p[1])}`;
|
|
794
|
+
const points = new Map<string, { p: readonly [number, number]; n: number }>();
|
|
795
|
+
for (const p of q.points) {
|
|
796
|
+
const k = keyOf(p);
|
|
797
|
+
const e = points.get(k);
|
|
798
|
+
if (e === undefined) points.set(k, { p, n: 1 });
|
|
799
|
+
else e.n++;
|
|
800
|
+
}
|
|
801
|
+
const measure = (k: number): void => {
|
|
802
|
+
let best = Infinity;
|
|
803
|
+
let bx = NaN;
|
|
804
|
+
let by = NaN;
|
|
805
|
+
for (const p of q.points) {
|
|
806
|
+
const d = q.distance(q.pixels[k], p);
|
|
807
|
+
if (d < best) {
|
|
808
|
+
best = d;
|
|
809
|
+
bx = p[0];
|
|
810
|
+
by = p[1];
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
slot!.nearest[k] = best;
|
|
814
|
+
slot!.nearX[k] = bx;
|
|
815
|
+
slot!.nearY[k] = by;
|
|
816
|
+
};
|
|
817
|
+
// A vertex whose coordinates are gone, and one whose coordinates are new; a count that only moved changes no minimum.
|
|
818
|
+
let gone: Set<string> | null = null;
|
|
819
|
+
const added: Array<readonly [number, number]> = [];
|
|
820
|
+
const samePixels = slot.points !== null && slot.pixels === q.pixels;
|
|
821
|
+
if (samePixels) {
|
|
822
|
+
gone = new Set();
|
|
823
|
+
let kept = 0;
|
|
824
|
+
let differ = 0;
|
|
825
|
+
for (const [k, { p, n: now }] of points) {
|
|
826
|
+
const was = slot.points!.get(k)?.n ?? 0;
|
|
827
|
+
kept += Math.min(was, now);
|
|
828
|
+
differ += Math.max(0, now - was);
|
|
829
|
+
if (was === 0) added.push(p);
|
|
830
|
+
}
|
|
831
|
+
for (const [k, { n: was }] of slot.points!) {
|
|
832
|
+
const now = points.get(k)?.n ?? 0;
|
|
833
|
+
differ += Math.max(0, was - now);
|
|
834
|
+
if (now === 0) gone.add(k);
|
|
835
|
+
}
|
|
836
|
+
if (differ > kept) gone = null;
|
|
837
|
+
}
|
|
838
|
+
if (gone === null) {
|
|
839
|
+
tally.fillNearestRebuilt++;
|
|
840
|
+
slot.pixels = q.pixels;
|
|
841
|
+
slot.nearest = new Float64Array(n);
|
|
842
|
+
slot.nearX = new Float64Array(n);
|
|
843
|
+
slot.nearY = new Float64Array(n);
|
|
844
|
+
for (let k = 0; k < n; k++) measure(k);
|
|
845
|
+
} else {
|
|
846
|
+
for (let k = 0; k < n; k++) {
|
|
847
|
+
if (plant !== 'stale-fill-nearest' && gone.has(`${coordKey(slot.nearX[k])},${coordKey(slot.nearY[k])}`)) {
|
|
848
|
+
tally.fillNearestMeasured++;
|
|
849
|
+
measure(k);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
tally.fillNearestCarried++;
|
|
853
|
+
for (const p of added) {
|
|
854
|
+
const d = q.distance(q.pixels[k], p);
|
|
855
|
+
if (d < slot.nearest[k]) {
|
|
856
|
+
slot.nearest[k] = d;
|
|
857
|
+
slot.nearX[k] = p[0];
|
|
858
|
+
slot.nearY[k] = p[1];
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
slot.points = points;
|
|
864
|
+
// The pixels in the hull: carried outside the box of the hull edges that changed, measured again inside it.
|
|
865
|
+
const hullEdges = new Map<string, { a: readonly [number, number]; b: readonly [number, number]; n: number }>();
|
|
866
|
+
for (let k = 0; k < q.hull.length; k++) {
|
|
867
|
+
const a = q.hull[k];
|
|
868
|
+
const b = q.hull[(k + 1) % q.hull.length];
|
|
869
|
+
const key = `${keyOf(a)}>${keyOf(b)}`;
|
|
870
|
+
const e = hullEdges.get(key);
|
|
871
|
+
if (e === undefined) hullEdges.set(key, { a, b, n: 1 });
|
|
872
|
+
else e.n++;
|
|
873
|
+
}
|
|
874
|
+
if (!samePixels || slot.hullEdges === null) {
|
|
875
|
+
tally.fillHullsMeasured++;
|
|
876
|
+
slot.inHull = new Uint8Array(n);
|
|
877
|
+
for (let k = 0; k < n; k++) slot.inHull[k] = q.inHull(q.pixels[k]) ? 1 : 0;
|
|
878
|
+
} else {
|
|
879
|
+
let minX = Infinity;
|
|
880
|
+
let minY = Infinity;
|
|
881
|
+
let maxX = -Infinity;
|
|
882
|
+
let maxY = -Infinity;
|
|
883
|
+
const widen = (p: readonly [number, number]): void => {
|
|
884
|
+
minX = Math.min(minX, p[0]);
|
|
885
|
+
maxX = Math.max(maxX, p[0]);
|
|
886
|
+
minY = Math.min(minY, p[1]);
|
|
887
|
+
maxY = Math.max(maxY, p[1]);
|
|
888
|
+
};
|
|
889
|
+
for (const [key, e] of hullEdges) if ((slot.hullEdges.get(key)?.n ?? 0) !== e.n) (widen(e.a), widen(e.b));
|
|
890
|
+
for (const [key, e] of slot.hullEdges) if ((hullEdges.get(key)?.n ?? 0) !== e.n) (widen(e.a), widen(e.b));
|
|
891
|
+
if (minX === Infinity) tally.fillHullsReused++;
|
|
892
|
+
else {
|
|
893
|
+
tally.fillHullsCarried++;
|
|
894
|
+
minX -= HULL_CARRY_MARGIN;
|
|
895
|
+
minY -= HULL_CARRY_MARGIN;
|
|
896
|
+
maxX += HULL_CARRY_MARGIN;
|
|
897
|
+
maxY += HULL_CARRY_MARGIN;
|
|
898
|
+
for (let k = 0; k < n; k++) {
|
|
899
|
+
const c = q.centre(q.pixels[k]);
|
|
900
|
+
if (c[0] < minX || c[0] > maxX || c[1] < minY || c[1] > maxY) continue;
|
|
901
|
+
if (plant === 'stale-fill-hull') continue;
|
|
902
|
+
tally.fillHullPixelsMeasured++;
|
|
903
|
+
slot.inHull[k] = q.inHull(q.pixels[k]) ? 1 : 0;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
slot.hullEdges = hullEdges;
|
|
908
|
+
const kept: number[] = [];
|
|
909
|
+
for (let k = 0; k < n; k++) if (slot.inHull[k]) kept.push(k);
|
|
910
|
+
const inside = new Int32Array(kept.length);
|
|
911
|
+
const nearest = new Float64Array(kept.length);
|
|
912
|
+
kept.forEach((k, j) => {
|
|
913
|
+
inside[j] = q.pixels[k];
|
|
914
|
+
nearest[j] = slot!.nearest[k];
|
|
915
|
+
});
|
|
916
|
+
return { inside, nearest };
|
|
917
|
+
},
|
|
918
|
+
regionEdges: (polygon, transition) => {
|
|
919
|
+
let slot = regionEdgeSlots.find((r) => Object.is(r.transition, transition) && samePolygon(polygon, r.polygon));
|
|
920
|
+
if (slot === undefined) {
|
|
921
|
+
slot = { polygon: flat(polygon), transition, last: new Map() };
|
|
922
|
+
regionEdgeSlots.push(slot);
|
|
923
|
+
}
|
|
924
|
+
const last = slot.last;
|
|
925
|
+
const now = new Map<string, RegionEdgeRecord>();
|
|
926
|
+
slot.last = now;
|
|
927
|
+
const keyOf = (p: readonly [number, number]): string => `${coordKey(p[0])},${coordKey(p[1])}`;
|
|
928
|
+
return {
|
|
929
|
+
entry: (a, b) => {
|
|
930
|
+
const key = plant === 'stale-region-edge' ? keyOf(a) : `${keyOf(a)};${keyOf(b)}`;
|
|
931
|
+
let record = now.get(key);
|
|
932
|
+
if (record !== undefined) return record;
|
|
933
|
+
record = last.get(key);
|
|
934
|
+
if (record === undefined) {
|
|
935
|
+
tally.regionEdgesComputed++;
|
|
936
|
+
record = {};
|
|
937
|
+
} else tally.regionEdgesCarried++;
|
|
938
|
+
now.set(key, record);
|
|
939
|
+
return record;
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
},
|
|
620
943
|
};
|
|
621
944
|
}
|
package/src/meshreduce.ts
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
* #1240), and as `measureMeshQualityStep` carried from the last step's
|
|
30
30
|
* measurement (`StepRasters`, issue #1246), each of which returns the same
|
|
31
31
|
* report — and taken only when every row the caller's
|
|
32
|
-
* contract requires is `pass`: the art fit at `targets.artFit
|
|
32
|
+
* contract requires is `pass`: the art fit at `targets.artFit` (an overshoot or
|
|
33
|
+
* undercut bound declared `null` is measured and never required), the boundary
|
|
33
34
|
* deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
|
|
34
35
|
* angle when declared, orientation and degeneracy at 0, and every region's
|
|
35
36
|
* `MQ_MAX_EDGE` and `MQ_TRANSITION`. Before the measurement, the structural
|
|
@@ -216,11 +217,27 @@ class Stop extends Error {
|
|
|
216
217
|
function checkFit(who: string, field: string, fit: unknown): void {
|
|
217
218
|
if (fit === undefined || fit === null || !isObject(fit)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is ${JSON.stringify(fit)}; required { minCoverage, maxOvershoot, maxUndercut } — no field has a default`);
|
|
218
219
|
if (!isFiniteNumber(fit.minCoverage) || fit.minCoverage < 0 || fit.minCoverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.minCoverage is ${JSON.stringify(fit.minCoverage)}; required a fraction in 0..1`);
|
|
220
|
+
// `null` is a bound declared absent (issue #1254): measured, reported `undeclared`, never gating. A field left
|
|
221
|
+
// out is not the same thing and stays refused — `JSON.stringify` names it `undefined` in the message.
|
|
219
222
|
for (const k of ['maxOvershoot', 'maxUndercut'] as const) {
|
|
220
|
-
|
|
223
|
+
const v = fit[k];
|
|
224
|
+
if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Whether `fit` declares the bound of an art row absent (issue #1254): the
|
|
230
|
+
* 8-connected `MQ_OVERSHOOT` under `maxOvershoot: null`, `MQ_UNDERCUT` under
|
|
231
|
+
* `maxUndercut: null`. Such a row is `undeclared` when measured and is never
|
|
232
|
+
* required — not for admission, not for a step, not when it could not be
|
|
233
|
+
* measured. Every other row answers false.
|
|
234
|
+
*/
|
|
235
|
+
function artBoundAbsent(fit: ArtFitBounds, row: MeasureRow): boolean {
|
|
236
|
+
if (row.code === 'MQ_OVERSHOOT') return fit.maxOvershoot === null;
|
|
237
|
+
if (row.code === 'MQ_UNDERCUT') return fit.maxUndercut === null;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
224
241
|
/**
|
|
225
242
|
* The fields a reduction reads and a measurement does not, refused by name. The
|
|
226
243
|
* art, the mesh's arrays, the regions' shapes and the sample floors are refused
|
|
@@ -755,12 +772,14 @@ function rowName(row: MeasureRow): string {
|
|
|
755
772
|
|
|
756
773
|
/**
|
|
757
774
|
* The first required row that is not `pass`, in `BLOCKING_ORDER`, or null when
|
|
758
|
-
* every one passes. A row is required exactly when its bound is declared
|
|
775
|
+
* every one passes. A row is required exactly when its bound is declared — an
|
|
776
|
+
* art bound declared `null` is not (`artBoundAbsent`, issue #1254); the
|
|
759
777
|
* overshoot row gated is the 8-connected one (P12).
|
|
760
778
|
*/
|
|
761
|
-
function firstBlockingRow(report: MeshQualityReport): MeasureRow | null {
|
|
779
|
+
function firstBlockingRow(report: MeshQualityReport, artFit: ArtFitBounds): MeasureRow | null {
|
|
762
780
|
const rows = report.candidates[0]?.geometry?.rows ?? [];
|
|
763
781
|
const required = (r: MeasureRow): boolean => {
|
|
782
|
+
if (artBoundAbsent(artFit, r)) return false;
|
|
764
783
|
if (r.code === 'MQ_OVERSHOOT') return r.art?.connectivity === 8;
|
|
765
784
|
if (r.code === 'MQ_MIN_ANGLE') return r.bound !== null;
|
|
766
785
|
if (r.code === 'MQ_HOLES' || r.code === 'MQ_ISLANDS' || r.code === 'MQ_TRACE_DEVIATION' || r.code === 'MQ_FILL_DISTANCE') return false;
|
|
@@ -1142,7 +1161,7 @@ function tryRemoval(run: Run, v: number): string | null {
|
|
|
1142
1161
|
undo();
|
|
1143
1162
|
return `outline: ${err.message}`;
|
|
1144
1163
|
}
|
|
1145
|
-
const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'));
|
|
1164
|
+
const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'), run.input.targets.artFit);
|
|
1146
1165
|
if (blocking !== null) {
|
|
1147
1166
|
undo();
|
|
1148
1167
|
return rowName(blocking);
|
|
@@ -1214,24 +1233,23 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
|
|
|
1214
1233
|
const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
|
|
1215
1234
|
|
|
1216
1235
|
// The source, measured against its own admissibility bounds (correction 3) — this is also what refuses a
|
|
1217
|
-
// malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
);
|
|
1236
|
+
// malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes. Through the step
|
|
1237
|
+
// rasters when the call carries them, so what a region reads of the art alone is computed once (issue #1253).
|
|
1238
|
+
const admitInput: MeshMeasureInput = {
|
|
1239
|
+
id: 'source',
|
|
1240
|
+
attachment: input.attachment,
|
|
1241
|
+
art: input.art,
|
|
1242
|
+
source: src,
|
|
1243
|
+
targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
|
|
1244
|
+
referenceHull: null,
|
|
1245
|
+
minArtSamples: input.minArtSamples,
|
|
1246
|
+
regionArtSamples: input.regionArtSamples,
|
|
1247
|
+
protect: input.protect,
|
|
1248
|
+
influences: input.influences,
|
|
1249
|
+
boneOrder: input.boneOrder,
|
|
1250
|
+
preset: input.preset,
|
|
1251
|
+
};
|
|
1252
|
+
const admit = steps === null ? measureMeshQualityWith(admitInput, rasters) : measureMeshQualityStep(admitInput, steps);
|
|
1235
1253
|
const effective = effectiveOf(input, admit.effective, sourceHull);
|
|
1236
1254
|
const sourceCounts = admit.sourceCounts;
|
|
1237
1255
|
const noMesh = (termination: Termination): MeshReductionResult => ({
|
|
@@ -1251,7 +1269,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
|
|
|
1251
1269
|
}
|
|
1252
1270
|
for (const code of ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT']) {
|
|
1253
1271
|
const row = admitRows.find((r) => r.code === code && (code !== 'MQ_OVERSHOOT' || r.art?.connectivity === 8));
|
|
1254
|
-
if (row !== undefined && row.state !== 'pass') {
|
|
1272
|
+
if (row !== undefined && row.state !== 'pass' && !artBoundAbsent(input.sourceBounds, row)) {
|
|
1255
1273
|
const found = row.state === 'fail' ? `${row.value} against ${row.bound!.op} ${row.bound!.value}` : `${row.state} (${row.reason ?? ''})`;
|
|
1256
1274
|
throw new Stop(
|
|
1257
1275
|
'invalid-input',
|
|
@@ -1295,7 +1313,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
|
|
|
1295
1313
|
const refined = refineRegions(run);
|
|
1296
1314
|
if (refined.kind === 'budget') return noMesh({ reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'none-met-the-targets' });
|
|
1297
1315
|
const startCanon = canonicalise(work, run.sourceTurn, boneRank);
|
|
1298
|
-
const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'));
|
|
1316
|
+
const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'), input.targets.artFit);
|
|
1299
1317
|
let termination: Termination;
|
|
1300
1318
|
if (startBlock !== null) {
|
|
1301
1319
|
const constraint = refined.kind === 'stuck' ? refined.constraint : rowName(startBlock);
|